Commit Graph

395 Commits

Author SHA1 Message Date
Philipp Schneider 4b4d61ce88 fix: restore falling back to current document values for undefined fields (#16272)
### 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>
2026-04-15 10:58:47 -07:00
Boyan Bratvanov d9b3c07bfa fix: expose type for field's position (#14390)
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>
2026-04-09 18:25:33 +00:00
Jens Becker 72396f6c1b fix: default virtual fields to readOnly in admin UI (#16016)
### 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
2026-04-09 10:23:27 -07:00
Jarrod Flesch b705af4d4f chore: extract sanitizeField from sanitizeFields (#16133)
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.
2026-04-02 07:17:37 -07:00
Jens Becker e09c619190 fix: skip default validation for virtual fields (#16015)
### 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
2026-04-01 11:22:35 -04:00
Patrik aa143e6e8f fix: trashed documents still show as IDs in relationship responses (#16102)
# 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
2026-03-30 10:25:33 -07:00
Sasha 395e1ed1a4 feat: add disableUnique property to the slug field for better multi tenant plugin support (#15963)
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.
2026-03-17 14:20:38 +00:00
Omar Yusuf Abdi fac59c8515 fix: text field validation rejecting localized object values (#15932)
## 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
2026-03-12 16:37:33 -07:00
Palamar Roman f0498f2c0f feat(richtext-lexical): separate configuration for lexical block icons (#15632)
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.
2026-03-02 13:16:26 -08:00
Jessica Rynkar 1cc3bb93bc fix: populate previousValue correctly in afterChange hooks for nested lexical fields (#15623)
## 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`
2026-02-13 23:24:50 +00:00
Jarrod Flesch 6c07f3b946 fix(next): export SlugField from the client dir not rsc (#15461)
Fixes https://github.com/payloadcms/payload/issues/15451.

Changes introduced in https://github.com/payloadcms/payload/pull/15392
moved the `SlugField` from client to server exports, throwing the
following runtime error when navigating to the edit view:

```
useServerFunctions must be used within a ServerFunctionsProvider
```

---------

Co-authored-by: Jake <jacobsfletch@gmail.com>
2026-02-02 14:30:11 +00:00
Patrik 5561799188 fix: widgets and other features failing with transitive dependency imports (#15392)
### 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
2026-01-28 12:38:05 -08:00
Alessio Gravili dfc1600364 fix: remove depth from count operation types (#15356)
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
2026-01-26 18:14:24 +00:00
Paul 02800b0bb9 chore: export fractional indexing utilities (#15286)
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.
2026-01-20 16:16:04 -06:00
Jarrod Flesch f4e8990bab fix: select hasMany prevent duplicate values (#15218)
Fixes https://github.com/payloadcms/payload/issues/15208

Adds duplicate value validation to select `hasMany: true` fields.
2026-01-20 16:31:19 -05:00
Alessio Gravili f5a7a00a19 fix: conditional tabs breaking in Next.js 16 due to unstable tab id (#15270)
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.
2026-01-20 08:14:10 -05:00
Jessica Rynkar b6b6bab465 fix: correct previousValue and value in afterChange when using seo-plugin (#15253)
### 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`.
2026-01-20 12:07:16 +00:00
Paul d7351d5710 chore: add v4 comments for relationTo types (#15187)
Adds v4 todo comment for relationTo, reminder to make [] arrays fail
when they should contain at least one value
2026-01-13 16:28:24 +00:00
Paul 9521ec62b0 fix: throw error on empty relationTo array and allow disabling lockDocuments on all collections (#14871)
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
2026-01-13 10:45:55 -05:00
Said Akhrarov d68e75ada7 fix: add distinct validate to richtext field type definition (#15069)
### 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
2026-01-12 22:57:02 -05:00
Jake d288752f68 fix: field schema map paths (#10852)
Continuation of #10638.

Field paths within the schema map are not correct.

For example, an unnamed tab containing a text field should have the
schema path:
- `_index-0.fieldWithinUnnamedTab`

However, within the schema map that path is formatted as:
- `fieldWithinUnnamedTab`

The leading index in the first example _should_ exist, as this field is
nested within another field, regardless of this field being unnamed.
Otherwise, it would be impossible to traverse the schema and lookup this
field.

This discrepancy is not an issue in the admin panel because fields are
_also_ provided the wrong schema paths. They can properly locate their
schema within the schema map because they match despite being incorrect.
Both are wrong and that's why it works.

Here's comprehensive example of how field paths _should_ be formatted:

```ts
{
  // ...
  fields: [
    {
      // path: 'topLevelNamedField'
      // schemaPath: 'topLevelNamedField'
      // indexPath: ''
      name: 'topLevelNamedField',
      type: 'text',
    },
    {
      // path: 'array'
      // schemaPath: 'array'
      // indexPath: ''
      name: 'array',
      type: 'array',
      fields: [
        {
          // path: 'array.[n].fieldWithinArray'
          // schemaPath: 'array.fieldWithinArray'
          // indexPath: ''
          name: 'fieldWithinArray',
          type: 'text',
        },
        {
          // path: 'array.[n].nestedArray'
          // schemaPath: 'array.nestedArray'
          // indexPath: ''
          name: 'nestedArray',
          type: 'array',
          fields: [
            {
              // path: 'array.[n].nestedArray.[n].fieldWithinNestedArray'
              // schemaPath: 'array.nestedArray.fieldWithinNestedArray'
              // indexPath: ''
              name: 'fieldWithinNestedArray',
              type: 'text',
            },
          ],
        },
        {
          // path: 'array.[n]._index-2'
          // schemaPath: 'array._index-2'
          // indexPath: '2'
          type: 'row',
          fields: [
            {
              // path: 'array.[n].fieldWithinRowWithinArray'
              // schemaPath: 'array._index-2.fieldWithinRowWithinArray'
              // indexPath: ''
              name: 'fieldWithinRowWithinArray',
              type: 'text',
            },
          ],
        },
      ],
    },
    {
      // path: '_index-2'
      // schemaPath: '_index-2'
      // indexPath: '2'
      type: 'row',
      fields: [
        {
          // path: 'fieldWithinRow'
          // schemaPath: '_index-2.fieldWithinRow'
          // indexPath: ''
          name: 'fieldWithinRow',
          type: 'text',
        },
      ],
    },
    {
      // path: '_index-3'
      // schemaPath: '_index-3'
      // indexPath: '3'
      type: 'tabs',
      tabs: [
        {
          // path: '_index-3-0'
          // schemaPath: '_index-3-0'
          // indexPath: '3-0'
          label: 'Unnamed Tab',
          fields: [
            {
              // path: 'fieldWithinUnnamedTab'
              // schemaPath: '_index-3-0.fieldWithinUnnamedTab'
              // indexPath: ''
              name: 'fieldWithinUnnamedTab',
              type: 'text',
            },
            {
              // path: '_index-3-0-1'
              // schemaPath: '_index-3-0-1'
              // indexPath: '3-0-1'
              type: 'tabs',
              tabs: [
                {
                  // path: '_index-3-0-1-0'
                  // schemaPath: '_index-3-0-1-0'
                  // indexPath: '3-0-1-0'
                  label: 'Nested Unnamed Tab',
                  fields: [
                    {
                      // path: 'fieldWithinNestedUnnamedTab'
                      // schemaPath: '_index-3-0-1-0.fieldWithinNestedUnnamedTab'
                      // indexPath: ''
                      name: 'fieldWithinNestedUnnamedTab',
                      type: 'text',
                    },
                  ],
                },
              ],
            },
          ],
        },
        {
          // path: 'namedTab'
          // schemaPath: '_index-3.namedTab'
          // indexPath: ''
          label: 'Named Tab',
          name: 'namedTab',
          fields: [
            {
              // path: 'namedTab.fieldWithinNamedTab'
              // schemaPath: '_index-3.namedTab.fieldWithinNamedTab'
              // indexPath: ''
              name: 'fieldWithinNamedTab',
              type: 'text',
            },
          ],
        },
      ],
    },
  ]
}
```
2026-01-09 17:21:17 -05:00
Jarrod Flesch 40081f457d fix(db-mongodb): find id field from flattened fields (#15110)
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
2026-01-08 14:55:42 -05:00
Paul a8785ba0a3 feat: add support for additional IANA timezones, custom UTC offsets and overriding the timezone field (#15120)
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
2026-01-08 11:05:25 -06:00
Sasha 4e45432592 test: migrate to vitest (#14337)
Continuation of https://github.com/payloadcms/payload/pull/10200

* For VSCode - you'll need to install the official plugin
https://marketplace.visualstudio.com/items?itemName=vitest.explorer
* Every test suite now explicitly imports the test runner functions like
`describe`, `expect`, instead of using it from `global`. While `vitest`
supports globals with https://vitest.dev/config/#globals, the main
benefit of not doing this is that we don't conflict with test runners
that have the same functions, e.g `playwright` and `tstyche`.
* Now we have only 1 test config file that uses
[projects](https://vitest.dev/guide/projects) for unit and integration
tests

---------

Co-authored-by: Alessio Gravili <github@gravili.net>
2025-12-19 16:07:14 -05:00
Jessica Rynkar bb1501ea14 fix: previousValue from hooks should be populated within lexical blocks (#14856)
### 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>
2025-12-12 10:58:54 +00:00
Jens Becker 22a02557d6 fix: add missing beforeInput and afterInput properties to UploadAdmin type (#14775)
### 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.
2025-12-04 11:19:08 -06:00
Jarrod Flesch 51c951fbcc fix: sanitized versions type was incorrect (#14810)
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'
```
2025-12-04 10:02:21 -05:00
Jake Fletcher 59a1607a3f feat: support custom slugify functions (#14117)
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)
2025-12-02 17:14:48 +00:00
Jarrod Flesch 87137febd4 fix: corrects outgoing localized data from afterRead (#14603)
Additional incorrect data was being appended when querying with `locale:
"all"` for localized groups and tabs.

#### Before (incorrect)
```ts
g1: {
  en: {
    g2: {
      g2a1: [
        { text: 'EN Deep 1' },
      ]
    }
  },
  g2: { // <-- bad
    g2a1: []
  }
}
```

#### After (correct)
```ts
g1: {
  en: {
    g2: {
      g2a1: [
        { text: 'EN Deep 1' },
      ]
    }
  },
}
```
2025-11-18 11:02:39 -05:00
Jarrod Flesch 9f55254da3 fix: relationships should not fallback if fallbackLocale is false (#14641)
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.
2025-11-17 16:21:51 -05:00
Paul 8b34e40105 feat: add support for UTC timezone in date fields (#14586)
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.
2025-11-13 13:20:02 -05:00
Jessica Rynkar d60ea6e8f1 fix: previous value undefined for nested fields in afterChange (#14582)
### 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.**
2025-11-13 15:41:41 +00:00
Patrik 8f7ef35274 fix: default values inconsistent between Local and REST APIs (#14556)
### 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
2025-11-13 07:39:49 -08:00
German Jablonski bb520d6e78 fix: changing the language of the admin panel does not work for "radio" and "select" fields. (#14569)
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.
2025-11-12 16:42:50 +00:00
Slava Nossar 339a0c390d fix: use TSiblingData for previousSiblingDoc in FieldHook (#14503)
### 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
2025-11-06 20:59:07 +00:00
Paul df40d0e6fe feat: add support for polymorphic uploads (#14363)
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.
2025-11-06 14:40:51 +00:00
Paul a3df837872 feat: timezone config can now be added on a per field basis (#14410)
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.
2025-10-30 11:12:37 -04:00
Patrik b766ae6570 fix: globals with versions return _status field when access denied (#14406)
### 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
2025-10-30 07:57:56 -07:00
Paul 2ced43dc56 fix: allow slugField to accept localized argument and fixed slug generation with custom field names (#14234)
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
2025-10-17 17:07:16 +00:00
Jessica Rynkar 623a1b8216 feat: accept multiple locales in fallbackLocale (#13822)
### 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>
2025-10-16 12:02:59 +01:00
German Jablonski 13a1d90890 fix: validate Point Field to -180 to 180 for longitude and -90 to 90 for latitude (#14206)
Fixes #14185
2025-10-16 07:47:25 +00:00
Alessio Gravili 07a1eff99c feat(richtext-lexical): client-side block markdown shortcuts, code block (#13813)
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
2025-10-07 20:15:45 +00:00
Jacob Fletcher b09ae6772f feat: slug field (#14007)
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
2025-10-07 10:15:45 -04:00
Said Akhrarov cd546b3125 feat(ui): add support for disabling join field row types (#12738)
### 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):

![image](https://github.com/user-attachments/assets/22b55477-cf56-4b0e-a845-e6f2b39efe3b)

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

![image](https://github.com/user-attachments/assets/3a2bb0ba-2d5e-4299-8689-249b2d3fefe2)
2025-10-03 11:10:10 +01:00
Patrik 537f58b4bc feat: adds disableGroupBy to fields admin props (#14017)
### 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
2025-10-02 06:22:32 -07:00
Alessio Gravili 41aa201f7b fix: ensure blocks filterOptions are awaited (#13960)
Fixes #13956
2025-09-29 17:48:30 +00:00
Alessio Gravili d8dace6509 feat: conditional blocks (#13801)
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
2025-09-22 14:20:25 -07:00
Sasha 1072171f97 feat: support hasMany virtual relationship fields (#13879)
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.
2025-09-19 21:04:07 +03:00
Alessio Gravili 8d3b146d2d fix(next): unnamed, unlabeled groups displayed without label in version view (#13831)
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
2025-09-17 11:38:07 +03:00
Alessio Gravili b34e5eadf4 fix(live-preview): client-side live preview failed to populate localized fields (#13794)
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
2025-09-15 12:16:52 -04:00