diff --git a/packages/payload/src/collections/operations/utilities/update.ts b/packages/payload/src/collections/operations/utilities/update.ts index f604129ecf1..4ea088a0584 100644 --- a/packages/payload/src/collections/operations/utilities/update.ts +++ b/packages/payload/src/collections/operations/utilities/update.ts @@ -37,7 +37,11 @@ import { hasDraftValidationEnabled, hasLocalizeStatusEnabled, } from '../../../utilities/getVersionsConfig.js' +import { mergeLocalizedData } from '../../../utilities/mergeLocalizedData.js' import { buildLocalizedPublishData } from '../../../versions/buildSingleLocalePublishData.js' +import { getLocalizedDraftStatus } from '../../../versions/drafts/getLocalizedDraftStatus.js' +import { hasNonLocalizedDataChanged } from '../../../versions/drafts/hasNonLocalizedDataChanged.js' + export type SharedUpdateDocumentArgs = { autosave: boolean collectionConfig: SanitizedCollectionConfig @@ -312,6 +316,25 @@ export const updateDocument = async < for (const localeCode of accessibleLocaleCodes) { result._status[localeCode] = unpublishAllLocales ? 'draft' : 'published' } + } else if (isSavingDraft) { + const shouldDraftAllLocales = hasNonLocalizedDataChanged({ + after: mergeLocalizedData({ + configBlockReferences: config.blocks, + dataWithLocales: result, + docWithLocales, + fields: collectionConfig.fields, + localesToUpdate: config.localization.localeCodes, + }), + before: docWithLocales, + configBlockReferences: config.blocks, + fields: collectionConfig.fields, + }) + + result._status = getLocalizedDraftStatus({ + existingStatus: shouldDraftAllLocales ? result._status : docWithLocales._status, + locale: shouldDraftAllLocales ? 'all' : locale, + localeCodes: config.localization.localeCodes, + }) } else if ( !isSavingDraft && result._status && diff --git a/packages/payload/src/globals/operations/update.ts b/packages/payload/src/globals/operations/update.ts index 85461cf379a..f89cd1143ee 100644 --- a/packages/payload/src/globals/operations/update.ts +++ b/packages/payload/src/globals/operations/update.ts @@ -35,6 +35,8 @@ import { killTransaction } from '../../utilities/killTransaction.js' import { resolveSelect } from '../../utilities/resolveSelect.js' import { sanitizeSelect } from '../../utilities/sanitizeSelect.js' import { buildLocalizedPublishData } from '../../versions/buildSingleLocalePublishData.js' +import { getLocalizedDraftStatus } from '../../versions/drafts/getLocalizedDraftStatus.js' +import { hasNonLocalizedDataChanged } from '../../versions/drafts/hasNonLocalizedDataChanged.js' import { getLatestGlobalVersion } from '../../versions/getLatestGlobalVersion.js' import { saveVersion } from '../../versions/saveVersion.js' type Args = { @@ -298,6 +300,19 @@ export const updateOperation = async < for (const localeCode of accessibleLocaleCodes) { result._status[localeCode] = unpublishAllLocales ? 'draft' : 'published' } + } else if (isSavingDraft) { + const shouldDraftAllLocales = hasNonLocalizedDataChanged({ + after: result, + before: globalJSON, + configBlockReferences: config.blocks, + fields: globalConfig.fields, + }) + + result._status = getLocalizedDraftStatus({ + existingStatus: shouldDraftAllLocales ? result._status : globalJSON._status, + locale: shouldDraftAllLocales ? 'all' : locale!, + localeCodes: config.localization.localeCodes, + }) } else if ( !isSavingDraft && result._status && diff --git a/packages/payload/src/versions/drafts/getLocalizedDraftStatus.spec.ts b/packages/payload/src/versions/drafts/getLocalizedDraftStatus.spec.ts new file mode 100644 index 00000000000..35956fdbcb9 --- /dev/null +++ b/packages/payload/src/versions/drafts/getLocalizedDraftStatus.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { getLocalizedDraftStatus } from './getLocalizedDraftStatus.js' + +describe('getLocalizedDraftStatus', () => { + it('should preserve existing locale statuses and draft the requested locale', () => { + expect( + getLocalizedDraftStatus({ + existingStatus: { + en: 'published', + es: 'published', + }, + locale: 'en', + localeCodes: ['en', 'es'], + }), + ).toStrictEqual({ + en: 'draft', + es: 'published', + }) + }) + + it('should draft all configured locales for locale all without adding an all key', () => { + expect( + getLocalizedDraftStatus({ + existingStatus: { + en: 'published', + es: 'published', + }, + locale: 'all', + localeCodes: ['en', 'es'], + }), + ).toStrictEqual({ + en: 'draft', + es: 'draft', + }) + }) +}) diff --git a/packages/payload/src/versions/drafts/getLocalizedDraftStatus.ts b/packages/payload/src/versions/drafts/getLocalizedDraftStatus.ts new file mode 100644 index 00000000000..828b31b7dbe --- /dev/null +++ b/packages/payload/src/versions/drafts/getLocalizedDraftStatus.ts @@ -0,0 +1,26 @@ +import type { JsonObject } from '../../types/index.js' + +type GetLocalizedDraftStatusArgs = { + existingStatus: unknown + locale: string + localeCodes: string[] +} + +export const getLocalizedDraftStatus = ({ + existingStatus, + locale, + localeCodes, +}: GetLocalizedDraftStatusArgs): JsonObject => { + const status = + existingStatus && typeof existingStatus === 'object' && !Array.isArray(existingStatus) + ? { ...(existingStatus as JsonObject) } + : {} + + const localesToDraft = locale === 'all' ? localeCodes : [locale] + + for (const localeCode of localesToDraft) { + status[localeCode] = 'draft' + } + + return status +} diff --git a/packages/payload/src/versions/drafts/hasNonLocalizedDataChanged.spec.ts b/packages/payload/src/versions/drafts/hasNonLocalizedDataChanged.spec.ts new file mode 100644 index 00000000000..9cc9c4a7aac --- /dev/null +++ b/packages/payload/src/versions/drafts/hasNonLocalizedDataChanged.spec.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from 'vitest' + +import type { Field } from '../../fields/config/types.js' + +import { hasNonLocalizedDataChanged } from './hasNonLocalizedDataChanged.js' + +const fields: Field[] = [ + { + name: 'title', + type: 'text', + localized: true, + }, + { + name: 'summary', + type: 'text', + }, + { + name: 'content', + type: 'richText', + }, + { + name: 'meta', + type: 'group', + fields: [ + { + name: 'localizedDescription', + type: 'text', + localized: true, + }, + { + name: 'sharedDescription', + type: 'text', + }, + ], + }, + { + name: 'rows', + type: 'array', + fields: [ + { + name: 'localizedLabel', + type: 'text', + localized: true, + }, + { + name: 'sharedLabel', + type: 'text', + }, + ], + }, + { + name: 'blocks', + type: 'blocks', + blocks: [ + { + slug: 'textBlock', + fields: [ + { + name: 'localizedBlockText', + type: 'text', + localized: true, + }, + { + name: 'sharedBlockText', + type: 'text', + }, + ], + }, + ], + }, +] + +describe('hasNonLocalizedDataChanged', () => { + it('should ignore localized field changes', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + title: { en: 'Draft EN', es: 'Published ES' }, + summary: 'Shared', + }, + before: { + title: { en: 'Published EN', es: 'Published ES' }, + summary: 'Shared', + }, + configBlockReferences: [], + fields, + }), + ).toBe(false) + }) + + it('should detect non-localized scalar field changes', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + summary: 'Shared draft', + }, + before: { + summary: 'Shared published', + }, + configBlockReferences: [], + fields, + }), + ).toBe(true) + }) + + it('should detect rich-text-like non-localized object changes', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + content: { + root: { + children: [{ text: 'Draft rich text', type: 'text' }], + type: 'root', + }, + }, + }, + before: { + content: { + root: { + children: [{ text: 'Published rich text', type: 'text' }], + type: 'root', + }, + }, + }, + configBlockReferences: [], + fields, + }), + ).toBe(true) + }) + + it('should compare non-localized children inside non-localized groups', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + meta: { + localizedDescription: { en: 'Draft EN' }, + sharedDescription: 'Shared draft', + }, + }, + before: { + meta: { + localizedDescription: { en: 'Published EN' }, + sharedDescription: 'Shared published', + }, + }, + configBlockReferences: [], + fields, + }), + ).toBe(true) + }) + + it('should ignore localized children inside non-localized groups', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + meta: { + localizedDescription: { en: 'Draft EN' }, + sharedDescription: 'Shared', + }, + }, + before: { + meta: { + localizedDescription: { en: 'Published EN' }, + sharedDescription: 'Shared', + }, + }, + configBlockReferences: [], + fields, + }), + ).toBe(false) + }) + + it('should ignore empty non-localized groups when the stored document omits them', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + meta: {}, + title: { en: 'Draft EN' }, + }, + before: { + title: { en: 'Published EN' }, + }, + configBlockReferences: [], + fields, + }), + ).toBe(false) + }) + + it('should ignore empty non-localized arrays when the stored document omits them', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + rows: [], + title: { en: 'Draft EN' }, + }, + before: { + title: { en: 'Published EN' }, + }, + configBlockReferences: [], + fields, + }), + ).toBe(false) + }) + + it('should detect non-localized array structure changes', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + rows: [ + { id: 'row-1', sharedLabel: 'One' }, + { id: 'row-2', sharedLabel: 'Two' }, + ], + }, + before: { + rows: [{ id: 'row-1', sharedLabel: 'One' }], + }, + configBlockReferences: [], + fields, + }), + ).toBe(true) + }) + + it('should detect removing all rows from a non-localized array', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + rows: [], + }, + before: { + rows: [{ id: 'row-1', sharedLabel: 'One' }], + }, + configBlockReferences: [], + fields, + }), + ).toBe(true) + }) + + it('should ignore localized child changes inside existing non-localized array rows', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + rows: [{ id: 'row-1', localizedLabel: { en: 'Draft EN' }, sharedLabel: 'One' }], + }, + before: { + rows: [{ id: 'row-1', localizedLabel: { en: 'Published EN' }, sharedLabel: 'One' }], + }, + configBlockReferences: [], + fields, + }), + ).toBe(false) + }) + + it('should detect non-localized block structure changes', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + blocks: [ + { + blockType: 'textBlock', + id: 'block-1', + sharedBlockText: 'One', + }, + ], + }, + before: { + blocks: [], + }, + configBlockReferences: [], + fields, + }), + ).toBe(true) + }) + + it('should detect removing all rows from a non-localized blocks field', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + blocks: [], + }, + before: { + blocks: [ + { + blockType: 'textBlock', + id: 'block-1', + sharedBlockText: 'One', + }, + ], + }, + configBlockReferences: [], + fields, + }), + ).toBe(true) + }) + + it('should ignore localized child changes inside existing non-localized blocks', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + blocks: [ + { + blockType: 'textBlock', + id: 'block-1', + localizedBlockText: { en: 'Draft EN' }, + sharedBlockText: 'Shared', + }, + ], + }, + before: { + blocks: [ + { + blockType: 'textBlock', + id: 'block-1', + localizedBlockText: { en: 'Published EN' }, + sharedBlockText: 'Shared', + }, + ], + }, + configBlockReferences: [], + fields, + }), + ).toBe(false) + }) + + it('should ignore undefined values when comparing non-localized data', () => { + expect( + hasNonLocalizedDataChanged({ + after: { + meta: { + sharedDescription: undefined, + }, + rows: [{ id: 'row-1', sharedLabel: 'One' }, undefined], + summary: 'Shared', + }, + before: { + meta: {}, + rows: [{ id: 'row-1', sharedLabel: 'One' }], + summary: 'Shared', + }, + configBlockReferences: [], + fields, + }), + ).toBe(false) + }) +}) diff --git a/packages/payload/src/versions/drafts/hasNonLocalizedDataChanged.ts b/packages/payload/src/versions/drafts/hasNonLocalizedDataChanged.ts new file mode 100644 index 00000000000..2e665df413e --- /dev/null +++ b/packages/payload/src/versions/drafts/hasNonLocalizedDataChanged.ts @@ -0,0 +1,286 @@ +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 ComparableValue = + | { [key: string]: ComparableValue } + | boolean + | ComparableValue[] + | null + | number + | string + +type HasNonLocalizedDataChangedArgs = { + after: JsonObject + before: JsonObject + configBlockReferences: SanitizedConfig['blocks'] + fields: Field[] +} + +type NormalizeArgs = { + configBlockReferences: SanitizedConfig['blocks'] + data: JsonObject + fields: Field[] + parentIsLocalized?: boolean +} + +export const hasNonLocalizedDataChanged = ({ + after, + before, + configBlockReferences, + fields, +}: HasNonLocalizedDataChangedArgs): boolean => { + const beforeSharedData = normalizeNonLocalizedData({ + configBlockReferences, + data: removeUndefinedValues(before), + fields, + }) + const afterSharedData = normalizeNonLocalizedData({ + configBlockReferences, + data: removeUndefinedValues(after), + fields, + }) + + return stableStringify(beforeSharedData) !== stableStringify(afterSharedData) +} + +const removeUndefinedValues = (value: T): T => { + if (Array.isArray(value)) { + return value + .map((item) => removeUndefinedValues(item)) + .filter((item) => typeof item !== 'undefined') as T + } + + if (value && typeof value === 'object') { + const cleanedValue: Record = {} + + for (const [key, item] of Object.entries(value as Record)) { + const cleanedItem = removeUndefinedValues(item) + + if (typeof cleanedItem !== 'undefined') { + cleanedValue[key] = cleanedItem + } + } + + return cleanedValue as T + } + + return value +} + +const normalizeNonLocalizedData = ({ + configBlockReferences, + data, + fields, + parentIsLocalized = false, +}: NormalizeArgs): Record => { + const result: Record = {} + + for (const field of fields) { + if (fieldAffectsData(field)) { + if (!(field.name in data)) { + continue + } + + const fieldIsLocalized = fieldShouldBeLocalized({ field, parentIsLocalized }) + + if (fieldIsLocalized) { + continue + } + + const value = data[field.name] + + switch (field.type) { + case 'array': { + const normalizedRows = normalizeArrayRows({ + configBlockReferences, + fields: field.fields, + rows: Array.isArray(value) ? value : [], + }) + + if (normalizedRows.length > 0) { + result[field.name] = normalizedRows + } + break + } + + case 'blocks': { + const normalizedBlocks = normalizeBlocks({ + blocks: Array.isArray(value) ? value : [], + configBlockReferences, + fieldBlocks: field.blocks, + }) + + if (normalizedBlocks.length > 0) { + result[field.name] = normalizedBlocks + } + break + } + + case 'group': { + const normalizedGroup = normalizeNonLocalizedData({ + configBlockReferences, + data: isJsonObject(value) ? value : {}, + fields: field.fields, + parentIsLocalized, + }) + + if (Object.keys(normalizedGroup).length > 0) { + result[field.name] = normalizedGroup + } + break + } + + default: { + result[field.name] = normalizeComparableValue(value) + break + } + } + } else if ('fields' in field && Array.isArray(field.fields)) { + Object.assign( + result, + normalizeNonLocalizedData({ + configBlockReferences, + data, + fields: field.fields, + parentIsLocalized, + }), + ) + } else if (field.type === 'tabs') { + for (const tab of field.tabs) { + const tabIsLocalized = fieldShouldBeLocalized({ field: tab, parentIsLocalized }) + + if (tabHasName(tab)) { + if (tabIsLocalized || !(tab.name in data)) { + continue + } + + const tabValue = data[tab.name] + const normalizedTab = normalizeNonLocalizedData({ + configBlockReferences, + data: isJsonObject(tabValue) ? tabValue : {}, + fields: tab.fields, + parentIsLocalized, + }) + + if (Object.keys(normalizedTab).length > 0) { + result[tab.name] = normalizedTab + } + } else { + Object.assign( + result, + normalizeNonLocalizedData({ + configBlockReferences, + data, + fields: tab.fields, + parentIsLocalized, + }), + ) + } + } + } + } + + return result +} + +const normalizeArrayRows = ({ + configBlockReferences, + fields, + rows, +}: { + configBlockReferences: SanitizedConfig['blocks'] + fields: Field[] + rows: unknown[] +}): ComparableValue[] => { + return rows.map((row) => { + const rowData = isJsonObject(row) ? row : {} + + return { + id: normalizeComparableValue(rowData.id), + ...normalizeNonLocalizedData({ + configBlockReferences, + data: rowData, + fields, + }), + } + }) +} + +const normalizeBlocks = ({ + blocks, + configBlockReferences, + fieldBlocks, +}: { + blocks: unknown[] + configBlockReferences: SanitizedConfig['blocks'] + fieldBlocks: (Block | string)[] +}): ComparableValue[] => { + return blocks.map((blockData) => { + const blockObject = isJsonObject(blockData) ? blockData : {} + const blockType = typeof blockObject.blockType === 'string' ? blockObject.blockType : undefined + const blockOrSlug = fieldBlocks.find((block) => { + const slug = typeof block === 'string' ? block : block.slug + return slug === blockType + }) + const block: Block | FlattenedBlock | undefined = + typeof blockOrSlug === 'string' + ? configBlockReferences?.find((blockRef) => blockRef.slug === blockOrSlug) + : blockOrSlug + + return { + id: normalizeComparableValue(blockObject.id), + blockName: normalizeComparableValue(blockObject.blockName), + blockType: normalizeComparableValue(blockObject.blockType), + ...(block + ? normalizeNonLocalizedData({ + configBlockReferences, + data: blockObject, + fields: block.fields, + }) + : {}), + } + }) +} + +const isJsonObject = (value: unknown): value is JsonObject => { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +const normalizeComparableValue = (value: unknown): ComparableValue => { + if (Array.isArray(value)) { + return value.map((item) => normalizeComparableValue(item)) + } + + if (value && typeof value === 'object') { + const objectValue = value as Record + const result: Record = {} + + for (const key of Object.keys(objectValue).sort()) { + const normalizedValue = normalizeComparableValue(objectValue[key]) + + if (typeof normalizedValue !== 'undefined') { + result[key] = normalizedValue + } + } + + return result + } + + if ( + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' || + value === null + ) { + return value + } + + return null +} + +const stableStringify = (value: ComparableValue | Record): string => { + return JSON.stringify(value) +} diff --git a/test/localization/config.ts b/test/localization/config.ts index ce684fe8f41..2db87ab7e4b 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -33,6 +33,7 @@ import { localizedDateFieldsSlug, localizedPostsSlug, localizedSortSlug, + localizedStatusSharedGlobalSlug, portugueseLocale, relationEnglishTitle, relationEnglishTitle2, @@ -467,6 +468,25 @@ export default buildConfigWithDefaults({ slug: 'global-text', versions: false, }, + { + fields: [ + { + name: 'localizedText', + localized: true, + type: 'text', + }, + { + name: 'sharedText', + type: 'text', + }, + ], + slug: localizedStatusSharedGlobalSlug, + versions: { + drafts: { + localizeStatus: true, + }, + }, + }, { fields: [ { diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 487ec51e490..6b71c1fce94 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -37,6 +37,7 @@ import { localizedDraftsSlug, localizedPostsSlug, localizedSortSlug, + localizedStatusSharedGlobalSlug, portugueseLocale, relationEnglishTitle, relationEnglishTitle2, @@ -4077,6 +4078,238 @@ describe('Localization', () => { expect(latestVersionDoc.text!.es).toBe('spanish draft 2') }) + it('should mark all locales as draft when a draft save changes a non-localized field', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + nonLocalizedGroup: { + nonLocalizedText: 'shared published', + }, + text: 'english published', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'spanish published', + _status: 'published', + }, + locale: spanishLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + nonLocalizedGroup: { + nonLocalizedText: 'shared draft', + }, + text: 'english draft', + _status: 'draft', + }, + draft: true, + locale: defaultLocale, + }) + + const allLocalesDraft = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + draft: true, + locale: 'all', + }) + + expect(allLocalesDraft._status!.en).toBe('draft') + expect(allLocalesDraft._status!.es).toBe('draft') + expect(allLocalesDraft.text!.en).toBe('english draft') + expect(allLocalesDraft.text!.es).toBe('spanish published') + expect(allLocalesDraft.nonLocalizedGroup?.nonLocalizedText).toBe('shared draft') + + const spanishView = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + draft: true, + locale: spanishLocale, + }) + + expect(spanishView._status).toBe('draft') + expect(spanishView.text).toBe('spanish published') + expect(spanishView.nonLocalizedGroup?.nonLocalizedText).toBe('shared draft') + }) + + it('should preserve non-localized draft changes when another locale saves a localized draft', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + nonLocalizedGroup: { + nonLocalizedText: 'shared published before second locale draft', + }, + text: 'english published before second locale draft', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'spanish published before second locale draft', + _status: 'published', + }, + locale: spanishLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + nonLocalizedGroup: { + nonLocalizedText: 'shared draft from english', + }, + text: 'english draft before second locale draft', + _status: 'draft', + }, + draft: true, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'spanish draft after shared english draft', + _status: 'draft', + }, + draft: true, + locale: spanishLocale, + }) + + const englishDraft = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + draft: true, + locale: defaultLocale, + }) + + expect(englishDraft._status).toBe('draft') + expect(englishDraft.nonLocalizedGroup?.nonLocalizedText).toBe('shared draft from english') + + const allLocalesDraft = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + draft: true, + locale: 'all', + }) + + expect(allLocalesDraft._status!.en).toBe('draft') + expect(allLocalesDraft._status!.es).toBe('draft') + expect(allLocalesDraft.nonLocalizedGroup?.nonLocalizedText).toBe( + 'shared draft from english', + ) + }) + + it('should keep published locales published when a draft save changes only localized fields', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + nonLocalizedGroup: { + nonLocalizedText: 'shared published localized-only test', + }, + text: 'english published localized-only test', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'spanish published localized-only test', + _status: 'published', + }, + locale: spanishLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'english draft localized-only test', + _status: 'draft', + }, + draft: true, + locale: defaultLocale, + }) + + const allLocalesDraft = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + draft: true, + locale: 'all', + }) + + expect(allLocalesDraft._status!.en).toBe('draft') + expect(allLocalesDraft._status!.es).toBe('published') + expect(allLocalesDraft.text!.en).toBe('english draft localized-only test') + expect(allLocalesDraft.text!.es).toBe('spanish published localized-only test') + expect(allLocalesDraft.nonLocalizedGroup?.nonLocalizedText).toBe( + 'shared published localized-only test', + ) + }) + + it('should mark all locales as draft when saving a draft with locale all', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + nonLocalizedGroup: { + nonLocalizedText: 'shared published all-locale draft', + }, + text: 'english published all-locale draft', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: 'spanish published all-locale draft', + _status: 'published', + }, + locale: spanishLocale, + }) + + await payload.update({ + collection: allFieldsLocalizedSlug, + id: doc.id, + data: { + text: { + en: 'english draft all-locale draft', + es: 'spanish draft all-locale draft', + }, + _status: 'draft', + }, + draft: true, + locale: 'all', + }) + + const allLocalesDraft = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + draft: true, + locale: 'all', + }) + + expect(allLocalesDraft._status!.en).toBe('draft') + expect(allLocalesDraft._status!.es).toBe('draft') + }) + it('should allow querying metadata per locale', async () => { const doc = await payload.create({ collection: allFieldsLocalizedSlug, @@ -4281,6 +4514,135 @@ describe('Localization', () => { expect(unpublishedDocument._status!.en).toBe('draft') expect(unpublishedDocument._status!.es).toBe('draft') }) + + it('should mark all locales as draft when a global draft save changes a non-localized field', async () => { + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + localizedText: englishTitle, + sharedText: 'english global published', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + localizedText: spanishTitle, + sharedText: 'spanish global published', + _status: 'published', + }, + locale: spanishLocale, + }) + + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + sharedText: 'english global draft', + _status: 'draft', + }, + draft: true, + locale: defaultLocale, + }) + + const allLocalesDraft = await payload.findGlobal({ + slug: localizedStatusSharedGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(allLocalesDraft._status!.en).toBe('draft') + expect(allLocalesDraft._status!.es).toBe('draft') + }) + + it('should keep published locales published when a global draft save changes only localized fields', async () => { + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + localizedText: englishTitle, + sharedText: 'english global published localized-only', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + localizedText: spanishTitle, + sharedText: 'spanish global published localized-only', + _status: 'published', + }, + locale: spanishLocale, + }) + + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + localizedText: 'english global draft localized-only', + _status: 'draft', + }, + draft: true, + locale: defaultLocale, + }) + + const allLocalesDraft = await payload.findGlobal({ + slug: localizedStatusSharedGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(allLocalesDraft._status!.en).toBe('draft') + expect(allLocalesDraft._status!.es).toBe('published') + expect(allLocalesDraft.localizedText!.en).toBe('english global draft localized-only') + expect(allLocalesDraft.localizedText!.es).toBe(spanishTitle) + expect(allLocalesDraft.sharedText).toBe('spanish global published localized-only') + }) + + it('should mark all locales as draft when saving a global draft with locale all', async () => { + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + localizedText: englishTitle, + sharedText: 'english global published all-locale draft', + _status: 'published', + }, + locale: defaultLocale, + }) + + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + localizedText: spanishTitle, + sharedText: 'spanish global published all-locale draft', + _status: 'published', + }, + locale: spanishLocale, + }) + + await payload.updateGlobal({ + slug: localizedStatusSharedGlobalSlug, + data: { + localizedText: { + en: 'english global draft all-locale draft', + es: 'spanish global draft all-locale draft', + }, + _status: 'draft', + }, + draft: true, + locale: 'all', + }) + + const allLocalesDraft = await payload.findGlobal({ + slug: localizedStatusSharedGlobalSlug, + draft: true, + locale: 'all', + }) + + expect(allLocalesDraft._status!.en).toBe('draft') + expect(allLocalesDraft._status!.es).toBe('draft') + }) }) }) diff --git a/test/localization/shared.ts b/test/localization/shared.ts index 075b5e33900..f5462d020b7 100644 --- a/test/localization/shared.ts +++ b/test/localization/shared.ts @@ -27,3 +27,4 @@ export const arrayWithFallbackCollectionSlug = 'array-with-fallback-fields' export const localeRestrictedSlug = 'locale-restricted' export const globalWithDraftsSlug = 'global-drafts' +export const localizedStatusSharedGlobalSlug = 'localized-status-shared-global'