diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index cadd9f4be..12f18f365 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -70,6 +70,7 @@ const RichText: React.FC = (props) => { const [enabledLeaves, setEnabledLeaves] = useState({}); const [initialValueKey, setInitialValueKey] = useState(''); const editorRef = useRef(null); + const toolbarRef = useRef(null); const renderElement = useCallback(({ attributes, children, element }) => { const matchedElement = enabledElements[element?.type]; @@ -176,6 +177,31 @@ const RichText: React.FC = (props) => { setInitialValueKey(JSON.stringify(initialValue || '')); }, [initialValue]); + useEffect(() => { + function setClickableState(clickState: 'disabled' | 'enabled') { + const selectors = 'button, a, [role="button"]'; + const toolbarButtons: (HTMLButtonElement | HTMLAnchorElement)[] = toolbarRef.current.querySelectorAll(selectors); + const editorButtons: (HTMLButtonElement | HTMLAnchorElement)[] = editorRef.current.querySelectorAll(selectors); + + [...(toolbarButtons || []), ...(editorButtons || [])].forEach((child) => { + const isButton = child.tagName === 'BUTTON'; + const isDisabling = clickState === 'disabled'; + child.setAttribute('tabIndex', isDisabling ? '-1' : '0'); + if (isButton) child.setAttribute('disabled', isDisabling ? 'disabled' : null); + }); + } + + if (loaded && readOnly) { + setClickableState('disabled'); + } + + return () => { + if (loaded && readOnly) { + setClickableState('enabled'); + } + }; + }, [loaded, readOnly]); + if (!loaded) { return null; } @@ -215,13 +241,16 @@ const RichText: React.FC = (props) => { editor={editor} value={valueToRender as any[]} onChange={(val) => { - if (val !== defaultValue && val !== value) { + if (!readOnly && val !== defaultValue && val !== value) { setValue(val); } }} >
-
+
{elements.map((element, i) => { let elementName: string; diff --git a/src/admin/components/forms/field-types/RichText/index.scss b/src/admin/components/forms/field-types/RichText/index.scss index 730fbe195..8b72848c5 100644 --- a/src/admin/components/forms/field-types/RichText/index.scss +++ b/src/admin/components/forms/field-types/RichText/index.scss @@ -3,6 +3,7 @@ .rich-text { margin-bottom: base(2); display: flex; + isolation: isolate; &__toolbar { @include blur-bg(var(--theme-elevation-0)); @@ -103,6 +104,29 @@ .rich-text__editor { background-color: var(--theme-elevation-150); padding: base(.5); + + .popup button { + display: none; + } + } + + .rich-text__toolbar { + pointer-events: none; + position: relative; + top: 0; + + &::after { + content: ' '; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--theme-elevation-150); + opacity: .85; + z-index: 2; + backdrop-filter: unset; + } } } diff --git a/src/admin/components/forms/field-types/Select/index.tsx b/src/admin/components/forms/field-types/Select/index.tsx index 246066eb0..6a8946d21 100644 --- a/src/admin/components/forms/field-types/Select/index.tsx +++ b/src/admin/components/forms/field-types/Select/index.tsx @@ -95,6 +95,7 @@ const Select: React.FC = (props) => { showError={showError} errorMessage={errorMessage} required={required} + readOnly={readOnly} description={description} style={style} className={className} diff --git a/test/fields/collections/RichText/index.ts b/test/fields/collections/RichText/index.ts index 579d3ea86..5c0d04b71 100644 --- a/test/fields/collections/RichText/index.ts +++ b/test/fields/collections/RichText/index.ts @@ -73,12 +73,46 @@ const RichTextFields: CollectionConfig = { }, }, }, + { + name: 'richTextReadOnly', + type: 'richText', + admin: { + readOnly: true, + link: { + fields: [ + { + name: 'rel', + label: 'Rel Attribute', + type: 'select', + hasMany: true, + options: [ + 'noopener', 'noreferrer', 'nofollow', + ], + admin: { + description: 'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.', + }, + }, + ], + }, + upload: { + collections: { + uploads: { + fields: [ + { + name: 'caption', + type: 'richText', + }, + ], + }, + }, + }, + }, + }, ], }; -export const richTextDoc = { - selectHasMany: ['one', 'five'], - richText: [ +function generateRichText() { + return [ { children: [ { @@ -220,7 +254,13 @@ export const richTextDoc = { ], }; }), - ], + ]; +} + +export const richTextDoc = { + selectHasMany: ['one', 'five'], + richText: generateRichText(), + richTextReadOnly: generateRichText(), }; export default RichTextFields; diff --git a/test/fields/collections/Select/index.ts b/test/fields/collections/Select/index.ts index abf04f599..aeb0919f9 100644 --- a/test/fields/collections/Select/index.ts +++ b/test/fields/collections/Select/index.ts @@ -24,6 +24,27 @@ const SelectFields: CollectionConfig = { }, ], }, + { + name: 'selectReadOnly', + type: 'select', + admin: { + readOnly: true, + }, + options: [ + { + label: 'Value One', + value: 'one', + }, + { + label: 'Value Two', + value: 'two', + }, + { + label: 'Value Three', + value: 'three', + }, + ], + }, { name: 'selectHasMany', hasMany: true, diff --git a/test/fields/config.ts b/test/fields/config.ts index f0d6b3ae7..b014ebac1 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -92,6 +92,7 @@ export default buildConfig({ const richTextUploadIndex = richTextDocWithRelationship.richText.findIndex(({ type }) => type === 'upload'); richTextDocWithRelationship.richText[richTextUploadIndex].value = { id: createdUploadDoc.id }; + richTextDocWithRelationship.richTextReadOnly[richTextUploadIndex].value = { id: createdUploadDoc.id }; await payload.create({ collection: 'rich-text-fields', data: richTextDocWithRelationship }); diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index a35919090..2d8c36f7d 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -143,73 +143,84 @@ describe('fields', () => { }); describe('fields - richText', () => { - test('should create new url link', async () => { + async function navigateToRichTextFields() { const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields'); await page.goto(url.list); await page.locator('.row-1 .cell-id').click(); + } - // Open link popup - await page.locator('.rich-text__toolbar .link').click(); + describe('toolbar', () => { + test('should create new url link', async () => { + await navigateToRichTextFields(); - const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); - await expect(editLinkModal).toBeVisible(); + // Open link popup + await page.locator('.rich-text__toolbar button:not([disabled]) .link').click(); - // Fill values and click Confirm - await editLinkModal.locator('#field-text').fill('link text'); - await editLinkModal.locator('label[for="field-linkType-custom"]').click(); - await editLinkModal.locator('#field-url').fill('https://payloadcms.com'); - await wait(200); - await editLinkModal.locator('button[type="submit"]').click(); + const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); + await expect(editLinkModal).toBeVisible(); - // Remove link - await page.locator('span >> text="link text"').click(); - const popup = page.locator('.popup--active .rich-text-link__popup'); - await expect(popup.locator('.rich-text-link__link-label')).toBeVisible(); - await popup.locator('.rich-text-link__link-close').click(); - await expect(page.locator('span >> text="link text"')).toHaveCount(0); + // Fill values and click Confirm + await editLinkModal.locator('#field-text').fill('link text'); + await editLinkModal.locator('label[for="field-linkType-custom"]').click(); + await editLinkModal.locator('#field-url').fill('https://payloadcms.com'); + await wait(200); + await editLinkModal.locator('button[type="submit"]').click(); + + // Remove link from editor body + await page.locator('span >> text="link text"').click(); + const popup = page.locator('.popup--active .rich-text-link__popup'); + await expect(popup.locator('.rich-text-link__link-label')).toBeVisible(); + await popup.locator('.rich-text-link__link-close').click(); + await expect(page.locator('span >> text="link text"')).toHaveCount(0); + }); + + test('should not create new url link when read only', async () => { + await navigateToRichTextFields(); + + // Attempt to open link popup + const modalTrigger = page.locator('.rich-text--read-only .rich-text__toolbar button .link'); + await expect(modalTrigger).toBeDisabled(); + }); }); - test('should populate url link', async () => { - const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields'); - await page.goto(url.list); - await page.locator('.row-1 .cell-id').click(); + describe('editor', () => { + test('should populate url link', async () => { + navigateToRichTextFields(); - // Open link popup - await page.locator('span >> text="render links"').click(); - const popup = page.locator('.popup--active .rich-text-link__popup'); - await expect(popup).toBeVisible(); - await expect(popup.locator('a')).toHaveAttribute('href', 'https://payloadcms.com'); + // Open link popup + await page.locator('#field-richText span >> text="render links"').click(); + const popup = page.locator('.popup--active .rich-text-link__popup'); + await expect(popup).toBeVisible(); + await expect(popup.locator('a')).toHaveAttribute('href', 'https://payloadcms.com'); - // Open link edit modal - await popup.locator('.rich-text-link__link-edit').click(); - const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); - await expect(editLinkModal).toBeVisible(); + // Open link edit modal + await popup.locator('.rich-text-link__link-edit').click(); + const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); + await expect(editLinkModal).toBeVisible(); - // Close link edit modal - await editLinkModal.locator('button[type="submit"]').click(); - await expect(editLinkModal).not.toBeVisible(); - }); + // Close link edit modal + await editLinkModal.locator('button[type="submit"]').click(); + await expect(editLinkModal).not.toBeVisible(); + }); - test('should populate relationship link', async () => { - const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields'); - await page.goto(url.list); - await page.locator('.row-1 .cell-id').click(); + test('should populate relationship link', async () => { + navigateToRichTextFields(); - // Open link popup - await page.locator('span >> text="link to relationships"').click(); - const popup = page.locator('.popup--active .rich-text-link__popup'); - await expect(popup).toBeVisible(); - await expect(popup.locator('a')).toHaveAttribute('href', /\/admin\/collections\/array-fields\/.*/); + // Open link popup + await page.locator('#field-richText span >> text="link to relationships"').click(); + const popup = page.locator('.popup--active .rich-text-link__popup'); + await expect(popup).toBeVisible(); + await expect(popup.locator('a')).toHaveAttribute('href', /\/admin\/collections\/array-fields\/.*/); - // Open link edit modal - await popup.locator('.rich-text-link__link-edit').click(); - const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); - await expect(editLinkModal).toBeVisible(); + // Open link edit modal + await popup.locator('.rich-text-link__link-edit').click(); + const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); + await expect(editLinkModal).toBeVisible(); - // Close link edit modal - await editLinkModal.locator('button[type="submit"]').click(); - await expect(editLinkModal).not.toBeVisible(); - // await page.locator('span >> text="render links"').click(); + // Close link edit modal + await editLinkModal.locator('button[type="submit"]').click(); + await expect(editLinkModal).not.toBeVisible(); + }); }); }); });