### What?
**Behavior prior to this change:**
When restoring a version, `beforeValidate` field processing backfilled
`undefined` fields from the current document data into the
to-be-restored document data. This behavior is correct for partial
updates (preserving unmodified fields), but incorrect for restore
operations, where the version snapshot is the complete desired state.
This is the current behavior on `main`: Note how the `Radio` field is
not set in the first version, but set to `Test en` after the restore of
said first version.
https://github.com/user-attachments/assets/56467956-7caa-4363-989e-bcd1a663ce7a
**Behavior with this change:**
The desired fallback behavior during partial updates is retained, but
the to-be-restored document no longer runs this fallback behavior. The
restored document equals the document that the user triggered the
restore for.
Note how now the `Radio` field is not set again after the restore, just
like it was when the first version was created:
https://github.com/user-attachments/assets/0b5833b5-3694-4b10-9a11-f25f5c1a3b24
### Why?
When a user restores an old document version, the user's expectation is
that the old document is what they are going to get after successful
restore. It is unexpected if the document is a "frankenstein" version of
the old document and the current document, especially since the user has
no way to see the resulting document before the restore and can't know
which attributes might be set from the current document as fallback
value.
### How?
`getFallbackValue` is skipped during restore operations denoted with the
new request context `isRestoringVersion = true`.
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1214068985579207
---------
Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
Adds `FieldPosition = 'main' | 'sidebar'` and uses it in `FieldAdmin`
and `AdminClient,` replacing the previous `'sidebar'`-only type.
This is especially useful when overriding a plugin field's default
`position: 'sidebar'` back to `'main'`, which already worked at runtime
but failed type-checking.
Also updates the docs to list both options and exports `FieldPosition`
from the package index.
---------
Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
### What?
Virtual fields (`virtual: true` or `virtual: "path"`) now default to
`admin.readOnly: true` during field sanitization.
### Why?
Virtual fields are populated server-side and not persisted from user
input. Without `readOnly`, users can type into them but edits are
silently discarded on save — confusing UX with no indication the field
is virtual.
### How?
In `sanitize.ts`, after initializing `field.admin`, set `readOnly: true`
for virtual fields unless the user has explicitly set `readOnly: false`.
Fixes#16013
Related #16012
Extracts per-field sanitization logic into a standalone `sanitizeField`
function that can be called independently. The `sanitizeFields` function
now delegates to `sanitizeField` in a loop, maintaining identical
behavior while exposing the core logic.
This enables sanitizing individual fields after initial config
sanitization, which is needed for the orderable optimization in the
follow-up PR.
Exports `sanitizeField` and `SanitizeFieldArgs` from the payload
package.
### What?
Virtual fields (`virtual: true` or `virtual: "path"`) no longer get a
default type-based validator during field sanitization. They receive a
noop validator instead.
### Why?
Virtual fields are populated server-side (via hooks, built-in
population, or plugins), so their value is `undefined` in form state.
The default validator causes false validation errors in the admin UI
that users cannot resolve.
### How?
In `sanitize.ts`, when assigning a default validator, check if the field
is virtual first. If so, assign `() => true` instead of the type-based
validator. Explicit `validate` functions are still preserved.
Fixes#16012
Related #16013
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1213743778134481
# Overview
When a relationship points to a collection with `trash: true`,
soft-deleted documents were showing as raw IDs in responses instead of
being omitted.
## Key Changes
In the dataloader, trash-enabled collections now fetch with `trash:
true` so soft-deleted documents are returned alongside live ones.
`deletedAt` is merged into the select to ensure it's always present in
the result.
In `populate()`, the returned doc is checked for `deletedAt`. If set,
the doc is trashed and `relationshipValue` is set to null. For hasMany,
null entries are filtered from the array after all row promises settle.
For single relationships, the field is set to null.
This correctly distinguishes trashed documents from inaccessible ones —
access-denied or permanently deleted documents still fall back to the
raw ID, unchanged from existing behavior. `depth: 0` is also unaffected.
Fixes#16022
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1213860470196517
Fixes https://github.com/payloadcms/payload/issues/14938
This PR adds a new property `disableUnique` which is useful when you
want to add a compound unique index to the collection `indexes` config
instead, for example with the multi-tenant plugin where the slug is only
unique per tenant, not globally.
## Summary
Fixes#15828
When using `locale: 'all'` with a required localized text field, the
`text` validator incorrectly rejects the value. This happens because:
1. With `locale: 'all'`, the value passed to the validator is an object
like `{en: "Hello", es: "World"}` (locale unflattening via
`mergeLocaleActions` happens *after* validation in `promise.ts`)
2. The old condition `!(typeof value === 'string' ||
Array.isArray(value))` evaluates to `true` for objects, triggering the
required error
3. The `textarea` validator already handles this correctly by using
`!value` (objects are truthy)
### Fix
Changed the text validator's required check from:
```ts
if (!(typeof value === 'string' || Array.isArray(value)) || value?.length === 0)
```
to:
```ts
if (!value || ((typeof value === 'string' || Array.isArray(value)) && value.length === 0))
```
This aligns with the textarea validator's approach:
- `!value` catches `null`, `undefined`, and empty string `""`
- The second clause catches empty arrays `[]` (truthy but length 0)
- Localized objects like `{en: "Hello"}` pass because `!obj` is `false`
### Changes
- **`packages/payload/src/fields/validations.ts`** — 1-line fix to the
required check condition
- **`packages/payload/src/fields/validations.spec.ts`** — 3 unit tests
(localized object accepted, null rejected, empty string rejected)
- **`test/fields/collections/Text/index.ts`** — Added
`localizedRequiredText` test field
- **`test/fields/int.spec.ts`** — Integration test verifying `locale:
'all'` with required localized text
## Test plan
- [x] Unit tests: 88/88 passed (85 existing + 3 new)
- [x] Integration tests (SQLite): 154/154 passed
- [ ] CI passes on all database adapters
This PR adds a new images property to BlockConfig, allowing developers
to specify separate images for different UI contexts:
- **images.icon** — small icon displayed in Lexical editor slash menus
and toolbars (20x20px)
- **images.thumbnail** — larger thumbnail shown in the block selection
drawer (3:2 aspect ratio)
## Why
Currently, `imageURL` serves dual purposes:
1. Block selection drawer thumbnails (3:2 aspect ratio, e.g., 480x320px)
2. Lexical editor icons (scaled down to 20x20px)
Citing documentation:
> Display Contexts:
> Block Selection Drawer: Images appear as thumbnails in a responsive
grid when editors add blocks
> Lexical Editor: Images are scaled down to 20x20px icons in menus and
toolbars
This creates problem , where developer needs to choose either good
looking icons (but worse UX on block drawers) or good looking thumnails,
but scaled down images as icons.
## Solution
Add a dedicated `images` property with a fallback chain:
```
Lexical editor icon: images.icon → images.thumbnail → imageURL (deprecated) → default BlockIcon
Block drawer thumbnail: images.thumbnail → imageURL (deprecated) → default placeholder
```
Now you can set different URL for block icons in lexical blocks:
<img width="562" height="370" alt="image"
src="https://github.com/user-attachments/assets/912e1482-0b37-40f7-a144-e21e43fc7aec"
/>
Example:
```ts
const QuoteBlock: Block = {
slug: 'quote',
images: {
icon: 'https://example.com/icons/quote-20x20.svg',
thumbnail: { url: 'https://example.com/thumbnails/quote-480x320.jpg', alt: 'Quote block' },
},
fields: [...],
}
```
## Backwards Compatibility
**Fully backwards compatible** — imageURL and imageAltText still work (marked as @deprecated), with automatic fallback to the new images property behavior.
## What
Fixes `previousValue` being `undefined` in `afterChange` hook for nested
Lexical fields.
## Why
We had this issue previously where the wrong `previousValue` data was
being accessed with nested fields, this introduced the fix of
`previousValData` which properly scopes to sibling-level or full
document data.
When the previous fix was done, the `previousValue` in the rich text
case here was missed.
## How
Changed the `richText` case in
`packages/payload/src/fields/hooks/afterChange/promise.ts` to use the
same `previousValData` pattern as other field types:
```diff
- previousValue: previousDoc?.[field.name],
+ previousValue: getNestedValue(previousValData, pathSegments) ?? previousValData?.[field.name],
```
### Testing
Added to `Lexical > lexical.int.spec.ts`
### What
Re-exports UI components from `@payloadcms/next/rsc` and
`@payloadcms/next/client` instead of requiring direct imports from
`@payloadcms/ui` in the generated importMap.
### Why
The generated `importMap.js` was importing from `@payloadcms/ui`, which
isn't in users' `package.json` (it's a transitive dependency through
`@payloadcms/next`). This breaks import resolution with pnpm and can
fail in production builds with any package manager without
`@payloadcms/ui` installed.
### How
- Added re-exports in `@payloadcms/next/rsc` for: `CollectionCards`,
`FolderField`, `FolderTableCell`, `SlugField`
- Added re-exports in `@payloadcms/next/client` for: `FolderTypeField`
and all `QueryPresets*` components
- Updated all component paths in `@payloadcms/payload` to use
`@payloadcms/next/rsc` or `@payloadcms/next/client` instead of
`@payloadcms/ui`
Fixes#15010
The `payload.count`, `payload.countVersions` and
`payload.countGlobalVersions` operations accepted a `depth` argument,
but this value is never used in the count logic. Having it show up in
auto-suggestions can be confusing for users (as I just experienced),
since it raises the question of why a count operation would need a depth
at all.
This PR removes the unused depth property
Exports the fractional indexing utilities and also reorders the docs so
that we mostly link to our docs on the topic and from there only link
out once to the article.
This PR fixes broken conditional tabs support in Next.js 16. In Next.js
16, the config is sanitized multiple times during development, with each
run assigning a new UUID to `tab.id`. This causes a mismatch between the
`tab.id` in form state and the `tab.id` in the client config - likely
because the client config is cached, while [config sanitization is
not](https://github.com/payloadcms/payload/pull/9501).
Using UUIDs in form state is also inconsistent with the rest of the
codebase, where every other field uses its path as a key. This PR
replaces the random UUID with the tab's schema path, ensuring a stable
ID regardless of how many times config sanitization runs, and aligning
tabs closer to how other fields are keyed in form state.
### What
Fixes issue where `previousValue` and `value` return identical data in
`afterChange` hook when using `tabs` fields.
This bug was surfaced by the `plugin-seo`, which wraps all fields in a
`tabs` field when `tabbedUI` is enabled.
### Why
In `afterChange/promise.ts`, for the `tab` case, we incorrectly assign:
```ts
tabPreviousSiblingDoc = siblingDoc
```
instead of:
```ts
tabPreviousSiblingDoc = {...previousSiblingDoc}
```
This caused hooks to receive the **current document state** for both
`previousValue` and `value`.
### Where
Updates the code in `promise.ts`.
#### Testing
Int test added to `plugin-seo`.
This PR fixes some issues reported from Discord:
1. An empty relationTo array is valid via TS but it will now throw an
error as it's not valid config
```ts
relationTo: [], // Now throws error in sanitisation
```
2. Setting lockDocuments: false on all collections and globals is now
supported, it will skip the creation of the lock documents collection
altogether instead of creating an invalid collection
### What?
This PR adds the `RichTextFieldValidation` type to the `validate`
property of the `RichTextField`.
### Why?
Previously, adding the default `richText` validate function to a typical
`type: 'richText'` field would result in a TS error due to mismatching
types, specifically on the `options` argument. With these changes, you
can now simply thread the default `richText` validation through to the
`validate` property as expected with no unnecessary type manipulation.
Previously, this would result in a TS error:
```ts
import type { CollectionConfig } from 'payload'
import { richText } from 'payload/shared'
export const PostsCollection: CollectionConfig = {
slug: 'posts',
// ...
fields: [
// ...
{
name: 'content',
type: 'richText',
validate: richText, // Type error here
},
],
}
```
With the changes introduced here, the TS error is resolved.
### How?
By threading the existing `RichTextFieldValidation` type to the
`validate` property of the `RichTextField`.
Fixes#15068
Fixes#14927
## Summary
Fixes custom ID fields that are nested within tabs/groups to work
correctly with MongoDB adapter.
## Problem
When a custom id field was nested inside tabs or other unnamed grouping
fields, MongoDB's schema builder couldn't find it because it only
searched top-level fields. This caused the custom ID to be ignored and
MongoDB would fall back to auto-generating ObjectIDs.
## Solution
- MongoDB adapter: Pass flattenedFields to schema builder so it can find
custom ID fields at the flattened top level
- afterRead hooks: Track field depth and prevent hidden custom ID fields
from being removed when they're at the top level (depth 0), since they
need to be accessible even if marked as hidden
## Testing
Added new CustomIDNested collection and comprehensive tests covering:
- Creating documents with numeric custom IDs nested in tabs
- Retrieving documents by custom ID
- Updating documents with custom ID
This PR is quite extensive and comes with several bugfixes and new
features as listed.
### Additional IANA timezones
Previously we were only checking the `supportedValuesOf` for validating
the IANA timezones however this list of names is not comprehensive, nor
is it canonical for what's supported by the runtime. We now check the
`DateTimeFormat` API as well which means timezones like
`Asia/Ho_Chi_Minh` and `Asia/Saigon` are both supported.
### Custom UTC offsets support
In addition to IANA timezone names (like `America/New_York`), you can
also use fixed UTC offsets in the `±HH:mm` format:
```ts
{
name: 'eventTime',
type: 'date',
timezone: {
supportedTimezones: [
{ label: 'UTC+5:30 (India)', value: '+05:30' },
{ label: 'UTC-8 (Pacific)', value: '-08:00' },
{ label: 'UTC+0', value: '+00:00' },
],
},
}
```
NOTE: Since GraphQL does not allow special symbols like +- in the enums
we format them to _TZOFFSET_MINUS_05_00. The `_TZOFFSET_` prefix is to
ensure that we show this is an internal enum and avoid potential
conflicts for any custom enums.
### Overriding the timezone field
You can now override the timezone field via `override` on timezone
config:
```ts
{
name: 'publishedAt',
type: 'date',
label: 'Published At',
timezone: {
override: ({ baseField }) => ({
...baseField,
admin: {
...baseField.admin,
disableListColumn: true, // Hide from list view columns
},
}),
},
}
```
### Bugfixes
- The timezone field can now correctly inherit or display a formatted
label from the parent, for label functions or complex translations you
should use the override as above
- The timezone field can now be hidden from list column
- Types are now generated correctly per field if they have their own
timezones, previously it was assigned to the global SupportedTimezones
list
- Field's own timezones are now run through validation as well
### What?
Updates the `previousValue` data sent to the `afterChange` hook to
accomodate **lexical nested** paths.
### Why?
Currently the `afterChange` hook uses the previousDoc:
```ts
previousValue: getNestedValue(previousDoc, pathSegments) ?? previousDoc?.[field.name],
```
However, with fields from within Lexical, the data on the full
`previousDoc` has a complex nesting structure. In this case, we should
be using the `previousSiblingDoc` to only return the sibling data and
easily de-structure the data.
### How?
Checks if sibling data exists, and uses it if it does:
```ts
const previousValData =
previousSiblingDoc && Object.keys(previousSiblingDoc).length > 0
? previousSiblingDoc
: previousDoc
```
**Reported by client.**
Related PR: https://github.com/payloadcms/payload/pull/14582
---------
Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
### What?
This PR adds the missing `beforeInput` and `afterInput` properties to
the `UploadAdmin` type so that the type definition reflects the actual
supported admin props for upload fields.
### Why?
Although upload fields [already support `beforeInput` and
`afterInput`](https://github.com/payloadcms/payload/blob/fb93cd13bcd66dca3777826fc420a941d3cb75ab/packages/ui/src/fields/Upload/index.tsx#L51)
in practice, these props are currently not included in the `UploadAdmin`
TypeScript type. This leads to type errors or the need for type
assertions when trying to use them. Aligning the type definition with
the existing runtime behavior improves DX and keeps the admin field
types consistent, similar to what was done for RichTextField in #12889.
### How?
Extended the UploadAdmin type to include the `beforeInput` and
`afterInput` properties.
Updates the config.versions (on the sanitized config) to be proper.
#### Before
```ts
versions: SanitizedCollectionVersions // not accurate
```
#### After
```ts
versions?: SanitizedCollectionVersions
```
Previously sanitization did not remove the versions key when it was set
to false so the actual type should have been `versions:
SanitizedCollectionVersions | false`.
I chose to remove the versions key if it is false in the sanitization
function so it is easier to work with, i.e. optional chaining.
Also adds importable helper functions:
```ts
import {
getAutosaveInterval,
getVersionsMax,
hasAutosaveEnabled,
hasDraftsEnabled,
hasDraftValidationEnabled,
hasScheduledPublishEnabled,
} from 'payload/shared'
```
Continuation of #14007.
Supports overriding the default slugify function of the slug field. This
is necessary if the slug requires special treatment, such as character
encoding, additional language support, etc.
For example, if you wanted to use the
[`slugify`](https://www.npmjs.com/package/slugify) package, you could do
something like this:
```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
import slugify from 'slugify';
export const MyCollection: CollectionConfig = {
// ...
fields: [
// ...
slugField({
slugify: ({ valueToSlugify }) => slugify(valueToSlugify, {
// ...additional `slugify` options here
})
})
]
}
```
This PR also deprecates the old `fieldToUse` arg in favor of `useAsSlug`
which is more semantically clear, following the same convention as
`useAsTitle`.
Example:
```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
export const MyCollection: CollectionConfig = {
// ...
fields: [
// ...
slugField({
useAsSlug: 'myCustomTitle'
})
]
}
```
In follow-up PRs, we should also:
- Improve the default slugify function to better handle special
characters on its own, etc.
- [Support nested
slugs](https://github.com/payloadcms/payload/pull/14783)
Relationship fields would fallback even if the parent of the request set
`fallbackLocale: false`. This was because of the logic in
sanitizeFallbackLocale which would transform `false` value into `null`,
and when a relationship populate function ran, it would transform the
`null` into the `localization.defaultLocale` value.
Added int test that failed before and passes after to cover.
Adds support for 'UTC' as a value for timezones. This was not previously
supported because we run a check on the values used against the runtime
of Intl API.
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
admin: {
timezones: {
supportedTimezones: [
{
label: 'UTC',
value: 'UTC',
},
// ...other timezones
],
defaultTimezone: 'UTC',
},
},
})
```
Also fixes an issue when having only one timezone making it selected by
default.
### What?
Updates the `previousValue` data sent to the `afterChange` hook to
accomodate nested paths.
### Why?
Currently the `afterChange` hook returns this:
```ts
previousValue: previousDoc?.[field.name],
```
For any nested fields, this breaks because it does not match the
structure of the incoming data.
### How?
Uses the path segments to correctly extract the relevant data.
**Reported by client.**
### What?
Default values were incorrectly appearing (or not appearing) depending
on access control configuration. When access control denied access to a
global or collection document, default values would sometimes still
populate, and conversely, when access was allowed, defaults would
sometimes be blocked.
### Why?
The previous logic checked `args.data` before querying the database,
making it impossible to distinguish between:
1. A document that doesn't exist and access is allowed (should populate
defaults for globals)
2. A document that was filtered by access control (should NOT populate
defaults)
This caused default values like `_status: 'draft'` to appear when access
control should have returned an empty response, and also blocked
legitimate defaults on uncreated globals without access restrictions.
### How?
- **Query first**: Both globals and collections now query the database
before checking `args.data`
- **Detect access denial**: Only return early when `!docFromDB &&
!args.data && !overrideAccess && accessResult !== true`
- **Correct fallback**: Use `args.data ?? docFromDB ?? {}` (globals) or
`args.data ?? docFromDB` (collections)
- **Simplified**: Removed `shouldSkipDefaults` logic from `promise.ts`
since it's now handled at the operation level
Fixes#14493
Fixes https://github.com/payloadcms/payload/issues/14476
Plugin-defined label functions (e.g., `label: ({ t }) =>
t('plugin-redirects:internalLink')`) don't update when switching
languages.
## Root Cause
The global Payload config was being **mutated**. When
`createClientField` copied the `options` array by reference, it would
evaluate label functions and replace them with strings in the global
config. Subsequent requests would find strings instead of functions and
couldn't re-evaluate.
## Why PR #11725 Wasn't Enough
[PR #11725](https://github.com/payloadcms/payload/pull/11725) fixed the
client config cache to store separate configs per language, but didn't
prevent the source config from being mutated.
## Solution
**File**: `packages/payload/src/fields/config/client.ts`
- Skip copying `options` in the default case
- Create a fresh array in the radio/select handler instead of reusing
the reference
- This prevents mutating the global config while still evaluating labels
correctly per language
## Testing
The testing is located at
https://github.com/payloadcms/payload/pull/14569, which depends on this
PR. You can see that the new tests there pass because they already
contain this commit.
I originally created it into a different PR because CI was failing and I
wasn't sure if it was due to this change. Now I've left them separate
for traceability and clarity. The other pull request is a feature, this
one is a fix.
### What?
In field hooks, `previousSiblingDoc` is typed as `TData`.
### Why?
This value should actually be typed as `TSiblingData`, I've confirmed
the sibling data is actually what is returned when a field hook runs.
### How?
Updated `FieldHook` typing, as well as `RichText` field hook typings
Fixes#9735
This PR adds support for polymorphic uploads similar to polymorphic
relationships. Closes
https://github.com/payloadcms/payload/discussions/13912
It works with bulk upload as well and in the bulk uploader you can
choose which collection the files are uploaded into.
You can enable this by adding an array of slugs into `relationTo`:
```ts
import type { CollectionConfig } from 'payload'
export const ExampleCollection: CollectionConfig = {
slug: 'example-collection',
fields: [
{
name: 'media',
type: 'upload',
relationTo: ['images', 'documents', 'videos'], // references multiple upload collections
},
],
}
```
It can also be combined with `hasMany`:
```ts
import type { CollectionConfig } from 'payload'
export const ExampleCollection: CollectionConfig = {
slug: 'example-collection',
fields: [
{
name: 'media',
type: 'upload',
relationTo: ['images', 'documents', 'videos'], // references multiple upload collections
hasMany: true, // allows multiple uploads
},
],
}
```
**Known issue**: https://github.com/payloadcms/payload/issues/12014
Filtering by polymorphic relationships is currently broken and I'll work
on that in a separate PR.
This PR enables you to add timezone specific config on a per field basis
so each field can support its own list of timezones
```ts
{
name: 'date',
type: 'date',
timezone: {
defaultTimezone: 'America/New_York',
supportedTimezones: [
{ label: 'New York', value: 'America/New_York' },
{ label: 'Los Angeles', value: 'America/Los_Angeles' },
{ label: 'London', value: 'Europe/London' },
],
},
}
```
You can also enforce a specific timezone by specifying just one with a
default value:
```ts
{
name: 'date',
type: 'date',
timezone: {
defaultTimezone: 'Europe/London',
supportedTimezones: [
{ label: 'London', value: 'Europe/London' },
],
},
}
```
It also fixes a bug with date fields in list view not showing the date
in the formatted timezone correctly.
### What?
Fixes an issue where globals with drafts/versions enabled would return
the `_status` field with its default value (`'draft'`) when access
control denied read access, instead of returning an empty object.
### Why?
When a global has:
1. Versions/drafts enabled (which adds the `_status` field with
`defaultValue: 'draft'`)
2. Read access control with a where clause (e.g., `return {_status:
{equals: 'published'}}`)
And a user without permissions requests the global (e.g., an unpublished
draft exists), the API was returning `{"_status": "draft"}` instead of
an empty object `{}`. This exposes the document's status even when
access is denied.
The issue occurred because when the DB query returned `null` (filtered
by access control), the `findOne` operation would set `doc = {}`, and
then the `afterRead` hook would populate default values including
`_status: 'draft'`.
### How?
- Modified `afterRead/promise.ts` to skip setting default values for
globals when:
- The document has no `id` (indicating it doesn't exist or was filtered)
- AND access control is active (`!overrideAccess`)
Fixes#14096
Previously you had to use the overrides to enable localization for the
`slugField` however it's a very common request that slugs are localized
so we've added it to the args.
Now you can do:
```ts
slugField({ localized: true })
```
There was also a bug relating to using custom field names, even though
we accept it the hook would still look for `slug`, this has been fixed.
Todo:
- [x] Add e2e tests for general field and localized field
### What?
This change allows `fallbackLocale` to accept an **array of locales** in
find queries and locale configuration.
```ts
/** Local API **/
await payload.findByID({
id,
collection,
locale: 'en',
fallbackLocale: ['fr', 'es'],
})
/** REST API **/
await fetch(`${baseURL}/api/${collectionSlug}?locale=en&fallbackLocale[]=fr&fallbackLocale[]=es`)
/** GraphQL **/
await restClient.GRAPHQL_POST({
body,
query: { locale: 'en', fallbackLocale: ['fr', 'es']},
})
/** Locale Configs **/
locales: [
{
code: 'en',
label: 'English',
fallbackLocale: ['fr', 'es'],
},
]
```
### Why?
This update is part of the planned [localization
enhancements](https://github.com/payloadcms/payload/discussions/10705).
Currently, only one fallback locale can be specified. If that locale
doesn’t contain data, the query returns nothing. To work around this,
users have to inspect the response themselves and then make additional
queries for other locales.
With this change, Payload handles that work automatically. Users only
need to provide a list of fallback locales in their preferred order, and
Payload will check each one until it finds a value.
### How?
Updates query handling across the local API, REST API, and GraphQL so
that `fallbackLocale` can accept either a **string** or an **array** of
locales. When an array is passed, Payload will iterate through them in
order and return the first found localized value. This behavior applies
to both collections and globals.
#### Feature request:
https://github.com/payloadcms/payload/discussions/13443
---------
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
This PR:
- adds support for client-side markdown shortcuts for blocks with the
`admin.jsx` property. This was previously only supported server-side.
The code mimics the server-side implementation, as this could be used
for full markdown imports on the client-side, rather than just simple
markdown shortcuts
- exports a pre-made `CodeBlock` that can be used within the
`BlocksFeature`. Video Example:
https://github.com/user-attachments/assets/31f2d66b-64e9-4e9d-a2b6-89ddefda3a84
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211217132290839
Discussion #8859. Requires #14012.
Exports a new `slugField`. This is a wrapper around the text field that
you can drop into any field schema.
A slug is a unique, indexed, URL-friendly string that identifies a
particular document, often used to construct the URL of a webpage. Slugs
are a fundamental concept for seemingly every project.
Traditionally, you'd build this field from scratch, but there are many
edge cases and nice-to-haves to makes this difficult to maintain by
hand. For example, it needs to automatically generate based on the value
of another field, provide UI to lock and re-generate the slug on-demand,
etc.
Fixes#13938.
When autosave is enabled, the slug is only ever generated once after the
initial create, leading to single character, or incomplete slugs.
For example, it is expected that "My Title" → "my-title, however ends up
as "m".
This PR overhauls the field to feel a lot more natural. Now, we only
generate the slug through:
1. The `create` operation, unless the user has modified the slug
manually
2. The `update` operation, if:
a. Autosave is _not_ enabled and there is no slug
b. Autosave _is_ enabled, the doc is unpublished, and the user has not
modified the slug manually
The slug should stabilize after all above criteria have been met,
because the URL is typically derived from the slug. This is to protect
modifying potentially live URLs, breaking links, etc. without explicit
intent.
This fix, along with all the other features, is now standardized behind
the new `slugField`:
```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
export const MyCollection: CollectionConfig = {
// ...
fields: [
// ...
slugField()
]
}
```
In the future we could also make this field smart enough to auto
increment itself when its generated slug is not unique.
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211513433305005
### What?
This PR adds a new `admin.disableRowTypes` config to `'join'` fields
which hides the `"Type"` column from the relationship table.
### Why?
While the collection type column _can be_ helpful for providing
information, it's not always necessary and can sometimes be redundant
when the field only has a singular relationTo. Hiding it can be helpful
by removing visual noise and providing editors the data directly.
### How?
By threading `admin.disableRowTypes` directly to the `getTableState`
function of the `RelationshipTable` component.
**With row types** (via `admin.disableRowTypes: false | undefined` OR
default for polymorphic):

**Without row types** (default for monomorphic):

### What?
Adds a new `disableGroupBy` admin config property for fields to control
their visibility in the list view GroupBy dropdown.
### Why
Previously, the GroupByBuilder was incorrectly using `disableListFilter`
to determine which fields to show in the group-by dropdown.
### How
- Added new `disableGroupBy` property to the field admin config types.
- Updated `GroupByBuilder` to filter fields based on `disableGroupBy`
instead of `disableListFilter`
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211511898438807
This PR introduces support for conditionally setting allowable block
types via a new `field.filterOptions` property on the blocks field.
Closes the following feature requests:
https://github.com/payloadcms/payload/discussions/5348,
https://github.com/payloadcms/payload/discussions/4668 (partly)
## Example
```ts
fields: [
{
name: 'enabledBlocks',
type: 'text',
admin: {
description:
"Change the value of this field to change the enabled blocks of the blocksWithDynamicFilterOptions field. If it's empty, all blocks are enabled.",
},
},
{
name: 'blocksWithFilterOptions',
type: 'blocks',
filterOptions: ['block1', 'block2'],
blocks: [
{
slug: 'block1',
fields: [
{
type: 'text',
name: 'block1Text',
},
],
},
{
slug: 'block2',
fields: [
{
type: 'text',
name: 'block2Text',
},
],
},
{
slug: 'block3',
fields: [
{
type: 'text',
name: 'block3Text',
},
],
},
],
},
{
name: 'blocksWithDynamicFilterOptions',
type: 'blocks',
filterOptions: ({ siblingData: _siblingData, data }) => {
const siblingData = _siblingData as { enabledBlocks: string }
if (siblingData?.enabledBlocks !== data?.enabledBlocks) {
// Just an extra assurance that the field is working as intended
throw new Error('enabledBlocks and siblingData.enabledBlocks must be identical')
}
return siblingData?.enabledBlocks?.length ? [siblingData.enabledBlocks] : true
},
blocks: [
{
slug: 'block1',
fields: [
{
type: 'text',
name: 'block1Text',
},
],
},
{
slug: 'block2',
fields: [
{
type: 'text',
name: 'block2Text',
},
],
},
{
slug: 'block3',
fields: [
{
type: 'text',
name: 'block3Text',
},
],
},
],
},
]
```
https://github.com/user-attachments/assets/e38a804f-22fa-4fd2-a6af-ba9b0a5a04d2
# Rationale
## Why not `block.condition`?
- Individual blocks are often reused in multiple contexts, where the
logic for when they should be available may differ. It’s more
appropriate for the blocks field (typically tied to a single collection)
to determine availability.
- Hiding existing blocks when they no longer satisfy a condition would
cause issues - for example, reordering blocks would break or cause block
data to disappear. Instead, this implementation ensures consistency by
throwing a validation error if a block is no longer allowed. This aligns
with the behavior of `filterOptions` in relationship fields, rather than
`condition`.
## Why not call it `blocksFilterOptions`?
Although the type differs from relationship fields, this property is
named `filterOptions` (and not `blocksFilterOptions`) for consistency
across field types. For example, the Select field also uses
`filterOptions` despite its type being unique.
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211334752795631
This PR adds support for the following configuration:
```ts
const config = {
collections: [
{
slug: 'categories',
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'posts',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'categories',
type: 'relationship',
relationTo: 'categories',
hasMany: true,
},
],
},
{
slug: 'examples',
fields: [
{
name: 'postCategoriesTitles',
type: 'text',
virtual: 'post.categories.title',
// hasMany: true - added automatically during the sanitization
},
{
type: 'relationship',
relationTo: 'posts',
name: 'post',
},
{
name: 'postsTitles',
type: 'text',
virtual: 'posts.title',
// hasMany: true - added automatically during the sanitization
},
{
type: 'relationship',
relationTo: 'posts',
name: 'posts',
hasMany: true,
},
],
},
],
}
```
In the result:
`postsTitles` - will be always populated with an array of posts titles.
`postCategoriesTitles` - will be always populated with an array of the
categories titles that are related to this post
The virtual `text` field is sanitizated to `hasMany: true`
automatically, but you can specify that manually as well.
Before, unnamed, unlabelled group were part of an unlabeled collapsible
in the version diff view. This means, all it displayed was a clickable,
empty rectangle. This looks like a bug rather than intended.
This PR displays a new <Unnamed Group> label in these cases.
It also fixes the incorrect `NamedGroupFieldClient` type. Previously,
that type did not include the `name` property, even though it was
available on the client.
Before:
<img width="2372" height="688" alt="Screenshot 2025-09-16 at 18 57
45@2x"
src="https://github.com/user-attachments/assets/0f351f84-a00f-4067-aa40-d0e8fbfd5f0b"
/>
After:
<img width="2326" height="598" alt="Screenshot 2025-09-16 at 18 56
14@2x"
src="https://github.com/user-attachments/assets/bddee841-8218-4a90-a052-9875c5f252c0"
/>
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211375615406676
Fixes#13756
The findByID endpoint, by default, expects the data for localized fields
to be an object, values mapped to the locale. This is not the case for
client-side live preview, as we send already-flattened data to the
findByID endpoint.
For localized fields where the value is an object (richText/json/group),
the afterRead hook handler would attempt to flatten the field value,
even though it was already flattened.
## Solution
The solution is to expose a `flattenLocales` arg to the findByID
endpoint (default: true) and pass `flattenLocales: false` from the
client-side live preview request handler.
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211334752795627