From aba3b4117d94330ddc66786d5dfd91a259fc065f Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Tue, 18 Nov 2025 15:56:15 -0500 Subject: [PATCH 01/27] adds tests and config properties for localizing metadata --- .../src/collections/config/sanitize.ts | 9 +- packages/payload/src/config/defaults.ts | 5 + packages/payload/src/config/types.ts | 17 ++ .../payload/src/globals/config/sanitize.ts | 9 +- packages/payload/src/versions/baseFields.ts | 3 +- .../drafts/replaceWithDraftIfAvailable.ts | 30 ++- packages/payload/src/versions/saveVersion.ts | 8 +- test/_community/payload-types.ts | 29 +-- test/localization/config.ts | 3 + test/localization/int.spec.ts | 191 ++++++++++++++++++ 10 files changed, 284 insertions(+), 20 deletions(-) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index b073c8ee992..5fa2a736862 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -195,7 +195,14 @@ export const sanitizeCollection = async ( sanitized.versions.drafts.validate = false } - sanitized.fields = mergeBaseFields(sanitized.fields, baseVersionFields) + sanitized.fields = mergeBaseFields( + sanitized.fields, + baseVersionFields({ + localizeMetadata: config.localization + ? config.localization?.localizeMetadata || false + : false, + }), + ) } } diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index b8493fc708f..5982183210f 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -147,6 +147,11 @@ export const addDefaultsToConfig = (config: Config): Config => { }, } as JobsConfig config.localization = config.localization ?? false + + if (config.localization && config.experimental?.localizeMetadata) { + config.localization.localizeMetadata = config.localization.localizeMetadata ?? true + } + config.maxDepth = config.maxDepth ?? 10 config.routes = { admin: '/admin', diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 0a429c234cf..572cf223107 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -511,6 +511,12 @@ export type BaseLocalizationConfig = { locales: Locale[] req: PayloadRequest }) => Locale[] | Promise + /** + * If true, status and updatedAt fields will be localized + * + * @default false + */ + localizeMetadata?: boolean } export type LocalizationConfigWithNoLabels = Prettify< @@ -1087,6 +1093,17 @@ export type Config = { email?: EmailAdapter | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] + /** + * Experimental features that are not yet stable. + */ + experimental?: { + /** + * If true, the admin panel will attempt to localize the `updatedAt` and `status` fields. + * + * @default false + */ + localizeMetadata?: boolean + } /** * Options for folder view within the admin panel * diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index d79d3f580a1..baa40ef632c 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -122,7 +122,14 @@ export const sanitizeGlobal = async ( global.versions.drafts.validate = false } - global.fields = mergeBaseFields(global.fields, baseVersionFields) + global.fields = mergeBaseFields( + global.fields, + baseVersionFields({ + localizeMetadata: config.localization + ? config.localization.localizeMetadata || false + : false, + }), + ) } } diff --git a/packages/payload/src/versions/baseFields.ts b/packages/payload/src/versions/baseFields.ts index 91435afc62c..cc1291f0507 100644 --- a/packages/payload/src/versions/baseFields.ts +++ b/packages/payload/src/versions/baseFields.ts @@ -12,7 +12,7 @@ export const statuses: Option[] = [ }, ] -export const baseVersionFields: Field[] = [ +export const baseVersionFields = ({ localizeMetadata }: { localizeMetadata: boolean }): Field[] => [ { name: '_status', type: 'select', @@ -25,6 +25,7 @@ export const baseVersionFields: Field[] = [ defaultValue: 'draft', index: true, label: ({ t }) => t('version:status'), + localized: Boolean(localizeMetadata), options: statuses, }, ] diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts index 25fcc45b285..10d7671b080 100644 --- a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -30,9 +30,9 @@ export const replaceWithDraftIfAvailable = async ({ req, select, }: Arguments): Promise => { - const { locale } = req + const { locale, payload } = req - const queryToBuild: Where = { + let queryToBuild: Where = { and: [ { 'version._status': { @@ -42,6 +42,32 @@ export const replaceWithDraftIfAvailable = async ({ ], } + if (payload.config.localization && payload.config.localization.localizeMetadata) { + if (locale === 'all') { + queryToBuild = { + and: [ + { + or: payload.config.localization.localeCodes.map((localeCode) => ({ + [`version._status.${localeCode}`]: { + equals: 'draft', + }, + })), + }, + ], + } + } else if (locale) { + queryToBuild = { + and: [ + { + [`version._status.${locale}`]: { + equals: 'draft', + }, + }, + ], + } + } + } + if (entityType === 'collection') { queryToBuild.and!.push({ parent: { diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index f00fd9df18b..64d50f544af 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -47,7 +47,13 @@ export const saveVersion = async ({ } & TData = deepCopyObjectSimple(docWithLocales) if (draft) { - versionData._status = 'draft' + if (payload.config.localization && payload.config.localization.localizeMetadata) { + if (req?.locale && payload.config.localization.localeCodes.includes(req.locale)) { + versionData._status[req.locale] = 'draft' + } + } else { + versionData._status = 'draft' + } } if (collection?.timestamps && draft) { diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 39b48742beb..281c37c390a 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -86,8 +86,9 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; + fallbackLocale: null; globals: { menu: Menu; }; @@ -126,7 +127,7 @@ export interface UserAuthOperations { * via the `definition` "posts". */ export interface Post { - id: string; + id: number; title?: string | null; content?: { root: { @@ -151,7 +152,7 @@ export interface Post { * via the `definition` "media". */ export interface Media { - id: string; + id: number; updatedAt: string; createdAt: string; url?: string | null; @@ -195,7 +196,7 @@ export interface Media { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: string; + id: number; key: string; data: | { @@ -212,7 +213,7 @@ export interface PayloadKv { * via the `definition` "users". */ export interface User { - id: string; + id: number; updatedAt: string; createdAt: string; email: string; @@ -236,24 +237,24 @@ export interface User { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: | ({ relationTo: 'posts'; - value: string | Post; + value: number | Post; } | null) | ({ relationTo: 'media'; - value: string | Media; + value: number | Media; } | null) | ({ relationTo: 'users'; - value: string | User; + value: number | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; updatedAt: string; createdAt: string; @@ -263,10 +264,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; key?: string | null; value?: @@ -286,7 +287,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; @@ -420,7 +421,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "menu". */ export interface Menu { - id: string; + id: number; globalText?: string | null; updatedAt?: string | null; createdAt?: string | null; diff --git a/test/localization/config.ts b/test/localization/config.ts index 85ad95dd562..a5ed175d049 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -430,6 +430,9 @@ export default buildConfigWithDefaults({ slug: 'global-text', }, ], + experimental: { + localizeMetadata: true, + }, localization: { filterAvailableLocales: ({ locales }) => { return locales.filter((locale) => locale.code !== 'xx') diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 2d284ff89ee..c9cf20bc9e5 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -3578,6 +3578,197 @@ describe('Localization', () => { expect((allLocalesDoc.g1 as any).es).toBeUndefined() }) }) + + describe('localizeMetadata', () => { + it('should not return fallback status data', async () => { + // TODO: allow fields to opt out of using fallbackLocale + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + text: 'Localized Metadata EN', + _status: 'published', + }, + locale: defaultLocale, + }) + + const esDoc = await payload.findByID({ + locale: spanishLocale, + id: doc.id, + collection: allFieldsLocalizedSlug, + }) + + expect(esDoc._status).toBeUndefined() + }) + + it('should return correct data based on draft arg', async () => { + // NOTE: passes in MongoDB, fails in PG + // -> fails to query on version._status.[localeCode] in `replaceWithDraftIfAvailable` when locale = 'all' + + // create english draft 1 + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + text: 'english draft 1', + _status: 'draft', + }, + draft: true, + locale: defaultLocale, + }) + // update english published 1 + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'english published 1', + _status: 'published', + }, + locale: defaultLocale, + }) + + // create spanish draft 1 + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'spanish draft 1', + _status: 'draft', + }, + draft: true, + locale: spanishLocale, + }) + // update spanish published 1 + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'spanish published 1', + _status: 'published', + }, + locale: spanishLocale, + }) + // update spanish draft 2 + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'spanish draft 2', + _status: 'draft', + }, + draft: true, + locale: spanishLocale, + }) + + const publishedDoc = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + locale: 'all', + draft: false, + }) + + expect(publishedDoc._status!.en).toBe('published') + expect(publishedDoc.text!.en).toBe('english published 1') + expect(publishedDoc._status!.es).toBe('published') + expect(publishedDoc.text!.es).toBe('spanish published 1') + + const latestVersionDoc = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + draft: true, + locale: 'all', + }) + + expect(latestVersionDoc._status!.en).toBe('published') + expect(latestVersionDoc.text!.en).toBe('english published 1') + expect(latestVersionDoc._status!.es).toBe('draft') + expect(latestVersionDoc.text!.es).toBe('spanish draft 2') + }) + + it('should allow querying metadata per locale', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + text: 'Localized Metadata EN', + _status: 'published', + }, + locale: defaultLocale, + }) + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'Localized Metadata ES', + _status: 'draft', + }, + draft: true, + locale: spanishLocale, + }) + + const esPublished = await payload.find({ + locale: spanishLocale, + collection: allFieldsLocalizedSlug, + where: { + and: [ + { + id: { + equals: doc.id, + }, + }, + { + _status: { + equals: 'published', + }, + }, + ], + }, + }) + expect(esPublished.totalDocs).toBe(0) + + const esDraft = await payload.find({ + locale: spanishLocale, + collection: allFieldsLocalizedSlug, + draft: true, + where: { + and: [ + { + id: { + equals: doc.id, + }, + }, + { + _status: { + equals: 'draft', + }, + }, + ], + }, + }) + + expect(esDraft.totalDocs).toBe(1) + expect(esDraft.docs[0]!.text).toBe('Localized Metadata ES') + + const enPublished = await payload.find({ + locale: defaultLocale, + collection: allFieldsLocalizedSlug, + draft: true, + where: { + and: [ + { + id: { + equals: doc.id, + }, + }, + { + _status: { + equals: 'published', + }, + }, + ], + }, + }) + expect(enPublished.totalDocs).toBe(1) + expect(enPublished.docs[0]!.text).toBe('Localized Metadata EN') + }) + }) }) async function createLocalizedPost(data: { From 1185032864bd91bc974efe094cea45cb3f032491 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 19 Nov 2025 10:50:21 -0500 Subject: [PATCH 02/27] adjust where the properties are set --- .../src/collections/config/sanitize.ts | 18 +++++++----- .../payload/src/collections/config/types.ts | 15 ++++++++++ packages/payload/src/config/defaults.ts | 5 ---- packages/payload/src/config/types.ts | 17 ----------- .../payload/src/globals/config/sanitize.ts | 12 +++++--- packages/payload/src/globals/config/types.ts | 15 ++++++++++ packages/payload/src/versions/baseFields.ts | 4 +-- .../drafts/replaceWithDraftIfAvailable.ts | 3 +- packages/payload/src/versions/saveVersion.ts | 6 +++- packages/payload/src/versions/types.ts | 24 ++++++++++++++++ test/_community/payload-types.ts | 28 +++++++++---------- .../collections/AllFields/index.ts | 3 ++ test/localization/config.ts | 3 -- 13 files changed, 99 insertions(+), 54 deletions(-) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 5fa2a736862..c80a6c025f2 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -166,14 +166,20 @@ export const sanitizeCollection = async ( } if (sanitized.versions) { - if (sanitized.versions === true) { - sanitized.versions = { drafts: false, maxPerDoc: 100 } - } - if (sanitized.timestamps === false) { throw new TimestampsRequired(collection) } + if (sanitized.versions === true) { + sanitized.versions = { + drafts: false, + localizeMetadata: Boolean(sanitized.experimental?.localizeMetadata), + maxPerDoc: 100, + } + } + + sanitized.versions.localizeMetadata ??= Boolean(sanitized.experimental?.localizeMetadata) + sanitized.versions.maxPerDoc = typeof sanitized.versions.maxPerDoc === 'number' ? sanitized.versions.maxPerDoc : 100 @@ -198,9 +204,7 @@ export const sanitizeCollection = async ( sanitized.fields = mergeBaseFields( sanitized.fields, baseVersionFields({ - localizeMetadata: config.localization - ? config.localization?.localizeMetadata || false - : false, + localized: sanitized.versions.localizeMetadata, }), ) } diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 6c4d7a153d1..ca75aec8bb7 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -476,6 +476,17 @@ export type CollectionAdminOptions = { useAsTitle?: string } +type CollectionExperimentalOptions = { + /** + * If true and localization is enabled + * - status (when drafts are enabled) will be localized + * - updatedAt (in versions) will be localized + * + * @default false + */ + localizeMetadata?: boolean +} + /** Manage all aspects of a data collection */ export type CollectionConfig = { /** @@ -536,6 +547,10 @@ export type CollectionConfig = { * Custom rest api endpoints, set false to disable all rest endpoints for this collection. */ endpoints?: false | Omit[] + /** + * Experimental features that are not yet stable. + */ + experimental?: CollectionExperimentalOptions fields: Field[] /** * Enables folders for this collection diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index 5982183210f..b8493fc708f 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -147,11 +147,6 @@ export const addDefaultsToConfig = (config: Config): Config => { }, } as JobsConfig config.localization = config.localization ?? false - - if (config.localization && config.experimental?.localizeMetadata) { - config.localization.localizeMetadata = config.localization.localizeMetadata ?? true - } - config.maxDepth = config.maxDepth ?? 10 config.routes = { admin: '/admin', diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 572cf223107..0a429c234cf 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -511,12 +511,6 @@ export type BaseLocalizationConfig = { locales: Locale[] req: PayloadRequest }) => Locale[] | Promise - /** - * If true, status and updatedAt fields will be localized - * - * @default false - */ - localizeMetadata?: boolean } export type LocalizationConfigWithNoLabels = Prettify< @@ -1093,17 +1087,6 @@ export type Config = { email?: EmailAdapter | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] - /** - * Experimental features that are not yet stable. - */ - experimental?: { - /** - * If true, the admin panel will attempt to localize the `updatedAt` and `status` fields. - * - * @default false - */ - localizeMetadata?: boolean - } /** * Options for folder view within the admin panel * diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index baa40ef632c..4a143b68f48 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -99,9 +99,15 @@ export const sanitizeGlobal = async ( if (global.versions) { if (global.versions === true) { - global.versions = { drafts: false, max: 100 } + global.versions = { + drafts: false, + localizeMetadata: Boolean(global.experimental?.localizeMetadata), + max: 100, + } } + global.versions.localizeMetadata ??= Boolean(global.experimental?.localizeMetadata) + global.versions.max = typeof global.versions.max === 'number' ? global.versions.max : 100 if (global.versions.drafts) { @@ -125,9 +131,7 @@ export const sanitizeGlobal = async ( global.fields = mergeBaseFields( global.fields, baseVersionFields({ - localizeMetadata: config.localization - ? config.localization.localizeMetadata || false - : false, + localized: global.versions.localizeMetadata, }), ) } diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index 80c0ade3b24..f4fc8147221 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -165,6 +165,17 @@ export type GlobalAdminOptions = { preview?: GeneratePreviewURL } +type GlobalExperimentalOptions = { + /** + * If true and localization is enabled + * - status (when drafts are enabled) will be localized + * - updatedAt (in versions) will be localized + * + * @default false + */ + localizeMetadata?: boolean +} + export type GlobalConfig = { /** * Do not set this property manually. This is set to true during sanitization, to avoid @@ -185,6 +196,10 @@ export type GlobalConfig = { */ dbName?: DBIdentifierName endpoints?: false | Omit[] + /** + * Experimental features that are not yet stable. + */ + experimental?: GlobalExperimentalOptions fields: Field[] /** * Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks diff --git a/packages/payload/src/versions/baseFields.ts b/packages/payload/src/versions/baseFields.ts index cc1291f0507..e6314ac8b8f 100644 --- a/packages/payload/src/versions/baseFields.ts +++ b/packages/payload/src/versions/baseFields.ts @@ -12,7 +12,7 @@ export const statuses: Option[] = [ }, ] -export const baseVersionFields = ({ localizeMetadata }: { localizeMetadata: boolean }): Field[] => [ +export const baseVersionFields = ({ localized }: { localized: boolean }): Field[] => [ { name: '_status', type: 'select', @@ -25,7 +25,7 @@ export const baseVersionFields = ({ localizeMetadata }: { localizeMetadata: bool defaultValue: 'draft', index: true, label: ({ t }) => t('version:status'), - localized: Boolean(localizeMetadata), + localized: Boolean(localized), options: statuses, }, ] diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts index 10d7671b080..7244f5389b6 100644 --- a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -42,8 +42,9 @@ export const replaceWithDraftIfAvailable = async ({ ], } - if (payload.config.localization && payload.config.localization.localizeMetadata) { + if (payload.config.localization && entity.versions.localizeMetadata) { if (locale === 'all') { + // TODO: update our drizzle logic to support this type of query queryToBuild = { and: [ { diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index 64d50f544af..860b89daf2a 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -46,8 +46,12 @@ export const saveVersion = async ({ updatedAt?: string } & TData = deepCopyObjectSimple(docWithLocales) + const localizedStatus = collection + ? collection.versions.drafts && collection.versions.localizeMetadata + : global!.versions.drafts && global!.versions.localizeMetadata + if (draft) { - if (payload.config.localization && payload.config.localization.localizeMetadata) { + if (payload.config.localization && localizedStatus) { if (req?.locale && payload.config.localization.localeCodes.includes(req.locale)) { versionData._status[req.locale] = 'draft' } diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index efbc4e3c954..b4a54eae948 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -74,6 +74,12 @@ export type IncomingCollectionVersions = { * To enable, set to true or pass an object with draft options. */ drafts?: boolean | IncomingDrafts + /** + * If true, status and updatedAt will be localized + * + * @default false + */ + localizeMetadata?: boolean /** * Use this setting to control how many versions to keep on a document by document basis. * Must be an integer. Use 0 to save all versions. @@ -89,6 +95,12 @@ export interface SanitizedCollectionVersions extends Omit | PayloadMigrationsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; fallbackLocale: null; globals: { @@ -127,7 +127,7 @@ export interface UserAuthOperations { * via the `definition` "posts". */ export interface Post { - id: number; + id: string; title?: string | null; content?: { root: { @@ -152,7 +152,7 @@ export interface Post { * via the `definition` "media". */ export interface Media { - id: number; + id: string; updatedAt: string; createdAt: string; url?: string | null; @@ -196,7 +196,7 @@ export interface Media { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: number; + id: string; key: string; data: | { @@ -213,7 +213,7 @@ export interface PayloadKv { * via the `definition` "users". */ export interface User { - id: number; + id: string; updatedAt: string; createdAt: string; email: string; @@ -237,24 +237,24 @@ export interface User { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'posts'; - value: number | Post; + value: string | Post; } | null) | ({ relationTo: 'media'; - value: number | Media; + value: string | Media; } | null) | ({ relationTo: 'users'; - value: number | User; + value: string | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; updatedAt: string; createdAt: string; @@ -264,10 +264,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: number; + id: string; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; key?: string | null; value?: @@ -287,7 +287,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string; @@ -421,7 +421,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "menu". */ export interface Menu { - id: number; + id: string; globalText?: string | null; updatedAt?: string | null; createdAt?: string | null; diff --git a/test/localization/collections/AllFields/index.ts b/test/localization/collections/AllFields/index.ts index bd6ac5cf469..9f50944f766 100644 --- a/test/localization/collections/AllFields/index.ts +++ b/test/localization/collections/AllFields/index.ts @@ -7,6 +7,9 @@ export const AllFieldsLocalized: CollectionConfig = { admin: { useAsTitle: 'text', }, + experimental: { + localizeMetadata: true, + }, fields: [ // Simple localized fields { diff --git a/test/localization/config.ts b/test/localization/config.ts index a5ed175d049..85ad95dd562 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -430,9 +430,6 @@ export default buildConfigWithDefaults({ slug: 'global-text', }, ], - experimental: { - localizeMetadata: true, - }, localization: { filterAvailableLocales: ({ locales }) => { return locales.filter((locale) => locale.code !== 'xx') From 35f8cbb377d5a23a9aac957bf2e01456f474bdd2 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 19 Nov 2025 15:50:32 +0000 Subject: [PATCH 03/27] chore: add failing localized status test --- test/localization/int.spec.ts | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index c9cf20bc9e5..0dd444b3447 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -3768,6 +3768,50 @@ describe('Localization', () => { expect(enPublished.totalDocs).toBe(1) expect(enPublished.docs[0]!.text).toBe('Localized Metadata EN') }) + + it('should preserve published data when publishing other locales', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + text: 'Published EN', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'New Draft EN', + _status: 'draft', + }, + draft: true, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'Published ES', + _status: 'published', + }, + locale: spanishLocale, + }) + + const finalDoc = await payload.findByID({ + locale: 'all', + id: doc.id, + collection: allFieldsLocalizedSlug, + draft: false, + }) + + expect(finalDoc._status!.es).toBe('published') + expect(finalDoc.text!.es).toBe('Published ES') + expect(finalDoc._status!.en).toBe('published') + expect(finalDoc.text!.en).toBe('Published EN') + }) }) }) From a49a46f083360d814b1cf40739685b69b10481d7 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 19 Nov 2025 14:05:59 -0500 Subject: [PATCH 04/27] fixes data loss test --- .../operations/utilities/update.ts | 22 +- .../src/utilities/mergeLocalizedData.spec.ts | 751 ++++++++++++++++++ .../src/utilities/mergeLocalizedData.ts | 308 +++++++ 3 files changed, 1080 insertions(+), 1 deletion(-) create mode 100644 packages/payload/src/utilities/mergeLocalizedData.spec.ts create mode 100644 packages/payload/src/utilities/mergeLocalizedData.ts diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index c50f5d93bcf..42f7789008c 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -34,6 +34,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 { mergeLocalizedData } from '../../../utilities/mergeLocalizedData.js' import { getLatestCollectionVersion } from '../../../versions/getLatestCollectionVersion.js' export type SharedUpdateDocumentArgs = { @@ -276,7 +277,7 @@ export const updateDocument = async < // Handle potential password update // ///////////////////////////////////// - const dataToUpdate: JsonObject = { ...result } + let dataToUpdate: JsonObject = { ...result } if (shouldSavePassword && typeof password === 'string') { const { hash, salt } = await generatePasswordSaltHash({ @@ -297,6 +298,25 @@ export const updateDocument = async < if (!isSavingDraft) { // Ensure updatedAt date is always updated dataToUpdate.updatedAt = new Date().toISOString() + if (collectionConfig.versions.localizeMetadata) { + const mainDoc = await payload.db.findOne>({ + collection: collectionConfig.slug, + req, + where: { + id: { + equals: id, + }, + }, + }) + + dataToUpdate = mergeLocalizedData({ + configBlockReferences: config.blocks, + dataWithLocales: dataToUpdate || {}, + docWithLocales: mainDoc || {}, + fields: collectionConfig.fields, + selectedLocales: [locale], + }) + } result = await req.payload.db.updateOne({ id, collection: collectionConfig.slug, diff --git a/packages/payload/src/utilities/mergeLocalizedData.spec.ts b/packages/payload/src/utilities/mergeLocalizedData.spec.ts new file mode 100644 index 00000000000..4b3b7a87040 --- /dev/null +++ b/packages/payload/src/utilities/mergeLocalizedData.spec.ts @@ -0,0 +1,751 @@ +import type { Field } from '../fields/config/types.js' + +import { mergeLocalizedData } from './mergeLocalizedData.js' + +describe('mergeLocalizedData', () => { + const selectedLocales = ['en'] + const configBlockReferences = [] + + describe('simple fields', () => { + it('should merge localized field values for selected locales', () => { + const fields: Field[] = [ + { + name: 'title', + type: 'text', + localized: true, + }, + ] + + const docWithLocales = { + title: { + en: 'English Title', + es: 'Spanish Title', + de: 'German Title', + }, + } + + const dataWithLocales = { + title: { + en: 'Updated English Title', + es: 'Updated Spanish Title', + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.title).toEqual({ + en: 'Updated English Title', + es: 'Spanish Title', + de: 'German Title', + }) + }) + + it('should keep doc value for non-localized fields', () => { + const fields: Field[] = [ + { + name: 'title', + type: 'text', + localized: false, + }, + ] + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales: { + title: 'New Title', + }, + docWithLocales: { + title: 'Old Title', + }, + fields, + selectedLocales, + }) + + expect(result.title).toBe('New Title') + + const missingData = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales: {}, + docWithLocales: { + title: 'Old Title', + }, + fields, + selectedLocales, + }) + + expect(missingData.title).toBe('Old Title') + + const updatedData = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales: { + title: 'Updated Title', + }, + docWithLocales: {}, + fields, + selectedLocales, + }) + + expect(updatedData.title).toBe('Updated Title') + }) + }) + + describe('groups', () => { + it('should merge localized group with locale keys at top level', () => { + const fields: Field[] = [ + { + name: 'meta', + type: 'group', + localized: true, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'description', + type: 'text', + }, + ], + }, + ] + + const docWithLocales = { + meta: { + en: { + title: 'EN Title', + description: 'EN Desc', + }, + es: { + title: 'ES Title', + description: 'ES Desc', + }, + }, + } + + const dataWithLocales = { + meta: { + en: { + title: 'Updated EN Title', + description: 'Updated EN Desc', + }, + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.meta).toEqual({ + en: { + title: 'Updated EN Title', + description: 'Updated EN Desc', + }, + es: { + title: 'ES Title', + description: 'ES Desc', + }, + }) + }) + + it('should handle non-localized group with localized children', () => { + const fields: Field[] = [ + { + name: 'meta', + type: 'group', + localized: false, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + }, + { + name: 'version', + type: 'number', + localized: false, + }, + ], + }, + ] + + const docWithLocales = { + meta: { + title: { + en: 'EN Title', + es: 'ES Title', + }, + version: 1, + }, + } + + const dataWithLocales = { + meta: { + title: { + en: 'Updated EN Title', + }, + version: 2, + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.meta).toEqual({ + title: { + en: 'Updated EN Title', + es: 'ES Title', + }, + version: 2, + }) + }) + }) + + describe('arrays', () => { + it('should merge localized array with locale keys at top level', () => { + const fields: Field[] = [ + { + name: 'items', + type: 'array', + localized: true, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + ] + + const docWithLocales = { + items: { + en: [{ name: 'EN Item 1' }, { name: 'EN Item 2' }], + es: [{ name: 'ES Item 1' }], + }, + } + + const dataWithLocales = { + items: { + en: [{ name: 'Updated EN Item 1' }, { name: 'Updated EN Item 2' }], + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.items).toEqual({ + en: [{ name: 'Updated EN Item 1' }, { name: 'Updated EN Item 2' }], + es: [{ name: 'ES Item 1' }], + }) + }) + + it('should handle non-localized array with localized children', () => { + const fields: Field[] = [ + { + name: 'items', + type: 'array', + localized: false, + fields: [ + { + name: 'name', + type: 'text', + localized: true, + }, + ], + }, + ] + + const docWithLocales = { + items: [ + { + name: { + en: 'EN Item 1', + es: 'ES Item 1', + }, + }, + { + name: { + en: 'EN Item 2', + es: 'ES Item 2', + }, + }, + ], + } + + const dataWithLocales = { + items: [ + { + name: { + en: 'Updated EN Item 1', + }, + }, + { + name: { + en: 'Updated EN Item 2', + }, + }, + ], + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.items).toEqual([ + { + name: { + en: 'Updated EN Item 1', + es: 'ES Item 1', + }, + }, + { + name: { + en: 'Updated EN Item 2', + es: 'ES Item 2', + }, + }, + ]) + }) + }) + + describe('blocks', () => { + it('should merge localized blocks with locale keys at top level', () => { + const fields: Field[] = [ + { + name: 'content', + type: 'blocks', + localized: true, + blocks: [ + { + slug: 'text', + fields: [ + { + name: 'text', + type: 'text', + }, + ], + }, + ], + }, + ] + + const docWithLocales = { + content: { + en: [{ blockType: 'text', text: 'EN Text' }], + es: [{ blockType: 'text', text: 'ES Text' }], + }, + } + + const dataWithLocales = { + content: { + en: [{ blockType: 'text', text: 'Updated EN Text' }], + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.content).toEqual({ + en: [{ blockType: 'text', text: 'Updated EN Text' }], + es: [{ blockType: 'text', text: 'ES Text' }], + }) + }) + + it('should handle blocks with nested arrays', () => { + const fields: Field[] = [ + { + name: 'content', + type: 'blocks', + localized: true, + blocks: [ + { + slug: 'nested', + fields: [ + { + name: 'items', + type: 'array', + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + ], + }, + ], + }, + ] + + const docWithLocales = { + content: { + en: [ + { + blockType: 'nested', + items: [{ name: 'EN Item 1' }], + }, + ], + es: [ + { + blockType: 'nested', + items: [{ name: 'ES Item 1' }], + }, + ], + }, + } + + const dataWithLocales = { + content: { + en: [ + { + blockType: 'nested', + items: [{ name: 'Updated EN Item 1' }], + }, + ], + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.content).toEqual({ + en: [ + { + blockType: 'nested', + items: [{ name: 'Updated EN Item 1' }], + }, + ], + es: [ + { + blockType: 'nested', + items: [{ name: 'ES Item 1' }], + }, + ], + }) + }) + }) + + describe('tabs', () => { + it('should merge localized named tabs with locale keys at top level', () => { + const fields: Field[] = [ + { + type: 'tabs', + tabs: [ + { + name: 'meta', + localized: true, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + }, + ], + }, + ] + + const docWithLocales = { + meta: { + en: { + title: 'EN Title', + }, + es: { + title: 'ES Title', + }, + }, + } + + const dataWithLocales = { + meta: { + en: { + title: 'Updated EN Title', + }, + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.meta).toEqual({ + en: { + title: 'Updated EN Title', + }, + es: { + title: 'ES Title', + }, + }) + }) + + it('should handle unnamed tabs with localized fields', () => { + const fields: Field[] = [ + { + type: 'tabs', + tabs: [ + { + label: 'tab1', + fields: [ + { + name: 'title', + type: 'text', + localized: true, + }, + ], + }, + ], + }, + ] + + const docWithLocales = { + title: { + en: 'EN Title', + es: 'ES Title', + }, + } + + const dataWithLocales = { + title: { + en: 'Updated EN Title', + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.title).toEqual({ + en: 'Updated EN Title', + es: 'ES Title', + }) + }) + }) + + describe('deeply nested structures', () => { + it('should handle multiple levels of nesting with locale keys only at topmost localized field', () => { + const fields: Field[] = [ + { + name: 'outer', + type: 'group', + localized: true, + fields: [ + { + name: 'inner', + type: 'group', + localized: false, + fields: [ + { + name: 'items', + type: 'array', + localized: false, + fields: [ + { + name: 'text', + type: 'text', + localized: false, + }, + ], + }, + ], + }, + ], + }, + ] + + const docWithLocales = { + outer: { + en: { + inner: { + items: [{ text: 'EN Item 1' }], + }, + }, + es: { + inner: { + items: [{ text: 'ES Item 1' }], + }, + }, + }, + } + + const dataWithLocales = { + outer: { + en: { + inner: { + items: [{ text: 'Updated EN Item 1' }], + }, + }, + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales, + }) + + expect(result.outer).toEqual({ + en: { + inner: { + items: [{ text: 'Updated EN Item 1' }], + }, + }, + es: { + inner: { + items: [{ text: 'ES Item 1' }], + }, + }, + }) + }) + }) + + describe('multiple selected locales', () => { + it('should merge multiple locales when selected', () => { + const fields: Field[] = [ + { + name: 'title', + type: 'text', + localized: true, + }, + ] + + const docWithLocales = { + title: { + en: 'EN Title', + es: 'ES Title', + de: 'DE Title', + fr: 'FR Title', + }, + } + + const dataWithLocales = { + title: { + en: 'Updated EN Title', + es: 'Updated ES Title', + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales: ['en', 'es'], + }) + + expect(result.title).toEqual({ + en: 'Updated EN Title', + es: 'Updated ES Title', + de: 'DE Title', + fr: 'FR Title', + }) + }) + }) + + describe('pass through fields, rows, collapsibles, unnamed tabs, unnamed groups', () => { + it('should not lose merged locale data when processing unnamed tabs', () => { + const fields: Field[] = [ + { + name: 'title', + type: 'text', + localized: true, + }, + { + type: 'tabs', + tabs: [ + { + label: 'Other Fields', + fields: [ + { + name: 'description', + type: 'text', + localized: true, + }, + ], + }, + ], + }, + ] + + const docWithLocales = { + title: { + en: 'English Title', + }, + description: { + en: 'English Description', + }, + } + + const dataWithLocales = { + title: { + en: 'English Title', + es: 'Spanish Title', + }, + description: { + en: 'English Description', + es: 'Spanish Description', + }, + } + + const result = mergeLocalizedData({ + configBlockReferences: [], + dataWithLocales, + docWithLocales, + fields, + selectedLocales: ['es'], + }) + + expect(result.title).toEqual({ + en: 'English Title', + es: 'Spanish Title', + }) + + expect(result.description).toEqual({ + en: 'English Description', + es: 'Spanish Description', + }) + }) + }) +}) diff --git a/packages/payload/src/utilities/mergeLocalizedData.ts b/packages/payload/src/utilities/mergeLocalizedData.ts new file mode 100644 index 00000000000..834b22a9120 --- /dev/null +++ b/packages/payload/src/utilities/mergeLocalizedData.ts @@ -0,0 +1,308 @@ +import type { Block, Field, FlattenedBlock } from '../fields/config/types.js' +import type { SanitizedConfig } from '../index.js' +import type { JsonObject } from '../types/index.js' + +import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js' + +type MergeDataToSelectedLocalesArgs = { + configBlockReferences: SanitizedConfig['blocks'] + dataWithLocales: JsonObject + docWithLocales: JsonObject + fields: Field[] + parentIsLocalized?: boolean + selectedLocales: string[] +} + +/** + * Merges data from dataWithLocales onto docWithLocales for specified locales. + * For localized fields, merges only the specified locales while preserving others. + * For non-localized fields, keeps existing values from docWithLocales unchanged. + * Returns a new object without mutating the original. + */ +export function mergeLocalizedData({ + configBlockReferences, + dataWithLocales, + docWithLocales, + fields, + parentIsLocalized = false, + selectedLocales, +}: MergeDataToSelectedLocalesArgs): JsonObject { + if (!docWithLocales || typeof docWithLocales !== 'object') { + return dataWithLocales || docWithLocales + } + + const result: JsonObject = { ...docWithLocales } + + for (const field of fields) { + if (fieldAffectsData(field)) { + // If the parent is localized, all children are inherently "localized" + if (parentIsLocalized && dataWithLocales[field.name]) { + result[field.name] = dataWithLocales[field.name] + continue + } + + const fieldIsLocalized = fieldShouldBeLocalized({ field, parentIsLocalized }) + + switch (field.type) { + case 'array': { + if (field.name in dataWithLocales) { + const newValue = dataWithLocales[field.name] + const existingValue = docWithLocales[field.name] + + if (fieldIsLocalized) { + // If localized, handle locale keys + if (newValue && typeof newValue === 'object' && !Array.isArray(newValue)) { + const updatedArray: Record = { ...(existingValue || {}) } + + for (const locale of selectedLocales) { + if (locale in newValue) { + updatedArray[locale] = newValue[locale] + } + } + + result[field.name] = updatedArray + } else { + // Preserve existing value if new value is not a valid object + result[field.name] = existingValue + } + } else if (Array.isArray(newValue)) { + // Non-localized array - still process children for any localized fields + result[field.name] = newValue.map((newItem: JsonObject, index: number) => { + const existingItem = existingValue?.[index] || {} + + return mergeLocalizedData({ + configBlockReferences, + dataWithLocales: newItem, + docWithLocales: existingItem, + fields: field.fields, + parentIsLocalized, + selectedLocales, + }) + }) + } + } + break + } + + case 'blocks': { + if (field.name in dataWithLocales) { + const newValue = dataWithLocales[field.name] + const existingValue = docWithLocales[field.name] + + if (fieldIsLocalized) { + // If localized, handle locale keys + if (newValue && typeof newValue === 'object' && !Array.isArray(newValue)) { + const updatedData: Record = { ...(existingValue || {}) } + + for (const locale of selectedLocales) { + if (locale in newValue) { + updatedData[locale] = newValue[locale] + } + } + + result[field.name] = updatedData + } else { + // Preserve existing value if new value is not a valid object + result[field.name] = existingValue + } + } else if (Array.isArray(newValue)) { + // Non-localized blocks - still process children for any localized fields + result[field.name] = newValue.map((newBlockData: JsonObject, index: number) => { + let block: Block | FlattenedBlock | undefined + if (configBlockReferences && field.blockReferences) { + for (const blockOrReference of field.blockReferences) { + if (typeof blockOrReference === 'string') { + block = configBlockReferences.find((b) => b.slug === newBlockData.blockType) + } else { + block = blockOrReference + } + } + } else if (field.blocks) { + block = field.blocks.find((b) => b.slug === newBlockData.blockType) + } + + if (block) { + const blockData = + Array.isArray(existingValue) && existingValue[index] + ? (existingValue[index] as JsonObject) + : {} + + return mergeLocalizedData({ + configBlockReferences, + dataWithLocales: newBlockData, + docWithLocales: blockData, + fields: block?.fields || [], + parentIsLocalized, + selectedLocales, + }) + } + + return newBlockData + }) + } + } + break + } + + case 'group': { + if (fieldAffectsData(field) && field.name) { + // Named groups create a nested data structure + if (field.name in dataWithLocales) { + const newValue = dataWithLocales[field.name] + const existingValue = docWithLocales[field.name] + + if (fieldIsLocalized) { + if (newValue && typeof newValue === 'object' && !Array.isArray(newValue)) { + const groupData: Record = { ...(existingValue || {}) } + + for (const locale of selectedLocales) { + if (locale in newValue && typeof newValue[locale] === 'object') { + groupData[locale] = newValue[locale] + } + } + + result[field.name] = groupData + } else { + // Preserve existing value if new value is not a valid object + result[field.name] = existingValue + } + } else if (typeof newValue === 'object' && !Array.isArray(newValue)) { + // Non-localized group - still process children for any localized fields + result[field.name] = mergeLocalizedData({ + configBlockReferences, + dataWithLocales: newValue, + docWithLocales: existingValue || {}, + fields: field.fields, + parentIsLocalized, + selectedLocales, + }) + } + } + } else { + // Unnamed groups pass through the same data level + const merged = mergeLocalizedData({ + configBlockReferences, + dataWithLocales, + docWithLocales: result, // Use current result to avoid re-processing already-handled fields + fields: field.fields, + parentIsLocalized, + selectedLocales, + }) + Object.assign(result, merged) + } + break + } + + default: { + // For all other data-affecting fields (text, number, select, etc.) + if (fieldIsLocalized) { + if (field.name in dataWithLocales) { + const newValue = dataWithLocales[field.name] + const existingValue = docWithLocales[field.name] || {} + + // If localized, handle locale keys + if (newValue && typeof newValue === 'object' && !Array.isArray(newValue)) { + const merged: Record = { ...existingValue } + + for (const locale of selectedLocales) { + if (locale in newValue) { + merged[locale] = newValue[locale] + } + } + + result[field.name] = merged + } else if (parentIsLocalized) { + // Child of localized parent - replace with new value + result[field.name] = newValue + } else { + // Preserve existing value if new value is not a valid object + result[field.name] = existingValue + } + } + } else if (parentIsLocalized) { + result[field.name] = dataWithLocales[field.name] + } else { + result[field.name] = + field.name in dataWithLocales + ? dataWithLocales[field.name] + : docWithLocales[field.name] + } + break + } + } + } else { + // Layout-only fields that don't affect data structure + switch (field.type) { + case 'collapsible': + case 'row': { + // These pass through the same data level + const merged = mergeLocalizedData({ + configBlockReferences, + dataWithLocales, + docWithLocales: result, // Use current result to avoid re-processing already-handled fields + fields: field.fields, + parentIsLocalized, + selectedLocales, + }) + Object.assign(result, merged) + break + } + + case 'tabs': { + for (const tab of field.tabs) { + if (tabHasName(tab)) { + // Named tabs create a nested data structure and can be localized + const tabIsLocalized = fieldShouldBeLocalized({ field: tab, parentIsLocalized }) + + if (tab.name in dataWithLocales) { + const newValue = dataWithLocales[tab.name] + const existingValue = docWithLocales[tab.name] + + if (tabIsLocalized) { + if (newValue && typeof newValue === 'object' && !Array.isArray(newValue)) { + const merged: Record = { ...(existingValue || {}) } + + for (const locale of selectedLocales) { + if (locale in newValue && typeof newValue[locale] === 'object') { + merged[locale] = newValue[locale] + } + } + + result[tab.name] = merged + } else { + // Preserve existing value if new value is not a valid object + result[tab.name] = existingValue + } + } else if (typeof newValue === 'object' && !Array.isArray(newValue)) { + // Non-localized tab - still process children for any localized fields + result[tab.name] = mergeLocalizedData({ + configBlockReferences, + dataWithLocales: newValue as JsonObject, + docWithLocales: existingValue || {}, + fields: tab.fields, + parentIsLocalized, + selectedLocales, + }) + } + } + } else { + // Unnamed tabs pass through the same data level + const merged = mergeLocalizedData({ + configBlockReferences, + dataWithLocales, + docWithLocales: result, // Use current result to avoid re-processing already-handled fields + fields: tab.fields, + parentIsLocalized, + selectedLocales, + }) + Object.assign(result, merged) + } + } + break + } + } + } + } + + return result +} From b3a08415962746ce559c4ba625ae43a51990137f Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 19 Nov 2025 14:29:46 -0500 Subject: [PATCH 05/27] adjust experimental placement --- .../src/collections/config/sanitize.ts | 7 +- .../payload/src/collections/config/types.ts | 15 -- packages/payload/src/config/types.ts | 3 + .../payload/src/globals/config/sanitize.ts | 6 +- packages/payload/src/globals/config/types.ts | 15 -- packages/payload/src/versions/saveVersion.ts | 4 +- packages/payload/src/versions/types.ts | 36 ++--- .../collections/AllFields/index.ts | 7 +- test/localization/payload-types.ts | 152 +++++++++--------- 9 files changed, 101 insertions(+), 144 deletions(-) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index c80a6c025f2..981a94c654f 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -173,13 +173,10 @@ export const sanitizeCollection = async ( if (sanitized.versions === true) { sanitized.versions = { drafts: false, - localizeMetadata: Boolean(sanitized.experimental?.localizeMetadata), maxPerDoc: 100, } } - sanitized.versions.localizeMetadata ??= Boolean(sanitized.experimental?.localizeMetadata) - sanitized.versions.maxPerDoc = typeof sanitized.versions.maxPerDoc === 'number' ? sanitized.versions.maxPerDoc : 100 @@ -191,6 +188,8 @@ export const sanitizeCollection = async ( } } + sanitized.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) + if (sanitized.versions.drafts.autosave === true) { sanitized.versions.drafts.autosave = { interval: versionDefaults.autosaveInterval, @@ -204,7 +203,7 @@ export const sanitizeCollection = async ( sanitized.fields = mergeBaseFields( sanitized.fields, baseVersionFields({ - localized: sanitized.versions.localizeMetadata, + localized: sanitized.versions.drafts.localizeStatus, }), ) } diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index ca75aec8bb7..6c4d7a153d1 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -476,17 +476,6 @@ export type CollectionAdminOptions = { useAsTitle?: string } -type CollectionExperimentalOptions = { - /** - * If true and localization is enabled - * - status (when drafts are enabled) will be localized - * - updatedAt (in versions) will be localized - * - * @default false - */ - localizeMetadata?: boolean -} - /** Manage all aspects of a data collection */ export type CollectionConfig = { /** @@ -547,10 +536,6 @@ export type CollectionConfig = { * Custom rest api endpoints, set false to disable all rest endpoints for this collection. */ endpoints?: false | Omit[] - /** - * Experimental features that are not yet stable. - */ - experimental?: CollectionExperimentalOptions fields: Field[] /** * Enables folders for this collection diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 0a429c234cf..e13719303d1 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1087,6 +1087,9 @@ export type Config = { email?: EmailAdapter | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] + experimental?: { + localizeStatus?: boolean + } /** * Options for folder view within the admin panel * diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 4a143b68f48..272db90cd1e 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -101,13 +101,10 @@ export const sanitizeGlobal = async ( if (global.versions === true) { global.versions = { drafts: false, - localizeMetadata: Boolean(global.experimental?.localizeMetadata), max: 100, } } - global.versions.localizeMetadata ??= Boolean(global.experimental?.localizeMetadata) - global.versions.max = typeof global.versions.max === 'number' ? global.versions.max : 100 if (global.versions.drafts) { @@ -117,6 +114,7 @@ export const sanitizeGlobal = async ( validate: false, } } + global.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) if (global.versions.drafts.autosave === true) { global.versions.drafts.autosave = { @@ -131,7 +129,7 @@ export const sanitizeGlobal = async ( global.fields = mergeBaseFields( global.fields, baseVersionFields({ - localized: global.versions.localizeMetadata, + localized: global.versions.drafts.localizeStatus, }), ) } diff --git a/packages/payload/src/globals/config/types.ts b/packages/payload/src/globals/config/types.ts index f4fc8147221..80c0ade3b24 100644 --- a/packages/payload/src/globals/config/types.ts +++ b/packages/payload/src/globals/config/types.ts @@ -165,17 +165,6 @@ export type GlobalAdminOptions = { preview?: GeneratePreviewURL } -type GlobalExperimentalOptions = { - /** - * If true and localization is enabled - * - status (when drafts are enabled) will be localized - * - updatedAt (in versions) will be localized - * - * @default false - */ - localizeMetadata?: boolean -} - export type GlobalConfig = { /** * Do not set this property manually. This is set to true during sanitization, to avoid @@ -196,10 +185,6 @@ export type GlobalConfig = { */ dbName?: DBIdentifierName endpoints?: false | Omit[] - /** - * Experimental features that are not yet stable. - */ - experimental?: GlobalExperimentalOptions fields: Field[] /** * Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index 860b89daf2a..eb37c3c6d0c 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -47,8 +47,8 @@ export const saveVersion = async ({ } & TData = deepCopyObjectSimple(docWithLocales) const localizedStatus = collection - ? collection.versions.drafts && collection.versions.localizeMetadata - : global!.versions.drafts && global!.versions.localizeMetadata + ? collection.versions.drafts && collection.versions.drafts.localizeStatus + : global!.versions.drafts && global!.versions.drafts.localizeStatus if (draft) { if (payload.config.localization && localizedStatus) { diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index b4a54eae948..3013d3c46af 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -38,6 +38,12 @@ export type IncomingDrafts = { * To enable, set to true or pass an object with options. */ autosave?: Autosave | boolean + /** + * If true, status will be localized + * + * @default false + */ + localizeStatus?: boolean /** * Allow for editors to schedule publish / unpublish events in the future. */ @@ -56,6 +62,12 @@ export type SanitizedDrafts = { * To enable, set to true or pass an object with options. */ autosave: Autosave | false + /** + * If true, status will be localized + * + * @default false + */ + localizeStatus?: boolean /** * Allow for editors to schedule publish / unpublish events in the future. */ @@ -74,12 +86,6 @@ export type IncomingCollectionVersions = { * To enable, set to true or pass an object with draft options. */ drafts?: boolean | IncomingDrafts - /** - * If true, status and updatedAt will be localized - * - * @default false - */ - localizeMetadata?: boolean /** * Use this setting to control how many versions to keep on a document by document basis. * Must be an integer. Use 0 to save all versions. @@ -95,12 +101,6 @@ export interface SanitizedCollectionVersions extends Omit | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; fallbackLocale: | ('false' | 'none' | 'null') @@ -172,7 +172,7 @@ export interface UserAuthOperations { * via the `definition` "richText". */ export interface RichText { - id: string; + id: number; richText?: | { [k: string]: unknown; @@ -201,7 +201,7 @@ export interface RichText { * via the `definition` "blocks-fields". */ export interface BlocksField { - id: string; + id: number; title?: string | null; tabContent?: | { @@ -244,12 +244,12 @@ export interface BlocksField { * via the `definition` "nested-arrays". */ export interface NestedArray { - id: string; + id: number; arrayWithBlocks?: | { blocksWithinArray?: | { - relationWithinBlock?: (string | null) | LocalizedPost; + relationWithinBlock?: (number | null) | LocalizedPost; myGroup?: { text?: string | null; }; @@ -263,7 +263,7 @@ export interface NestedArray { | null; arrayWithLocalizedRelation?: | { - localizedRelation?: (string | null) | LocalizedPost; + localizedRelation?: (number | null) | LocalizedPost; id?: string | null; }[] | null; @@ -276,12 +276,12 @@ export interface NestedArray { * via the `definition` "localized-posts". */ export interface LocalizedPost { - id: string; + id: number; title?: string | null; description?: string | null; localizedDescription?: string | null; localizedCheckbox?: boolean | null; - children?: (string | LocalizedPost)[] | null; + children?: (number | LocalizedPost)[] | null; group?: { children?: string | null; }; @@ -294,18 +294,18 @@ export interface LocalizedPost { * via the `definition` "nested-field-tables". */ export interface NestedFieldTable { - id: string; + id: number; array?: | { relation?: { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null; - hasManyRelation?: (string | LocalizedPost)[] | null; + hasManyRelation?: (number | LocalizedPost)[] | null; hasManyPolyRelation?: | { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; }[] | null; select?: ('one' | 'two' | 'three')[] | null; @@ -320,7 +320,7 @@ export interface NestedFieldTable { | { relation?: { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null; id?: string | null; blockName?: string | null; @@ -331,7 +331,7 @@ export interface NestedFieldTable { | { relation?: { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null; id?: string | null; }[] @@ -349,7 +349,7 @@ export interface NestedFieldTable { * via the `definition` "localized-drafts". */ export interface LocalizedDraft { - id: string; + id: number; title?: string | null; updatedAt: string; createdAt: string; @@ -360,7 +360,7 @@ export interface LocalizedDraft { * via the `definition` "localized-date-fields". */ export interface LocalizedDateField { - id: string; + id: number; localizedDate?: string | null; date?: string | null; updatedAt: string; @@ -372,7 +372,7 @@ export interface LocalizedDateField { * via the `definition` "all-fields-localized". */ export interface AllFieldsLocalized { - id: string; + id: number; text?: string | null; textarea?: string | null; number?: number | null; @@ -449,7 +449,7 @@ export interface AllFieldsLocalized { | null; }; }; - selfRelation?: (string | null) | AllFieldsLocalized; + selfRelation?: (number | null) | AllFieldsLocalized; updatedAt: string; createdAt: string; _status?: ('draft' | 'published') | null; @@ -459,9 +459,9 @@ export interface AllFieldsLocalized { * via the `definition` "users". */ export interface User { - id: string; + id: number; name?: string | null; - relation?: (string | null) | LocalizedPost; + relation?: (number | null) | LocalizedPost; updatedAt: string; createdAt: string; email: string; @@ -485,7 +485,7 @@ export interface User { * via the `definition` "no-localized-fields". */ export interface NoLocalizedField { - id: string; + id: number; text?: string | null; updatedAt: string; createdAt: string; @@ -496,7 +496,7 @@ export interface NoLocalizedField { * via the `definition` "array-fields". */ export interface ArrayField { - id: string; + id: number; items?: | { text?: string | null; @@ -511,7 +511,7 @@ export interface ArrayField { * via the `definition` "localized-required". */ export interface LocalizedRequired { - id: string; + id: number; title: string; nav: { layout: ( @@ -571,27 +571,27 @@ export interface LocalizedRequired { * via the `definition` "with-localized-relationship". */ export interface WithLocalizedRelationship { - id: string; - localizedRelationship?: (string | null) | LocalizedPost; - localizedRelationHasManyField?: (string | LocalizedPost)[] | null; + id: number; + localizedRelationship?: (number | null) | LocalizedPost; + localizedRelationHasManyField?: (number | LocalizedPost)[] | null; localizedRelationMultiRelationTo?: | ({ relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } | null); localizedRelationMultiRelationToHasMany?: | ( | { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | { relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } )[] | null; @@ -603,7 +603,7 @@ export interface WithLocalizedRelationship { * via the `definition` "cannot-create-default-locale". */ export interface CannotCreateDefaultLocale { - id: string; + id: number; name?: string | null; updatedAt: string; createdAt: string; @@ -613,33 +613,33 @@ export interface CannotCreateDefaultLocale { * via the `definition` "relationship-localized". */ export interface RelationshipLocalized { - id: string; - relationship?: (string | null) | LocalizedPost; - relationshipHasMany?: (string | LocalizedPost)[] | null; + id: number; + relationship?: (number | null) | LocalizedPost; + relationshipHasMany?: (number | LocalizedPost)[] | null; relationMultiRelationTo?: | ({ relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } | null); relationMultiRelationToHasMany?: | ( | { relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | { relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } )[] | null; arrayField?: | { - nestedRelation?: (string | null) | LocalizedPost; + nestedRelation?: (number | null) | LocalizedPost; id?: string | null; }[] | null; @@ -651,7 +651,7 @@ export interface RelationshipLocalized { * via the `definition` "nested". */ export interface Nested { - id: string; + id: number; blocks?: | { someText?: string | null; @@ -688,7 +688,7 @@ export interface Nested { * via the `definition` "groups". */ export interface Group { - id: string; + id: number; groupLocalizedRow?: { text?: string | null; }; @@ -722,7 +722,7 @@ export interface Group { * via the `definition` "tabs". */ export interface Tab { - id: string; + id: number; tabLocalized?: { title?: string | null; array?: @@ -759,7 +759,7 @@ export interface Tab { * via the `definition` "localized-sort". */ export interface LocalizedSort { - id: string; + id: number; title?: string | null; date?: string | null; updatedAt: string; @@ -770,7 +770,7 @@ export interface LocalizedSort { * via the `definition` "blocks-same-name". */ export interface BlocksSameName { - id: string; + id: number; blocks?: | ( | { @@ -795,7 +795,7 @@ export interface BlocksSameName { * via the `definition` "localized-within-localized". */ export interface LocalizedWithinLocalized { - id: string; + id: number; myTab?: { shouldNotBeLocalized?: string | null; }; @@ -824,7 +824,7 @@ export interface LocalizedWithinLocalized { * via the `definition` "array-with-fallback-fields". */ export interface ArrayWithFallbackField { - id: string; + id: number; items: { text?: string | null; id?: string | null; @@ -843,7 +843,7 @@ export interface ArrayWithFallbackField { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: string; + id: number; key: string; data: | { @@ -860,100 +860,100 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: | ({ relationTo: 'richText'; - value: string | RichText; + value: number | RichText; } | null) | ({ relationTo: 'blocks-fields'; - value: string | BlocksField; + value: number | BlocksField; } | null) | ({ relationTo: 'nested-arrays'; - value: string | NestedArray; + value: number | NestedArray; } | null) | ({ relationTo: 'nested-field-tables'; - value: string | NestedFieldTable; + value: number | NestedFieldTable; } | null) | ({ relationTo: 'localized-drafts'; - value: string | LocalizedDraft; + value: number | LocalizedDraft; } | null) | ({ relationTo: 'localized-date-fields'; - value: string | LocalizedDateField; + value: number | LocalizedDateField; } | null) | ({ relationTo: 'all-fields-localized'; - value: string | AllFieldsLocalized; + value: number | AllFieldsLocalized; } | null) | ({ relationTo: 'users'; - value: string | User; + value: number | User; } | null) | ({ relationTo: 'localized-posts'; - value: string | LocalizedPost; + value: number | LocalizedPost; } | null) | ({ relationTo: 'no-localized-fields'; - value: string | NoLocalizedField; + value: number | NoLocalizedField; } | null) | ({ relationTo: 'array-fields'; - value: string | ArrayField; + value: number | ArrayField; } | null) | ({ relationTo: 'localized-required'; - value: string | LocalizedRequired; + value: number | LocalizedRequired; } | null) | ({ relationTo: 'with-localized-relationship'; - value: string | WithLocalizedRelationship; + value: number | WithLocalizedRelationship; } | null) | ({ relationTo: 'relationship-localized'; - value: string | RelationshipLocalized; + value: number | RelationshipLocalized; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: string | CannotCreateDefaultLocale; + value: number | CannotCreateDefaultLocale; } | null) | ({ relationTo: 'nested'; - value: string | Nested; + value: number | Nested; } | null) | ({ relationTo: 'groups'; - value: string | Group; + value: number | Group; } | null) | ({ relationTo: 'tabs'; - value: string | Tab; + value: number | Tab; } | null) | ({ relationTo: 'localized-sort'; - value: string | LocalizedSort; + value: number | LocalizedSort; } | null) | ({ relationTo: 'blocks-same-name'; - value: string | BlocksSameName; + value: number | BlocksSameName; } | null) | ({ relationTo: 'localized-within-localized'; - value: string | LocalizedWithinLocalized; + value: number | LocalizedWithinLocalized; } | null) | ({ relationTo: 'array-with-fallback-fields'; - value: string | ArrayWithFallbackField; + value: number | ArrayWithFallbackField; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; updatedAt: string; createdAt: string; @@ -963,10 +963,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; key?: string | null; value?: @@ -986,7 +986,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; @@ -1689,7 +1689,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "global-array". */ export interface GlobalArray { - id: string; + id: number; array?: | { text?: string | null; @@ -1704,7 +1704,7 @@ export interface GlobalArray { * via the `definition` "global-text". */ export interface GlobalText { - id: string; + id: number; text?: string | null; updatedAt?: string | null; createdAt?: string | null; From d0503483d057bfb24056d6b45b6d4107b56064d7 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 19 Nov 2025 14:32:41 -0500 Subject: [PATCH 06/27] update property usage --- .../src/versions/drafts/replaceWithDraftIfAvailable.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts index 7244f5389b6..f21baaa179e 100644 --- a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -42,7 +42,11 @@ export const replaceWithDraftIfAvailable = async ({ ], } - if (payload.config.localization && entity.versions.localizeMetadata) { + if ( + payload.config.localization && + entity.versions.drafts && + entity.versions.drafts.localizeStatus + ) { if (locale === 'all') { // TODO: update our drizzle logic to support this type of query queryToBuild = { From 7d642548d8e71145e275ef18d96a7011561fef53 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 19 Nov 2025 14:36:48 -0500 Subject: [PATCH 07/27] only allow localizing status if localization is enabled --- packages/payload/src/collections/config/sanitize.ts | 6 +++++- packages/payload/src/globals/config/sanitize.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 981a94c654f..f9e91090dc9 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -188,7 +188,11 @@ export const sanitizeCollection = async ( } } - sanitized.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) + if (!config.localization) { + sanitized.versions.drafts.localizeStatus = false + } else { + sanitized.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) + } if (sanitized.versions.drafts.autosave === true) { sanitized.versions.drafts.autosave = { diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 272db90cd1e..ff445712126 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -114,7 +114,12 @@ export const sanitizeGlobal = async ( validate: false, } } - global.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) + + if (!config.localization) { + global.versions.drafts.localizeStatus = false + } else { + global.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) + } if (global.versions.drafts.autosave === true) { global.versions.drafts.autosave = { From 4d49f775b45d50ec3121642ecfbfd917f6157e70 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 20 Nov 2025 08:18:21 -0500 Subject: [PATCH 08/27] adjust conditional in update function --- packages/payload/src/collections/operations/utilities/update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 42f7789008c..34845c47d07 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -298,7 +298,7 @@ export const updateDocument = async < if (!isSavingDraft) { // Ensure updatedAt date is always updated dataToUpdate.updatedAt = new Date().toISOString() - if (collectionConfig.versions.localizeMetadata) { + if (config.localization && collectionConfig.versions.drafts) { const mainDoc = await payload.db.findOne>({ collection: collectionConfig.slug, req, From 19fb17318b283bad5a883b8991109167c40d22c5 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 20 Nov 2025 09:25:23 -0500 Subject: [PATCH 09/27] update test to fail --- test/localization/int.spec.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 6aaf6990292..1a1d857ab25 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -3618,7 +3618,7 @@ describe('Localization', () => { }) }) - describe('localizeMetadata', () => { + describe('localize status', () => { it('should not return fallback status data', async () => { // TODO: allow fields to opt out of using fallbackLocale const doc = await payload.create({ @@ -3808,11 +3808,11 @@ describe('Localization', () => { expect(enPublished.docs[0]!.text).toBe('Localized Metadata EN') }) - it('should preserve published data when publishing other locales', async () => { + it('should preserve published and draft data when publishing other locales', async () => { const doc = await payload.create({ collection: allFieldsLocalizedSlug, data: { - text: 'Published EN', + text: 'en published', _status: 'published', }, locale: defaultLocale, @@ -3822,7 +3822,7 @@ describe('Localization', () => { collection: allFieldsLocalizedSlug, id: doc.id, data: { - text: 'New Draft EN', + text: 'en draft', _status: 'draft', }, draft: true, @@ -3833,23 +3833,36 @@ describe('Localization', () => { collection: allFieldsLocalizedSlug, id: doc.id, data: { - text: 'Published ES', + text: 'es published', + _status: 'published', }, locale: spanishLocale, }) - const finalDoc = await payload.findByID({ + const mainDocument = await payload.findByID({ locale: 'all', id: doc.id, collection: allFieldsLocalizedSlug, draft: false, }) - expect(finalDoc._status!.es).toBe('published') - expect(finalDoc.text!.es).toBe('Published ES') - expect(finalDoc._status!.en).toBe('published') - expect(finalDoc.text!.en).toBe('Published EN') + expect(mainDocument._status!.es).toBe('published') + expect(mainDocument.text!.es).toBe('es published') + expect(mainDocument._status!.en).toBe('published') + expect(mainDocument.text!.en).toBe('en published') + + const latestVersion = await payload.findByID({ + locale: 'all', + id: doc.id, + collection: allFieldsLocalizedSlug, + draft: true, + }) + + expect(latestVersion._status!.es).toBe('published') + expect(latestVersion.text!.es).toBe('es published') + expect(latestVersion._status!.en).toBe('draft') + expect(latestVersion.text!.en).toBe('en draft') }) }) }) From 5546935f02ae1cdd35a2d9cdf96018acd94defe1 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 20 Nov 2025 13:39:25 -0500 Subject: [PATCH 10/27] implements publish/unpublish all locale functionality --- .../src/collections/endpoints/update.ts | 18 +- .../src/collections/endpoints/updateByID.ts | 16 +- .../collections/operations/local/update.ts | 16 ++ .../src/collections/operations/update.ts | 7 +- .../src/collections/operations/updateByID.ts | 7 +- .../operations/utilities/update.ts | 113 +++++++------ .../src/utilities/parseParams/index.ts | 46 +++--- packages/payload/src/versions/saveSnapshot.ts | 1 - packages/payload/src/versions/saveVersion.ts | 14 -- test/localization/int.spec.ts | 62 ++++++- test/localization/payload-types.ts | 156 +++++++++--------- 11 files changed, 288 insertions(+), 168 deletions(-) diff --git a/packages/payload/src/collections/endpoints/update.ts b/packages/payload/src/collections/endpoints/update.ts index 8e948e4171d..db8bf0b4e4f 100644 --- a/packages/payload/src/collections/endpoints/update.ts +++ b/packages/payload/src/collections/endpoints/update.ts @@ -11,9 +11,19 @@ import { updateOperation } from '../operations/update.js' export const updateHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { depth, draft, limit, overrideLock, populate, select, sort, trash, where } = parseParams( - req.query, - ) + const { + depth, + draft, + limit, + overrideLock, + populate, + publishAllLocales, + select, + sort, + trash, + unpublishAllLocales, + where, + } = parseParams(req.query) const result = await updateOperation({ collection, @@ -23,10 +33,12 @@ export const updateHandler: PayloadHandler = async (req) => { limit, overrideLock: overrideLock ?? false, populate, + publishAllLocales, req, select, sort, trash, + unpublishAllLocales, where: where!, }) diff --git a/packages/payload/src/collections/endpoints/updateByID.ts b/packages/payload/src/collections/endpoints/updateByID.ts index 7936504b132..25001b68f06 100644 --- a/packages/payload/src/collections/endpoints/updateByID.ts +++ b/packages/payload/src/collections/endpoints/updateByID.ts @@ -10,8 +10,18 @@ import { updateByIDOperation } from '../operations/updateByID.js' export const updateByIDHandler: PayloadHandler = async (req) => { const { id, collection } = getRequestCollectionWithID(req) - const { autosave, depth, draft, overrideLock, populate, publishSpecificLocale, select, trash } = - parseParams(req.query) + const { + autosave, + depth, + draft, + overrideLock, + populate, + publishAllLocales, + publishSpecificLocale, + select, + trash, + unpublishAllLocales, + } = parseParams(req.query) const doc = await updateByIDOperation({ id, @@ -22,10 +32,12 @@ export const updateByIDHandler: PayloadHandler = async (req) => { draft, overrideLock: overrideLock ?? false, populate, + publishAllLocales, publishSpecificLocale, req, select, trash, + unpublishAllLocales, }) let message = req.t('general:updatedSuccessfully') diff --git a/packages/payload/src/collections/operations/local/update.ts b/packages/payload/src/collections/operations/local/update.ts index 74947151524..f6b73cd1d79 100644 --- a/packages/payload/src/collections/operations/local/update.ts +++ b/packages/payload/src/collections/operations/local/update.ts @@ -95,8 +95,16 @@ export type BaseOptions = { overrideLock?: boolean overwriteExistingFiles?: boolean populate?: PopulateType + publishAllLocales?: boolean publishSpecificLocale?: string req: PayloadRequest select?: SelectType @@ -57,6 +58,7 @@ export type Arguments = { */ sort?: Sort trash?: boolean + unpublishAllLocales?: boolean where: Where } @@ -103,6 +105,7 @@ export const updateOperation = async < overrideLock, overwriteExistingFiles = false, populate, + publishAllLocales, publishSpecificLocale, req: { fallbackLocale, @@ -115,6 +118,7 @@ export const updateOperation = async < showHiddenFields, sort: incomingSort, trash = false, + unpublishAllLocales, where, } = args @@ -243,7 +247,6 @@ export const updateOperation = async < // /////////////////////////////////////////////// const updatedDoc = await updateDocument({ id, - accessResults: accessResult, autosave, collectionConfig, config, @@ -258,10 +261,12 @@ export const updateOperation = async < overrideLock: overrideLock!, payload, populate, + publishAllLocales, publishSpecificLocale, req, select: select!, showHiddenFields: showHiddenFields!, + unpublishAllLocales, }) return updatedDoc diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index c49cb83ae57..e38a56d6e06 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -45,11 +45,13 @@ export type Arguments = { overrideLock?: boolean overwriteExistingFiles?: boolean populate?: PopulateType + publishAllLocales?: boolean publishSpecificLocale?: string req: PayloadRequest select?: SelectType showHiddenFields?: boolean trash?: boolean + unpublishAllLocales?: boolean } export const updateByIDOperation = async < @@ -95,6 +97,7 @@ export const updateByIDOperation = async < overrideLock, overwriteExistingFiles = false, populate, + publishAllLocales, publishSpecificLocale, req: { fallbackLocale, @@ -106,6 +109,7 @@ export const updateByIDOperation = async < select: incomingSelect, showHiddenFields, trash = false, + unpublishAllLocales, } = args if (!id) { @@ -203,7 +207,6 @@ export const updateByIDOperation = async < let result = await updateDocument({ id, - accessResults, autosave, collectionConfig, config, @@ -218,10 +221,12 @@ export const updateByIDOperation = async < overrideLock: overrideLock!, payload, populate, + publishAllLocales, publishSpecificLocale, req, select: select!, showHiddenFields: showHiddenFields!, + unpublishAllLocales, }) await unlinkTempFiles({ diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 34845c47d07..d0fc55411ab 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -2,7 +2,6 @@ import type { DeepPartial } from 'ts-essentials' import type { Args } from '../../../fields/hooks/beforeChange/index.js' import type { - AccessResult, CollectionSlug, FileToSave, SanitizedConfig, @@ -25,7 +24,6 @@ import type { import { ensureUsernameOrEmail } from '../../../auth/ensureUsernameOrEmail.js' import { generatePasswordSaltHash } from '../../../auth/strategies/local/generatePasswordSaltHash.js' -import { combineQueries } from '../../../database/combineQueries.js' import { afterChange } from '../../../fields/hooks/afterChange/index.js' import { afterRead } from '../../../fields/hooks/afterRead/index.js' import { beforeChange } from '../../../fields/hooks/beforeChange/index.js' @@ -35,10 +33,7 @@ import { deleteAssociatedFiles } from '../../../uploads/deleteAssociatedFiles.js import { uploadFiles } from '../../../uploads/uploadFiles.js' import { checkDocumentLockStatus } from '../../../utilities/checkDocumentLockStatus.js' import { mergeLocalizedData } from '../../../utilities/mergeLocalizedData.js' -import { getLatestCollectionVersion } from '../../../versions/getLatestCollectionVersion.js' - export type SharedUpdateDocumentArgs = { - accessResults: AccessResult autosave: boolean collectionConfig: SanitizedCollectionConfig config: SanitizedConfig @@ -54,10 +49,12 @@ export type SharedUpdateDocumentArgs = { overrideLock: boolean payload: Payload populate?: PopulateType + publishAllLocales?: boolean publishSpecificLocale?: string req: PayloadRequest select: SelectType showHiddenFields: boolean + unpublishAllLocales?: boolean } /** @@ -78,7 +75,6 @@ export const updateDocument = async < TSelect extends SelectFromCollectionSlug = SelectType, >({ id, - accessResults, autosave, collectionConfig, config, @@ -93,14 +89,23 @@ export const updateDocument = async < overrideLock, payload, populate, + publishAllLocales: publishLocaleArg, publishSpecificLocale, req, select, showHiddenFields, + unpublishAllLocales, }: SharedUpdateDocumentArgs): Promise> => { const password = data?.password + const publishAllLocales = + publishLocaleArg ?? + (collectionConfig.versions.drafts && collectionConfig.versions.drafts.localizeStatus + ? false + : true) const isSavingDraft = - Boolean(draftArg && collectionConfig.versions.drafts) && data._status !== 'published' + Boolean(draftArg && collectionConfig.versions.drafts) && + data._status !== 'published' && + !publishAllLocales const shouldSavePassword = Boolean( password && collectionConfig.auth && @@ -110,6 +115,10 @@ export const updateDocument = async < !isSavingDraft, ) + if (isSavingDraft) { + data._status = 'draft' + } + // ///////////////////////////////////// // Handle potentially locked documents // ///////////////////////////////////// @@ -245,40 +254,71 @@ export const updateDocument = async < (collectionConfig.trash && (Boolean(data?.deletedAt) || isRestoringDraftFromTrash)), } + // ///////////////////////////////////// + // Handle Localized Data Merging + // ///////////////////////////////////// + let result: JsonObject = await beforeChange(beforeChangeArgs) let snapshotToSave: JsonObject | undefined if (config.localization && collectionConfig.versions) { - if (publishSpecificLocale) { - snapshotToSave = deepCopyObjectSimple(result) + let isSnapshotRequired = false + + if (collectionConfig.versions.drafts && collectionConfig.versions.drafts.localizeStatus) { + if (publishAllLocales || unpublishAllLocales) { + let accessibleLocaleCodes = config.localization.localeCodes - // the published data to save to the main document - result = await beforeChange({ - ...beforeChangeArgs, - docWithLocales: - (await getLatestCollectionVersion({ - id, - config: collectionConfig, - payload, - published: true, - query: { - collection: collectionConfig.slug, - locale, - req, - where: combineQueries({ id: { equals: id } }, accessResults), - }, + if (config.localization.filterAvailableLocales) { + const filteredLocales = await config.localization.filterAvailableLocales({ + locales: config.localization.locales, req, - })) || {}, + }) + accessibleLocaleCodes = filteredLocales.map((locale) => + typeof locale === 'string' ? locale : locale.code, + ) + } + + if (typeof result._status !== 'object' || result._status === null) { + result._status = {} + } + + for (const localeCode of accessibleLocaleCodes) { + result._status[localeCode] = unpublishAllLocales ? 'draft' : 'published' + } + } else if (!isSavingDraft) { + // publishing a single locale + isSnapshotRequired = true + } + } else if (publishSpecificLocale) { + // previous way of publishing a single locale + isSnapshotRequired = true + } + + if (isSnapshotRequired) { + snapshotToSave = deepCopyObjectSimple(result) + + const currentDoc = await payload.db.findOne>({ + collection: collectionConfig.slug, + req, + where: { id: { equals: id } }, + }) + + result = mergeLocalizedData({ + configBlockReferences: config.blocks, + dataWithLocales: result || {}, + docWithLocales: currentDoc || {}, + fields: collectionConfig.fields, + selectedLocales: [locale], }) } } + const dataToUpdate: JsonObject = { ...result } + // ///////////////////////////////////// // Handle potential password update // ///////////////////////////////////// - let dataToUpdate: JsonObject = { ...result } - if (shouldSavePassword && typeof password === 'string') { const { hash, salt } = await generatePasswordSaltHash({ collection: collectionConfig, @@ -298,25 +338,6 @@ export const updateDocument = async < if (!isSavingDraft) { // Ensure updatedAt date is always updated dataToUpdate.updatedAt = new Date().toISOString() - if (config.localization && collectionConfig.versions.drafts) { - const mainDoc = await payload.db.findOne>({ - collection: collectionConfig.slug, - req, - where: { - id: { - equals: id, - }, - }, - }) - - dataToUpdate = mergeLocalizedData({ - configBlockReferences: config.blocks, - dataWithLocales: dataToUpdate || {}, - docWithLocales: mainDoc || {}, - fields: collectionConfig.fields, - selectedLocales: [locale], - }) - } result = await req.payload.db.updateOne({ id, collection: collectionConfig.slug, diff --git a/packages/payload/src/utilities/parseParams/index.ts b/packages/payload/src/utilities/parseParams/index.ts index fcacfe71347..b195845d585 100644 --- a/packages/payload/src/utilities/parseParams/index.ts +++ b/packages/payload/src/utilities/parseParams/index.ts @@ -7,27 +7,6 @@ import { sanitizeJoinParams } from '../sanitizeJoinParams.js' import { sanitizePopulateParam } from '../sanitizePopulateParam.js' import { sanitizeSelectParam } from '../sanitizeSelectParam.js' -type ParsedParams = { - autosave?: boolean - data?: Record - depth?: number - draft?: boolean - field?: string - flattenLocales?: boolean - joins?: JoinQuery - limit?: number - overrideLock?: boolean - page?: number - pagination?: boolean - populate?: PopulateType - publishSpecificLocale?: string - select?: SelectType - selectedLocales?: string[] - sort?: string[] - trash?: boolean - where?: Where -} & Record - type RawParams = { [key: string]: unknown autosave?: string @@ -42,14 +21,39 @@ type RawParams = { page?: string pagination?: string populate?: unknown + publishAllLocales?: string publishSpecificLocale?: string select?: unknown selectedLocales?: string sort?: string trash?: string + unpublishAllLocales?: string where?: Where } +type ParsedParams = { + autosave?: boolean + data?: Record + depth?: number + draft?: boolean + field?: string + flattenLocales?: boolean + joins?: JoinQuery + limit?: number + overrideLock?: boolean + page?: number + pagination?: boolean + populate?: PopulateType + publishAllLocales?: boolean + publishSpecificLocale?: string + select?: SelectType + selectedLocales?: string[] + sort?: string[] + trash?: boolean + unpublishAllLocales?: boolean + where?: Where +} & Record + export const booleanParams = [ 'autosave', 'draft', diff --git a/packages/payload/src/versions/saveSnapshot.ts b/packages/payload/src/versions/saveSnapshot.ts index 269dce2d155..6c47dad4a11 100644 --- a/packages/payload/src/versions/saveSnapshot.ts +++ b/packages/payload/src/versions/saveSnapshot.ts @@ -32,7 +32,6 @@ export const saveSnapshot = async ({ const docData: { _status?: 'draft' } & T = deepCopyObjectSimple(data || ({} as T)) - docData._status = 'draft' if (docData._id) { delete docData._id diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index eb37c3c6d0c..6c8c55e85eb 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -46,20 +46,6 @@ export const saveVersion = async ({ updatedAt?: string } & TData = deepCopyObjectSimple(docWithLocales) - const localizedStatus = collection - ? collection.versions.drafts && collection.versions.drafts.localizeStatus - : global!.versions.drafts && global!.versions.drafts.localizeStatus - - if (draft) { - if (payload.config.localization && localizedStatus) { - if (req?.locale && payload.config.localization.localeCodes.includes(req.locale)) { - versionData._status[req.locale] = 'draft' - } - } else { - versionData._status = 'draft' - } - } - if (collection?.timestamps && draft) { versionData.updatedAt = now } diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 1a1d857ab25..7b0968144e9 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -3834,7 +3834,6 @@ describe('Localization', () => { id: doc.id, data: { text: 'es published', - _status: 'published', }, locale: spanishLocale, @@ -3864,6 +3863,67 @@ describe('Localization', () => { expect(latestVersion._status!.en).toBe('draft') expect(latestVersion.text!.en).toBe('en draft') }) + + it('should allow updating the status of all locales at once', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + text: 'en draft', + _status: 'draft', + }, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'es draft', + _status: 'draft', + }, + locale: spanishLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'en published', + _status: 'published', + }, + locale: 'en', + publishAllLocales: true, + }) + + const mainDocument = await payload.findByID({ + locale: 'all', + id: doc.id, + collection: allFieldsLocalizedSlug, + draft: false, + }) + + expect(mainDocument._status!.en).toBe('published') + expect(mainDocument.text!.en).toBe('en published') + expect(mainDocument._status!.es).toBe('published') + expect(mainDocument.text!.es).toBe('es draft') + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + unpublishAllLocales: true, + data: {}, + }) + + const unpublishedDocument = await payload.findByID({ + locale: 'all', + id: doc.id, + collection: allFieldsLocalizedSlug, + draft: false, + }) + + expect(unpublishedDocument._status!.en).toBe('draft') + expect(unpublishedDocument._status!.es).toBe('draft') + }) }) }) diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index f3de30ba6d9..b83f9830020 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -124,7 +124,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: number; + defaultIDType: string; }; fallbackLocale: | ('false' | 'none' | 'null') @@ -172,7 +172,7 @@ export interface UserAuthOperations { * via the `definition` "richText". */ export interface RichText { - id: number; + id: string; richText?: | { [k: string]: unknown; @@ -201,7 +201,7 @@ export interface RichText { * via the `definition` "blocks-fields". */ export interface BlocksField { - id: number; + id: string; title?: string | null; tabContent?: | { @@ -244,12 +244,12 @@ export interface BlocksField { * via the `definition` "nested-arrays". */ export interface NestedArray { - id: number; + id: string; arrayWithBlocks?: | { blocksWithinArray?: | { - relationWithinBlock?: (number | null) | LocalizedPost; + relationWithinBlock?: (string | null) | LocalizedPost; myGroup?: { text?: string | null; }; @@ -263,7 +263,7 @@ export interface NestedArray { | null; arrayWithLocalizedRelation?: | { - localizedRelation?: (number | null) | LocalizedPost; + localizedRelation?: (string | null) | LocalizedPost; id?: string | null; }[] | null; @@ -276,12 +276,12 @@ export interface NestedArray { * via the `definition` "localized-posts". */ export interface LocalizedPost { - id: number; + id: string; title?: string | null; description?: string | null; localizedDescription?: string | null; localizedCheckbox?: boolean | null; - children?: (number | LocalizedPost)[] | null; + children?: (string | LocalizedPost)[] | null; group?: { children?: string | null; }; @@ -294,18 +294,18 @@ export interface LocalizedPost { * via the `definition` "nested-field-tables". */ export interface NestedFieldTable { - id: number; + id: string; array?: | { relation?: { relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; } | null; - hasManyRelation?: (number | LocalizedPost)[] | null; + hasManyRelation?: (string | LocalizedPost)[] | null; hasManyPolyRelation?: | { relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; }[] | null; select?: ('one' | 'two' | 'three')[] | null; @@ -320,7 +320,7 @@ export interface NestedFieldTable { | { relation?: { relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; } | null; id?: string | null; blockName?: string | null; @@ -331,7 +331,7 @@ export interface NestedFieldTable { | { relation?: { relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; } | null; id?: string | null; }[] @@ -349,7 +349,7 @@ export interface NestedFieldTable { * via the `definition` "localized-drafts". */ export interface LocalizedDraft { - id: number; + id: string; title?: string | null; updatedAt: string; createdAt: string; @@ -360,7 +360,7 @@ export interface LocalizedDraft { * via the `definition` "localized-date-fields". */ export interface LocalizedDateField { - id: number; + id: string; localizedDate?: string | null; date?: string | null; updatedAt: string; @@ -372,7 +372,7 @@ export interface LocalizedDateField { * via the `definition` "all-fields-localized". */ export interface AllFieldsLocalized { - id: number; + id: string; text?: string | null; textarea?: string | null; number?: number | null; @@ -454,7 +454,7 @@ export interface AllFieldsLocalized { | null; }; }; - selfRelation?: (number | null) | AllFieldsLocalized; + selfRelation?: (string | null) | AllFieldsLocalized; updatedAt: string; createdAt: string; _status?: ('draft' | 'published') | null; @@ -464,9 +464,9 @@ export interface AllFieldsLocalized { * via the `definition` "users". */ export interface User { - id: number; + id: string; name?: string | null; - relation?: (number | null) | LocalizedPost; + relation?: (string | null) | LocalizedPost; updatedAt: string; createdAt: string; email: string; @@ -490,7 +490,7 @@ export interface User { * via the `definition` "no-localized-fields". */ export interface NoLocalizedField { - id: number; + id: string; text?: string | null; group?: { en?: { @@ -506,7 +506,7 @@ export interface NoLocalizedField { * via the `definition` "array-fields". */ export interface ArrayField { - id: number; + id: string; items?: | { text?: string | null; @@ -521,7 +521,7 @@ export interface ArrayField { * via the `definition` "localized-required". */ export interface LocalizedRequired { - id: number; + id: string; title: string; nav: { layout: ( @@ -581,27 +581,27 @@ export interface LocalizedRequired { * via the `definition` "with-localized-relationship". */ export interface WithLocalizedRelationship { - id: number; - localizedRelationship?: (number | null) | LocalizedPost; - localizedRelationHasManyField?: (number | LocalizedPost)[] | null; + id: string; + localizedRelationship?: (string | null) | LocalizedPost; + localizedRelationHasManyField?: (string | LocalizedPost)[] | null; localizedRelationMultiRelationTo?: | ({ relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: number | CannotCreateDefaultLocale; + value: string | CannotCreateDefaultLocale; } | null); localizedRelationMultiRelationToHasMany?: | ( | { relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; } | { relationTo: 'cannot-create-default-locale'; - value: number | CannotCreateDefaultLocale; + value: string | CannotCreateDefaultLocale; } )[] | null; @@ -613,7 +613,7 @@ export interface WithLocalizedRelationship { * via the `definition` "cannot-create-default-locale". */ export interface CannotCreateDefaultLocale { - id: number; + id: string; name?: string | null; updatedAt: string; createdAt: string; @@ -623,33 +623,33 @@ export interface CannotCreateDefaultLocale { * via the `definition` "relationship-localized". */ export interface RelationshipLocalized { - id: number; - relationship?: (number | null) | LocalizedPost; - relationshipHasMany?: (number | LocalizedPost)[] | null; + id: string; + relationship?: (string | null) | LocalizedPost; + relationshipHasMany?: (string | LocalizedPost)[] | null; relationMultiRelationTo?: | ({ relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: number | CannotCreateDefaultLocale; + value: string | CannotCreateDefaultLocale; } | null); relationMultiRelationToHasMany?: | ( | { relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; } | { relationTo: 'cannot-create-default-locale'; - value: number | CannotCreateDefaultLocale; + value: string | CannotCreateDefaultLocale; } )[] | null; arrayField?: | { - nestedRelation?: (number | null) | LocalizedPost; + nestedRelation?: (string | null) | LocalizedPost; id?: string | null; }[] | null; @@ -661,7 +661,7 @@ export interface RelationshipLocalized { * via the `definition` "nested". */ export interface Nested { - id: number; + id: string; blocks?: | { someText?: string | null; @@ -698,7 +698,7 @@ export interface Nested { * via the `definition` "groups". */ export interface Group { - id: number; + id: string; groupLocalizedRow?: { text?: string | null; }; @@ -732,7 +732,7 @@ export interface Group { * via the `definition` "tabs". */ export interface Tab { - id: number; + id: string; tabLocalized?: { title?: string | null; array?: @@ -769,7 +769,7 @@ export interface Tab { * via the `definition` "localized-sort". */ export interface LocalizedSort { - id: number; + id: string; title?: string | null; date?: string | null; updatedAt: string; @@ -780,7 +780,7 @@ export interface LocalizedSort { * via the `definition` "blocks-same-name". */ export interface BlocksSameName { - id: number; + id: string; blocks?: | ( | { @@ -805,7 +805,7 @@ export interface BlocksSameName { * via the `definition` "localized-within-localized". */ export interface LocalizedWithinLocalized { - id: number; + id: string; myTab?: { shouldNotBeLocalized?: string | null; }; @@ -834,7 +834,7 @@ export interface LocalizedWithinLocalized { * via the `definition` "array-with-fallback-fields". */ export interface ArrayWithFallbackField { - id: number; + id: string; items: { text?: string | null; id?: string | null; @@ -853,7 +853,7 @@ export interface ArrayWithFallbackField { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: number; + id: string; key: string; data: | { @@ -870,100 +870,100 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: number; + id: string; document?: | ({ relationTo: 'richText'; - value: number | RichText; + value: string | RichText; } | null) | ({ relationTo: 'blocks-fields'; - value: number | BlocksField; + value: string | BlocksField; } | null) | ({ relationTo: 'nested-arrays'; - value: number | NestedArray; + value: string | NestedArray; } | null) | ({ relationTo: 'nested-field-tables'; - value: number | NestedFieldTable; + value: string | NestedFieldTable; } | null) | ({ relationTo: 'localized-drafts'; - value: number | LocalizedDraft; + value: string | LocalizedDraft; } | null) | ({ relationTo: 'localized-date-fields'; - value: number | LocalizedDateField; + value: string | LocalizedDateField; } | null) | ({ relationTo: 'all-fields-localized'; - value: number | AllFieldsLocalized; + value: string | AllFieldsLocalized; } | null) | ({ relationTo: 'users'; - value: number | User; + value: string | User; } | null) | ({ relationTo: 'localized-posts'; - value: number | LocalizedPost; + value: string | LocalizedPost; } | null) | ({ relationTo: 'no-localized-fields'; - value: number | NoLocalizedField; + value: string | NoLocalizedField; } | null) | ({ relationTo: 'array-fields'; - value: number | ArrayField; + value: string | ArrayField; } | null) | ({ relationTo: 'localized-required'; - value: number | LocalizedRequired; + value: string | LocalizedRequired; } | null) | ({ relationTo: 'with-localized-relationship'; - value: number | WithLocalizedRelationship; + value: string | WithLocalizedRelationship; } | null) | ({ relationTo: 'relationship-localized'; - value: number | RelationshipLocalized; + value: string | RelationshipLocalized; } | null) | ({ relationTo: 'cannot-create-default-locale'; - value: number | CannotCreateDefaultLocale; + value: string | CannotCreateDefaultLocale; } | null) | ({ relationTo: 'nested'; - value: number | Nested; + value: string | Nested; } | null) | ({ relationTo: 'groups'; - value: number | Group; + value: string | Group; } | null) | ({ relationTo: 'tabs'; - value: number | Tab; + value: string | Tab; } | null) | ({ relationTo: 'localized-sort'; - value: number | LocalizedSort; + value: string | LocalizedSort; } | null) | ({ relationTo: 'blocks-same-name'; - value: number | BlocksSameName; + value: string | BlocksSameName; } | null) | ({ relationTo: 'localized-within-localized'; - value: number | LocalizedWithinLocalized; + value: string | LocalizedWithinLocalized; } | null) | ({ relationTo: 'array-with-fallback-fields'; - value: number | ArrayWithFallbackField; + value: string | ArrayWithFallbackField; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; updatedAt: string; createdAt: string; @@ -973,10 +973,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: number; + id: string; user: { relationTo: 'users'; - value: number | User; + value: string | User; }; key?: string | null; value?: @@ -996,7 +996,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: number; + id: string; name?: string | null; batch?: number | null; updatedAt: string; @@ -1717,7 +1717,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "global-array". */ export interface GlobalArray { - id: number; + id: string; array?: | { text?: string | null; @@ -1732,7 +1732,7 @@ export interface GlobalArray { * via the `definition` "global-text". */ export interface GlobalText { - id: number; + id: string; text?: string | null; updatedAt?: string | null; createdAt?: string | null; @@ -1772,6 +1772,6 @@ export interface Auth { declare module 'payload' { - // @ts-ignore + // @ts-ignore export interface GeneratedTypes extends Config {} -} +} \ No newline at end of file From a7cef11bd5df83bc88f30f88c6f6ffa88cae5f81 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Fri, 21 Nov 2025 12:37:57 +0000 Subject: [PATCH 11/27] chore: update int test --- test/localization/int.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 59fca5243f6..27a721e2d24 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -3620,8 +3620,7 @@ describe('Localization', () => { }) describe('localize status', () => { - it('should not return fallback status data', async () => { - // TODO: allow fields to opt out of using fallbackLocale + it('should set other locales to draft upon creation', async () => { const doc = await payload.create({ collection: allFieldsLocalizedSlug, data: { @@ -3637,7 +3636,7 @@ describe('Localization', () => { collection: allFieldsLocalizedSlug, }) - expect(esDoc._status).toBeUndefined() + expect(esDoc._status).toContain('draft') }) it('should return correct data based on draft arg', async () => { From 405c5632ffa1d7e31d0a9cef74576286f5f2ffee Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 21 Nov 2025 09:15:47 -0500 Subject: [PATCH 12/27] integrate publishAllLocales in create operation --- .../src/collections/endpoints/create.ts | 3 +- .../src/collections/operations/create.ts | 36 ++++++++++++++++++- .../collections/operations/local/create.ts | 6 ++++ test/localization/int.spec.ts | 20 +++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/payload/src/collections/endpoints/create.ts b/packages/payload/src/collections/endpoints/create.ts index 76c762d36ea..7347ce184b0 100644 --- a/packages/payload/src/collections/endpoints/create.ts +++ b/packages/payload/src/collections/endpoints/create.ts @@ -11,7 +11,7 @@ import { createOperation } from '../operations/create.js' export const createHandler: PayloadHandler = async (req) => { const collection = getRequestCollection(req) - const { autosave, depth, draft, populate, select } = parseParams(req.query) + const { autosave, depth, draft, populate, publishAllLocales, select } = parseParams(req.query) const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined @@ -22,6 +22,7 @@ export const createHandler: PayloadHandler = async (req) => { depth, draft, populate, + publishAllLocales, publishSpecificLocale, req, select, diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 98b6ed550af..36986d0f352 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -47,6 +47,7 @@ export type Arguments = { overrideAccess?: boolean overwriteExistingFiles?: boolean populate?: PopulateType + publishAllLocales?: boolean publishSpecificLocale?: string req: PayloadRequest select?: SelectType @@ -105,6 +106,7 @@ export const createOperation = async < overrideAccess, overwriteExistingFiles = false, populate, + publishAllLocales, publishSpecificLocale, req: { fallbackLocale, @@ -120,7 +122,11 @@ export const createOperation = async < let { data } = args - const isSavingDraft = Boolean(draft && collectionConfig.versions.drafts) + const isSavingDraft = Boolean(draft && collectionConfig.versions.drafts && !publishAllLocales) + + if (isSavingDraft) { + data._status = 'draft' + } let duplicatedFromDocWithLocales: JsonObject = {} let duplicatedFromDoc: JsonObject = {} @@ -237,6 +243,34 @@ export const createOperation = async < !collectionConfig.versions.drafts.validate, }) + if ( + config.localization && + collectionConfig.versions && + collectionConfig.versions.drafts && + collectionConfig.versions.drafts.localizeStatus && + publishAllLocales + ) { + let accessibleLocaleCodes = config.localization.localeCodes + + if (config.localization.filterAvailableLocales) { + const filteredLocales = await config.localization.filterAvailableLocales({ + locales: config.localization.locales, + req, + }) + accessibleLocaleCodes = filteredLocales.map((locale) => + typeof locale === 'string' ? locale : locale.code, + ) + } + + if (typeof resultWithLocales._status !== 'object' || resultWithLocales._status === null) { + resultWithLocales._status = {} + } + + for (const localeCode of accessibleLocaleCodes) { + resultWithLocales._status[localeCode] = 'published' + } + } + // ///////////////////////////////////// // Write files to local storage // ///////////////////////////////////// diff --git a/packages/payload/src/collections/operations/local/create.ts b/packages/payload/src/collections/operations/local/create.ts index a39c2a5d585..5987995bf1d 100644 --- a/packages/payload/src/collections/operations/local/create.ts +++ b/packages/payload/src/collections/operations/local/create.ts @@ -88,6 +88,10 @@ type BaseOptions = { * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. */ populate?: PopulateType + /** + * Publish to all locales + */ + publishAllLocales?: boolean /** * The `PayloadRequest` object. You can pass it to thread the current [transaction](https://payloadcms.com/docs/database/transactions), user and locale to the operation. * Recommended to pass when using the Local API from hooks, as usually you want to execute the operation within the current transaction. @@ -151,6 +155,7 @@ export async function createLocal< overrideAccess = true, overwriteExistingFiles = false, populate, + publishAllLocales, select, showHiddenFields, } = options @@ -178,6 +183,7 @@ export async function createLocal< overrideAccess, overwriteExistingFiles, populate, + publishAllLocales, req, select, showHiddenFields, diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 27a721e2d24..1b09f60fc39 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -3639,6 +3639,26 @@ describe('Localization', () => { expect(esDoc._status).toContain('draft') }) + it('should allow publishing of all locales upon creation', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + text: 'Localized Metadata EN', + _status: 'published', + }, + locale: defaultLocale, + publishAllLocales: true, + }) + + const esDoc = await payload.findByID({ + locale: spanishLocale, + id: doc.id, + collection: allFieldsLocalizedSlug, + }) + + expect(esDoc._status).toContain('published') + }) + it('should return correct data based on draft arg', async () => { // NOTE: passes in MongoDB, fails in PG // -> fails to query on version._status.[localeCode] in `replaceWithDraftIfAvailable` when locale = 'all' From 48ab45cd8e66cb0c01039e417e0f9aa4dc070b17 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 21 Nov 2025 13:21:27 -0500 Subject: [PATCH 13/27] don't publish all if draft arg is true --- .../src/collections/operations/create.ts | 8 +++++++- .../collections/operations/utilities/update.ts | 11 ++++++----- packages/ui/src/elements/Autosave/index.tsx | 18 ++++++++++++++++-- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts index 36986d0f352..a68029dfb49 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -106,7 +106,7 @@ export const createOperation = async < overrideAccess, overwriteExistingFiles = false, populate, - publishAllLocales, + publishAllLocales: publishAllLocalesArg, publishSpecificLocale, req: { fallbackLocale, @@ -122,6 +122,12 @@ export const createOperation = async < let { data } = args + const publishAllLocales = + !draft && + (publishAllLocalesArg ?? + (collectionConfig.versions.drafts && collectionConfig.versions.drafts.localizeStatus + ? false + : true)) const isSavingDraft = Boolean(draft && collectionConfig.versions.drafts && !publishAllLocales) if (isSavingDraft) { diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index d0fc55411ab..8089c899e32 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -89,7 +89,7 @@ export const updateDocument = async < overrideLock, payload, populate, - publishAllLocales: publishLocaleArg, + publishAllLocales: publishAllLocalesArg, publishSpecificLocale, req, select, @@ -98,10 +98,11 @@ export const updateDocument = async < }: SharedUpdateDocumentArgs): Promise> => { const password = data?.password const publishAllLocales = - publishLocaleArg ?? - (collectionConfig.versions.drafts && collectionConfig.versions.drafts.localizeStatus - ? false - : true) + !draftArg && + (publishAllLocalesArg ?? + (collectionConfig.versions.drafts && collectionConfig.versions.drafts.localizeStatus + ? false + : true)) const isSavingDraft = Boolean(draftArg && collectionConfig.versions.drafts) && data._status !== 'published' && diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index 174311f0258..5a7a6102cfe 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -4,6 +4,7 @@ import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload' import { dequal } from 'dequal/lite' import { reduceFieldsToValues, versionDefaults } from 'payload/shared' +import * as qs from 'qs-esm' import React, { useDeferredValue, useEffect, useRef, useState } from 'react' import type { OnSaveContext } from '../../views/Edit/index.js' @@ -132,15 +133,28 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) let method: string let entitySlug: string + const params = qs.stringify( + { + autosave: true, + depth: 0, + draft: true, + 'fallback-locale': 'null', + locale: localeRef.current, + }, + { + addQueryPrefix: true, + }, + ) + if (collection && id) { entitySlug = collection.slug - url = `${serverURL}${api}/${entitySlug}/${id}?depth=0&draft=true&autosave=true&locale=${localeRef.current}&fallback-locale=null` + url = `${serverURL}${api}/${entitySlug}/${id}${params}` method = 'PATCH' } if (globalDoc) { entitySlug = globalDoc.slug - url = `${serverURL}${api}/globals/${entitySlug}?depth=0&draft=true&autosave=true&locale=${localeRef.current}&fallback-locale=null` + url = `${serverURL}${api}/globals/${entitySlug}${params}` method = 'POST' } From 47c5f41bd145d6b890d3c9400db36d9e03089466 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 26 Nov 2025 15:10:13 +0000 Subject: [PATCH 14/27] chore: move global functionality from UI branch --- .../src/collections/config/sanitize.ts | 9 ++- .../operations/utilities/update.ts | 6 +- .../payload/src/globals/config/sanitize.ts | 10 ++-- .../payload/src/globals/endpoints/update.ts | 4 ++ .../payload/src/globals/operations/update.ts | 59 +++++++++++++++++-- .../utilities/traverseForLocalizedFields.ts | 45 ++++++++++++++ 6 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 packages/payload/src/utilities/traverseForLocalizedFields.ts diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index f9e91090dc9..79f4bb52b51 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -16,6 +16,7 @@ import { uploadCollectionEndpoints } from '../../uploads/endpoints/index.js' import { getBaseUploadFields } from '../../uploads/getBaseFields.js' import { flattenAllFields } from '../../utilities/flattenAllFields.js' import { formatLabels } from '../../utilities/formatLabels.js' +import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' import { baseVersionFields } from '../../versions/baseFields.js' import { versionDefaults } from '../../versions/defaults.js' import { defaultCollectionEndpoints } from '../endpoints/index.js' @@ -188,10 +189,12 @@ export const sanitizeCollection = async ( } } - if (!config.localization) { - sanitized.versions.drafts.localizeStatus = false - } else { + const hasLocalizedFields = traverseForLocalizedFields(sanitized.fields) + + if (config.localization && hasLocalizedFields) { sanitized.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) + } else { + sanitized.versions.drafts.localizeStatus = false } if (sanitized.versions.drafts.autosave === true) { diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 8089c899e32..60342918622 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -94,7 +94,7 @@ export const updateDocument = async < req, select, showHiddenFields, - unpublishAllLocales, + unpublishAllLocales: unpublishAllLocalesArg, }: SharedUpdateDocumentArgs): Promise> => { const password = data?.password const publishAllLocales = @@ -103,6 +103,10 @@ export const updateDocument = async < (collectionConfig.versions.drafts && collectionConfig.versions.drafts.localizeStatus ? false : true)) + const unpublishAllLocales = + typeof unpublishAllLocalesArg === 'string' + ? unpublishAllLocalesArg === 'true' + : !!unpublishAllLocalesArg const isSavingDraft = Boolean(draftArg && collectionConfig.versions.drafts) && data._status !== 'published' && diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index ff445712126..2e7eaef7c0c 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -7,10 +7,10 @@ import { fieldAffectsData } from '../../fields/config/types.js' import { mergeBaseFields } from '../../fields/mergeBaseFields.js' import { flattenAllFields } from '../../utilities/flattenAllFields.js' import { toWords } from '../../utilities/formatLabels.js' +import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' import { baseVersionFields } from '../../versions/baseFields.js' import { versionDefaults } from '../../versions/defaults.js' import { defaultGlobalEndpoints } from '../endpoints/index.js' - export const sanitizeGlobal = async ( config: Config, global: GlobalConfig, @@ -115,10 +115,12 @@ export const sanitizeGlobal = async ( } } - if (!config.localization) { - global.versions.drafts.localizeStatus = false - } else { + const hasLocalizedFields = traverseForLocalizedFields(global.fields) + + if (config.localization && hasLocalizedFields) { global.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) + } else { + global.versions.drafts.localizeStatus = false } if (global.versions.drafts.autosave === true) { diff --git a/packages/payload/src/globals/endpoints/update.ts b/packages/payload/src/globals/endpoints/update.ts index 79f4e9d4f1c..0af2b07c929 100644 --- a/packages/payload/src/globals/endpoints/update.ts +++ b/packages/payload/src/globals/endpoints/update.ts @@ -16,6 +16,8 @@ export const updateHandler: PayloadHandler = async (req) => { const draft = searchParams.get('draft') === 'true' const autosave = searchParams.get('autosave') === 'true' const publishSpecificLocale = req.query.publishSpecificLocale as string | undefined + const publishAllLocales = searchParams.get('publishAllLocales') === 'true' + const unpublishAllLocales = searchParams.get('unpublishAllLocales') === 'true' const result = await updateOperation({ slug: globalConfig.slug, @@ -25,9 +27,11 @@ export const updateHandler: PayloadHandler = async (req) => { draft, globalConfig, populate: sanitizePopulateParam(req.query.populate), + publishAllLocales, publishSpecificLocale, req, select: sanitizeSelectParam(req.query.select), + unpublishAllLocales, }) let message = req.t('general:updatedSuccessfully') diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 770a64fab7e..e24120e5b4f 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -26,10 +26,10 @@ import { commitTransaction } from '../../utilities/commitTransaction.js' import { getSelectMode } from '../../utilities/getSelectMode.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { mergeLocalizedData } from '../../utilities/mergeLocalizedData.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' import { getLatestGlobalVersion } from '../../versions/getLatestGlobalVersion.js' import { saveVersion } from '../../versions/saveVersion.js' - type Args = { autosave?: boolean data: DeepPartial, 'id'>> @@ -40,11 +40,13 @@ type Args = { overrideAccess?: boolean overrideLock?: boolean populate?: PopulateType + publishAllLocales?: boolean publishSpecificLocale?: string req: PayloadRequest select?: SelectType showHiddenFields?: boolean slug: string + unpublishAllLocales?: boolean } export const updateOperation = async < @@ -67,11 +69,13 @@ export const updateOperation = async < overrideAccess, overrideLock, populate, + publishAllLocales, publishSpecificLocale, - req: { fallbackLocale, locale, payload }, + req: { fallbackLocale, locale, payload, payload: { config } = {} }, req, select: incomingSelect, showHiddenFields, + unpublishAllLocales, } = args try { @@ -236,12 +240,48 @@ export const updateOperation = async < let result: JsonObject = await beforeChange(beforeChangeArgs) let snapshotToSave: JsonObject | undefined - if (payload.config.localization && globalConfig.versions) { - if (publishSpecificLocale) { + // ///////////////////////////////////// + // Handle Localized Data Merging + // ///////////////////////////////////// + + if (config && config.localization && globalConfig.versions) { + let isSnapshotRequired = false + + if (globalConfig.versions.drafts && globalConfig.versions.drafts.localizeStatus) { + if (publishAllLocales || unpublishAllLocales) { + let accessibleLocaleCodes = config.localization.localeCodes + + if (config.localization.filterAvailableLocales) { + const filteredLocales = await config.localization.filterAvailableLocales({ + locales: config.localization.locales, + req, + }) + accessibleLocaleCodes = filteredLocales.map((locale) => + typeof locale === 'string' ? locale : locale.code, + ) + } + + if (typeof result._status !== 'object' || result._status === null) { + result._status = {} + } + + for (const localeCode of accessibleLocaleCodes) { + result._status[localeCode] = unpublishAllLocales ? 'draft' : 'published' + } + } else if (!isSavingDraft) { + // publishing a single locale + isSnapshotRequired = true + } + } else if (publishSpecificLocale) { + // previous way of publishing a single locale + isSnapshotRequired = true + } + + if (isSnapshotRequired) { snapshotToSave = deepCopyObjectSimple(result) // the published data to save to the main document - result = await beforeChange({ + const mostRecentPublishedDoc = await beforeChange({ ...beforeChangeArgs, docWithLocales: ( @@ -255,9 +295,16 @@ export const updateOperation = async < }) )?.global || {}, }) + + result = mergeLocalizedData({ + configBlockReferences: config.blocks, + dataWithLocales: result || {}, + docWithLocales: mostRecentPublishedDoc || {}, + fields: globalConfig.fields, + selectedLocales: [locale!], + }) } } - // ///////////////////////////////////// // Update // ///////////////////////////////////// diff --git a/packages/payload/src/utilities/traverseForLocalizedFields.ts b/packages/payload/src/utilities/traverseForLocalizedFields.ts new file mode 100644 index 00000000000..ae5e178e9e3 --- /dev/null +++ b/packages/payload/src/utilities/traverseForLocalizedFields.ts @@ -0,0 +1,45 @@ +import type { Field } from '../fields/config/types.js' + +export const traverseForLocalizedFields = (fields: Field[]): boolean => { + for (const field of fields) { + if ('localized' in field && field.localized) { + return true + } + + switch (field.type) { + case 'array': + case 'collapsible': + case 'group': + case 'row': + if (field.fields && traverseForLocalizedFields(field.fields)) { + return true + } + break + + case 'blocks': + if (field.blocks) { + for (const block of field.blocks) { + if (block.fields && traverseForLocalizedFields(block.fields)) { + return true + } + } + } + break + + case 'tabs': + if (field.tabs) { + for (const tab of field.tabs) { + if ('localized' in tab && tab.localized) { + return true + } + if ('fields' in tab && tab.fields && traverseForLocalizedFields(tab.fields)) { + return true + } + } + } + break + } + } + + return false +} From 78dc90ac3c35101cae4190b1e1258c4790c81fc8 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 3 Dec 2025 13:07:19 +0000 Subject: [PATCH 15/27] chore: revert changes to saveSnapshot and saveVersion --- .../src/collections/operations/utilities/update.ts | 11 ++++++++++- packages/payload/src/versions/saveSnapshot.ts | 1 + packages/payload/src/versions/saveVersion.ts | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 60342918622..53ef2b5628e 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -305,7 +305,16 @@ export const updateDocument = async < const currentDoc = await payload.db.findOne>({ collection: collectionConfig.slug, req, - where: { id: { equals: id } }, + where: { + and: [ + { id: { equals: id } }, + { + _status: { + equals: 'published', + }, + }, + ], + }, }) result = mergeLocalizedData({ diff --git a/packages/payload/src/versions/saveSnapshot.ts b/packages/payload/src/versions/saveSnapshot.ts index 6c47dad4a11..42bbd086ecd 100644 --- a/packages/payload/src/versions/saveSnapshot.ts +++ b/packages/payload/src/versions/saveSnapshot.ts @@ -33,6 +33,7 @@ export const saveSnapshot = async ({ _status?: 'draft' } & T = deepCopyObjectSimple(data || ({} as T)) + docData._status = 'draft' if (docData._id) { delete docData._id } diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index 6c8c55e85eb..907823d323f 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -45,6 +45,9 @@ export const saveVersion = async ({ _status?: 'draft' updatedAt?: string } & TData = deepCopyObjectSimple(docWithLocales) + if (draft) { + versionData._status = 'draft' + } if (collection?.timestamps && draft) { versionData.updatedAt = now From 8d31fc188af4dc81b739f8a3673fc9d51c8a1588 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 3 Dec 2025 13:10:12 +0000 Subject: [PATCH 16/27] chore: add publishAll and unpublishAll to global operations --- .../payload/src/globals/operations/local/update.ts | 14 ++++++++++++++ packages/payload/src/globals/operations/update.ts | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/globals/operations/local/update.ts b/packages/payload/src/globals/operations/local/update.ts index b5e0bc3fc3d..2fbe57ac584 100644 --- a/packages/payload/src/globals/operations/local/update.ts +++ b/packages/payload/src/globals/operations/local/update.ts @@ -65,6 +65,12 @@ export type Options = { * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. */ populate?: PopulateType + /** + * Publish the document / documents in all locales. Requires `versions.drafts.localizeStatus` to be enabled. + * + * @default undefined + */ + publishAllLocales?: boolean /** * Publish the document / documents with a specific locale. */ @@ -87,6 +93,10 @@ export type Options = { * the Global slug to operate against. */ slug: TSlug + /** + * Unpublish the document / documents in all locales. Requires `versions.drafts.localizeStatus` to be enabled. + */ + unpublishAllLocales?: boolean /** * If you set `overrideAccess` to `false`, you can pass a user to use against the access control checks. */ @@ -108,9 +118,11 @@ export async function updateGlobalLocal< overrideAccess = true, overrideLock, populate, + publishAllLocales, publishSpecificLocale, select, showHiddenFields, + unpublishAllLocales, } = options const globalConfig = payload.globals.config.find((config) => config.slug === globalSlug) @@ -128,9 +140,11 @@ export async function updateGlobalLocal< overrideAccess, overrideLock, populate, + publishAllLocales, publishSpecificLocale: publishSpecificLocale!, req: await createLocalReq(options as CreateLocalReqOptions, payload), select, showHiddenFields, + unpublishAllLocales, }) } diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index e24120e5b4f..ed9c5bd2452 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -75,9 +75,14 @@ export const updateOperation = async < req, select: incomingSelect, showHiddenFields, - unpublishAllLocales, + unpublishAllLocales: unpublishAllLocalesArg, } = args + const unpublishAllLocales = + typeof unpublishAllLocalesArg === 'string' + ? unpublishAllLocalesArg === 'true' + : !!unpublishAllLocalesArg + try { const shouldCommit = !disableTransaction && (await initTransaction(req)) From 4acec1cc6105999ff164357a424a6c11cadbe66a Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 3 Dec 2025 14:31:22 +0000 Subject: [PATCH 17/27] chore: fix test --- test/versions/int.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index dfb9560f576..83b1ac48896 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -2584,6 +2584,7 @@ describe('Versions', () => { const retrieved = await payload.findByID({ id: draft.id, collection: draftCollectionSlug, + draft: false, }) expect(retrieved._status).toStrictEqual('published') @@ -2648,7 +2649,6 @@ describe('Versions', () => { description: 'hello', title: 'my doc to publish in the future', }, - draft: true, }) expect(published._status).toStrictEqual('published') From 944a10af3fdd57bad4fdb7fdb620dceecd57a06a Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Wed, 3 Dec 2025 17:52:48 +0000 Subject: [PATCH 18/27] chore: revert save snapshot/version changes and update --- .../operations/utilities/update.ts | 37 +++++++++++-------- packages/payload/src/versions/saveSnapshot.ts | 1 - packages/payload/src/versions/saveVersion.ts | 3 -- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 53ef2b5628e..323909aa0e9 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -28,7 +28,7 @@ import { afterChange } from '../../../fields/hooks/afterChange/index.js' import { afterRead } from '../../../fields/hooks/afterRead/index.js' import { beforeChange } from '../../../fields/hooks/beforeChange/index.js' import { beforeValidate } from '../../../fields/hooks/beforeValidate/index.js' -import { deepCopyObjectSimple, saveVersion } from '../../../index.js' +import { deepCopyObjectSimple, getLatestCollectionVersion, saveVersion } from '../../../index.js' import { deleteAssociatedFiles } from '../../../uploads/deleteAssociatedFiles.js' import { uploadFiles } from '../../../uploads/uploadFiles.js' import { checkDocumentLockStatus } from '../../../utilities/checkDocumentLockStatus.js' @@ -268,6 +268,7 @@ export const updateDocument = async < if (config.localization && collectionConfig.versions) { let isSnapshotRequired = false + let currentDoc if (collectionConfig.versions.drafts && collectionConfig.versions.drafts.localizeStatus) { if (publishAllLocales || unpublishAllLocales) { @@ -293,30 +294,34 @@ export const updateDocument = async < } else if (!isSavingDraft) { // publishing a single locale isSnapshotRequired = true + + currentDoc = await payload.db.findOne>({ + collection: collectionConfig.slug, + req, + where: { id: { equals: id } }, + }) } } else if (publishSpecificLocale) { // previous way of publishing a single locale isSnapshotRequired = true + currentDoc = await getLatestCollectionVersion({ + id, + config: collectionConfig, + payload, + published: true, + query: { + collection: collectionConfig.slug, + locale, + req, + where: { id: { equals: id } }, + }, + req, + }) } if (isSnapshotRequired) { snapshotToSave = deepCopyObjectSimple(result) - const currentDoc = await payload.db.findOne>({ - collection: collectionConfig.slug, - req, - where: { - and: [ - { id: { equals: id } }, - { - _status: { - equals: 'published', - }, - }, - ], - }, - }) - result = mergeLocalizedData({ configBlockReferences: config.blocks, dataWithLocales: result || {}, diff --git a/packages/payload/src/versions/saveSnapshot.ts b/packages/payload/src/versions/saveSnapshot.ts index 42bbd086ecd..6c47dad4a11 100644 --- a/packages/payload/src/versions/saveSnapshot.ts +++ b/packages/payload/src/versions/saveSnapshot.ts @@ -33,7 +33,6 @@ export const saveSnapshot = async ({ _status?: 'draft' } & T = deepCopyObjectSimple(data || ({} as T)) - docData._status = 'draft' if (docData._id) { delete docData._id } diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index 907823d323f..6c8c55e85eb 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -45,9 +45,6 @@ export const saveVersion = async ({ _status?: 'draft' updatedAt?: string } & TData = deepCopyObjectSimple(docWithLocales) - if (draft) { - versionData._status = 'draft' - } if (collection?.timestamps && draft) { versionData.updatedAt = now From 17019d7fbf5f15375501b157fe2bb2cf1582d13b Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 3 Dec 2025 16:26:06 -0500 Subject: [PATCH 19/27] fix: globals --- .../operations/utilities/update.ts | 16 +++-- .../payload/src/globals/operations/update.ts | 72 +++++++++++-------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index 323909aa0e9..2d41f795aa3 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -267,7 +267,7 @@ export const updateDocument = async < let snapshotToSave: JsonObject | undefined if (config.localization && collectionConfig.versions) { - let isSnapshotRequired = false + let snapshotData: JsonObject | undefined let currentDoc if (collectionConfig.versions.drafts && collectionConfig.versions.drafts.localizeStatus) { @@ -293,17 +293,15 @@ export const updateDocument = async < } } else if (!isSavingDraft) { // publishing a single locale - isSnapshotRequired = true - currentDoc = await payload.db.findOne>({ collection: collectionConfig.slug, req, where: { id: { equals: id } }, }) + snapshotData = result } } else if (publishSpecificLocale) { // previous way of publishing a single locale - isSnapshotRequired = true currentDoc = await getLatestCollectionVersion({ id, config: collectionConfig, @@ -311,16 +309,20 @@ export const updateDocument = async < published: true, query: { collection: collectionConfig.slug, - locale, + locale: 'all', req, where: { id: { equals: id } }, }, req, }) + snapshotData = { + ...result, + _status: 'draft', + } } - if (isSnapshotRequired) { - snapshotToSave = deepCopyObjectSimple(result) + if (snapshotData) { + snapshotToSave = deepCopyObjectSimple(snapshotData || {}) result = mergeLocalizedData({ configBlockReferences: config.blocks, diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index ed9c5bd2452..24fc259afdb 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -69,7 +69,7 @@ export const updateOperation = async < overrideAccess, overrideLock, populate, - publishAllLocales, + publishAllLocales: publishAllLocalesArg, publishSpecificLocale, req: { fallbackLocale, locale, payload, payload: { config } = {} }, req, @@ -78,11 +78,6 @@ export const updateOperation = async < unpublishAllLocales: unpublishAllLocalesArg, } = args - const unpublishAllLocales = - typeof unpublishAllLocalesArg === 'string' - ? unpublishAllLocalesArg === 'true' - : !!unpublishAllLocalesArg - try { const shouldCommit = !disableTransaction && (await initTransaction(req)) @@ -105,8 +100,24 @@ export const updateOperation = async < let { data } = args + const publishAllLocales = + !draftArg && + (publishAllLocalesArg ?? + (globalConfig.versions.drafts && globalConfig.versions.drafts.localizeStatus + ? false + : true)) + const unpublishAllLocales = + typeof unpublishAllLocalesArg === 'string' + ? unpublishAllLocalesArg === 'true' + : !!unpublishAllLocalesArg const isSavingDraft = - Boolean(draftArg && globalConfig.versions?.drafts) && data._status !== 'published' + Boolean(draftArg && globalConfig.versions?.drafts) && + data._status !== 'published' && + !publishAllLocales + + if (isSavingDraft) { + data._status = 'draft' + } // ///////////////////////////////////// // 1. Retrieve and execute access @@ -250,7 +261,8 @@ export const updateOperation = async < // ///////////////////////////////////// if (config && config.localization && globalConfig.versions) { - let isSnapshotRequired = false + let currentGlobal: JsonObject | null = null + let snapshotData: JsonObject | undefined if (globalConfig.versions.drafts && globalConfig.versions.drafts.localizeStatus) { if (publishAllLocales || unpublishAllLocales) { @@ -275,36 +287,38 @@ export const updateOperation = async < } } else if (!isSavingDraft) { // publishing a single locale - isSnapshotRequired = true + currentGlobal = await payload.db.findGlobal({ + slug: globalConfig.slug, + req, + where: query, + }) + snapshotData = result } } else if (publishSpecificLocale) { // previous way of publishing a single locale - isSnapshotRequired = true + currentGlobal = ( + await getLatestGlobalVersion({ + slug, + config: globalConfig, + payload, + published: true, + req, + where: query, + }) + ).global + snapshotData = { + ...result, + _status: 'draft', + } } - if (isSnapshotRequired) { - snapshotToSave = deepCopyObjectSimple(result) - - // the published data to save to the main document - const mostRecentPublishedDoc = await beforeChange({ - ...beforeChangeArgs, - docWithLocales: - ( - await getLatestGlobalVersion({ - slug, - config: globalConfig, - payload, - published: true, - req, - where: query, - }) - )?.global || {}, - }) + if (snapshotData) { + snapshotToSave = deepCopyObjectSimple(snapshotData) result = mergeLocalizedData({ configBlockReferences: config.blocks, dataWithLocales: result || {}, - docWithLocales: mostRecentPublishedDoc || {}, + docWithLocales: currentGlobal || {}, fields: globalConfig.fields, selectedLocales: [locale!], }) From a6639f538589bc2faf5c4aa376be225184f06c31 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Thu, 4 Dec 2025 10:44:32 +0000 Subject: [PATCH 20/27] chore: fix global update error --- packages/payload/src/globals/operations/update.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 24fc259afdb..23e9ae3c444 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -103,7 +103,9 @@ export const updateOperation = async < const publishAllLocales = !draftArg && (publishAllLocalesArg ?? - (globalConfig.versions.drafts && globalConfig.versions.drafts.localizeStatus + (globalConfig.versions && + globalConfig.versions.drafts && + globalConfig.versions.drafts.localizeStatus ? false : true)) const unpublishAllLocales = From 299bd32b2e23f71f316c505984f0325e81c39913 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Thu, 4 Dec 2025 16:11:38 +0000 Subject: [PATCH 21/27] chore: add postgres update --- packages/drizzle/src/queries/getTableColumnFromPath.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/drizzle/src/queries/getTableColumnFromPath.ts b/packages/drizzle/src/queries/getTableColumnFromPath.ts index 92056e23487..3ab09ac00b7 100644 --- a/packages/drizzle/src/queries/getTableColumnFromPath.ts +++ b/packages/drizzle/src/queries/getTableColumnFromPath.ts @@ -117,6 +117,7 @@ export const getTableColumnFromPath = ({ } } + let localizedPathQuery = false if (field) { const pathSegments = [...incomingSegments] @@ -131,6 +132,7 @@ export const getTableColumnFromPath = ({ if (matchedLocale) { locale = matchedLocale + localizedPathQuery = true pathSegments.splice(1, 1) } } @@ -967,7 +969,13 @@ export const getTableColumnFromPath = ({ const parentTable = aliasTable || adapter.tables[tableName] newTableName = `${tableName}${adapter.localesSuffix}` - newTable = adapter.tables[newTableName] + // use an alias because the same query may contain constraints with different locale value + if (localizedPathQuery) { + const { newAliasTable } = getTableAlias({ adapter, tableName: newTableName }) + newTable = newAliasTable + } else { + newTable = adapter.tables[newTableName] + } let condition = eq(parentTable.id, newTable._parentID) From f54223f63bc33be365ca4c8a91af479bcce2eb08 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 4 Dec 2025 11:28:43 -0500 Subject: [PATCH 22/27] fix build --- .../versions/drafts/replaceWithDraftIfAvailable.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts index f21baaa179e..aa5c7974a73 100644 --- a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts +++ b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts @@ -8,6 +8,7 @@ import type { PayloadRequest, SelectType, Where } from '../../types/index.js' import { hasWhereAccessResult } from '../../auth/index.js' import { combineQueries } from '../../database/combineQueries.js' import { docHasTimestamps } from '../../types/index.js' +import { hasLocalizeStatusEnabled } from '../../utilities/getVersionsConfig.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' import { appendVersionToQueryKey } from './appendVersionToQueryKey.js' import { getQueryDraftsSelect } from './getQueryDraftsSelect.js' @@ -42,17 +43,16 @@ export const replaceWithDraftIfAvailable = async ({ ], } - if ( - payload.config.localization && - entity.versions.drafts && - entity.versions.drafts.localizeStatus - ) { + if (hasLocalizeStatusEnabled(entity)) { if (locale === 'all') { // TODO: update our drizzle logic to support this type of query queryToBuild = { and: [ { - or: payload.config.localization.localeCodes.map((localeCode) => ({ + or: ( + (payload.config.localization && payload.config.localization.localeCodes) || + [] + ).map((localeCode) => ({ [`version._status.${localeCode}`]: { equals: 'draft', }, From bf0a45c7bc8be944f194bfd376aceace2d0dfc03 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 4 Dec 2025 13:23:20 -0500 Subject: [PATCH 23/27] remove ui changes from this PR --- packages/ui/src/elements/Autosave/index.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx index d11c5d3a8e7..e4f16cca3de 100644 --- a/packages/ui/src/elements/Autosave/index.tsx +++ b/packages/ui/src/elements/Autosave/index.tsx @@ -8,7 +8,6 @@ import { hasDraftValidationEnabled, reduceFieldsToValues, } from 'payload/shared' -import * as qs from 'qs-esm' import React, { useDeferredValue, useEffect, useRef, useState } from 'react' import type { OnSaveContext } from '../../views/Edit/index.js' @@ -129,28 +128,15 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc }) let method: string let entitySlug: string - const params = qs.stringify( - { - autosave: true, - depth: 0, - draft: true, - 'fallback-locale': 'null', - locale: localeRef.current, - }, - { - addQueryPrefix: true, - }, - ) - if (collection && id) { entitySlug = collection.slug - url = `${serverURL}${api}/${entitySlug}/${id}${params}` + url = `${serverURL}${api}/${entitySlug}/${id}?depth=0&draft=true&autosave=true&locale=${localeRef.current}&fallback-locale=null` method = 'PATCH' } if (globalDoc) { entitySlug = globalDoc.slug - url = `${serverURL}${api}/globals/${entitySlug}${params}` + url = `${serverURL}${api}/globals/${entitySlug}?depth=0&draft=true&autosave=true&locale=${localeRef.current}&fallback-locale=null` method = 'POST' } From bf1a77d087a6efbe28809df560bf0a491b90b655 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Thu, 18 Dec 2025 13:25:22 +0000 Subject: [PATCH 24/27] chore: add experimental to localizeStatus types --- packages/payload/src/config/types.ts | 3 --- packages/payload/src/versions/types.ts | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index ea4a866e152..ffe355b7273 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1088,9 +1088,6 @@ export type Config = { email?: EmailAdapter | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] - experimental?: { - localizeStatus?: boolean - } /** * Options for folder view within the admin panel * diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index 3700e664a41..b267089d4b1 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -41,6 +41,8 @@ export type IncomingDrafts = { /** * If true, status will be localized * + * @experimental + * * @default false */ localizeStatus?: boolean @@ -65,6 +67,8 @@ export type SanitizedDrafts = { /** * If true, status will be localized * + * @experimental + * * @default false */ localizeStatus?: boolean From ea6f9a45a514cdeb5e5ba4f6800d37a81c65eadb Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Thu, 18 Dec 2025 15:20:39 +0000 Subject: [PATCH 25/27] chore: correct experimental.localizeStatus --- packages/payload/src/collections/config/sanitize.ts | 8 ++++---- packages/payload/src/config/types.ts | 12 ++++++++++++ packages/payload/src/globals/config/sanitize.ts | 8 ++++---- test/localization/config.ts | 3 +++ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 3bab489c7fe..837fc17318f 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -192,9 +192,9 @@ export const sanitizeCollection = async ( const hasLocalizedFields = traverseForLocalizedFields(sanitized.fields) if (config.localization && hasLocalizedFields) { - sanitized.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) - } else { - sanitized.versions.drafts.localizeStatus = false + if (sanitized.versions.drafts.localizeStatus === undefined) { + sanitized.versions.drafts.localizeStatus = false + } } if (sanitized.versions.drafts.autosave === true) { @@ -210,7 +210,7 @@ export const sanitizeCollection = async ( sanitized.fields = mergeBaseFields( sanitized.fields, baseVersionFields({ - localized: sanitized.versions.drafts.localizeStatus, + localized: sanitized.versions.drafts.localizeStatus ?? false, }), ) } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index ffe355b7273..18f1a962ed2 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1088,6 +1088,18 @@ export type Config = { email?: EmailAdapter | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] + /** + * @experimental features + * These features are not yet fully stable and may change in future versions + */ + experimental?: { + /** + * @experimental when enabled, stores status per-locale + * Required localization and versions.drafts to be enabled + * versions.drafts.localizeStatus must also be set on the collection or global level + */ + localizeStatus?: boolean + } /** * Options for folder view within the admin panel * diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 2e7eaef7c0c..1d014cf26cf 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -118,9 +118,9 @@ export const sanitizeGlobal = async ( const hasLocalizedFields = traverseForLocalizedFields(global.fields) if (config.localization && hasLocalizedFields) { - global.versions.drafts.localizeStatus ??= Boolean(config.experimental?.localizeStatus) - } else { - global.versions.drafts.localizeStatus = false + if (global.versions.drafts.localizeStatus === undefined) { + global.versions.drafts.localizeStatus = false + } } if (global.versions.drafts.autosave === true) { @@ -136,7 +136,7 @@ export const sanitizeGlobal = async ( global.fields = mergeBaseFields( global.fields, baseVersionFields({ - localized: global.versions.drafts.localizeStatus, + localized: global.versions.drafts.localizeStatus ?? false, }), ) } diff --git a/test/localization/config.ts b/test/localization/config.ts index 85ad95dd562..77c9a1dac7f 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -402,6 +402,9 @@ export default buildConfigWithDefaults({ LocalizedWithinLocalized, ArrayWithFallbackCollection, ], + experimental: { + localizeStatus: true, + }, globals: [ { fields: [ From 0115deecfcbcf9051d7cc27e7b60c4cf1c047143 Mon Sep 17 00:00:00 2001 From: Jessica Chowdhury Date: Thu, 18 Dec 2025 15:40:24 +0000 Subject: [PATCH 26/27] chore: update docs --- packages/payload/src/config/types.ts | 14 +++++++++----- packages/payload/src/versions/types.ts | 10 ++++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 18f1a962ed2..967329bce18 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1089,14 +1089,18 @@ export type Config = { /** Custom REST endpoints */ endpoints?: Endpoint[] /** - * @experimental features - * These features are not yet fully stable and may change in future versions + * Experimental features may be unstable or change in future versions. */ experimental?: { /** - * @experimental when enabled, stores status per-locale - * Required localization and versions.drafts to be enabled - * versions.drafts.localizeStatus must also be set on the collection or global level + * Enable per-locale status for documents. + * + * Requires: + * - `localization` enabled + * - `versions.drafts` enabled + * - `versions.drafts.localizeStatus` set at collection or global level + * + * @experimental */ localizeStatus?: boolean } diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index b267089d4b1..c2b52c106f6 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -39,10 +39,11 @@ export type IncomingDrafts = { */ autosave?: Autosave | boolean /** - * If true, status will be localized + * Localizes the status field. * - * @experimental + * Only effective if the experimental `experimental.localizeStatus` is enabled. * + * @experimental * @default false */ localizeStatus?: boolean @@ -65,10 +66,11 @@ export type SanitizedDrafts = { */ autosave: Autosave | false /** - * If true, status will be localized + * Localizes the status field. * - * @experimental + * Only effective if the experimental `experimental.localizeStatus` is enabled. * + * @experimental * @default false */ localizeStatus?: boolean From ad6d3939937e5ae316f8963ebe76bbb311a57a8c Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 18 Dec 2025 15:13:49 -0500 Subject: [PATCH 27/27] require experimental flag to be turned on before collection enabling --- .../src/collections/config/sanitize.ts | 15 ++++++-- .../payload/src/globals/config/sanitize.ts | 4 +++ packages/payload/src/utilities/miniChalk.ts | 35 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 packages/payload/src/utilities/miniChalk.ts diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 837fc17318f..a4f94a6f3ef 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -16,6 +16,7 @@ import { uploadCollectionEndpoints } from '../../uploads/endpoints/index.js' import { getBaseUploadFields } from '../../uploads/getBaseFields.js' import { flattenAllFields } from '../../utilities/flattenAllFields.js' import { formatLabels } from '../../utilities/formatLabels.js' +import { miniChalk } from '../../utilities/miniChalk.js' import { traverseForLocalizedFields } from '../../utilities/traverseForLocalizedFields.js' import { baseVersionFields } from '../../versions/baseFields.js' import { versionDefaults } from '../../versions/defaults.js' @@ -191,12 +192,22 @@ export const sanitizeCollection = async ( const hasLocalizedFields = traverseForLocalizedFields(sanitized.fields) - if (config.localization && hasLocalizedFields) { - if (sanitized.versions.drafts.localizeStatus === undefined) { + if (config.localization) { + if (hasLocalizedFields && sanitized.versions.drafts.localizeStatus === undefined) { sanitized.versions.drafts.localizeStatus = false } } + // TODO v4: remove this sanitization check, should not need to enable the experimental flag + if (sanitized.versions.drafts.localizeStatus && !config.experimental?.localizeStatus) { + sanitized.versions.drafts.localizeStatus = false + console.log( + miniChalk.yellowBold( + `Warning: "localizeStatus" for drafts is an experimental feature. To enable, set "experimental.localizeStatus" to true in your Payload config.`, + ), + ) + } + if (sanitized.versions.drafts.autosave === true) { sanitized.versions.drafts.autosave = { interval: versionDefaults.autosaveInterval, diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index 1d014cf26cf..df06e835bc2 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -123,6 +123,10 @@ export const sanitizeGlobal = async ( } } + global.versions.drafts.localizeStatus = config.experimental?.localizeStatus + ? global.versions.drafts.localizeStatus + : false + if (global.versions.drafts.autosave === true) { global.versions.drafts.autosave = { interval: versionDefaults.autosaveInterval, diff --git a/packages/payload/src/utilities/miniChalk.ts b/packages/payload/src/utilities/miniChalk.ts new file mode 100644 index 00000000000..a6e4e158aca --- /dev/null +++ b/packages/payload/src/utilities/miniChalk.ts @@ -0,0 +1,35 @@ +const codes = { + blue: '\x1b[34m', + bold: '\x1b[1m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + green: '\x1b[32m', + magenta: '\x1b[35m', + red: '\x1b[31m', + reset: '\x1b[0m', + underline: '\x1b[4m', + white: '\x1b[37m', + yellow: '\x1b[33m', +} + +function colorize(str: string, ...styles: (keyof typeof codes)[]) { + const start = styles.map((s) => codes[s] || '').join('') + return `${start}${str}${codes.reset}` +} + +export const miniChalk = { + blue: (str: string) => colorize(str, 'blue'), + bold: (str: string) => colorize(str, 'bold'), + cyan: (str: string) => colorize(str, 'cyan'), + dim: (str: string) => colorize(str, 'dim'), + green: (str: string) => colorize(str, 'green'), + magenta: (str: string) => colorize(str, 'magenta'), + red: (str: string) => colorize(str, 'red'), + underline: (str: string) => colorize(str, 'underline'), + white: (str: string) => colorize(str, 'white'), + yellow: (str: string) => colorize(str, 'yellow'), + + // combos + redBold: (str: string) => colorize(str, 'red', 'bold'), + yellowBold: (str: string) => colorize(str, 'yellow', 'bold'), +}