From f115b606c494af3d7cb2f8911c3e5c8a50f89f6a Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 3 Feb 2025 11:05:01 -0700 Subject: [PATCH 1/4] fix(richtext-lexical): link drawer has no fields if parent document `create` access control is `false` --- .../link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx | 2 +- packages/ui/src/forms/RenderFields/index.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx b/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx index a1c056e3b71..ee4d54cca7a 100644 --- a/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx +++ b/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx @@ -250,8 +250,8 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R setNotLink, config.routes.admin, config.routes.api, - config.collections, config.serverURL, + getEntityConfig, t, i18n, locale?.code, diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index 4f1e1a87ff2..7dbe362d2c9 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -57,6 +57,7 @@ export const RenderFields: React.FC = (props) => { // This is different from `admin.readOnly` which is executed based on `operation` const hasReadPermission = permissions === true || + permissions?.read === true || permissions?.[parentName] === true || ('name' in field && typeof permissions === 'object' && @@ -79,6 +80,7 @@ export const RenderFields: React.FC = (props) => { // If the user does not have access control to begin with, force it to be read-only const hasOperationPermission = permissions === true || + permissions[operation] === true || permissions?.[parentName] === true || ('name' in field && typeof permissions === 'object' && From 787ff8c63bdb969d79c8e7555e7cdac1d1ebd4e8 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 3 Feb 2025 11:34:23 -0700 Subject: [PATCH 2/4] chore: add e2e test --- .../collections/Lexical/e2e/main/e2e.spec.ts | 48 ++++++++++++++++--- .../collections/LexicalAccessControl/index.ts | 29 +++++++++++ test/fields/config.ts | 2 + test/fields/payload-types.ts | 45 ++++++++++++++++- test/fields/seed.ts | 14 ++++++ test/fields/slugs.ts | 4 ++ 6 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 test/fields/collections/LexicalAccessControl/index.ts diff --git a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts index 3352788f115..46ea768c904 100644 --- a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts @@ -47,13 +47,10 @@ let serverURL: string */ async function navigateToLexicalFields( navigateToListView: boolean = true, - localized: boolean = false, + collectionSlug: string = 'lexical-fields', ) { if (navigateToListView) { - const url: AdminUrlUtil = new AdminUrlUtil( - serverURL, - localized ? 'lexical-localized-fields' : 'lexical-fields', - ) + const url: AdminUrlUtil = new AdminUrlUtil(serverURL, collectionSlug) await page.goto(url.list) } @@ -932,6 +929,45 @@ describe('lexicalMain', () => { }) }) + test('ensure link drawer displays fields if document does not have `create` permission', async () => { + await navigateToLexicalFields(true, 'lexical-access-control') + const richTextField = page.locator('.rich-text-lexical').first() + await richTextField.scrollIntoViewIfNeeded() + await expect(richTextField).toBeVisible() + + const paragraph = richTextField.locator('.LexicalEditorTheme__paragraph').first() + await paragraph.scrollIntoViewIfNeeded() + await expect(paragraph).toBeVisible() + /** + * Type some text + */ + await paragraph.click() + await page.keyboard.type('Text') + + // Select "there" by pressing shift + arrow left + for (let i = 0; i < 4; i++) { + await page.keyboard.press('Shift+ArrowLeft') + } + // Ensure inline toolbar appeared + const inlineToolbar = page.locator('.inline-toolbar-popup') + await expect(inlineToolbar).toBeVisible() + + const linkButton = inlineToolbar.locator('.toolbar-popup__button-link') + await expect(linkButton).toBeVisible() + await linkButton.click() + + /** + * Link Drawer + */ + + const linkDrawer = page.locator('dialog[id^=drawer_1_lexical-rich-text-link-]').first() // IDs starting with drawer_1_lexical-rich-text-link- (there's some other symbol after the underscore) + await expect(linkDrawer).toBeVisible() + + const urlInput = linkDrawer.locator('#field-url').first() + + await expect(urlInput).toBeVisible() + }) + test('lexical cursor / selection should be preserved when swapping upload field and clicking within with its list drawer', async () => { await navigateToLexicalFields() const richTextField = page.locator('.rich-text-lexical').first() @@ -1292,7 +1328,7 @@ describe('lexicalMain', () => { expect(htmlContent).toContain('Start typing, or press') }) test.skip('ensure simple localized lexical field works', async () => { - await navigateToLexicalFields(true, true) + await navigateToLexicalFields(true, 'lexical-localized-fields') }) }) diff --git a/test/fields/collections/LexicalAccessControl/index.ts b/test/fields/collections/LexicalAccessControl/index.ts new file mode 100644 index 00000000000..db015bb9a1b --- /dev/null +++ b/test/fields/collections/LexicalAccessControl/index.ts @@ -0,0 +1,29 @@ +import type { CollectionConfig } from 'payload' + +import { defaultEditorFeatures, lexicalEditor } from '@payloadcms/richtext-lexical' + +import { lexicalAccessControlSlug } from '../../slugs.js' + +export const LexicalAccessControl: CollectionConfig = { + slug: lexicalAccessControlSlug, + access: { + read: () => true, + create: () => false, + }, + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'richText', + type: 'richText', + editor: lexicalEditor({ + features: [...defaultEditorFeatures], + }), + }, + ], +} diff --git a/test/fields/config.ts b/test/fields/config.ts index 6e58c7a0b71..2c0f4ecc89f 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -21,6 +21,7 @@ import GroupFields from './collections/Group/index.js' import IndexedFields from './collections/Indexed/index.js' import JSONFields from './collections/JSON/index.js' import { LexicalFields } from './collections/Lexical/index.js' +import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js' import { LexicalInBlock } from './collections/LexicalInBlock/index.js' import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js' import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js' @@ -68,6 +69,7 @@ export const collectionSlugs: CollectionConfig[] = [ ], }, LexicalInBlock, + LexicalAccessControl, SelectVersionsFields, ArrayFields, BlockFields, diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 913a00d8565..25952d77afe 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -34,6 +34,7 @@ export interface Config { lexicalObjectReferenceBug: LexicalObjectReferenceBug; users: User; LexicalInBlock: LexicalInBlock; + 'lexical-access-control': LexicalAccessControl; 'select-versions-fields': SelectVersionsField; 'array-fields': ArrayField; 'block-fields': BlockField; @@ -80,6 +81,7 @@ export interface Config { lexicalObjectReferenceBug: LexicalObjectReferenceBugSelect | LexicalObjectReferenceBugSelect; users: UsersSelect | UsersSelect; LexicalInBlock: LexicalInBlockSelect | LexicalInBlockSelect; + 'lexical-access-control': LexicalAccessControlSelect | LexicalAccessControlSelect; 'select-versions-fields': SelectVersionsFieldsSelect | SelectVersionsFieldsSelect; 'array-fields': ArrayFieldsSelect | ArrayFieldsSelect; 'block-fields': BlockFieldsSelect | BlockFieldsSelect; @@ -454,6 +456,31 @@ export interface LexicalInBlock { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "lexical-access-control". + */ +export interface LexicalAccessControl { + id: string; + title?: string | null; + richText?: { + 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; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "select-versions-fields". @@ -469,7 +496,7 @@ export interface SelectVersionsField { | null; blocks?: | { - hasManyArr?: ('a' | 'b' | 'c')[] | null; + hasManyBlocks?: ('a' | 'b' | 'c')[] | null; id?: string | null; blockName?: string | null; blockType: 'block'; @@ -1830,6 +1857,10 @@ export interface PayloadLockedDocument { relationTo: 'LexicalInBlock'; value: string | LexicalInBlock; } | null) + | ({ + relationTo: 'lexical-access-control'; + value: string | LexicalAccessControl; + } | null) | ({ relationTo: 'select-versions-fields'; value: string | SelectVersionsField; @@ -2104,6 +2135,16 @@ export interface LexicalInBlockSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "lexical-access-control_select". + */ +export interface LexicalAccessControlSelect { + title?: T; + richText?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "select-versions-fields_select". @@ -2122,7 +2163,7 @@ export interface SelectVersionsFieldsSelect { block?: | T | { - hasManyArr?: T; + hasManyBlocks?: T; id?: T; blockName?: T; }; diff --git a/test/fields/seed.ts b/test/fields/seed.ts index a114c4e1031..7bdedcb101f 100644 --- a/test/fields/seed.ts +++ b/test/fields/seed.ts @@ -495,10 +495,12 @@ export const seed = async (_payload: Payload) => { data: { text: 'text', }, + depth: 0, }) await _payload.create({ collection: 'LexicalInBlock', + depth: 0, data: { content: { root: { @@ -537,24 +539,36 @@ export const seed = async (_payload: Payload) => { }, }) + await _payload.create({ + collection: 'lexical-access-control', + data: { + richText: textToLexicalJSON({ text: 'text' }), + title: 'title', + }, + depth: 0, + }) + await Promise.all([ _payload.create({ collection: customIDSlug, data: { id: nonStandardID, }, + depth: 0, }), _payload.create({ collection: customTabIDSlug, data: { id: customTabID, }, + depth: 0, }), _payload.create({ collection: customRowIDSlug, data: { id: customRowID, }, + depth: 0, }), ]) } diff --git a/test/fields/slugs.ts b/test/fields/slugs.ts index cfb3ec03ff5..e2374376ea3 100644 --- a/test/fields/slugs.ts +++ b/test/fields/slugs.ts @@ -17,6 +17,9 @@ export const lexicalFieldsSlug = 'lexical-fields' export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields' export const lexicalMigrateFieldsSlug = 'lexical-migrate-fields' export const lexicalRelationshipFieldsSlug = 'lexical-relationship-fields' + +export const lexicalAccessControlSlug = 'lexical-access-control' + export const numberFieldsSlug = 'number-fields' export const pointFieldsSlug = 'point-fields' export const radioFieldsSlug = 'radio-fields' @@ -52,6 +55,7 @@ export const collectionSlugs = [ lexicalFieldsSlug, lexicalMigrateFieldsSlug, lexicalRelationshipFieldsSlug, + lexicalAccessControlSlug, numberFieldsSlug, pointFieldsSlug, radioFieldsSlug, From 2e2fc9df725f39b1808894d793b680f64aadf959 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 3 Feb 2025 11:35:04 -0700 Subject: [PATCH 3/4] safe access --- packages/ui/src/forms/RenderFields/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index 7dbe362d2c9..ad3f280ba7b 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -80,7 +80,7 @@ export const RenderFields: React.FC = (props) => { // If the user does not have access control to begin with, force it to be read-only const hasOperationPermission = permissions === true || - permissions[operation] === true || + permissions?.[operation] === true || permissions?.[parentName] === true || ('name' in field && typeof permissions === 'object' && From ee0b8d0a79cc2eeda67a2bdc056f98f87a2cfbaa Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 3 Feb 2025 11:41:05 -0700 Subject: [PATCH 4/4] simplify --- test/fields/collections/Lexical/e2e/main/e2e.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts index 46ea768c904..8c364a0451e 100644 --- a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts @@ -944,7 +944,7 @@ describe('lexicalMain', () => { await paragraph.click() await page.keyboard.type('Text') - // Select "there" by pressing shift + arrow left + // Select text for (let i = 0; i < 4; i++) { await page.keyboard.press('Shift+ArrowLeft') } @@ -956,10 +956,6 @@ describe('lexicalMain', () => { await expect(linkButton).toBeVisible() await linkButton.click() - /** - * Link Drawer - */ - const linkDrawer = page.locator('dialog[id^=drawer_1_lexical-rich-text-link-]').first() // IDs starting with drawer_1_lexical-rich-text-link- (there's some other symbol after the underscore) await expect(linkDrawer).toBeVisible()