From 9acc1e4c994b1d62816bc5dddd3e04de01f26683 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Fri, 6 Oct 2023 11:30:18 +0200 Subject: [PATCH] feat: block node validations --- .../elements/DocumentDrawer/DrawerContent.tsx | 2 + .../addFieldStatePromise.ts | 9 +++ .../forms/Form/buildStateFromSchema/index.ts | 15 ++++- .../buildStateFromSchema/iterateFields.ts | 4 ++ .../src/admin/components/forms/Form/index.tsx | 12 ++++ .../src/admin/components/forms/Form/types.ts | 1 + .../forms/field-types/RichText/index.tsx | 5 +- .../forms/field-types/RichText/types.ts | 3 +- .../admin/components/forms/useField/index.tsx | 3 + .../admin/components/views/Account/index.tsx | 3 + .../admin/components/views/Global/index.tsx | 3 + .../views/collections/Edit/index.tsx | 4 +- .../List/Cell/field-types/Richtext/index.tsx | 5 +- packages/payload/src/config/schema.ts | 1 + .../payload/src/fields/config/sanitize.ts | 5 ++ packages/payload/src/fields/config/schema.ts | 1 + packages/payload/src/fields/config/types.ts | 3 + .../src/fields/hooks/afterRead/promise.ts | 2 +- .../src/fields/hooks/beforeChange/promise.ts | 1 + packages/payload/src/fields/validations.ts | 12 ++++ .../src/graphql/schema/buildObjectType.ts | 2 +- packages/richtext-lexical/src/field/Field.tsx | 6 +- .../field/features/Blocks/component/index.tsx | 7 ++- .../src/field/features/Blocks/index.tsx | 3 +- .../src/field/features/Blocks/validate.ts | 62 +++++++++++++++++++ .../floatingLinkEditor/LinkEditor/index.tsx | 1 + .../component/ExtraFieldsDrawer/index.tsx | 3 + .../src/field/features/types.ts | 24 ++++++- .../src/field/lexical/config/sanitize.ts | 4 ++ packages/richtext-lexical/src/index.ts | 4 ++ .../src/populate/validation.ts | 18 ------ .../richtext-lexical/src/validate/index.ts | 44 +++++++++++++ .../src/validate/validateNodes.ts | 51 +++++++++++++++ .../richtext-slate/src/data/validation.ts | 10 +-- .../richtext-slate/src/field/RichText.tsx | 4 +- .../src/field/elements/link/Button/index.tsx | 1 + .../src/field/elements/link/Element/index.tsx | 1 + .../upload/Element/UploadDrawer/index.tsx | 3 + packages/richtext-slate/src/index.ts | 2 + 39 files changed, 298 insertions(+), 46 deletions(-) create mode 100644 packages/richtext-lexical/src/field/features/Blocks/validate.ts delete mode 100644 packages/richtext-lexical/src/populate/validation.ts create mode 100644 packages/richtext-lexical/src/validate/index.ts create mode 100644 packages/richtext-lexical/src/validate/validateNodes.ts diff --git a/packages/payload/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx b/packages/payload/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx index 19d18093f..87e4991dd 100644 --- a/packages/payload/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx +++ b/packages/payload/src/admin/components/elements/DocumentDrawer/DrawerContent.tsx @@ -41,6 +41,7 @@ const Content: React.FC = ({ const hasInitializedState = useRef(false) const [isOpen, setIsOpen] = useState(false) const [collectionConfig] = useRelatedCollections(collectionSlug) + const config = useConfig() const { admin: { components: { views: { Edit } = {} } = {} } = {} } = collectionConfig @@ -82,6 +83,7 @@ const Content: React.FC = ({ const preferences = await getDocPreferences() const state = await buildStateFromSchema({ id, + config, data, fieldSchema: fields, locale, diff --git a/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/addFieldStatePromise.ts b/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/addFieldStatePromise.ts index d465ba893..24a284131 100644 --- a/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/addFieldStatePromise.ts +++ b/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/addFieldStatePromise.ts @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next' import ObjectID from 'bson-objectid' import type { User } from '../../../../../auth' +import type { SanitizedConfig } from '../../../../../config/types' import type { NonPresentationalField } from '../../../../../fields/config/types' import type { Data, Fields, FormField } from '../types' @@ -12,6 +13,7 @@ import getValueWithDefault from '../../../../../fields/getDefaultValue' import { iterateFields } from './iterateFields' type Args = { + config: SanitizedConfig data: Data field: NonPresentationalField fullData: Data @@ -30,6 +32,7 @@ type Args = { export const addFieldStatePromise = async ({ id, + config, data, field, fullData, @@ -68,6 +71,7 @@ export const addFieldStatePromise = async ({ validationResult = await fieldState.validate(data?.[field.name], { ...field, id, + config, data: fullData, operation, siblingData: data, @@ -100,6 +104,7 @@ export const addFieldStatePromise = async ({ acc.promises.push( iterateFields({ id, + config, data: row, fields: field.fields, fullData, @@ -188,6 +193,7 @@ export const addFieldStatePromise = async ({ acc.promises.push( iterateFields({ id, + config, data: row, fields: block.fields, fullData, @@ -249,6 +255,7 @@ export const addFieldStatePromise = async ({ case 'group': { await iterateFields({ id, + config, data: data?.[field.name] || {}, fields: field.fields, fullData, @@ -348,6 +355,7 @@ export const addFieldStatePromise = async ({ // Handle field types that do not use names (row, etc) await iterateFields({ id, + config, data, fields: field.fields, fullData, @@ -364,6 +372,7 @@ export const addFieldStatePromise = async ({ const promises = field.tabs.map((tab) => iterateFields({ id, + config, data: tabHasName(tab) ? data?.[tab.name] : data, fields: tab.fields, fullData, diff --git a/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/index.ts b/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/index.ts index c8735a0ef..5090e9103 100644 --- a/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/index.ts +++ b/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/index.ts @@ -1,12 +1,14 @@ import type { TFunction } from 'i18next' import type { User } from '../../../../../auth' +import type { SanitizedConfig } from '../../../../../config/types' import type { Field as FieldSchema } from '../../../../../fields/config/types' import type { Data, Fields } from '../types' import { iterateFields } from './iterateFields' type Args = { + config: SanitizedConfig data?: Data fieldSchema: FieldSchema[] id?: number | string @@ -21,13 +23,24 @@ type Args = { } const buildStateFromSchema = async (args: Args): Promise => { - const { id, data: fullData = {}, fieldSchema, locale, operation, preferences, t, user } = args + const { + id, + config, + data: fullData = {}, + fieldSchema, + locale, + operation, + preferences, + t, + user, + } = args if (fieldSchema) { const state: Fields = {} await iterateFields({ id, + config, data: fullData, fields: fieldSchema, fullData, diff --git a/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/iterateFields.ts b/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/iterateFields.ts index 0be15233b..2f0d20d53 100644 --- a/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/iterateFields.ts +++ b/packages/payload/src/admin/components/forms/Form/buildStateFromSchema/iterateFields.ts @@ -1,6 +1,7 @@ import type { TFunction } from 'i18next' import type { User } from '../../../../../auth' +import type { SanitizedConfig } from '../../../../../config/types' import type { Field as FieldSchema } from '../../../../../fields/config/types' import type { Data, Fields } from '../types' @@ -8,6 +9,7 @@ import { fieldIsPresentationalOnly } from '../../../../../fields/config/types' import { addFieldStatePromise } from './addFieldStatePromise' type Args = { + config: SanitizedConfig data: Data fields: FieldSchema[] fullData: Data @@ -26,6 +28,7 @@ type Args = { export const iterateFields = async ({ id, + config, data, fields, fullData, @@ -51,6 +54,7 @@ export const iterateFields = async ({ promises.push( addFieldStatePromise({ id, + config, data, field, fullData, diff --git a/packages/payload/src/admin/components/forms/Form/index.tsx b/packages/payload/src/admin/components/forms/Form/index.tsx index 12e5f1d3c..a6ff798d7 100644 --- a/packages/payload/src/admin/components/forms/Form/index.tsx +++ b/packages/payload/src/admin/components/forms/Form/index.tsx @@ -24,6 +24,7 @@ import wait from '../../../../utilities/wait' import { requests } from '../../../api' import useThrottledEffect from '../../../hooks/useThrottledEffect' import { useAuth } from '../../utilities/Auth' +import { useConfig } from '../../utilities/Config' import { useDocumentInfo } from '../../utilities/DocumentInfo' import { useLocale } from '../../utilities/Locale' import { useOperation } from '../../utilities/OperationProvider' @@ -62,6 +63,7 @@ const Form: React.FC = (props) => { onSubmit, onSuccess, redirect, + submitted: submittedFromProps, waitForAutocomplete, } = props @@ -72,6 +74,8 @@ const Form: React.FC = (props) => { const { id, collection, getDocPreferences, global } = useDocumentInfo() const operation = useOperation() + const config = useConfig() + const [modified, setModified] = useState(false) const [processing, setProcessing] = useState(false) const [submitted, setSubmitted] = useState(false) @@ -165,6 +169,7 @@ const Form: React.FC = (props) => { if (typeof field.validate === 'function') { validationResult = await field.validate(field.value, { id, + config, data, operation, siblingData: contextRef.current.getSiblingData(path), @@ -452,6 +457,7 @@ const Form: React.FC = (props) => { if (fieldConfig) { const subFieldState = await buildStateFromSchema({ id, + config, data, fieldSchema: fieldConfig, locale, @@ -490,6 +496,7 @@ const Form: React.FC = (props) => { if (fieldConfig) { const subFieldState = await buildStateFromSchema({ id, + config, data, fieldSchema: fieldConfig, locale, @@ -557,6 +564,7 @@ const Form: React.FC = (props) => { const preferences = await getDocPreferences() const state = await buildStateFromSchema({ id, + config, data, fieldSchema, locale, @@ -601,6 +609,10 @@ const Form: React.FC = (props) => { contextRef.current.removeFieldRow = removeFieldRow contextRef.current.replaceFieldRow = replaceFieldRow + useEffect(() => { + if (typeof submittedFromProps === 'boolean') setSubmitted(submittedFromProps) + }, [submittedFromProps]) + useEffect(() => { if (initialState) { contextRef.current = { ...initContextState } as FormContextType diff --git a/packages/payload/src/admin/components/forms/Form/types.ts b/packages/payload/src/admin/components/forms/Form/types.ts index 8ce524e4c..c9e2668cc 100644 --- a/packages/payload/src/admin/components/forms/Form/types.ts +++ b/packages/payload/src/admin/components/forms/Form/types.ts @@ -49,6 +49,7 @@ export type Props = { onSubmit?: (fields: Fields, data: Data) => void onSuccess?: (json: unknown) => void redirect?: string + submitted?: boolean validationOperation?: 'create' | 'update' waitForAutocomplete?: boolean } diff --git a/packages/payload/src/admin/components/forms/field-types/RichText/index.tsx b/packages/payload/src/admin/components/forms/field-types/RichText/index.tsx index 4400bb931..ae2ab1663 100644 --- a/packages/payload/src/admin/components/forms/field-types/RichText/index.tsx +++ b/packages/payload/src/admin/components/forms/field-types/RichText/index.tsx @@ -3,12 +3,9 @@ import React from 'react' import type { RichTextField } from '../../../../../fields/config/types' import type { RichTextAdapter } from './types' -import { useConfig } from '../../../utilities/Config' - const RichText: React.FC = (props) => { - const config = useConfig() // eslint-disable-next-line react/destructuring-assignment - const editor: RichTextAdapter = props.editor || config.editor + const editor: RichTextAdapter = props.editor return } diff --git a/packages/payload/src/admin/components/forms/field-types/RichText/types.ts b/packages/payload/src/admin/components/forms/field-types/RichText/types.ts index 91e22cfee..a959304bc 100644 --- a/packages/payload/src/admin/components/forms/field-types/RichText/types.ts +++ b/packages/payload/src/admin/components/forms/field-types/RichText/types.ts @@ -1,5 +1,5 @@ import type { PayloadRequest } from '../../../../../express/types' -import type { RichTextField } from '../../../../../fields/config/types' +import type { RichTextField, Validate } from '../../../../../fields/config/types' import type { CellComponentProps } from '../../../views/collections/List/Cell/types' export type RichTextFieldProps = Omit< @@ -21,4 +21,5 @@ export type RichTextAdapter = { showHiddenFields: boolean siblingDoc: Record }) => Promise | null + validate: Validate> } diff --git a/packages/payload/src/admin/components/forms/useField/index.tsx b/packages/payload/src/admin/components/forms/useField/index.tsx index 1b284682f..7955e9e45 100644 --- a/packages/payload/src/admin/components/forms/useField/index.tsx +++ b/packages/payload/src/admin/components/forms/useField/index.tsx @@ -6,6 +6,7 @@ import type { FieldType, Options } from './types' import useThrottledEffect from '../../../hooks/useThrottledEffect' import { useAuth } from '../../utilities/Auth' +import { useConfig } from '../../utilities/Config' import { useDocumentInfo } from '../../utilities/DocumentInfo' import { useOperation } from '../../utilities/OperationProvider' import { useForm, useFormFields, useFormProcessing, useFormSubmitted } from '../Form/context' @@ -26,6 +27,7 @@ const useField = (options: Options): FieldType => { const field = useFormFields(([fields]) => fields[path]) const { t } = useTranslation() const dispatchField = useFormFields(([_, dispatch]) => dispatch) + const config = useConfig() const { getData, getSiblingData, setModified } = useForm() @@ -106,6 +108,7 @@ const useField = (options: Options): FieldType => { const validateOptions = { id, + config, data: getData(), operation, siblingData: getSiblingData(path), diff --git a/packages/payload/src/admin/components/views/Account/index.tsx b/packages/payload/src/admin/components/views/Account/index.tsx index 633aedbef..62b4a9ca8 100644 --- a/packages/payload/src/admin/components/views/Account/index.tsx +++ b/packages/payload/src/admin/components/views/Account/index.tsx @@ -25,6 +25,7 @@ const AccountView: React.FC = () => { const { id, docPermissions, getDocPermissions, getDocPreferences, preferencesKey, slug } = useDocumentInfo() const { getPreference } = usePreferences() + const config = useConfig() const { admin: { @@ -65,6 +66,7 @@ const AccountView: React.FC = () => { const preferences = await getDocPreferences() const state = await buildStateFromSchema({ id, + config, data: json.doc, fieldSchema: collection.fields, locale, @@ -94,6 +96,7 @@ const AccountView: React.FC = () => { const state = await buildStateFromSchema({ id, + config, data: dataToRender, fieldSchema: fields, locale, diff --git a/packages/payload/src/admin/components/views/Global/index.tsx b/packages/payload/src/admin/components/views/Global/index.tsx index 1e0a530f8..c98225f2b 100644 --- a/packages/payload/src/admin/components/views/Global/index.tsx +++ b/packages/payload/src/admin/components/views/Global/index.tsx @@ -29,6 +29,7 @@ const GlobalView: React.FC = (props) => { useDocumentInfo() const { getPreference } = usePreferences() const { t } = useTranslation() + const config = useConfig() const { routes: { api }, @@ -44,6 +45,7 @@ const GlobalView: React.FC = (props) => { setUpdatedAt(json?.result?.updatedAt) const preferences = await getDocPreferences() const state = await buildStateFromSchema({ + config, data: json.result, fieldSchema: fields, locale, @@ -68,6 +70,7 @@ const GlobalView: React.FC = (props) => { const awaitInitialState = async () => { const preferences = await getDocPreferences() const state = await buildStateFromSchema({ + config, data: dataToRender, fieldSchema: fields, locale, diff --git a/packages/payload/src/admin/components/views/collections/Edit/index.tsx b/packages/payload/src/admin/components/views/collections/Edit/index.tsx index ed8ddb08b..de0c6ade2 100644 --- a/packages/payload/src/admin/components/views/collections/Edit/index.tsx +++ b/packages/payload/src/admin/components/views/collections/Edit/index.tsx @@ -31,10 +31,11 @@ const EditView: React.FC = (props) => { const { code: locale } = useLocale() + const config = useConfig() const { routes: { admin, api }, serverURL, - } = useConfig() + } = config const { params: { id } = {} } = useRouteMatch>() const history = useHistory() @@ -56,6 +57,7 @@ const EditView: React.FC = (props) => { const state = await buildStateFromSchema({ id, + config, data: doc || {}, fieldSchema: overrides.fieldSchema, locale, diff --git a/packages/payload/src/admin/components/views/collections/List/Cell/field-types/Richtext/index.tsx b/packages/payload/src/admin/components/views/collections/List/Cell/field-types/Richtext/index.tsx index 8b0b218db..d2d7e47bb 100644 --- a/packages/payload/src/admin/components/views/collections/List/Cell/field-types/Richtext/index.tsx +++ b/packages/payload/src/admin/components/views/collections/List/Cell/field-types/Richtext/index.tsx @@ -4,12 +4,9 @@ import type { RichTextField } from '../../../../../../../../fields/config/types' import type { RichTextAdapter } from '../../../../../../forms/field-types/RichText/types' import type { CellComponentProps } from '../../types' -import { useConfig } from '../../../../../../utilities/Config' - const RichTextCell: React.FC> = (props) => { - const config = useConfig() // eslint-disable-next-line react/destructuring-assignment - const editor: RichTextAdapter = props.field.editor || config.editor + const editor: RichTextAdapter = props.field.editor return } diff --git a/packages/payload/src/config/schema.ts b/packages/payload/src/config/schema.ts index 2d596d442..637ad2d1e 100644 --- a/packages/payload/src/config/schema.ts +++ b/packages/payload/src/config/schema.ts @@ -86,6 +86,7 @@ export default joi.object({ CellComponent: component.required(), FieldComponent: component.required(), afterReadPromise: joi.func().required(), + validate: joi.func().required(), }), email: joi.object(), endpoints: endpointsSchema, diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 77d75def9..3b8e7fdc1 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -32,6 +32,11 @@ export const sanitizeFields = ({ config, fields, validRelationships }: Args): Fi throw new InvalidFieldName(field, field.name) } + // Make sure that the richText field has an editor + if (field.type === 'richText' && !field.editor && config.editor) { + field.editor = config.editor + } + // Auto-label if ( 'name' in field && diff --git a/packages/payload/src/fields/config/schema.ts b/packages/payload/src/fields/config/schema.ts index fb310c2e5..6a901140a 100644 --- a/packages/payload/src/fields/config/schema.ts +++ b/packages/payload/src/fields/config/schema.ts @@ -358,6 +358,7 @@ export const richText = baseField.keys({ CellComponent: componentSchema.required(), FieldComponent: componentSchema.required(), afterReadPromise: joi.func().required(), + validate: joi.func().required(), }), type: joi.string().valid('richText').required(), }) diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index a69bd0d6c..96da3e8fb 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -10,6 +10,7 @@ import type { RowLabel } from '../../admin/components/forms/RowLabel/types' import type { RichTextAdapter } from '../../admin/components/forms/field-types/RichText/types' import type { User } from '../../auth' import type { TypeWithID } from '../../collections/config/types' +import type { SanitizedConfig } from '../../config/types' import type { PayloadRequest, RequestContext } from '../../express/types' import type { Payload } from '../../payload' import type { Operation, Where } from '../../types' @@ -91,6 +92,7 @@ export type Labels = { } export type ValidateOptions = { + config: SanitizedConfig data: Partial id?: number | string operation?: Operation @@ -100,6 +102,7 @@ export type ValidateOptions = { user?: Partial } & TFieldConfig +// TODO: Having TFieldConfig as any breaks all type checking / auto-completions for the base ValidateOptions properties. export type Validate = ( value: TValue, options: ValidateOptions, diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 0f90ea8c0..90def94a9 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -128,7 +128,7 @@ export const promise = async ({ } case 'richText': { - const editor: RichTextAdapter = field?.editor || req?.payload?.config?.editor + const editor: RichTextAdapter = field?.editor if (editor?.afterReadPromise) { const afterReadPromise = editor.afterReadPromise({ currentDepth, diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 083cbacf7..57b1383c6 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -111,6 +111,7 @@ export const promise = async ({ const validationResult = await field.validate(valueToValidate, { ...field, id, + config: req.payload.config, data: merge(doc, data, { arrayMerge: (_, source) => source }), jsonError, operation, diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index d44ec3c92..ce3e97743 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -1,3 +1,4 @@ +import type { RichTextAdapter } from '../exports/types' import type { ArrayField, BlockField, @@ -11,6 +12,7 @@ import type { RadioField, RelationshipField, RelationshipValue, + RichTextField, SelectField, TextField, TextareaField, @@ -212,6 +214,15 @@ export const date: Validate = (value, { required, t return true } +export const richText: Validate = async ( + value, + options, +) => { + const editor: RichTextAdapter = options?.editor + + return await editor.validate(value, options) +} + const validateFilterOptions: Validate = async ( value, { id, data, filterOptions, payload, relationTo, siblingData, t, user }, @@ -507,6 +518,7 @@ export default { point, radio, relationship, + richText, select, text, textarea, diff --git a/packages/payload/src/graphql/schema/buildObjectType.ts b/packages/payload/src/graphql/schema/buildObjectType.ts index 5d1d3f9ea..a6f8a63b6 100644 --- a/packages/payload/src/graphql/schema/buildObjectType.ts +++ b/packages/payload/src/graphql/schema/buildObjectType.ts @@ -427,7 +427,7 @@ function buildObjectType({ async resolve(parent, args, context) { let depth = payload.config.defaultDepth if (typeof args.depth !== 'undefined') depth = args.depth - const editor: RichTextAdapter = field?.editor || payload?.config?.editor + const editor: RichTextAdapter = field?.editor if (editor?.afterReadPromise) { await editor?.afterReadPromise({ diff --git a/packages/richtext-lexical/src/field/Field.tsx b/packages/richtext-lexical/src/field/Field.tsx index 97dee6e64..1f4817dcb 100644 --- a/packages/richtext-lexical/src/field/Field.tsx +++ b/packages/richtext-lexical/src/field/Field.tsx @@ -6,7 +6,7 @@ import { ErrorBoundary } from 'react-error-boundary' import type { FieldProps } from '../types' -import { richTextValidate } from '../populate/validation' +import { richTextValidate } from '../validate' import './index.scss' import { LexicalProvider } from './lexical/LexicalProvider' @@ -36,9 +36,9 @@ const RichText: React.FC = (props) => { const memoizedValidate = useCallback( (value, validationOptions) => { - return validate(value, { ...validationOptions, required }) + return validate(value, { ...validationOptions, props, required }) }, - [validate, required], + [validate, required, props], ) const fieldType = useField({ diff --git a/packages/richtext-lexical/src/field/features/Blocks/component/index.tsx b/packages/richtext-lexical/src/field/features/Blocks/component/index.tsx index 066fd509c..5c43ae8c3 100644 --- a/packages/richtext-lexical/src/field/features/Blocks/component/index.tsx +++ b/packages/richtext-lexical/src/field/features/Blocks/component/index.tsx @@ -1,5 +1,5 @@ import { type ElementFormatType } from 'lexical' -import { Form, buildInitialState } from 'payload/components/forms' +import { Form, buildInitialState, useFormSubmitted } from 'payload/components/forms' import React, { useMemo } from 'react' import { type BlockFields } from '../nodes/BlocksNode' @@ -27,6 +27,7 @@ type Props = { export const BlockComponent: React.FC = (props) => { const { children, className, fields, format, nodeKey } = props const payloadConfig = useConfig() + const submitted = useFormSubmitted() const { editorConfig, field } = useEditorConfigContext() @@ -48,7 +49,7 @@ export const BlockComponent: React.FC = (props) => { const formContent = useMemo(() => { return ( block && ( -
+ = (props) => { ) ) - }, [block, field, nodeKey]) + }, [block, field, nodeKey, submitted]) return
{formContent}
} diff --git a/packages/richtext-lexical/src/field/features/Blocks/index.tsx b/packages/richtext-lexical/src/field/features/Blocks/index.tsx index bb0e4e2d2..0286ac302 100644 --- a/packages/richtext-lexical/src/field/features/Blocks/index.tsx +++ b/packages/richtext-lexical/src/field/features/Blocks/index.tsx @@ -8,10 +8,10 @@ import type { FeatureProvider } from '../types' import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu' import { BlockIcon } from '../../lexical/ui/icons/Block' import { blockAfterReadPromiseHOC } from './afterReadPromise' -import { INSERT_BLOCK_WITH_DRAWER_COMMAND } from './drawer' import './index.scss' import { BlockNode } from './nodes/BlocksNode' import { BlocksPlugin, INSERT_BLOCK_COMMAND } from './plugin' +import { blockValidationHOC } from './validate' export type BlocksFeatureProps = { blocks: Block[] @@ -45,6 +45,7 @@ export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => { afterReadPromises: [blockAfterReadPromiseHOC(props)], node: BlockNode, type: BlockNode.getType(), + validations: [blockValidationHOC(props)], }, ], plugins: [ diff --git a/packages/richtext-lexical/src/field/features/Blocks/validate.ts b/packages/richtext-lexical/src/field/features/Blocks/validate.ts new file mode 100644 index 000000000..35bfdf2e5 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/Blocks/validate.ts @@ -0,0 +1,62 @@ +import type { Block } from 'payload/types' + +import { sanitizeFields } from 'payload/config' + +import type { BlocksFeatureProps } from '.' +import type { NodeValidation } from '../types' +import type { SerializedBlockNode } from './nodes/BlocksNode' + +export const blockValidationHOC = ( + props: BlocksFeatureProps, +): NodeValidation => { + const blockValidation: NodeValidation = async ({ + node, + nodeValidations, + payloadConfig, + validation, + }) => { + const blockFieldValues = node.fields.data + + const blocks: Block[] = props.blocks + // Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here + blocks.forEach((block) => { + const validRelationships = payloadConfig.collections.map((c) => c.slug) || [] + block.fields = sanitizeFields({ + config: payloadConfig, + fields: block.fields, + validRelationships, + }) + }) + + // find block + const block = props.blocks.find((block) => block.slug === blockFieldValues.blockType) + + // validate block + if (!block) { + return 'Block not found' + } + + for (const field of block.fields) { + if ('validate' in field && typeof field.validate === 'function' && field.validate) { + const fieldValue = 'name' in field ? node.fields.data[field.name] : null + const validationResult = await field.validate(fieldValue, { + id: validation.options.id, + config: payloadConfig, + data: fieldValue, + operation: validation.options.operation, + siblingData: validation.options.siblingData, + t: validation.options.t, + user: validation.options.user, + }) + + if (validationResult !== true) { + return validationResult + } + } + } + + return true + } + + return blockValidation +} diff --git a/packages/richtext-lexical/src/field/features/Link/plugins/floatingLinkEditor/LinkEditor/index.tsx b/packages/richtext-lexical/src/field/features/Link/plugins/floatingLinkEditor/LinkEditor/index.tsx index 0da89e093..e8a84fb48 100644 --- a/packages/richtext-lexical/src/field/features/Link/plugins/floatingLinkEditor/LinkEditor/index.tsx +++ b/packages/richtext-lexical/src/field/features/Link/plugins/floatingLinkEditor/LinkEditor/index.tsx @@ -132,6 +132,7 @@ export function LinkEditor({ // values saved in the link node you clicked on. const preferences = await getDocPreferences() const state = await buildStateFromSchema({ + config, data, fieldSchema, locale, diff --git a/packages/richtext-lexical/src/field/features/Upload/component/ExtraFieldsDrawer/index.tsx b/packages/richtext-lexical/src/field/features/Upload/component/ExtraFieldsDrawer/index.tsx index dd9066535..133c9ce33 100644 --- a/packages/richtext-lexical/src/field/features/Upload/component/ExtraFieldsDrawer/index.tsx +++ b/packages/richtext-lexical/src/field/features/Upload/component/ExtraFieldsDrawer/index.tsx @@ -8,6 +8,7 @@ import { Form, FormSubmit, RenderFields } from 'payload/components/forms' import { buildStateFromSchema, useAuth, + useConfig, useDocumentInfo, useLocale, } from 'payload/components/utilities' @@ -51,6 +52,7 @@ export const ExtraFieldsUploadDrawer: React.FC< const [initialState, setInitialState] = useState({}) const fieldSchema = (editorConfig?.resolvedFeatureMap.get('upload')?.props as UploadFeatureProps) ?.collections?.[relatedCollection.slug]?.fields + const config = useConfig() const handleUpdateEditData = useCallback( (_, data) => { @@ -75,6 +77,7 @@ export const ExtraFieldsUploadDrawer: React.FC< const awaitInitialState = async () => { const preferences = await getDocPreferences() const state = await buildStateFromSchema({ + config, data: deepCopyObject(fields || {}), fieldSchema, locale, diff --git a/packages/richtext-lexical/src/field/features/types.ts b/packages/richtext-lexical/src/field/features/types.ts index da1133b19..322a1407b 100644 --- a/packages/richtext-lexical/src/field/features/types.ts +++ b/packages/richtext-lexical/src/field/features/types.ts @@ -1,7 +1,8 @@ import type { Transformer } from '@lexical/markdown' -import type { Klass, LexicalEditor, LexicalNode } from 'lexical' +import type { Klass, LexicalEditor, LexicalNode, SerializedEditorState } from 'lexical' import type { SerializedLexicalNode } from 'lexical' -import type { PayloadRequest, RichTextField } from 'payload/types' +import type { SanitizedConfig } from 'payload/config' +import type { PayloadRequest, RichTextField, ValidateOptions } from 'payload/types' import type React from 'react' import type { AdapterProps } from '../../types' @@ -28,6 +29,22 @@ export type AfterReadPromise Promise[] + +export type NodeValidation = ({ + node, + nodeValidations, + payloadConfig, + validation, +}: { + node: T + nodeValidations: Map> + payloadConfig: SanitizedConfig + validation: { + options: ValidateOptions + value: SerializedEditorState + } +}) => Promise | string | true + export type Feature = { floatingSelectToolbar?: { sections: FloatingToolbarSection[] @@ -37,6 +54,7 @@ export type Feature = { afterReadPromises?: Array node: Klass type: string + validations?: Array }> plugins?: Array< | { @@ -124,4 +142,6 @@ export type SanitizedFeatures = Required< > groupsWithOptions: SlashMenuGroup[] } + /** The node types mapped to their validations */ + validations: Map> } diff --git a/packages/richtext-lexical/src/field/lexical/config/sanitize.ts b/packages/richtext-lexical/src/field/lexical/config/sanitize.ts index 1605f6612..cf39b20c4 100644 --- a/packages/richtext-lexical/src/field/lexical/config/sanitize.ts +++ b/packages/richtext-lexical/src/field/lexical/config/sanitize.ts @@ -17,6 +17,7 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature dynamicOptions: [], groupsWithOptions: [], }, + validations: new Map(), } features.forEach((feature) => { @@ -26,6 +27,9 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature if (node?.afterReadPromises?.length) { sanitized.afterReadPromises.set(node.type, node.afterReadPromises) } + if (node?.validations?.length) { + sanitized.validations.set(node.type, node.validations) + } }) } if (feature.plugins?.length) { diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 6004e7b87..18bdae802 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -11,6 +11,7 @@ import { defaultEditorConfig, defaultSanitizedEditorConfig } from './field/lexic import { sanitizeEditorConfig } from './field/lexical/config/sanitize' import { cloneDeep } from './field/lexical/utils/cloneDeep' import { richTextRelationshipPromise } from './populate/richTextRelationshipPromise' +import { richTextValidateHOC } from './validate' export function lexicalEditor({ userConfig, @@ -56,6 +57,9 @@ export function lexicalEditor({ return null }, + validate: richTextValidateHOC({ + editorConfig: finalSanitizedEditorConfig, + }), } } diff --git a/packages/richtext-lexical/src/populate/validation.ts b/packages/richtext-lexical/src/populate/validation.ts deleted file mode 100644 index ee8f7c45a..000000000 --- a/packages/richtext-lexical/src/populate/validation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { RichTextField, Validate } from 'payload/types' - -import type { AdapterProps } from '../types' - -import { defaultRichTextValue } from './defaultValue' - -export const richTextValidate: Validate> = ( - value, - { required, t }, -) => { - if (required) { - const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue) - if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true - return t('validation:required') - } - - return true -} diff --git a/packages/richtext-lexical/src/validate/index.ts b/packages/richtext-lexical/src/validate/index.ts new file mode 100644 index 000000000..8968268d3 --- /dev/null +++ b/packages/richtext-lexical/src/validate/index.ts @@ -0,0 +1,44 @@ +import type { SerializedEditorState } from 'lexical' +import type { RichTextField, Validate } from 'payload/types' + +import type { SanitizedEditorConfig } from '../field/lexical/config/types' + +import { defaultRichTextValue } from '../populate/defaultValue' +import { validateNodes } from './validateNodes' + +export const richTextValidateHOC = ({ editorConfig }: { editorConfig: SanitizedEditorConfig }) => { + const richTextValidate: Validate< + SerializedEditorState, + SerializedEditorState, + unknown, + RichTextField + > = async (value, options) => { + const { required, t } = options + + if (required) { + const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue) + if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true + return t('validation:required') + } + + // Traverse through nodes and validate them. Just like a node can hook into the population process (e.g. link or relationship nodes), + // they can also hook into the validation process. E.g. a block node probably has fields with validation rules. + + const rootNodes = value?.root?.children + if (rootNodes && Array.isArray(rootNodes) && rootNodes?.length) { + return await validateNodes({ + nodeValidations: editorConfig.features.validations, + nodes: rootNodes, + payloadConfig: options.config, + validation: { + options, + value, + }, + }) + } + + return true + } + + return richTextValidate +} diff --git a/packages/richtext-lexical/src/validate/validateNodes.ts b/packages/richtext-lexical/src/validate/validateNodes.ts new file mode 100644 index 000000000..4f48f7741 --- /dev/null +++ b/packages/richtext-lexical/src/validate/validateNodes.ts @@ -0,0 +1,51 @@ +import type { SerializedEditorState, SerializedLexicalNode } from 'lexical' +import type { SanitizedConfig } from 'payload/config' +import type { RichTextField, ValidateOptions } from 'payload/types' + +import type { NodeValidation } from '../field/features/types' +export async function validateNodes({ + nodeValidations, + nodes, + payloadConfig, + validation: validationFromProps, +}: { + nodeValidations: Map> + nodes: SerializedLexicalNode[] + payloadConfig: SanitizedConfig + validation: { + options: ValidateOptions + value: SerializedEditorState + } +}): Promise { + for (const node of nodes) { + // Validate node + if (nodeValidations?.has(node.type)) { + const validations = nodeValidations.get(node.type) + for (const validation of validations) { + const validationResult = await validation({ + node, + nodeValidations, + payloadConfig, + validation: validationFromProps, + }) + if (validationResult !== true) { + return validationResult + } + } + } + // Validate node's children + if ('children' in node && node?.children) { + const childrenValidationResult = await validateNodes({ + nodeValidations, + nodes: node.children as SerializedLexicalNode[], + payloadConfig, + validation: validationFromProps, + }) + if (childrenValidationResult !== true) { + return childrenValidationResult + } + } + } + + return true +} diff --git a/packages/richtext-slate/src/data/validation.ts b/packages/richtext-slate/src/data/validation.ts index fafd6b20a..e319851e0 100644 --- a/packages/richtext-slate/src/data/validation.ts +++ b/packages/richtext-slate/src/data/validation.ts @@ -4,10 +4,12 @@ import type { AdapterArguments } from '../types' import { defaultRichTextValue } from './defaultValue' -export const richText: Validate> = ( - value, - { required, t }, -) => { +export const richTextValidate: Validate< + unknown, + unknown, + RichTextField, + RichTextField +> = (value, { required, t }) => { if (required) { const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue) if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true diff --git a/packages/richtext-slate/src/field/RichText.tsx b/packages/richtext-slate/src/field/RichText.tsx index d4d38c07e..673f52639 100644 --- a/packages/richtext-slate/src/field/RichText.tsx +++ b/packages/richtext-slate/src/field/RichText.tsx @@ -15,7 +15,7 @@ import { Editable, Slate, withReact } from 'slate-react' import type { ElementNode, FieldProps, RichTextElement, RichTextLeaf, TextNode } from '../types' import { defaultRichTextValue } from '../data/defaultValue' -import { richText } from '../data/validation' +import { richTextValidate } from '../data/validation' import elementTypes from './elements' import listTypes from './elements/listTypes' import enablePlugins from './enablePlugins' @@ -80,7 +80,7 @@ const RichText: React.FC = (props) => { label, path: pathFromProps, required, - validate = richText, + validate = richTextValidate, } = props const elements: RichTextElement[] = admin?.elements || defaultElements diff --git a/packages/richtext-slate/src/field/elements/link/Button/index.tsx b/packages/richtext-slate/src/field/elements/link/Button/index.tsx index d0f8b2fcd..01d9ceae7 100644 --- a/packages/richtext-slate/src/field/elements/link/Button/index.tsx +++ b/packages/richtext-slate/src/field/elements/link/Button/index.tsx @@ -104,6 +104,7 @@ export const LinkButton: React.FC<{ const preferences = await getDocPreferences() const state = await buildStateFromSchema({ + config, data, fieldSchema, locale, diff --git a/packages/richtext-slate/src/field/elements/link/Element/index.tsx b/packages/richtext-slate/src/field/elements/link/Element/index.tsx index 008f065dc..3f9a6178d 100644 --- a/packages/richtext-slate/src/field/elements/link/Element/index.tsx +++ b/packages/richtext-slate/src/field/elements/link/Element/index.tsx @@ -103,6 +103,7 @@ export const LinkElement: React.FC<{ const preferences = await getDocPreferences() const state = await buildStateFromSchema({ + config, data, fieldSchema, locale, diff --git a/packages/richtext-slate/src/field/elements/upload/Element/UploadDrawer/index.tsx b/packages/richtext-slate/src/field/elements/upload/Element/UploadDrawer/index.tsx index 7f1f9440e..67340b687 100644 --- a/packages/richtext-slate/src/field/elements/upload/Element/UploadDrawer/index.tsx +++ b/packages/richtext-slate/src/field/elements/upload/Element/UploadDrawer/index.tsx @@ -6,6 +6,7 @@ import { Form, FormSubmit, RenderFields } from 'payload/components/forms' import { buildStateFromSchema, useAuth, + useConfig, useDocumentInfo, useLocale, } from 'payload/components/utilities' @@ -35,6 +36,7 @@ export const UploadDrawer: React.FC< const { getDocPreferences } = useDocumentInfo() const [initialState, setInitialState] = useState({}) const fieldSchema = fieldProps?.admin?.upload?.collections?.[relatedCollection.slug]?.fields + const config = useConfig() const handleUpdateEditData = useCallback( (_, data) => { @@ -54,6 +56,7 @@ export const UploadDrawer: React.FC< const awaitInitialState = async () => { const preferences = await getDocPreferences() const state = await buildStateFromSchema({ + config, data: deepCopyObject(element?.fields || {}), fieldSchema, locale, diff --git a/packages/richtext-slate/src/index.ts b/packages/richtext-slate/src/index.ts index 437f13a22..eff40d056 100644 --- a/packages/richtext-slate/src/index.ts +++ b/packages/richtext-slate/src/index.ts @@ -6,6 +6,7 @@ import type { AdapterArguments } from './types' import RichTextCell from './cell' import { richTextRelationshipPromise } from './data/richTextRelationshipPromise' +import { richTextValidate } from './data/validation' import RichTextField from './field' export function slateEditor(args: AdapterArguments): RichTextAdapter { @@ -45,5 +46,6 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter