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:
@@ -13,7 +13,6 @@ export const ArrayCollection: CollectionConfig = {
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
36
test/localization/collections/ArrayWithFallback/index.ts
Normal file
36
test/localization/collections/ArrayWithFallback/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user