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:
@@ -32,7 +32,7 @@ export const getBaseFields = (
|
||||
.map(({ slug }) => slug)
|
||||
}
|
||||
|
||||
const baseFields = [
|
||||
const baseFields: Field[] = [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
@@ -40,63 +40,49 @@ export const getBaseFields = (
|
||||
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
|
||||
@@ -116,7 +102,7 @@ export const getBaseFields = (
|
||||
})
|
||||
}
|
||||
|
||||
baseFields[1].fields.push({
|
||||
baseFields.push({
|
||||
name: 'newTab',
|
||||
type: 'checkbox',
|
||||
label: ({ t }) => t('fields:openInNewTab'),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
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(
|
||||
@@ -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)
|
||||
@@ -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">
|
||||
<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
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ export async function buildConfigWithDefaults(
|
||||
ParagraphFeature(),
|
||||
RelationshipFeature(),
|
||||
LinkFeature({
|
||||
fields: [
|
||||
fields: ({ defaultFields }) => [
|
||||
...defaultFields,
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -72,7 +72,8 @@ export const LexicalFields: CollectionConfig = {
|
||||
TreeViewFeature(),
|
||||
//HTMLConverterFeature(),
|
||||
LinkFeature({
|
||||
fields: [
|
||||
fields: ({ defaultFields }) => [
|
||||
...defaultFields,
|
||||
{
|
||||
name: 'rel',
|
||||
label: 'Rel Attribute',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -38,7 +38,8 @@ const RichTextFields: CollectionConfig = {
|
||||
TreeViewFeature(),
|
||||
HTMLConverterFeature({}),
|
||||
LinkFeature({
|
||||
fields: [
|
||||
fields: ({ defaultFields }) => [
|
||||
...defaultFields,
|
||||
{
|
||||
name: 'rel',
|
||||
label: 'Rel Attribute',
|
||||
|
||||
Reference in New Issue
Block a user