Skip to content

Commit f6e9073

Browse files
r1tsuuDanRibbens
andauthored
fix: unique value errors are not displayed properly for localized fields (#16069)
Previously, in the admin panel errors with localized + unique fields were not shown properly as "Field is unique" and not highlighted because of having a path with the locale appended. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213807392375112 --------- Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
1 parent 5a39afc commit f6e9073

7 files changed

Lines changed: 182 additions & 11 deletions

File tree

packages/db-mongodb/src/utilities/handleError.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,29 @@ function extractFieldFromMessage(message: string) {
1111
return null
1212
}
1313

14+
function stripLocaleFromPath(path: string, req?: Partial<PayloadRequest>): string {
15+
if (!path) {
16+
return path
17+
}
18+
19+
const localization = req?.payload?.config?.localization
20+
if (!localization) {
21+
return path
22+
}
23+
24+
const lastDotIndex = path.lastIndexOf('.')
25+
if (lastDotIndex === -1) {
26+
return path
27+
}
28+
29+
const lastSegment = path.substring(lastDotIndex + 1)
30+
if (localization.localeCodes.includes(lastSegment)) {
31+
return path.substring(0, lastDotIndex)
32+
}
33+
34+
return path
35+
}
36+
1437
export const handleError = ({
1538
collection,
1639
error,
@@ -36,6 +59,10 @@ export const handleError = ({
3659
path = extractFieldFromMessage(error.message)
3760
}
3861

62+
if (path) {
63+
path = stripLocaleFromPath(path, req)
64+
}
65+
3966
throw new ValidationError(
4067
{
4168
collection,

packages/drizzle/src/upsertRow/handleUpsertError.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ export const handleUpsertError = ({
6060
}
6161
}
6262
} else if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
63-
// SQLite - extract from message: "UNIQUE constraint failed: table.field"
64-
const regex = /UNIQUE constraint failed: ([^.]+)\.([^.]+)/
63+
// SQLite - extract from message: "UNIQUE constraint failed: table.field[, table.field2, ...]"
64+
const regex = /UNIQUE constraint failed: ([^.]+)\.([^.,]+)/
6565
const match: string[] = error.message?.match(regex)
6666
if (match && match[2]) {
6767
if (adapter.fieldConstraints[tableName]) {

test/__helpers/e2e/helpers.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -301,17 +301,15 @@ export async function changeLocale(page: Page, newLocale: string) {
301301
.textContent()
302302

303303
if (currentlySelectedLocale !== `(${newLocale})`) {
304-
const localeToSelect = page
304+
const localeButton = page
305305
.locator('.popup__content .popup-button-list__button')
306-
.locator('.localizer__locale-code', {
307-
hasText: `${newLocale}`,
308-
})
306+
.filter({ has: page.locator(`[data-locale="${newLocale}"]`) })
309307

310-
await expect(async () => await expect(localeToSelect).toBeEnabled()).toPass({
308+
await expect(async () => await expect(localeButton).toBeEnabled()).toPass({
311309
timeout: POLL_TOPASS_TIMEOUT,
312310
})
313311

314-
await localeToSelect.click()
312+
await localeButton.click()
315313

316314
const regexPattern = new RegExp(`locale=${newLocale}`)
317315

test/localization/config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ export default buildConfigWithDefaults({
159159
{
160160
type: 'tabs',
161161
tabs: [
162+
{
163+
label: 'SEO',
164+
fields: [
165+
{
166+
name: 'seoTitle',
167+
type: 'text',
168+
localized: true,
169+
unique: true,
170+
},
171+
],
172+
},
162173
{
163174
label: 'Main Nav',
164175
fields: [

test/localization/e2e.spec.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ describe('Localization', () => {
336336
await waitForFormReady(page)
337337
await changeLocale(page, defaultLocale)
338338
await page.locator('#field-title').fill(englishTitle)
339+
await page.locator('button.tabs-field__tab-button', { hasText: 'Main Nav' }).click()
339340
await page.locator('#field-nav__layout .blocks-field__drawer-toggler').click()
340341
await page.locator('button[title="Text"]').click()
341342
await page.locator('#field-nav__layout__0__text').waitFor({ state: 'visible' })
@@ -894,7 +895,7 @@ describe('Localization', () => {
894895
await changeLocale(page, defaultLocale)
895896
await fillValues({ title: 'English Title' })
896897
await saveDocAndAssert(page)
897-
const id = await page.locator('.id-label').innerText()
898+
const id = await page.locator('.id-label').getAttribute('title')
898899

899900
await changeLocale(page, spanishLocale)
900901
await fillValues({ title: 'Spanish Title' })
@@ -925,11 +926,11 @@ describe('Localization', () => {
925926
// Close all toasts to prevent them from interfering with subsequent tests. E.g. the following could happen
926927
await closeAllToasts(page)
927928

928-
await expect.poll(() => page.url()).not.toContain(id)
929929
await page.waitForURL((url) => !url.toString().includes(id))
930930

931931
// Wait for page to be ready after duplicate redirect
932932
await expect(page.locator('.localizer button.popup-button')).toBeVisible()
933+
await waitForFormReady(page)
933934
await changeLocale(page, defaultLocale)
934935
await expect(page.locator('#field-title')).toHaveValue('English Title')
935936
await changeLocale(page, spanishLocale)
@@ -1029,6 +1030,63 @@ describe('Localization', () => {
10291030
})
10301031
})
10311032

1033+
describe('unique localized field validation errors', () => {
1034+
test('should show correct field name in toast and highlight seoTitle inside tabs on duplicate unique value', async () => {
1035+
await page.goto(urlWithRequiredLocalizedFields.create)
1036+
await waitForFormReady(page)
1037+
await changeLocale(page, defaultLocale)
1038+
1039+
const uniqueSeoTitle = `seo-e2e-unique-${Date.now()}`
1040+
1041+
await payload.create({
1042+
collection: withRequiredLocalizedFields,
1043+
data: {
1044+
title: 'Existing doc title',
1045+
seoTitle: uniqueSeoTitle,
1046+
nav: {
1047+
layout: [
1048+
{
1049+
blockType: 'text',
1050+
text: 'existing block',
1051+
},
1052+
],
1053+
},
1054+
},
1055+
locale: defaultLocale,
1056+
})
1057+
1058+
// seoTitle is in the SEO tab (active by default) — fill it first
1059+
await page.locator('#field-seoTitle').fill(uniqueSeoTitle)
1060+
await page.locator('#field-title').fill('Second doc title')
1061+
1062+
await page.locator('button.tabs-field__tab-button', { hasText: 'Main Nav' }).click()
1063+
await page.locator('#field-nav__layout .blocks-field__drawer-toggler').click()
1064+
await page.locator('button[title="Text"]').click()
1065+
await page.locator('#field-nav__layout__0__text').waitFor({ state: 'visible' })
1066+
await page.locator('#field-nav__layout__0__text').fill('test block')
1067+
1068+
// Switch back to SEO tab so the field error tooltip is visible after save
1069+
await page.locator('button.tabs-field__tab-button', { hasText: 'SEO' }).click()
1070+
1071+
await saveDocAndAssert(page, '#action-save', 'error', { disableDismissAllToasts: true })
1072+
1073+
// 1. Toast error message should reference 'seoTitle', not 'seoTitle.en'
1074+
const errorToast = page.locator('.payload-toast-container .toast-error')
1075+
await expect(errorToast).toBeVisible()
1076+
await expect(errorToast.locator('[data-testid="field-error"]')).toHaveText('seoTitle')
1077+
1078+
await closeAllToasts(page)
1079+
1080+
// 2. SEO tab button should be highlighted with an error pill
1081+
const seoTabButton = page.locator('button.tabs-field__tab-button', { hasText: 'SEO' })
1082+
await expect(seoTabButton).toHaveClass(/tabs-field__tab-button--has-error/)
1083+
await expect(seoTabButton.locator('.error-pill')).toBeVisible()
1084+
1085+
// 3. The seoTitle field itself should be in error state
1086+
await expect(page.locator('.field-type.text:has(#field-seoTitle)')).toHaveClass(/\berror\b/)
1087+
})
1088+
})
1089+
10321090
describe('A11y', () => {
10331091
test.fixme('Locale picker should have no accessibility violations', async ({}, testInfo) => {
10341092
await page.goto(url.list)

test/localization/int.spec.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,6 @@ describe('Localization', () => {
217217
id: postWithLocalizedData.id,
218218
collection,
219219
locale: portugueseLocale,
220-
// @ts-expect-error - testing fallbackLocale 'none' for backwards compatibility though the correct type here is `false`
221220
fallbackLocale: 'none',
222221
})
223222

@@ -2933,6 +2932,82 @@ describe('Localization', () => {
29332932
}),
29342933
).rejects.toBeTruthy()
29352934
})
2935+
2936+
it('should return correct error path without locale suffix for top-level localized unique field', async () => {
2937+
const uniqueValue = `unique-path-test-${Date.now()}`
2938+
2939+
await payload.create({
2940+
collection: localizedPostsSlug,
2941+
locale: 'en',
2942+
data: {
2943+
unique: uniqueValue,
2944+
},
2945+
})
2946+
2947+
try {
2948+
await payload.create({
2949+
collection: localizedPostsSlug,
2950+
locale: 'en',
2951+
data: {
2952+
unique: uniqueValue,
2953+
},
2954+
})
2955+
expect.unreachable('Should have thrown a ValidationError')
2956+
} catch (error: any) {
2957+
// eslint-disable-next-line vitest/no-conditional-expect
2958+
expect(error.name).toBe('ValidationError')
2959+
const fieldError = error.data.errors[0]
2960+
2961+
// eslint-disable-next-line vitest/no-conditional-expect
2962+
expect(fieldError.message).toContain('unique')
2963+
// The path should be the field name without locale suffix
2964+
// eslint-disable-next-line vitest/no-conditional-expect
2965+
expect(fieldError.path).toBe('unique')
2966+
}
2967+
})
2968+
2969+
it('should return correct error path without locale suffix for localized unique field inside tabs', async () => {
2970+
const uniqueValue = `seo-unique-test-${Date.now()}`
2971+
2972+
const blockData = [{ blockType: 'text', text: 'test' }]
2973+
2974+
await payload.create({
2975+
collection: withRequiredLocalizedFields,
2976+
locale: 'en',
2977+
data: {
2978+
title: 'Test title 1',
2979+
seoTitle: uniqueValue,
2980+
nav: {
2981+
layout: blockData,
2982+
},
2983+
},
2984+
})
2985+
2986+
try {
2987+
await payload.create({
2988+
collection: withRequiredLocalizedFields,
2989+
locale: 'en',
2990+
data: {
2991+
title: 'Test title 2',
2992+
seoTitle: uniqueValue,
2993+
nav: {
2994+
layout: blockData,
2995+
},
2996+
},
2997+
})
2998+
expect.unreachable('Should have thrown a ValidationError')
2999+
} catch (error: any) {
3000+
// eslint-disable-next-line vitest/no-conditional-expect
3001+
expect(error.name).toBe('ValidationError')
3002+
const fieldError = error.data.errors[0]
3003+
3004+
// eslint-disable-next-line vitest/no-conditional-expect
3005+
expect(fieldError.message).toContain('unique')
3006+
// The path should be the field name without locale suffix (not "seoTitle.en")
3007+
// eslint-disable-next-line vitest/no-conditional-expect
3008+
expect(fieldError.path).toBe('seoTitle')
3009+
}
3010+
})
29363011
})
29373012

29383013
describe('Copying To Locale', () => {

test/localization/payload-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,7 @@ export interface ArrayField {
534534
export interface LocalizedRequired {
535535
id: string;
536536
title: string;
537+
seoTitle?: string | null;
537538
nav: {
538539
layout: (
539540
| {
@@ -1380,6 +1381,7 @@ export interface ArrayFieldsSelect<T extends boolean = true> {
13801381
*/
13811382
export interface LocalizedRequiredSelect<T extends boolean = true> {
13821383
title?: T;
1384+
seoTitle?: T;
13831385
nav?:
13841386
| T
13851387
| {

0 commit comments

Comments
 (0)