Skip to content

Commit 15b18cc

Browse files
authored
fix(richtext-lexical): drag/drop image into rich text fails when a field name matches the collection slug (#16397)
## Summary When a top-level rich text field has the same `name` as the collection's `slug`, dragging or pasting an image into the editor opens a blank bulk upload drawer. The lexical field and the document layout both mount a `BulkUploadProvider`; when the field path equals the collection slug they compute the same drawer slug and render two drawers for it, one with empty state (blank), which is what the user sees. The fix is a 1-line change: namespace the lexical field's nested `BulkUploadProvider` with a `lexical-` prefix so its drawer slug can never collide with the document-level provider. ```diff - <BulkUploadProvider drawerSlugPrefix={path}> + <BulkUploadProvider drawerSlugPrefix={`lexical-${path}`}> ``` The rest of the diff is a new e2e test. ## Test plan Added `test/lexical/collections/LexicalSlugFieldNameCollision/e2e.spec.ts`, which asserts that dropping a file into a rich text editor opens exactly one bulk upload drawer when the field name equals the collection slug. Verified that the test fails without the production change (`Expected: 1, Received: 2`) and passes with it. --------- Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
1 parent 987cf84 commit 15b18cc

7 files changed

Lines changed: 171 additions & 2 deletions

File tree

packages/richtext-lexical/src/field/Field.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,9 @@ const RichTextComponent: React.FC<
213213
<ErrorBoundary fallbackRender={fallbackRender} onReset={() => {}}>
214214
{BeforeInput}
215215
{/* Lexical may be in a drawer. We need to define another BulkUploadProvider to ensure that the bulk upload drawer
216-
is rendered in the correct depth (not displayed *behind* the current drawer)*/}
217-
<BulkUploadProvider drawerSlugPrefix={path}>
216+
is rendered in the correct depth (not displayed *behind* the current drawer).
217+
The `lexical-` prefix prevents drawer-slug collisions with non-lexical `BulkUploadProvider`s up the tree. */}
218+
<BulkUploadProvider drawerSlugPrefix={`lexical-${path}`}>
218219
<LexicalProvider
219220
composerKey={pathWithEditDepth}
220221
editorConfig={editorConfig}

test/lexical/baseConfig.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from './collections/LexicalNestedBlocks/index.js'
3030
import { LexicalObjectReferenceBugCollection } from './collections/LexicalObjectReferenceBug/index.js'
3131
import { LexicalRelationshipsFields } from './collections/LexicalRelationships/index.js'
32+
import { LexicalSlugFieldNameCollision } from './collections/LexicalSlugFieldNameCollision/index.js'
3233
import { LexicalViews } from './collections/LexicalViews/index.js'
3334
import { LexicalViewsFrontend } from './collections/LexicalViewsFrontend/index.js'
3435
import { LexicalViewsNested } from './collections/LexicalViewsNested/index.js'
@@ -72,6 +73,7 @@ export const baseConfig: Partial<Config> = {
7273
LexicalInBlock,
7374
LexicalAccessControl,
7475
LexicalRelationshipsFields,
76+
LexicalSlugFieldNameCollision,
7577
LexicalNestedBlocks,
7678
RichTextFields,
7779
TextFields,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { expect, test } from '@playwright/test'
2+
import path from 'path'
3+
import { fileURLToPath } from 'url'
4+
5+
import type { PayloadTestSDK } from '../../../__helpers/shared/sdk/index.js'
6+
import type { Config } from '../../payload-types.js'
7+
8+
import { ensureCompilationIsDone } from '../../../__helpers/e2e/helpers.js'
9+
import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js'
10+
import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js'
11+
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
12+
import { lexicalSlugFieldNameCollisionSlug } 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+
// Repro: dropping an image mounts two bulk upload drawers for the same slug,
25+
// one blank, when a top-level rich text field name equals the collection slug.
26+
describe('Lexical: collection slug equals top-level field name', () => {
27+
let lexical: LexicalHelpers
28+
29+
beforeAll(async ({ browser }, testInfo) => {
30+
testInfo.setTimeout(TEST_TIMEOUT_LONG)
31+
process.env.SEED_IN_CONFIG_ONINIT = 'false'
32+
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
33+
34+
const page = await browser.newPage()
35+
await ensureCompilationIsDone({ page, serverURL })
36+
await page.close()
37+
})
38+
39+
beforeEach(async ({ page }) => {
40+
const url = new AdminUrlUtil(serverURL, lexicalSlugFieldNameCollisionSlug)
41+
lexical = new LexicalHelpers(page)
42+
await page.goto(url.create)
43+
await lexical.editor.first().focus()
44+
})
45+
46+
test('drag/drop image opens exactly one bulk upload drawer when a field name matches the collection slug', async ({
47+
page,
48+
}) => {
49+
const filePath = path.resolve(dirname, './collections/Upload/payload.jpg')
50+
51+
await lexical.dropFile({ filePath })
52+
53+
await expect(page.locator('.bulk-upload--actions-bar')).toBeVisible()
54+
55+
// Two providers compute the same drawer slug and both mount a drawer; the
56+
// empty one is what the user sees as the blank drawer. Should be exactly 1.
57+
const bulkDrawers = page.locator(
58+
'dialog[aria-label*="bulk-upload-drawer-slug"][open], dialog[id*="bulk-upload-drawer-slug"][open]',
59+
)
60+
await expect(bulkDrawers).toHaveCount(1)
61+
})
62+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import { FixedToolbarFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
4+
5+
import { lexicalSlugFieldNameCollisionSlug } from '../../slugs.js'
6+
7+
// The slug and the rich text field name must stay equal to repro the bug.
8+
export const LexicalSlugFieldNameCollision: CollectionConfig = {
9+
slug: lexicalSlugFieldNameCollisionSlug,
10+
labels: {
11+
singular: 'Lexical Slug Field Name Collision',
12+
plural: 'Lexical Slug Field Name Collision',
13+
},
14+
fields: [
15+
{
16+
name: lexicalSlugFieldNameCollisionSlug,
17+
type: 'richText',
18+
editor: lexicalEditor({
19+
features: ({ defaultFeatures }) => [...defaultFeatures, FixedToolbarFeature()],
20+
}),
21+
},
22+
],
23+
}

test/lexical/collections/utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,47 @@ export class LexicalHelpers {
115115
return {}
116116
}
117117

118+
// Simulates a desktop file drop by firing dragenter/dragover/drop with a
119+
// populated DataTransfer, triggering Lexical's `DROP_COMMAND`.
120+
async dropFile({ filePath }: { filePath: string }) {
121+
const name = path.basename(filePath)
122+
const mime = inferMimeFromExt(path.extname(name))
123+
const buf = await fs.promises.readFile(filePath)
124+
const bytes = Array.from(buf)
125+
126+
const editor = this.editor.first()
127+
await editor.evaluate(
128+
(el, p) => {
129+
const target = el.querySelector('p, span, br, div') ?? (el as HTMLElement)
130+
131+
const dt = new DataTransfer()
132+
const file = new File([new Uint8Array(p.bytes)], p.name, { type: p.mime })
133+
dt.items.add(file)
134+
135+
const rect = target.getBoundingClientRect()
136+
const x = rect.left + Math.max(rect.width / 2, 1)
137+
const y = rect.top + Math.max(rect.height / 2, 1)
138+
139+
const dispatch = (type: 'dragenter' | 'dragover' | 'drop') => {
140+
const evt = new DragEvent(type, {
141+
bubbles: true,
142+
cancelable: true,
143+
composed: true,
144+
clientX: x,
145+
clientY: y,
146+
dataTransfer: dt,
147+
})
148+
target.dispatchEvent(evt)
149+
}
150+
151+
dispatch('dragenter')
152+
dispatch('dragover')
153+
dispatch('drop')
154+
},
155+
{ bytes, name, mime },
156+
)
157+
}
158+
118159
async paste(type: 'html' | 'markdown', text: string) {
119160
await this.page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
120161

test/lexical/payload-types.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export interface Config {
105105
LexicalInBlock: LexicalInBlock;
106106
'lexical-access-control': LexicalAccessControl;
107107
'lexical-relationship-fields': LexicalRelationshipField;
108+
collision: Collision;
108109
'lexical-nested-blocks': LexicalNestedBlock;
109110
'rich-text-fields': RichTextField;
110111
'text-fields': TextField;
@@ -141,6 +142,7 @@ export interface Config {
141142
LexicalInBlock: LexicalInBlockSelect<false> | LexicalInBlockSelect<true>;
142143
'lexical-access-control': LexicalAccessControlSelect<false> | LexicalAccessControlSelect<true>;
143144
'lexical-relationship-fields': LexicalRelationshipFieldsSelect<false> | LexicalRelationshipFieldsSelect<true>;
145+
collision: CollisionSelect<false> | CollisionSelect<true>;
144146
'lexical-nested-blocks': LexicalNestedBlocksSelect<false> | LexicalNestedBlocksSelect<true>;
145147
'rich-text-fields': RichTextFieldsSelect<false> | RichTextFieldsSelect<true>;
146148
'text-fields': TextFieldsSelect<false> | TextFieldsSelect<true>;
@@ -842,6 +844,30 @@ export interface LexicalRelationshipField {
842844
createdAt: string;
843845
_status?: ('draft' | 'published') | null;
844846
}
847+
/**
848+
* This interface was referenced by `Config`'s JSON-Schema
849+
* via the `definition` "collision".
850+
*/
851+
export interface Collision {
852+
id: string;
853+
collision?: {
854+
root: {
855+
type: string;
856+
children: {
857+
type: any;
858+
version: number;
859+
[k: string]: unknown;
860+
}[];
861+
direction: ('ltr' | 'rtl') | null;
862+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
863+
indent: number;
864+
version: number;
865+
};
866+
[k: string]: unknown;
867+
} | null;
868+
updatedAt: string;
869+
createdAt: string;
870+
}
845871
/**
846872
* This interface was referenced by `Config`'s JSON-Schema
847873
* via the `definition` "lexical-nested-blocks".
@@ -1315,6 +1341,10 @@ export interface PayloadLockedDocument {
13151341
relationTo: 'lexical-relationship-fields';
13161342
value: string | LexicalRelationshipField;
13171343
} | null)
1344+
| ({
1345+
relationTo: 'collision';
1346+
value: string | Collision;
1347+
} | null)
13181348
| ({
13191349
relationTo: 'lexical-nested-blocks';
13201350
value: string | LexicalNestedBlock;
@@ -1611,6 +1641,15 @@ export interface LexicalRelationshipFieldsSelect<T extends boolean = true> {
16111641
createdAt?: T;
16121642
_status?: T;
16131643
}
1644+
/**
1645+
* This interface was referenced by `Config`'s JSON-Schema
1646+
* via the `definition` "collision_select".
1647+
*/
1648+
export interface CollisionSelect<T extends boolean = true> {
1649+
collision?: T;
1650+
updatedAt?: T;
1651+
createdAt?: T;
1652+
}
16141653
/**
16151654
* This interface was referenced by `Config`'s JSON-Schema
16161655
* via the `definition` "lexical-nested-blocks_select".

test/lexical/slugs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const lexicalRelationshipFieldsSlug = 'lexical-relationship-fields'
1717
export const lexicalAccessControlSlug = 'lexical-access-control'
1818
export const lexicalAutosaveSlug = 'lexical-autosave'
1919
export const richTextFieldsSlug = 'rich-text-fields'
20+
export const lexicalSlugFieldNameCollisionSlug = 'collision'
2021

2122
// Auxiliary slugs
2223
export const textFieldsSlug = 'text-fields'

0 commit comments

Comments
 (0)