feat(richtext-lexical)!: various validation improvement (#6163)

BREAKING: this will now display errors if you're previously had invalid link or upload fields data - for example if you have a required field added to an uploads node and did not provide a value to it every time you've added an upload node
This commit is contained in:
Alessio Gravili
2024-05-01 11:33:02 -04:00
committed by GitHub
13 changed files with 113 additions and 28 deletions

View File

@@ -243,6 +243,7 @@ jobs:
- fields__collections__Blocks - fields__collections__Blocks
- fields__collections__Array - fields__collections__Array
- fields__collections__Relationship - fields__collections__Relationship
- fields__collections__RichText
- fields__collections__Lexical - fields__collections__Lexical
- live-preview - live-preview
- localization - localization

View File

@@ -227,6 +227,8 @@ export const buildFormState = async ({ req }: { req: PayloadRequestWithData }) =
status: httpStatus.OK, status: httpStatus.OK,
}) })
} catch (err) { } catch (err) {
req.payload.logger.error({ err, msg: `There was an error building form state` })
return routeError({ return routeError({
config: req.payload.config, config: req.payload.config,
err, err,

View File

@@ -2,13 +2,13 @@ import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
import type { NodeValidation } from '../types.js' import type { NodeValidation } from '../types.js'
import type { BlocksFeatureProps } from './feature.server.js' import type { BlocksFeatureProps } from './feature.server.js'
import type { SerializedBlockNode } from './nodes/BlocksNode.js' import type { BlockFields, SerializedBlockNode } from './nodes/BlocksNode.js'
export const blockValidationHOC = ( export const blockValidationHOC = (
props: BlocksFeatureProps, props: BlocksFeatureProps,
): NodeValidation<SerializedBlockNode> => { ): NodeValidation<SerializedBlockNode> => {
return async ({ node, validation }) => { return async ({ node, validation }) => {
const blockFieldData = node.fields const blockFieldData = node.fields ?? ({} as BlockFields)
const { const {
options: { id, operation, preferences, req }, options: { id, operation, preferences, req },

View File

@@ -15,6 +15,7 @@ import { AutoLinkNode } from './nodes/AutoLinkNode.js'
import { LinkNode } from './nodes/LinkNode.js' import { LinkNode } from './nodes/LinkNode.js'
import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js' import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js'
import { linkPopulationPromiseHOC } from './populationPromise.js' import { linkPopulationPromiseHOC } from './populationPromise.js'
import { linkValidation } from './validate.js'
export type ExclusiveLinkCollectionsProps = export type ExclusiveLinkCollectionsProps =
| { | {
@@ -143,6 +144,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
}, },
node: AutoLinkNode, node: AutoLinkNode,
populationPromises: [linkPopulationPromiseHOC(props)], populationPromises: [linkPopulationPromiseHOC(props)],
validations: [linkValidation(props)],
}), }),
createNode({ createNode({
converters: { converters: {
@@ -172,6 +174,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
}, },
node: LinkNode, node: LinkNode,
populationPromises: [linkPopulationPromiseHOC(props)], populationPromises: [linkPopulationPromiseHOC(props)],
validations: [linkValidation(props)],
}), }),
], ],
serverFeatureProps: props, serverFeatureProps: props,

View File

@@ -27,13 +27,6 @@ export function LinkPlugin(): null {
editor.registerCommand( editor.registerCommand(
TOGGLE_LINK_COMMAND, TOGGLE_LINK_COMMAND,
(payload: LinkPayload) => { (payload: LinkPayload) => {
// validate
if (payload?.fields.linkType === 'custom') {
if (!(validateUrl === undefined || validateUrl(payload?.fields.url))) {
return false
}
}
toggleLink(payload) toggleLink(payload)
return true return true
}, },

View File

@@ -0,0 +1,51 @@
import type { FieldWithRichTextRequiredEditor } from 'payload/types'
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
import type { NodeValidation } from '../types.js'
import type { LinkFeatureServerProps } from './feature.server.js'
import type { SerializedAutoLinkNode, SerializedLinkNode } from './nodes/types.js'
export const linkValidation = (
props: LinkFeatureServerProps,
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
): NodeValidation<SerializedAutoLinkNode | SerializedLinkNode> => {
return async ({
node,
validation: {
options: { id, operation, preferences, req },
},
}) => {
/**
* Run buildStateFromSchema as that properly validates link fields and link sub-fields
*/
const data = {
...node.fields,
text: 'ignored',
}
const result = await buildStateFromSchema({
id,
data,
fieldSchema: props.fields as FieldWithRichTextRequiredEditor[], // Sanitized in feature.server.ts
operation: operation === 'create' || operation === 'update' ? operation : 'update',
preferences,
req,
siblingData: data,
})
let errorPaths = []
for (const fieldKey in result) {
if (result[fieldKey].errorPaths) {
errorPaths = errorPaths.concat(result[fieldKey].errorPaths)
}
}
if (errorPaths.length) {
return 'Link fields validation failed: ' + errorPaths.join(', ')
}
return true
}
}

View File

@@ -175,7 +175,7 @@ export const UploadFeature: FeatureProviderProviderServer<
}, },
node: UploadNode, node: UploadNode,
populationPromises: [uploadPopulationPromiseHOC(props)], populationPromises: [uploadPopulationPromiseHOC(props)],
validations: [uploadValidation()], validations: [uploadValidation(props)],
}), }),
], ],
serverFeatureProps: props, serverFeatureProps: props,

View File

@@ -1,30 +1,63 @@
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
import { isValidID } from 'payload/utilities' import { isValidID } from 'payload/utilities'
import type { NodeValidation } from '../types.js' import type { NodeValidation } from '../types.js'
import type { UploadFeatureProps } from './feature.server.js'
import type { SerializedUploadNode } from './nodes/UploadNode.js' import type { SerializedUploadNode } from './nodes/UploadNode.js'
import { CAN_USE_DOM } from '../../lexical/utils/canUseDOM.js' export const uploadValidation = (
props: UploadFeatureProps,
export const uploadValidation = (): NodeValidation<SerializedUploadNode> => { ): NodeValidation<SerializedUploadNode> => {
return ({ return async ({
node, node,
validation: { validation: {
options: { options: {
id,
operation,
preferences,
req,
req: { payload, t }, req: { payload, t },
}, },
}, },
}) => { }) => {
if (!CAN_USE_DOM) { const idType = payload.collections[node.relationTo].customIDType || payload.db.defaultIDType
const idType = payload.collections[node.relationTo].customIDType || payload.db.defaultIDType // @ts-expect-error
// @ts-expect-error const nodeID = node?.value?.id || node?.value // for backwards-compatibility
const id = node?.value?.id || node?.value // for backwards-compatibility
if (!isValidID(id, idType)) { if (!isValidID(nodeID, idType)) {
return t('validation:validUploadID') return t('validation:validUploadID')
}
if (Object.keys(props?.collections).length === 0) {
return true
}
const collection = props?.collections[node.relationTo]
if (!collection.fields?.length) {
return true
}
const result = await buildStateFromSchema({
id,
data: node?.fields ?? {},
fieldSchema: collection.fields,
operation: operation === 'create' || operation === 'update' ? operation : 'update',
preferences,
req,
siblingData: node?.fields ?? {},
})
let errorPaths = []
for (const fieldKey in result) {
if (result[fieldKey].errorPaths) {
errorPaths = errorPaths.concat(result[fieldKey].errorPaths)
} }
} }
// TODO: validate upload collection fields if (errorPaths.length) {
return 'Upload fields validation failed: ' + errorPaths.join(', ')
}
return true return true
} }

View File

@@ -17,6 +17,8 @@ export function sanitizeUrl(url: string): string {
const urlRegExp = const urlRegExp =
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z\d.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z\d.-]+)((?:\/[+~%/.\w-]*)?\??[-+=&;%@.\w]*#?\w*)?)/ /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z\d.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z\d.-]+)((?:\/[+~%/.\w-]*)?\??[-+=&;%@.\w]*#?\w*)?)/
// Do not keep validateUrl function too loose. This is run when pasting in text, to determine if links are in that text and if it should create AutoLinkNodes.
// This is why we do not allow stuff like anchors here, as we don't want copied anchors to be turned into AutoLinkNodes.
export function validateUrl(url: string): boolean { export function validateUrl(url: string): boolean {
// TODO Fix UI for link insertion; it should never default to an invalid URL such as https://. // TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
// Maybe show a dialog where they user can type the URL before inserting it. // Maybe show a dialog where they user can type the URL before inserting it.

View File

@@ -1,5 +1,4 @@
import type { I18n } from '@payloadcms/translations' import type { SanitizedConfig } from 'payload/config'
import type { Config, SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types' import type { Field } from 'payload/types'
import type { Editor } from 'slate' import type { Editor } from 'slate'
@@ -64,7 +63,7 @@ export function transformExtraFields(
} }
} }
if (Array.isArray(customFieldSchema) || fields.length > 0) { if ((Array.isArray(customFieldSchema) && customFieldSchema?.length) || extraFields?.length) {
fields.push({ fields.push({
name: 'fields', name: 'fields',
type: 'group', type: 'group',

View File

@@ -33,6 +33,7 @@ export function textToLexicalJSON({
direction: 'ltr', direction: 'ltr',
format: '', format: '',
indent: 0, indent: 0,
textFormat: 0,
type: 'paragraph', type: 'paragraph',
version: 1, version: 1,
} as SerializedParagraphNode, } as SerializedParagraphNode,

View File

@@ -1,3 +1,4 @@
import { textFieldsSlug } from '../../slugs.js'
import { loremIpsum } from './loremIpsum.js' import { loremIpsum } from './loremIpsum.js'
export function generateLexicalRichText() { export function generateLexicalRichText() {
@@ -90,8 +91,8 @@ export function generateLexicalRichText() {
fields: { fields: {
url: 'https://', url: 'https://',
doc: { doc: {
value: '{{ARRAY_DOC_ID}}', value: '{{TEXT_DOC_ID}}',
relationTo: 'array-fields', relationTo: textFieldsSlug,
}, },
newTab: false, newTab: false,
linkType: 'internal', linkType: 'internal',

View File

@@ -13,7 +13,6 @@ import type { LexicalField, LexicalMigrateField, RichTextField } from './payload
import { devUser } from '../credentials.js' import { devUser } from '../credentials.js'
import { NextRESTClient } from '../helpers/NextRESTClient.js' import { NextRESTClient } from '../helpers/NextRESTClient.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js' import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { arrayDoc } from './collections/Array/shared.js'
import { lexicalDocData } from './collections/Lexical/data.js' import { lexicalDocData } from './collections/Lexical/data.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js' import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { richTextDocData } from './collections/RichText/data.js' import { richTextDocData } from './collections/RichText/data.js'
@@ -172,7 +171,7 @@ describe('Lexical', () => {
const linkNode: SerializedLinkNode = (lexical.root.children[1] as SerializedParagraphNode) const linkNode: SerializedLinkNode = (lexical.root.children[1] as SerializedParagraphNode)
.children[3] as SerializedLinkNode .children[3] as SerializedLinkNode
expect(linkNode.fields.doc.value.items[1].text).toStrictEqual(arrayDoc.items[1].text) expect(linkNode.fields.doc.value.text).toStrictEqual(textDoc.text)
}) })
it('should populate relationship node', async () => { it('should populate relationship node', async () => {