fix(richtext-lexical): link drawer has no fields if parent document create access control is false (#10954)

Previously, the lexical link drawer did not display any fields if the
`create` permission was false, even though the `update` permission was
true.

The issue was a faulty permission check in `RenderFields` that did not
check the top-level permission operation keys for truthiness. It only
checked if the `permissions` variable itself was `true`, or if the
sub-fields had `create` / `update` permissions set to `true`.
This commit is contained in:
Alessio Gravili
2025-02-03 12:02:40 -07:00
committed by GitHub
parent 6353cf8bbe
commit 136c90c725
8 changed files with 133 additions and 9 deletions

View File

@@ -250,8 +250,8 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
setNotLink, setNotLink,
config.routes.admin, config.routes.admin,
config.routes.api, config.routes.api,
config.collections,
config.serverURL, config.serverURL,
getEntityConfig,
t, t,
i18n, i18n,
locale?.code, locale?.code,

View File

@@ -57,6 +57,7 @@ export const RenderFields: React.FC<RenderFieldsProps> = (props) => {
// This is different from `admin.readOnly` which is executed based on `operation` // This is different from `admin.readOnly` which is executed based on `operation`
const hasReadPermission = const hasReadPermission =
permissions === true || permissions === true ||
permissions?.read === true ||
permissions?.[parentName] === true || permissions?.[parentName] === true ||
('name' in field && ('name' in field &&
typeof permissions === 'object' && typeof permissions === 'object' &&
@@ -79,6 +80,7 @@ export const RenderFields: React.FC<RenderFieldsProps> = (props) => {
// If the user does not have access control to begin with, force it to be read-only // If the user does not have access control to begin with, force it to be read-only
const hasOperationPermission = const hasOperationPermission =
permissions === true || permissions === true ||
permissions?.[operation] === true ||
permissions?.[parentName] === true || permissions?.[parentName] === true ||
('name' in field && ('name' in field &&
typeof permissions === 'object' && typeof permissions === 'object' &&

View File

@@ -47,13 +47,10 @@ let serverURL: string
*/ */
async function navigateToLexicalFields( async function navigateToLexicalFields(
navigateToListView: boolean = true, navigateToListView: boolean = true,
localized: boolean = false, collectionSlug: string = 'lexical-fields',
) { ) {
if (navigateToListView) { if (navigateToListView) {
const url: AdminUrlUtil = new AdminUrlUtil( const url: AdminUrlUtil = new AdminUrlUtil(serverURL, collectionSlug)
serverURL,
localized ? 'lexical-localized-fields' : 'lexical-fields',
)
await page.goto(url.list) await page.goto(url.list)
} }
@@ -932,6 +929,41 @@ 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 text
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()
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 () => { test('lexical cursor / selection should be preserved when swapping upload field and clicking within with its list drawer', async () => {
await navigateToLexicalFields() await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').first() const richTextField = page.locator('.rich-text-lexical').first()
@@ -1292,7 +1324,7 @@ describe('lexicalMain', () => {
expect(htmlContent).toContain('Start typing, or press') expect(htmlContent).toContain('Start typing, or press')
}) })
test.skip('ensure simple localized lexical field works', async () => { test.skip('ensure simple localized lexical field works', async () => {
await navigateToLexicalFields(true, true) await navigateToLexicalFields(true, 'lexical-localized-fields')
}) })
}) })

View File

@@ -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],
}),
},
],
}

View File

@@ -21,6 +21,7 @@ import GroupFields from './collections/Group/index.js'
import IndexedFields from './collections/Indexed/index.js' import IndexedFields from './collections/Indexed/index.js'
import JSONFields from './collections/JSON/index.js' import JSONFields from './collections/JSON/index.js'
import { LexicalFields } from './collections/Lexical/index.js' import { LexicalFields } from './collections/Lexical/index.js'
import { LexicalAccessControl } from './collections/LexicalAccessControl/index.js'
import { LexicalInBlock } from './collections/LexicalInBlock/index.js' import { LexicalInBlock } from './collections/LexicalInBlock/index.js'
import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js' import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js' import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
@@ -68,6 +69,7 @@ export const collectionSlugs: CollectionConfig[] = [
], ],
}, },
LexicalInBlock, LexicalInBlock,
LexicalAccessControl,
SelectVersionsFields, SelectVersionsFields,
ArrayFields, ArrayFields,
BlockFields, BlockFields,

View File

@@ -34,6 +34,7 @@ export interface Config {
lexicalObjectReferenceBug: LexicalObjectReferenceBug; lexicalObjectReferenceBug: LexicalObjectReferenceBug;
users: User; users: User;
LexicalInBlock: LexicalInBlock; LexicalInBlock: LexicalInBlock;
'lexical-access-control': LexicalAccessControl;
'select-versions-fields': SelectVersionsField; 'select-versions-fields': SelectVersionsField;
'array-fields': ArrayField; 'array-fields': ArrayField;
'block-fields': BlockField; 'block-fields': BlockField;
@@ -80,6 +81,7 @@ export interface Config {
lexicalObjectReferenceBug: LexicalObjectReferenceBugSelect<false> | LexicalObjectReferenceBugSelect<true>; lexicalObjectReferenceBug: LexicalObjectReferenceBugSelect<false> | LexicalObjectReferenceBugSelect<true>;
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
LexicalInBlock: LexicalInBlockSelect<false> | LexicalInBlockSelect<true>; LexicalInBlock: LexicalInBlockSelect<false> | LexicalInBlockSelect<true>;
'lexical-access-control': LexicalAccessControlSelect<false> | LexicalAccessControlSelect<true>;
'select-versions-fields': SelectVersionsFieldsSelect<false> | SelectVersionsFieldsSelect<true>; 'select-versions-fields': SelectVersionsFieldsSelect<false> | SelectVersionsFieldsSelect<true>;
'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>; 'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>;
'block-fields': BlockFieldsSelect<false> | BlockFieldsSelect<true>; 'block-fields': BlockFieldsSelect<false> | BlockFieldsSelect<true>;
@@ -454,6 +456,31 @@ export interface LexicalInBlock {
updatedAt: string; updatedAt: string;
createdAt: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "select-versions-fields". * via the `definition` "select-versions-fields".
@@ -469,7 +496,7 @@ export interface SelectVersionsField {
| null; | null;
blocks?: blocks?:
| { | {
hasManyArr?: ('a' | 'b' | 'c')[] | null; hasManyBlocks?: ('a' | 'b' | 'c')[] | null;
id?: string | null; id?: string | null;
blockName?: string | null; blockName?: string | null;
blockType: 'block'; blockType: 'block';
@@ -1830,6 +1857,10 @@ export interface PayloadLockedDocument {
relationTo: 'LexicalInBlock'; relationTo: 'LexicalInBlock';
value: string | LexicalInBlock; value: string | LexicalInBlock;
} | null) } | null)
| ({
relationTo: 'lexical-access-control';
value: string | LexicalAccessControl;
} | null)
| ({ | ({
relationTo: 'select-versions-fields'; relationTo: 'select-versions-fields';
value: string | SelectVersionsField; value: string | SelectVersionsField;
@@ -2104,6 +2135,16 @@ export interface LexicalInBlockSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "lexical-access-control_select".
*/
export interface LexicalAccessControlSelect<T extends boolean = true> {
title?: T;
richText?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "select-versions-fields_select". * via the `definition` "select-versions-fields_select".
@@ -2122,7 +2163,7 @@ export interface SelectVersionsFieldsSelect<T extends boolean = true> {
block?: block?:
| T | T
| { | {
hasManyArr?: T; hasManyBlocks?: T;
id?: T; id?: T;
blockName?: T; blockName?: T;
}; };

View File

@@ -495,10 +495,12 @@ export const seed = async (_payload: Payload) => {
data: { data: {
text: 'text', text: 'text',
}, },
depth: 0,
}) })
await _payload.create({ await _payload.create({
collection: 'LexicalInBlock', collection: 'LexicalInBlock',
depth: 0,
data: { data: {
content: { content: {
root: { 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([ await Promise.all([
_payload.create({ _payload.create({
collection: customIDSlug, collection: customIDSlug,
data: { data: {
id: nonStandardID, id: nonStandardID,
}, },
depth: 0,
}), }),
_payload.create({ _payload.create({
collection: customTabIDSlug, collection: customTabIDSlug,
data: { data: {
id: customTabID, id: customTabID,
}, },
depth: 0,
}), }),
_payload.create({ _payload.create({
collection: customRowIDSlug, collection: customRowIDSlug,
data: { data: {
id: customRowID, id: customRowID,
}, },
depth: 0,
}), }),
]) ])
} }

View File

@@ -17,6 +17,9 @@ export const lexicalFieldsSlug = 'lexical-fields'
export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields' export const lexicalLocalizedFieldsSlug = 'lexical-localized-fields'
export const lexicalMigrateFieldsSlug = 'lexical-migrate-fields' export const lexicalMigrateFieldsSlug = 'lexical-migrate-fields'
export const lexicalRelationshipFieldsSlug = 'lexical-relationship-fields' export const lexicalRelationshipFieldsSlug = 'lexical-relationship-fields'
export const lexicalAccessControlSlug = 'lexical-access-control'
export const numberFieldsSlug = 'number-fields' export const numberFieldsSlug = 'number-fields'
export const pointFieldsSlug = 'point-fields' export const pointFieldsSlug = 'point-fields'
export const radioFieldsSlug = 'radio-fields' export const radioFieldsSlug = 'radio-fields'
@@ -52,6 +55,7 @@ export const collectionSlugs = [
lexicalFieldsSlug, lexicalFieldsSlug,
lexicalMigrateFieldsSlug, lexicalMigrateFieldsSlug,
lexicalRelationshipFieldsSlug, lexicalRelationshipFieldsSlug,
lexicalAccessControlSlug,
numberFieldsSlug, numberFieldsSlug,
pointFieldsSlug, pointFieldsSlug,
radioFieldsSlug, radioFieldsSlug,