Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
45bec77
feat: adds experimental.localizeMeta
jessrynkar Sep 26, 2025
9f8a2db
chore: fix type import
jessrynkar Sep 26, 2025
27cdcc7
chore: conditionally chain version.localizedMeta
jessrynkar Sep 26, 2025
57ea0e6
chore: populate localizedMeta outside of saveVersions
jessrynkar Sep 26, 2025
fa442fc
chore: simplify populateLocalizedMeta and add to snapshot version
jessrynkar Sep 26, 2025
3d5e232
chore: ensure status component renders correctly across locales
jessrynkar Sep 29, 2025
465dda0
chore: add translations for localizedMeta
jessrynkar Sep 29, 2025
d02ea01
chore: fix status when autosave true
jessrynkar Sep 29, 2025
c3d313a
Merge branch 'main' into feat/localized-meta
jessrynkar Sep 29, 2025
08bc692
chore: update global snapshot version with localizedMeta
jessrynkar Sep 29, 2025
59c2ced
chore: update e2e test
jessrynkar Sep 29, 2025
d2d3f01
chore: fix failing tests
jessrynkar Sep 29, 2025
b304f05
chore: update e2e test
jessrynkar Sep 29, 2025
4857979
Merge branch 'main' into feat/localized-meta
jessrynkar Sep 30, 2025
3d64cc1
chore: remove localizedMeta fields from the version
jessrynkar Sep 30, 2025
6ee3e73
chore: remove unused import
jessrynkar Sep 30, 2025
e319723
chore: merge conflicts
jessrynkar Oct 6, 2025
b3eb543
chore: add test and update getLocalizedPaths
jessrynkar Oct 7, 2025
b925c1c
improve types
JarrodMFlesch Oct 15, 2025
3669909
adjust populateLocalizedMeta logic and call location
JarrodMFlesch Oct 15, 2025
7ba8864
safely check deletedAt
JarrodMFlesch Oct 15, 2025
55d378a
chore: merge conflict
jessrynkar Oct 16, 2025
8ccd514
chore: fix test
jessrynkar Oct 16, 2025
995b31c
Merge branch 'main' into feat/localized-meta
JarrodMFlesch Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/admin/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -312,19 +312,19 @@ const config = buildConfig({
timezones: {
supportedTimezones: [
{
label: "Europe/Dublin",
value: "Europe/Dublin",
label: 'Europe/Dublin',
value: 'Europe/Dublin',
},
{
label: "Europe/Amsterdam",
value: "Europe/Amsterdam",
label: 'Europe/Amsterdam',
value: 'Europe/Amsterdam',
},
{
label: "Europe/Bucharest",
value: "Europe/Bucharest",
label: 'Europe/Bucharest',
value: 'Europe/Bucharest',
},
],
defaultTimezone: "Europe/Amsterdam",
defaultTimezone: 'Europe/Amsterdam',
},
},
})
Expand Down
23 changes: 23 additions & 0 deletions docs/configuration/localization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ localization: {

Since the filtering happens at the root level of the application and its result is not calculated every time you navigate to a new page, you may want to call `router.refresh` in a custom component that watches when values that affect the result change. In the example above, you would want to do this when `supportedLocales` changes on the tenant document.

## Experimental Options

Experimental options are features that may not be fully stable and may change or be removed in future releases.

These options can be enabled in your Payload Config under the `experimental` key. You can set them like this:

```ts
import { buildConfig } from 'payload'

export default buildConfig({
// ...
experimental: {
localizeMeta: true,
},
})
```

The following experimental options are available related to localization:

| Option | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`localizeMeta`** | **Boolean.** When `true`, shows document metadata (e.g., status, updatedAt) per locale in the admin panel instead of showing the latest overall metadata. Defaults to `false`. |

## Field Localization

Payload Localization works on a **field** level—not a document level. In addition to configuring the base Payload Config to support Localization, you need to specify each field that you would like to localize.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ The following options are available:
| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). |
| **`bin`** | Register custom bin scripts for Payload to execute. [More Details](#custom-bin-scripts). |
| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). |
| **`experimental`** | Configure experimental features for Payload. These may be unstable and may change or be removed in future releases. [More details](../experimental/overview). |
| **`db`** \* | The Database Adapter which will be used by Payload. [More details](../database/overview). |
| **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. |
| **`collections`** | An array of Collections for Payload to manage. [More details](./collections). |
Expand Down
3 changes: 2 additions & 1 deletion docs/database/indexes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const MyCollection: CollectionConfig = {
index: true,
// highlight-end
},
]
],
}
```

Expand Down Expand Up @@ -63,6 +63,7 @@ export const MyCollection: CollectionConfig = {
],
}
```

## Localized fields and MongoDB indexes

When you set `index: true` or `unique: true` on a localized field, MongoDB creates one index **per locale path** (e.g., `slug.en`, `slug.da-dk`, etc.). With many locales and indexed fields, this can quickly approach MongoDB's per-collection index limit.
Expand Down
45 changes: 45 additions & 0 deletions docs/experimental/overview.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: Experimental Features
label: Overview
order: 10
desc: Enable and configure experimental functionality within Payload. These featuresmay be unstable and may change or be removed without notice.
keywords: experimental, unstable, beta, preview, features, configuration, Payload, cms, headless, javascript, node, react, nextjs
---

Experimental features allow you to try out new functionality before it becomes a stable part of Payload. These features may still be in active development, may have incomplete functionality, and can change or be removed in future releases without warning.

## How It Works

Experimental features are configured via the root-level `experimental` property in your [Payload Config](../configuration/overview). This property contains individual feature flags, each flag can be configured independently, allowing you to selectively opt into specific functionality.

```ts
import { buildConfig } from 'payload'

const config = buildConfig({
// ...
experimental: {
localizeMeta: true, // highlight-line
},
})
```

## Experimental Options

The following options are available:

| Option | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`localizeMeta`** | **Boolean.** When `true`, shows document metadata (e.g., status, updatedAt) per locale in the admin panel instead of showing the latest overall metadata. Defaults to `false`. |

This list may change without notice.

## When to Use Experimental Features

You might enable an experimental feature when:

- You want early access to new capabilities before their stable release.
- You can accept the risks of using potentially unstable functionality.
- You are testing new features in a development or staging environment.
- You wish to provide feedback to the Payload team on new functionality.

If you are working on a production application, carefully evaluate whether the benefits outweigh the risks. For most stable applications, it is recommended to wait until the feature is officially released.
18 changes: 9 additions & 9 deletions docs/fields/text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,15 @@ export const ExampleCollection: CollectionConfig = {

The slug field exposes a few top-level config options for easy customization:

| Option | Description |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | To be used as the slug field's name. Defaults to `slug`. |
| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](#slug-overrides). |
| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. |
| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. |
| `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. |
| `position` | The position of the slug field. [More details](./overview#admin-options). |
| `required` | Require the slug field. Defaults to `true`. |
| Option | Description |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | To be used as the slug field's name. Defaults to `slug`. |
| `overrides` | A function that receives the default fields so you can override on a granular level. See example below. [More details](#slug-overrides). |
| `checkboxName` | To be used as the name for the `generateSlug` checkbox field. Defaults to `generateSlug`. |
| `fieldToUse` | The name of the field to use when generating the slug. This field must exist in the same collection. Defaults to `title`. |
| `localized` | Enable localization on the `slug` and `generateSlug` fields. Defaults to `false`. |
| `position` | The position of the slug field. [More details](./overview#admin-options). |
| `required` | Require the slug field. Defaults to `true`. |

### Slug Overrides

Expand Down
11 changes: 11 additions & 0 deletions packages/db-mongodb/src/queryDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,17 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(

for (let i = 0; i < result.docs.length; i++) {
const id = result.docs[i].parent

const localizedMeta = result.docs[i]?.version?.localizedMeta || {}

if (locale) {
if (localizedMeta[locale]) {
result.docs[i].status = localizedMeta[locale].status
result.docs[i].version._status = localizedMeta[locale].status
result.docs[i].version.updatedAt = localizedMeta[locale].updatedAt
}
}

result.docs[i] = result.docs[i].version ?? {}
result.docs[i].id = id
}
Expand Down
22 changes: 14 additions & 8 deletions packages/drizzle/src/queryDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,21 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
where: combinedWhere,
})

return {
...result,
docs: result.docs.map((doc) => {
doc = {
id: doc.parent,
...doc.version,
for (let i = 0; i < result.docs.length; i++) {
const id = result.docs[i].parent
const localizedMeta = result.docs[i]?.version?.localizedMeta || {}

if (locale) {
if (localizedMeta[locale]) {
result.docs[i].status = localizedMeta[locale].status
result.docs[i].version._status = localizedMeta[locale].status
result.docs[i].version.updatedAt = localizedMeta[locale].updatedAt
}
}

return doc
}),
result.docs[i] = result.docs[i].version ?? {}
result.docs[i].id = id
}

return result
}
5 changes: 5 additions & 0 deletions packages/payload/src/collections/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import { authCollectionEndpoints } from '../../auth/endpoints/index.js'
import { getBaseAuthFields } from '../../auth/getAuthFields.js'
import { TimestampsRequired } from '../../errors/TimestampsRequired.js'
import { baseLocalizedMetaFields } from '../../fields/baseFields/baseLocalizedMeta.js'
import { sanitizeFields } from '../../fields/config/sanitize.js'
import { fieldAffectsData } from '../../fields/config/types.js'
import { mergeBaseFields } from '../../fields/mergeBaseFields.js'
Expand Down Expand Up @@ -261,6 +262,10 @@ export const sanitizeCollection = async (
sanitized.fields = mergeBaseFields(sanitized.fields, getBaseAuthFields(sanitized.auth))
}

if (config.localization && collection.versions && config.experimental?.localizeMeta) {
sanitized.fields = mergeBaseFields(sanitized.fields, baseLocalizedMetaFields(config, false))
}

if (collection?.admin?.pagination?.limits?.length) {
sanitized.admin!.pagination!.limits = collection.admin.pagination.limits
}
Expand Down
19 changes: 19 additions & 0 deletions packages/payload/src/collections/operations/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { uploadFiles } from '../../uploads/uploadFiles.js'
import { commitTransaction } from '../../utilities/commitTransaction.js'
import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { populateLocalizedMeta } from '../../utilities/populateLocalizedMeta.js'
import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js'
import { sanitizeSelect } from '../../utilities/sanitizeSelect.js'
import { buildAfterOperation } from './utils.js'
Expand Down Expand Up @@ -137,6 +138,24 @@ export const createOperation = async <
duplicatedFromDocWithLocales = duplicateResult.duplicatedFromDocWithLocales
}

// /////////////////////////////////////
// Handle localized meta
// /////////////////////////////////////

if (
config.experimental?.localizeMeta &&
config.localization &&
data._status &&
config.localization.locales.length > 0
) {
data.localizedMeta = populateLocalizedMeta({
config,
previousMeta: data.localizedMeta,
publishSpecificLocale,
status: data._status,
})
}

// /////////////////////////////////////
// Access
// /////////////////////////////////////
Expand Down
17 changes: 9 additions & 8 deletions packages/payload/src/collections/operations/findByID.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FindOneArgs } from '../../database/types.js'
import type { FindOneArgs, TypeWithVersion } from '../../database/types.js'
import type { CollectionSlug, JoinQuery } from '../../index.js'
import type {
ApplyDisableErrors,
Expand Down Expand Up @@ -29,14 +29,14 @@ import { sanitizeSelect } from '../../utilities/sanitizeSelect.js'
import { replaceWithDraftIfAvailable } from '../../versions/drafts/replaceWithDraftIfAvailable.js'
import { buildAfterOperation } from './utils.js'

export type FindByIDArgs = {
export type FindByIDArgs<TSlug extends CollectionSlug = CollectionSlug> = {
collection: Collection
currentDepth?: number
/**
* You may pass the document data directly which will skip the `db.findOne` database query.
* This is useful if you want to use this endpoint solely for running hooks and populating data.
*/
data?: Record<string, unknown>
data?: DataFromCollectionSlug<TSlug> & TypeWithID
depth?: number
disableErrors?: boolean
draft?: boolean
Expand All @@ -56,7 +56,7 @@ export const findByIDOperation = async <
TDisableErrors extends boolean,
TSelect extends SelectFromCollectionSlug<TSlug>,
>(
incomingArgs: FindByIDArgs,
incomingArgs: FindByIDArgs<TSlug>,
): Promise<ApplyDisableErrors<TransformCollectionWithSelect<TSlug, TSelect>, TDisableErrors>> => {
let args = incomingArgs

Expand Down Expand Up @@ -169,8 +169,9 @@ export const findByIDOperation = async <
throw new NotFound(t)
}

let result: DataFromCollectionSlug<TSlug> =
(args.data as DataFromCollectionSlug<TSlug>) ?? (await req.payload.db.findOne(findOneArgs))!
let result =
(args.data as DataFromCollectionSlug<TSlug>) ??
(await req.payload.db.findOne<DataFromCollectionSlug<TSlug>>(findOneArgs))!

if (!result) {
if (!disableErrors) {
Expand Down Expand Up @@ -231,7 +232,7 @@ export const findByIDOperation = async <
// swallow error
}

result._isLocked = !!lockStatus
result._isLocked = Boolean(lockStatus)
result._userEditing = lockStatus?.user?.value ?? null
}

Expand All @@ -242,7 +243,7 @@ export const findByIDOperation = async <
if (collectionConfig.versions?.drafts && draftEnabled) {
result = await replaceWithDraftIfAvailable({
accessResult,
doc: result,
doc: result as TypeWithVersion<DataFromCollectionSlug<TSlug>>['version'],
entity: collectionConfig,
entityType: 'collection',
overrideAccess,
Expand Down
4 changes: 2 additions & 2 deletions packages/payload/src/collections/operations/local/findByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
TransformCollectionWithSelect,
} from '../../../types/index.js'
import type { CreateLocalReqOptions } from '../../../utilities/createLocalReq.js'
import type { SelectFromCollectionSlug } from '../../config/types.js'
import type { SelectFromCollectionSlug, TypeWithID } from '../../config/types.js'

import { APIError } from '../../../errors/index.js'
import { createLocalReq } from '../../../utilities/createLocalReq.js'
Expand Down Expand Up @@ -45,7 +45,7 @@ export type Options<
* You may pass the document data directly which will skip the `db.findOne` database query.
* This is useful if you want to use this endpoint solely for running hooks and populating data.
*/
data?: Record<string, unknown>
data?: Record<string, unknown> & TypeWithID
/**
* [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const restoreVersionOperation = async <
throw new Forbidden(req.t)
}

if (collectionConfig.trash && doc?.deletedAt) {
if (collectionConfig.trash && doc && 'deletedAt' in doc && doc.deletedAt) {
throw new APIError(
`Cannot restore a version of a trashed document (ID: ${parentDocID}). Restore the document first.`,
httpStatus.FORBIDDEN,
Expand Down
19 changes: 19 additions & 0 deletions packages/payload/src/collections/operations/utilities/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { deepCopyObjectSimple, saveVersion } from '../../../index.js'
import { deleteAssociatedFiles } from '../../../uploads/deleteAssociatedFiles.js'
import { uploadFiles } from '../../../uploads/uploadFiles.js'
import { checkDocumentLockStatus } from '../../../utilities/checkDocumentLockStatus.js'
import { populateLocalizedMeta } from '../../../utilities/populateLocalizedMeta.js'
import { getLatestCollectionVersion } from '../../../versions/getLatestCollectionVersion.js'

export type SharedUpdateDocumentArgs<TSlug extends CollectionSlug> = {
Expand Down Expand Up @@ -142,6 +143,24 @@ export const updateDocument = async <
})
}

// /////////////////////////////////////
// Handle localized meta
// /////////////////////////////////////

if (
config.experimental?.localizeMeta &&
config.localization &&
data._status &&
config.localization.locales.length > 0
) {
data.localizedMeta = populateLocalizedMeta({
config,
previousMeta: originalDoc.localizedMeta,
publishSpecificLocale,
status: data._status,
})
}

// /////////////////////////////////////
// Delete any associated files
// /////////////////////////////////////
Expand Down
10 changes: 10 additions & 0 deletions packages/payload/src/config/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,16 @@ export const createClientConfig = ({

break

case 'experimental':
if (config.experimental) {
clientConfig.experimental = {}
if (config.experimental?.localizeMeta) {
clientConfig.experimental.localizeMeta = config.experimental.localizeMeta
}
}

break

case 'folders':
if (config.folders) {
clientConfig.folders = {
Expand Down
Loading
Loading