feat(richtext-lexical)!: sub-field hooks and localization support (#6591)

## BREAKING
- Our internal field hook methods now have new required `schemaPath` and
path `props`. This affects the following functions, if you are using
those: `afterChangeTraverseFields`, `afterReadTraverseFields`,
`beforeChangeTraverseFields`, `beforeValidateTraverseFields`,
`afterReadPromise`
- The afterChange field hook's `value` is now the value AFTER the
previous hooks were run. Previously, this was the original value, which
I believe is a bug
- Only relevant if you have built your own richText adapter: the
richText adapter `populationPromises` property has been renamed to
`graphQLPopulationPromises` and is now only run for graphQL. Previously,
it was run for graphQL AND the rest API. To migrate, use
`hooks.afterRead` to run population for the rest API
- Only relevant if you have built your own lexical features: The
`populationPromises` server feature property has been renamed to
`graphQLPopulationPromises` and is now only run for graphQL. Previously,
it was run for graphQL AND the rest API. To migrate, use
`hooks.afterRead` to run population for the rest API
- Serialized lexical link and upload nodes now have a new `id` property.
While not breaking, localization / hooks will not work for their fields
until you have migrated to that. Re-saving the old document on the new
version will automatically add the `id` property for you. You will also
get a bunch of console logs for every lexical node which is not migrated
This commit is contained in:
Alessio Gravili
2024-06-12 13:33:08 -04:00
committed by GitHub
parent 27510bb963
commit 4e127054ca
62 changed files with 1959 additions and 514 deletions

View File

@@ -920,10 +920,9 @@ describe('lexicalBlocks', () => {
await wait(300)
await page.click('#action-save', { delay: 100 })
await wait(300)
await expect(page.locator('.payload-toast-container')).toContainText(
'The following field is invalid',
'The following fields are invalid',
)
await wait(300)

View File

@@ -28,6 +28,7 @@ export function generateLexicalRichText() {
format: '',
type: 'upload',
version: 2,
id: '665d105a91e1c337ba8308dd',
fields: {
caption: {
root: {

View File

@@ -0,0 +1,44 @@
export function generateLexicalLocalizedRichText(text1: string, text2: string, blockID?: string) {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: text1,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: blockID ?? '66685716795f191f08367b1a',
blockName: '',
textLocalized: text2,
counter: 1,
blockType: 'block',
},
},
],
direction: 'ltr',
},
}
}

View File

@@ -21,11 +21,50 @@ export const LexicalLocalizedFields: CollectionConfig = {
localized: true,
},
{
name: 'lexicalSimple',
name: 'lexicalBlocksSubLocalized',
type: 'richText',
localized: true,
admin: {
description: 'Non-localized field with localized block subfields',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
{
slug: 'block',
fields: [
{
name: 'textLocalized',
type: 'text',
localized: true,
},
{
name: 'counter',
type: 'number',
hooks: {
beforeChange: [
({ value }) => {
return value ? value + 1 : 1
},
],
afterRead: [
({ value }) => {
return value ? value * 10 : 10
},
],
},
},
{
name: 'rel',
type: 'relationship',
relationTo: lexicalLocalizedFieldsSlug,
},
],
},
],
}),
],
}),
},
{
@@ -60,36 +99,5 @@ export const LexicalLocalizedFields: CollectionConfig = {
],
}),
},
{
name: 'lexicalBlocksSubLocalized',
type: 'richText',
admin: {
description: 'Non-localized field with localized block subfields',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
{
slug: 'block',
fields: [
{
name: 'textLocalized',
type: 'text',
localized: true,
},
{
name: 'rel',
type: 'relationship',
relationTo: lexicalLocalizedFieldsSlug,
},
],
},
],
}),
],
}),
},
],
}

View File

@@ -54,6 +54,7 @@ export function generateLexicalRichText() {
direction: 'ltr',
format: '',
indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link',
version: 2,
fields: {
@@ -86,6 +87,7 @@ export function generateLexicalRichText() {
direction: 'ltr',
format: '',
indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link',
version: 2,
fields: {
@@ -230,6 +232,7 @@ export function generateLexicalRichText() {
format: '',
type: 'upload',
version: 2,
id: '665d10938106ab380c7f372f',
relationTo: 'uploads',
value: '{{UPLOAD_DOC_ID}}',
fields: {

View File

@@ -14,6 +14,8 @@ import { devUser } from '../credentials.js'
import { NextRESTClient } from '../helpers/NextRESTClient.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { lexicalDocData } from './collections/Lexical/data.js'
import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { richTextDocData } from './collections/RichText/data.js'
import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText.js'
@@ -569,4 +571,100 @@ describe('Lexical', () => {
expect(populatedDocEditorRelationshipNode.value.text).toStrictEqual(textDoc.text)
})
})
describe('Localization', () => {
it('ensure localized lexical field is different across locales', async () => {
const lexicalDocEN = await payload.find({
collection: 'lexical-localized-fields',
locale: 'en',
where: {
title: {
equals: 'Localized Lexical en',
},
},
})
expect(lexicalDocEN.docs[0].lexicalBlocksLocalized.root.children[0].children[0].text).toEqual(
'English text',
)
const lexicalDocES = await payload.findByID({
collection: 'lexical-localized-fields',
locale: 'es',
id: lexicalDocEN.docs[0].id,
})
expect(lexicalDocES.lexicalBlocksLocalized.root.children[0].children[0].text).toEqual(
'Spanish text',
)
})
it('ensure localized text field within blocks field within unlocalized lexical field is different across locales', async () => {
const lexicalDocEN = await payload.find({
collection: 'lexical-localized-fields',
locale: 'en',
where: {
title: {
equals: 'Localized Lexical en',
},
},
})
expect(
lexicalDocEN.docs[0].lexicalBlocksSubLocalized.root.children[0].children[0].text,
).toEqual('Shared text')
expect(
(lexicalDocEN.docs[0].lexicalBlocksSubLocalized.root.children[1].fields as any)
.textLocalized,
).toEqual('English text in block')
const lexicalDocES = await payload.findByID({
collection: 'lexical-localized-fields',
locale: 'es',
id: lexicalDocEN.docs[0].id,
})
expect(lexicalDocES.lexicalBlocksSubLocalized.root.children[0].children[0].text).toEqual(
'Shared text',
)
expect(
(lexicalDocES.lexicalBlocksSubLocalized.root.children[1].fields as any).textLocalized,
).toEqual('Spanish text in block')
})
})
describe('Hooks', () => {
it('ensure hook within number field within lexical block runs', async () => {
const lexicalDocEN = await payload.create({
collection: 'lexical-localized-fields',
locale: 'en',
data: {
title: 'Localized Lexical hooks',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }) as any,
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',
) as any,
},
})
expect(
(lexicalDocEN.lexicalBlocksSubLocalized.root.children[1].fields as any).counter,
).toEqual(20) // Initial: 1. BeforeChange: +1 (2). AfterRead: *10 (20)
// update document with same data
const lexicalDocENUpdated = await payload.update({
collection: 'lexical-localized-fields',
locale: 'en',
id: lexicalDocEN.id,
data: lexicalDocEN,
})
expect(
(lexicalDocENUpdated.lexicalBlocksSubLocalized.root.children[1].fields as any).counter,
).toEqual(210) // Initial: 20. BeforeChange: +1 (21). AfterRead: *10 (210)
})
})
})

View File

@@ -207,7 +207,7 @@ export interface LexicalMigrateField {
export interface LexicalLocalizedField {
id: string;
title: string;
lexicalSimple?: {
lexicalBlocksSubLocalized?: {
root: {
type: string;
children: {
@@ -237,21 +237,6 @@ export interface LexicalLocalizedField {
};
[k: string]: unknown;
} | null;
lexicalBlocksSubLocalized?: {
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;
}

View File

@@ -15,6 +15,7 @@ import { dateDoc } from './collections/Date/shared.js'
import { groupDoc } from './collections/Group/shared.js'
import { jsonDoc } from './collections/JSON/shared.js'
import { lexicalDocData } from './collections/Lexical/data.js'
import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { numberDoc } from './collections/Number/shared.js'
@@ -281,9 +282,11 @@ export const seed = async (_payload: Payload) => {
collection: lexicalLocalizedFieldsSlug,
data: {
title: 'Localized Lexical en',
lexicalSimple: textToLexicalJSON({ text: 'English text' }),
lexicalBlocksLocalized: textToLexicalJSON({ text: 'English text' }),
lexicalBlocksSubLocalized: textToLexicalJSON({ text: 'English text' }),
lexicalBlocksLocalized: textToLexicalJSON({ text: 'English text' }) as any,
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',
) as any,
},
locale: 'en',
depth: 0,
@@ -295,9 +298,12 @@ export const seed = async (_payload: Payload) => {
id: lexicalLocalizedDoc1.id,
data: {
title: 'Localized Lexical es',
lexicalSimple: textToLexicalJSON({ text: 'Spanish text' }),
lexicalBlocksLocalized: textToLexicalJSON({ text: 'Spanish text' }),
lexicalBlocksSubLocalized: textToLexicalJSON({ text: 'Spanish text' }),
lexicalBlocksLocalized: textToLexicalJSON({ text: 'Spanish text' }) as any,
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'Spanish text in block',
(lexicalLocalizedDoc1.lexicalBlocksSubLocalized.root.children[1].fields as any).id,
) as any,
},
locale: 'es',
depth: 0,