Skip to content

Commit 1fe75e0

Browse files
authored
fix(richtext-lexical): editor throws an error if OrderedList is registered but not UnorderedList or CheckList (#14149)
The problem was that the nodes weren't being added. The logic to avoid adding the same node or plugin multiple times was repeated in several places. It was also difficult to understand and easy to make mistakes. I extracted the logic into a new `shouldRegisterList` utility and corrected the behavior where necessary. Fixes #14148
1 parent 7ecb5a0 commit 1fe75e0

File tree

10 files changed

+183
-33
lines changed

10 files changed

+183
-33
lines changed

packages/richtext-lexical/src/features/lists/checklist/client/index.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ChecklistIcon } from '../../../../lexical/ui/icons/Checklist/index.js'
99
import { createClientFeature } from '../../../../utilities/createClientFeature.js'
1010
import { toolbarTextDropdownGroupWithItems } from '../../../shared/toolbar/textDropdownGroup.js'
1111
import { LexicalListPlugin } from '../../plugin/index.js'
12+
import { shouldRegisterListBaseNodes } from '../../shared/shouldRegisterListBaseNodes.js'
1213
import { slashMenuListGroupWithItems } from '../../shared/slashMenuListGroup.js'
1314
import { CHECK_LIST } from '../markdownTransformers.js'
1415
import { LexicalCheckListPlugin } from './plugin/index.js'
@@ -62,7 +63,8 @@ export const ChecklistFeatureClient = createClientFeature(({ featureProviderMap
6263
},
6364
]
6465

65-
if (!featureProviderMap.has('unorderedList') && !featureProviderMap.has('orderedList')) {
66+
const shouldRegister = shouldRegisterListBaseNodes('checklist', featureProviderMap)
67+
if (shouldRegister) {
6668
plugins.push({
6769
Component: LexicalListPlugin,
6870
position: 'normal',
@@ -71,10 +73,7 @@ export const ChecklistFeatureClient = createClientFeature(({ featureProviderMap
7173

7274
return {
7375
markdownTransformers: [CHECK_LIST],
74-
nodes:
75-
featureProviderMap.has('unorderedList') || featureProviderMap.has('orderedList')
76-
? []
77-
: [ListNode, ListItemNode],
76+
nodes: shouldRegister ? [ListNode, ListItemNode] : [],
7877
plugins,
7978
slashMenu: {
8079
groups: [

packages/richtext-lexical/src/features/lists/checklist/server/index.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ListItemNode, ListNode } from '@lexical/list'
33
import { createServerFeature } from '../../../../utilities/createServerFeature.js'
44
import { createNode } from '../../../typeUtilities.js'
55
import { ListHTMLConverter, ListItemHTMLConverter } from '../../htmlConverter.js'
6+
import { shouldRegisterListBaseNodes } from '../../shared/shouldRegisterListBaseNodes.js'
67
import { CHECK_LIST } from '../markdownTransformers.js'
78
import { i18n } from './i18n.js'
89

@@ -12,23 +13,22 @@ export const ChecklistFeature = createServerFeature({
1213
ClientFeature: '@payloadcms/richtext-lexical/client#ChecklistFeatureClient',
1314
i18n,
1415
markdownTransformers: [CHECK_LIST],
15-
nodes:
16-
featureProviderMap.has('unorderedList') || featureProviderMap.has('orderedList')
17-
? []
18-
: [
19-
createNode({
20-
converters: {
21-
html: ListHTMLConverter as any, // ListHTMLConverter uses a different generic type than ListNode[exportJSON], thus we need to cast as any
22-
},
23-
node: ListNode,
24-
}),
25-
createNode({
26-
converters: {
27-
html: ListItemHTMLConverter as any,
28-
},
29-
node: ListItemNode,
30-
}),
31-
],
16+
nodes: shouldRegisterListBaseNodes('checklist', featureProviderMap)
17+
? [
18+
createNode({
19+
converters: {
20+
html: ListHTMLConverter as any, // ListHTMLConverter uses a different generic type than ListNode[exportJSON], thus we need to cast as any
21+
},
22+
node: ListNode,
23+
}),
24+
createNode({
25+
converters: {
26+
html: ListItemHTMLConverter as any,
27+
},
28+
node: ListItemNode,
29+
}),
30+
]
31+
: [],
3232
}
3333
},
3434
key: 'checklist',

packages/richtext-lexical/src/features/lists/orderedList/client/index.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { OrderedListIcon } from '../../../../lexical/ui/icons/OrderedList/index.
88
import { createClientFeature } from '../../../../utilities/createClientFeature.js'
99
import { toolbarTextDropdownGroupWithItems } from '../../../shared/toolbar/textDropdownGroup.js'
1010
import { LexicalListPlugin } from '../../plugin/index.js'
11+
import { shouldRegisterListBaseNodes } from '../../shared/shouldRegisterListBaseNodes.js'
1112
import { slashMenuListGroupWithItems } from '../../shared/slashMenuListGroup.js'
1213
import { ORDERED_LIST } from '../markdownTransformer.js'
1314

@@ -53,17 +54,18 @@ const toolbarGroups: ToolbarGroup[] = [
5354
]
5455

5556
export const OrderedListFeatureClient = createClientFeature(({ featureProviderMap }) => {
57+
const shouldRegister = shouldRegisterListBaseNodes('ordered', featureProviderMap)
5658
return {
5759
markdownTransformers: [ORDERED_LIST],
58-
nodes: featureProviderMap.has('orderedList') ? [] : [ListNode, ListItemNode],
59-
plugins: featureProviderMap.has('orderedList')
60-
? []
61-
: [
60+
nodes: shouldRegister ? [ListNode, ListItemNode] : [],
61+
plugins: shouldRegister
62+
? [
6263
{
6364
Component: LexicalListPlugin,
6465
position: 'normal',
6566
},
66-
],
67+
]
68+
: [],
6769
slashMenu: {
6870
groups: [
6971
slashMenuListGroupWithItems([

packages/richtext-lexical/src/features/lists/orderedList/server/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ListItemNode, ListNode } from '@lexical/list'
33
import { createServerFeature } from '../../../../utilities/createServerFeature.js'
44
import { createNode } from '../../../typeUtilities.js'
55
import { ListHTMLConverter, ListItemHTMLConverter } from '../../htmlConverter.js'
6+
import { shouldRegisterListBaseNodes } from '../../shared/shouldRegisterListBaseNodes.js'
67
import { ORDERED_LIST } from '../markdownTransformer.js'
78
import { i18n } from './i18n.js'
89

@@ -12,9 +13,8 @@ export const OrderedListFeature = createServerFeature({
1213
ClientFeature: '@payloadcms/richtext-lexical/client#OrderedListFeatureClient',
1314
i18n,
1415
markdownTransformers: [ORDERED_LIST],
15-
nodes: featureProviderMap.has('unorderedList')
16-
? []
17-
: [
16+
nodes: shouldRegisterListBaseNodes('ordered', featureProviderMap)
17+
? [
1818
createNode({
1919
converters: {
2020
html: ListHTMLConverter as any, // ListHTMLConverter uses a different generic type than ListNode[exportJSON], thus we need to cast as any
@@ -27,7 +27,8 @@ export const OrderedListFeature = createServerFeature({
2727
},
2828
node: ListItemNode,
2929
}),
30-
],
30+
]
31+
: [],
3132
}
3233
},
3334
key: 'orderedList',
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Priority order: unordered > ordered > checklist.
2+
// That's why we don't include unordered among the parameter options. It registers by default.
3+
export function shouldRegisterListBaseNodes(
4+
type: 'checklist' | 'ordered',
5+
featureProviderMap: Map<string, unknown>,
6+
) {
7+
if (type === 'ordered') {
8+
// OrderedList only registers if UnorderedList is NOT present
9+
return !featureProviderMap.has('unorderedList')
10+
}
11+
12+
if (type === 'checklist') {
13+
// Checklist only registers if neither UnorderedList nor OrderedList are present
14+
return !featureProviderMap.has('unorderedList') && !featureProviderMap.has('orderedList')
15+
}
16+
17+
return false
18+
}

test/lexical/baseConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { LexicalHeadingFeature } from './collections/LexicalHeadingFeature/index
1414
import { LexicalInBlock } from './collections/LexicalInBlock/index.js'
1515
import { LexicalJSXConverter } from './collections/LexicalJSXConverter/index.js'
1616
import { LexicalLinkFeature } from './collections/LexicalLinkFeature/index.js'
17+
import { LexicalListsFeature } from './collections/LexicalListsFeature/index.js'
1718
import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
1819
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
1920
import { LexicalObjectReferenceBugCollection } from './collections/LexicalObjectReferenceBug/index.js'
@@ -34,6 +35,7 @@ export const baseConfig: Partial<Config> = {
3435
collections: [
3536
LexicalFullyFeatured,
3637
LexicalLinkFeature,
38+
LexicalListsFeature,
3739
LexicalHeadingFeature,
3840
LexicalJSXConverter,
3941
getLexicalFieldsCollection({
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect, test } from '@playwright/test'
2+
import path from 'path'
3+
import { fileURLToPath } from 'url'
4+
5+
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
6+
import type { Config } from '../../payload-types.js'
7+
8+
import { ensureCompilationIsDone } from '../../../helpers.js'
9+
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
10+
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
11+
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
12+
import { lexicalListsFeatureSlug } from '../../slugs.js'
13+
import { LexicalHelpers } from '../utils.js'
14+
15+
const filename = fileURLToPath(import.meta.url)
16+
const currentFolder = path.dirname(filename)
17+
const dirname = path.resolve(currentFolder, '../../')
18+
19+
let payload: PayloadTestSDK<Config>
20+
let serverURL: string
21+
22+
const { beforeAll, beforeEach, describe } = test
23+
24+
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
25+
// PLEASE do not reset the database or perform any operations that modify it in this file.
26+
test.describe.configure({ mode: 'parallel' })
27+
28+
describe('Lexical Lists Features', () => {
29+
let lexical: LexicalHelpers
30+
beforeAll(async ({ browser }, testInfo) => {
31+
testInfo.setTimeout(TEST_TIMEOUT_LONG)
32+
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
33+
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
34+
35+
const page = await browser.newPage()
36+
await ensureCompilationIsDone({ page, serverURL })
37+
await page.close()
38+
})
39+
beforeEach(async ({ page }) => {
40+
const url = new AdminUrlUtil(serverURL, lexicalListsFeatureSlug)
41+
lexical = new LexicalHelpers(page)
42+
await page.goto(url.create)
43+
await lexical.editor.first().focus()
44+
})
45+
test('Registering only ordered list should work', async ({ page }) => {
46+
await page.keyboard.type('- hello')
47+
await expect(lexical.editor.locator('li')).toBeHidden()
48+
await page.keyboard.press('Enter')
49+
await page.keyboard.type('1. hello')
50+
await expect(lexical.editor.locator('li')).toBeVisible()
51+
})
52+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import {
4+
ChecklistFeature,
5+
FixedToolbarFeature,
6+
lexicalEditor,
7+
OrderedListFeature,
8+
TreeViewFeature,
9+
UnorderedListFeature,
10+
} from '@payloadcms/richtext-lexical'
11+
12+
import { lexicalListsFeatureSlug } from '../../slugs.js'
13+
14+
export const LexicalListsFeature: CollectionConfig = {
15+
slug: lexicalListsFeatureSlug,
16+
labels: {
17+
singular: 'Lexical Lists Features',
18+
plural: 'Lexical Lists Features',
19+
},
20+
fields: [
21+
{
22+
name: 'onlyOrderedList',
23+
type: 'richText',
24+
editor: lexicalEditor({
25+
features: [
26+
TreeViewFeature(),
27+
FixedToolbarFeature(),
28+
OrderedListFeature(),
29+
// UnorderedListFeature(),
30+
// ChecklistFeature(),
31+
],
32+
}),
33+
},
34+
],
35+
}

test/lexical/payload-types.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export interface Config {
8585
collections: {
8686
'lexical-fully-featured': LexicalFullyFeatured;
8787
'lexical-link-feature': LexicalLinkFeature;
88+
'lexical-lists-features': LexicalListsFeature;
8889
'lexical-heading-feature': LexicalHeadingFeature;
8990
'lexical-jsx-converter': LexicalJsxConverter;
9091
'lexical-fields': LexicalField;
@@ -110,6 +111,7 @@ export interface Config {
110111
collectionsSelect: {
111112
'lexical-fully-featured': LexicalFullyFeaturedSelect<false> | LexicalFullyFeaturedSelect<true>;
112113
'lexical-link-feature': LexicalLinkFeatureSelect<false> | LexicalLinkFeatureSelect<true>;
114+
'lexical-lists-features': LexicalListsFeaturesSelect<false> | LexicalListsFeaturesSelect<true>;
113115
'lexical-heading-feature': LexicalHeadingFeatureSelect<false> | LexicalHeadingFeatureSelect<true>;
114116
'lexical-jsx-converter': LexicalJsxConverterSelect<false> | LexicalJsxConverterSelect<true>;
115117
'lexical-fields': LexicalFieldsSelect<false> | LexicalFieldsSelect<true>;
@@ -215,6 +217,30 @@ export interface LexicalLinkFeature {
215217
updatedAt: string;
216218
createdAt: string;
217219
}
220+
/**
221+
* This interface was referenced by `Config`'s JSON-Schema
222+
* via the `definition` "lexical-lists-features".
223+
*/
224+
export interface LexicalListsFeature {
225+
id: string;
226+
onlyOrderedList?: {
227+
root: {
228+
type: string;
229+
children: {
230+
type: any;
231+
version: number;
232+
[k: string]: unknown;
233+
}[];
234+
direction: ('ltr' | 'rtl') | null;
235+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
236+
indent: number;
237+
version: number;
238+
};
239+
[k: string]: unknown;
240+
} | null;
241+
updatedAt: string;
242+
createdAt: string;
243+
}
218244
/**
219245
* This interface was referenced by `Config`'s JSON-Schema
220246
* via the `definition` "lexical-heading-feature".
@@ -992,6 +1018,10 @@ export interface PayloadLockedDocument {
9921018
relationTo: 'lexical-link-feature';
9931019
value: string | LexicalLinkFeature;
9941020
} | null)
1021+
| ({
1022+
relationTo: 'lexical-lists-features';
1023+
value: string | LexicalListsFeature;
1024+
} | null)
9951025
| ({
9961026
relationTo: 'lexical-heading-feature';
9971027
value: string | LexicalHeadingFeature;
@@ -1120,6 +1150,15 @@ export interface LexicalLinkFeatureSelect<T extends boolean = true> {
11201150
updatedAt?: T;
11211151
createdAt?: T;
11221152
}
1153+
/**
1154+
* This interface was referenced by `Config`'s JSON-Schema
1155+
* via the `definition` "lexical-lists-features_select".
1156+
*/
1157+
export interface LexicalListsFeaturesSelect<T extends boolean = true> {
1158+
onlyOrderedList?: T;
1159+
updatedAt?: T;
1160+
createdAt?: T;
1161+
}
11231162
/**
11241163
* This interface was referenced by `Config`'s JSON-Schema
11251164
* via the `definition` "lexical-heading-feature_select".
@@ -1627,6 +1666,6 @@ export interface Auth {
16271666

16281667

16291668
declare module 'payload' {
1630-
// @ts-ignore
1669+
// @ts-ignore
16311670
export interface GeneratedTypes extends Config {}
1632-
}
1671+
}

test/lexical/slugs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const lexicalFullyFeaturedSlug = 'lexical-fully-featured'
44
export const lexicalFieldsSlug = 'lexical-fields'
55
export const lexicalJSXConverterSlug = 'lexical-jsx-converter'
66
export const lexicalHeadingFeatureSlug = 'lexical-heading-feature'
7+
export const lexicalListsFeatureSlug = 'lexical-lists-features'
78

89
export const lexicalLinkFeatureSlug = 'lexical-link-feature'
910
export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields'
@@ -28,4 +29,5 @@ export const collectionSlugs = [
2829
richTextFieldsSlug,
2930
textFieldsSlug,
3031
uploadsSlug,
32+
lexicalListsFeatureSlug,
3133
]

0 commit comments

Comments
 (0)