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__Array
|
||||
- fields__collections__Relationship
|
||||
- fields__collections__RichText
|
||||
- fields__collections__Lexical
|
||||
- live-preview
|
||||
- localization
|
||||
|
||||
@@ -227,6 +227,8 @@ export const buildFormState = async ({ req }: { req: PayloadRequestWithData }) =
|
||||
status: httpStatus.OK,
|
||||
})
|
||||
} catch (err) {
|
||||
req.payload.logger.error({ err, msg: `There was an error building form state` })
|
||||
|
||||
return routeError({
|
||||
config: req.payload.config,
|
||||
err,
|
||||
|
||||
@@ -2,13 +2,13 @@ import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
|
||||
|
||||
import type { NodeValidation } from '../types.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 = (
|
||||
props: BlocksFeatureProps,
|
||||
): NodeValidation<SerializedBlockNode> => {
|
||||
return async ({ node, validation }) => {
|
||||
const blockFieldData = node.fields
|
||||
const blockFieldData = node.fields ?? ({} as BlockFields)
|
||||
|
||||
const {
|
||||
options: { id, operation, preferences, req },
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AutoLinkNode } from './nodes/AutoLinkNode.js'
|
||||
import { LinkNode } from './nodes/LinkNode.js'
|
||||
import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js'
|
||||
import { linkPopulationPromiseHOC } from './populationPromise.js'
|
||||
import { linkValidation } from './validate.js'
|
||||
|
||||
export type ExclusiveLinkCollectionsProps =
|
||||
| {
|
||||
@@ -143,6 +144,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
|
||||
},
|
||||
node: AutoLinkNode,
|
||||
populationPromises: [linkPopulationPromiseHOC(props)],
|
||||
validations: [linkValidation(props)],
|
||||
}),
|
||||
createNode({
|
||||
converters: {
|
||||
@@ -172,6 +174,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
|
||||
},
|
||||
node: LinkNode,
|
||||
populationPromises: [linkPopulationPromiseHOC(props)],
|
||||
validations: [linkValidation(props)],
|
||||
}),
|
||||
],
|
||||
serverFeatureProps: props,
|
||||
|
||||
@@ -27,13 +27,6 @@ export function LinkPlugin(): null {
|
||||
editor.registerCommand(
|
||||
TOGGLE_LINK_COMMAND,
|
||||
(payload: LinkPayload) => {
|
||||
// validate
|
||||
if (payload?.fields.linkType === 'custom') {
|
||||
if (!(validateUrl === undefined || validateUrl(payload?.fields.url))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
toggleLink(payload)
|
||||
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,
|
||||
populationPromises: [uploadPopulationPromiseHOC(props)],
|
||||
validations: [uploadValidation()],
|
||||
validations: [uploadValidation(props)],
|
||||
}),
|
||||
],
|
||||
serverFeatureProps: props,
|
||||
|
||||
@@ -1,30 +1,63 @@
|
||||
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
|
||||
import { isValidID } from 'payload/utilities'
|
||||
|
||||
import type { NodeValidation } from '../types.js'
|
||||
import type { UploadFeatureProps } from './feature.server.js'
|
||||
import type { SerializedUploadNode } from './nodes/UploadNode.js'
|
||||
|
||||
import { CAN_USE_DOM } from '../../lexical/utils/canUseDOM.js'
|
||||
|
||||
export const uploadValidation = (): NodeValidation<SerializedUploadNode> => {
|
||||
return ({
|
||||
export const uploadValidation = (
|
||||
props: UploadFeatureProps,
|
||||
): NodeValidation<SerializedUploadNode> => {
|
||||
return async ({
|
||||
node,
|
||||
validation: {
|
||||
options: {
|
||||
id,
|
||||
operation,
|
||||
preferences,
|
||||
req,
|
||||
req: { payload, t },
|
||||
},
|
||||
},
|
||||
}) => {
|
||||
if (!CAN_USE_DOM) {
|
||||
const idType = payload.collections[node.relationTo].customIDType || payload.db.defaultIDType
|
||||
// @ts-expect-error
|
||||
const id = node?.value?.id || node?.value // for backwards-compatibility
|
||||
const idType = payload.collections[node.relationTo].customIDType || payload.db.defaultIDType
|
||||
// @ts-expect-error
|
||||
const nodeID = node?.value?.id || node?.value // for backwards-compatibility
|
||||
|
||||
if (!isValidID(id, idType)) {
|
||||
return t('validation:validUploadID')
|
||||
if (!isValidID(nodeID, idType)) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export function sanitizeUrl(url: string): string {
|
||||
const urlRegExp =
|
||||
/((([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 {
|
||||
// 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.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { I18n } from '@payloadcms/translations'
|
||||
import type { Config, SanitizedConfig } from 'payload/config'
|
||||
import type { SanitizedConfig } from 'payload/config'
|
||||
import type { Field } from 'payload/types'
|
||||
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({
|
||||
name: 'fields',
|
||||
type: 'group',
|
||||
|
||||
@@ -33,6 +33,7 @@ export function textToLexicalJSON({
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
textFormat: 0,
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
} as SerializedParagraphNode,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { textFieldsSlug } from '../../slugs.js'
|
||||
import { loremIpsum } from './loremIpsum.js'
|
||||
|
||||
export function generateLexicalRichText() {
|
||||
@@ -90,8 +91,8 @@ export function generateLexicalRichText() {
|
||||
fields: {
|
||||
url: 'https://',
|
||||
doc: {
|
||||
value: '{{ARRAY_DOC_ID}}',
|
||||
relationTo: 'array-fields',
|
||||
value: '{{TEXT_DOC_ID}}',
|
||||
relationTo: textFieldsSlug,
|
||||
},
|
||||
newTab: false,
|
||||
linkType: 'internal',
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { LexicalField, LexicalMigrateField, RichTextField } from './payload
|
||||
import { devUser } from '../credentials.js'
|
||||
import { NextRESTClient } from '../helpers/NextRESTClient.js'
|
||||
import { initPayloadInt } from '../helpers/initPayloadInt.js'
|
||||
import { arrayDoc } from './collections/Array/shared.js'
|
||||
import { lexicalDocData } from './collections/Lexical/data.js'
|
||||
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
|
||||
import { richTextDocData } from './collections/RichText/data.js'
|
||||
@@ -172,7 +171,7 @@ describe('Lexical', () => {
|
||||
|
||||
const linkNode: SerializedLinkNode = (lexical.root.children[1] as SerializedParagraphNode)
|
||||
.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 () => {
|
||||
|
||||
Reference in New Issue
Block a user