feat: block node validations

This commit is contained in:
Alessio Gravili
2023-10-06 11:30:18 +02:00
parent f0689d403d
commit 9acc1e4c99
39 changed files with 298 additions and 46 deletions

View File

@@ -41,6 +41,7 @@ const Content: React.FC<DocumentDrawerProps> = ({
const hasInitializedState = useRef(false) const hasInitializedState = useRef(false)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [collectionConfig] = useRelatedCollections(collectionSlug) const [collectionConfig] = useRelatedCollections(collectionSlug)
const config = useConfig()
const { admin: { components: { views: { Edit } = {} } = {} } = {} } = collectionConfig const { admin: { components: { views: { Edit } = {} } = {} } = {} } = collectionConfig
@@ -82,6 +83,7 @@ const Content: React.FC<DocumentDrawerProps> = ({
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
id, id,
config,
data, data,
fieldSchema: fields, fieldSchema: fields,
locale, locale,

View File

@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'
import ObjectID from 'bson-objectid' import ObjectID from 'bson-objectid'
import type { User } from '../../../../../auth' import type { User } from '../../../../../auth'
import type { SanitizedConfig } from '../../../../../config/types'
import type { NonPresentationalField } from '../../../../../fields/config/types' import type { NonPresentationalField } from '../../../../../fields/config/types'
import type { Data, Fields, FormField } from '../types' import type { Data, Fields, FormField } from '../types'
@@ -12,6 +13,7 @@ import getValueWithDefault from '../../../../../fields/getDefaultValue'
import { iterateFields } from './iterateFields' import { iterateFields } from './iterateFields'
type Args = { type Args = {
config: SanitizedConfig
data: Data data: Data
field: NonPresentationalField field: NonPresentationalField
fullData: Data fullData: Data
@@ -30,6 +32,7 @@ type Args = {
export const addFieldStatePromise = async ({ export const addFieldStatePromise = async ({
id, id,
config,
data, data,
field, field,
fullData, fullData,
@@ -68,6 +71,7 @@ export const addFieldStatePromise = async ({
validationResult = await fieldState.validate(data?.[field.name], { validationResult = await fieldState.validate(data?.[field.name], {
...field, ...field,
id, id,
config,
data: fullData, data: fullData,
operation, operation,
siblingData: data, siblingData: data,
@@ -100,6 +104,7 @@ export const addFieldStatePromise = async ({
acc.promises.push( acc.promises.push(
iterateFields({ iterateFields({
id, id,
config,
data: row, data: row,
fields: field.fields, fields: field.fields,
fullData, fullData,
@@ -188,6 +193,7 @@ export const addFieldStatePromise = async ({
acc.promises.push( acc.promises.push(
iterateFields({ iterateFields({
id, id,
config,
data: row, data: row,
fields: block.fields, fields: block.fields,
fullData, fullData,
@@ -249,6 +255,7 @@ export const addFieldStatePromise = async ({
case 'group': { case 'group': {
await iterateFields({ await iterateFields({
id, id,
config,
data: data?.[field.name] || {}, data: data?.[field.name] || {},
fields: field.fields, fields: field.fields,
fullData, fullData,
@@ -348,6 +355,7 @@ export const addFieldStatePromise = async ({
// Handle field types that do not use names (row, etc) // Handle field types that do not use names (row, etc)
await iterateFields({ await iterateFields({
id, id,
config,
data, data,
fields: field.fields, fields: field.fields,
fullData, fullData,
@@ -364,6 +372,7 @@ export const addFieldStatePromise = async ({
const promises = field.tabs.map((tab) => const promises = field.tabs.map((tab) =>
iterateFields({ iterateFields({
id, id,
config,
data: tabHasName(tab) ? data?.[tab.name] : data, data: tabHasName(tab) ? data?.[tab.name] : data,
fields: tab.fields, fields: tab.fields,
fullData, fullData,

View File

@@ -1,12 +1,14 @@
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import type { User } from '../../../../../auth' import type { User } from '../../../../../auth'
import type { SanitizedConfig } from '../../../../../config/types'
import type { Field as FieldSchema } from '../../../../../fields/config/types' import type { Field as FieldSchema } from '../../../../../fields/config/types'
import type { Data, Fields } from '../types' import type { Data, Fields } from '../types'
import { iterateFields } from './iterateFields' import { iterateFields } from './iterateFields'
type Args = { type Args = {
config: SanitizedConfig
data?: Data data?: Data
fieldSchema: FieldSchema[] fieldSchema: FieldSchema[]
id?: number | string id?: number | string
@@ -21,13 +23,24 @@ type Args = {
} }
const buildStateFromSchema = async (args: Args): Promise<Fields> => { const buildStateFromSchema = async (args: Args): Promise<Fields> => {
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) { if (fieldSchema) {
const state: Fields = {} const state: Fields = {}
await iterateFields({ await iterateFields({
id, id,
config,
data: fullData, data: fullData,
fields: fieldSchema, fields: fieldSchema,
fullData, fullData,

View File

@@ -1,6 +1,7 @@
import type { TFunction } from 'i18next' import type { TFunction } from 'i18next'
import type { User } from '../../../../../auth' import type { User } from '../../../../../auth'
import type { SanitizedConfig } from '../../../../../config/types'
import type { Field as FieldSchema } from '../../../../../fields/config/types' import type { Field as FieldSchema } from '../../../../../fields/config/types'
import type { Data, Fields } from '../types' import type { Data, Fields } from '../types'
@@ -8,6 +9,7 @@ import { fieldIsPresentationalOnly } from '../../../../../fields/config/types'
import { addFieldStatePromise } from './addFieldStatePromise' import { addFieldStatePromise } from './addFieldStatePromise'
type Args = { type Args = {
config: SanitizedConfig
data: Data data: Data
fields: FieldSchema[] fields: FieldSchema[]
fullData: Data fullData: Data
@@ -26,6 +28,7 @@ type Args = {
export const iterateFields = async ({ export const iterateFields = async ({
id, id,
config,
data, data,
fields, fields,
fullData, fullData,
@@ -51,6 +54,7 @@ export const iterateFields = async ({
promises.push( promises.push(
addFieldStatePromise({ addFieldStatePromise({
id, id,
config,
data, data,
field, field,
fullData, fullData,

View File

@@ -24,6 +24,7 @@ import wait from '../../../../utilities/wait'
import { requests } from '../../../api' import { requests } from '../../../api'
import useThrottledEffect from '../../../hooks/useThrottledEffect' import useThrottledEffect from '../../../hooks/useThrottledEffect'
import { useAuth } from '../../utilities/Auth' import { useAuth } from '../../utilities/Auth'
import { useConfig } from '../../utilities/Config'
import { useDocumentInfo } from '../../utilities/DocumentInfo' import { useDocumentInfo } from '../../utilities/DocumentInfo'
import { useLocale } from '../../utilities/Locale' import { useLocale } from '../../utilities/Locale'
import { useOperation } from '../../utilities/OperationProvider' import { useOperation } from '../../utilities/OperationProvider'
@@ -62,6 +63,7 @@ const Form: React.FC<Props> = (props) => {
onSubmit, onSubmit,
onSuccess, onSuccess,
redirect, redirect,
submitted: submittedFromProps,
waitForAutocomplete, waitForAutocomplete,
} = props } = props
@@ -72,6 +74,8 @@ const Form: React.FC<Props> = (props) => {
const { id, collection, getDocPreferences, global } = useDocumentInfo() const { id, collection, getDocPreferences, global } = useDocumentInfo()
const operation = useOperation() const operation = useOperation()
const config = useConfig()
const [modified, setModified] = useState(false) const [modified, setModified] = useState(false)
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false)
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
@@ -165,6 +169,7 @@ const Form: React.FC<Props> = (props) => {
if (typeof field.validate === 'function') { if (typeof field.validate === 'function') {
validationResult = await field.validate(field.value, { validationResult = await field.validate(field.value, {
id, id,
config,
data, data,
operation, operation,
siblingData: contextRef.current.getSiblingData(path), siblingData: contextRef.current.getSiblingData(path),
@@ -452,6 +457,7 @@ const Form: React.FC<Props> = (props) => {
if (fieldConfig) { if (fieldConfig) {
const subFieldState = await buildStateFromSchema({ const subFieldState = await buildStateFromSchema({
id, id,
config,
data, data,
fieldSchema: fieldConfig, fieldSchema: fieldConfig,
locale, locale,
@@ -490,6 +496,7 @@ const Form: React.FC<Props> = (props) => {
if (fieldConfig) { if (fieldConfig) {
const subFieldState = await buildStateFromSchema({ const subFieldState = await buildStateFromSchema({
id, id,
config,
data, data,
fieldSchema: fieldConfig, fieldSchema: fieldConfig,
locale, locale,
@@ -557,6 +564,7 @@ const Form: React.FC<Props> = (props) => {
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
id, id,
config,
data, data,
fieldSchema, fieldSchema,
locale, locale,
@@ -601,6 +609,10 @@ const Form: React.FC<Props> = (props) => {
contextRef.current.removeFieldRow = removeFieldRow contextRef.current.removeFieldRow = removeFieldRow
contextRef.current.replaceFieldRow = replaceFieldRow contextRef.current.replaceFieldRow = replaceFieldRow
useEffect(() => {
if (typeof submittedFromProps === 'boolean') setSubmitted(submittedFromProps)
}, [submittedFromProps])
useEffect(() => { useEffect(() => {
if (initialState) { if (initialState) {
contextRef.current = { ...initContextState } as FormContextType contextRef.current = { ...initContextState } as FormContextType

View File

@@ -49,6 +49,7 @@ export type Props = {
onSubmit?: (fields: Fields, data: Data) => void onSubmit?: (fields: Fields, data: Data) => void
onSuccess?: (json: unknown) => void onSuccess?: (json: unknown) => void
redirect?: string redirect?: string
submitted?: boolean
validationOperation?: 'create' | 'update' validationOperation?: 'create' | 'update'
waitForAutocomplete?: boolean waitForAutocomplete?: boolean
} }

View File

@@ -3,12 +3,9 @@ import React from 'react'
import type { RichTextField } from '../../../../../fields/config/types' import type { RichTextField } from '../../../../../fields/config/types'
import type { RichTextAdapter } from './types' import type { RichTextAdapter } from './types'
import { useConfig } from '../../../utilities/Config'
const RichText: React.FC<RichTextField> = (props) => { const RichText: React.FC<RichTextField> = (props) => {
const config = useConfig()
// eslint-disable-next-line react/destructuring-assignment // eslint-disable-next-line react/destructuring-assignment
const editor: RichTextAdapter = props.editor || config.editor const editor: RichTextAdapter = props.editor
return <editor.FieldComponent {...props} /> return <editor.FieldComponent {...props} />
} }

View File

@@ -1,5 +1,5 @@
import type { PayloadRequest } from '../../../../../express/types' 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' import type { CellComponentProps } from '../../../views/collections/List/Cell/types'
export type RichTextFieldProps<AdapterProps = unknown> = Omit< export type RichTextFieldProps<AdapterProps = unknown> = Omit<
@@ -21,4 +21,5 @@ export type RichTextAdapter<AdapterProps = unknown> = {
showHiddenFields: boolean showHiddenFields: boolean
siblingDoc: Record<string, unknown> siblingDoc: Record<string, unknown>
}) => Promise<void> | null }) => Promise<void> | null
validate: Validate<unknown, unknown, RichTextField<AdapterProps>>
} }

View File

@@ -6,6 +6,7 @@ import type { FieldType, Options } from './types'
import useThrottledEffect from '../../../hooks/useThrottledEffect' import useThrottledEffect from '../../../hooks/useThrottledEffect'
import { useAuth } from '../../utilities/Auth' import { useAuth } from '../../utilities/Auth'
import { useConfig } from '../../utilities/Config'
import { useDocumentInfo } from '../../utilities/DocumentInfo' import { useDocumentInfo } from '../../utilities/DocumentInfo'
import { useOperation } from '../../utilities/OperationProvider' import { useOperation } from '../../utilities/OperationProvider'
import { useForm, useFormFields, useFormProcessing, useFormSubmitted } from '../Form/context' import { useForm, useFormFields, useFormProcessing, useFormSubmitted } from '../Form/context'
@@ -26,6 +27,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
const field = useFormFields(([fields]) => fields[path]) const field = useFormFields(([fields]) => fields[path])
const { t } = useTranslation() const { t } = useTranslation()
const dispatchField = useFormFields(([_, dispatch]) => dispatch) const dispatchField = useFormFields(([_, dispatch]) => dispatch)
const config = useConfig()
const { getData, getSiblingData, setModified } = useForm() const { getData, getSiblingData, setModified } = useForm()
@@ -106,6 +108,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
const validateOptions = { const validateOptions = {
id, id,
config,
data: getData(), data: getData(),
operation, operation,
siblingData: getSiblingData(path), siblingData: getSiblingData(path),

View File

@@ -25,6 +25,7 @@ const AccountView: React.FC = () => {
const { id, docPermissions, getDocPermissions, getDocPreferences, preferencesKey, slug } = const { id, docPermissions, getDocPermissions, getDocPreferences, preferencesKey, slug } =
useDocumentInfo() useDocumentInfo()
const { getPreference } = usePreferences() const { getPreference } = usePreferences()
const config = useConfig()
const { const {
admin: { admin: {
@@ -65,6 +66,7 @@ const AccountView: React.FC = () => {
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
id, id,
config,
data: json.doc, data: json.doc,
fieldSchema: collection.fields, fieldSchema: collection.fields,
locale, locale,
@@ -94,6 +96,7 @@ const AccountView: React.FC = () => {
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
id, id,
config,
data: dataToRender, data: dataToRender,
fieldSchema: fields, fieldSchema: fields,
locale, locale,

View File

@@ -29,6 +29,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
useDocumentInfo() useDocumentInfo()
const { getPreference } = usePreferences() const { getPreference } = usePreferences()
const { t } = useTranslation() const { t } = useTranslation()
const config = useConfig()
const { const {
routes: { api }, routes: { api },
@@ -44,6 +45,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
setUpdatedAt(json?.result?.updatedAt) setUpdatedAt(json?.result?.updatedAt)
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
config,
data: json.result, data: json.result,
fieldSchema: fields, fieldSchema: fields,
locale, locale,
@@ -68,6 +70,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
const awaitInitialState = async () => { const awaitInitialState = async () => {
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
config,
data: dataToRender, data: dataToRender,
fieldSchema: fields, fieldSchema: fields,
locale, locale,

View File

@@ -31,10 +31,11 @@ const EditView: React.FC<IndexProps> = (props) => {
const { code: locale } = useLocale() const { code: locale } = useLocale()
const config = useConfig()
const { const {
routes: { admin, api }, routes: { admin, api },
serverURL, serverURL,
} = useConfig() } = config
const { params: { id } = {} } = useRouteMatch<Record<string, string>>() const { params: { id } = {} } = useRouteMatch<Record<string, string>>()
const history = useHistory() const history = useHistory()
@@ -56,6 +57,7 @@ const EditView: React.FC<IndexProps> = (props) => {
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
id, id,
config,
data: doc || {}, data: doc || {},
fieldSchema: overrides.fieldSchema, fieldSchema: overrides.fieldSchema,
locale, locale,

View File

@@ -4,12 +4,9 @@ import type { RichTextField } from '../../../../../../../../fields/config/types'
import type { RichTextAdapter } from '../../../../../../forms/field-types/RichText/types' import type { RichTextAdapter } from '../../../../../../forms/field-types/RichText/types'
import type { CellComponentProps } from '../../types' import type { CellComponentProps } from '../../types'
import { useConfig } from '../../../../../../utilities/Config'
const RichTextCell: React.FC<CellComponentProps<RichTextField>> = (props) => { const RichTextCell: React.FC<CellComponentProps<RichTextField>> = (props) => {
const config = useConfig()
// eslint-disable-next-line react/destructuring-assignment // eslint-disable-next-line react/destructuring-assignment
const editor: RichTextAdapter = props.field.editor || config.editor const editor: RichTextAdapter = props.field.editor
return <editor.CellComponent {...props} /> return <editor.CellComponent {...props} />
} }

View File

@@ -86,6 +86,7 @@ export default joi.object({
CellComponent: component.required(), CellComponent: component.required(),
FieldComponent: component.required(), FieldComponent: component.required(),
afterReadPromise: joi.func().required(), afterReadPromise: joi.func().required(),
validate: joi.func().required(),
}), }),
email: joi.object(), email: joi.object(),
endpoints: endpointsSchema, endpoints: endpointsSchema,

View File

@@ -32,6 +32,11 @@ export const sanitizeFields = ({ config, fields, validRelationships }: Args): Fi
throw new InvalidFieldName(field, field.name) 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 // Auto-label
if ( if (
'name' in field && 'name' in field &&

View File

@@ -358,6 +358,7 @@ export const richText = baseField.keys({
CellComponent: componentSchema.required(), CellComponent: componentSchema.required(),
FieldComponent: componentSchema.required(), FieldComponent: componentSchema.required(),
afterReadPromise: joi.func().required(), afterReadPromise: joi.func().required(),
validate: joi.func().required(),
}), }),
type: joi.string().valid('richText').required(), type: joi.string().valid('richText').required(),
}) })

View File

@@ -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 { RichTextAdapter } from '../../admin/components/forms/field-types/RichText/types'
import type { User } from '../../auth' import type { User } from '../../auth'
import type { TypeWithID } from '../../collections/config/types' import type { TypeWithID } from '../../collections/config/types'
import type { SanitizedConfig } from '../../config/types'
import type { PayloadRequest, RequestContext } from '../../express/types' import type { PayloadRequest, RequestContext } from '../../express/types'
import type { Payload } from '../../payload' import type { Payload } from '../../payload'
import type { Operation, Where } from '../../types' import type { Operation, Where } from '../../types'
@@ -91,6 +92,7 @@ export type Labels = {
} }
export type ValidateOptions<TData, TSiblingData, TFieldConfig> = { export type ValidateOptions<TData, TSiblingData, TFieldConfig> = {
config: SanitizedConfig
data: Partial<TData> data: Partial<TData>
id?: number | string id?: number | string
operation?: Operation operation?: Operation
@@ -100,6 +102,7 @@ export type ValidateOptions<TData, TSiblingData, TFieldConfig> = {
user?: Partial<User> user?: Partial<User>
} & TFieldConfig } & TFieldConfig
// TODO: Having TFieldConfig as any breaks all type checking / auto-completions for the base ValidateOptions properties.
export type Validate<TValue = any, TData = any, TSiblingData = any, TFieldConfig = any> = ( export type Validate<TValue = any, TData = any, TSiblingData = any, TFieldConfig = any> = (
value: TValue, value: TValue,
options: ValidateOptions<TData, TSiblingData, TFieldConfig>, options: ValidateOptions<TData, TSiblingData, TFieldConfig>,

View File

@@ -128,7 +128,7 @@ export const promise = async ({
} }
case 'richText': { case 'richText': {
const editor: RichTextAdapter = field?.editor || req?.payload?.config?.editor const editor: RichTextAdapter = field?.editor
if (editor?.afterReadPromise) { if (editor?.afterReadPromise) {
const afterReadPromise = editor.afterReadPromise({ const afterReadPromise = editor.afterReadPromise({
currentDepth, currentDepth,

View File

@@ -111,6 +111,7 @@ export const promise = async ({
const validationResult = await field.validate(valueToValidate, { const validationResult = await field.validate(valueToValidate, {
...field, ...field,
id, id,
config: req.payload.config,
data: merge(doc, data, { arrayMerge: (_, source) => source }), data: merge(doc, data, { arrayMerge: (_, source) => source }),
jsonError, jsonError,
operation, operation,

View File

@@ -1,3 +1,4 @@
import type { RichTextAdapter } from '../exports/types'
import type { import type {
ArrayField, ArrayField,
BlockField, BlockField,
@@ -11,6 +12,7 @@ import type {
RadioField, RadioField,
RelationshipField, RelationshipField,
RelationshipValue, RelationshipValue,
RichTextField,
SelectField, SelectField,
TextField, TextField,
TextareaField, TextareaField,
@@ -212,6 +214,15 @@ export const date: Validate<unknown, unknown, DateField> = (value, { required, t
return true return true
} }
export const richText: Validate<unknown, unknown, RichTextField, RichTextField> = async (
value,
options,
) => {
const editor: RichTextAdapter = options?.editor
return await editor.validate(value, options)
}
const validateFilterOptions: Validate = async ( const validateFilterOptions: Validate = async (
value, value,
{ id, data, filterOptions, payload, relationTo, siblingData, t, user }, { id, data, filterOptions, payload, relationTo, siblingData, t, user },
@@ -507,6 +518,7 @@ export default {
point, point,
radio, radio,
relationship, relationship,
richText,
select, select,
text, text,
textarea, textarea,

View File

@@ -427,7 +427,7 @@ function buildObjectType({
async resolve(parent, args, context) { async resolve(parent, args, context) {
let depth = payload.config.defaultDepth let depth = payload.config.defaultDepth
if (typeof args.depth !== 'undefined') depth = args.depth if (typeof args.depth !== 'undefined') depth = args.depth
const editor: RichTextAdapter = field?.editor || payload?.config?.editor const editor: RichTextAdapter = field?.editor
if (editor?.afterReadPromise) { if (editor?.afterReadPromise) {
await editor?.afterReadPromise({ await editor?.afterReadPromise({

View File

@@ -6,7 +6,7 @@ import { ErrorBoundary } from 'react-error-boundary'
import type { FieldProps } from '../types' import type { FieldProps } from '../types'
import { richTextValidate } from '../populate/validation' import { richTextValidate } from '../validate'
import './index.scss' import './index.scss'
import { LexicalProvider } from './lexical/LexicalProvider' import { LexicalProvider } from './lexical/LexicalProvider'
@@ -36,9 +36,9 @@ const RichText: React.FC<FieldProps> = (props) => {
const memoizedValidate = useCallback( const memoizedValidate = useCallback(
(value, validationOptions) => { (value, validationOptions) => {
return validate(value, { ...validationOptions, required }) return validate(value, { ...validationOptions, props, required })
}, },
[validate, required], [validate, required, props],
) )
const fieldType = useField<SerializedEditorState>({ const fieldType = useField<SerializedEditorState>({

View File

@@ -1,5 +1,5 @@
import { type ElementFormatType } from 'lexical' 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 React, { useMemo } from 'react'
import { type BlockFields } from '../nodes/BlocksNode' import { type BlockFields } from '../nodes/BlocksNode'
@@ -27,6 +27,7 @@ type Props = {
export const BlockComponent: React.FC<Props> = (props) => { export const BlockComponent: React.FC<Props> = (props) => {
const { children, className, fields, format, nodeKey } = props const { children, className, fields, format, nodeKey } = props
const payloadConfig = useConfig() const payloadConfig = useConfig()
const submitted = useFormSubmitted()
const { editorConfig, field } = useEditorConfigContext() const { editorConfig, field } = useEditorConfigContext()
@@ -48,7 +49,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
const formContent = useMemo(() => { const formContent = useMemo(() => {
return ( return (
block && ( block && (
<Form initialState={initialDataRef?.current}> <Form initialState={initialDataRef?.current} submitted={submitted}>
<BlockContent <BlockContent
baseClass={baseClass} baseClass={baseClass}
block={block} block={block}
@@ -59,7 +60,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
</Form> </Form>
) )
) )
}, [block, field, nodeKey]) }, [block, field, nodeKey, submitted])
return <div className={baseClass}>{formContent}</div> return <div className={baseClass}>{formContent}</div>
} }

View File

@@ -8,10 +8,10 @@ import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu' import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { BlockIcon } from '../../lexical/ui/icons/Block' import { BlockIcon } from '../../lexical/ui/icons/Block'
import { blockAfterReadPromiseHOC } from './afterReadPromise' import { blockAfterReadPromiseHOC } from './afterReadPromise'
import { INSERT_BLOCK_WITH_DRAWER_COMMAND } from './drawer'
import './index.scss' import './index.scss'
import { BlockNode } from './nodes/BlocksNode' import { BlockNode } from './nodes/BlocksNode'
import { BlocksPlugin, INSERT_BLOCK_COMMAND } from './plugin' import { BlocksPlugin, INSERT_BLOCK_COMMAND } from './plugin'
import { blockValidationHOC } from './validate'
export type BlocksFeatureProps = { export type BlocksFeatureProps = {
blocks: Block[] blocks: Block[]
@@ -45,6 +45,7 @@ export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => {
afterReadPromises: [blockAfterReadPromiseHOC(props)], afterReadPromises: [blockAfterReadPromiseHOC(props)],
node: BlockNode, node: BlockNode,
type: BlockNode.getType(), type: BlockNode.getType(),
validations: [blockValidationHOC(props)],
}, },
], ],
plugins: [ plugins: [

View File

@@ -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<SerializedBlockNode> => {
const blockValidation: NodeValidation<SerializedBlockNode> = 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
}

View File

@@ -132,6 +132,7 @@ export function LinkEditor({
// values saved in the link node you clicked on. // values saved in the link node you clicked on.
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
config,
data, data,
fieldSchema, fieldSchema,
locale, locale,

View File

@@ -8,6 +8,7 @@ import { Form, FormSubmit, RenderFields } from 'payload/components/forms'
import { import {
buildStateFromSchema, buildStateFromSchema,
useAuth, useAuth,
useConfig,
useDocumentInfo, useDocumentInfo,
useLocale, useLocale,
} from 'payload/components/utilities' } from 'payload/components/utilities'
@@ -51,6 +52,7 @@ export const ExtraFieldsUploadDrawer: React.FC<
const [initialState, setInitialState] = useState({}) const [initialState, setInitialState] = useState({})
const fieldSchema = (editorConfig?.resolvedFeatureMap.get('upload')?.props as UploadFeatureProps) const fieldSchema = (editorConfig?.resolvedFeatureMap.get('upload')?.props as UploadFeatureProps)
?.collections?.[relatedCollection.slug]?.fields ?.collections?.[relatedCollection.slug]?.fields
const config = useConfig()
const handleUpdateEditData = useCallback( const handleUpdateEditData = useCallback(
(_, data) => { (_, data) => {
@@ -75,6 +77,7 @@ export const ExtraFieldsUploadDrawer: React.FC<
const awaitInitialState = async () => { const awaitInitialState = async () => {
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
config,
data: deepCopyObject(fields || {}), data: deepCopyObject(fields || {}),
fieldSchema, fieldSchema,
locale, locale,

View File

@@ -1,7 +1,8 @@
import type { Transformer } from '@lexical/markdown' 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 { 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 React from 'react'
import type { AdapterProps } from '../../types' import type { AdapterProps } from '../../types'
@@ -28,6 +29,22 @@ export type AfterReadPromise<T extends SerializedLexicalNode = SerializedLexical
req: PayloadRequest req: PayloadRequest
showHiddenFields: boolean showHiddenFields: boolean
}) => Promise<void>[] }) => Promise<void>[]
export type NodeValidation<T extends SerializedLexicalNode = SerializedLexicalNode> = ({
node,
nodeValidations,
payloadConfig,
validation,
}: {
node: T
nodeValidations: Map<string, Array<NodeValidation>>
payloadConfig: SanitizedConfig
validation: {
options: ValidateOptions<SerializedEditorState, unknown, RichTextField>
value: SerializedEditorState
}
}) => Promise<string | true> | string | true
export type Feature = { export type Feature = {
floatingSelectToolbar?: { floatingSelectToolbar?: {
sections: FloatingToolbarSection[] sections: FloatingToolbarSection[]
@@ -37,6 +54,7 @@ export type Feature = {
afterReadPromises?: Array<AfterReadPromise> afterReadPromises?: Array<AfterReadPromise>
node: Klass<LexicalNode> node: Klass<LexicalNode>
type: string type: string
validations?: Array<NodeValidation>
}> }>
plugins?: Array< plugins?: Array<
| { | {
@@ -124,4 +142,6 @@ export type SanitizedFeatures = Required<
> >
groupsWithOptions: SlashMenuGroup[] groupsWithOptions: SlashMenuGroup[]
} }
/** The node types mapped to their validations */
validations: Map<string, Array<NodeValidation>>
} }

View File

@@ -17,6 +17,7 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
dynamicOptions: [], dynamicOptions: [],
groupsWithOptions: [], groupsWithOptions: [],
}, },
validations: new Map(),
} }
features.forEach((feature) => { features.forEach((feature) => {
@@ -26,6 +27,9 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
if (node?.afterReadPromises?.length) { if (node?.afterReadPromises?.length) {
sanitized.afterReadPromises.set(node.type, node.afterReadPromises) sanitized.afterReadPromises.set(node.type, node.afterReadPromises)
} }
if (node?.validations?.length) {
sanitized.validations.set(node.type, node.validations)
}
}) })
} }
if (feature.plugins?.length) { if (feature.plugins?.length) {

View File

@@ -11,6 +11,7 @@ import { defaultEditorConfig, defaultSanitizedEditorConfig } from './field/lexic
import { sanitizeEditorConfig } from './field/lexical/config/sanitize' import { sanitizeEditorConfig } from './field/lexical/config/sanitize'
import { cloneDeep } from './field/lexical/utils/cloneDeep' import { cloneDeep } from './field/lexical/utils/cloneDeep'
import { richTextRelationshipPromise } from './populate/richTextRelationshipPromise' import { richTextRelationshipPromise } from './populate/richTextRelationshipPromise'
import { richTextValidateHOC } from './validate'
export function lexicalEditor({ export function lexicalEditor({
userConfig, userConfig,
@@ -56,6 +57,9 @@ export function lexicalEditor({
return null return null
}, },
validate: richTextValidateHOC({
editorConfig: finalSanitizedEditorConfig,
}),
} }
} }

View File

@@ -1,18 +0,0 @@
import type { RichTextField, Validate } from 'payload/types'
import type { AdapterProps } from '../types'
import { defaultRichTextValue } from './defaultValue'
export const richTextValidate: Validate<unknown, unknown, RichTextField<AdapterProps>> = (
value,
{ required, t },
) => {
if (required) {
const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue)
if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true
return t('validation:required')
}
return true
}

View File

@@ -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
}

View File

@@ -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<string, Array<NodeValidation>>
nodes: SerializedLexicalNode[]
payloadConfig: SanitizedConfig
validation: {
options: ValidateOptions<SerializedEditorState, unknown, RichTextField>
value: SerializedEditorState
}
}): Promise<string | true> {
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
}

View File

@@ -4,10 +4,12 @@ import type { AdapterArguments } from '../types'
import { defaultRichTextValue } from './defaultValue' import { defaultRichTextValue } from './defaultValue'
export const richText: Validate<unknown, unknown, RichTextField<AdapterArguments>> = ( export const richTextValidate: Validate<
value, unknown,
{ required, t }, unknown,
) => { RichTextField<AdapterArguments>,
RichTextField<AdapterArguments>
> = (value, { required, t }) => {
if (required) { if (required) {
const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue) const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue)
if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true

View File

@@ -15,7 +15,7 @@ import { Editable, Slate, withReact } from 'slate-react'
import type { ElementNode, FieldProps, RichTextElement, RichTextLeaf, TextNode } from '../types' import type { ElementNode, FieldProps, RichTextElement, RichTextLeaf, TextNode } from '../types'
import { defaultRichTextValue } from '../data/defaultValue' import { defaultRichTextValue } from '../data/defaultValue'
import { richText } from '../data/validation' import { richTextValidate } from '../data/validation'
import elementTypes from './elements' import elementTypes from './elements'
import listTypes from './elements/listTypes' import listTypes from './elements/listTypes'
import enablePlugins from './enablePlugins' import enablePlugins from './enablePlugins'
@@ -80,7 +80,7 @@ const RichText: React.FC<FieldProps> = (props) => {
label, label,
path: pathFromProps, path: pathFromProps,
required, required,
validate = richText, validate = richTextValidate,
} = props } = props
const elements: RichTextElement[] = admin?.elements || defaultElements const elements: RichTextElement[] = admin?.elements || defaultElements

View File

@@ -104,6 +104,7 @@ export const LinkButton: React.FC<{
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
config,
data, data,
fieldSchema, fieldSchema,
locale, locale,

View File

@@ -103,6 +103,7 @@ export const LinkElement: React.FC<{
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
config,
data, data,
fieldSchema, fieldSchema,
locale, locale,

View File

@@ -6,6 +6,7 @@ import { Form, FormSubmit, RenderFields } from 'payload/components/forms'
import { import {
buildStateFromSchema, buildStateFromSchema,
useAuth, useAuth,
useConfig,
useDocumentInfo, useDocumentInfo,
useLocale, useLocale,
} from 'payload/components/utilities' } from 'payload/components/utilities'
@@ -35,6 +36,7 @@ export const UploadDrawer: React.FC<
const { getDocPreferences } = useDocumentInfo() const { getDocPreferences } = useDocumentInfo()
const [initialState, setInitialState] = useState({}) const [initialState, setInitialState] = useState({})
const fieldSchema = fieldProps?.admin?.upload?.collections?.[relatedCollection.slug]?.fields const fieldSchema = fieldProps?.admin?.upload?.collections?.[relatedCollection.slug]?.fields
const config = useConfig()
const handleUpdateEditData = useCallback( const handleUpdateEditData = useCallback(
(_, data) => { (_, data) => {
@@ -54,6 +56,7 @@ export const UploadDrawer: React.FC<
const awaitInitialState = async () => { const awaitInitialState = async () => {
const preferences = await getDocPreferences() const preferences = await getDocPreferences()
const state = await buildStateFromSchema({ const state = await buildStateFromSchema({
config,
data: deepCopyObject(element?.fields || {}), data: deepCopyObject(element?.fields || {}),
fieldSchema, fieldSchema,
locale, locale,

View File

@@ -6,6 +6,7 @@ import type { AdapterArguments } from './types'
import RichTextCell from './cell' import RichTextCell from './cell'
import { richTextRelationshipPromise } from './data/richTextRelationshipPromise' import { richTextRelationshipPromise } from './data/richTextRelationshipPromise'
import { richTextValidate } from './data/validation'
import RichTextField from './field' import RichTextField from './field'
export function slateEditor(args: AdapterArguments): RichTextAdapter<AdapterArguments> { export function slateEditor(args: AdapterArguments): RichTextAdapter<AdapterArguments> {
@@ -45,5 +46,6 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<AdapterArgu
} }
return null return null
}, },
validate: richTextValidate,
} }
} }