Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/db-mongodb/src/utilities/handleError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ function extractFieldFromMessage(message: string) {
return null
}

function stripLocaleFromPath(path: string, req?: Partial<PayloadRequest>): 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,
Expand All @@ -36,6 +59,10 @@ export const handleError = ({
path = extractFieldFromMessage(error.message)
}

if (path) {
path = stripLocaleFromPath(path, req)
}

throw new ValidationError(
{
collection,
Expand Down
4 changes: 2 additions & 2 deletions packages/drizzle/src/upsertRow/handleUpsertError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
10 changes: 4 additions & 6 deletions test/__helpers/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)

Expand Down
11 changes: 11 additions & 0 deletions test/localization/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
62 changes: 60 additions & 2 deletions test/localization/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
77 changes: 76 additions & 1 deletion test/localization/int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})

Expand Down Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions test/localization/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ export interface ArrayField {
export interface LocalizedRequired {
id: string;
title: string;
seoTitle?: string | null;
nav: {
layout: (
| {
Expand Down Expand Up @@ -1380,6 +1381,7 @@ export interface ArrayFieldsSelect<T extends boolean = true> {
*/
export interface LocalizedRequiredSelect<T extends boolean = true> {
title?: T;
seoTitle?: T;
nav?:
| T
| {
Expand Down
Loading