From b74f4fb9b2db1b293f1f760d21492201e54b909c Mon Sep 17 00:00:00 2001 From: Jessica Rynkar <67977755+jessrynkar@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:29:10 +0100 Subject: [PATCH] fix(ui): fallback to default locale checkbox passes wrong value (#12396) ### What? Allows document to successfully be saved when `fallback to default locale` checked without throwing an error. ### Why? The `fallback to default locale` checkbox allows users to successfully save a document in the admin panel while using fallback data for required fields, this has been broken since the release of `v3`. Without the checkbox override, the user would be prevented from saving the document in the UI because the field is required and will throw an error. The logic of using fallback data is not affected by this checkbox - it is purely to allow saving the document in the UI. ### How? The `fallback` checkbox used to have an `onChange` function that replaces the field value with null, allowing it to get processed through the standard localization logic and get replaced by fallback data. However, this `onChange` was removed at some point and the field was passing the actual checkbox value `true`/`false` which then breaks the form and prevent it from saving. This fallback checkbox is only displayed when `fallback: true` is set in the localization config. This PR also updated the checkbox to only be displayed when `required: true` - when it's the field is not `required` this checkbox serves no purpose. Also adds tests to `localization/e2e`. Fixes #11245 --------- Co-authored-by: Jarrod Flesch --- .../src/fields/hooks/afterRead/promise.ts | 34 ++++---- packages/ui/src/fields/Array/index.tsx | 7 +- packages/ui/src/fields/Blocks/index.tsx | 7 +- packages/ui/src/forms/NullifyField/index.scss | 14 ++++ packages/ui/src/forms/NullifyField/index.tsx | 47 ++++++++--- test/localization/collections/Array/index.ts | 1 - .../collections/ArrayWithFallback/index.ts | 36 +++++++++ test/localization/config.ts | 2 + test/localization/e2e.spec.ts | 78 ++++++++++++++++++- test/localization/int.spec.ts | 75 +++++++++++++++++- test/localization/payload-types.ts | 47 ++++++++++- test/localization/shared.ts | 1 + 12 files changed, 316 insertions(+), 33 deletions(-) create mode 100644 packages/ui/src/forms/NullifyField/index.scss create mode 100644 test/localization/collections/ArrayWithFallback/index.ts diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 3ceb856d7..bad55561c 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -111,13 +111,14 @@ export const promise = async ({ parentSchemaPath, }) + const fieldAffectsDataResult = fieldAffectsData(field) const pathSegments = path ? path.split('.') : [] const schemaPathSegments = schemaPath ? schemaPath.split('.') : [] const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : [] let removedFieldValue = false if ( - fieldAffectsData(field) && + fieldAffectsDataResult && field.hidden && typeof siblingDoc[field.name!] !== 'undefined' && !showHiddenFields @@ -139,16 +140,17 @@ export const promise = async ({ } } - const shouldHoistLocalizedValue = + const shouldHoistLocalizedValue: boolean = Boolean( flattenLocales && - fieldAffectsData(field) && - typeof siblingDoc[field.name!] === 'object' && - siblingDoc[field.name!] !== null && - fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) && - locale !== 'all' && - req.payload.config.localization + fieldAffectsDataResult && + typeof siblingDoc[field.name!] === 'object' && + siblingDoc[field.name!] !== null && + fieldShouldBeLocalized({ field, parentIsLocalized: parentIsLocalized! }) && + locale !== 'all' && + req.payload.config.localization, + ) - if (shouldHoistLocalizedValue) { + if (fieldAffectsDataResult && shouldHoistLocalizedValue) { // replace actual value with localized value before sanitizing // { [locale]: fields } -> fields const value = siblingDoc[field.name!][locale!] @@ -187,7 +189,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 (fieldAffectsData(field) && typeof siblingDoc[field.name] === 'undefined') { + if (fieldAffectsDataResult && typeof siblingDoc[field.name] === 'undefined') { siblingDoc[field.name] = {} } @@ -234,7 +236,7 @@ export const promise = async ({ } } - if (fieldAffectsData(field)) { + if (fieldAffectsDataResult) { // Execute hooks if (triggerHooks && field.hooks?.afterRead) { for (const hook of field.hooks.afterRead) { @@ -400,7 +402,7 @@ export const promise = async ({ } } - if (Array.isArray(rows)) { + if (Array.isArray(rows) && rows.length > 0) { rows.forEach((row, rowIndex) => { traverseFields({ blockData, @@ -468,6 +470,8 @@ export const promise = async ({ }) } }) + } else if (shouldHoistLocalizedValue && (!rows || rows.length === 0)) { + siblingDoc[field.name] = null } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] } @@ -477,7 +481,7 @@ export const promise = async ({ case 'blocks': { const rows = siblingDoc[field.name] - if (Array.isArray(rows)) { + if (Array.isArray(rows) && rows.length > 0) { rows.forEach((row, rowIndex) => { const blockTypeToMatch = (row as JsonObject).blockType @@ -573,6 +577,8 @@ export const promise = async ({ }) } }) + } else if (shouldHoistLocalizedValue && (!rows || rows.length === 0)) { + siblingDoc[field.name] = null } else if (field.hidden !== true || showHiddenFields === true) { siblingDoc[field.name] = [] } @@ -617,7 +623,7 @@ export const promise = async ({ } case 'group': { - if (fieldAffectsData(field)) { + if (fieldAffectsDataResult) { let groupDoc = siblingDoc[field.name] as JsonObject if (typeof siblingDoc[field.name] !== 'object') { diff --git a/packages/ui/src/fields/Array/index.tsx b/packages/ui/src/fields/Array/index.tsx index 79e871d6f..848971467 100644 --- a/packages/ui/src/fields/Array/index.tsx +++ b/packages/ui/src/fields/Array/index.tsx @@ -381,7 +381,12 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => { Fallback={} /> - + {BeforeInput} {(rows?.length > 0 || (!valid && (showRequired || showMinRows))) && ( { /> {BeforeInput} - + {(rows.length > 0 || (!valid && (showRequired || showMinRows))) && ( = ({ fieldValue, localized, path, + readOnly = false, }) => { const { code: currentLocale } = useLocale() const { @@ -25,6 +31,7 @@ export const NullifyLocaleField: React.FC = ({ } = useConfig() const [checked, setChecked] = React.useState(typeof fieldValue !== 'number') const { t } = useTranslation() + const { dispatchFields, setModified } = useForm() if (!localized || !localization) { // hide when field is not localized or localization is not enabled @@ -36,6 +43,18 @@ export const NullifyLocaleField: React.FC = ({ return null } + const onChange = () => { + const useFallback = !checked + + dispatchFields({ + type: 'UPDATE', + path, + value: useFallback ? null : fieldValue || 0, + }) + setModified(true) + setChecked(useFallback) + } + if (fieldValue) { let hideCheckbox = false if (typeof fieldValue === 'number' && fieldValue > 0) { @@ -54,18 +73,22 @@ export const NullifyLocaleField: React.FC = ({ } return ( - - + + {!fieldValue && readOnly ? ( + t('general:fallbackToDefaultLocale') + ) : ( + + )} ) } diff --git a/test/localization/collections/Array/index.ts b/test/localization/collections/Array/index.ts index 961a2f02e..32fc7b764 100644 --- a/test/localization/collections/Array/index.ts +++ b/test/localization/collections/Array/index.ts @@ -13,7 +13,6 @@ export const ArrayCollection: CollectionConfig = { { name: 'text', type: 'text', - required: true, }, ], }, diff --git a/test/localization/collections/ArrayWithFallback/index.ts b/test/localization/collections/ArrayWithFallback/index.ts new file mode 100644 index 000000000..0cc54cba8 --- /dev/null +++ b/test/localization/collections/ArrayWithFallback/index.ts @@ -0,0 +1,36 @@ +import type { CollectionConfig } from 'payload' + +import { arrayWithFallbackCollectionSlug } from '../../shared.js' + +export const ArrayWithFallbackCollection: CollectionConfig = { + slug: arrayWithFallbackCollectionSlug, + fields: [ + { + name: 'items', + type: 'array', + localized: true, + required: true, + fields: [ + { + name: 'text', + type: 'text', + }, + ], + }, + { + name: 'itemsReadOnly', + type: 'array', + localized: true, + admin: { + readOnly: true, + }, + fields: [ + { + name: 'text', + type: 'text', + required: true, + }, + ], + }, + ], +} diff --git a/test/localization/config.ts b/test/localization/config.ts index 912dd5610..9269e0e02 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -9,6 +9,7 @@ import type { LocalizedPost } from './payload-types.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' import { ArrayCollection } from './collections/Array/index.js' +import { ArrayWithFallbackCollection } from './collections/ArrayWithFallback/index.js' import { BlocksCollection } from './collections/Blocks/index.js' import { Group } from './collections/Group/index.js' import { LocalizedDateFields } from './collections/LocalizedDateFields/index.js' @@ -397,6 +398,7 @@ export default buildConfigWithDefaults({ ], }, LocalizedWithinLocalized, + ArrayWithFallbackCollection, ], globals: [ { diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 8a1d66361..ed743488c 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -26,10 +26,11 @@ import { import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' -import { blocksCollectionSlug } from './collections/Blocks/index.js' +import { arrayCollectionSlug } from './collections/Array/index.js' import { nestedToArrayAndBlockCollectionSlug } from './collections/NestedToArrayAndBlock/index.js' import { richTextSlug } from './collections/RichText/index.js' import { + arrayWithFallbackCollectionSlug, defaultLocale, englishTitle, localizedDraftsSlug, @@ -57,6 +58,8 @@ let urlWithRequiredLocalizedFields: AdminUrlUtil let urlRelationshipLocalized: AdminUrlUtil let urlCannotCreateDefaultLocale: AdminUrlUtil let urlPostsWithDrafts: AdminUrlUtil +let urlArray: AdminUrlUtil +let arrayWithFallbackURL: AdminUrlUtil const title = 'english title' const spanishTitle = 'spanish title' @@ -81,6 +84,8 @@ describe('Localization', () => { urlWithRequiredLocalizedFields = new AdminUrlUtil(serverURL, withRequiredLocalizedFields) urlCannotCreateDefaultLocale = new AdminUrlUtil(serverURL, 'cannot-create-default-locale') urlPostsWithDrafts = new AdminUrlUtil(serverURL, localizedDraftsSlug) + urlArray = new AdminUrlUtil(serverURL, arrayCollectionSlug) + arrayWithFallbackURL = new AdminUrlUtil(serverURL, arrayWithFallbackCollectionSlug) context = await browser.newContext() page = await context.newPage() @@ -585,6 +590,66 @@ describe('Localization', () => { }) }) + describe('fallback checkbox', () => { + test('should show fallback checkbox for non-default locale', async () => { + await createLocalizedArrayItem(page, arrayWithFallbackURL) + + const fallbackCheckbox = page.locator('#field-items', { + hasText: 'Fallback to default locale', + }) + await expect(fallbackCheckbox).toBeVisible() + }) + + test('should save document successfully when fallback checkbox is checked', async () => { + await createLocalizedArrayItem(page, arrayWithFallbackURL) + + const checkbox = page.locator('#field-items input[type="checkbox"]') + // have to uncheck and check again to allow save + await checkbox.click() + await expect(checkbox).not.toBeChecked() + await checkbox.click() + await expect(checkbox).toBeChecked() + await saveDocAndAssert(page) + await expect(page.locator('.payload-toast-container')).toContainText('successfully') + }) + + test('should save correct data when fallback checkbox is checked', async () => { + await createLocalizedArrayItem(page, arrayWithFallbackURL) + + const checkbox = page.locator('#field-items input[type="checkbox"]') + // have to uncheck and check again to allow save + await checkbox.click() + await expect(checkbox).not.toBeChecked() + await checkbox.click() + await expect(checkbox).toBeChecked() + await saveDocAndAssert(page) + + const id = page.url().split('/').pop() + const apiURL = `${serverURL}/api/${arrayWithFallbackCollectionSlug}/${id}` + await page.goto(apiURL) + const data = await page.evaluate(() => { + return JSON.parse(document.querySelector('body')?.innerText || '{}') + }) + + // should see fallback data when querying the locale individually + await expect.poll(() => data.items[0].text).toBe('test') + + const apiURLAll = apiURL.replace('es', 'all') + await page.goto(apiURLAll) + const dataAll = await page.evaluate(() => { + return JSON.parse(document.querySelector('body')?.innerText || '{}') + }) + // should not see fallback data when querying all locales + // - sql it will be undefined + // - mongodb it will be null + await expect + .poll(() => { + return !dataAll.items?.es + }) + .toBeTruthy() + }) + }) + test('should use label in search filter when string or object', async () => { await page.goto(url.list) const searchInput = page.locator('.search-filter__input') @@ -608,6 +673,17 @@ describe('Localization', () => { }) }) +async function createLocalizedArrayItem(page: Page, url: AdminUrlUtil) { + await changeLocale(page, defaultLocale) + await page.goto(url.create) + const addArrayRow = page.locator('#field-items .array-field__add-row') + await addArrayRow.click() + const textField = page.locator('#field-items__0__text') + await textField.fill('test') + await saveDocAndAssert(page) + await changeLocale(page, spanishLocale) +} + async function fillValues(data: Partial) { const { description: descVal, title: titleVal } = data diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 09e4c8e70..175ed1f00 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -148,6 +148,36 @@ describe('Localization', () => { expect(localizedFallback.title.es).toEqual('') }) + it('should show correct fallback data for arrays', async () => { + const localizedArrayPost = await payload.create({ + collection: arrayCollectionSlug, + data: { + items: [ + { + text: 'localized array item', + }, + ], + }, + }) + + const resultAllLocales: any = await payload.findByID({ + id: localizedArrayPost.id, + collection: arrayCollectionSlug, + locale: 'all', + }) + + expect(resultAllLocales.items.en[0].text).toEqual('localized array item') + expect(resultAllLocales.items.es).toEqual(undefined) + + const resultSpanishLocale: any = await payload.findByID({ + id: localizedArrayPost.id, + collection: arrayCollectionSlug, + locale: spanishLocale, + }) + + expect(resultSpanishLocale.items[0].text).toEqual('localized array item') + }) + it('should fallback to spanish translation when empty and locale-specific fallback is provided', async () => { const localizedFallback: any = await payload.findByID({ id: postWithLocalizedData.id, @@ -1183,11 +1213,52 @@ describe('Localization', () => { data: { items: [], }, - fallbackLocale: 'none', + fallbackLocale: false, locale: spanishLocale, }) - expect(updatedSpanishDoc.items).toStrictEqual([]) + expect(updatedSpanishDoc.items).toStrictEqual(null) + }) + + it('should allow optional fallback data', async () => { + const englishDoc = await payload.create({ + collection: arrayCollectionSlug, + data: { + items: [ + { + text: englishTitle, + }, + ], + }, + locale: defaultLocale, + }) + + await payload.update({ + id: englishDoc.id, + collection: arrayCollectionSlug, + data: { + items: [], + }, + locale: spanishLocale, + }) + + const docWithoutFallback = await payload.findByID({ + id: englishDoc.id, + collection: arrayCollectionSlug, + locale: spanishLocale, + }) + + // eslint-disable-next-line jest/no-conditional-in-test + if (['firestore', 'mongodb'].includes(process.env.PAYLOAD_DATABASE!)) { + expect(docWithoutFallback.items).toStrictEqual(null) + } else { + // TODO: build out compatability with SQL databases + // Currently SQL databases always fallback since the localized values are joined in. + // The join only has 2 states, undefined or the localized value of the requested locale. + // If the localized value is not in the DB, there is no way to know if the value should fallback or not so we fallback if fallbackLocale is truthy. + // In MongoDB the value can be set to null, which allows us to know that the value should fallback. + expect(docWithoutFallback.items).toStrictEqual(englishDoc.items) + } }) it('should use fallback value if setting null', async () => { diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index bd6674deb..8b4a53317 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -87,6 +87,7 @@ export interface Config { 'localized-sort': LocalizedSort; 'blocks-same-name': BlocksSameName; 'localized-within-localized': LocalizedWithinLocalized; + 'array-with-fallback-fields': ArrayWithFallbackField; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -113,6 +114,7 @@ export interface Config { 'localized-sort': LocalizedSortSelect | LocalizedSortSelect; 'blocks-same-name': BlocksSameNameSelect | BlocksSameNameSelect; 'localized-within-localized': LocalizedWithinLocalizedSelect | LocalizedWithinLocalizedSelect; + 'array-with-fallback-fields': ArrayWithFallbackFieldsSelect | ArrayWithFallbackFieldsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -388,7 +390,7 @@ export interface ArrayField { id: string; items?: | { - text: string; + text?: string | null; id?: string | null; }[] | null; @@ -708,6 +710,25 @@ export interface LocalizedWithinLocalized { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "array-with-fallback-fields". + */ +export interface ArrayWithFallbackField { + id: string; + items: { + text?: string | null; + id?: string | null; + }[]; + itemsReadOnly?: + | { + text: string; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -794,6 +815,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'localized-within-localized'; value: string | LocalizedWithinLocalized; + } | null) + | ({ + relationTo: 'array-with-fallback-fields'; + value: string | ArrayWithFallbackField; } | null); globalSlug?: string | null; user: { @@ -1362,6 +1387,26 @@ export interface LocalizedWithinLocalizedSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "array-with-fallback-fields_select". + */ +export interface ArrayWithFallbackFieldsSelect { + items?: + | T + | { + text?: T; + id?: T; + }; + itemsReadOnly?: + | T + | { + text?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: 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 fd5c54ab2..5c6c93f19 100644 --- a/test/localization/shared.ts +++ b/test/localization/shared.ts @@ -22,3 +22,4 @@ export const localizedDraftsSlug = 'localized-drafts' export const usersSlug = 'users' export const blocksWithLocalizedSameName = 'blocks-same-name' export const cannotCreateDefaultLocale = 'cannot-create-default-locale' +export const arrayWithFallbackCollectionSlug = 'array-with-fallback-fields'