feat: adds ability to copy data from across locales (#8203)

### What?

Adds ability to copy data from one locale to another at a document
level.

### How?

For any localized collection, you will find a new option in the document
controls called `Copy to Locale`.

This option will open a drawer, from here you can select your origin and
destination locales.

If data already exists in the destination locale, you can choose to: 
1. Overwrite this data (this will copy any empty fields in your origin
locale)
2. Not overwrite existing data (this will only copy data into empty
fields in the destination locale)

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
This commit is contained in:
Jessica Chowdhury
2024-11-26 17:06:30 -05:00
committed by GitHub
parent 61d6614746
commit 601759d967
50 changed files with 1401 additions and 5 deletions

View File

@@ -31,5 +31,21 @@ export const NestedToArrayAndBlock: CollectionConfig = {
},
],
},
{
name: 'topLevelArray',
type: 'array',
localized: false,
fields: [
{
name: 'localizedText',
type: 'text',
localized: true,
},
{
name: 'notLocalizedText',
type: 'text',
},
],
},
],
}

View File

@@ -0,0 +1,14 @@
import type { CollectionConfig } from 'payload'
export const noLocalizedFieldsCollectionSlug = 'no-localized-fields'
export const NoLocalizedFieldsCollection: CollectionConfig = {
slug: noLocalizedFieldsCollectionSlug,
fields: [
{
name: 'text',
type: 'text',
localized: false,
},
],
}

View File

@@ -0,0 +1,31 @@
import type { CollectionConfig } from 'payload/types'
import { lexicalEditor, TreeViewFeature } from '@payloadcms/richtext-lexical'
import { slateEditor } from '@payloadcms/richtext-slate'
export const richTextSlug = 'richText'
export const RichTextCollection: CollectionConfig = {
slug: richTextSlug,
fields: [
{
type: 'richText',
name: 'richText',
label: 'Rich Text',
localized: true,
editor: slateEditor({
admin: {
elements: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
},
}),
},
{
name: 'lexical',
type: 'richText',
localized: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures, TreeViewFeature()],
}),
},
],
}

View File

@@ -13,6 +13,8 @@ import { LocalizedWithinLocalized } from './collections/LocalizedWithinLocalized
import { NestedArray } from './collections/NestedArray/index.js'
import { NestedFields } from './collections/NestedFields/index.js'
import { NestedToArrayAndBlock } from './collections/NestedToArrayAndBlock/index.js'
import { NoLocalizedFieldsCollection } from './collections/NoLocalizedFields/index.js'
import { RichTextCollection } from './collections/RichText/index.js'
import { Tab } from './collections/Tab/index.js'
import {
blocksWithLocalizedSameName,
@@ -54,6 +56,7 @@ export default buildConfigWithDefaults({
},
},
collections: [
RichTextCollection,
BlocksCollection,
NestedArray,
NestedFields,
@@ -119,6 +122,7 @@ export default buildConfigWithDefaults({
},
],
},
NoLocalizedFieldsCollection,
ArrayCollection,
{
fields: [
@@ -392,6 +396,16 @@ export default buildConfigWithDefaults({
],
slug: 'global-array',
},
{
fields: [
{
name: 'text',
localized: true,
type: 'text',
},
],
slug: 'global-text',
},
],
localization: {
defaultLocale,

View File

@@ -3,7 +3,6 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
@@ -18,7 +17,10 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { nestedToArrayAndBlockCollectionSlug } from './collections/NestedToArrayAndBlock/index.js'
import { richTextSlug } from './collections/RichText/index.js'
import {
defaultLocale,
englishTitle,
localizedPostsSlug,
spanishLocale,
@@ -40,7 +42,6 @@ const { beforeAll, describe } = test
let url: AdminUrlUtil
let urlWithRequiredLocalizedFields: AdminUrlUtil
const defaultLocale = 'en'
const title = 'english title'
const spanishTitle = 'spanish title'
const arabicTitle = 'arabic title'
@@ -49,13 +50,15 @@ const description = 'description'
let page: Page
let payload: PayloadTestSDK<Config>
let serverURL: string
let richTextURL: AdminUrlUtil
describe('Localization', () => {
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname }))
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
url = new AdminUrlUtil(serverURL, localizedPostsSlug)
richTextURL = new AdminUrlUtil(serverURL, richTextSlug)
urlWithRequiredLocalizedFields = new AdminUrlUtil(serverURL, withRequiredLocalizedFields)
const context = await browser.newContext()
@@ -251,6 +254,149 @@ describe('Localization', () => {
await expect(page.locator('#field-children .rs__menu')).toContainText('spanish-relation2')
})
})
describe('copy localized data', () => {
test('should show Copy To Locale button and drawer', async () => {
await changeLocale(page, defaultLocale)
await createAndSaveDoc(page, url, { description, title })
await openCopyToLocaleDrawer(page)
await expect(page.locator('.copy-locale-data__content')).toBeVisible()
})
test('should copy data to correct locale', async () => {
await createAndSaveDoc(page, url, { title })
await openCopyToLocaleDrawer(page)
await setToLocale(page, 'Spanish')
await runCopy(page)
await expect(page.locator('#field-title')).toHaveValue(title)
})
test('should copy rich text data to correct locale', async () => {
await changeLocale(page, defaultLocale)
await page.goto(richTextURL.create)
const richTextField = page.locator('#field-richText')
const richTextContent = '<p>This is rich text in English</p>'
await richTextField.fill(richTextContent)
await saveDocAndAssert(page)
await openCopyToLocaleDrawer(page)
await setToLocale(page, 'Spanish')
await runCopy(page)
await expect(richTextField).toContainText(richTextContent)
})
test('should copy nested array to locale', async () => {
const sampleText = 'Copy this text'
const nestedArrayURL = new AdminUrlUtil(serverURL, nestedToArrayAndBlockCollectionSlug)
await page.goto(nestedArrayURL.create)
await changeLocale(page, 'ar')
const addArrayRow = page.locator('.array-field__add-row')
await addArrayRow.click()
const arrayField = page.locator('#field-topLevelArray__0__localizedText')
await expect(arrayField).toBeVisible()
await arrayField.fill(sampleText)
await saveDocAndAssert(page)
await openCopyToLocaleDrawer(page)
await setToLocale(page, 'English')
await runCopy(page)
await expect(arrayField).toHaveValue(sampleText)
})
test('should default source locale to current locale', async () => {
await changeLocale(page, spanishLocale)
await createAndSaveDoc(page, url, { title })
await openCopyToLocaleDrawer(page)
const fromLocaleField = page.locator('#field-fromLocale')
await expect(fromLocaleField).toContainText('Spanish')
await page.locator('.drawer-close-button').click()
})
test('should not overwrite existing data when overwrite is unchecked', async () => {
await changeLocale(page, defaultLocale)
await createAndSaveDoc(page, url, { title: englishTitle, description })
await changeLocale(page, spanishLocale)
await fillValues({ title: spanishTitle, description: 'Spanish description' })
await saveDocAndAssert(page)
await changeLocale(page, defaultLocale)
await openCopyToLocaleDrawer(page)
await setToLocale(page, 'Spanish')
const overwriteCheckbox = page.locator('#field-overwriteExisting')
await expect(overwriteCheckbox).not.toBeChecked()
await runCopy(page)
await expect(page.locator('#field-title')).toHaveValue(spanishTitle)
await expect(page.locator('#field-description')).toHaveValue('Spanish description')
})
test('should overwrite existing data when overwrite is checked', async () => {
await changeLocale(page, defaultLocale)
await createAndSaveDoc(page, url, { title: englishTitle, description })
await changeLocale(page, spanishLocale)
await fillValues({ title: spanishTitle })
await saveDocAndAssert(page)
await changeLocale(page, defaultLocale)
await openCopyToLocaleDrawer(page)
await setToLocale(page, 'Spanish')
const overwriteCheckbox = page.locator('#field-overwriteExisting')
await overwriteCheckbox.click()
await runCopy(page)
await expect(page.locator('#field-title')).toHaveValue(englishTitle)
})
test('should not include current locale in toLocale options', async () => {
await changeLocale(page, defaultLocale)
await createAndSaveDoc(page, url, { title })
await openCopyToLocaleDrawer(page)
const toLocaleDropdown = page.locator('#field-toLocale')
await toLocaleDropdown.click()
const options = await page
.locator('.rs__option')
.evaluateAll((els) => els.map((el) => el.textContent))
await expect.poll(() => options).not.toContain('English')
await page.locator('.drawer-close-button').click()
})
test('should handle back to back copies', async () => {
await changeLocale(page, defaultLocale)
await createAndSaveDoc(page, url, { title })
await openCopyToLocaleDrawer(page)
await setToLocale(page, 'Spanish')
await runCopy(page)
await expect(page.locator('#field-title')).toHaveValue(title)
await openCopyToLocaleDrawer(page)
await setToLocale(page, 'Hungarian')
await runCopy(page)
await expect(page.locator('#field-title')).toHaveValue(title)
})
test('should throw error if unsaved data', async () => {
await createAndSaveDoc(page, url, { title })
await fillValues({ title: 'updated' })
const docControls = page.locator('.doc-controls__popup')
await docControls.click()
const copyButton = page.locator('#copy-locale-data__button')
await expect(copyButton).toBeVisible()
await copyButton.click()
await expect(page.locator('.payload-toast-container')).toContainText('unsaved')
})
})
})
async function fillValues(data: Partial<LocalizedPost>) {
@@ -263,3 +409,31 @@ async function fillValues(data: Partial<LocalizedPost>) {
await page.locator('#field-description').fill(descVal)
}
}
async function runCopy(page) {
const copyDrawerClose = page.locator('.copy-locale-data__sub-header button')
await expect(copyDrawerClose).toBeVisible()
await copyDrawerClose.click()
}
async function createAndSaveDoc(page, url, values) {
await page.goto(url.create)
await fillValues(values)
await saveDocAndAssert(page)
}
async function openCopyToLocaleDrawer(page) {
const docControls = page.locator('.doc-controls__popup')
await docControls.click()
const copyButton = page.locator('#copy-locale-data__button')
await expect(copyButton).toBeVisible()
await copyButton.click()
await expect(page.locator('.copy-locale-data__content')).toBeVisible()
}
async function setToLocale(page, locale) {
const toField = page.locator('#field-toLocale')
await toField.click({ delay: 100 })
const options = page.locator('.rs__option')
await options.locator(`text=${locale}`).click()
}

View File

@@ -11,11 +11,13 @@ export interface Config {
users: UserAuthOperations;
};
collections: {
richText: RichText;
'blocks-fields': BlocksField;
'nested-arrays': NestedArray;
'nested-field-tables': NestedFieldTable;
users: User;
'localized-posts': LocalizedPost;
'no-localized-fields': NoLocalizedField;
'array-fields': ArrayField;
'localized-required': LocalizedRequired;
'with-localized-relationship': WithLocalizedRelationship;
@@ -33,11 +35,13 @@ export interface Config {
};
collectionsJoins: {};
collectionsSelect: {
richText: RichTextSelect<false> | RichTextSelect<true>;
'blocks-fields': BlocksFieldsSelect<false> | BlocksFieldsSelect<true>;
'nested-arrays': NestedArraysSelect<false> | NestedArraysSelect<true>;
'nested-field-tables': NestedFieldTablesSelect<false> | NestedFieldTablesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'localized-posts': LocalizedPostsSelect<false> | LocalizedPostsSelect<true>;
'no-localized-fields': NoLocalizedFieldsSelect<false> | NoLocalizedFieldsSelect<true>;
'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>;
'localized-required': LocalizedRequiredSelect<false> | LocalizedRequiredSelect<true>;
'with-localized-relationship': WithLocalizedRelationshipSelect<false> | WithLocalizedRelationshipSelect<true>;
@@ -58,17 +62,19 @@ export interface Config {
};
globals: {
'global-array': GlobalArray;
'global-text': GlobalText;
};
globalsSelect: {
'global-array': GlobalArraySelect<false> | GlobalArraySelect<true>;
'global-text': GlobalTextSelect<false> | GlobalTextSelect<true>;
};
locale: 'en' | 'es' | 'pt' | 'ar' | 'hu';
user: User & {
collection: 'users';
};
jobs?: {
jobs: {
tasks: unknown;
workflows?: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
@@ -89,6 +95,35 @@ export interface UserAuthOperations {
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "richText".
*/
export interface RichText {
id: string;
richText?:
| {
[k: string]: unknown;
}[]
| null;
lexical?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "blocks-fields".
@@ -118,6 +153,21 @@ export interface BlocksField {
blockType: 'blockInsideBlock';
}[]
| null;
nonLocalizedBlocksField?:
| {
array?:
| {
link?: {
label?: string | null;
};
id?: string | null;
}[]
| null;
id?: string | null;
blockName?: string | null;
blockType: 'blockInsideBlock';
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -244,6 +294,16 @@ export interface User {
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "no-localized-fields".
*/
export interface NoLocalizedField {
id: string;
text?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-fields".
@@ -419,6 +479,13 @@ export interface Nested {
blockType: 'block';
}[]
| null;
topLevelArray?:
| {
localizedText?: string | null;
notLocalizedText?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -559,6 +626,10 @@ export interface LocalizedWithinLocalized {
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'richText';
value: string | RichText;
} | null)
| ({
relationTo: 'blocks-fields';
value: string | BlocksField;
@@ -579,6 +650,10 @@ export interface PayloadLockedDocument {
relationTo: 'localized-posts';
value: string | LocalizedPost;
} | null)
| ({
relationTo: 'no-localized-fields';
value: string | NoLocalizedField;
} | null)
| ({
relationTo: 'array-fields';
value: string | ArrayField;
@@ -665,6 +740,16 @@ export interface PayloadMigration {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "richText_select".
*/
export interface RichTextSelect<T extends boolean = true> {
richText?: T;
lexical?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "blocks-fields_select".
@@ -701,6 +786,26 @@ export interface BlocksFieldsSelect<T extends boolean = true> {
blockName?: T;
};
};
nonLocalizedBlocksField?:
| T
| {
blockInsideBlock?:
| T
| {
array?:
| T
| {
link?:
| T
| {
label?: T;
};
id?: T;
};
id?: T;
blockName?: T;
};
};
updatedAt?: T;
createdAt?: T;
}
@@ -821,6 +926,15 @@ export interface LocalizedPostsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "no-localized-fields_select".
*/
export interface NoLocalizedFieldsSelect<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-fields_select".
@@ -965,6 +1079,13 @@ export interface NestedSelect<T extends boolean = true> {
blockName?: T;
};
};
topLevelArray?:
| T
| {
localizedText?: T;
notLocalizedText?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
@@ -1169,6 +1290,16 @@ export interface GlobalArray {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global-text".
*/
export interface GlobalText {
id: string;
text?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global-array_select".
@@ -1184,6 +1315,16 @@ export interface GlobalArraySelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "global-text_select".
*/
export interface GlobalTextSelect<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".