diff --git a/packages/db-mongodb/src/utilities/handleError.ts b/packages/db-mongodb/src/utilities/handleError.ts index 172548ff6d4..9ef4160b93a 100644 --- a/packages/db-mongodb/src/utilities/handleError.ts +++ b/packages/db-mongodb/src/utilities/handleError.ts @@ -11,6 +11,29 @@ function extractFieldFromMessage(message: string) { return null } +function stripLocaleFromPath(path: string, req?: Partial): string { + if (!path) { + return path + } + + const localization = req?.payload?.config?.localization + if (!localization) { + return path + } + + const lastDotIndex = path.lastIndexOf('.') + if (lastDotIndex === -1) { + return path + } + + const lastSegment = path.substring(lastDotIndex + 1) + if (localization.localeCodes.includes(lastSegment)) { + return path.substring(0, lastDotIndex) + } + + return path +} + export const handleError = ({ collection, error, @@ -36,6 +59,10 @@ export const handleError = ({ path = extractFieldFromMessage(error.message) } + if (path) { + path = stripLocaleFromPath(path, req) + } + throw new ValidationError( { collection, diff --git a/packages/drizzle/src/upsertRow/handleUpsertError.ts b/packages/drizzle/src/upsertRow/handleUpsertError.ts index 9ca7bccc81d..75c8542b2c2 100644 --- a/packages/drizzle/src/upsertRow/handleUpsertError.ts +++ b/packages/drizzle/src/upsertRow/handleUpsertError.ts @@ -60,8 +60,8 @@ export const handleUpsertError = ({ } } } else if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { - // SQLite - extract from message: "UNIQUE constraint failed: table.field" - const regex = /UNIQUE constraint failed: ([^.]+)\.([^.]+)/ + // SQLite - extract from message: "UNIQUE constraint failed: table.field[, table.field2, ...]" + const regex = /UNIQUE constraint failed: ([^.]+)\.([^.,]+)/ const match: string[] = error.message?.match(regex) if (match && match[2]) { if (adapter.fieldConstraints[tableName]) { diff --git a/test/__helpers/e2e/helpers.ts b/test/__helpers/e2e/helpers.ts index 79016019bb3..2fc6331844b 100644 --- a/test/__helpers/e2e/helpers.ts +++ b/test/__helpers/e2e/helpers.ts @@ -301,17 +301,15 @@ export async function changeLocale(page: Page, newLocale: string) { .textContent() if (currentlySelectedLocale !== `(${newLocale})`) { - const localeToSelect = page + const localeButton = page .locator('.popup__content .popup-button-list__button') - .locator('.localizer__locale-code', { - hasText: `${newLocale}`, - }) + .filter({ has: page.locator(`[data-locale="${newLocale}"]`) }) - await expect(async () => await expect(localeToSelect).toBeEnabled()).toPass({ + await expect(async () => await expect(localeButton).toBeEnabled()).toPass({ timeout: POLL_TOPASS_TIMEOUT, }) - await localeToSelect.click() + await localeButton.click() const regexPattern = new RegExp(`locale=${newLocale}`) diff --git a/test/localization/config.ts b/test/localization/config.ts index 8fe715fca39..b393c090a23 100644 --- a/test/localization/config.ts +++ b/test/localization/config.ts @@ -159,6 +159,17 @@ export default buildConfigWithDefaults({ { type: 'tabs', tabs: [ + { + label: 'SEO', + fields: [ + { + name: 'seoTitle', + type: 'text', + localized: true, + unique: true, + }, + ], + }, { label: 'Main Nav', fields: [ diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 4aa40f252c9..f4800e46ae4 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -336,6 +336,7 @@ describe('Localization', () => { await waitForFormReady(page) await changeLocale(page, defaultLocale) await page.locator('#field-title').fill(englishTitle) + await page.locator('button.tabs-field__tab-button', { hasText: 'Main Nav' }).click() await page.locator('#field-nav__layout .blocks-field__drawer-toggler').click() await page.locator('button[title="Text"]').click() await page.locator('#field-nav__layout__0__text').waitFor({ state: 'visible' }) @@ -837,7 +838,7 @@ describe('Localization', () => { await changeLocale(page, defaultLocale) await fillValues({ title: 'English Title' }) await saveDocAndAssert(page) - const id = await page.locator('.id-label').innerText() + const id = await page.locator('.id-label').getAttribute('title') await changeLocale(page, spanishLocale) await fillValues({ title: 'Spanish Title' }) @@ -868,11 +869,11 @@ describe('Localization', () => { // Close all toasts to prevent them from interfering with subsequent tests. E.g. the following could happen await closeAllToasts(page) - await expect.poll(() => page.url()).not.toContain(id) await page.waitForURL((url) => !url.toString().includes(id)) // Wait for page to be ready after duplicate redirect await expect(page.locator('.localizer button.popup-button')).toBeVisible() + await waitForFormReady(page) await changeLocale(page, defaultLocale) await expect(page.locator('#field-title')).toHaveValue('English Title') await changeLocale(page, spanishLocale) @@ -972,6 +973,63 @@ describe('Localization', () => { }) }) + describe('unique localized field validation errors', () => { + test('should show correct field name in toast and highlight seoTitle inside tabs on duplicate unique value', async () => { + await page.goto(urlWithRequiredLocalizedFields.create) + await waitForFormReady(page) + await changeLocale(page, defaultLocale) + + const uniqueSeoTitle = `seo-e2e-unique-${Date.now()}` + + await payload.create({ + collection: withRequiredLocalizedFields, + data: { + title: 'Existing doc title', + seoTitle: uniqueSeoTitle, + nav: { + layout: [ + { + type: 'text', + text: 'existing block', + }, + ], + }, + }, + locale: defaultLocale, + }) + + // seoTitle is in the SEO tab (active by default) — fill it first + await page.locator('#field-seoTitle').fill(uniqueSeoTitle) + await page.locator('#field-title').fill('Second doc title') + + await page.locator('button.tabs-field__tab-button', { hasText: 'Main Nav' }).click() + await page.locator('#field-nav__layout .blocks-field__drawer-toggler').click() + await page.locator('button[title="Text"]').click() + await page.locator('#field-nav__layout__0__text').waitFor({ state: 'visible' }) + await page.locator('#field-nav__layout__0__text').fill('test block') + + // Switch back to SEO tab so the field error tooltip is visible after save + await page.locator('button.tabs-field__tab-button', { hasText: 'SEO' }).click() + + await saveDocAndAssert(page, '#action-save', 'error', { disableDismissAllToasts: true }) + + // 1. Toast error message should reference 'seoTitle', not 'seoTitle.en' + const errorToast = page.locator('.payload-toast-container .toast-error') + await expect(errorToast).toBeVisible() + await expect(errorToast.locator('[data-testid="field-error"]')).toHaveText('seoTitle') + + await closeAllToasts(page) + + // 2. SEO tab button should be highlighted with an error pill + const seoTabButton = page.locator('button.tabs-field__tab-button', { hasText: 'SEO' }) + await expect(seoTabButton).toHaveClass(/tabs-field__tab-button--has-error/) + await expect(seoTabButton.locator('.error-pill')).toBeVisible() + + // 3. The seoTitle field itself should be in error state + await expect(page.locator('.field-type.text:has(#field-seoTitle)')).toHaveClass(/\berror\b/) + }) + }) + describe('A11y', () => { test.fixme('Locale picker should have no accessibility violations', async ({}, testInfo) => { await page.goto(url.list) diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index a355f5c4c52..f3b731ee17c 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -217,7 +217,6 @@ describe('Localization', () => { id: postWithLocalizedData.id, collection, locale: portugueseLocale, - // @ts-expect-error - testing fallbackLocale 'none' for backwards compatibility though the correct type here is `false` fallbackLocale: 'none', }) @@ -2933,6 +2932,82 @@ describe('Localization', () => { }), ).rejects.toBeTruthy() }) + + it('should return correct error path without locale suffix for top-level localized unique field', async () => { + const uniqueValue = `unique-path-test-${Date.now()}` + + await payload.create({ + collection: localizedPostsSlug, + locale: 'en', + data: { + unique: uniqueValue, + }, + }) + + try { + await payload.create({ + collection: localizedPostsSlug, + locale: 'en', + data: { + unique: uniqueValue, + }, + }) + expect.unreachable('Should have thrown a ValidationError') + } catch (error: any) { + // eslint-disable-next-line vitest/no-conditional-expect + expect(error.name).toBe('ValidationError') + const fieldError = error.data.errors[0] + + // eslint-disable-next-line vitest/no-conditional-expect + expect(fieldError.message).toContain('unique') + // The path should be the field name without locale suffix + // eslint-disable-next-line vitest/no-conditional-expect + expect(fieldError.path).toBe('unique') + } + }) + + it('should return correct error path without locale suffix for localized unique field inside tabs', async () => { + const uniqueValue = `seo-unique-test-${Date.now()}` + + const blockData = [{ blockType: 'text', text: 'test' }] + + await payload.create({ + collection: withRequiredLocalizedFields, + locale: 'en', + data: { + title: 'Test title 1', + seoTitle: uniqueValue, + nav: { + layout: blockData, + }, + }, + }) + + try { + await payload.create({ + collection: withRequiredLocalizedFields, + locale: 'en', + data: { + title: 'Test title 2', + seoTitle: uniqueValue, + nav: { + layout: blockData, + }, + }, + }) + expect.unreachable('Should have thrown a ValidationError') + } catch (error: any) { + // eslint-disable-next-line vitest/no-conditional-expect + expect(error.name).toBe('ValidationError') + const fieldError = error.data.errors[0] + + // eslint-disable-next-line vitest/no-conditional-expect + expect(fieldError.message).toContain('unique') + // The path should be the field name without locale suffix (not "seoTitle.en") + // eslint-disable-next-line vitest/no-conditional-expect + expect(fieldError.path).toBe('seoTitle') + } + }) }) describe('Copying To Locale', () => { diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index 88157751d8c..a54d51b381c 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -534,6 +534,7 @@ export interface ArrayField { export interface LocalizedRequired { id: string; title: string; + seoTitle?: string | null; nav: { layout: ( | { @@ -1380,6 +1381,7 @@ export interface ArrayFieldsSelect { */ export interface LocalizedRequiredSelect { title?: T; + seoTitle?: T; nav?: | T | {