diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index 62c3cd63dac..c2ea1af88ba 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -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. diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index dd697723cd6..6f62d6563c7 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -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). | diff --git a/docs/experimental/overview.mdx b/docs/experimental/overview.mdx new file mode 100644 index 00000000000..638e4bfc81a --- /dev/null +++ b/docs/experimental/overview.mdx @@ -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. diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index b073c8ee992..004387b113e 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -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' @@ -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)) + } + if (collection?.admin?.pagination?.limits?.length) { sanitized.admin!.pagination!.limits = collection.admin.pagination.limits } diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index dbb0fd2bf8c..23919d9f7d0 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -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' @@ -140,6 +141,24 @@ export const createOperation = async < duplicatedFromDocWithLocales = duplicateResult.duplicatedFromDocWithLocales } + if ( + config.experimental?.localizeMeta && + config.localization && + config.localization.locales.length > 0 && + locale + ) { + duplicatedFromDocWithLocales._localizedMeta = populateLocalizedMeta({ + config, + locale, + previousMeta: duplicatedFromDocWithLocales._localizedMeta, + publishSpecificLocale, + status: data._status, + }) + + data._localizedMeta = duplicatedFromDocWithLocales._localizedMeta[locale] + duplicatedFromDoc._localizedMeta = duplicatedFromDocWithLocales._localizedMeta[locale] + } + // ///////////////////////////////////// // Access // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 57a51c0abf7..0a3462abc29 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -30,6 +30,7 @@ import { uploadFiles } from '../../../uploads/uploadFiles.js' import { checkDocumentLockStatus } from '../../../utilities/checkDocumentLockStatus.js' import { filterDataToSelectedLocales } from '../../../utilities/filterDataToSelectedLocales.js' import { mergeLocalizedData } from '../../../utilities/mergeLocalizedData.js' +import { populateLocalizedMeta } from '../../../utilities/populateLocalizedMeta.js' import { getLatestCollectionVersion } from '../../../versions/getLatestCollectionVersion.js' export type SharedUpdateDocumentArgs = { @@ -224,6 +225,27 @@ export const updateDocument = async < } } + // ///////////////////////////////////// + // Handle localizedMeta + // ///////////////////////////////////// + + if ( + config.experimental?.localizeMeta && + config.localization && + config.localization.locales.length > 0 + ) { + docWithLocales._localizedMeta = populateLocalizedMeta({ + config, + locale, + previousMeta: docWithLocales._localizedMeta, + publishSpecificLocale, + status: data._status, + }) + + data._localizedMeta = docWithLocales._localizedMeta[locale] + originalDoc._localizedMeta = docWithLocales._localizedMeta[locale] + } + // ///////////////////////////////////// // beforeChange - Fields // ///////////////////////////////////// diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 2d2d4744cc4..3084028d928 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -226,6 +226,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 = { diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index b8493fc708f..06f871ed9bf 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -48,6 +48,7 @@ export const defaults: Omit = { defaultDepth: 2, defaultMaxTextLength: 40000, endpoints: [], + experimental: {}, globals: [], graphQL: { disablePlaygroundInProduction: true, @@ -125,6 +126,7 @@ export const addDefaultsToConfig = (config: Config): Config => { config.defaultDepth = config.defaultDepth ?? 2 config.defaultMaxTextLength = config.defaultMaxTextLength ?? 40000 config.endpoints = config.endpoints ?? [] + config.experimental = config.experimental || {} config.globals = config.globals ?? [] config.graphQL = { disableIntrospectionInProduction: true, diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 0a429c234cf..245edddd669 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -735,6 +735,17 @@ export type AfterErrorHook = ( args: AfterErrorHookArgs, ) => AfterErrorResult | Promise +/** + * Experimental features. + * These may be unstable and may change or be removed in future releases. + */ +export type ExperimentalConfig = { + /** + * 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`. + */ + localizeMeta?: boolean +} + /** * This is the central configuration * @@ -1087,6 +1098,12 @@ export type Config = { email?: EmailAdapter | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] + /** + * Configure experimental features for Payload. + * + * These features may be unstable and may change or be removed in future releases. + */ + experimental?: ExperimentalConfig /** * Options for folder view within the admin panel * diff --git a/packages/payload/src/fields/baseFields/baseLocalizedMeta.ts b/packages/payload/src/fields/baseFields/baseLocalizedMeta.ts new file mode 100644 index 00000000000..851e132b36a --- /dev/null +++ b/packages/payload/src/fields/baseFields/baseLocalizedMeta.ts @@ -0,0 +1,36 @@ +import type { Config, SanitizedConfig } from '../../config/types.js' +import type { Field } from '../config/types.js' +export const baseLocalizedMetaFields = (config: Config | SanitizedConfig): Field[] => { + if (!config.localization || !config.localization.locales) { + return [] + } + + return [ + { + name: '_localizedMeta', + type: 'group', + admin: { + disableBulkEdit: true, + disableListColumn: true, + disableListFilter: true, + hidden: true, + }, + fields: [ + { + name: 'status', + type: 'select', + options: [ + { label: ({ t }: any) => t('version:draft'), value: 'draft' }, + { label: ({ t }: any) => t('version:published'), value: 'published' }, + ], + }, + { + name: 'updatedAt', + type: 'date', + }, + ] as Field[], + label: ({ t }: any) => t('localization:localizedMeta'), + localized: true, + }, + ] +} diff --git a/packages/payload/src/utilities/populateLocalizedMeta.ts b/packages/payload/src/utilities/populateLocalizedMeta.ts new file mode 100644 index 00000000000..cce75fa3ee6 --- /dev/null +++ b/packages/payload/src/utilities/populateLocalizedMeta.ts @@ -0,0 +1,59 @@ +import type { LocalizedMeta } from '../collections/config/types.js' +import type { SanitizedConfig } from '../config/types.js' + +/** + * Returned object can be directly assigned to `data.localizedMeta` when saving a document + */ +export function populateLocalizedMeta(args: { + config: SanitizedConfig + locale: string + previousMeta: LocalizedMeta + publishSpecificLocale?: string + status: 'draft' | 'published' +}): LocalizedMeta { + const { config, locale, previousMeta, publishSpecificLocale, status } = args + + if (!config.localization) { + return {} + } + + const now = new Date().toISOString() + const localizedMeta: LocalizedMeta = {} + + const defaultDraft = (): LocalizedMeta[string] => ({ status: 'draft', updatedAt: now }) + const publishedNow = (): LocalizedMeta[string] => ({ status: 'published', updatedAt: now }) + + for (const code of config.localization.localeCodes) { + const previous = previousMeta?.[code] + + if (status === 'draft') { + if (code === locale) { + // Incoming locale is saved as draft + localizedMeta[code] = defaultDraft() + } else { + // Other locales keep previous state or become draft if none existed + localizedMeta[code] = previous || defaultDraft() + } + continue + } + + if (status === 'published') { + if (publishSpecificLocale) { + if (code === publishSpecificLocale) { + // Only publish the specified locale + localizedMeta[code] = publishedNow() + } else { + // Other locales keep previous state or become draft if none existed + localizedMeta[code] = previous || defaultDraft() + } + continue + } + + // If publishSpecificLocale is false it is publishAll + localizedMeta[code] = publishedNow() + continue + } + } + + return localizedMeta +} diff --git a/test/localization/config.ts b/test/localization/config.ts index b20add234df..694190f70ad 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -63,6 +63,9 @@ export default buildConfigWithDefaults({ baseDir: path.resolve(dirname), }, }, + experimental: { + localizeMeta: true, + }, collections: [ RichTextCollection, BlocksCollection, diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index a61f15fe2c2..a61270f2309 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -1,4 +1,4 @@ -import type { Payload, User, Where } from 'payload' +import type { LocalizationConfig, Payload, User, Where } from 'payload' import path from 'path' import { createLocalReq } from 'payload' @@ -3561,7 +3561,252 @@ describe('Localization', () => { } }) - describe('publish specific locales', () => { + describe('_localizedMeta', () => { + describe('publishing', () => { + it('should publish all', async () => { + // Create draft with published locales + const draft = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + _status: 'published', + text: 'English text', + }, + locale: 'en', + }) + + const mainDoc = await payload.findByID({ + id: draft.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: false, + }) + + expect(mainDoc?._localizedMeta?.en?.status).toEqual('published') + expect(mainDoc?._localizedMeta?.es?.status).toEqual('published') + }) + + it('should publish specific locale', async () => { + // Create draft in English + const draft = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + _status: 'draft', + text: 'English text', + }, + locale: 'en', + }) + + const mainDoc = await payload.findByID({ + id: draft.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: false, + }) + + expect(mainDoc?._localizedMeta?.es.status).toEqual('draft') + expect(mainDoc?._localizedMeta?.en.status).toEqual('draft') + + const versionDoc = await payload.findByID({ + id: draft.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: true, + }) + expect(versionDoc._localizedMeta.es.status).toEqual('draft') + expect(versionDoc._localizedMeta.en.status).toEqual('draft') + + // Publish only English + await payload.update({ + id: draft.id, + collection: allFieldsLocalizedSlug, + data: { + text: 'Published english', + _status: 'published', + }, + locale: 'en', + publishSpecificLocale: 'en', + }) + + const updatedMainDoc = await payload.findByID({ + id: draft.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: true, + }) + + expect(updatedMainDoc._localizedMeta.en.status).toEqual('published') + expect(updatedMainDoc._localizedMeta.es.status).toEqual('draft') + + const updatedVersionDoc = await payload.findByID({ + id: draft.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: true, + }) + expect(updatedVersionDoc._localizedMeta.en.status).toEqual('published') + expect(updatedVersionDoc._localizedMeta.es.status).toEqual('draft') + + const publishedStatusQuery = await payload.find({ + collection: allFieldsLocalizedSlug, + where: { + '_localizedMeta.status': { + equals: 'published', + }, + }, + locale: 'en', + }) + + expect(publishedStatusQuery.docs).toHaveLength(1) + + const draftStatusQuery = await payload.find({ + collection: allFieldsLocalizedSlug, + where: { + '_localizedMeta.status': { + equals: 'draft', + }, + }, + locale: 'es', + draft: true, + }) + + expect(draftStatusQuery.docs).toHaveLength(1) + }) + }) + + describe('unpublishing', () => { + it('should set _localizedMeta to draft when bulk unpublishing', async () => { + // Create published doc + const published = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + _status: 'published', + text: 'English text', + }, + locale: 'en', + }) + + const mainDoc = await payload.findByID({ + id: published.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: false, + }) + expect(mainDoc._localizedMeta.en.status).toEqual('published') + expect(mainDoc._localizedMeta.es.status).toEqual('published') + + // Unpublish all locales + await payload.update({ + id: published.id, + collection: allFieldsLocalizedSlug, + data: { + _status: 'draft', + }, + locale: 'en', + }) + + const updatedMainDoc = await payload.findByID({ + id: published.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: false, + }) + expect(updatedMainDoc._localizedMeta.en.status).toEqual('draft') + expect(updatedMainDoc._localizedMeta.es.status).toEqual('published') + }) + + it('should unpublish specific locale', async () => { + // Create draft with published locales + const draft = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + _status: 'published', + text: 'English text', + }, + locale: 'en', + }) + + const mainDoc = await payload.findByID({ + id: draft.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: false, + }) + expect(mainDoc._localizedMeta.en.status).toEqual('published') + expect(mainDoc._localizedMeta.es.status).toEqual('published') + + // Unpublish only English + await payload.update({ + id: draft.id, + collection: allFieldsLocalizedSlug, + data: { + _status: 'draft', + }, + locale: 'en', + unpublishSpecificLocale: 'en', + }) + + const updatedMainDoc = await payload.findByID({ + id: draft.id, + collection: allFieldsLocalizedSlug, + locale: 'all', + draft: true, + }) + + expect(updatedMainDoc._localizedMeta.en.status).toEqual('draft') + expect(updatedMainDoc._localizedMeta.es.status).toEqual('published') + }) + }) + + describe('querying by _localizedMeta.status', () => { + it('should query correctly after publishing specific locale', async () => { + // Create draft in English + const draft = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + _status: 'draft', + text: 'English text', + }, + locale: 'en', + }) + + await payload.update({ + id: draft.id, + collection: allFieldsLocalizedSlug, + data: { + text: 'Published', + _status: 'published', + }, + locale: 'en', + publishSpecificLocale: 'en', + }) + + const publishedStatusQuery = await payload.find({ + collection: allFieldsLocalizedSlug, + where: { + '_localizedMeta.status': { + equals: 'published', + }, + }, + locale: 'en', + }) + expect(publishedStatusQuery.docs).toHaveLength(1) + + const draftStatusQuery = await payload.find({ + collection: allFieldsLocalizedSlug, + where: { + '_localizedMeta.status': { + equals: 'draft', + }, + }, + draft: true, + locale: 'es', + }) + expect(draftStatusQuery.docs).toHaveLength(1) + }) + }) + }) + + describe('publish locales', () => { describe('collections', () => { it('should publish only the specified locale with correct nesting structure', async () => { // Create draft with all field types in multiple locales