Skip to content

Commit

Permalink
feat(richtext-lexical)!: various validation improvement (#6163)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
AlessioGr committed May 1, 2024
2 parents 568b074 + 401c16e commit d8f91cc
Show file tree
Hide file tree
Showing 13 changed files with 113 additions and 28 deletions.
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ jobs:
- fields__collections__Blocks
- fields__collections__Array
- fields__collections__Relationship
- fields__collections__RichText
- fields__collections__Lexical
- live-preview
- localization
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/routes/rest/buildFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
| {
Expand Down Expand Up @@ -143,6 +144,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
},
node: AutoLinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
validations: [linkValidation(props)],
}),
createNode({
converters: {
Expand Down Expand Up @@ -172,6 +174,7 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
},
node: LinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
validations: [linkValidation(props)],
}),
],
serverFeatureProps: props,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
51 changes: 51 additions & 0 deletions packages/richtext-lexical/src/field/features/link/validate.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export const UploadFeature: FeatureProviderProviderServer<
},
node: UploadNode,
populationPromises: [uploadPopulationPromiseHOC(props)],
validations: [uploadValidation()],
validations: [uploadValidation(props)],
}),
],
serverFeatureProps: props,
Expand Down
55 changes: 44 additions & 11 deletions packages/richtext-lexical/src/field/features/upload/validate.ts
Original file line number Diff line number Diff line change
@@ -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(nodeID, idType)) {
return t('validation:validUploadID')
}

if (Object.keys(props?.collections).length === 0) {
return true
}

if (!isValidID(id, idType)) {
return t('validation:validUploadID')
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
}
Expand Down
2 changes: 2 additions & 0 deletions packages/richtext-lexical/src/field/lexical/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions packages/richtext-slate/src/field/elements/link/utilities.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function textToLexicalJSON({
direction: 'ltr',
format: '',
indent: 0,
textFormat: 0,
type: 'paragraph',
version: 1,
} as SerializedParagraphNode,
Expand Down
5 changes: 3 additions & 2 deletions test/fields/collections/RichText/generateLexicalRichText.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { textFieldsSlug } from '../../slugs.js'
import { loremIpsum } from './loremIpsum.js'

export function generateLexicalRichText() {
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 1 addition & 2 deletions test/fields/lexical.int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 () => {
Expand Down

0 comments on commit d8f91cc

Please sign in to comment.