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 <jarrodmflesch@gmail.com>
This commit is contained in:
Jessica Rynkar
2025-08-05 14:29:10 +01:00
committed by GitHub
parent 8401b2166d
commit b74f4fb9b2
12 changed files with 316 additions and 33 deletions

View File

@@ -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') {

View File

@@ -381,7 +381,12 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
Fallback={<FieldDescription description={description} path={path} />}
/>
</header>
<NullifyLocaleField fieldValue={value} localized={localized} path={path} />
<NullifyLocaleField
fieldValue={value}
localized={localized}
path={path}
readOnly={readOnly}
/>
{BeforeInput}
{(rows?.length > 0 || (!valid && (showRequired || showMinRows))) && (
<DraggableSortable

View File

@@ -382,7 +382,12 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
/>
</header>
{BeforeInput}
<NullifyLocaleField fieldValue={value} localized={localized} path={path} />
<NullifyLocaleField
fieldValue={value}
localized={localized}
path={path}
readOnly={readOnly}
/>
{(rows.length > 0 || (!valid && (showRequired || showMinRows))) && (
<DraggableSortable
className={`${baseClass}__rows`}

View File

@@ -0,0 +1,14 @@
@layer payload-default {
.nullify-locale-field {
margin-bottom: 0;
.field-type.checkbox {
display: flex;
flex-direction: column;
margin: 0;
}
+ .array-field__add-row {
margin-top: calc(var(--base) / 2);
}
}
}

View File

@@ -7,17 +7,23 @@ import { CheckboxField } from '../../fields/Checkbox/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { useForm } from '../Form/context.js'
import './index.scss'
const baseClass = 'nullify-locale-field'
type NullifyLocaleFieldProps = {
readonly fieldValue?: [] | null | number
readonly localized: boolean
readonly path: string
readonly readOnly?: boolean
}
export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({
fieldValue,
localized,
path,
readOnly = false,
}) => {
const { code: currentLocale } = useLocale()
const {
@@ -25,6 +31,7 @@ export const NullifyLocaleField: React.FC<NullifyLocaleFieldProps> = ({
} = useConfig()
const [checked, setChecked] = React.useState<boolean>(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<NullifyLocaleFieldProps> = ({
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<NullifyLocaleFieldProps> = ({
}
return (
<Banner>
<CheckboxField
checked={checked}
field={{
name: '',
label: t('general:fallbackToDefaultLocale'),
}}
id={`field-${path.replace(/\./g, '__')}`}
path={path}
schemaPath=""
// onToggle={onChange}
/>
<Banner className={baseClass}>
{!fieldValue && readOnly ? (
t('general:fallbackToDefaultLocale')
) : (
<CheckboxField
checked={checked}
field={{
name: '',
label: t('general:fallbackToDefaultLocale'),
}}
id={`field-${path.replace(/\./g, '__')}`}
onChange={onChange}
path={path}
schemaPath=""
/>
)}
</Banner>
)
}

View File

@@ -13,7 +13,6 @@ export const ArrayCollection: CollectionConfig = {
{
name: 'text',
type: 'text',
required: true,
},
],
},

View File

@@ -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,
},
],
},
],
}

View File

@@ -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: [
{

View File

@@ -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<LocalizedPost>) {
const { description: descVal, title: titleVal } = data

View File

@@ -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 () => {

View File

@@ -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<false> | LocalizedSortSelect<true>;
'blocks-same-name': BlocksSameNameSelect<false> | BlocksSameNameSelect<true>;
'localized-within-localized': LocalizedWithinLocalizedSelect<false> | LocalizedWithinLocalizedSelect<true>;
'array-with-fallback-fields': ArrayWithFallbackFieldsSelect<false> | ArrayWithFallbackFieldsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -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<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-with-fallback-fields_select".
*/
export interface ArrayWithFallbackFieldsSelect<T extends boolean = true> {
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".

View File

@@ -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'