feat(richtext-lexical): new slate => lexical migration function which migrates all your documents at once (#6947)

This commit is contained in:
Alessio Gravili
2024-06-26 15:40:14 -04:00
committed by GitHub
parent abf6e9aa6b
commit 51056769e5
9 changed files with 311 additions and 12 deletions

View File

@@ -10,6 +10,21 @@ keywords: lexical, rich text, editor, headless cms, migrate, migration
While both Slate and Lexical save the editor state in JSON, the structure of the JSON is different. While both Slate and Lexical save the editor state in JSON, the structure of the JSON is different.
### Migration via Migration Script (Recommended)
Just import the `migrateSlateToLexical` function we provide, pass it the `payload` object and run it. Depending on the amount of collections, this might take a while.
IMPORTANT: This will overwrite all slate data. We recommend doing the following first:
1. Take a backup of your entire database. If anything goes wrong and you do not have a backup, you are on your own and will not receive any support.
2. Make every richText field a lexical editor. This script will only convert lexical richText fields with old Slate data
3. Add the SlateToLexicalFeature (as seen below) first, and test it out by loading up the admin panel, to see if the migrator works as expected. You might have to build some custom converters for some fields first in order to convert custom Slate nodes. The SlateToLexicalFeature is where the converters are stored. Only fields with this feature added will be migrated.
```ts
import { migrateSlateToLexical } from '@payloadcms/richtext-lexical'
await migrateSlateToLexical({ payload })
```
### Migration via SlateToLexicalFeature ### Migration via SlateToLexicalFeature
One way to handle this is to just give your lexical editor the ability to read the slate JSON. One way to handle this is to just give your lexical editor the ability to read the slate JSON.
@@ -42,7 +57,7 @@ This is by far the easiest way to migrate from Slate to Lexical, although it doe
- There is a performance hit when initializing the lexical editor - There is a performance hit when initializing the lexical editor
- The editor will still output the Slate data in the output JSON, as the on-the-fly converter only runs for the admin panel - The editor will still output the Slate data in the output JSON, as the on-the-fly converter only runs for the admin panel
The easy way to solve this: Just save the document! This overrides the slate data with the lexical data, and the next time the document is loaded, the lexical data will be used. This solves both the performance and the output issue for that specific document. The easy way to solve this: Edit the richText field and save the document! This overrides the slate data with the lexical data, and the next time the document is loaded, the lexical data will be used. This solves both the performance and the output issue for that specific document. This, however, is a slow and gradual migration process, thus you will have to support both API formats. Especially for a large number of documents, we recommend running the migration script, as explained above.
### Migration via migration script ### Migration via migration script

View File

@@ -18,7 +18,12 @@ export type SlateToLexicalFeatureProps = {
| SlateNodeConverterProvider[] | SlateNodeConverterProvider[]
} }
export const SlateToLexicalFeature = createServerFeature<SlateToLexicalFeatureProps>({ export const SlateToLexicalFeature = createServerFeature<
SlateToLexicalFeatureProps,
{
converters?: SlateNodeConverterProvider[]
}
>({
feature: ({ props }) => { feature: ({ props }) => {
if (!props) { if (!props) {
props = {} props = {}
@@ -56,7 +61,9 @@ export const SlateToLexicalFeature = createServerFeature<SlateToLexicalFeaturePr
node: UnknownConvertedNode, node: UnknownConvertedNode,
}, },
], ],
sanitizedServerFeatureProps: props, sanitizedServerFeatureProps: {
converters,
},
} }
}, },
key: 'slateToLexical', key: 'slateToLexical',

View File

@@ -154,9 +154,9 @@ export type ResolvedClientFeature<ClientFeatureProps> = ClientFeature<ClientFeat
order: number order: number
} }
export type ResolvedClientFeatureMap = Map<string, ResolvedClientFeature<unknown>> export type ResolvedClientFeatureMap = Map<string, ResolvedClientFeature<any>>
export type ClientFeatureProviderMap = Map<string, FeatureProviderClient<unknown, unknown>> export type ClientFeatureProviderMap = Map<string, FeatureProviderClient<any, any>>
/** /**
* Plugins are react components which get added to the editor. You can use them to interact with lexical, e.g. to create a command which creates a node, or opens a modal, or some other more "outside" functionality * Plugins are react components which get added to the editor. You can use them to interact with lexical, e.g. to create a command which creates a node, or opens a modal, or some other more "outside" functionality

View File

@@ -356,12 +356,12 @@ export type ResolvedServerFeature<ServerProps, ClientFeatureProps> = ServerFeatu
order: number order: number
} }
export type ResolvedServerFeatureMap = Map<string, ResolvedServerFeature<unknown, unknown>> export type ResolvedServerFeatureMap = Map<string, ResolvedServerFeature<any, any>>
export type ServerFeatureProviderMap = Map<string, FeatureProviderServer<unknown, unknown, unknown>> export type ServerFeatureProviderMap = Map<string, FeatureProviderServer<any, any, any>>
export type SanitizedServerFeatures = Required< export type SanitizedServerFeatures = Required<
Pick<ResolvedServerFeature<unknown, unknown>, 'i18n' | 'markdownTransformers' | 'nodes'> Pick<ResolvedServerFeature<any, any>, 'i18n' | 'markdownTransformers' | 'nodes'>
> & { > & {
/** The node types mapped to their converters */ /** The node types mapped to their converters */
converters: { converters: {

View File

@@ -83,7 +83,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
} }
} }
let features: FeatureProviderServer<unknown, unknown, unknown>[] = [] let features: FeatureProviderServer<any, any, any>[] = []
let resolvedFeatureMap: ResolvedServerFeatureMap let resolvedFeatureMap: ResolvedServerFeatureMap
let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only
@@ -977,5 +977,6 @@ export { defaultRichTextValue } from './populateGraphQL/defaultValue.js'
export type { LexicalEditorProps, LexicalRichTextAdapter } from './types.js' export type { LexicalEditorProps, LexicalRichTextAdapter } from './types.js'
export { createServerFeature } from './utilities/createServerFeature.js' export { createServerFeature } from './utilities/createServerFeature.js'
export { migrateSlateToLexical } from './utilities/migrateSlateToLexical/index.js'
export * from './nodeTypes.js' export * from './nodeTypes.js'

View File

@@ -30,7 +30,7 @@ export const defaultEditorLexicalConfig: LexicalEditorConfig = {
theme: LexicalEditorTheme, theme: LexicalEditorTheme,
} }
export const defaultEditorFeatures: FeatureProviderServer<unknown, unknown, unknown>[] = [ export const defaultEditorFeatures: FeatureProviderServer<any, any, any>[] = [
BoldFeature(), BoldFeature(),
ItalicFeature(), ItalicFeature(),
UnderlineFeature(), UnderlineFeature(),

View File

@@ -13,7 +13,7 @@ import type {
import type { LexicalFieldAdminProps } from '../../types.js' import type { LexicalFieldAdminProps } from '../../types.js'
export type ServerEditorConfig = { export type ServerEditorConfig = {
features: FeatureProviderServer<unknown, unknown, unknown>[] features: FeatureProviderServer<any, any, any>[]
lexical?: LexicalEditorConfig lexical?: LexicalEditorConfig
} }
@@ -24,7 +24,7 @@ export type SanitizedServerEditorConfig = {
} }
export type ClientEditorConfig = { export type ClientEditorConfig = {
features: FeatureProviderClient<unknown, unknown>[] features: FeatureProviderClient<any, any>[]
lexical?: LexicalEditorConfig lexical?: LexicalEditorConfig
} }

View File

@@ -0,0 +1,164 @@
import type { CollectionConfig, Field, GlobalConfig, Payload } from 'payload'
import { migrateDocumentFields } from './recurse.js'
/**
* This goes through every single collection and field in the payload config, and migrates its data from Slate to Lexical. This does not support sub-fields within slate.
*
* It will only translate fields fulfilling all these requirements:
* - field schema uses lexical editor
* - lexical editor has SlateToLexicalFeature added
* - saved field data is in Slate format
*
* @param payload
*/
export async function migrateSlateToLexical({ payload }: { payload: Payload }) {
const collections = payload.config.collections
const allLocales = payload.config.localization ? payload.config.localization.localeCodes : [null]
const totalCollections = collections.length
for (const locale of allLocales) {
let curCollection = 0
for (const collection of collections) {
curCollection++
await migrateCollection({
collection,
cur: curCollection,
locale,
max: totalCollections,
payload,
})
}
for (const global of payload.config.globals) {
await migrateGlobal({
global,
locale,
payload,
})
}
}
}
async function migrateGlobal({
global,
locale,
payload,
}: {
global: GlobalConfig
locale: null | string
payload: Payload
}) {
console.log(`SlateToLexical: ${locale}: Migrating global:`, global.slug)
const document = await payload.findGlobal({
slug: global.slug,
depth: 0,
locale: locale || undefined,
overrideAccess: true,
})
const found = migrateDocument({
document,
fields: global.fields,
})
if (found) {
await payload.updateGlobal({
slug: global.slug,
data: document,
depth: 0,
locale: locale || undefined,
})
}
}
async function migrateCollection({
collection,
cur,
locale,
max,
payload,
}: {
collection: CollectionConfig
cur: number
locale: null | string
max: number
payload: Payload
}) {
console.log(
`SlateToLexical: ${locale}: Migrating collection:`,
collection.slug,
'(' + cur + '/' + max + ')',
)
const documentCount = (
await payload.count({
collection: collection.slug,
depth: 0,
locale: locale || undefined,
})
).totalDocs
let page = 1
let migrated = 0
while (migrated < documentCount) {
const documents = await payload.find({
collection: collection.slug,
depth: 0,
locale: locale || undefined,
overrideAccess: true,
page,
pagination: true,
})
for (const document of documents.docs) {
migrated++
console.log(
`SlateToLexical: ${locale}: Migrating collection:`,
collection.slug,
'(' +
cur +
'/' +
max +
') - Migrating Document: ' +
document.id +
' (' +
migrated +
'/' +
documentCount +
')',
)
const found = migrateDocument({
document,
fields: collection.fields,
})
if (found) {
await payload.update({
id: document.id,
collection: collection.slug,
data: document,
depth: 0,
locale: locale || undefined,
})
}
}
page++
}
}
function migrateDocument({
document,
fields,
}: {
document: Record<string, unknown>
fields: Field[]
}): boolean {
return !!migrateDocumentFields({
data: document,
fields,
found: 0,
})
}

View File

@@ -0,0 +1,112 @@
import type { Field } from 'payload'
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload/shared'
import type {
SlateNodeConverter,
SlateNodeConverterProvider,
} from '../../features/migrations/slateToLexical/converter/types.js'
import type { LexicalRichTextAdapter } from '../../types.js'
import { convertSlateToLexical } from '../../features/migrations/slateToLexical/converter/index.js'
type NestedRichTextFieldsArgs = {
data: unknown
fields: Field[]
found: number
}
export const migrateDocumentFields = ({
data,
fields,
found,
}: NestedRichTextFieldsArgs): number => {
for (const field of fields) {
if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
if (fieldAffectsData(field) && typeof data[field.name] === 'object') {
migrateDocumentFields({
data: data[field.name],
fields: field.fields,
found,
})
} else {
migrateDocumentFields({
data,
found,
fields: field.fields,
})
}
} else if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
migrateDocumentFields({
data,
found,
fields: tab.fields,
})
})
} else if (Array.isArray(data[field.name])) {
if (field.type === 'blocks') {
data[field.name].forEach((row, i) => {
const block = field.blocks.find(({ slug }) => slug === row?.blockType)
if (block) {
migrateDocumentFields({
data: data[field.name][i],
found,
fields: block.fields,
})
}
})
}
if (field.type === 'array') {
data[field.name].forEach((_, i) => {
migrateDocumentFields({
data: data[field.name][i],
found,
fields: field.fields,
})
})
}
}
if (field.type === 'richText' && Array.isArray(data[field.name])) {
// Slate richText
const editor: LexicalRichTextAdapter = field.editor as LexicalRichTextAdapter
if (editor && typeof editor === 'object') {
if ('features' in editor && editor.features?.length) {
// find slatetolexical feature
const slateToLexicalFeature = editor.editorConfig.resolvedFeatureMap.get('slateToLexical')
if (slateToLexicalFeature) {
// DO CONVERSION
const converterProviders = (
slateToLexicalFeature.sanitizedServerFeatureProps as {
converters?: SlateNodeConverterProvider[]
}
).converters
const converters: SlateNodeConverter[] = []
for (const converter of converterProviders) {
converters.push(converter.converter)
}
data[field.name] = convertSlateToLexical({
converters,
slateData: data[field.name],
})
found++
}
}
}
}
}
return found
}