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:
1
.github/workflows/main.yml
vendored
1
.github/workflows/main.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user