feat(richtext-lexical)!: change link fields handling (#6162)

**BREAKING:**
- Drawer fields are no longer wrapped in a `fields` group. This might be breaking if you depend on them being in a field group in any way - potentially if you use custom link fields. This does not change how the data is saved
- If you pass in an array of custom fields to the link feature, those were previously added to the base fields. Now, they completely replace the base fields for consistency. If you want to ADD fields to the base fields now, you will have to pass in a function and spread `defaultFields` - similar to how adding your own features to lexical works

**Example Migration for ADDING fields to the link base fields:**

**Previous:**
```ts
 LinkFeature({
    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.',
        },
      },
    ],
  }),
```

**Now:**
```ts
 LinkFeature({
    fields: ({ defaultFields }) => [
      ...defaultFields,
      {
        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.',
        },
      },
    ],
  }),
This commit is contained in:
Alessio Gravili
2024-04-30 23:01:08 -04:00
committed by GitHub
parent d9bb51fdc7
commit 5a82f34801
11 changed files with 112 additions and 124 deletions

View File

@@ -32,25 +32,13 @@ export const getBaseFields = (
.map(({ slug }) => slug)
}
const baseFields = [
const baseFields: Field[] = [
{
name: 'text',
type: 'text',
label: ({ t }) => t('fields:textToDisplay'),
required: true,
},
{
name: 'fields',
type: 'group',
admin: {
style: {
borderBottom: 0,
borderTop: 0,
margin: 0,
padding: 0,
},
},
fields: [
{
name: 'linkType',
type: 'radio',
@@ -66,7 +54,7 @@ export const getBaseFields = (
},
],
required: true,
},
} as RadioField,
{
name: 'url',
type: 'text',
@@ -78,25 +66,23 @@ export const getBaseFields = (
}
},
},
] as Field[],
},
]
// Only display internal link-specific fields / options / conditions if there are enabled relations
if (enabledRelations?.length) {
;(baseFields[1].fields[0] as RadioField).options.push({
;(baseFields[1] as RadioField).options.push({
label: ({ t }) => t('fields:internalLink'),
value: 'internal',
})
;(baseFields[1].fields[1] as TextField).admin = {
condition: ({ fields }) => fields?.linkType !== 'internal',
;(baseFields[2] as TextField).admin = {
condition: ({ linkType }) => linkType !== 'internal',
}
baseFields[1].fields.push({
baseFields.push({
name: 'doc',
admin: {
condition: ({ fields }) => {
return fields?.linkType === 'internal'
condition: ({ linkType }) => {
return linkType === 'internal'
},
},
// when admin.hidden is a function we need to dynamically call hidden with the user to know if the collection should be shown
@@ -116,7 +102,7 @@ export const getBaseFields = (
})
}
baseFields[1].fields.push({
baseFields.push({
name: 'newTab',
type: 'checkbox',
label: ({ t }) => t('fields:openInNewTab'),

View File

@@ -1,9 +1,9 @@
import type { FormState } from 'payload/types'
import type { LinkPayload } from '../plugins/floatingLinkEditor/types.js'
import type { LinkFields } from '../nodes/types.js'
export interface Props {
drawerSlug: string
handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void
stateData?: LinkPayload
stateData?: LinkFields & { text: string }
}

View File

@@ -23,6 +23,7 @@ import {
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { LinkNode } from '../../../nodes/LinkNode.js'
import type { LinkFields } from '../../../nodes/types.js'
import type { LinkPayload } from '../types.js'
import { useEditorConfigContext } from '../../../../../lexical/config/client/EditorConfigProvider.js'
@@ -40,8 +41,8 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
const [editor] = useLexicalComposerContext()
const editorRef = useRef<HTMLDivElement | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const [linkLabel, setLinkLabel] = useState('')
const [linkUrl, setLinkUrl] = useState(null)
const [linkLabel, setLinkLabel] = useState(null)
const { uuid } = useEditorConfigContext()
@@ -49,7 +50,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
const { i18n, t } = useTranslation()
const [stateData, setStateData] = useState<LinkPayload>(null)
const [stateData, setStateData] = useState<LinkFields & { text: string }>(null)
const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth()
@@ -88,27 +89,25 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
if (focusLinkParent == null || badNode) {
setIsLink(false)
setIsAutoLink(false)
setLinkUrl('')
setLinkLabel('')
setLinkUrl(null)
setLinkLabel(null)
setSelectedNodes([])
return
}
// Initial state:
const data: LinkPayload = {
fields: {
const data: LinkFields & { text: string } = {
doc: undefined,
linkType: undefined,
newTab: undefined,
url: '',
...focusLinkParent.getFields(),
},
text: focusLinkParent.getTextContent(),
}
if (focusLinkParent.getFields()?.linkType === 'custom') {
setLinkUrl(focusLinkParent.getFields()?.url ?? '')
setLinkLabel('')
setLinkUrl(focusLinkParent.getFields()?.url ?? null)
setLinkLabel(null)
} else {
// internal link
setLinkUrl(
@@ -120,11 +119,22 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
const relatedField = config.collections.find(
(coll) => coll.slug === focusLinkParent.getFields()?.doc?.relationTo,
)
if (!relatedField) {
// Usually happens if the user removed all default fields. In this case, we let them specify the label or do not display the label at all.
// label could be a virtual field the user added. This is useful if they want to use the link feature for things other than links.
setLinkLabel(
focusLinkParent.getFields()?.label ? String(focusLinkParent.getFields()?.label) : null,
)
setLinkUrl(
focusLinkParent.getFields()?.url ? String(focusLinkParent.getFields()?.url) : null,
)
} else {
const label = t('fields:linkedTo', {
label: getTranslation(relatedField.labels.singular, i18n),
}).replace(/<[^>]*>?/g, '')
setLinkLabel(label)
}
}
setStateData(data)
setIsLink(true)
@@ -167,8 +177,8 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
if (rootElement !== null) {
setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem)
}
setLinkUrl('')
setLinkLabel('')
setLinkUrl(null)
setLinkLabel(null)
}
return true
@@ -264,9 +274,14 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
<React.Fragment>
<div className="link-editor" ref={editorRef}>
<div className="link-input">
{linkUrl && linkUrl.length > 0 ? (
<a href={linkUrl} rel="noopener noreferrer" target="_blank">
{linkLabel != null && linkLabel.length > 0 ? linkLabel : linkUrl}
</a>
) : linkLabel != null && linkLabel.length > 0 ? (
<span className="link-input__label-pure">{linkLabel}</span>
) : null}
{editor.isEditable() && (
<React.Fragment>
<button
@@ -304,9 +319,12 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
handleModalSubmit={(fields: FormState, data: Data) => {
closeModal(drawerSlug)
const newLinkPayload: LinkPayload = data as LinkPayload
const newLinkPayload = data as LinkFields & { text: string }
newLinkPayload.selectedNodes = selectedNodes
const bareLinkFields: LinkFields = {
...newLinkPayload,
}
delete bareLinkFields.text
// See: https://github.com/facebook/lexical/pull/5536. This updates autolink nodes to link nodes whenever a change was made (which is good!).
editor.update(() => {
@@ -322,7 +340,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
if (linkParent && $isAutoLinkNode(linkParent)) {
const linkNode = $createLinkNode({
fields: newLinkPayload.fields,
fields: bareLinkFields,
})
linkParent.replace(linkNode, true)
}
@@ -330,7 +348,11 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
// Needs to happen AFTER a potential auto link => link node conversion, as otherwise, the updated text to display may be lost due to
// it being applied to the auto link node instead of the link node.
editor.dispatchCommand(TOGGLE_LINK_COMMAND, newLinkPayload)
editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
fields: bareLinkFields,
selectedNodes,
text: newLinkPayload.text,
})
}}
stateData={stateData}
/>

View File

@@ -36,12 +36,20 @@ html[data-theme='light'] {
position: relative;
font-family: var(--font-body);
&__label-pure {
color: var(--color-base-1000);
margin-right: 15px;
display: block;
white-space: nowrap;
overflow: hidden;
}
a {
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
margin-right: 30px;
margin-right: 15px;
text-overflow: ellipsis;
color: var(--color-blue-600);
border-bottom: 1px dotted;

View File

@@ -1,12 +1,11 @@
import type { SanitizedConfig } from 'payload/config'
import type { Field, FieldWithRichTextRequiredEditor, GroupField } from 'payload/types'
import type { FieldWithRichTextRequiredEditor } from 'payload/types'
import { getBaseFields } from '../../drawer/baseFields.js'
/**
* This function is run to enrich the basefields which every link has with potential, custom user-added fields.
*/
// eslint-disable-next-line @typescript-eslint/require-await
export function transformExtraFields(
customFieldSchema:
| ((args: {
@@ -17,50 +16,22 @@ export function transformExtraFields(
config: SanitizedConfig,
enabledCollections?: false | string[],
disabledCollections?: false | string[],
): Field[] {
): FieldWithRichTextRequiredEditor[] {
const baseFields: FieldWithRichTextRequiredEditor[] = getBaseFields(
config,
enabledCollections,
disabledCollections,
)
const fields =
typeof customFieldSchema === 'function'
? customFieldSchema({ config, defaultFields: baseFields })
: baseFields
let fields: FieldWithRichTextRequiredEditor[]
// Wrap fields which are not part of the base schema in a group named 'fields' - otherwise they will be rendered but not saved
const extraFields = []
for (let i = fields.length - 1; i >= 0; i--) {
const field = fields[i]
if ('name' in field) {
if (
!baseFields.find((baseField) => !('name' in baseField) || baseField.name === field.name)
) {
if (field.name !== 'fields' && field.type !== 'group') {
extraFields.push(field)
// Remove from fields from now, as they need to be part of the fields group below
fields.splice(fields.indexOf(field), 1)
}
}
}
if (typeof customFieldSchema === 'function') {
fields = customFieldSchema({ config, defaultFields: baseFields })
} else if (Array.isArray(customFieldSchema)) {
fields = customFieldSchema
} else {
fields = baseFields
}
if (Array.isArray(customFieldSchema) || fields.length > 0) {
// find field with name 'fields' and add the extra fields to it
const fieldsField: GroupField = fields.find(
(field) => field.type === 'group' && field.name === 'fields',
) as GroupField
if (!fieldsField) {
throw new Error(
'Could not find field with name "fields". This is required to add fields to the link field.',
)
}
fieldsField.fields = Array.isArray(fieldsField.fields) ? fieldsField.fields : []
fieldsField.fields.push(
...(Array.isArray(customFieldSchema) ? customFieldSchema.concat(extraFields) : extraFields),
)
}
return fields
}

View File

@@ -32,9 +32,7 @@ export const linkPopulationPromiseHOC = (
recurseNestedFields({
context,
currentDepth,
data: {
fields: node.fields,
},
data: node.fields,
depth,
editorPopulationPromises,
fieldPromises,
@@ -45,9 +43,7 @@ export const linkPopulationPromiseHOC = (
populationPromises,
req,
showHiddenFields,
siblingDoc: {
fields: node.fields,
},
siblingDoc: node.fields,
})
}
}

View File

@@ -82,7 +82,8 @@ export async function buildConfigWithDefaults(
ParagraphFeature(),
RelationshipFeature(),
LinkFeature({
fields: [
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'description',
type: 'text',

View File

@@ -520,7 +520,7 @@ describe('lexical', () => {
const drawerContent = page.locator('.drawer__content').first()
await expect(drawerContent).toBeVisible()
const urlField = drawerContent.locator('input#field-fields__url').first()
const urlField = drawerContent.locator('input#field-url').first()
await expect(urlField).toBeVisible()
// Fill with https://www.payloadcms.com
await urlField.fill('https://www.payloadcms.com')

View File

@@ -72,7 +72,8 @@ export const LexicalFields: CollectionConfig = {
TreeViewFeature(),
//HTMLConverterFeature(),
LinkFeature({
fields: [
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',

View File

@@ -39,7 +39,8 @@ export const LexicalMigrateFields: CollectionConfig = {
TreeViewFeature(),
HTMLConverterFeature(),
LinkFeature({
fields: [
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',
@@ -79,7 +80,8 @@ export const LexicalMigrateFields: CollectionConfig = {
TreeViewFeature(),
HTMLConverterFeature(),
LinkFeature({
fields: [
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',

View File

@@ -38,7 +38,8 @@ const RichTextFields: CollectionConfig = {
TreeViewFeature(),
HTMLConverterFeature({}),
LinkFeature({
fields: [
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',