From 8040c8558c4734e6ca4c778da4a1e8d2531ec0fe Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 13 Nov 2025 13:40:25 -0500 Subject: [PATCH 1/4] fix: localization transforms on nested localized fields --- .../db-mongodb/src/utilities/transform.ts | 39 ++- .../collections/AllFields/index.ts | 238 ++++++++++++++++++ test/localization/config.ts | 2 + test/localization/int.spec.ts | 99 ++++++++ test/localization/payload-types.ts | 212 ++++++++++++++++ test/localization/shared.ts | 1 + 6 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 test/localization/collections/AllFields/index.ts diff --git a/packages/db-mongodb/src/utilities/transform.ts b/packages/db-mongodb/src/utilities/transform.ts index f28b4a08bb9..0e87a83637f 100644 --- a/packages/db-mongodb/src/utilities/transform.ts +++ b/packages/db-mongodb/src/utilities/transform.ts @@ -305,11 +305,13 @@ const stripFields = ({ config, data, fields, + parentIsLocalized = false, reservedKeys = [], }: { config: SanitizedConfig data: any fields: FlattenedField[] + parentIsLocalized?: boolean reservedKeys?: string[] }) => { for (const k in data) { @@ -325,12 +327,14 @@ const stripFields = ({ continue } + const shouldLocalizeField = fieldShouldBeLocalized({ field, parentIsLocalized }) + if (field.type === 'blocks') { reservedKeys.push('blockType') } if ('flattenedFields' in field || 'blocks' in field) { - if (field.localized && config.localization) { + if (shouldLocalizeField && config.localization) { for (const localeKey in fieldData) { if (!config.localization.localeCodes.some((code) => code === localeKey)) { delete fieldData[localeKey] @@ -389,7 +393,13 @@ const stripFields = ({ continue } - stripFields({ config, data, fields, reservedKeys }) + stripFields({ + config, + data, + fields, + parentIsLocalized: parentIsLocalized || field.localized, + reservedKeys, + }) } if (hasNull) { @@ -398,7 +408,13 @@ const stripFields = ({ continue } else { - stripFields({ config, data: localeData, fields: field.flattenedFields, reservedKeys }) + stripFields({ + config, + data: localeData, + fields: field.flattenedFields, + parentIsLocalized: parentIsLocalized || field.localized, + reservedKeys, + }) } } continue @@ -451,7 +467,13 @@ const stripFields = ({ continue } - stripFields({ config, data, fields, reservedKeys }) + stripFields({ + config, + data, + fields, + parentIsLocalized: parentIsLocalized || field.localized, + reservedKeys, + }) } if (hasNull) { @@ -460,7 +482,13 @@ const stripFields = ({ continue } else { - stripFields({ config, data: fieldData, fields: field.flattenedFields, reservedKeys }) + stripFields({ + config, + data: fieldData, + fields: field.flattenedFields, + parentIsLocalized: parentIsLocalized || field.localized, + reservedKeys, + }) } } } @@ -524,6 +552,7 @@ export const transform = ({ config, data, fields: flattenAllFields({ cache: true, fields }), + parentIsLocalized: false, reservedKeys: ['id', 'globalType'], }) } diff --git a/test/localization/collections/AllFields/index.ts b/test/localization/collections/AllFields/index.ts new file mode 100644 index 00000000000..eb565b1bb06 --- /dev/null +++ b/test/localization/collections/AllFields/index.ts @@ -0,0 +1,238 @@ +import type { CollectionConfig } from 'payload' + +import { allFieldsLocalizedSlug } from '../../shared.js' + +export const AllFieldsLocalized: CollectionConfig = { + slug: allFieldsLocalizedSlug, + fields: [ + // Simple localized fields + { + name: 'text', + type: 'text', + localized: true, + }, + { + name: 'textarea', + type: 'textarea', + localized: true, + }, + { + name: 'number', + type: 'number', + localized: true, + }, + { + name: 'email', + type: 'email', + localized: true, + }, + { + name: 'code', + type: 'code', + localized: true, + }, + { + name: 'json', + type: 'json', + localized: true, + }, + { + name: 'select', + type: 'select', + localized: true, + options: ['option1', 'option2', 'option3'], + }, + { + name: 'radio', + type: 'radio', + localized: true, + options: ['radio1', 'radio2'], + }, + { + name: 'checkbox', + type: 'checkbox', + localized: true, + }, + { + name: 'date', + type: 'date', + localized: true, + }, + + // Localized group with localized children + { + name: 'localizedGroup', + type: 'group', + fields: [ + { + name: 'title', + type: 'text', + localized: true, + }, + { + name: 'description', + type: 'textarea', + localized: true, + }, + ], + localized: true, + }, + + // Non-localized group with localized children + { + name: 'nonLocalizedGroup', + type: 'group', + fields: [ + { + name: 'localizedText', + type: 'text', + localized: true, + }, + { + name: 'nonLocalizedText', + type: 'text', + }, + ], + }, + + // Localized array with localized children + { + name: 'localizedArray', + type: 'array', + fields: [ + { + name: 'item', + type: 'text', + localized: true, + }, + ], + localized: true, + }, + + // Non-localized array with localized children + { + name: 'nonLocalizedArray', + type: 'array', + fields: [ + { + name: 'localizedItem', + type: 'text', + localized: true, + }, + ], + }, + + // Localized blocks with nested localized fields + { + name: 'localizedBlocks', + type: 'blocks', + blocks: [ + { + slug: 'localizedTextBlock', + fields: [ + { + name: 'text', + type: 'text', + localized: true, + }, + ], + }, + { + slug: 'nestedBlock', + fields: [ + { + name: 'nestedArray', + type: 'array', + fields: [ + { + name: 'item', + type: 'text', + }, + ], + localized: true, + }, + ], + }, + ], + localized: true, + }, + + // Named tabs with localized tab + { + type: 'tabs', + tabs: [ + { + name: 'localizedTab', + fields: [ + { + name: 'tabText', + type: 'text', + localized: true, + }, + ], + label: 'Localized Tab', + localized: true, + }, + { + name: 'nonLocalizedTab', + fields: [ + { + name: 'localizedInNonLocalizedTab', + type: 'text', + localized: true, + }, + ], + label: 'Non-Localized Tab', + }, + ], + }, + + // Unnamed tab (passes through) + { + type: 'tabs', + tabs: [ + { + fields: [ + { + name: 'unnamedTabLocalizedText', + type: 'text', + localized: true, + }, + ], + label: 'Unnamed Tab', + }, + ], + }, + + // Deeply nested: localized group > non-localized group > localized array + { + name: 'g1', + type: 'group', + label: 'Deeply Nested Group', + fields: [ + { + name: 'g2', + type: 'group', + fields: [ + { + name: 'g2a1', + type: 'array', + fields: [ + { + name: 'text', + type: 'text', + localized: true, + }, + ], + localized: true, + }, + ], + }, + ], + localized: true, + }, + ], + versions: { + drafts: true, + }, +} diff --git a/test/localization/config.ts b/test/localization/config.ts index 9269e0e0255..85ad95dd562 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -8,6 +8,7 @@ import type { LocalizedPost } from './payload-types.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' +import { AllFieldsLocalized } from './collections/AllFields/index.js' import { ArrayCollection } from './collections/Array/index.js' import { ArrayWithFallbackCollection } from './collections/ArrayWithFallback/index.js' import { BlocksCollection } from './collections/Blocks/index.js' @@ -68,6 +69,7 @@ export default buildConfigWithDefaults({ NestedFields, LocalizedDrafts, LocalizedDateFields, + AllFieldsLocalized, { admin: { listSearchableFields: 'name', diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 270708f7ecc..72a082ba371 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -25,6 +25,7 @@ import { groupSlug } from './collections/Group/index.js' import { nestedToArrayAndBlockCollectionSlug } from './collections/NestedToArrayAndBlock/index.js' import { tabSlug } from './collections/Tab/index.js' import { + allFieldsLocalizedSlug, defaultLocale, defaultLocale as englishLocale, englishTitle, @@ -3442,6 +3443,104 @@ describe('Localization', () => { }) }) }) + + describe('Localized data shape', () => { + beforeEach(async () => { + await payload.delete({ + collection: allFieldsLocalizedSlug, + where: { + id: { + exists: true, + }, + }, + }) + }) + it('should only nest the top level localized field values under locale keys', async () => { + const doc = await payload.create({ + collection: allFieldsLocalizedSlug, + data: { + g1: { + g2: { + g2a1: [{ text: 'EN Deep 1' }, { text: 'EN Deep 2' }], + }, + }, + localizedArray: [{ item: 'EN Item 1' }, { item: 'EN Item 2' }], + localizedBlocks: [ + { blockType: 'localizedTextBlock', text: 'EN Text' }, + { blockType: 'nestedBlock', nestedArray: [{ item: 'EN Nested' }] }, + ], + localizedGroup: { + description: 'EN Description', + title: 'EN Title', + }, + localizedTab: { + tabText: 'EN Tab Text', + }, + nonLocalizedArray: [{ localizedItem: 'EN Item 1' }, { localizedItem: 'EN Item 2' }], + nonLocalizedGroup: { + localizedText: 'EN Localized', + nonLocalizedText: 'Shared Text', + }, + number: 100, + select: 'option1', + text: 'English text', + _status: 'draft', + }, + locale: 'en', + }) + + const allLocalesDoc = await payload.findByID({ + collection: allFieldsLocalizedSlug, + id: doc.id, + locale: 'all', + }) + + // Verify simple localized fields have locale keys at top level + expect((allLocalesDoc.text as any).en).toBe('English text') + expect((allLocalesDoc.text as any).es).toBeUndefined() + expect((allLocalesDoc.number as any).en).toBe(100) + expect((allLocalesDoc.select as any).en).toBe('option1') + + // Verify localized group has locale keys at top level, children do not + expect((allLocalesDoc.localizedGroup as any).en).toBeDefined() + expect((allLocalesDoc.localizedGroup as any).en.title).toBe('EN Title') + expect((allLocalesDoc.localizedGroup as any).en.description).toBe('EN Description') + expect((allLocalesDoc.localizedGroup as any).es).toBeUndefined() + + // Verify non-localized group with localized children + expect(allLocalesDoc.nonLocalizedGroup!.nonLocalizedText).toBe('Shared Text') + expect((allLocalesDoc.nonLocalizedGroup!.localizedText as any).en).toBe('EN Localized') + expect((allLocalesDoc.nonLocalizedGroup!.localizedText as any).es).toBeUndefined() + + // Verify localized array has locale keys at top level, items do not + expect((allLocalesDoc.localizedArray as any).en).toHaveLength(2) + expect((allLocalesDoc.localizedArray as any).en[0].item).toBe('EN Item 1') + expect((allLocalesDoc.localizedArray as any).en[1].item).toBe('EN Item 2') + expect((allLocalesDoc.localizedArray as any).es).toBeUndefined() + + // Verify non-localized array with localized children + expect(allLocalesDoc.nonLocalizedArray).toHaveLength(2) + expect((allLocalesDoc.nonLocalizedArray?.[0]!.localizedItem as any).en).toBe('EN Item 1') + expect((allLocalesDoc.nonLocalizedArray?.[0]!.localizedItem as any).es).toBeUndefined() + + // Verify localized blocks have locale keys at top level, nested fields do not + expect((allLocalesDoc.localizedBlocks as any).en).toHaveLength(2) + expect((allLocalesDoc.localizedBlocks as any).en[0].text).toBe('EN Text') + expect((allLocalesDoc.localizedBlocks as any).en[1].nestedArray[0].item).toBe('EN Nested') + expect((allLocalesDoc.localizedBlocks as any).es).toBeUndefined() + + // Verify localized named tabs have locale keys at top level + expect((allLocalesDoc.localizedTab as any).en).toBeDefined() + expect((allLocalesDoc.localizedTab as any).en.tabText).toBe('EN Tab Text') + expect((allLocalesDoc.localizedTab as any).es).toBeUndefined() + + // Verify deeply nested localization has locale keys only at topmost localized field + expect((allLocalesDoc.g1 as any).en).toBeDefined() + expect((allLocalesDoc.g1 as any).en.g2.g2a1).toHaveLength(2) + expect((allLocalesDoc.g1 as any).en.g2.g2a1[0].text).toBe('EN Deep 1') + expect((allLocalesDoc.g1 as any).es).toBeUndefined() + }) + }) }) async function createLocalizedPost(data: { diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index 3353ebdde87..2770a4b7949 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -73,6 +73,7 @@ export interface Config { 'nested-field-tables': NestedFieldTable; 'localized-drafts': LocalizedDraft; 'localized-date-fields': LocalizedDateField; + 'all-fields-localized': AllFieldsLocalized; users: User; 'localized-posts': LocalizedPost; 'no-localized-fields': NoLocalizedField; @@ -88,6 +89,7 @@ export interface Config { 'blocks-same-name': BlocksSameName; 'localized-within-localized': LocalizedWithinLocalized; 'array-with-fallback-fields': ArrayWithFallbackField; + 'payload-kv': PayloadKv; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -100,6 +102,7 @@ export interface Config { 'nested-field-tables': NestedFieldTablesSelect | NestedFieldTablesSelect; 'localized-drafts': LocalizedDraftsSelect | LocalizedDraftsSelect; 'localized-date-fields': LocalizedDateFieldsSelect | LocalizedDateFieldsSelect; + 'all-fields-localized': AllFieldsLocalizedSelect | AllFieldsLocalizedSelect; users: UsersSelect | UsersSelect; 'localized-posts': LocalizedPostsSelect | LocalizedPostsSelect; 'no-localized-fields': NoLocalizedFieldsSelect | NoLocalizedFieldsSelect; @@ -115,6 +118,7 @@ export interface Config { 'blocks-same-name': BlocksSameNameSelect | BlocksSameNameSelect; 'localized-within-localized': LocalizedWithinLocalizedSelect | LocalizedWithinLocalizedSelect; 'array-with-fallback-fields': ArrayWithFallbackFieldsSelect | ArrayWithFallbackFieldsSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -192,6 +196,7 @@ export interface RichText { */ export interface BlocksField { id: string; + title?: string | null; tabContent?: | { text?: string | null; @@ -356,6 +361,92 @@ export interface LocalizedDateField { createdAt: string; _status?: ('draft' | 'published') | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "all-fields-localized". + */ +export interface AllFieldsLocalized { + id: string; + text?: string | null; + textarea?: string | null; + number?: number | null; + email?: string | null; + code?: string | null; + json?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + select?: ('option1' | 'option2' | 'option3') | null; + radio?: ('radio1' | 'radio2') | null; + checkbox?: boolean | null; + date?: string | null; + localizedGroup?: { + title?: string | null; + description?: string | null; + }; + nonLocalizedGroup?: { + localizedText?: string | null; + nonLocalizedText?: string | null; + }; + localizedArray?: + | { + item?: string | null; + id?: string | null; + }[] + | null; + nonLocalizedArray?: + | { + localizedItem?: string | null; + id?: string | null; + }[] + | null; + localizedBlocks?: + | ( + | { + text?: string | null; + id?: string | null; + blockName?: string | null; + blockType: 'localizedTextBlock'; + } + | { + nestedArray?: + | { + item?: string | null; + id?: string | null; + }[] + | null; + id?: string | null; + blockName?: string | null; + blockType: 'nestedBlock'; + } + )[] + | null; + localizedTab?: { + tabText?: string | null; + }; + nonLocalizedTab?: { + localizedInNonLocalizedTab?: string | null; + }; + unnamedTabLocalizedText?: string | null; + g1?: { + g2?: { + g2a1?: + | { + text?: string | null; + id?: string | null; + }[] + | null; + }; + }; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". @@ -740,6 +831,23 @@ export interface ArrayWithFallbackField { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv". + */ +export interface PayloadKv { + id: string; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -771,6 +879,10 @@ export interface PayloadLockedDocument { relationTo: 'localized-date-fields'; value: string | LocalizedDateField; } | null) + | ({ + relationTo: 'all-fields-localized'; + value: string | AllFieldsLocalized; + } | null) | ({ relationTo: 'users'; value: string | User; @@ -888,6 +1000,7 @@ export interface RichTextSelect { * via the `definition` "blocks-fields_select". */ export interface BlocksFieldsSelect { + title?: T; tabContent?: | T | { @@ -1038,6 +1151,97 @@ export interface LocalizedDateFieldsSelect { createdAt?: T; _status?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "all-fields-localized_select". + */ +export interface AllFieldsLocalizedSelect { + text?: T; + textarea?: T; + number?: T; + email?: T; + code?: T; + json?: T; + select?: T; + radio?: T; + checkbox?: T; + date?: T; + localizedGroup?: + | T + | { + title?: T; + description?: T; + }; + nonLocalizedGroup?: + | T + | { + localizedText?: T; + nonLocalizedText?: T; + }; + localizedArray?: + | T + | { + item?: T; + id?: T; + }; + nonLocalizedArray?: + | T + | { + localizedItem?: T; + id?: T; + }; + localizedBlocks?: + | T + | { + localizedTextBlock?: + | T + | { + text?: T; + id?: T; + blockName?: T; + }; + nestedBlock?: + | T + | { + nestedArray?: + | T + | { + item?: T; + id?: T; + }; + id?: T; + blockName?: T; + }; + }; + localizedTab?: + | T + | { + tabText?: T; + }; + nonLocalizedTab?: + | T + | { + localizedInNonLocalizedTab?: T; + }; + unnamedTabLocalizedText?: T; + g1?: + | T + | { + g2?: + | T + | { + g2a1?: + | T + | { + text?: T; + id?: T; + }; + }; + }; + updatedAt?: T; + createdAt?: T; + _status?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". @@ -1432,6 +1636,14 @@ export interface ArrayWithFallbackFieldsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv_select". + */ +export interface PayloadKvSelect { + key?: T; + data?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". diff --git a/test/localization/shared.ts b/test/localization/shared.ts index 5c6c93f192e..e976be7f27c 100644 --- a/test/localization/shared.ts +++ b/test/localization/shared.ts @@ -22,4 +22,5 @@ export const localizedDraftsSlug = 'localized-drafts' export const usersSlug = 'users' export const blocksWithLocalizedSameName = 'blocks-same-name' export const cannotCreateDefaultLocale = 'cannot-create-default-locale' +export const allFieldsLocalizedSlug = 'all-fields-localized' export const arrayWithFallbackCollectionSlug = 'array-with-fallback-fields' From b45c2b2ff2a9ec570f2a864ed03b9da3e176cfce Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 13 Nov 2025 15:01:06 -0500 Subject: [PATCH 2/4] correct localized data shape from afterRead to account for locale all on groups and tabs --- .../src/fields/hooks/afterRead/promise.ts | 231 ++++++++++++------ .../collections/AllFields/index.ts | 30 +++ test/localization/int.spec.ts | 12 + test/localization/payload-types.ts | 14 ++ 4 files changed, 208 insertions(+), 79 deletions(-) diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 5d2c35b3da7..f33f4183f64 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -140,12 +140,17 @@ export const promise = async ({ } } + const shouldLocalizeField = fieldShouldBeLocalized({ + field, + parentIsLocalized: parentIsLocalized!, + }) + const shouldHoistLocalizedValue: boolean = Boolean( flattenLocales && fieldAffectsDataResult && typeof siblingDoc[field.name!] === 'object' && siblingDoc[field.name!] !== null && - fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) && + shouldLocalizeField && locale !== 'all' && req.payload.config.localization, ) @@ -201,7 +206,11 @@ export const promise = async ({ case 'group': { // Fill groups with empty objects so fields with hooks within groups can populate // themselves virtually as necessary - if (fieldAffectsDataResult && typeof siblingDoc[field.name] === 'undefined') { + if ( + fieldAffectsDataResult && + shouldHoistLocalizedValue && + typeof siblingDoc[field.name] === 'undefined' + ) { siblingDoc[field.name] = {} } @@ -258,7 +267,7 @@ export const promise = async ({ // => Object.entries(siblingDoc[field.name]) will be the value of a single locale, not all locales // => do not run the hook for each locale !shouldHoistLocalizedValue && - fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) && + shouldLocalizeField && typeof siblingDoc[field.name] === 'object' if (fieldAffectsDataResult) { @@ -644,44 +653,73 @@ export const promise = async ({ case 'group': { if (fieldAffectsDataResult) { - let groupDoc = siblingDoc[field.name] as JsonObject - - if (typeof siblingDoc[field.name] !== 'object') { - groupDoc = {} - } - const groupSelect = select?.[field.name] - traverseFields({ - blockData, - collection, - context, - currentDepth, - depth, - doc, - draft, - fallbackLocale, - fieldPromises, - fields: field.fields, - findMany, - flattenLocales, - global, - locale, - overrideAccess, - parentIndexPath: '', - parentIsLocalized: parentIsLocalized || field.localized, - parentPath: path, - parentSchemaPath: schemaPath, - populate, - populationPromises, - req, - select: typeof groupSelect === 'object' ? groupSelect : undefined, - selectMode, - showHiddenFields, - siblingDoc: groupDoc, - triggerAccessControl, - triggerHooks, - }) + if (shouldLocalizeField && !shouldHoistLocalizedValue) { + Object.values(siblingDoc[field.name] || {}).forEach((localizedData) => { + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: '', + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: path, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select: typeof groupSelect === 'object' ? groupSelect : undefined, + selectMode, + showHiddenFields, + siblingDoc: localizedData || {}, + triggerAccessControl, + triggerHooks, + }) + }) + } else { + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: '', + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: path, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select: typeof groupSelect === 'object' ? groupSelect : undefined, + selectMode, + showHiddenFields, + siblingDoc: typeof siblingDoc[field.name] !== 'object' ? {} : siblingDoc[field.name], + triggerAccessControl, + triggerHooks, + }) + } } else { traverseFields({ blockData, @@ -819,51 +857,86 @@ export const promise = async ({ const isNamedTab = tabHasName(field) - if (isNamedTab) { - tabDoc = siblingDoc[field.name] as JsonObject + if (isNamedTab && shouldLocalizeField && !shouldHoistLocalizedValue) { + Object.values(siblingDoc[field.name] || {}).forEach((localizedData) => { + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: '', + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: path, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select: tabSelect, + selectMode, + showHiddenFields, + siblingDoc: localizedData || {}, + triggerAccessControl, + triggerHooks, + }) + }) + } else { + if (isNamedTab) { + tabDoc = siblingDoc[field.name] as JsonObject + + if (typeof siblingDoc[field.name] !== 'object') { + tabDoc = {} + } - if (typeof siblingDoc[field.name] !== 'object') { - tabDoc = {} + if (typeof select?.[field.name] === 'object') { + tabSelect = select?.[field.name] as SelectType + } + } else { + tabSelect = select } - if (typeof select?.[field.name] === 'object') { - tabSelect = select?.[field.name] as SelectType - } - } else { - tabSelect = select + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: isNamedTab ? '' : indexPath, + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: isNamedTab ? path : parentPath, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select: tabSelect, + selectMode, + showHiddenFields, + siblingDoc: tabDoc, + triggerAccessControl, + triggerHooks, + }) } - traverseFields({ - blockData, - collection, - context, - currentDepth, - depth, - doc, - draft, - fallbackLocale, - fieldPromises, - fields: field.fields, - findMany, - flattenLocales, - global, - locale, - overrideAccess, - parentIndexPath: isNamedTab ? '' : indexPath, - parentIsLocalized: parentIsLocalized || field.localized, - parentPath: isNamedTab ? path : parentPath, - parentSchemaPath: schemaPath, - populate, - populationPromises, - req, - select: tabSelect, - selectMode, - showHiddenFields, - siblingDoc: tabDoc, - triggerAccessControl, - triggerHooks, - }) - break } diff --git a/test/localization/collections/AllFields/index.ts b/test/localization/collections/AllFields/index.ts index eb565b1bb06..9c58493231e 100644 --- a/test/localization/collections/AllFields/index.ts +++ b/test/localization/collections/AllFields/index.ts @@ -204,6 +204,36 @@ export const AllFieldsLocalized: CollectionConfig = { ], }, + // Deeply nested: localized tab + { + type: 'tabs', + tabs: [ + { + name: 't1', + label: 'Deeply Nested Tab', + localized: true, + fields: [ + { + type: 'tabs', + tabs: [ + { + name: 't2', + label: 'Nested Tab Level 2', + fields: [ + { + name: 'text', + type: 'text', + localized: true, + }, + ], + }, + ], + }, + ], + }, + ], + }, + // Deeply nested: localized group > non-localized group > localized array { name: 'g1', diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 72a082ba371..9ec70651cfe 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -3459,6 +3459,11 @@ describe('Localization', () => { const doc = await payload.create({ collection: allFieldsLocalizedSlug, data: { + t1: { + t2: { + text: 'EN Deep Text', + }, + }, g1: { g2: { g2a1: [{ text: 'EN Deep 1' }, { text: 'EN Deep 2' }], @@ -3536,9 +3541,16 @@ describe('Localization', () => { // Verify deeply nested localization has locale keys only at topmost localized field expect((allLocalesDoc.g1 as any).en).toBeDefined() + expect((allLocalesDoc.g1 as any).g2).toBeUndefined() expect((allLocalesDoc.g1 as any).en.g2.g2a1).toHaveLength(2) expect((allLocalesDoc.g1 as any).en.g2.g2a1[0].text).toBe('EN Deep 1') expect((allLocalesDoc.g1 as any).es).toBeUndefined() + + // Verify deeply nested localization in tab has locale keys only at topmost localized field + expect((allLocalesDoc.t1 as any).en).toBeDefined() + expect((allLocalesDoc.t1 as any).t2).toBeUndefined() + expect((allLocalesDoc.t1 as any).en.t2.text).toBe('EN Deep Text') + expect((allLocalesDoc.t1 as any).es).toBeUndefined() }) }) }) diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index 2770a4b7949..4ea108b487a 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -433,6 +433,11 @@ export interface AllFieldsLocalized { localizedInNonLocalizedTab?: string | null; }; unnamedTabLocalizedText?: string | null; + t1?: { + t2?: { + text?: string | null; + }; + }; g1?: { g2?: { g2a1?: @@ -1224,6 +1229,15 @@ export interface AllFieldsLocalizedSelect { localizedInNonLocalizedTab?: T; }; unnamedTabLocalizedText?: T; + t1?: + | T + | { + t2?: + | T + | { + text?: T; + }; + }; g1?: | T | { From 6e167486b2d22bacf5b0c8c8e894e6d66daa17f1 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Thu, 13 Nov 2025 15:05:53 -0500 Subject: [PATCH 3/4] handle tabs similar to how groups are handled --- .../src/fields/hooks/afterRead/promise.ts | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index f33f4183f64..7caf7f9b08c 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -852,13 +852,46 @@ export const promise = async ({ } case 'tab': { - let tabDoc = siblingDoc + const tabDoc = siblingDoc let tabSelect: SelectType | undefined const isNamedTab = tabHasName(field) - if (isNamedTab && shouldLocalizeField && !shouldHoistLocalizedValue) { - Object.values(siblingDoc[field.name] || {}).forEach((localizedData) => { + if (isNamedTab) { + if (shouldLocalizeField && !shouldHoistLocalizedValue) { + Object.values(siblingDoc[field.name] || {}).forEach((localizedData) => { + traverseFields({ + blockData, + collection, + context, + currentDepth, + depth, + doc, + draft, + fallbackLocale, + fieldPromises, + fields: field.fields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + parentIndexPath: '', + parentIsLocalized: parentIsLocalized || field.localized, + parentPath: path, + parentSchemaPath: schemaPath, + populate, + populationPromises, + req, + select: tabSelect, + selectMode, + showHiddenFields, + siblingDoc: localizedData || {}, + triggerAccessControl, + triggerHooks, + }) + }) + } else { traverseFields({ blockData, collection, @@ -882,29 +915,18 @@ export const promise = async ({ populate, populationPromises, req, - select: tabSelect, + select: + typeof select?.[field.name] === 'object' + ? (select?.[field.name] as SelectType) + : undefined, selectMode, showHiddenFields, - siblingDoc: localizedData || {}, + siblingDoc: typeof siblingDoc[field.name] !== 'object' ? {} : siblingDoc[field.name], triggerAccessControl, triggerHooks, }) - }) - } else { - if (isNamedTab) { - tabDoc = siblingDoc[field.name] as JsonObject - - if (typeof siblingDoc[field.name] !== 'object') { - tabDoc = {} - } - - if (typeof select?.[field.name] === 'object') { - tabSelect = select?.[field.name] as SelectType - } - } else { - tabSelect = select } - + } else { traverseFields({ blockData, collection, @@ -928,7 +950,7 @@ export const promise = async ({ populate, populationPromises, req, - select: tabSelect, + select, selectMode, showHiddenFields, siblingDoc: tabDoc, From 69841b2d34953318f870ad9eccbeb7a3f058b679 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 14 Nov 2025 08:47:58 -0500 Subject: [PATCH 4/4] revert empty obj change --- .../src/fields/hooks/afterRead/promise.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 7caf7f9b08c..508412bbf46 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -206,11 +206,7 @@ export const promise = async ({ case 'group': { // Fill groups with empty objects so fields with hooks within groups can populate // themselves virtually as necessary - if ( - fieldAffectsDataResult && - shouldHoistLocalizedValue && - typeof siblingDoc[field.name] === 'undefined' - ) { + if (fieldAffectsDataResult && typeof siblingDoc[field.name] === 'undefined') { siblingDoc[field.name] = {} } @@ -653,7 +649,10 @@ export const promise = async ({ case 'group': { if (fieldAffectsDataResult) { - const groupSelect = select?.[field.name] + const groupSelect = + typeof select?.[field.name] === 'object' + ? (select?.[field.name] as SelectType) + : undefined if (shouldLocalizeField && !shouldHoistLocalizedValue) { Object.values(siblingDoc[field.name] || {}).forEach((localizedData) => { @@ -680,7 +679,7 @@ export const promise = async ({ populate, populationPromises, req, - select: typeof groupSelect === 'object' ? groupSelect : undefined, + select: groupSelect, selectMode, showHiddenFields, siblingDoc: localizedData || {}, @@ -712,7 +711,7 @@ export const promise = async ({ populate, populationPromises, req, - select: typeof groupSelect === 'object' ? groupSelect : undefined, + select: groupSelect, selectMode, showHiddenFields, siblingDoc: typeof siblingDoc[field.name] !== 'object' ? {} : siblingDoc[field.name], @@ -853,11 +852,14 @@ export const promise = async ({ case 'tab': { const tabDoc = siblingDoc - let tabSelect: SelectType | undefined const isNamedTab = tabHasName(field) if (isNamedTab) { + const tabSelect: SelectType | undefined = + typeof select?.[field.name] === 'object' + ? (select?.[field.name] as SelectType) + : undefined if (shouldLocalizeField && !shouldHoistLocalizedValue) { Object.values(siblingDoc[field.name] || {}).forEach((localizedData) => { traverseFields({ @@ -915,10 +917,7 @@ export const promise = async ({ populate, populationPromises, req, - select: - typeof select?.[field.name] === 'object' - ? (select?.[field.name] as SelectType) - : undefined, + select: tabSelect, selectMode, showHiddenFields, siblingDoc: typeof siblingDoc[field.name] !== 'object' ? {} : siblingDoc[field.name],