Files
payload/test/localization/int.spec.ts
German Jablonski 4a712b3483 fix(ui): preserve localized blocks and arrays when using CopyToLocale (#13216)
## Problem:
In PR #11887, a bug fix for `copyToLocale` was introduced to address
issues with copying content between locales in Postgres. However, an
incorrect algorithm was used, which removed all "id" properties from
documents being copied. This led to bug #12536, where `copyToLocale`
would mistakenly delete the document in the source language, affecting
not only Postgres but any database.

## Cause and Solution:

When copying documents with localized arrays or blocks, Postgres throws
errors if there are two blocks with the same ID. This is why PR #11887
removed all IDs from the document to avoid conflicts. However, this
removal was too broad and caused issues in cases where it was
unnecessary.


The correct solution should remove the IDs only in nested fields whose
ancestors are localized. The reasoning is as follows:
- When an array/block is **not localized** (`localized: false`), if it
contains localized fields, these fields share the same ID across
different locales.
- When an array/block **is localized** (`localized: true`), its
descendant fields cannot share the same ID across different locales if
Postgres is being used. This wouldn't be an issue if the table
containing localized blocks had a composite primary key of `locale +
id`. However, since the primary key is just `id`, we need to assign a
new ID for these fields.

This PR properly removes IDs **only for nested fields** whose ancestors
are localized.

Fixes #12536

## Example:
### Before Fix:
```js
// Original document (en)
array: [{
  id: "123",
  text: { en: "English text" }
}]

// After copying to 'es' locale, a new ID was created instead of updating the existing item
array: [{
  id: "456",  // 🐛 New ID created!
  text: { es: "Spanish text" } // 🐛 'en' locale is missing
}]
```
### After fix:
```js
// After fix
array: [{
  id: "123",  //  Same ID maintained
  text: {
    en: "English text",
    es: "Spanish text"  //  Properly merged with existing item
  }
}]
```


## Additional fixes:

### TraverseFields

In the process of designing an appropriate solution, I detected a couple
of bugs in traverseFields that are also addressed in this PR.

### Fixed MongoDB Empty Array Handling

During testing, I discovered that MongoDB and PostgreSQL behave
differently when querying documents that don't exist in a specific
locale:
- PostgreSQL: Returns the document with data from the fallback locale
- MongoDB: Returns the document with empty arrays for localized fields

This difference caused `copyToLocale` to fail in MongoDB because the
merge algorithm only checked for `null` or `undefined` values, but not
empty arrays. When MongoDB returned `content: []` for a non-existent
locale, the algorithm would attempt to iterate over the empty array
instead of using the source locale's data.

### Move test e2e to int

The test introduced in #11887 didn't catch the bug because our e2e suite
doesn't run on Postgres. I migrated the test to an integration test that
does run on Postgres and MongoDB.
2025-07-24 20:37:13 +01:00

3056 lines
88 KiB
TypeScript

import type { Payload, User, Where } from 'payload'
import path from 'path'
import { createLocalReq } from 'payload'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import type {
BlocksField,
LocalizedPost,
LocalizedSort,
Nested,
WithLocalizedRelationship,
} from './payload-types.js'
import { devUser } from '../credentials.js'
// eslint-disable-next-line payload/no-relative-monorepo-imports
import { copyDataFromLocaleHandler } from '../../packages/ui/src/utilities/copyDataFromLocale.js'
import { idToString } from '../helpers/idToString.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { arrayCollectionSlug } from './collections/Array/index.js'
import { groupSlug } from './collections/Group/index.js'
import { nestedToArrayAndBlockCollectionSlug } from './collections/NestedToArrayAndBlock/index.js'
import { tabSlug } from './collections/Tab/index.js'
import {
defaultLocale,
defaultLocale as englishLocale,
englishTitle,
hungarianLocale,
localizedDateFieldsSlug,
localizedPostsSlug,
localizedSortSlug,
portugueseLocale,
relationEnglishTitle,
relationEnglishTitle2,
relationshipLocalizedSlug,
relationSpanishTitle,
relationSpanishTitle2,
spanishLocale,
spanishTitle,
withLocalizedRelSlug,
withRequiredLocalizedFields,
} from './shared.js'
const collection = localizedPostsSlug
let payload: Payload
let restClient: NextRESTClient
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('Localization', () => {
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname))
})
afterAll(async () => {
await payload.destroy()
})
describe('Localization with fallback true', () => {
let post1: LocalizedPost
let postWithLocalizedData: LocalizedPost
beforeAll(async () => {
post1 = await payload.create({
collection,
data: {
title: englishTitle,
},
})
postWithLocalizedData = await payload.create({
collection,
data: {
title: englishTitle,
},
})
await payload.update({
id: postWithLocalizedData.id,
collection,
data: {
title: spanishTitle,
},
locale: spanishLocale,
})
})
describe('Localized text', () => {
it('create english', async () => {
const allDocs = await payload.find({
collection,
where: {
title: { equals: post1.title },
},
})
expect(allDocs.docs).toContainEqual(expect.objectContaining(post1))
})
it('add spanish translation', async () => {
const updated = await payload.update({
id: post1.id,
collection,
data: {
title: spanishTitle,
},
locale: spanishLocale,
})
expect(updated.title).toEqual(spanishTitle)
const localized: any = await payload.findByID({
id: post1.id,
collection,
locale: 'all',
})
expect(localized.title.en).toEqual(englishTitle)
expect(localized.title.es).toEqual(spanishTitle)
})
it('should fallback to english translation when empty', async () => {
await payload.update({
id: post1.id,
collection,
data: {
title: '',
},
locale: spanishLocale,
})
const retrievedInEnglish = await payload.findByID({
id: post1.id,
collection,
})
expect(retrievedInEnglish.title).toEqual(englishTitle)
const localizedFallback: any = await payload.findByID({
id: post1.id,
collection,
locale: 'all',
})
expect(localizedFallback.title.en).toEqual(englishTitle)
expect(localizedFallback.title.es).toEqual('')
})
it('should fallback to spanish translation when empty and locale-specific fallback is provided', async () => {
const localizedFallback: any = await payload.findByID({
id: postWithLocalizedData.id,
collection,
locale: portugueseLocale,
})
expect(localizedFallback.title).toEqual(spanishTitle)
})
it('should respect fallback none', async () => {
const localizedFallback: any = await payload.findByID({
id: postWithLocalizedData.id,
collection,
locale: portugueseLocale,
// @ts-expect-error - testing fallbackLocale 'none' for backwards compatibility though the correct type here is `false`
fallbackLocale: 'none',
})
expect(localizedFallback.title).not.toBeDefined()
})
describe('fallback locales', () => {
let englishData
let spanishData
let localizedDoc
beforeAll(async () => {
englishData = {
localizedCheckbox: false,
}
spanishData = {
localizedCheckbox: true,
title: 'spanish title',
}
localizedDoc = await payload.create({
collection: localizedPostsSlug,
data: englishData,
locale: englishLocale,
})
await payload.update({
id: localizedDoc.id,
collection: localizedPostsSlug,
data: spanishData,
locale: spanishLocale,
})
await payload.update({
id: localizedDoc.id,
collection: localizedPostsSlug,
data: { localizedCheckbox: true },
locale: portugueseLocale,
})
})
it('should return localized fields using fallbackLocale specified in the requested locale config', async () => {
const portugueseDoc = await payload.findByID({
id: localizedDoc.id,
collection: localizedPostsSlug,
locale: portugueseLocale,
})
expect(portugueseDoc.title).toStrictEqual(spanishData.title)
expect(portugueseDoc.localizedCheckbox).toStrictEqual(true)
})
})
describe('querying', () => {
let localizedPost: LocalizedPost
beforeEach(async () => {
const { id } = await payload.create({
collection,
data: {
title: englishTitle,
},
})
localizedPost = await payload.update({
id,
collection,
data: {
title: spanishTitle,
},
locale: spanishLocale,
})
})
it('unspecified locale returns default', async () => {
const localized = await payload.findByID({
id: localizedPost.id,
collection,
})
expect(localized.title).toEqual(englishTitle)
})
it('specific locale - same as default', async () => {
const localized = await payload.findByID({
id: localizedPost.id,
collection,
locale: defaultLocale,
})
expect(localized.title).toEqual(englishTitle)
})
it('specific locale - not default', async () => {
const localized = await payload.findByID({
id: localizedPost.id,
collection,
locale: spanishLocale,
})
expect(localized.title).toEqual(spanishTitle)
})
it('all locales', async () => {
const localized: any = await payload.findByID({
id: localizedPost.id,
collection,
locale: 'all',
})
expect(localized.title.en).toEqual(englishTitle)
expect(localized.title.es).toEqual(spanishTitle)
})
it('rest all locales with all', async () => {
const response = await restClient.GET(`/${collection}/${localizedPost.id}`, {
query: {
locale: 'all',
},
})
expect(response.status).toBe(200)
const localized = await response.json()
expect(localized.title.en).toEqual(englishTitle)
expect(localized.title.es).toEqual(spanishTitle)
})
it('rest all locales with asterisk', async () => {
const response = await restClient.GET(`/${collection}/${localizedPost.id}`, {
query: {
locale: '*',
},
})
expect(response.status).toBe(200)
const localized = await response.json()
expect(localized.title.en).toEqual(englishTitle)
expect(localized.title.es).toEqual(spanishTitle)
})
it('by localized field value - default locale', async () => {
const result = await payload.find({
collection,
where: {
title: {
equals: englishTitle,
},
},
})
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id)
})
it('by localized field value - alternate locale', async () => {
const result = await payload.find({
collection,
locale: spanishLocale,
where: {
title: {
equals: spanishTitle,
},
},
})
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id)
})
it('by localized field value - opposite locale???', async () => {
const result = await payload.find({
collection,
locale: 'all',
where: {
'title.es': {
equals: spanishTitle,
},
},
})
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id)
})
it('by localized field value with sorting', async () => {
const doc_1 = await payload.create({ collection, data: { title: 'word_b' } })
const doc_2 = await payload.create({ collection, data: { title: 'word_a' } })
const doc_3 = await payload.create({ collection, data: { title: 'word_c' } })
await payload.create({ collection, data: { title: 'others_c' } })
const { docs } = await payload.find({
collection,
sort: 'title',
where: {
title: {
like: 'word',
},
},
})
expect(docs).toHaveLength(3)
expect(docs[0].id).toBe(doc_2.id)
expect(docs[1].id).toBe(doc_1.id)
expect(docs[2].id).toBe(doc_3.id)
})
if (['mongodb'].includes(process.env.PAYLOAD_DATABASE)) {
describe('Localized sorting', () => {
let localizedAccentPostOne: LocalizedPost
let localizedAccentPostTwo: LocalizedPost
beforeEach(async () => {
localizedAccentPostOne = await payload.create({
collection,
data: {
title: 'non accent post',
localizedDescription: 'something',
},
locale: englishLocale,
})
localizedAccentPostTwo = await payload.create({
collection,
data: {
title: 'accent post',
localizedDescription: 'veterinarian',
},
locale: englishLocale,
})
await payload.update({
id: localizedAccentPostOne.id,
collection,
data: {
title: 'non accent post',
localizedDescription: 'valami',
},
locale: hungarianLocale,
})
await payload.update({
id: localizedAccentPostTwo.id,
collection,
data: {
title: 'accent post',
localizedDescription: 'állatorvos',
},
locale: hungarianLocale,
})
})
it('should sort alphabetically even with accented letters', async () => {
const sortByDescriptionQuery = await payload.find({
collection,
sort: 'description',
where: {
title: {
like: 'accent',
},
},
locale: hungarianLocale,
})
expect(sortByDescriptionQuery.docs[0].id).toEqual(localizedAccentPostTwo.id)
})
})
}
})
})
describe('Localized date', () => {
it('can create a localized date', async () => {
const document = await payload.create({
collection: localizedDateFieldsSlug,
data: {
localizedDate: new Date().toISOString(),
date: new Date().toISOString(),
},
})
expect(document.localizedDate).toBeTruthy()
})
it('data is typed as string', async () => {
const document = await payload.create({
collection: localizedDateFieldsSlug,
data: {
localizedDate: new Date().toISOString(),
date: new Date().toISOString(),
},
})
expect(typeof document.localizedDate).toBe('string')
expect(typeof document.date).toBe('string')
})
})
describe('Localized Sort Count', () => {
const expectedTotalDocs = 5
const posts: LocalizedSort[] = []
beforeAll(async () => {
for (let i = 1; i <= expectedTotalDocs; i++) {
const post = await payload.create({
collection: localizedSortSlug,
data: {
date: new Date().toISOString(),
title: `EN ${i}`,
},
locale: englishLocale,
})
posts.push(post)
await payload.update({
id: post.id,
collection: localizedSortSlug,
data: {
date: new Date().toISOString(),
title: `ES ${i}`,
},
locale: spanishLocale,
})
}
})
it('should have correct totalDocs when unsorted', async () => {
const simpleQuery = await payload.find({
collection: localizedSortSlug,
})
const sortByIdQuery = await payload.find({
collection: localizedSortSlug,
sort: 'id',
})
expect(simpleQuery.totalDocs).toEqual(expectedTotalDocs)
expect(sortByIdQuery.totalDocs).toEqual(expectedTotalDocs)
})
// https://github.com/payloadcms/payload/issues/4889
it('should have correct totalDocs when sorted by localized fields', async () => {
const sortByTitleQuery = await payload.find({
collection: localizedSortSlug,
sort: 'title',
})
const sortByDateQuery = await payload.find({
collection: localizedSortSlug,
sort: 'date',
})
expect(sortByTitleQuery.totalDocs).toEqual(expectedTotalDocs)
expect(sortByDateQuery.totalDocs).toEqual(expectedTotalDocs)
})
it('should return correct order when sorted by localized fields', async () => {
const { docs: docsAsc } = await payload.find({
collection: localizedSortSlug,
sort: 'title',
})
docsAsc.forEach((doc, i) => {
expect(posts[i].id).toBe(doc.id)
})
const { docs: docsDesc } = await payload.find({
collection: localizedSortSlug,
sort: '-title',
})
docsDesc.forEach((doc, i) => {
expect(posts.at(posts.length - i - 1).id).toBe(doc.id)
})
// Test with words
const randomWords = [
'sunset',
'whisper',
'lighthouse',
'harmony',
'crystal',
'thunder',
'meadow',
'voyage',
'echo',
'quicksand',
]
const randomWordsSpanish = [
'atardecer',
'susurro',
'faro',
'armonía',
'cristal',
'trueno',
'pradera',
'viaje',
'eco',
'arenas movedizas',
]
expect(randomWords).toHaveLength(randomWordsSpanish.length)
const randomWordsPosts: (number | string)[] = []
for (let i = 0; i < randomWords.length; i++) {
const en = randomWords[i]
const post = await payload.create({ collection: 'localized-sort', data: { title: en } })
const es = randomWordsSpanish[i]
await payload.update({
collection: 'localized-sort',
data: { title: es },
id: post.id,
locale: 'es',
})
randomWordsPosts.push(post.id)
}
const ascSortedWordsEn = randomWords.toSorted((a, b) => a.localeCompare(b))
const descSortedWordsEn = randomWords.toSorted((a, b) => b.localeCompare(a))
const q = { id: { in: randomWordsPosts } }
const { docs: randomWordsEnAsc } = await payload.find({
collection: localizedSortSlug,
sort: 'title',
where: q,
})
randomWordsEnAsc.forEach((doc, i) => {
expect(ascSortedWordsEn[i]).toBe(doc.title)
})
const { docs: randomWordsEnDesc } = await payload.find({
collection: localizedSortSlug,
sort: '-title',
where: q,
})
randomWordsEnDesc.forEach((doc, i) => {
expect(descSortedWordsEn[i]).toBe(doc.title)
})
// Test sorting for Spanish locale
const ascSortedWordsEs = randomWordsSpanish.toSorted((a, b) => a.localeCompare(b))
const descSortedWordsEs = randomWordsSpanish.toSorted((a, b) => b.localeCompare(a))
// Fetch sorted words in Spanish (ascending)
const { docs: randomWordsEsAsc } = await payload.find({
collection: localizedSortSlug,
sort: 'title',
where: q,
locale: 'es',
})
randomWordsEsAsc.forEach((doc, i) => {
expect(ascSortedWordsEs[i]).toBe(doc.title)
})
// Fetch sorted words in Spanish (descending)
const { docs: randomWordsEsDesc } = await payload.find({
collection: localizedSortSlug,
sort: '-title',
where: q,
locale: 'es',
})
randomWordsEsDesc.forEach((doc, i) => {
expect(descSortedWordsEs[i]).toBe(doc.title)
})
})
})
describe('Localized Relationship', () => {
let localizedRelation: LocalizedPost
let localizedRelation2: LocalizedPost
let withRelationship: WithLocalizedRelationship
beforeAll(async () => {
localizedRelation = await createLocalizedPost({
title: {
[defaultLocale]: relationEnglishTitle,
[spanishLocale]: relationSpanishTitle,
},
})
localizedRelation2 = await createLocalizedPost({
title: {
[defaultLocale]: relationEnglishTitle2,
[spanishLocale]: relationSpanishTitle2,
},
})
withRelationship = await payload.create({
collection: withLocalizedRelSlug,
data: {
localizedRelationHasManyField: [localizedRelation.id, localizedRelation2.id],
localizedRelationMultiRelationTo: {
relationTo: localizedPostsSlug,
value: localizedRelation.id,
},
localizedRelationMultiRelationToHasMany: [
{ relationTo: localizedPostsSlug, value: localizedRelation.id },
{ relationTo: localizedPostsSlug, value: localizedRelation2.id },
],
localizedRelationship: localizedRelation.id,
},
})
})
describe('regular relationship', () => {
it('can query localized relationship', async () => {
const result = await payload.find({
collection: withLocalizedRelSlug,
where: {
'localizedRelationship.title': {
equals: localizedRelation.title,
},
},
})
expect(result.docs[0].id).toEqual(withRelationship.id)
})
it('specific locale', async () => {
const result = await payload.find({
collection: withLocalizedRelSlug,
locale: spanishLocale,
where: {
'localizedRelationship.title': {
equals: relationSpanishTitle,
},
},
})
expect(result.docs[0].id).toEqual(withRelationship.id)
})
it('all locales', async () => {
const result = await payload.find({
collection: withLocalizedRelSlug,
locale: 'all',
where: {
'localizedRelationship.title.es': {
equals: relationSpanishTitle,
},
},
})
expect(result.docs[0].id).toEqual(withRelationship.id)
})
it('populates relationships with all locales', async () => {
// the relationship fields themselves are localized on this collection
const result: any = await payload.find({
collection: relationshipLocalizedSlug,
depth: 1,
locale: 'all',
})
expect(result.docs[0].relationship.en.id).toBeDefined()
expect(result.docs[0].relationshipHasMany.en[0].id).toBeDefined()
expect(result.docs[0].relationMultiRelationTo.en.value.id).toBeDefined()
expect(result.docs[0].relationMultiRelationToHasMany.en[0].value.id).toBeDefined()
expect(result.docs[0].arrayField.en[0].nestedRelation.id).toBeDefined()
})
})
describe('relationship - hasMany', () => {
it('default locale', async () => {
const result = await payload.find({
collection: withLocalizedRelSlug,
where: {
'localizedRelationHasManyField.title': {
equals: localizedRelation.title,
},
},
})
expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id)
// Second relationship
const result2 = await payload.find({
collection: withLocalizedRelSlug,
where: {
'localizedRelationHasManyField.title': {
equals: localizedRelation2.title,
},
},
})
expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id)
})
it('specific locale', async () => {
const result = await payload.find({
collection: withLocalizedRelSlug,
locale: spanishLocale,
where: {
'localizedRelationHasManyField.title': {
equals: relationSpanishTitle,
},
},
})
expect(result.docs[0].id).toEqual(withRelationship.id)
// Second relationship
const result2 = await payload.find({
collection: withLocalizedRelSlug,
locale: spanishLocale,
where: {
'localizedRelationHasManyField.title': {
equals: relationSpanishTitle2,
},
},
})
expect(result2.docs[0].id).toEqual(withRelationship.id)
})
it('relationship population uses locale', async () => {
const result = await payload.findByID({
id: withRelationship.id,
collection: withLocalizedRelSlug,
depth: 1,
locale: spanishLocale,
})
expect((result.localizedRelationship as LocalizedPost).title).toEqual(
relationSpanishTitle,
)
})
it('all locales', async () => {
const queryRelation = (where: Where) => {
return payload.find({
collection: withLocalizedRelSlug,
locale: 'all',
where,
})
}
const result = await queryRelation({
'localizedRelationHasManyField.title.en': {
equals: relationEnglishTitle,
},
})
expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id)
// First relationship - spanish
const result2 = await queryRelation({
'localizedRelationHasManyField.title.es': {
equals: relationSpanishTitle,
},
})
expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id)
// Second relationship - english
const result3 = await queryRelation({
'localizedRelationHasManyField.title.en': {
equals: relationEnglishTitle2,
},
})
expect(result3.docs.map(({ id }) => id)).toContain(withRelationship.id)
// Second relationship - spanish
const result4 = await queryRelation({
'localizedRelationHasManyField.title.es': {
equals: relationSpanishTitle2,
},
})
expect(result4.docs[0].id).toEqual(withRelationship.id)
})
})
describe('relationTo multi', () => {
it('by id', async () => {
const result = await payload.find({
collection: withLocalizedRelSlug,
where: {
'localizedRelationMultiRelationTo.value': {
equals: localizedRelation.id,
},
},
})
expect(result.docs[0].id).toEqual(withRelationship.id)
// Second relationship
const result2 = await payload.find({
collection: withLocalizedRelSlug,
locale: spanishLocale,
where: {
'localizedRelationMultiRelationTo.value': {
equals: localizedRelation.id,
},
},
})
expect(result2.docs[0].id).toEqual(withRelationship.id)
})
})
describe('relationTo multi hasMany', () => {
it('by id', async () => {
const result = await payload.find({
collection: withLocalizedRelSlug,
where: {
'localizedRelationMultiRelationToHasMany.value': {
equals: localizedRelation.id,
},
},
})
expect(result.docs[0].id).toEqual(withRelationship.id)
// First relationship - spanish locale
const result2 = await payload.find({
collection: withLocalizedRelSlug,
locale: spanishLocale,
where: {
'localizedRelationMultiRelationToHasMany.value': {
equals: localizedRelation.id,
},
},
})
expect(result2.docs[0].id).toEqual(withRelationship.id)
// Second relationship
const result3 = await payload.find({
collection: withLocalizedRelSlug,
where: {
'localizedRelationMultiRelationToHasMany.value': {
equals: localizedRelation2.id,
},
},
})
expect(result3.docs[0].id).toEqual(withRelationship.id)
// Second relationship - spanish locale
const result4 = await payload.find({
collection: withLocalizedRelSlug,
where: {
'localizedRelationMultiRelationToHasMany.value': {
equals: localizedRelation2.id,
},
},
})
expect(result4.docs[0].id).toEqual(withRelationship.id)
})
})
})
describe('Localized - arrays with nested localized fields', () => {
it('should allow moving rows and retain existing row locale data', async () => {
const globalArray: any = await payload.findGlobal({
slug: 'global-array',
})
const reversedArrayRows = [...globalArray.array].reverse()
const updatedGlobal = await payload.updateGlobal({
slug: 'global-array',
data: {
array: reversedArrayRows,
},
locale: 'all',
})
expect(updatedGlobal.array[0].text.en).toStrictEqual('test en 2')
expect(updatedGlobal.array[0].text.es).toStrictEqual('test es 2')
})
})
describe('Localized - required', () => {
it('should update without passing all required fields', async () => {
const newDoc = await payload.create({
collection: withRequiredLocalizedFields,
data: {
nav: {
layout: [
{
blockType: 'text',
text: 'laiwejfilwaje',
},
],
},
title: 'hello',
},
})
await payload.update({
id: newDoc.id,
collection: withRequiredLocalizedFields,
data: {
nav: {
layout: [
{
blockType: 'number',
number: 12,
},
],
},
title: 'en espanol, big bird',
},
locale: spanishLocale,
})
const updatedDoc = await payload.update({
id: newDoc.id,
collection: withRequiredLocalizedFields,
data: {
title: 'hello x2',
},
})
expect(updatedDoc.nav.layout[0].blockType).toStrictEqual('text')
const spanishDoc = await payload.findByID({
id: newDoc.id,
collection: withRequiredLocalizedFields,
locale: spanishLocale,
})
expect(spanishDoc.nav.layout[0].blockType).toStrictEqual('number')
})
})
describe('Localized - GraphQL', () => {
let token
let client
it('should allow user to login and retrieve populated localized field', async () => {
const query = `mutation {
loginUser(email: "dev@payloadcms.com", password: "test") {
token
user {
relation {
title
}
}
}
}`
const { data } = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query }),
query: { locale: 'en' },
})
.then((res) => res.json())
const result = data.loginUser
expect(typeof result.token).toStrictEqual('string')
expect(typeof result.user.relation.title).toStrictEqual('string')
token = result.token
})
it('should allow retrieval of populated localized fields within meUser', async () => {
const query = `query {
meUser {
user {
id
relation {
title
}
}
}
}`
const { data } = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query }),
headers: {
Authorization: `JWT ${token}`,
},
query: { locale: 'en' },
})
.then((res) => res.json())
const result = data.meUser
expect(typeof result.user.relation.title).toStrictEqual('string')
})
it('should create and update collections', async () => {
const create = `mutation {
createLocalizedPost(
data: {
title: "${englishTitle}"
}
locale: ${defaultLocale}
) {
id
title
}
}`
const { data } = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query: create }),
headers: {
Authorization: `JWT ${token}`,
},
query: { locale: 'en' },
})
.then((res) => res.json())
const createResult = data.createLocalizedPost
const update = `mutation {
updateLocalizedPost(
id: ${payload.db.defaultIDType === 'number' ? createResult.id : `"${createResult.id}"`},
data: {
title: "${spanishTitle}"
}
locale: ${spanishLocale}
) {
title
}
}`
const { data: updateData } = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query: update }),
headers: {
Authorization: `JWT ${token}`,
},
query: { locale: 'en' },
})
.then((res) => res.json())
const updateResult = updateData.updateLocalizedPost
const result = await payload.findByID({
id: createResult.id,
collection: localizedPostsSlug,
locale: 'all',
})
expect(createResult.title).toStrictEqual(englishTitle)
expect(updateResult.title).toStrictEqual(spanishTitle)
expect(result.title[defaultLocale]).toStrictEqual(englishTitle)
expect(result.title[spanishLocale]).toStrictEqual(spanishTitle)
})
it('should query multiple locales', async () => {
const englishDoc = await payload.create({
collection: localizedPostsSlug,
data: {
title: englishTitle,
},
locale: defaultLocale,
})
const spanishDoc = await payload.create({
collection: localizedPostsSlug,
data: {
title: spanishTitle,
},
locale: spanishLocale,
})
const query = `
{
es: LocalizedPost(id: ${idToString(spanishDoc.id, payload)}, locale: es) {
title
}
en: LocalizedPost(id: ${idToString(englishDoc.id, payload)}, locale: en) {
title
}
}
`
const { data: multipleLocaleData } = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query }),
headers: {
Authorization: `JWT ${token}`,
},
query: { locale: 'en' },
})
.then((res) => res.json())
const { en, es } = multipleLocaleData
expect(en.title).toStrictEqual(englishTitle)
expect(es.title).toStrictEqual(spanishTitle)
})
})
describe('Localized - Arrays', () => {
let docID
beforeAll(async () => {
const englishDoc = await payload.create({
collection: arrayCollectionSlug,
data: {
items: [
{
text: englishTitle,
},
],
},
})
docID = englishDoc.id
})
it('should use default locale as fallback', async () => {
const spanishDoc = await payload.findByID({
id: docID,
collection: arrayCollectionSlug,
locale: spanishLocale,
})
expect(spanishDoc.items[0].text).toStrictEqual(englishTitle)
})
it('should use empty array as value', async () => {
const updatedSpanishDoc = await payload.update({
id: docID,
collection: arrayCollectionSlug,
data: {
items: [],
},
fallbackLocale: 'none',
locale: spanishLocale,
})
expect(updatedSpanishDoc.items).toStrictEqual([])
})
it('should use fallback value if setting null', async () => {
await payload.update({
id: docID,
collection: arrayCollectionSlug,
data: {
items: [],
},
locale: spanishLocale,
})
const updatedSpanishDoc = await payload.update({
id: docID,
collection: arrayCollectionSlug,
data: {
items: null,
},
locale: spanishLocale,
})
// should return the value of the fallback locale
expect(updatedSpanishDoc.items[0].text).toStrictEqual(englishTitle)
})
})
describe('Localized - Field Paths', () => {
it('should allow querying by non-localized field names ending in a locale', async () => {
await payload.update({
id: post1.id,
collection,
data: {
children: post1.id,
group: {
children: 'something',
},
},
})
const { docs: relationshipDocs } = await restClient
.GET(`/${collection}`, {
query: {
where: {
children: {
in: post1.id,
},
},
},
})
.then((res) => res.json())
expect(relationshipDocs.map(({ id }) => id)).toContain(post1.id)
const { docs: nestedFieldDocs } = await restClient
.GET(`/${collection}`, {
query: {
where: {
'group.children': {
contains: 'some',
},
},
},
})
.then((res) => res.json())
expect(nestedFieldDocs.map(({ id }) => id)).toContain(post1.id)
})
})
describe('Nested To Array And Block', () => {
it('should be equal to the created document', async () => {
const { id, blocks } = await payload.create({
collection: nestedToArrayAndBlockCollectionSlug,
data: {
blocks: [
{
array: [
{
text: 'english',
textNotLocalized: 'test',
},
],
blockType: 'block',
},
],
},
locale: defaultLocale,
})
await payload.update({
id,
collection: nestedToArrayAndBlockCollectionSlug,
data: {
blocks: (blocks as { array: { text: string }[] }[]).map((block) => ({
...block,
array: block.array.map((item) => ({ ...item, text: 'spanish' })),
})),
},
locale: spanishLocale,
})
const docDefaultLocale = await payload.findByID({
id,
collection: nestedToArrayAndBlockCollectionSlug,
locale: defaultLocale,
})
const docSpanishLocale = await payload.findByID({
id,
collection: nestedToArrayAndBlockCollectionSlug,
locale: spanishLocale,
})
const rowDefault = docDefaultLocale.blocks[0].array[0]
const rowSpanish = docSpanishLocale.blocks[0].array[0]
expect(rowDefault.text).toEqual('english')
expect(rowDefault.textNotLocalized).toEqual('test')
expect(rowSpanish.text).toEqual('spanish')
expect(rowSpanish.textNotLocalized).toEqual('test')
})
})
describe('Duplicate Collection', () => {
it('should duplicate localized document', async () => {
const localizedPost = await payload.create({
collection: localizedPostsSlug,
data: {
localizedCheckbox: true,
title: englishTitle,
},
locale: defaultLocale,
})
const id = localizedPost.id.toString()
await payload.update({
id,
collection: localizedPostsSlug,
data: {
localizedCheckbox: false,
title: spanishTitle,
},
locale: spanishLocale,
})
const result = await payload.duplicate({
id,
collection: localizedPostsSlug,
locale: defaultLocale,
})
const allLocales = await payload.findByID({
id: result.id,
collection: localizedPostsSlug,
locale: 'all',
})
// check fields
expect(result.title).toStrictEqual(englishTitle)
expect(allLocales.title.es).toStrictEqual(spanishTitle)
expect(allLocales.localizedCheckbox.en).toBeTruthy()
expect(allLocales.localizedCheckbox.es).toBeFalsy()
})
it('should duplicate with localized blocks', async () => {
// This test covers a few things:
// 1. make sure we can duplicate localized blocks
// - in relational DBs, we need to create new block / array IDs
// - and this needs to be done recursively for all block / array fields
// 2. make sure localized arrays / blocks work inside of localized groups / tabs
// - this is covered with myTab.group.nestedArray2
// 3. the field schema for `nav` is within an unnamed tab, which tests that we
// properly recursively loop through all field structures / types
const englishText = 'english'
const spanishText = 'spanish'
const doc = await payload.create({
collection: withRequiredLocalizedFields,
data: {
nav: {
layout: [
{
blockType: 'text',
text: englishText,
nestedArray: [
{
text: 'hello',
l2: [
{
l3: [
{
l4: [
{
superNestedText: 'hello',
},
],
},
],
},
],
},
{
text: 'goodbye',
l2: [
{
l3: [
{
l4: [
{
superNestedText: 'goodbye',
},
],
},
],
},
],
},
],
},
],
},
myTab: {
text: 'hello',
group: {
nestedText: 'hello',
nestedArray2: [
{
nestedText: 'hello',
},
{
nestedText: 'goodbye',
},
],
},
},
title: 'hello',
},
locale: defaultLocale,
})
await payload.update({
id: doc.id,
collection: withRequiredLocalizedFields,
data: {
nav: {
layout: [
{
blockType: 'text',
text: spanishText,
nestedArray: [
{
text: 'hola',
l2: [
{
l3: [
{
l4: [
{
superNestedText: 'hola',
},
],
},
],
},
],
},
{
text: 'adios',
l2: [
{
l3: [
{
l4: [
{
superNestedText: 'adios',
},
],
},
],
},
],
},
],
},
],
},
title: 'hello',
myTab: {
text: 'hola',
group: {
nestedText: 'hola',
nestedArray2: [
{
nestedText: 'hola',
},
{
nestedText: 'adios',
},
],
},
},
},
locale: spanishLocale,
})
const result = await payload.duplicate({
id: doc.id,
collection: withRequiredLocalizedFields,
locale: defaultLocale,
})
const allLocales = await payload.findByID({
id: result.id,
collection: withRequiredLocalizedFields,
locale: 'all',
})
// check fields
expect(result.nav.layout[0].text).toStrictEqual(englishText)
expect(allLocales.nav.layout.en[0].text).toStrictEqual(englishText)
expect(allLocales.nav.layout.es[0].text).toStrictEqual(spanishText)
expect(allLocales.myTab.group.en.nestedText).toStrictEqual('hello')
expect(allLocales.myTab.group.en.nestedArray2[0].nestedText).toStrictEqual('hello')
expect(allLocales.myTab.group.en.nestedArray2[1].nestedText).toStrictEqual('goodbye')
expect(allLocales.myTab.group.es.nestedText).toStrictEqual('hola')
expect(allLocales.myTab.group.es.nestedArray2[0].nestedText).toStrictEqual('hola')
expect(allLocales.myTab.group.es.nestedArray2[1].nestedText).toStrictEqual('adios')
})
})
describe('Localized group and tabs', () => {
it('should properly create/update/read localized group field', async () => {
const result = await payload.create({
collection: groupSlug,
data: {
groupLocalized: {
title: 'hello en',
},
},
locale: englishLocale,
})
expect(result.groupLocalized?.title).toBe('hello en')
await payload.update({
collection: groupSlug,
locale: spanishLocale,
id: result.id,
data: {
groupLocalized: {
title: 'hello es',
},
},
})
const docEn = await payload.findByID({
collection: groupSlug,
locale: englishLocale,
id: result.id,
})
const docEs = await payload.findByID({
collection: groupSlug,
locale: spanishLocale,
id: result.id,
})
expect(docEn.groupLocalized.title).toBe('hello en')
expect(docEs.groupLocalized.title).toBe('hello es')
})
it('should properly create/update/read localized field inside of group', async () => {
const result = await payload.create({
collection: groupSlug,
locale: englishLocale,
data: {
group: {
title: 'hello en',
},
},
})
expect(result.group.title).toBe('hello en')
await payload.update({
collection: groupSlug,
locale: spanishLocale,
id: result.id,
data: {
group: {
title: 'hello es',
},
},
})
const docEn = await payload.findByID({
collection: groupSlug,
locale: englishLocale,
id: result.id,
})
const docEs = await payload.findByID({
collection: groupSlug,
locale: spanishLocale,
id: result.id,
})
expect(docEn.group.title).toBe('hello en')
expect(docEs.group.title).toBe('hello es')
})
it('should properly create/update/read deep localized field inside of group', async () => {
const result = await payload.create({
collection: groupSlug,
locale: englishLocale,
data: {
deep: {
blocks: [
{
blockType: 'first',
title: 'hello en',
},
],
array: [{ title: 'hello en' }],
},
},
})
expect(result.deep.array[0].title).toBe('hello en')
await payload.update({
collection: groupSlug,
locale: spanishLocale,
id: result.id,
data: {
deep: {
blocks: [
{
blockType: 'first',
title: 'hello es',
id: result.deep.blocks[0].id,
},
],
array: [
{
id: result.deep.array[0].id,
title: 'hello es',
},
],
},
},
})
const docEn = await payload.findByID({
collection: groupSlug,
locale: englishLocale,
id: result.id,
})
const docEs = await payload.findByID({
collection: groupSlug,
locale: spanishLocale,
id: result.id,
})
expect(docEn.deep.array[0].title).toBe('hello en')
expect(docEn.deep.blocks[0].title).toBe('hello en')
expect(docEs.deep.array[0].title).toBe('hello es')
expect(docEs.deep.blocks[0].title).toBe('hello es')
})
it('should create/updated/read localized group with row field', async () => {
const doc = await payload.create({
collection: 'groups',
data: {
groupLocalizedRow: {
text: 'hello world',
},
},
locale: 'en',
})
expect(doc.groupLocalizedRow.text).toBe('hello world')
const docES = await payload.update({
collection: 'groups',
data: {
groupLocalizedRow: {
text: 'hola world or something',
},
},
locale: 'es',
id: doc.id,
})
expect(docES.groupLocalizedRow.text).toBe('hola world or something')
// check if docES didnt break EN
const docEN = await payload.findByID({ collection: 'groups', id: doc.id, locale: 'en' })
expect(docEN.groupLocalizedRow.text).toBe('hello world')
const all = await payload.findByID({ collection: 'groups', id: doc.id, locale: 'all' })
expect(all.groupLocalizedRow.en.text).toBe('hello world')
expect(all.groupLocalizedRow.es.text).toBe('hola world or something')
})
it('should not crash on empty localized tab', async () => {
const result = await payload.create({
collection: tabSlug,
locale: englishLocale,
data: {
tabLocalized: {},
},
})
expect(result).toBeTruthy()
})
it('should properly create/update/read array field inside localized tab field', async () => {
const result = await payload.create({
collection: tabSlug,
locale: englishLocale,
data: {
tabLocalized: {
title: 'hello en',
},
},
})
expect(result.tabLocalized?.title).toBe('hello en')
await payload.update({
collection: tabSlug,
locale: spanishLocale,
id: result.id,
data: {
tabLocalized: {
title: 'hello es',
},
},
})
const docEn = await payload.findByID({
collection: tabSlug,
locale: englishLocale,
id: result.id,
})
const docEs = await payload.findByID({
collection: tabSlug,
locale: spanishLocale,
id: result.id,
})
expect(docEn.tabLocalized.title).toBe('hello en')
expect(docEs.tabLocalized.title).toBe('hello es')
})
it('should properly create/update/read localized tab field', async () => {
const result = await payload.create({
collection: tabSlug,
locale: englishLocale,
data: {
tabLocalized: {
array: [
{
title: 'hello en',
},
],
},
},
})
expect(result.tabLocalized.array[0].title).toBe('hello en')
await payload.update({
collection: tabSlug,
locale: spanishLocale,
id: result.id,
data: {
tabLocalized: {
array: [{ title: 'hello es' }],
},
},
})
const docEn = await payload.findByID({
collection: tabSlug,
locale: englishLocale,
id: result.id,
})
const docEs = await payload.findByID({
collection: tabSlug,
locale: spanishLocale,
id: result.id,
})
expect(docEn.tabLocalized.array[0].title).toBe('hello en')
expect(docEs.tabLocalized.array[0].title).toBe('hello es')
})
it('should properly create/update/read localized field inside of tab', async () => {
const result = await payload.create({
collection: tabSlug,
locale: englishLocale,
data: {
tab: {
title: 'hello en',
},
},
})
expect(result.tab.title).toBe('hello en')
await payload.update({
collection: tabSlug,
locale: spanishLocale,
id: result.id,
data: {
tab: {
title: 'hello es',
},
},
})
const docEn = await payload.findByID({
collection: tabSlug,
locale: englishLocale,
id: result.id,
})
const docEs = await payload.findByID({
collection: tabSlug,
locale: spanishLocale,
id: result.id,
})
expect(docEn.tab.title).toBe('hello en')
expect(docEs.tab.title).toBe('hello es')
})
it('should properly create/update/read deep localized field inside of tab', async () => {
const result = await payload.create({
collection: tabSlug,
locale: englishLocale,
data: {
deep: {
blocks: [
{
blockType: 'first',
title: 'hello en',
},
],
array: [{ title: 'hello en' }],
},
},
})
expect(result.deep.array[0].title).toBe('hello en')
await payload.update({
collection: tabSlug,
locale: spanishLocale,
id: result.id,
data: {
deep: {
blocks: [
{
blockType: 'first',
title: 'hello es',
id: result.deep.blocks[0].id,
},
],
array: [
{
id: result.deep.array[0].id,
title: 'hello es',
},
],
},
},
})
const docEn = await payload.findByID({
collection: tabSlug,
locale: englishLocale,
id: result.id,
})
const docEs = await payload.findByID({
collection: tabSlug,
locale: spanishLocale,
id: result.id,
})
expect(docEn.deep.array[0].title).toBe('hello en')
expect(docEn.deep.blocks[0].title).toBe('hello en')
expect(docEs.deep.array[0].title).toBe('hello es')
expect(docEs.deep.blocks[0].title).toBe('hello es')
})
})
// Nested localized fields do no longer have their localized property stripped in
// this monorepo, as this is handled at runtime.
describe('nested localized field sanitization', () => {
it('ensure nested localized fields keep localized property in monorepo', () => {
const collection = payload.collections['localized-within-localized'].config
expect(collection.fields[0].tabs[0].fields[0].localized).toBeDefined()
expect(collection.fields[1].fields[0].localized).toBeDefined()
expect(collection.fields[2].blocks[0].fields[0].localized).toBeDefined()
expect(collection.fields[3].fields[0].localized).toBeDefined()
})
})
describe('nested blocks', () => {
let id
it('should allow creating nested blocks per locale', async () => {
const doc = await payload.create({
collection: 'blocks-fields',
data: {
content: [
{
blockType: 'blockInsideBlock',
array: [
{
link: {
label: 'English 1',
},
},
{
link: {
label: 'English 2',
},
},
],
content: [
{
blockType: 'textBlock',
text: 'hello',
},
],
},
],
},
})
id = doc.id
const retrievedInEN = await payload.findByID({
collection: 'blocks-fields',
id,
})
await payload.update({
collection: 'blocks-fields',
id,
locale: 'es',
data: {
content: [
{
blockType: 'blockInsideBlock',
array: [
{
link: {
label: 'Spanish 1',
},
},
{
link: {
label: 'Spanish 2',
},
},
],
content: [
{
blockType: 'textBlock',
text: 'hola',
},
],
},
],
},
})
const retrieved = await payload.findByID({
collection: 'blocks-fields',
id,
locale: 'all',
})
expect(retrieved.content.en[0].content).toHaveLength(1)
expect(retrieved.content.es[0].content).toHaveLength(1)
expect(retrieved.content.en[0].array[0].link.label).toStrictEqual('English 1')
expect(retrieved.content.en[0].array[1].link.label).toStrictEqual('English 2')
expect(retrieved.content.es[0].array[0].link.label).toStrictEqual('Spanish 1')
expect(retrieved.content.es[0].array[1].link.label).toStrictEqual('Spanish 2')
})
})
describe('nested arrays', () => {
it('should not duplicate block rows for blocks within localized array fields', async () => {
const randomDoc = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[0]
const randomDoc2 = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[1]
const blocksWithinArrayEN = [
{
blockName: '1',
blockType: 'someBlock',
relationWithinBlock: randomDoc.id,
myGroup: {
text: 'hello in english 1',
},
},
{
blockName: '2',
blockType: 'someBlock',
relationWithinBlock: randomDoc.id,
myGroup: {
text: 'hello in english 2',
},
},
{
blockName: '3',
blockType: 'someBlock',
relationWithinBlock: randomDoc.id,
myGroup: {
text: 'hello in english 3',
},
},
]
const blocksWithinArrayES = [
{
blockName: '1',
blockType: 'someBlock',
relationWithinBlock: randomDoc2.id,
myGroup: {
text: 'hello in spanish 1',
},
},
{
blockName: '2',
blockType: 'someBlock',
relationWithinBlock: randomDoc2.id,
myGroup: {
text: 'hello in spanish 2',
},
},
{
blockName: '3',
blockType: 'someBlock',
relationWithinBlock: randomDoc2.id,
myGroup: {
text: 'hello in spanish 3',
},
},
]
const createdEnDoc = await payload.create({
collection: 'nested-arrays',
locale: 'en',
depth: 0,
data: {
arrayWithBlocks: [
{
blocksWithinArray: blocksWithinArrayEN as any,
},
],
},
})
const updatedEsDoc = await payload.update({
collection: 'nested-arrays',
id: createdEnDoc.id,
depth: 0,
locale: 'es',
data: {
arrayWithBlocks: [
{
blocksWithinArray: blocksWithinArrayES as any,
},
],
},
})
const esArrayBlocks = updatedEsDoc.arrayWithBlocks[0].blocksWithinArray
// recursively remove any id field within esArrayRow
const removeId = (obj) => {
if (obj instanceof Object) {
delete obj.id
Object.values(obj).forEach(removeId)
}
}
removeId(esArrayBlocks)
removeId(createdEnDoc.arrayWithBlocks[0].blocksWithinArray)
expect(esArrayBlocks).toEqual(blocksWithinArrayES)
expect(createdEnDoc.arrayWithBlocks[0].blocksWithinArray).toEqual(blocksWithinArrayEN)
// pull enDoc again and make sure the update of esDoc did not mess with the data of enDoc
const enDoc2 = await payload.findByID({
id: createdEnDoc.id,
collection: 'nested-arrays',
locale: 'en',
depth: 0,
})
removeId(enDoc2.arrayWithBlocks[0].blocksWithinArray)
expect(enDoc2.arrayWithBlocks[0].blocksWithinArray).toEqual(blocksWithinArrayEN)
})
it('should update localized relation within unLocalized array', async () => {
const randomTextDoc = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[0]
const randomTextDoc2 = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[1]
const createdEnDoc = await payload.create({
collection: 'nested-arrays',
locale: 'en',
depth: 0,
data: {
arrayWithLocalizedRelation: [
{
localizedRelation: randomTextDoc.id,
},
],
},
})
const updatedEsDoc = await payload.update({
collection: 'nested-arrays',
id: createdEnDoc.id,
depth: 0,
locale: 'es',
data: {
arrayWithLocalizedRelation: [
{
id: createdEnDoc.arrayWithLocalizedRelation[0].id,
localizedRelation: randomTextDoc2.id,
},
],
},
})
expect(updatedEsDoc.arrayWithLocalizedRelation).toHaveLength(1)
expect(updatedEsDoc.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc2.id)
expect(createdEnDoc.arrayWithLocalizedRelation).toHaveLength(1)
expect(createdEnDoc.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc.id)
// pull enDoc again and make sure the update of esDoc did not mess with the data of enDoc
const enDoc2 = await payload.findByID({
id: createdEnDoc.id,
collection: 'nested-arrays',
locale: 'en',
depth: 0,
})
expect(enDoc2.arrayWithLocalizedRelation).toHaveLength(1)
expect(enDoc2.arrayWithLocalizedRelation[0].localizedRelation).toBe(randomTextDoc.id)
})
})
describe('nested fields', () => {
it('should update localized block', async () => {
const doc = await payload.create({
collection: 'blocks-fields',
locale: 'en',
data: {
content: [
{
blockType: 'blockInsideBlock',
content: [
{
blockType: 'textBlock',
text: 'some-text',
},
],
},
],
},
})
const updated = await payload.update({
id: doc.id,
collection: 'blocks-fields',
data: {
id: doc.id,
content: [
{
// This can't be added in Postgres because you'd get a duplicate ID error
// since the parent is localized, and the primary key in the block table
// consists only of the ID. That's why it's removed in `copyToLocale`.
// id: doc.content?.[0]?.id,
blockName: null,
array: [],
blockType: 'blockInsideBlock',
content: [
{
// Same as above.
// id: doc.content?.[0]?.content?.[0]?.id,
text: 'some-text',
blockName: null,
blockType: 'textBlock',
},
],
},
],
},
locale: 'es',
})
console.dir(updated, { depth: null })
expect(updated.content?.[0]?.content?.[0]?.text).toBe('some-text')
})
it('update specific locale should not erease the others in blocks and arrays', async () => {
const doc = await payload.create({
collection: 'nested',
locale: 'en',
data: {
blocks: [
{
blockType: 'block',
someText: 'some-block-text-en',
},
],
topLevelArray: [
{
localizedText: 'some-localized-text',
notLocalizedText: 'some-not-localized-text',
},
],
},
})
expect(doc.blocks?.[0]?.someText).toBe('some-block-text-en')
expect(doc.topLevelArray?.[0]?.localizedText).toBe('some-localized-text')
expect(doc.topLevelArray?.[0]?.notLocalizedText).toBe('some-not-localized-text')
expect(doc.topLevelArray).toHaveLength(1)
const findAllLocales = await payload.findByID({
id: doc.id,
collection: 'nested',
locale: 'all',
})
expect(findAllLocales.blocks?.[0]?.someText).toStrictEqual({
en: 'some-block-text-en',
})
expect(findAllLocales.topLevelArray?.[0]?.localizedText).toStrictEqual({
en: 'some-localized-text',
})
const updatedDoc = await payload.update({
id: doc.id,
collection: 'nested',
locale: 'es',
data: {
blocks: [
{
id: doc.blocks?.[0]?.id,
blockType: 'block',
someText: 'some-block-text-es',
},
],
topLevelArray: [
{
id: doc.topLevelArray?.[0]?.id,
localizedText: 'some-localized-text-es',
notLocalizedText: 'some-not-localized-text-es',
},
],
},
})
expect(updatedDoc.blocks?.[0]?.someText).toBe('some-block-text-es')
expect(updatedDoc.topLevelArray?.[0]?.localizedText).toBe('some-localized-text-es')
expect(updatedDoc.topLevelArray?.[0]?.notLocalizedText).toBe('some-not-localized-text-es')
const refreshedDoc = await payload.findByID({
id: doc.id,
collection: 'nested',
locale: 'all',
})
expect(refreshedDoc.blocks?.[0]?.someText).toStrictEqual({
en: 'some-block-text-en',
es: 'some-block-text-es',
})
expect(refreshedDoc.topLevelArray?.[0]?.localizedText).toStrictEqual({
en: 'some-localized-text',
es: 'some-localized-text-es',
})
})
it('update specific locale should not erease the others in simple fields', async () => {
const doc = await payload.create({
collection: 'localized-posts',
locale: 'en',
data: {
title: 'some-localized-title',
description: 'some-not-localized-description',
localizedDescription: 'some-localized-description',
},
})
expect(doc.title).toBe('some-localized-title')
expect(doc.localizedDescription).toBe('some-localized-description')
const findAllLocales = await payload.findByID({
id: doc.id,
collection: 'localized-posts',
locale: 'all',
})
expect(findAllLocales.title).toStrictEqual({
en: 'some-localized-title',
})
expect(findAllLocales.localizedDescription).toStrictEqual({
en: 'some-localized-description',
})
const updatedDoc = await payload.update({
id: doc.id,
collection: 'localized-posts',
locale: 'es',
data: {
title: 'some-localized-title-es',
description: 'some-not-localized-description-es',
localizedDescription: 'some-localized-description-es',
},
})
expect(updatedDoc.title).toBe('some-localized-title-es')
expect(updatedDoc.localizedDescription).toBe('some-localized-description-es')
const refreshedDoc = await payload.findByID({
id: doc.id,
collection: 'localized-posts',
locale: 'all',
})
expect(refreshedDoc.title).toStrictEqual({
en: 'some-localized-title',
es: 'some-localized-title-es',
})
expect(refreshedDoc.localizedDescription).toStrictEqual({
en: 'some-localized-description',
es: 'some-localized-description-es',
})
})
it('should allow for fields which could contain new tables within localized arrays to be stored', async () => {
const randomDoc = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[0]
const randomDoc2 = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[1]
const newDoc = await payload.create({
collection: 'nested-field-tables',
data: {
array: [
{
relation: {
value: randomDoc.id,
relationTo: 'localized-posts',
},
hasManyRelation: [randomDoc.id, randomDoc2.id],
hasManyPolyRelation: [
{
relationTo: 'localized-posts',
value: randomDoc.id,
},
{
relationTo: 'localized-posts',
value: randomDoc2.id,
},
],
number: [1, 2],
text: ['hello', 'goodbye'],
select: ['one'],
},
],
},
})
await payload.update({
collection: 'nested-field-tables',
id: newDoc.id,
locale: 'es',
data: {
array: [
{
relation: {
value: randomDoc2.id,
relationTo: 'localized-posts',
},
hasManyRelation: [randomDoc2.id, randomDoc.id],
hasManyPolyRelation: [
{
relationTo: 'localized-posts',
value: randomDoc2.id,
},
{
relationTo: 'localized-posts',
value: randomDoc.id,
},
],
select: ['two', 'three'],
text: ['hola', 'adios'],
number: [3, 4],
},
],
},
})
const retrieved = await payload.findByID({
collection: 'nested-field-tables',
id: newDoc.id,
depth: 0,
locale: 'all',
})
expect(retrieved.array.en[0].relation.value).toStrictEqual(randomDoc.id)
expect(retrieved.array.es[0].relation.value).toStrictEqual(randomDoc2.id)
expect(retrieved.array.en[0].hasManyRelation).toEqual([randomDoc.id, randomDoc2.id])
expect(retrieved.array.es[0].hasManyRelation).toEqual([randomDoc2.id, randomDoc.id])
expect(retrieved.array.en[0].hasManyPolyRelation).toEqual([
{ value: randomDoc.id, relationTo: 'localized-posts' },
{ value: randomDoc2.id, relationTo: 'localized-posts' },
])
expect(retrieved.array.es[0].hasManyPolyRelation).toEqual([
{ value: randomDoc2.id, relationTo: 'localized-posts' },
{ value: randomDoc.id, relationTo: 'localized-posts' },
])
expect(retrieved.array.en[0].number).toEqual([1, 2])
expect(retrieved.array.es[0].number).toEqual([3, 4])
expect(retrieved.array.en[0].select).toEqual(['one'])
expect(retrieved.array.es[0].select).toEqual(['two', 'three'])
expect(retrieved.array.en[0].text).toEqual(['hello', 'goodbye'])
expect(retrieved.array.es[0].text).toEqual(['hola', 'adios'])
})
it('should allow for relationship in new tables within blocks inside of localized blocks to be stored', async () => {
const randomDoc = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[0]
const randomDoc2 = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[1]
const docEn = await payload.create({
collection: 'nested-field-tables',
depth: 0,
data: {
blocks: [
{
blockType: 'block',
nestedBlocks: [
{
blockType: 'content',
relation: {
relationTo: 'localized-posts',
value: randomDoc.id,
},
},
],
},
{
blockType: 'block',
nestedBlocks: [
{
blockType: 'content',
relation: {
relationTo: 'localized-posts',
value: randomDoc.id,
},
},
],
},
{
blockType: 'block',
nestedBlocks: [
{
blockType: 'content',
relation: {
relationTo: 'localized-posts',
value: randomDoc.id,
},
},
],
},
],
},
})
expect(docEn.blocks[0].nestedBlocks[0].relation.value).toBe(randomDoc.id)
expect(docEn.blocks[1].nestedBlocks[0].relation.value).toBe(randomDoc.id)
expect(docEn.blocks[2].nestedBlocks[0].relation.value).toBe(randomDoc.id)
const docEs = await payload.update({
id: docEn.id,
depth: 0,
locale: 'es',
collection: 'nested-field-tables',
data: {
blocks: [
{
blockType: 'block',
nestedBlocks: [
{
blockType: 'content',
relation: {
relationTo: 'localized-posts',
value: randomDoc2.id,
},
},
],
},
{
blockType: 'block',
nestedBlocks: [
{
blockType: 'content',
relation: {
relationTo: 'localized-posts',
value: randomDoc2.id,
},
},
],
},
{
blockType: 'block',
nestedBlocks: [
{
blockType: 'content',
relation: {
relationTo: 'localized-posts',
value: randomDoc2.id,
},
},
],
},
],
},
})
expect(docEs.blocks[0].nestedBlocks[0].relation.value).toBe(randomDoc2.id)
expect(docEs.blocks[1].nestedBlocks[0].relation.value).toBe(randomDoc2.id)
expect(docEs.blocks[2].nestedBlocks[0].relation.value).toBe(randomDoc2.id)
const docAll = await payload.findByID({
collection: 'nested-field-tables',
id: docEn.id,
locale: 'all',
depth: 0,
})
expect(docAll.blocks.en[0].nestedBlocks[0].relation.value).toBe(randomDoc.id)
expect(docAll.blocks.en[1].nestedBlocks[0].relation.value).toBe(randomDoc.id)
expect(docAll.blocks.en[2].nestedBlocks[0].relation.value).toBe(randomDoc.id)
expect(docAll.blocks.es[0].nestedBlocks[0].relation.value).toBe(randomDoc2.id)
expect(docAll.blocks.es[1].nestedBlocks[0].relation.value).toBe(randomDoc2.id)
expect(docAll.blocks.es[2].nestedBlocks[0].relation.value).toBe(randomDoc2.id)
})
it('should allow for relationship in new tables within arrays inside of localized blocks to be stored', async () => {
const randomDoc = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[0]
const randomDoc2 = (
await payload.find({
collection: 'localized-posts',
depth: 0,
})
).docs[1]
const docEn = await payload.create({
collection: 'nested-field-tables',
depth: 0,
data: {
blocks: [
{
blockType: 'block',
array: [
{
relation: {
relationTo: 'localized-posts',
value: randomDoc.id,
},
},
],
},
{
blockType: 'block',
array: [
{
relation: {
relationTo: 'localized-posts',
value: randomDoc.id,
},
},
],
},
{
blockType: 'block',
array: [
{
relation: {
relationTo: 'localized-posts',
value: randomDoc.id,
},
},
],
},
],
},
})
expect(docEn.blocks[0].array[0].relation.value).toBe(randomDoc.id)
expect(docEn.blocks[1].array[0].relation.value).toBe(randomDoc.id)
expect(docEn.blocks[2].array[0].relation.value).toBe(randomDoc.id)
const docEs = await payload.update({
id: docEn.id,
depth: 0,
locale: 'es',
collection: 'nested-field-tables',
data: {
blocks: [
{
blockType: 'block',
array: [
{
relation: {
relationTo: 'localized-posts',
value: randomDoc2.id,
},
},
],
},
{
blockType: 'block',
array: [
{
relation: {
relationTo: 'localized-posts',
value: randomDoc2.id,
},
},
],
},
{
blockType: 'block',
array: [
{
relation: {
relationTo: 'localized-posts',
value: randomDoc2.id,
},
},
],
},
],
},
})
expect(docEs.blocks[0].array[0].relation.value).toBe(randomDoc2.id)
expect(docEs.blocks[1].array[0].relation.value).toBe(randomDoc2.id)
expect(docEs.blocks[2].array[0].relation.value).toBe(randomDoc2.id)
const docAll = await payload.findByID({
collection: 'nested-field-tables',
id: docEn.id,
locale: 'all',
depth: 0,
})
expect(docAll.blocks.en[0].array[0].relation.value).toBe(randomDoc.id)
expect(docAll.blocks.en[1].array[0].relation.value).toBe(randomDoc.id)
expect(docAll.blocks.en[2].array[0].relation.value).toBe(randomDoc.id)
expect(docAll.blocks.es[0].array[0].relation.value).toBe(randomDoc2.id)
expect(docAll.blocks.es[1].array[0].relation.value).toBe(randomDoc2.id)
expect(docAll.blocks.es[2].array[0].relation.value).toBe(randomDoc2.id)
})
})
describe('localized with unique', () => {
it('localized with unique should work for each locale', async () => {
await payload.create({
collection: 'localized-posts',
locale: 'ar',
data: {
unique: 'text',
},
})
await payload.create({
collection: 'localized-posts',
locale: 'en',
data: {
unique: 'text',
},
})
await payload.create({
collection: 'localized-posts',
locale: 'es',
data: {
unique: 'text',
},
})
await expect(
payload.create({
collection: 'localized-posts',
locale: 'en',
data: {
unique: 'text',
},
}),
).rejects.toBeTruthy()
})
})
describe('Copying To Locale', () => {
let user: User
beforeAll(async () => {
user = (
await payload.find({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
).docs[0] as unknown as User
user['collection'] = 'users'
})
it('should copy to locale', async () => {
const doc = await payload.create({
collection: 'localized-posts',
data: {
title: 'Hello',
group: {
children: 'Children',
},
unique: 'unique-field',
localizedCheckbox: true,
},
})
const req = await createLocalReq({ user }, payload)
const res = (await copyDataFromLocaleHandler({
fromLocale: 'en',
req,
toLocale: 'es',
docID: doc.id,
collectionSlug: 'localized-posts',
})) as LocalizedPost
expect(res.title).toBe('Hello')
expect(res.group.children).toBe('Children')
expect(res.unique).toBe('unique-field')
expect(res.localizedCheckbox).toBe(true)
})
it('should copy block to locale', async () => {
// This was previously an e2e test but it was migrated to int
// because at the moment only int tests run in Postgres in CI,
// and that's where the bug occurs.
const doc = await payload.create({
collection: 'blocks-fields',
locale: 'en',
data: {
content: [
{
blockType: 'blockInsideBlock',
content: [
{
blockType: 'textBlock',
text: 'some-text',
},
],
},
],
},
})
const req = await createLocalReq({ user }, payload)
const res = (await copyDataFromLocaleHandler({
fromLocale: 'en',
req,
toLocale: 'es',
docID: doc.id,
collectionSlug: 'blocks-fields',
})) as BlocksField
expect(res.content?.[0]?.content?.[0]?.text).toBe('some-text')
})
it('should copy localized nested to arrays', async () => {
const doc = await payload.create({
collection: 'nested',
locale: 'en',
data: {
topLevelArray: [
{
localizedText: 'some-localized-text',
notLocalizedText: 'some-not-localized-text',
},
],
},
})
const req = await createLocalReq({ user }, payload)
const res = (await copyDataFromLocaleHandler({
fromLocale: 'en',
req,
toLocale: 'es',
docID: doc.id,
collectionSlug: 'nested',
})) as Nested
expect(res.topLevelArray?.[0]?.localizedText).toBe('some-localized-text')
expect(res.topLevelArray?.[0]?.notLocalizedText).toBe('some-not-localized-text')
const refreshedDoc = await payload.findByID({
id: doc.id,
collection: 'nested',
})
// The source data should remain unchanged
expect(refreshedDoc.topLevelArray?.[0]?.localizedText).toBe('some-localized-text')
expect(refreshedDoc.topLevelArray?.[0]?.notLocalizedText).toBe('some-not-localized-text')
expect(refreshedDoc.topLevelArray).toHaveLength(1)
})
it('should copy localized arrays', async () => {
const doc = await payload.create({
collection: 'nested',
locale: 'en',
data: {
topLevelArrayLocalized: [
{
text: 'some-text',
},
],
},
})
const req = await createLocalReq({ user }, payload)
const res = (await copyDataFromLocaleHandler({
fromLocale: 'en',
req,
toLocale: 'es',
docID: doc.id,
collectionSlug: 'nested',
})) as Nested
expect(res.topLevelArrayLocalized?.[0]?.text).toBe('some-text')
const refreshedDoc = await payload.findByID({
id: doc.id,
collection: 'nested',
})
// The source data should remain unchanged
expect(refreshedDoc.topLevelArrayLocalized?.[0]?.text).toBe('some-text')
})
})
})
describe('Localization with fallback false', () => {
let post1: LocalizedPost
let postWithLocalizedData: LocalizedPost
beforeAll(async () => {
if (payload.config.localization) {
payload.config.localization.fallback = false
}
post1 = await payload.create({
collection,
data: {
title: englishTitle,
},
})
postWithLocalizedData = await payload.create({
collection,
data: {
title: englishTitle,
},
})
await payload.update({
id: postWithLocalizedData.id,
collection,
data: {
title: spanishTitle,
},
locale: spanishLocale,
})
})
describe('fallback locale', () => {
it('create english', async () => {
const allDocs = await payload.find({
collection,
where: {
title: { equals: post1.title },
},
})
expect(allDocs.docs).toContainEqual(expect.objectContaining(post1))
})
it('add spanish translation', async () => {
const updated = await payload.update({
id: post1.id,
collection,
data: {
title: spanishTitle,
},
locale: spanishLocale,
})
expect(updated.title).toEqual(spanishTitle)
const localized: any = await payload.findByID({
id: post1.id,
collection,
locale: 'all',
})
expect(localized.title.en).toEqual(englishTitle)
expect(localized.title.es).toEqual(spanishTitle)
})
it('should not fallback to english', async () => {
const retrievedDoc = await payload.findByID({
id: post1.id,
collection,
locale: portugueseLocale,
})
expect(retrievedDoc.title).not.toBeDefined()
})
it('should fallback to english with explicit fallbackLocale', async () => {
const fallbackDoc = await payload.findByID({
id: post1.id,
collection,
locale: portugueseLocale,
fallbackLocale: englishLocale,
})
expect(fallbackDoc.title).toBe(englishTitle)
})
it('should not fallback to spanish translation and no explicit fallback is provided', async () => {
const localizedFallback: any = await payload.findByID({
id: postWithLocalizedData.id,
collection,
locale: portugueseLocale,
})
expect(localizedFallback.title).not.toBeDefined()
})
it('should respect fallback none', async () => {
const localizedFallback: any = await payload.findByID({
id: postWithLocalizedData.id,
collection,
locale: portugueseLocale,
fallbackLocale: false,
})
expect(localizedFallback.title).not.toBeDefined()
})
})
})
})
async function createLocalizedPost(data: {
title: {
[defaultLocale]: string
[spanishLocale]: string
}
}): Promise<LocalizedPost> {
const localizedRelation: any = await payload.create({
collection,
data: {
title: data.title.en,
},
})
await payload.update({
id: localizedRelation.id,
collection,
data: {
title: data.title.es,
},
locale: spanishLocale,
})
return localizedRelation
}