Skip to content

Commit d6f7b47

Browse files
authored
fix(richtext-lexical): drag/drop image into rich text fails when a field name matches the collection slug (#16409)
## Summary Backport of #16397 to 3.x. 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 ea39d8a commit d6f7b47

7 files changed

Lines changed: 174 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
@@ -30,6 +30,7 @@ import {
3030
} from './collections/LexicalNestedBlocks/index.js'
3131
import { LexicalObjectReferenceBugCollection } from './collections/LexicalObjectReferenceBug/index.js'
3232
import { LexicalRelationshipsFields } from './collections/LexicalRelationships/index.js'
33+
import { LexicalSlugFieldNameCollision } from './collections/LexicalSlugFieldNameCollision/index.js'
3334
import { LexicalViews } from './collections/LexicalViews/index.js'
3435
import { LexicalViewsFrontend } from './collections/LexicalViewsFrontend/index.js'
3536
import { LexicalViewsNested } from './collections/LexicalViewsNested/index.js'
@@ -74,6 +75,7 @@ export const baseConfig: Partial<Config> = {
7475
LexicalInBlock,
7576
LexicalAccessControl,
7677
LexicalRelationshipsFields,
78+
LexicalSlugFieldNameCollision,
7779
LexicalNestedBlocks,
7880
RichTextFields,
7981
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
@@ -106,6 +106,7 @@ export interface Config {
106106
LexicalInBlock: LexicalInBlock;
107107
'lexical-access-control': LexicalAccessControl;
108108
'lexical-relationship-fields': LexicalRelationshipField;
109+
collision: Collision;
109110
'lexical-nested-blocks': LexicalNestedBlock;
110111
'rich-text-fields': RichTextField;
111112
'text-fields': TextField;
@@ -143,6 +144,7 @@ export interface Config {
143144
LexicalInBlock: LexicalInBlockSelect<false> | LexicalInBlockSelect<true>;
144145
'lexical-access-control': LexicalAccessControlSelect<false> | LexicalAccessControlSelect<true>;
145146
'lexical-relationship-fields': LexicalRelationshipFieldsSelect<false> | LexicalRelationshipFieldsSelect<true>;
147+
collision: CollisionSelect<false> | CollisionSelect<true>;
146148
'lexical-nested-blocks': LexicalNestedBlocksSelect<false> | LexicalNestedBlocksSelect<true>;
147149
'rich-text-fields': RichTextFieldsSelect<false> | RichTextFieldsSelect<true>;
148150
'text-fields': TextFieldsSelect<false> | TextFieldsSelect<true>;
@@ -939,6 +941,30 @@ export interface LexicalRelationshipField {
939941
createdAt: string;
940942
_status?: ('draft' | 'published') | null;
941943
}
944+
/**
945+
* This interface was referenced by `Config`'s JSON-Schema
946+
* via the `definition` "collision".
947+
*/
948+
export interface Collision {
949+
id: string;
950+
collision?: {
951+
root: {
952+
type: string;
953+
children: {
954+
type: any;
955+
version: number;
956+
[k: string]: unknown;
957+
}[];
958+
direction: ('ltr' | 'rtl') | null;
959+
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
960+
indent: number;
961+
version: number;
962+
};
963+
[k: string]: unknown;
964+
} | null;
965+
updatedAt: string;
966+
createdAt: string;
967+
}
942968
/**
943969
* This interface was referenced by `Config`'s JSON-Schema
944970
* via the `definition` "lexical-nested-blocks".
@@ -1441,6 +1467,10 @@ export interface PayloadLockedDocument {
14411467
relationTo: 'lexical-relationship-fields';
14421468
value: string | LexicalRelationshipField;
14431469
} | null)
1470+
| ({
1471+
relationTo: 'collision';
1472+
value: string | Collision;
1473+
} | null)
14441474
| ({
14451475
relationTo: 'lexical-nested-blocks';
14461476
value: string | LexicalNestedBlock;
@@ -1763,6 +1793,15 @@ export interface LexicalRelationshipFieldsSelect<T extends boolean = true> {
17631793
createdAt?: T;
17641794
_status?: T;
17651795
}
1796+
/**
1797+
* This interface was referenced by `Config`'s JSON-Schema
1798+
* via the `definition` "collision_select".
1799+
*/
1800+
export interface CollisionSelect<T extends boolean = true> {
1801+
collision?: T;
1802+
updatedAt?: T;
1803+
createdAt?: T;
1804+
}
17661805
/**
17671806
* This interface was referenced by `Config`'s JSON-Schema
17681807
* via the `definition` "lexical-nested-blocks_select".

test/lexical/slugs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export const lexicalCustomCellSlug = 'lexical-custom-cell'
3030
export const lexicalNestedBlocksSlug = 'lexical-nested-blocks'
3131
export const lexicalBenchmarkSlug = 'lexical-benchmark'
3232

33+
// Must match the rich text field name on `LexicalSlugFieldNameCollision` to
34+
// reproduce the bug; rename both together.
35+
export const lexicalSlugFieldNameCollisionSlug = 'collision'
36+
3337
export const collectionSlugs = [
3438
lexicalFieldsSlug,
3539
lexicalLocalizedFieldsSlug,

0 commit comments

Comments
 (0)