Skip to content

Commit

Permalink
feat(richtext-lexical)!: change link fields handling (#6162)
Browse files Browse the repository at this point in the history
**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.',
        },
      },
    ],
  }),
  • Loading branch information
AlessioGr committed May 1, 2024
1 parent d9bb51f commit 5a82f34
Show file tree
Hide file tree
Showing 11 changed files with 112 additions and 124 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,71 +32,57 @@ 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',
name: 'linkType',
type: 'radio',
admin: {
style: {
borderBottom: 0,
borderTop: 0,
margin: 0,
padding: 0,
},
description: ({ t }) => t('fields:chooseBetweenCustomTextOrDocument'),
},
fields: [
defaultValue: 'custom',
label: ({ t }) => t('fields:linkType'),
options: [
{
name: 'linkType',
type: 'radio',
admin: {
description: ({ t }) => t('fields:chooseBetweenCustomTextOrDocument'),
},
defaultValue: 'custom',
label: ({ t }) => t('fields:linkType'),
options: [
{
label: ({ t }) => t('fields:customURL'),
value: 'custom',
},
],
required: true,
label: ({ t }) => t('fields:customURL'),
value: 'custom',
},
{
name: 'url',
type: 'text',
label: ({ t }) => t('fields:enterURL'),
required: true,
validate: (value: string) => {
if (!validateUrl(value)) {
return 'Invalid URL'
}
},
},
] as Field[],
],
required: true,
} as RadioField,
{
name: 'url',
type: 'text',
label: ({ t }) => t('fields:enterURL'),
required: true,
validate: (value: string) => {
if (!validateUrl(value)) {
return 'Invalid URL'
}
},
},
]

// 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
Expand All @@ -116,7 +102,7 @@ export const getBaseFields = (
})
}

baseFields[1].fields.push({
baseFields.push({
name: 'newTab',
type: 'checkbox',
label: ({ t }) => t('fields:openInNewTab'),
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -40,16 +41,16 @@ 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()

const config = useConfig()

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()
Expand Down Expand Up @@ -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: {
doc: undefined,
linkType: undefined,
newTab: undefined,
url: '',
...focusLinkParent.getFields(),
},
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(
Expand All @@ -120,10 +119,21 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
const relatedField = config.collections.find(
(coll) => coll.slug === focusLinkParent.getFields()?.doc?.relationTo,
)
const label = t('fields:linkedTo', {
label: getTranslation(relatedField.labels.singular, i18n),
}).replace(/<[^>]*>?/g, '')
setLinkLabel(label)
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -264,9 +274,14 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
<React.Fragment>
<div className="link-editor" ref={editorRef}>
<div className="link-input">
<a href={linkUrl} rel="noopener noreferrer" target="_blank">
{linkLabel != null && linkLabel.length > 0 ? linkLabel : linkUrl}
</a>
{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
Expand Down Expand Up @@ -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(() => {
Expand All @@ -322,15 +340,19 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R

if (linkParent && $isAutoLinkNode(linkParent)) {
const linkNode = $createLinkNode({
fields: newLinkPayload.fields,
fields: bareLinkFields,
})
linkParent.replace(linkNode, true)
}
})

// 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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ export const linkPopulationPromiseHOC = (
recurseNestedFields({
context,
currentDepth,
data: {
fields: node.fields,
},
data: node.fields,
depth,
editorPopulationPromises,
fieldPromises,
Expand All @@ -45,9 +43,7 @@ export const linkPopulationPromiseHOC = (
populationPromises,
req,
showHiddenFields,
siblingDoc: {
fields: node.fields,
},
siblingDoc: node.fields,
})
}
}
Expand Down

0 comments on commit 5a82f34

Please sign in to comment.