Skip to content

Commit 38b8c68

Browse files
fix: preserve locale data in unnamed groups with localizeStatus (#15658)
1 parent e3d8a94 commit 38b8c68

2 files changed

Lines changed: 219 additions & 15 deletions

File tree

packages/payload/src/utilities/mergeLocalizedData.spec.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,5 +749,178 @@ describe('mergeLocalizedData', () => {
749749
es: 'Spanish Description',
750750
})
751751
})
752+
753+
it('should not lose other locale data when processing unnamed groups', () => {
754+
// https://github.com/payloadcms/payload/issues/15642
755+
const fields: Field[] = [
756+
{
757+
name: 'textFieldRoot',
758+
type: 'text',
759+
},
760+
{
761+
name: 'textFieldRootLocalized',
762+
type: 'text',
763+
localized: true,
764+
},
765+
// Unnamed group - fields at same data level as root
766+
{
767+
type: 'group',
768+
fields: [
769+
{
770+
name: 'textFieldNested',
771+
type: 'text',
772+
},
773+
{
774+
name: 'textFieldNestedLocalized',
775+
type: 'text',
776+
localized: true,
777+
},
778+
],
779+
},
780+
]
781+
782+
// Document already has English data published
783+
const docWithLocales = {
784+
textFieldRoot: 'Root Value',
785+
textFieldRootLocalized: {
786+
en: 'English Root Localized',
787+
es: 'Spanish Root Localized',
788+
},
789+
textFieldNested: 'Nested Value',
790+
textFieldNestedLocalized: {
791+
en: 'English Nested Localized',
792+
es: 'Spanish Nested Localized',
793+
},
794+
}
795+
796+
// Publishing only English locale with updated data
797+
const dataWithLocales = {
798+
textFieldRoot: 'Updated Root Value',
799+
textFieldRootLocalized: {
800+
en: 'Updated English Root Localized',
801+
},
802+
textFieldNested: 'Updated Nested Value',
803+
textFieldNestedLocalized: {
804+
en: 'Updated English Nested Localized',
805+
},
806+
}
807+
808+
const result = mergeLocalizedData({
809+
configBlockReferences: [],
810+
dataWithLocales,
811+
docWithLocales,
812+
fields,
813+
selectedLocales: ['en'],
814+
})
815+
816+
// Root non-localized field should be updated
817+
expect(result.textFieldRoot).toBe('Updated Root Value')
818+
819+
// Root localized field should merge: update en, preserve es
820+
expect(result.textFieldRootLocalized).toEqual({
821+
en: 'Updated English Root Localized',
822+
es: 'Spanish Root Localized',
823+
})
824+
825+
// Nested non-localized field should be updated
826+
expect(result.textFieldNested).toBe('Updated Nested Value')
827+
828+
// Nested localized field should merge: update en, preserve es
829+
// This is the bug - es data is lost
830+
expect(result.textFieldNestedLocalized).toEqual({
831+
en: 'Updated English Nested Localized',
832+
es: 'Spanish Nested Localized',
833+
})
834+
})
835+
836+
it('should not lose other locale data when processing row fields', () => {
837+
const fields: Field[] = [
838+
{
839+
type: 'row',
840+
fields: [
841+
{
842+
name: 'rowFieldLocalized',
843+
type: 'text',
844+
localized: true,
845+
},
846+
],
847+
},
848+
]
849+
850+
const docWithLocales = {
851+
rowFieldLocalized: {
852+
en: 'English Value',
853+
es: 'Spanish Value',
854+
},
855+
}
856+
857+
const dataWithLocales = {
858+
rowFieldLocalized: {
859+
en: 'Updated English Value',
860+
},
861+
}
862+
863+
const result = mergeLocalizedData({
864+
configBlockReferences: [],
865+
dataWithLocales,
866+
docWithLocales,
867+
fields,
868+
selectedLocales: ['en'],
869+
})
870+
871+
expect(result.rowFieldLocalized).toEqual({
872+
en: 'Updated English Value',
873+
es: 'Spanish Value',
874+
})
875+
})
876+
877+
it('should preserve other locale data when updating through unnamed tabs', () => {
878+
const fields: Field[] = [
879+
{
880+
type: 'tabs',
881+
tabs: [
882+
{
883+
label: 'Tab 1',
884+
fields: [
885+
{
886+
name: 'tabFieldLocalized',
887+
type: 'text',
888+
localized: true,
889+
},
890+
],
891+
},
892+
],
893+
},
894+
]
895+
896+
// Document has both en and es data
897+
const docWithLocales = {
898+
tabFieldLocalized: {
899+
en: 'English Value',
900+
es: 'Spanish Value',
901+
},
902+
}
903+
904+
// Only updating en
905+
const dataWithLocales = {
906+
tabFieldLocalized: {
907+
en: 'Updated English Value',
908+
},
909+
}
910+
911+
const result = mergeLocalizedData({
912+
configBlockReferences: [],
913+
dataWithLocales,
914+
docWithLocales,
915+
fields,
916+
selectedLocales: ['en'],
917+
})
918+
919+
// es should be preserved
920+
expect(result.tabFieldLocalized).toEqual({
921+
en: 'Updated English Value',
922+
es: 'Spanish Value',
923+
})
924+
})
752925
})
753926
})

packages/payload/src/utilities/mergeLocalizedData.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,35 @@ import type { JsonObject } from '../types/index.js'
44

55
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../fields/config/types.js'
66

7+
/**
8+
* Collects field names at the current data level, recursing through pass-through fields
9+
* (row, collapsible, unnamed group, unnamed tab) but stopping at named fields.
10+
*/
11+
function collectFlattenedFieldNames(fields: Field[]): Set<string> {
12+
const names = new Set<string>()
13+
for (const field of fields) {
14+
if (fieldAffectsData(field)) {
15+
names.add(field.name)
16+
} else if ('fields' in field && Array.isArray(field.fields)) {
17+
// Pass-through fields (row, collapsible, unnamed group)
18+
for (const name of collectFlattenedFieldNames(field.fields)) {
19+
names.add(name)
20+
}
21+
} else if (field.type === 'tabs') {
22+
for (const tab of field.tabs) {
23+
if (tabHasName(tab)) {
24+
names.add(tab.name)
25+
} else {
26+
for (const name of collectFlattenedFieldNames(tab.fields)) {
27+
names.add(name)
28+
}
29+
}
30+
}
31+
}
32+
}
33+
return names
34+
}
35+
736
type MergeDataToSelectedLocalesArgs = {
837
configBlockReferences: SanitizedConfig['blocks']
938
dataWithLocales: JsonObject
@@ -178,17 +207,6 @@ export function mergeLocalizedData({
178207
})
179208
}
180209
}
181-
} else {
182-
// Unnamed groups pass through the same data level
183-
const merged = mergeLocalizedData({
184-
configBlockReferences,
185-
dataWithLocales,
186-
docWithLocales: result, // Use current result to avoid re-processing already-handled fields
187-
fields: field.fields,
188-
parentIsLocalized,
189-
selectedLocales,
190-
})
191-
Object.assign(result, merged)
192210
}
193211
break
194212
}
@@ -234,17 +252,24 @@ export function mergeLocalizedData({
234252
// Layout-only fields that don't affect data structure
235253
switch (field.type) {
236254
case 'collapsible':
255+
case 'group':
237256
case 'row': {
238257
// These pass through the same data level
239258
const merged = mergeLocalizedData({
240259
configBlockReferences,
241260
dataWithLocales,
242-
docWithLocales: result, // Use current result to avoid re-processing already-handled fields
261+
docWithLocales,
243262
fields: field.fields,
244263
parentIsLocalized,
245264
selectedLocales,
246265
})
247-
Object.assign(result, merged)
266+
// Only copy fields that belong to this layout field to avoid overwriting already-processed fields
267+
const fieldNames = collectFlattenedFieldNames(field.fields)
268+
for (const name of fieldNames) {
269+
if (name in merged) {
270+
result[name] = merged[name]
271+
}
272+
}
248273
break
249274
}
250275

@@ -290,12 +315,18 @@ export function mergeLocalizedData({
290315
const merged = mergeLocalizedData({
291316
configBlockReferences,
292317
dataWithLocales,
293-
docWithLocales: result, // Use current result to avoid re-processing already-handled fields
318+
docWithLocales,
294319
fields: tab.fields,
295320
parentIsLocalized,
296321
selectedLocales,
297322
})
298-
Object.assign(result, merged)
323+
// Only copy fields that belong to this tab to avoid overwriting already-processed fields
324+
const tabFieldNames = collectFlattenedFieldNames(tab.fields)
325+
for (const name of tabFieldNames) {
326+
if (name in merged) {
327+
result[name] = merged[name]
328+
}
329+
}
299330
}
300331
}
301332
break

0 commit comments

Comments
 (0)