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) diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 724e36705ed..a4f94a6f3ef 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -16,6 +16,8 @@ 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' import { defaultCollectionEndpoints } from '../endpoints/index.js' @@ -166,14 +168,17 @@ 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, + maxPerDoc: 100, + } + } + sanitized.versions.maxPerDoc = typeof sanitized.versions.maxPerDoc === 'number' ? sanitized.versions.maxPerDoc : 100 @@ -185,6 +190,24 @@ export const sanitizeCollection = async ( } } + const hasLocalizedFields = traverseForLocalizedFields(sanitized.fields) + + 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, @@ -195,7 +218,12 @@ export const sanitizeCollection = async ( sanitized.versions.drafts.validate = false } - sanitized.fields = mergeBaseFields(sanitized.fields, baseVersionFields) + sanitized.fields = mergeBaseFields( + sanitized.fields, + baseVersionFields({ + localized: sanitized.versions.drafts.localizeStatus ?? false, + }), + ) } } else { delete sanitized.versions 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/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/create.ts b/packages/payload/src/collections/operations/create.ts index ee5ae9b2db2..01c2d5d0b32 100644 --- a/packages/payload/src/collections/operations/create.ts +++ b/packages/payload/src/collections/operations/create.ts @@ -29,7 +29,11 @@ import { generateFileData } from '../../uploads/generateFileData.js' import { unlinkTempFiles } from '../../uploads/unlinkTempFiles.js' import { uploadFiles } from '../../uploads/uploadFiles.js' import { commitTransaction } from '../../utilities/commitTransaction.js' -import { hasDraftsEnabled, hasDraftValidationEnabled } from '../../utilities/getVersionsConfig.js' +import { + hasDraftsEnabled, + hasDraftValidationEnabled, + hasLocalizeStatusEnabled, +} from '../../utilities/getVersionsConfig.js' import { initTransaction } from '../../utilities/initTransaction.js' import { killTransaction } from '../../utilities/killTransaction.js' import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js' @@ -49,6 +53,7 @@ export type Arguments = { overrideAccess?: boolean overwriteExistingFiles?: boolean populate?: PopulateType + publishAllLocales?: boolean publishSpecificLocale?: string req: PayloadRequest select?: SelectType @@ -100,6 +105,7 @@ export const createOperation = async < overrideAccess, overwriteExistingFiles = false, populate, + publishAllLocales: publishAllLocalesArg, publishSpecificLocale, req: { fallbackLocale, @@ -115,7 +121,14 @@ export const createOperation = async < let { data } = args - const isSavingDraft = Boolean(draft && hasDraftsEnabled(collectionConfig)) + const publishAllLocales = + !draft && + (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(collectionConfig) ? false : true)) + const isSavingDraft = Boolean(draft && hasDraftsEnabled(collectionConfig) && !publishAllLocales) + + if (isSavingDraft) { + data._status = 'draft' + } let duplicatedFromDocWithLocales: JsonObject = {} let duplicatedFromDoc: JsonObject = {} @@ -229,6 +242,34 @@ export const createOperation = async < skipValidation: isSavingDraft && !hasDraftValidationEnabled(collectionConfig), }) + 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/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 @@ -59,6 +60,7 @@ export type Arguments = { */ sort?: Sort trash?: boolean + unpublishAllLocales?: boolean where: Where } @@ -98,6 +100,7 @@ export const updateOperation = async < overrideLock, overwriteExistingFiles = false, populate, + publishAllLocales, publishSpecificLocale, req: { fallbackLocale, @@ -110,6 +113,7 @@ export const updateOperation = async < showHiddenFields, sort: incomingSort, trash = false, + unpublishAllLocales, where, } = args @@ -238,7 +242,6 @@ export const updateOperation = async < // /////////////////////////////////////////////// const updatedDoc = await updateDocument({ id, - accessResults: accessResult, autosave, collectionConfig, config, @@ -253,10 +256,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 2a4b94c3bec..b5b0f0f5e55 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -46,11 +46,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 < @@ -89,6 +91,7 @@ export const updateByIDOperation = async < overrideLock, overwriteExistingFiles = false, populate, + publishAllLocales, publishSpecificLocale, req: { fallbackLocale, @@ -100,6 +103,7 @@ export const updateByIDOperation = async < select: incomingSelect, showHiddenFields, trash = false, + unpublishAllLocales, } = args if (!id) { @@ -197,7 +201,6 @@ export const updateByIDOperation = async < let result = await updateDocument({ id, - accessResults, autosave, collectionConfig, config, @@ -212,10 +215,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 033c506e0b1..d535a3ffbda 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,23 +24,21 @@ 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' 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' import { hasDraftsEnabled, hasDraftValidationEnabled, + hasLocalizeStatusEnabled, } from '../../../utilities/getVersionsConfig.js' -import { getLatestCollectionVersion } from '../../../versions/getLatestCollectionVersion.js' - +import { mergeLocalizedData } from '../../../utilities/mergeLocalizedData.js' export type SharedUpdateDocumentArgs = { - accessResults: AccessResult autosave: boolean collectionConfig: SanitizedCollectionConfig config: SanitizedConfig @@ -57,10 +54,12 @@ export type SharedUpdateDocumentArgs = { overrideLock: boolean payload: Payload populate?: PopulateType + publishAllLocales?: boolean publishSpecificLocale?: string req: PayloadRequest select: SelectType showHiddenFields: boolean + unpublishAllLocales?: boolean } /** @@ -81,7 +80,6 @@ export const updateDocument = async < TSelect extends SelectFromCollectionSlug = SelectType, >({ id, - accessResults, autosave, collectionConfig, config, @@ -96,14 +94,25 @@ export const updateDocument = async < overrideLock, payload, populate, + publishAllLocales: publishAllLocalesArg, publishSpecificLocale, req, select, showHiddenFields, + unpublishAllLocales: unpublishAllLocalesArg, }: SharedUpdateDocumentArgs): Promise> => { const password = data?.password + const publishAllLocales = + !draftArg && + (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(collectionConfig) ? false : true)) + const unpublishAllLocales = + typeof unpublishAllLocalesArg === 'string' + ? unpublishAllLocalesArg === 'true' + : !!unpublishAllLocalesArg const isSavingDraft = - Boolean(draftArg && hasDraftsEnabled(collectionConfig)) && data._status !== 'published' + Boolean(draftArg && hasDraftsEnabled(collectionConfig)) && + data._status !== 'published' && + !publishAllLocales const shouldSavePassword = Boolean( password && collectionConfig.auth && @@ -113,6 +122,10 @@ export const updateDocument = async < !isSavingDraft, ) + if (isSavingDraft) { + data._status = 'draft' + } + // ///////////////////////////////////// // Handle potentially locked documents // ///////////////////////////////////// @@ -247,40 +260,87 @@ 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) - - // 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), - }, + let snapshotData: JsonObject | undefined + let currentDoc + + if (collectionConfig.versions.drafts && collectionConfig.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 + 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 + currentDoc = await getLatestCollectionVersion({ + id, + config: collectionConfig, + payload, + published: true, + query: { + collection: collectionConfig.slug, + locale: 'all', + req, + where: { id: { equals: id } }, + }, + req, + }) + snapshotData = { + ...result, + _status: 'draft', + } + } + + if (snapshotData) { + snapshotToSave = deepCopyObjectSimple(snapshotData || {}) + + result = mergeLocalizedData({ + configBlockReferences: config.blocks, + dataWithLocales: result || {}, + docWithLocales: currentDoc || {}, + fields: collectionConfig.fields, + selectedLocales: [locale], }) } } + const dataToUpdate: JsonObject = { ...result } + // ///////////////////////////////////// // Handle potential password update // ///////////////////////////////////// - const dataToUpdate: JsonObject = { ...result } - if (shouldSavePassword && typeof password === 'string') { const { hash, salt } = await generatePasswordSaltHash({ collection: collectionConfig, diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 9f4c606e21f..ec63e05c64c 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -1140,6 +1140,22 @@ export type Config = { email?: EmailAdapter | Promise /** Custom REST endpoints */ endpoints?: Endpoint[] + /** + * Experimental features may be unstable or change in future versions. + */ + experimental?: { + /** + * Enable per-locale status for documents. + * + * Requires: + * - `localization` enabled + * - `versions.drafts` enabled + * - `versions.drafts.localizeStatus` set at collection or global level + * + * @experimental + */ + localizeStatus?: boolean + } /** * Options for folder view within the admin panel * diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index d5174c5532d..0ecc4a286c6 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -103,6 +103,7 @@ export { hasAutosaveEnabled, hasDraftsEnabled, hasDraftValidationEnabled, + hasLocalizeStatusEnabled, hasScheduledPublishEnabled, } from '../utilities/getVersionsConfig.js' diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index d79d3f580a1..df06e835bc2 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, @@ -99,7 +99,10 @@ export const sanitizeGlobal = async ( if (global.versions) { if (global.versions === true) { - global.versions = { drafts: false, max: 100 } + global.versions = { + drafts: false, + max: 100, + } } global.versions.max = typeof global.versions.max === 'number' ? global.versions.max : 100 @@ -112,6 +115,18 @@ export const sanitizeGlobal = async ( } } + const hasLocalizedFields = traverseForLocalizedFields(global.fields) + + if (config.localization && hasLocalizedFields) { + if (global.versions.drafts.localizeStatus === undefined) { + global.versions.drafts.localizeStatus = false + } + } + + 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, @@ -122,7 +137,12 @@ export const sanitizeGlobal = async ( global.versions.drafts.validate = false } - global.fields = mergeBaseFields(global.fields, baseVersionFields) + global.fields = mergeBaseFields( + global.fields, + baseVersionFields({ + localized: global.versions.drafts.localizeStatus ?? false, + }), + ) } } 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/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 9232e11f0dc..0c750943f96 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -24,13 +24,17 @@ import { deepCopyObjectSimple } from '../../index.js' import { checkDocumentLockStatus } from '../../utilities/checkDocumentLockStatus.js' import { commitTransaction } from '../../utilities/commitTransaction.js' import { getSelectMode } from '../../utilities/getSelectMode.js' -import { hasDraftsEnabled, hasDraftValidationEnabled } from '../../utilities/getVersionsConfig.js' +import { + hasDraftsEnabled, + hasDraftValidationEnabled, + hasLocalizeStatusEnabled, +} from '../../utilities/getVersionsConfig.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'>> @@ -41,11 +45,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 < @@ -68,11 +74,13 @@ export const updateOperation = async < overrideAccess, overrideLock, populate, + publishAllLocales: publishAllLocalesArg, publishSpecificLocale, - req: { fallbackLocale, locale, payload }, + req: { fallbackLocale, locale, payload, payload: { config } = {} }, req, select: incomingSelect, showHiddenFields, + unpublishAllLocales: unpublishAllLocalesArg, } = args try { @@ -97,8 +105,20 @@ export const updateOperation = async < let { data } = args + const publishAllLocales = + !draftArg && (publishAllLocalesArg ?? (hasLocalizeStatusEnabled(globalConfig) ? false : true)) + const unpublishAllLocales = + typeof unpublishAllLocalesArg === 'string' + ? unpublishAllLocalesArg === 'true' + : !!unpublishAllLocalesArg const isSavingDraft = - Boolean(draftArg && hasDraftsEnabled(globalConfig)) && data._status !== 'published' + Boolean(draftArg && hasDraftsEnabled(globalConfig)) && + data._status !== 'published' && + !publishAllLocales + + if (isSavingDraft) { + data._status = 'draft' + } // ///////////////////////////////////// // 1. Retrieve and execute access @@ -236,28 +256,74 @@ export const updateOperation = async < let result: JsonObject = await beforeChange(beforeChangeArgs) let snapshotToSave: JsonObject | undefined - if (payload.config.localization && globalConfig.versions) { - if (publishSpecificLocale) { - snapshotToSave = deepCopyObjectSimple(result) - - // the published data to save to the main document - result = await beforeChange({ - ...beforeChangeArgs, - docWithLocales: - ( - await getLatestGlobalVersion({ - slug, - config: globalConfig, - payload, - published: true, - req, - where: query, - }) - )?.global || {}, + // ///////////////////////////////////// + // Handle Localized Data Merging + // ///////////////////////////////////// + + if (config && config.localization && globalConfig.versions) { + let currentGlobal: JsonObject | null = null + let snapshotData: JsonObject | undefined + + 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 + currentGlobal = await payload.db.findGlobal({ + slug: globalConfig.slug, + req, + where: query, + }) + snapshotData = result + } + } else if (publishSpecificLocale) { + // previous way of publishing a single locale + currentGlobal = ( + await getLatestGlobalVersion({ + slug, + config: globalConfig, + payload, + published: true, + req, + where: query, + }) + ).global + snapshotData = { + ...result, + _status: 'draft', + } + } + + if (snapshotData) { + snapshotToSave = deepCopyObjectSimple(snapshotData) + + result = mergeLocalizedData({ + configBlockReferences: config.blocks, + dataWithLocales: result || {}, + docWithLocales: currentGlobal || {}, + fields: globalConfig.fields, + selectedLocales: [locale!], }) } } - // ///////////////////////////////////// // Update // ///////////////////////////////////// diff --git a/packages/payload/src/utilities/getVersionsConfig.ts b/packages/payload/src/utilities/getVersionsConfig.ts index a0849602b12..d3ba8013b3d 100644 --- a/packages/payload/src/utilities/getVersionsConfig.ts +++ b/packages/payload/src/utilities/getVersionsConfig.ts @@ -13,6 +13,19 @@ export const hasDraftsEnabled = (config: EntityConfig): boolean => { return Boolean(config?.versions && typeof config.versions === 'object' && config.versions.drafts) } +/** + * Check if an entity has localized status enabled + */ +export const hasLocalizeStatusEnabled = (config: EntityConfig): boolean => { + return Boolean( + config?.versions && + typeof config.versions === 'object' && + config.versions.drafts && + typeof config.versions.drafts === 'object' && + config.versions.drafts.localizeStatus, + ) +} + /** * Check if an entity has autosave enabled */ @@ -71,10 +84,10 @@ export const getVersionsMax = (config: EntityConfig): number => { } // Collections have maxPerDoc, globals have max if ('maxPerDoc' in config.versions) { - return config.versions.maxPerDoc ?? 0 + return config.versions.maxPerDoc ?? 100 } if ('max' in config.versions) { - return config.versions.max ?? 0 + return config.versions.max ?? 100 } return 0 } diff --git a/packages/payload/src/utilities/mergeLocalizedData.spec.ts b/packages/payload/src/utilities/mergeLocalizedData.spec.ts new file mode 100644 index 00000000000..4ff85bf4958 --- /dev/null +++ b/packages/payload/src/utilities/mergeLocalizedData.spec.ts @@ -0,0 +1,753 @@ +import type { Field } from '../fields/config/types.js' + +import { describe, expect, it } from 'vitest' + +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 +} 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'), +} diff --git a/packages/payload/src/utilities/parseParams/index.ts b/packages/payload/src/utilities/parseParams/index.ts index 617ab958452..26938d52ec7 100644 --- a/packages/payload/src/utilities/parseParams/index.ts +++ b/packages/payload/src/utilities/parseParams/index.ts @@ -8,27 +8,6 @@ import { sanitizePopulateParam } from '../sanitizePopulateParam.js' import { sanitizeSelectParam } from '../sanitizeSelectParam.js' import { sanitizeSortParams } from '../sanitizeSortParams.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 @@ -43,14 +22,39 @@ type RawParams = { page?: string pagination?: string populate?: unknown + publishAllLocales?: string publishSpecificLocale?: string select?: unknown selectedLocales?: string sort?: string | 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/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 +} diff --git a/packages/payload/src/versions/baseFields.ts b/packages/payload/src/versions/baseFields.ts index 91435afc62c..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: Field[] = [ +export const baseVersionFields = ({ localized }: { localized: 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(localized), options: statuses, }, ] diff --git a/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts b/packages/payload/src/versions/drafts/replaceWithDraftIfAvailable.ts index 25fcc45b285..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' @@ -30,9 +31,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 +43,36 @@ export const replaceWithDraftIfAvailable = async ({ ], } + if (hasLocalizeStatusEnabled(entity)) { + if (locale === 'all') { + // TODO: update our drizzle logic to support this type of query + queryToBuild = { + and: [ + { + or: ( + (payload.config.localization && 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/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 6c8746d6265..f86ebe69771 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -47,10 +47,6 @@ export const saveVersion = async ({ updatedAt?: string } & TData = deepCopyObjectSimple(docWithLocales) - if (draft) { - versionData._status = 'draft' - } - if (collection?.timestamps && draft) { versionData.updatedAt = now } diff --git a/packages/payload/src/versions/types.ts b/packages/payload/src/versions/types.ts index a2238ee3459..c2b52c106f6 100644 --- a/packages/payload/src/versions/types.ts +++ b/packages/payload/src/versions/types.ts @@ -38,6 +38,15 @@ export type IncomingDrafts = { * To enable, set to true or pass an object with options. */ autosave?: Autosave | boolean + /** + * Localizes the status field. + * + * Only effective if the experimental `experimental.localizeStatus` is enabled. + * + * @experimental + * @default false + */ + localizeStatus?: boolean /** * Allow for editors to schedule publish / unpublish events in the future. */ @@ -56,6 +65,15 @@ export type SanitizedDrafts = { * To enable, set to true or pass an object with options. */ autosave: Autosave | false + /** + * Localizes the status field. + * + * Only effective if the experimental `experimental.localizeStatus` is enabled. + * + * @experimental + * @default false + */ + localizeStatus?: boolean /** * Allow for editors to schedule publish / unpublish events in the future. */ diff --git a/test/localization/collections/AllFields/index.ts b/test/localization/collections/AllFields/index.ts index 8d640cf6a53..a3dc07ef9d6 100644 --- a/test/localization/collections/AllFields/index.ts +++ b/test/localization/collections/AllFields/index.ts @@ -273,6 +273,8 @@ export const AllFieldsLocalized: CollectionConfig = { }, ], versions: { - drafts: true, + drafts: { + localizeStatus: true, + }, }, } diff --git a/test/localization/config.ts b/test/localization/config.ts index ac99dc5084c..574525b41ec 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: [ diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 502a6c24bdd..2a2bfb700bd 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -3619,6 +3619,333 @@ describe('Localization', () => { }) }) + describe('localize status', () => { + it('should set other locales to draft upon creation', async () => { + 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).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' + + // 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') + }) + + it('should preserve published and draft data when publishing other locales', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + text: 'en published', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'en draft', + _status: 'draft', + }, + draft: true, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'es published', + _status: 'published', + }, + locale: spanishLocale, + }) + + const mainDocument = await payload.findByID({ + locale: 'all', + id: doc.id, + collection: allFieldsLocalizedSlug, + draft: false, + }) + + 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') + }) + + 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') + }) + }) + describe('localized queries', () => { it('should count versions with query on localized field', async () => { await payload.create({ diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index 2cf4b1fa84d..55fd6361c15 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -1776,6 +1776,6 @@ export interface Auth { declare module 'payload' { - // @ts-ignore + // @ts-ignore export interface GeneratedTypes extends Config {} -} +} \ No newline at end of file diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index d48214cdb0e..7522e931d61 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -2585,6 +2585,7 @@ describe('Versions', () => { const retrieved = await payload.findByID({ id: draft.id, collection: draftCollectionSlug, + draft: false, }) expect(retrieved._status).toStrictEqual('published') @@ -2649,7 +2650,6 @@ describe('Versions', () => { description: 'hello', title: 'my doc to publish in the future', }, - draft: true, }) expect(published._status).toStrictEqual('published')