From 647cac3612c02dc164254e050ef589a88077b55f Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 28 Mar 2022 12:58:39 -0400 Subject: [PATCH 01/19] feat: WIP extended validation function arguments --- demo/collections/RelationshipA.ts | 4 + demo/collections/Validations.ts | 16 +- .../forms/Form/buildStateFromSchema.spec.js | 2 +- .../forms/Form/buildStateFromSchema.ts | 44 +++++- src/admin/components/forms/Form/index.tsx | 23 ++- src/admin/components/forms/Form/types.ts | 6 +- .../forms/field-types/Array/Array.tsx | 11 +- .../forms/field-types/Blocks/Blocks.tsx | 18 +-- .../forms/field-types/Checkbox/index.tsx | 8 +- .../forms/field-types/Code/Code.tsx | 9 +- .../forms/field-types/DateTime/index.tsx | 7 +- .../forms/field-types/Email/index.tsx | 7 +- .../forms/field-types/Number/index.tsx | 7 +- .../forms/field-types/Password/index.tsx | 8 +- .../forms/field-types/Point/index.tsx | 7 +- .../forms/field-types/RadioGroup/index.tsx | 7 +- .../forms/field-types/Relationship/index.tsx | 7 +- .../forms/field-types/RichText/RichText.tsx | 7 +- .../upload/Element/EditModal/index.tsx | 3 +- .../forms/field-types/Select/index.tsx | 8 +- .../forms/field-types/Text/index.tsx | 7 +- .../forms/field-types/Textarea/index.tsx | 9 +- .../forms/field-types/Upload/Add/index.tsx | 1 + .../forms/field-types/Upload/index.tsx | 7 +- src/admin/components/forms/useField/index.tsx | 30 +++- .../components/views/Account/Default.tsx | 1 + src/admin/components/views/Account/index.tsx | 2 +- .../views/CreateFirstUser/index.tsx | 1 + src/admin/components/views/Global/Default.tsx | 1 + src/admin/components/views/Global/index.tsx | 4 +- src/admin/components/views/Login/index.tsx | 1 + .../views/collections/Edit/Default.tsx | 1 + .../views/collections/Edit/index.tsx | 4 +- src/fields/config/sanitize.ts | 2 +- src/fields/config/types.ts | 13 +- src/fields/traverseFields.ts | 18 ++- src/fields/validationPromise.ts | 20 ++- src/fields/validations.spec.ts | 148 +++++++++++------- src/fields/validations.ts | 138 ++++++++-------- 39 files changed, 383 insertions(+), 234 deletions(-) diff --git a/demo/collections/RelationshipA.ts b/demo/collections/RelationshipA.ts index ba78729223..0e6f4f7ea3 100644 --- a/demo/collections/RelationshipA.ts +++ b/demo/collections/RelationshipA.ts @@ -1,4 +1,5 @@ import { CollectionConfig } from '../../src/collections/config/types'; +import { Where } from '../../src/types'; const RelationshipA: CollectionConfig = { slug: 'relationship-a', @@ -23,6 +24,9 @@ const RelationshipA: CollectionConfig = { relationTo: 'localized-posts', hasMany: true, localized: true, + // filterOptions: (args: Args): Where => ({ + // + // }), }, { name: 'postLocalizedMultiple', diff --git a/demo/collections/Validations.ts b/demo/collections/Validations.ts index cf8bd95253..8a5b98fadf 100644 --- a/demo/collections/Validations.ts +++ b/demo/collections/Validations.ts @@ -10,6 +10,20 @@ const Validations: CollectionConfig = { read: () => true, }, fields: [ + { + name: 'textWithOptions', + type: 'text', + label: 'Text with Options', + required: true, + // validate: (value: string, args) => { + // console.log(args); + // console.log(value); + // if (args?.data?.text !== 'test') { + // return 'The next field should be test'; + // } + // return true; + // }, + }, { name: 'text', type: 'text', @@ -58,7 +72,7 @@ const Validations: CollectionConfig = { name: 'atLeast3Rows', required: true, validate: (value) => { - const result = value && value.length >= 3; + const result = value >= 3; if (!result) { return 'This array needs to have at least 3 rows.'; diff --git a/src/admin/components/forms/Form/buildStateFromSchema.spec.js b/src/admin/components/forms/Form/buildStateFromSchema.spec.js index 388e08efba..07aed87a17 100644 --- a/src/admin/components/forms/Form/buildStateFromSchema.spec.js +++ b/src/admin/components/forms/Form/buildStateFromSchema.spec.js @@ -11,7 +11,7 @@ describe('Form - buildStateFromSchema', () => { defaultValue, }, ]; - const state = await buildStateFromSchema(fieldSchema, {}); + const state = await buildStateFromSchema({ fieldSchema }); expect(state.text.value).toBe(defaultValue); }); }); diff --git a/src/admin/components/forms/Form/buildStateFromSchema.ts b/src/admin/components/forms/Form/buildStateFromSchema.ts index bb57849e9b..34b91adf51 100644 --- a/src/admin/components/forms/Form/buildStateFromSchema.ts +++ b/src/admin/components/forms/Form/buildStateFromSchema.ts @@ -1,14 +1,21 @@ import ObjectID from 'bson-objectid'; -import { Field as FieldSchema, fieldAffectsData, FieldAffectingData, fieldIsPresentationalOnly } from '../../../../fields/config/types'; +import { User } from '../../../../auth'; +import { + Field as FieldSchema, + fieldAffectsData, + FieldAffectingData, + fieldIsPresentationalOnly, + ValidateOptions, +} from '../../../../fields/config/types'; import { Fields, Field, Data } from './types'; -const buildValidationPromise = async (fieldState: Field, field: FieldAffectingData) => { +const buildValidationPromise = async (fieldState: Field, options: ValidateOptions) => { const validatedFieldState = fieldState; let validationResult: boolean | string = true; - if (typeof field.validate === 'function') { - validationResult = await field.validate(fieldState.value, field); + if (typeof options.field.validate === 'function') { + validationResult = await options.field.validate(fieldState.value, options); } if (typeof validationResult === 'string') { @@ -19,7 +26,24 @@ const buildValidationPromise = async (fieldState: Field, field: FieldAffectingDa } }; -const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data = {}): Promise => { +type Props = { + fieldSchema: FieldSchema[] + data?: Data, + siblingData?: Data, + user?: User, + id?: string | number, + operation?: 'create' | 'update' +} + +const buildStateFromSchema = async (props: Props): Promise => { + const { + fieldSchema, + data: fullData = {}, + siblingData = {}, + user, + id, + operation, + } = props; if (fieldSchema) { const validationPromises = []; @@ -27,6 +51,7 @@ const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data = const value = typeof data?.[field.name] !== 'undefined' ? data[field.name] : field.defaultValue; const fieldState = { + field, value, initialValue: value, valid: true, @@ -35,7 +60,14 @@ const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data = passesCondition, }; - validationPromises.push(buildValidationPromise(fieldState, field)); + validationPromises.push(buildValidationPromise(fieldState, { + field, + data: fullData, + user, + siblingData, + id, + operation, + })); return fieldState; }; diff --git a/src/admin/components/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index 1101c62dd4..0d30e0f5a6 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom'; import { toast } from 'react-toastify'; import { useAuth } from '@payloadcms/config-provider'; import { useLocale } from '../../utilities/Locale'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; import { requests } from '../../../api'; import useThrottledEffect from '../../../hooks/useThrottledEffect'; import fieldReducer from './fieldReducer'; @@ -21,7 +22,7 @@ import { Context as FormContextType, Props, SubmitOptions } from './types'; import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FormWatchContext } from './context'; import buildStateFromSchema from './buildStateFromSchema'; -import { Field } from '../../../../fields/config/types'; +import { Field, FieldAffectingData } from '../../../../fields/config/types'; const baseClass = 'form'; @@ -41,11 +42,13 @@ const Form: React.FC = (props) => { initialData, // values only, paths are required as key - form should build initial state as convenience waitForAutocomplete, log, + validationOperation, } = props; const history = useHistory(); const locale = useLocale(); - const { refreshCookie } = useAuth(); + const { refreshCookie, user } = useAuth(); + const { id } = useDocumentInfo(); const [modified, setModified] = useState(false); const [processing, setProcessing] = useState(false); @@ -70,6 +73,7 @@ const Form: React.FC = (props) => { const validateForm = useCallback(async () => { const validatedFieldState = {}; let isValid = true; + const data = contextRef.current.getData(); const validationPromises = Object.entries(contextRef.current.fields).map(async ([path, field]) => { const validatedField = { @@ -81,7 +85,14 @@ const Form: React.FC = (props) => { let validationResult: boolean | string = true; if (typeof field.validate === 'function') { - validationResult = await field.validate(field.value); + validationResult = await field.validate(field.value, { + field: (field.field as unknown as FieldAffectingData), + data, + siblingData: contextRef.current.getSiblingData(path), + user, + id, + operation: validationOperation, + }); } if (typeof validationResult === 'string') { @@ -99,7 +110,7 @@ const Form: React.FC = (props) => { dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState }); return isValid; - }, [contextRef]); + }, [contextRef, id, user, validationOperation]); const submit = useCallback(async (options: SubmitOptions = {}, e): Promise => { const { @@ -313,10 +324,10 @@ const Form: React.FC = (props) => { }, [contextRef]); const reset = useCallback(async (fieldSchema: Field[], data: unknown) => { - const state = await buildStateFromSchema(fieldSchema, data); + const state = await buildStateFromSchema({ fieldSchema, data, user, id, operation: validationOperation }); contextRef.current = { ...initContextState } as FormContextType; dispatchFields({ type: 'REPLACE_STATE', state }); - }, []); + }, [id, user, validationOperation]); contextRef.current.dispatchFields = dispatchFields; contextRef.current.submit = submit; diff --git a/src/admin/components/forms/Form/types.ts b/src/admin/components/forms/Form/types.ts index cde5d07935..2ebd6f0a6b 100644 --- a/src/admin/components/forms/Form/types.ts +++ b/src/admin/components/forms/Form/types.ts @@ -1,11 +1,12 @@ -import { Field as FieldConfig, Condition } from '../../../../fields/config/types'; +import { Field as FieldConfig, Condition, Validate } from '../../../../fields/config/types'; export type Field = { value: unknown + field: Field initialValue: unknown errorMessage?: string valid: boolean - validate?: (val: unknown) => Promise | boolean | string + validate?: Validate disableFormData?: boolean ignoreWhileFlattening?: boolean condition?: Condition @@ -38,6 +39,7 @@ export type Props = { initialData?: Data waitForAutocomplete?: boolean log?: boolean + validationOperation?: 'create' | 'update' } export type SubmitOptions = { diff --git a/src/admin/components/forms/field-types/Array/Array.tsx b/src/admin/components/forms/field-types/Array/Array.tsx index 9de5c2985c..047cafabb8 100644 --- a/src/admin/components/forms/field-types/Array/Array.tsx +++ b/src/admin/components/forms/field-types/Array/Array.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useReducer, useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useReducer, useState } from 'react'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import withCondition from '../../withCondition'; @@ -54,10 +54,9 @@ const ArrayFieldType: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { minRows, maxRows, required }); - return validationResult; - }, [validate, maxRows, minRows, required]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const [disableFormData, setDisableFormData] = useState(false); @@ -75,7 +74,7 @@ const ArrayFieldType: React.FC = (props) => { }); const addRow = useCallback(async (rowIndex) => { - const subFieldState = await buildStateFromSchema(fields); + const subFieldState = await buildStateFromSchema({ fieldSchema: fields }); dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path }); dispatchRows({ type: 'ADD', rowIndex }); setValue(value as number + 1); diff --git a/src/admin/components/forms/field-types/Blocks/Blocks.tsx b/src/admin/components/forms/field-types/Blocks/Blocks.tsx index 946931257f..ac5605a1a9 100644 --- a/src/admin/components/forms/field-types/Blocks/Blocks.tsx +++ b/src/admin/components/forms/field-types/Blocks/Blocks.tsx @@ -1,6 +1,4 @@ -import React, { - useEffect, useReducer, useCallback, useState, -} from 'react'; +import React, { useCallback, useEffect, useReducer, useState } from 'react'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; import { usePreferences } from '../../../utilities/Preferences'; @@ -59,15 +57,9 @@ const Blocks: React.FC = (props) => { const formContext = useForm(); const { dispatchFields } = formContext; - const memoizedValidate = useCallback((value) => { - const validationResult = validate( - value, - { - minRows, maxRows, labels, blocks, required, - }, - ); - return validationResult; - }, [validate, maxRows, minRows, labels, blocks, required]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const [disableFormData, setDisableFormData] = useState(false); @@ -87,7 +79,7 @@ const Blocks: React.FC = (props) => { const addRow = useCallback(async (rowIndex, blockType) => { const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType); - const subFieldState = await buildStateFromSchema(block.fields); + const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields }); dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType }); dispatchRows({ type: 'ADD', rowIndex, blockType }); diff --git a/src/admin/components/forms/field-types/Checkbox/index.tsx b/src/admin/components/forms/field-types/Checkbox/index.tsx index e5897aff9b..f53defdfb8 100644 --- a/src/admin/components/forms/field-types/Checkbox/index.tsx +++ b/src/admin/components/forms/field-types/Checkbox/index.tsx @@ -15,7 +15,6 @@ const Checkbox: React.FC = (props) => { const { name, path: pathFromProps, - required, validate = checkbox, label, onChange, @@ -32,10 +31,9 @@ const Checkbox: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; - }, [validate, required]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/Code/Code.tsx b/src/admin/components/forms/field-types/Code/Code.tsx index d107305f95..a9fa9848e7 100644 --- a/src/admin/components/forms/field-types/Code/Code.tsx +++ b/src/admin/components/forms/field-types/Code/Code.tsx @@ -29,8 +29,6 @@ const Code: React.FC = (props) => { condition, } = {}, label, - minLength, - maxLength, } = props; const [highlighter] = useState(() => { @@ -43,10 +41,9 @@ const Code: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { minLength, maxLength, required }); - return validationResult; - }, [validate, maxLength, minLength, required]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/DateTime/index.tsx b/src/admin/components/forms/field-types/DateTime/index.tsx index 840b4f1b85..eef6121713 100644 --- a/src/admin/components/forms/field-types/DateTime/index.tsx +++ b/src/admin/components/forms/field-types/DateTime/index.tsx @@ -34,10 +34,9 @@ const DateTime: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; - }, [validate, required]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/Email/index.tsx b/src/admin/components/forms/field-types/Email/index.tsx index 8299af5b1e..3ec21d110d 100644 --- a/src/admin/components/forms/field-types/Email/index.tsx +++ b/src/admin/components/forms/field-types/Email/index.tsx @@ -30,10 +30,9 @@ const Email: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; - }, [validate, required]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const fieldType = useField({ path, diff --git a/src/admin/components/forms/field-types/Number/index.tsx b/src/admin/components/forms/field-types/Number/index.tsx index 9a9445da78..13bca4e064 100644 --- a/src/admin/components/forms/field-types/Number/index.tsx +++ b/src/admin/components/forms/field-types/Number/index.tsx @@ -32,10 +32,9 @@ const NumberField: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { min, max, required }); - return validationResult; - }, [validate, max, min, required]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/Password/index.tsx b/src/admin/components/forms/field-types/Password/index.tsx index 3206fd34c1..5bbd6607da 100644 --- a/src/admin/components/forms/field-types/Password/index.tsx +++ b/src/admin/components/forms/field-types/Password/index.tsx @@ -23,10 +23,10 @@ const Password: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; - }, [validate, required]); + const memoizedValidate = useCallback((value, options) => { + debugger; + return validate(value, options); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/Point/index.tsx b/src/admin/components/forms/field-types/Point/index.tsx index 6985d66c8b..9143cf3ab1 100644 --- a/src/admin/components/forms/field-types/Point/index.tsx +++ b/src/admin/components/forms/field-types/Point/index.tsx @@ -32,10 +32,9 @@ const PointField: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; - }, [validate, required]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const { value = [null, null], diff --git a/src/admin/components/forms/field-types/RadioGroup/index.tsx b/src/admin/components/forms/field-types/RadioGroup/index.tsx index 50c349fc10..7285dad252 100644 --- a/src/admin/components/forms/field-types/RadioGroup/index.tsx +++ b/src/admin/components/forms/field-types/RadioGroup/index.tsx @@ -35,10 +35,9 @@ const RadioGroup: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required, options }); - return validationResult; - }, [validate, required, options]); + const memoizedValidate = useCallback((value, validationOptions) => { + return validate(value, validationOptions); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index 450c637197..3aa918e5c0 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -62,10 +62,9 @@ const Relationship: React.FC = (props) => { const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false); const [search, setSearch] = useState(''); - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; - }, [validate, required]); + const memoizedValidate = useCallback((value, validationOptions) => { + return validate(value, validationOptions); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index 95f6aaff01..527a7c8f21 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -117,10 +117,9 @@ const RichText: React.FC = (props) => { ); }, [enabledLeaves, path, props]); - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; - }, [validate, required]); + const memoizedValidate = useCallback((value, validationOptions) => { + return validate(value, validationOptions); + }, [validate]); const fieldType = useField({ path, diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx index 29b946d1a2..7e68f01e1b 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx @@ -47,7 +47,7 @@ export const EditModal: React.FC = ({ slug, closeModal, relatedCollection useEffect(() => { const awaitInitialState = async () => { - const state = await buildStateFromSchema(fieldSchema, element?.fields); + const state = await buildStateFromSchema({ fieldSchema, data: element?.fields }); setInitialState(state); }; @@ -80,6 +80,7 @@ export const EditModal: React.FC = ({ slug, closeModal, relatedCollection
= (props) => { const { path: pathFromProps, name, - required, validate = select, label, options: optionsFromProps, @@ -44,10 +43,9 @@ const Select: React.FC = (props) => { setOptions(formatOptions(optionsFromProps)); }, [optionsFromProps]); - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required, options }); - return validationResult; - }, [validate, required, options]); + const memoizedValidate = useCallback((value, validationOptions) => { + return validate(value, validationOptions); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/Text/index.tsx b/src/admin/components/forms/field-types/Text/index.tsx index 2a16f505fe..1e24856bd6 100644 --- a/src/admin/components/forms/field-types/Text/index.tsx +++ b/src/admin/components/forms/field-types/Text/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import useField from '../../useField'; import withCondition from '../../withCondition'; import { text } from '../../../../../fields/validations'; @@ -24,10 +24,13 @@ const Text: React.FC = (props) => { } = props; const path = pathFromProps || name; + const memoizedValidate = useCallback((value, options) => { + return validate(value, options); + }, [validate]); const field = useField({ path, - validate, + validate: memoizedValidate, enableDebouncedValue: true, condition, }); diff --git a/src/admin/components/forms/field-types/Textarea/index.tsx b/src/admin/components/forms/field-types/Textarea/index.tsx index c72794e105..73bfb047de 100644 --- a/src/admin/components/forms/field-types/Textarea/index.tsx +++ b/src/admin/components/forms/field-types/Textarea/index.tsx @@ -24,16 +24,13 @@ const Textarea: React.FC = (props) => { condition, } = {}, label, - minLength, - maxLength, } = props; const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { minLength, maxLength, required }); - return validationResult; - }, [validate, maxLength, minLength, required]); + const memoizedValidate = useCallback((value, validationOptions) => { + return validate(value, validationOptions); + }, [validate]); const { value, diff --git a/src/admin/components/forms/field-types/Upload/Add/index.tsx b/src/admin/components/forms/field-types/Upload/Add/index.tsx index 3fa0127430..4318ce14d5 100644 --- a/src/admin/components/forms/field-types/Upload/Add/index.tsx +++ b/src/admin/components/forms/field-types/Upload/Add/index.tsx @@ -53,6 +53,7 @@ const AddUploadModal: React.FC = (props) => { action={`${serverURL}${api}/${collection.slug}`} onSuccess={onSuccess} disableSuccessStatus + validationOperation="create" >
diff --git a/src/admin/components/forms/field-types/Upload/index.tsx b/src/admin/components/forms/field-types/Upload/index.tsx index 67c139b8f3..b2d170f233 100644 --- a/src/admin/components/forms/field-types/Upload/index.tsx +++ b/src/admin/components/forms/field-types/Upload/index.tsx @@ -37,10 +37,9 @@ const Upload: React.FC = (props) => { const collection = collections.find((coll) => coll.slug === relationTo); - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; - }, [validate, required]); + const memoizedValidate = useCallback((value, validationOptions) => { + return validate(value, validationOptions); + }, [validate]); const field = useField({ path, diff --git a/src/admin/components/forms/useField/index.tsx b/src/admin/components/forms/useField/index.tsx index ee4c4cd3ed..d1c92aebad 100644 --- a/src/admin/components/forms/useField/index.tsx +++ b/src/admin/components/forms/useField/index.tsx @@ -1,9 +1,12 @@ import { useCallback, useEffect, useState, } from 'react'; +import { useAuth } from '@payloadcms/config-provider'; import { useFormProcessing, useFormSubmitted, useFormModified, useForm } from '../Form/context'; import useDebounce from '../../../hooks/useDebounce'; import { Options, FieldType } from './types'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; +import { useRenderedFields } from '../RenderFields'; const useField = (options: Options): FieldType => { const { @@ -19,6 +22,9 @@ const useField = (options: Options): FieldType => { const submitted = useFormSubmitted(); const processing = useFormProcessing(); const modified = useFormModified(); + const { user } = useAuth(); + const { id } = useDocumentInfo(); + const { operation } = useRenderedFields(); const { dispatchFields, @@ -55,7 +61,16 @@ const useField = (options: Options): FieldType => { errorMessage: undefined, }; - const validationResult = typeof validate === 'function' ? await validate(valueToSend) : true; + const validateOptions = { + id, + field: field?.field, + user, + data: formContext.getData(), + siblingData: formContext.getSiblingData(path), + operation, + }; + + const validationResult = typeof validate === 'function' ? await validate(valueToSend, validateOptions) : true; if (typeof validationResult === 'string') { fieldToDispatch.errorMessage = validationResult; @@ -68,13 +83,18 @@ const useField = (options: Options): FieldType => { dispatchFields(fieldToDispatch); } }, [ - path, - dispatchFields, - validate, + condition, disableFormData, + dispatchFields, + field, + formContext, + id, ignoreWhileFlattening, initialValue, - condition, + operation, + path, + user, + validate, ]); // Method to return from `useField`, used to diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx index db52b693c1..1baf99b410 100644 --- a/src/admin/components/views/Account/Default.tsx +++ b/src/admin/components/views/Account/Default.tsx @@ -61,6 +61,7 @@ const DefaultAccount: React.FC = (props) => { action={action} initialState={initialState} disabled={!hasSavePermission} + validationOperation="update" >
{ useEffect(() => { const awaitInitialState = async () => { - const state = await buildStateFromSchema(fields, dataToRender); + const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender }); setInitialState(state); }; diff --git a/src/admin/components/views/CreateFirstUser/index.tsx b/src/admin/components/views/CreateFirstUser/index.tsx index 65007628fb..bab615a805 100644 --- a/src/admin/components/views/CreateFirstUser/index.tsx +++ b/src/admin/components/views/CreateFirstUser/index.tsx @@ -65,6 +65,7 @@ const CreateFirstUser: React.FC = (props) => { method="post" redirect={admin} action={`${serverURL}${api}/${userSlug}/first-register`} + validationOperation="create" > = (props) => { onSuccess={onSave} disabled={!hasSavePermission} initialState={initialState} + validationOperation="update" >
= (props) => { const onSave = useCallback(async (json) => { getVersions(); - const state = await buildStateFromSchema(fields, json.result); + const state = await buildStateFromSchema({ fieldSchema: fields, data: json.result }); setInitialState(state); }, [getVersions, fields]); @@ -66,7 +66,7 @@ const GlobalView: React.FC = (props) => { useEffect(() => { const awaitInitialState = async () => { - const state = await buildStateFromSchema(fields, dataToRender); + const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender }); setInitialState(state); }; diff --git a/src/admin/components/views/Login/index.tsx b/src/admin/components/views/Login/index.tsx index 649724e49b..6dfc45303e 100644 --- a/src/admin/components/views/Login/index.tsx +++ b/src/admin/components/views/Login/index.tsx @@ -86,6 +86,7 @@ const Login: React.FC = () => { onSuccess={onSuccess} method="post" action={`${serverURL}${api}/${userSlug}/login`} + validationOperation="update" > = (props) => { onSuccess={onSave} disabled={!hasSavePermission} initialState={initialState} + validationOperation={isEditing ? 'update' : 'create'} >
= (props) => { if (!isEditing) { history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); } else { - const state = await buildStateFromSchema(collection.fields, json.doc); + const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc }); setInitialState(state); } }, [admin, collection, history, isEditing, getVersions]); @@ -97,7 +97,7 @@ const EditView: React.FC = (props) => { useEffect(() => { const awaitInitialState = async () => { - const state = await buildStateFromSchema(fields, dataToRender); + const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender }); setInitialState(state); }; diff --git a/src/fields/config/sanitize.ts b/src/fields/config/sanitize.ts index 03c1403054..9ceae2fe52 100644 --- a/src/fields/config/sanitize.ts +++ b/src/fields/config/sanitize.ts @@ -43,7 +43,7 @@ const sanitizeFields = (fields, validRelationships: string[]) => { if (typeof field.validate === 'undefined') { const defaultValidate = validations[field.type]; if (defaultValidate) { - field.validate = (val) => defaultValidate(val, field); + field.validate = (val) => (defaultValidate(val, { field })); } else { field.validate = () => true; } diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index c7ad136fc9..226c6bf5f5 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -1,10 +1,12 @@ /* eslint-disable no-use-before-define */ import { CSSProperties } from 'react'; import { Editor } from 'slate'; +import { Operation } from '../../types'; import { TypeWithID } from '../../collections/config/types'; import { PayloadRequest } from '../../express/types'; import { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types'; import { Description } from '../../admin/components/forms/FieldDescription/types'; +import { User } from '../../auth'; export type FieldHookArgs = { value?: P, @@ -49,7 +51,16 @@ export type Labels = { plural: string; }; -export type Validate = (value?: T, options?: any) => string | true | Promise; +export type ValidateOptions = { + field: F + data: Partial + siblingData: Partial + id?: string | number + user?: Partial + operation: Operation +} + +export type Validate = (value?: T, options?: ValidateOptions) => string | true | Promise; export type OptionObject = { label: string diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index b4e381de7a..38303b5eb2 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -1,11 +1,19 @@ import validationPromise from './validationPromise'; import accessPromise from './accessPromise'; import hookPromise from './hookPromise'; -import { Field, fieldHasSubFields, fieldIsArrayType, fieldIsBlockType, fieldAffectsData, HookName } from './config/types'; +import { + Field, + fieldHasSubFields, + fieldIsArrayType, + fieldIsBlockType, + fieldAffectsData, + HookName, +} from './config/types'; import { Operation } from '../types'; import { PayloadRequest } from '../express/types'; import { Payload } from '..'; import richTextRelationshipPromise from './richText/relationshipPromise'; +import getSiblingData from '../admin/components/forms/Form/getSiblingData'; type Arguments = { fields: Field[] @@ -374,9 +382,13 @@ const traverseFields = (args: Arguments): void => { hook, newData: { [field.name]: newRowCount }, existingData: { [field.name]: existingRowCount }, + siblingData: getSiblingData(data, field.name), field, path, skipValidation: skipValidationFromHere, + user: req.user, + operation, + id, })); } else if (fieldAffectsData(field)) { validationPromises.push(() => validationPromise({ @@ -384,9 +396,13 @@ const traverseFields = (args: Arguments): void => { hook, newData: data, existingData: originalDoc, + siblingData: getSiblingData(data, field.name), field, path, skipValidation: skipValidationFromHere, + user: req.user, + operation, + id, })); } } diff --git a/src/fields/validationPromise.ts b/src/fields/validationPromise.ts index 6cd37ebf82..21499bac13 100644 --- a/src/fields/validationPromise.ts +++ b/src/fields/validationPromise.ts @@ -1,3 +1,5 @@ +import { User } from 'payload/auth'; +import { Operation } from 'payload/types'; import { HookName, FieldAffectingData } from './config/types'; type Arguments = { @@ -7,7 +9,11 @@ type Arguments = { errors: {message: string, field: string}[] newData: Record existingData: Record + siblingData: Record + id?: string | number skipValidation?: boolean + user: User + operation: Operation } const validationPromise = async ({ @@ -15,20 +21,32 @@ const validationPromise = async ({ hook, newData, existingData, + siblingData, + id, field, path, skipValidation, + user, + operation, }: Arguments): Promise => { if (hook !== 'beforeChange' || skipValidation) return true; const hasCondition = field.admin && field.admin.condition; const shouldValidate = field.validate && !hasCondition; + const dataToValidate = newData || existingData; let valueToValidate = newData?.[field.name]; if (valueToValidate === undefined) valueToValidate = existingData?.[field.name]; if (valueToValidate === undefined) valueToValidate = field.defaultValue; - const result = shouldValidate ? await field.validate(valueToValidate, field) : true; + const result = shouldValidate ? await field.validate(valueToValidate, { + field, + data: dataToValidate, + siblingData, + id, + operation, + user, + }) : true; if (typeof result === 'string') { errors.push({ diff --git a/src/fields/validations.spec.ts b/src/fields/validations.spec.ts index 8c0c6cb706..be9a9fde61 100644 --- a/src/fields/validations.spec.ts +++ b/src/fields/validations.spec.ts @@ -1,177 +1,196 @@ - import { text, textarea, password, select } from './validations'; +import { ValidateOptions } from './config/types'; const minLengthMessage = (length: number) => `This value must be longer than the minimum length of ${length} characters.`; const maxLengthMessage = (length: number) => `This value must be shorter than the max length of ${length} characters.`; const requiredMessage = 'This field is required.'; +let options: ValidateOptions = { + operation: 'create', + field: { + type: 'text', + name: 'text', + }, + data: undefined, + siblingData: undefined, +}; describe('Field Validations', () => { describe('text', () => { it('should validate', () => { const val = 'test'; - const result = text(val); + const result = text(val, options); expect(result).toBe(true); }); it('should show required message', () => { const val = undefined; - const result = text(val, { required: true }); + const result = text(val, { ...options, field: { ...options.field, required: true } }); expect(result).toBe(requiredMessage); }); it('should handle undefined', () => { const val = undefined; - const result = text(val); + const result = text(val, options); expect(result).toBe(true); }); it('should validate maxLength', () => { const val = 'toolong'; - const result = text(val, { maxLength: 5 }); + const result = text(val, { ...options, field: { ...options.field, maxLength: 5 } }); expect(result).toBe(maxLengthMessage(5)); }); it('should validate minLength', () => { const val = 'short'; - const result = text(val, { minLength: 10 }); + const result = text(val, { ...options, field: { ...options.field, minLength: 10 } }); expect(result).toBe(minLengthMessage(10)); }); it('should validate maxLength with no value', () => { const val = undefined; - const result = text(val, { maxLength: 5 }); + const result = text(val, { ...options, field: { ...options.field, maxLength: 5 } }); expect(result).toBe(true); }); it('should validate minLength with no value', () => { const val = undefined; - const result = text(val, { minLength: 10 }); + const result = text(val, { ...options, field: { ...options.field, minLength: 10 } }); expect(result).toBe(true); }); }); describe('textarea', () => { + options = { ...options, field: { type: 'textarea', name: 'test' } }; it('should validate', () => { const val = 'test'; - const result = textarea(val); + const result = textarea(val, options); expect(result).toBe(true); }); it('should show required message', () => { const val = undefined; - const result = textarea(val, { required: true }); + const result = textarea(val, { ...options, field: { ...options.field, required: true } }); expect(result).toBe(requiredMessage); }); it('should handle undefined', () => { const val = undefined; - const result = textarea(val); + const result = textarea(val, options); expect(result).toBe(true); }); it('should validate maxLength', () => { const val = 'toolong'; - const result = textarea(val, { maxLength: 5 }); + const result = textarea(val, { ...options, field: { ...options.field, maxLength: 5 } }); expect(result).toBe(maxLengthMessage(5)); }); it('should validate minLength', () => { const val = 'short'; - const result = textarea(val, { minLength: 10 }); + const result = textarea(val, { ...options, field: { ...options.field, minLength: 10 } }); expect(result).toBe(minLengthMessage(10)); }); it('should validate maxLength with no value', () => { const val = undefined; - const result = textarea(val, { maxLength: 5 }); + const result = textarea(val, { ...options, field: { ...options.field, maxLength: 5 } }); expect(result).toBe(true); }); it('should validate minLength with no value', () => { const val = undefined; - const result = textarea(val, { minLength: 10 }); + const result = textarea(val, { ...options, field: { ...options.field, minLength: 10 } }); expect(result).toBe(true); }); }); describe('password', () => { + options.field = { type: 'password', name: 'test' }; it('should validate', () => { const val = 'test'; - const result = password(val); + const result = password(val, options); expect(result).toBe(true); }); it('should show required message', () => { const val = undefined; - const result = password(val, { required: true }); + const result = password(val, { ...options, field: { ...options.field, required: true } }); expect(result).toBe(requiredMessage); }); - it('should handle undefined', () => { const val = undefined; - const result = password(val); + const result = password(val, options); expect(result).toBe(true); }); it('should validate maxLength', () => { const val = 'toolong'; - const result = password(val, { maxLength: 5 }); + const result = password(val, { ...options, field: { ...options.field, maxLength: 5 } }); expect(result).toBe(maxLengthMessage(5)); }); it('should validate minLength', () => { const val = 'short'; - const result = password(val, { minLength: 10 }); + const result = password(val, { ...options, field: { ...options.field, minLength: 10 } }); expect(result).toBe(minLengthMessage(10)); }); it('should validate maxLength with no value', () => { const val = undefined; - const result = password(val, { maxLength: 5 }); + const result = password(val, { ...options, field: { ...options.field, maxLength: 5 } }); expect(result).toBe(true); }); it('should validate minLength with no value', () => { const val = undefined; - const result = password(val, { minLength: 10 }); + const result = password(val, { ...options, field: { ...options.field, minLength: 10 } }); expect(result).toBe(true); }); }); describe('select', () => { - const arrayOptions = { + options.field = { + type: 'select', options: ['one', 'two', 'three'], }; const optionsRequired = { - required: true, - options: [{ - value: 'one', - label: 'One', - }, { - value: 'two', - label: 'two', - }, { - value: 'three', - label: 'three', - }], + ...options, + field: { + ...options.field, + required: true, + options: [{ + value: 'one', + label: 'One', + }, { + value: 'two', + label: 'two', + }, { + value: 'three', + label: 'three', + }], + }, }; const optionsWithEmptyString = { - options: [{ - value: '', - label: 'None', - }, { - value: 'option', - label: 'Option', - }], + ...options, + field: { + ...options.field, + options: [{ + value: '', + label: 'None', + }, { + value: 'option', + label: 'Option', + }], + }, }; it('should allow valid input', () => { const val = 'one'; - const result = select(val, arrayOptions); + const result = select(val, options); expect(result).toStrictEqual(true); }); it('should prevent invalid input', () => { const val = 'bad'; - const result = select(val, arrayOptions); + const result = select(val, options); expect(result).not.toStrictEqual(true); }); it('should allow null input', () => { const val = null; - const result = select(val, arrayOptions); + const result = select(val, options); expect(result).toStrictEqual(true); }); it('should allow undefined input', () => { let val; - const result = select(val, arrayOptions); + const result = select(val, options); expect(result).toStrictEqual(true); }); it('should prevent empty string input', () => { const val = ''; - const result = select(val, arrayOptions); + const result = select(val, options); expect(result).not.toStrictEqual(true); }); it('should prevent undefined input with required', () => { @@ -179,35 +198,41 @@ describe('Field Validations', () => { const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); + it('should prevent empty string input with required', () => { + const result = select('', optionsRequired); + expect(result).not.toStrictEqual(true); + }); it('should prevent undefined input with required and hasMany', () => { let val; - const result = select(val, { ...optionsRequired, hasMany: true }); + options.field.hasMany = true; + const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); it('should prevent empty array input with required and hasMany', () => { - const result = select([], { ...optionsRequired, hasMany: true }); - expect(result).not.toStrictEqual(true); - }); - it('should prevent empty string input with required', () => { - const result = select('', { ...optionsRequired }); + optionsRequired.field.hasMany = true; + const result = select([], optionsRequired); expect(result).not.toStrictEqual(true); }); it('should prevent empty string array input with required and hasMany', () => { - const result = select([''], { ...optionsRequired, hasMany: true }); + options.field.hasMany = true; + const result = select([''], optionsRequired); expect(result).not.toStrictEqual(true); }); it('should prevent null input with required and hasMany', () => { const val = null; - const result = select(val, { ...optionsRequired, hasMany: true }); + options.field.hasMany = true; + const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); it('should allow valid input with option objects', () => { const val = 'one'; + options.field.hasMany = false; const result = select(val, optionsRequired); expect(result).toStrictEqual(true); }); it('should prevent invalid input with option objects', () => { const val = 'bad'; + options.field.hasMany = false; const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); @@ -218,27 +243,30 @@ describe('Field Validations', () => { }); it('should allow empty string input with option object and required', () => { const val = ''; - const result = select(val, { ...optionsWithEmptyString, required: true }); + optionsWithEmptyString.field.required = true; + const result = select(val, optionsWithEmptyString); expect(result).toStrictEqual(true); }); it('should allow valid input with hasMany', () => { const val = ['one', 'two']; - const result = select(val, arrayOptions); + const result = select(val, options); expect(result).toStrictEqual(true); }); it('should prevent invalid input with hasMany', () => { const val = ['one', 'bad']; - const result = select(val, arrayOptions); + const result = select(val, options); expect(result).not.toStrictEqual(true); }); it('should allow valid input with hasMany option objects', () => { const val = ['one', 'three']; - const result = select(val, { ...optionsRequired, hasMany: true }); + optionsRequired.field.hasMany = true; + const result = select(val, optionsRequired); expect(result).toStrictEqual(true); }); it('should prevent invalid input with hasMany option objects', () => { const val = ['three', 'bad']; - const result = select(val, { ...optionsRequired, hasMany: true }); + optionsRequired.field.hasMany = true; + const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); }); diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 06f1c7bc68..05c0a628f7 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -1,40 +1,50 @@ import defaultRichTextValue from './richText/defaultValue'; -import { Validate } from './config/types'; +import { + ArrayField, BlockField, + CheckboxField, + CodeField, DateField, + EmailField, + NumberField, PointField, RadioField, RelationshipField, + RichTextField, SelectField, + TextareaField, + TextField, UploadField, + Validate, +} from './config/types'; const defaultMessage = 'This field is required.'; -export const number: Validate = (value: string, options = {}) => { +export const number: Validate = (value: string, options) => { const parsedValue = parseInt(value, 10); - if ((value && typeof parsedValue !== 'number') || (options.required && Number.isNaN(parsedValue))) { + if ((value && typeof parsedValue !== 'number') || (options.field.required && Number.isNaN(parsedValue))) { return 'Please enter a valid number.'; } - if (options.max && parsedValue > options.max) { - return `"${value}" is greater than the max allowed value of ${options.max}.`; + if (options.field.max && parsedValue > options.field.max) { + return `"${value}" is greater than the max allowed value of ${options.field.max}.`; } - if (options.min && parsedValue < options.min) { - return `"${value}" is less than the min allowed value of ${options.min}.`; + if (options.field.min && parsedValue < options.field.min) { + return `"${value}" is less than the min allowed value of ${options.field.min}.`; } - if (options.required && typeof parsedValue !== 'number') { + if (options.field.required && typeof parsedValue !== 'number') { return defaultMessage; } return true; }; -export const text: Validate = (value: string, options = {}) => { - if (value && options.maxLength && value.length > options.maxLength) { - return `This value must be shorter than the max length of ${options.maxLength} characters.`; +export const text: Validate = (value: string, options) => { + if (value && options.field.maxLength && value.length > options.field.maxLength) { + return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; } - if (value && options.minLength && value?.length < options.minLength) { - return `This value must be longer than the minimum length of ${options.minLength} characters.`; + if (value && options.field.minLength && value?.length < options.field.minLength) { + return `This value must be longer than the minimum length of ${options.field.minLength} characters.`; } - if (options.required) { + if (options.field.required) { if (typeof value !== 'string' || value?.length === 0) { return defaultMessage; } @@ -43,65 +53,65 @@ export const text: Validate = (value: string, options = {}) => { return true; }; -export const password: Validate = (value: string, options = {}) => { - if (value && options.maxLength && value.length > options.maxLength) { - return `This value must be shorter than the max length of ${options.maxLength} characters.`; +export const password: Validate = (value: string, options) => { + if (value && options.field.maxLength && value.length > options.field.maxLength) { + return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; } - if (value && options.minLength && value.length < options.minLength) { - return `This value must be longer than the minimum length of ${options.minLength} characters.`; + if (value && options.field.minLength && value.length < options.field.minLength) { + return `This value must be longer than the minimum length of ${options.field.minLength} characters.`; } - if (options.required && !value) { + if (options.field.required && !value) { return defaultMessage; } return true; }; -export const email: Validate = (value: string, options = {}) => { +export const email: Validate = (value: string, options) => { if ((value && !/\S+@\S+\.\S+/.test(value)) - || (!value && options.required)) { + || (!value && options.field.required)) { return 'Please enter a valid email address.'; } return true; }; -export const textarea: Validate = (value: string, options = {}) => { - if (value && options.maxLength && value.length > options.maxLength) { - return `This value must be shorter than the max length of ${options.maxLength} characters.`; +export const textarea: Validate = (value: string, options) => { + if (value && options.field.maxLength && value.length > options.field.maxLength) { + return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; } - if (value && options.minLength && value.length < options.minLength) { - return `This value must be longer than the minimum length of ${options.minLength} characters.`; + if (value && options.field.minLength && value.length < options.field.minLength) { + return `This value must be longer than the minimum length of ${options.field.minLength} characters.`; } - if (options.required && !value) { + if (options.field.required && !value) { return defaultMessage; } return true; }; -export const wysiwyg: Validate = (value: string, options = {}) => { - if (options.required && !value) { +export const wysiwyg: Validate = (value: string, options) => { + if (options.field.required && !value) { return defaultMessage; } return true; }; -export const code: Validate = (value: string, options = {}) => { - if (options.required && value === undefined) { +export const code: Validate = (value: string, options) => { + if (options.field.required && value === undefined) { return defaultMessage; } return true; }; -export const richText: Validate = (value, options = {}) => { - if (options.required) { +export const richText: Validate = (value, options) => { + if (options.field.required) { const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue); if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true; return 'This field is required.'; @@ -110,16 +120,16 @@ export const richText: Validate = (value, options = {}) => { return true; }; -export const checkbox: Validate = (value: boolean, options = {}) => { +export const checkbox: Validate = (value: boolean, options) => { if ((value && typeof value !== 'boolean') - || (options.required && typeof value !== 'boolean')) { + || (options.field.required && typeof value !== 'boolean')) { return 'This field can only be equal to true or false.'; } return true; }; -export const date: Validate = (value, options = {}) => { +export const date: Validate = (value, options) => { if (value && !isNaN(Date.parse(value.toString()))) { /* eslint-disable-line */ return true; } @@ -128,50 +138,50 @@ export const date: Validate = (value, options = {}) => { return `"${value}" is not a valid date.`; } - if (options.required) { + if (options.field.required) { return defaultMessage; } return true; }; -export const upload: Validate = (value: string, options = {}) => { - if (value || !options.required) return true; +export const upload: Validate = (value: string, options) => { + if (value || !options.field.required) return true; return defaultMessage; }; -export const relationship: Validate = (value, options = {}) => { - if (value || !options.required) return true; +export const relationship: Validate = (value, options) => { + if (value || !options.field.required) return true; return defaultMessage; }; -export const array: Validate = (value, options = {}) => { - if (options.minRows && value < options.minRows) { - return `This field requires at least ${options.minRows} row(s).`; +export const array: Validate = (value, options) => { + if (options.field.minRows && value < options.field.minRows) { + return `This field requires at least ${options.field.minRows} row(s).`; } - if (options.maxRows && value > options.maxRows) { - return `This field requires no more than ${options.maxRows} row(s).`; + if (options.field.maxRows && value > options.field.maxRows) { + return `This field requires no more than ${options.field.maxRows} row(s).`; } - if (!value && options.required) { + if (!value && options.field.required) { return 'This field requires at least one row.'; } return true; }; -export const select: Validate = (value, options = {}) => { - if (Array.isArray(value) && value.some((input) => !options.options.some((option) => (option === input || option.value === input)))) { +export const select: Validate = (value, options) => { + if (Array.isArray(value) && value.some((input) => !options.field.options.some((option) => (option === input || (typeof option !== 'string' && option?.value === input))))) { return 'This field has an invalid selection'; } - if (typeof value === 'string' && !options.options.some((option) => (option === value || option.value === value))) { + if (typeof value === 'string' && !options.field.options.some((option) => (option === value || (typeof option !== 'string' && option.value === value)))) { return 'This field has an invalid selection'; } - if (options.required && ( - (typeof value === 'undefined' || value === null) || (options.hasMany && Array.isArray(value) && (value as [])?.length === 0)) + if (options.field.required && ( + (typeof value === 'undefined' || value === null) || (options.field.hasMany && Array.isArray(value) && (value as [])?.length === 0)) ) { return defaultMessage; } @@ -179,40 +189,40 @@ export const select: Validate = (value, options = {}) => { return true; }; -export const radio: Validate = (value, options = {}) => { +export const radio: Validate = (value, options) => { const stringValue = String(value); - if ((typeof value !== 'undefined' || !options.required) && (options.options.find((option) => String(option.value) === stringValue))) return true; + if ((typeof value !== 'undefined' || !options.field.required) && (options.field.options.find((option) => String(typeof option !== 'string' && option?.value) === stringValue))) return true; return defaultMessage; }; -export const blocks: Validate = (value, options = {}) => { - if (options.minRows && value < options.minRows) { - return `This field requires at least ${options.minRows} row(s).`; +export const blocks: Validate = (value, options) => { + if (options.field.minRows && value < options.field.minRows) { + return `This field requires at least ${options.field.minRows} row(s).`; } - if (options.maxRows && value > options.maxRows) { - return `This field requires no more than ${options.maxRows} row(s).`; + if (options.field.maxRows && value > options.field.maxRows) { + return `This field requires no more than ${options.field.maxRows} row(s).`; } - if (!value && options.required) { + if (!value && options.field.required) { return 'This field requires at least one row.'; } return true; }; -export const point: Validate = (value: [number | string, number | string] = ['', ''], options = {}) => { +export const point: Validate = (value: [number | string, number | string] = ['', ''], options) => { const lng = parseFloat(String(value[0])); const lat = parseFloat(String(value[1])); if ( (value[0] && value[1] && typeof lng !== 'number' && typeof lat !== 'number') - || (options.required && (Number.isNaN(lng) || Number.isNaN(lat))) + || (options.field.required && (Number.isNaN(lng) || Number.isNaN(lat))) || (Array.isArray(value) && value.length !== 2) ) { return 'This field requires two numbers'; } - if (!options.required && typeof value[0] !== typeof value[1]) { + if (!options.field.required && typeof value[0] !== typeof value[1]) { return 'This field requires two numbers or both can be empty'; } From e597b4c66b5e7a4680ca1382ef9874e6fb44796b Mon Sep 17 00:00:00 2001 From: James Date: Tue, 29 Mar 2022 14:11:22 -0400 Subject: [PATCH 02/19] chore: optimizes validation extended args --- .../forms/DraggableSection/index.tsx | 4 +- .../forms/Form/buildStateFromSchema.ts | 11 +- src/admin/components/forms/Form/index.tsx | 14 +- .../components/forms/RenderFields/index.tsx | 150 +++++------ .../components/forms/RenderFields/types.ts | 7 - .../upload/Element/EditModal/index.tsx | 1 - src/admin/components/forms/useField/index.tsx | 13 +- .../utilities/OperationProvider/index.tsx | 7 + .../components/views/Account/Default.tsx | 214 +++++++-------- src/admin/components/views/Global/Default.tsx | 250 +++++++++--------- src/admin/components/views/Login/index.tsx | 1 - .../views/collections/Edit/Auth/APIKey.tsx | 2 +- .../views/collections/Edit/Default.tsx | 137 +++++----- src/fields/config/types.ts | 6 +- src/fields/traverseFields.ts | 15 +- src/fields/validationPromise.ts | 28 +- src/fields/validations.ts | 34 +-- 17 files changed, 440 insertions(+), 454 deletions(-) create mode 100644 src/admin/components/utilities/OperationProvider/index.tsx diff --git a/src/admin/components/forms/DraggableSection/index.tsx b/src/admin/components/forms/DraggableSection/index.tsx index 86f021c995..f3f8d1b828 100644 --- a/src/admin/components/forms/DraggableSection/index.tsx +++ b/src/admin/components/forms/DraggableSection/index.tsx @@ -8,7 +8,7 @@ import PositionPanel from './PositionPanel'; import Button from '../../elements/Button'; import { NegativeFieldGutterProvider } from '../FieldTypeGutter/context'; import FieldTypeGutter from '../FieldTypeGutter'; -import RenderFields, { useRenderedFields } from '../RenderFields'; +import RenderFields from '../RenderFields'; import { Props } from './types'; import HiddenInput from '../field-types/HiddenInput'; @@ -38,7 +38,6 @@ const DraggableSection: React.FC = (props) => { } = props; const [isHovered, setIsHovered] = useState(false); - const { operation } = useRenderedFields(); const classes = [ baseClass, @@ -105,7 +104,6 @@ const DraggableSection: React.FC = (props) => { > ) => { +const buildValidationPromise = async (fieldState: Field, options: ValidateOptions) => { const validatedFieldState = fieldState; let validationResult: boolean | string = true; - if (typeof options.field.validate === 'function') { + if (fieldAffectsData(options.field) && typeof options.field.validate === 'function') { validationResult = await options.field.validate(fieldState.value, options); } @@ -26,7 +26,7 @@ const buildValidationPromise = async (fieldState: Field, options: ValidateOption } }; -type Props = { +type Args = { fieldSchema: FieldSchema[] data?: Data, siblingData?: Data, @@ -35,7 +35,7 @@ type Props = { operation?: 'create' | 'update' } -const buildStateFromSchema = async (props: Props): Promise => { +const buildStateFromSchema = async (args: Args): Promise => { const { fieldSchema, data: fullData = {}, @@ -43,7 +43,8 @@ const buildStateFromSchema = async (props: Props): Promise => { user, id, operation, - } = props; + } = args; + if (fieldSchema) { const validationPromises = []; diff --git a/src/admin/components/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index 0d30e0f5a6..8af44e5750 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -16,13 +16,13 @@ import reduceFieldsToValues from './reduceFieldsToValues'; import getSiblingDataFunc from './getSiblingData'; import getDataByPathFunc from './getDataByPath'; import wait from '../../../../utilities/wait'; +import { Field, FieldAffectingData } from '../../../../fields/config/types'; import buildInitialState from './buildInitialState'; import errorMessages from './errorMessages'; import { Context as FormContextType, Props, SubmitOptions } from './types'; - import { SubmittedContext, ProcessingContext, ModifiedContext, FormContext, FormWatchContext } from './context'; import buildStateFromSchema from './buildStateFromSchema'; -import { Field, FieldAffectingData } from '../../../../fields/config/types'; +import { useOperation } from '../../utilities/OperationProvider'; const baseClass = 'form'; @@ -42,13 +42,13 @@ const Form: React.FC = (props) => { initialData, // values only, paths are required as key - form should build initial state as convenience waitForAutocomplete, log, - validationOperation, } = props; const history = useHistory(); const locale = useLocale(); const { refreshCookie, user } = useAuth(); const { id } = useDocumentInfo(); + const operation = useOperation(); const [modified, setModified] = useState(false); const [processing, setProcessing] = useState(false); @@ -91,7 +91,7 @@ const Form: React.FC = (props) => { siblingData: contextRef.current.getSiblingData(path), user, id, - operation: validationOperation, + operation, }); } @@ -110,7 +110,7 @@ const Form: React.FC = (props) => { dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState }); return isValid; - }, [contextRef, id, user, validationOperation]); + }, [contextRef, id, user, operation]); const submit = useCallback(async (options: SubmitOptions = {}, e): Promise => { const { @@ -324,10 +324,10 @@ const Form: React.FC = (props) => { }, [contextRef]); const reset = useCallback(async (fieldSchema: Field[], data: unknown) => { - const state = await buildStateFromSchema({ fieldSchema, data, user, id, operation: validationOperation }); + const state = await buildStateFromSchema({ fieldSchema, data, user, id, operation }); contextRef.current = { ...initContextState } as FormContextType; dispatchFields({ type: 'REPLACE_STATE', state }); - }, [id, user, validationOperation]); + }, [id, user, operation]); contextRef.current.dispatchFields = dispatchFields; contextRef.current.submit = submit; diff --git a/src/admin/components/forms/RenderFields/index.tsx b/src/admin/components/forms/RenderFields/index.tsx index 21dc16d68a..028f658b0a 100644 --- a/src/admin/components/forms/RenderFields/index.tsx +++ b/src/admin/components/forms/RenderFields/index.tsx @@ -1,8 +1,9 @@ -import React, { createContext, useEffect, useContext, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import RenderCustomComponent from '../../utilities/RenderCustomComponent'; import useIntersect from '../../../hooks/useIntersect'; -import { Props, Context } from './types'; +import { Props } from './types'; import { fieldAffectsData, fieldIsPresentationalOnly } from '../../../../fields/config/types'; +import { useOperation } from '../../utilities/OperationProvider'; const baseClass = 'render-fields'; @@ -10,10 +11,6 @@ const intersectionObserverOptions = { rootMargin: '1000px', }; -const RenderedFieldContext = createContext({} as Context); - -export const useRenderedFields = (): Context => useContext(RenderedFieldContext); - const RenderFields: React.FC = (props) => { const { fieldSchema, @@ -21,30 +18,17 @@ const RenderFields: React.FC = (props) => { filter, permissions, readOnly: readOnlyOverride, - operation: operationFromProps, className, } = props; const [hasRendered, setHasRendered] = useState(false); const [intersectionRef, entry] = useIntersect(intersectionObserverOptions); + const operation = useOperation(); + const isIntersecting = Boolean(entry?.isIntersecting); const isAboveViewport = entry?.boundingClientRect?.top < 0; - const shouldRender = isIntersecting || isAboveViewport; - const { operation: operationFromContext } = useRenderedFields(); - - const operation = operationFromProps || operationFromContext; - - const [contextValue, setContextValue] = useState({ - operation, - }); - - useEffect(() => { - setContextValue({ - operation, - }); - }, [operation]); useEffect(() => { if (shouldRender && !hasRendered) { @@ -64,80 +48,78 @@ const RenderFields: React.FC = (props) => { className={classes} > {hasRendered && ( - - {fieldSchema.map((field, i) => { - const fieldIsPresentational = fieldIsPresentationalOnly(field); - let FieldComponent = fieldTypes[field.type]; + fieldSchema.map((field, i) => { + const fieldIsPresentational = fieldIsPresentationalOnly(field); + let FieldComponent = fieldTypes[field.type]; - if (fieldIsPresentational || (!field?.hidden && field?.admin?.disabled !== true)) { - if ((filter && typeof filter === 'function' && filter(field)) || !filter) { - if (fieldIsPresentational) { + if (fieldIsPresentational || (!field?.hidden && field?.admin?.disabled !== true)) { + if ((filter && typeof filter === 'function' && filter(field)) || !filter) { + if (fieldIsPresentational) { + return ( + + ); + } + + if (field?.admin?.hidden) { + FieldComponent = fieldTypes.hidden; + } + + const isFieldAffectingData = fieldAffectsData(field); + + const fieldPermissions = isFieldAffectingData ? permissions?.[field.name] : permissions; + + let { admin: { readOnly } = {} } = field; + + if (readOnlyOverride) readOnly = true; + + if ((isFieldAffectingData && permissions?.[field?.name]?.read?.permission !== false) || !isFieldAffectingData) { + if (isFieldAffectingData && permissions?.[field?.name]?.[operation]?.permission === false) { + readOnly = true; + } + + if (FieldComponent) { return ( - ); } - if (field?.admin?.hidden) { - FieldComponent = fieldTypes.hidden; - } - - const isFieldAffectingData = fieldAffectsData(field); - - const fieldPermissions = isFieldAffectingData ? permissions?.[field.name] : permissions; - - let { admin: { readOnly } = {} } = field; - - if (readOnlyOverride) readOnly = true; - - if ((isFieldAffectingData && permissions?.[field?.name]?.read?.permission !== false) || !isFieldAffectingData) { - if (isFieldAffectingData && permissions?.[field?.name]?.[operation]?.permission === false) { - readOnly = true; - } - - if (FieldComponent) { - return ( - - ); - } - - return ( -
- No matched field found for - {' '} - " - {field.label} - " -
- ); - } + return ( +
+ No matched field found for + {' '} + " + {field.label} + " +
+ ); } - - return null; } return null; - })} -
+ } + + return null; + }) )}
); diff --git a/src/admin/components/forms/RenderFields/types.ts b/src/admin/components/forms/RenderFields/types.ts index 3bdd3045da..72d3151e18 100644 --- a/src/admin/components/forms/RenderFields/types.ts +++ b/src/admin/components/forms/RenderFields/types.ts @@ -2,15 +2,8 @@ import { FieldPermissions } from '../../../../auth/types'; import { FieldWithPath, Field } from '../../../../fields/config/types'; import { FieldTypes } from '../field-types'; -export type Operation = 'create' | 'update' - -export type Context = { - operation: Operation -} - export type Props = { className?: string - operation?: Operation readOnly?: boolean permissions?: { [field: string]: FieldPermissions diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx index 7e68f01e1b..d2145b313c 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx @@ -80,7 +80,6 @@ export const EditModal: React.FC = ({ slug, closeModal, relatedCollection (options: Options): FieldType => { const { @@ -24,11 +24,13 @@ const useField = (options: Options): FieldType => { const modified = useFormModified(); const { user } = useAuth(); const { id } = useDocumentInfo(); - const { operation } = useRenderedFields(); + const operation = useOperation(); const { dispatchFields, getField, + getData, + getSiblingData, setModified, } = formContext || {}; @@ -65,8 +67,8 @@ const useField = (options: Options): FieldType => { id, field: field?.field, user, - data: formContext.getData(), - siblingData: formContext.getSiblingData(path), + data: getData(), + siblingData: getSiblingData(path), operation, }; @@ -87,7 +89,8 @@ const useField = (options: Options): FieldType => { disableFormData, dispatchFields, field, - formContext, + getData, + getSiblingData, id, ignoreWhileFlattening, initialValue, diff --git a/src/admin/components/utilities/OperationProvider/index.tsx b/src/admin/components/utilities/OperationProvider/index.tsx new file mode 100644 index 0000000000..e5b1a6c02d --- /dev/null +++ b/src/admin/components/utilities/OperationProvider/index.tsx @@ -0,0 +1,7 @@ +import { useContext, createContext } from 'react'; + +export const OperationContext = createContext(undefined); + +export type Operation = 'create' | 'update' + +export const useOperation = (): Operation | undefined => useContext(OperationContext); diff --git a/src/admin/components/views/Account/Default.tsx b/src/admin/components/views/Account/Default.tsx index 1baf99b410..81cfa6e03e 100644 --- a/src/admin/components/views/Account/Default.tsx +++ b/src/admin/components/views/Account/Default.tsx @@ -15,6 +15,7 @@ import Meta from '../../utilities/Meta'; import Auth from '../collections/Edit/Auth'; import Loading from '../../elements/Loading'; import { Props } from './types'; +import { OperationContext } from '../../utilities/OperationProvider'; import './index.scss'; @@ -55,116 +56,115 @@ const DefaultAccount: React.FC = (props) => { )} {!isLoading && ( - -
- - - {!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && ( + + +
+ + + {!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && ( - )} -
-
-

- -

-
- - field?.admin?.position !== 'sidebar'} - fieldTypes={fieldTypes} - fieldSchema={fields} - /> -
-
-
-
-
-
    - {(permissions?.create?.permission) && ( - -
  • Create New
  • -
    - )} -
-
- - {hasSavePermission && ( - Save - )} -
-
- field?.admin?.position === 'sidebar'} - fieldTypes={fieldTypes} - fieldSchema={fields} - /> -
-
    -
  • - - API URL - {' '} - - - - {apiURL} - -
  • -
  • -
    ID
    -
    {data?.id}
    -
  • - {timestamps && ( - - {data.updatedAt && ( -
  • -
    Last Modified
    -
    {format(new Date(data.updatedAt), dateFormat)}
    -
  • - )} - {data.createdAt && ( -
  • -
    Created
    -
    {format(new Date(data.createdAt), dateFormat)}
    -
  • - )} -
    - )} - -
+ )} +
+
+

+ +

+
+ + field?.admin?.position !== 'sidebar'} + fieldTypes={fieldTypes} + fieldSchema={fields} + />
-
- +
+
+
+
    + {(permissions?.create?.permission) && ( + +
  • Create New
  • +
    + )} +
+
+ + {hasSavePermission && ( + Save + )} +
+
+ field?.admin?.position === 'sidebar'} + fieldTypes={fieldTypes} + fieldSchema={fields} + /> +
+
    +
  • + + API URL + {' '} + + + + {apiURL} + +
  • +
  • +
    ID
    +
    {data?.id}
    +
  • + {timestamps && ( + + {data.updatedAt && ( +
  • +
    Last Modified
    +
    {format(new Date(data.updatedAt), dateFormat)}
    +
  • + )} + {data.createdAt && ( +
  • +
    Created
    +
    {format(new Date(data.createdAt), dateFormat)}
    +
  • + )} +
    + )} + +
+
+
+
+ + )}
); diff --git a/src/admin/components/views/Global/Default.tsx b/src/admin/components/views/Global/Default.tsx index af0540249a..9e13969692 100644 --- a/src/admin/components/views/Global/Default.tsx +++ b/src/admin/components/views/Global/Default.tsx @@ -21,6 +21,7 @@ import Status from '../../elements/Status'; import Autosave from '../../elements/Autosave'; import './index.scss'; +import { OperationContext } from '../../utilities/OperationProvider'; const baseClass = 'global-edit'; @@ -51,135 +52,134 @@ const DefaultGlobalView: React.FC = (props) => { )} {!isLoading && ( -
-
- - - {!(global.versions?.drafts && global.versions?.drafts?.autosave) && ( - - )} -
-
-

- Edit - {' '} - {label} -

- {description && ( -
- -
- )} -
- (!field.admin.position || (field.admin.position && field.admin.position !== 'sidebar'))} - fieldTypes={fieldTypes} - fieldSchema={fields} + + +
+ -
-
-
-
-
-
- {(preview && (!global.versions?.drafts || global.versions?.drafts?.autosave)) && ( - + + {!(global.versions?.drafts && global.versions?.drafts?.autosave) && ( + + )} +
+
+

+ Edit + {' '} + {label} +

+ {description && ( +
+ +
)} - {hasSavePermission && ( - - {global.versions?.drafts && ( - - {!global.versions.drafts.autosave && ( - - )} - - - )} - {!global.versions?.drafts && ( - Save - )} - - )} -
-
- {(preview && (global.versions?.drafts && !global.versions?.drafts?.autosave)) && ( - - )} - {global.versions?.drafts && ( - - - {(global.versions.drafts.autosave && hasSavePermission) && ( - - )} - - )} - field.admin.position === 'sidebar'} - fieldTypes={fieldTypes} - fieldSchema={fields} - /> -
-
    - {versions && ( -
  • -
    Versions
    - -
  • - )} - {(data && !hideAPIURL) && ( -
  • - - API URL - {' '} - - - - {apiURL} - -
  • - )} - {data.updatedAt && ( -
  • -
    Last Modified
    -
    {format(new Date(data.updatedAt as string), dateFormat)}
    -
  • - )} -
+
+ (!field.admin.position || (field.admin.position && field.admin.position !== 'sidebar'))} + fieldTypes={fieldTypes} + fieldSchema={fields} + /> - - +
+
+
+
+ {(preview && (!global.versions?.drafts || global.versions?.drafts?.autosave)) && ( + + )} + {hasSavePermission && ( + + {global.versions?.drafts && ( + + {!global.versions.drafts.autosave && ( + + )} + + + )} + {!global.versions?.drafts && ( + Save + )} + + )} +
+
+ {(preview && (global.versions?.drafts && !global.versions?.drafts?.autosave)) && ( + + )} + {global.versions?.drafts && ( + + + {(global.versions.drafts.autosave && hasSavePermission) && ( + + )} + + )} + field.admin.position === 'sidebar'} + fieldTypes={fieldTypes} + fieldSchema={fields} + /> +
+
    + {versions && ( +
  • +
    Versions
    + +
  • + )} + {(data && !hideAPIURL) && ( +
  • + + API URL + {' '} + + + + {apiURL} + +
  • + )} + {data.updatedAt && ( +
  • +
    Last Modified
    +
    {format(new Date(data.updatedAt as string), dateFormat)}
    +
  • + )} +
+
+
+
+ + )} ); diff --git a/src/admin/components/views/Login/index.tsx b/src/admin/components/views/Login/index.tsx index 6dfc45303e..649724e49b 100644 --- a/src/admin/components/views/Login/index.tsx +++ b/src/admin/components/views/Login/index.tsx @@ -86,7 +86,6 @@ const Login: React.FC = () => { onSuccess={onSuccess} method="post" action={`${serverURL}${api}/${userSlug}/login`} - validationOperation="update" > text(val, { minLength: 24, maxLength: 48 }); +const validate = (val) => text(val, { field: { minLength: 24, maxLength: 48 }, data: {}, siblingData: {} }); const APIKey: React.FC = () => { const [initialAPIKey, setInitialAPIKey] = useState(null); diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index 813e59b3b5..adfc0cf48d 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -25,6 +25,7 @@ import Status from '../../../elements/Status'; import Publish from '../../../elements/Publish'; import SaveDraft from '../../../elements/SaveDraft'; import { useDocumentInfo } from '../../../utilities/DocumentInfo'; +import { OperationContext } from '../../../utilities/OperationProvider'; import './index.scss'; @@ -76,32 +77,33 @@ const DefaultEditView: React.FC = (props) => { )} {!isLoading && ( -
-
- - - {!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && ( + + +
+ + + {!(collection.versions?.drafts && collection.versions?.drafts?.autosave) && ( - )} -
-
-

- -

-
- {auth && ( + )} +
+
+

+ +

+
+ {auth && ( = (props) => { email={data?.email} operation={operation} /> - )} - {upload && ( + )} + {upload && ( - )} - (!field?.admin?.position || (field?.admin?.position !== 'sidebar'))} - fieldTypes={fieldTypes} - fieldSchema={fields} - /> + )} + (!field?.admin?.position || (field?.admin?.position !== 'sidebar'))} + fieldTypes={fieldTypes} + fieldSchema={fields} + /> +
-
-
-
-
-
    - {(permissions?.create?.permission) && ( +
    +
    +
    +
      + {(permissions?.create?.permission) && (
    • Create New
    • {!disableDuplicate && (
    • )}
      - )} - {permissions?.delete?.permission && ( + )} + {permissions?.delete?.permission && (
    • = (props) => { />
    • )} -
    -
    - {(preview && (!collection.versions?.drafts || collection.versions?.drafts?.autosave)) && ( +
+
+ {(preview && (!collection.versions?.drafts || collection.versions?.drafts?.autosave)) && ( - )} - {hasSavePermission && ( + )} + {hasSavePermission && ( {collection.versions?.drafts && ( @@ -169,16 +170,16 @@ const DefaultEditView: React.FC = (props) => { Save )} - )} -
-
- {(isEditing && preview && (collection.versions?.drafts && !collection.versions?.drafts?.autosave)) && ( + )} +
+
+ {(isEditing && preview && (collection.versions?.drafts && !collection.versions?.drafts?.autosave)) && ( - )} - {collection.versions?.drafts && ( + )} + {collection.versions?.drafts && ( {(collection.versions?.drafts.autosave && hasSavePermission) && ( @@ -190,16 +191,15 @@ const DefaultEditView: React.FC = (props) => { )} )} - field?.admin?.position === 'sidebar'} - fieldTypes={fieldTypes} - fieldSchema={fields} - /> -
- {isEditing && ( + field?.admin?.position === 'sidebar'} + fieldTypes={fieldTypes} + fieldSchema={fields} + /> +
+ {isEditing && (
    {!hideAPIURL && (
  • @@ -243,11 +243,12 @@ const DefaultEditView: React.FC = (props) => { )}
- )} + )} +
- -
+ + )} ); diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 226c6bf5f5..6dd3af3871 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -51,16 +51,16 @@ export type Labels = { plural: string; }; -export type ValidateOptions = { +export type ValidateOptions = { field: F data: Partial siblingData: Partial id?: string | number user?: Partial - operation: Operation + operation?: Operation } -export type Validate = (value?: T, options?: ValidateOptions) => string | true | Promise; +export type Validate = (value?: T, options?: ValidateOptions) => string | true | Promise; export type OptionObject = { label: string diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts index 38303b5eb2..668706969a 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -13,7 +13,6 @@ import { Operation } from '../types'; import { PayloadRequest } from '../express/types'; import { Payload } from '..'; import richTextRelationshipPromise from './richText/relationshipPromise'; -import getSiblingData from '../admin/components/forms/Form/getSiblingData'; type Arguments = { fields: Field[] @@ -380,9 +379,10 @@ const traverseFields = (args: Arguments): void => { validationPromises.push(() => validationPromise({ errors, hook, - newData: { [field.name]: newRowCount }, - existingData: { [field.name]: existingRowCount }, - siblingData: getSiblingData(data, field.name), + data: { [field.name]: newRowCount }, + fullData, + originalDoc: { [field.name]: existingRowCount }, + fullOriginalDoc, field, path, skipValidation: skipValidationFromHere, @@ -394,9 +394,10 @@ const traverseFields = (args: Arguments): void => { validationPromises.push(() => validationPromise({ errors, hook, - newData: data, - existingData: originalDoc, - siblingData: getSiblingData(data, field.name), + data, + fullData, + originalDoc, + fullOriginalDoc, field, path, skipValidation: skipValidationFromHere, diff --git a/src/fields/validationPromise.ts b/src/fields/validationPromise.ts index 21499bac13..cca72be062 100644 --- a/src/fields/validationPromise.ts +++ b/src/fields/validationPromise.ts @@ -1,5 +1,6 @@ -import { User } from 'payload/auth'; -import { Operation } from 'payload/types'; +import merge from 'deepmerge'; +import { User } from '../auth'; +import { Operation } from '../types'; import { HookName, FieldAffectingData } from './config/types'; type Arguments = { @@ -7,9 +8,10 @@ type Arguments = { field: FieldAffectingData path: string errors: {message: string, field: string}[] - newData: Record - existingData: Record - siblingData: Record + data: Record + fullData: Record + originalDoc: Record + fullOriginalDoc: Record id?: string | number skipValidation?: boolean user: User @@ -19,9 +21,10 @@ type Arguments = { const validationPromise = async ({ errors, hook, - newData, - existingData, - siblingData, + originalDoc, + fullOriginalDoc, + data, + fullData, id, field, path, @@ -33,16 +36,15 @@ const validationPromise = async ({ const hasCondition = field.admin && field.admin.condition; const shouldValidate = field.validate && !hasCondition; - const dataToValidate = newData || existingData; - let valueToValidate = newData?.[field.name]; - if (valueToValidate === undefined) valueToValidate = existingData?.[field.name]; + let valueToValidate = data?.[field.name]; + if (valueToValidate === undefined) valueToValidate = originalDoc?.[field.name]; if (valueToValidate === undefined) valueToValidate = field.defaultValue; const result = shouldValidate ? await field.validate(valueToValidate, { field, - data: dataToValidate, - siblingData, + data: merge(fullOriginalDoc, fullData), + siblingData: merge(originalDoc, data), id, operation, user, diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 05c0a628f7..8b01649199 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -13,7 +13,7 @@ import { const defaultMessage = 'This field is required.'; -export const number: Validate = (value: string, options) => { +export const number: Validate = (value: string, options) => { const parsedValue = parseInt(value, 10); if ((value && typeof parsedValue !== 'number') || (options.field.required && Number.isNaN(parsedValue))) { @@ -35,7 +35,7 @@ export const number: Validate = (value: string, options) => { return true; }; -export const text: Validate = (value: string, options) => { +export const text: Validate = (value: string, options) => { if (value && options.field.maxLength && value.length > options.field.maxLength) { return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; } @@ -53,7 +53,7 @@ export const text: Validate = (value: string, options) => { return true; }; -export const password: Validate = (value: string, options) => { +export const password: Validate = (value: string, options) => { if (value && options.field.maxLength && value.length > options.field.maxLength) { return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; } @@ -69,7 +69,7 @@ export const password: Validate = (value: string, options) => { return true; }; -export const email: Validate = (value: string, options) => { +export const email: Validate = (value: string, options) => { if ((value && !/\S+@\S+\.\S+/.test(value)) || (!value && options.field.required)) { return 'Please enter a valid email address.'; @@ -78,7 +78,7 @@ export const email: Validate = (value: string, options) => { return true; }; -export const textarea: Validate = (value: string, options) => { +export const textarea: Validate = (value: string, options) => { if (value && options.field.maxLength && value.length > options.field.maxLength) { return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; } @@ -94,7 +94,7 @@ export const textarea: Validate = (value: string, options) => { return true; }; -export const wysiwyg: Validate = (value: string, options) => { +export const wysiwyg: Validate = (value: string, options) => { if (options.field.required && !value) { return defaultMessage; } @@ -102,7 +102,7 @@ export const wysiwyg: Validate = (value: string, options) => { return true; }; -export const code: Validate = (value: string, options) => { +export const code: Validate = (value: string, options) => { if (options.field.required && value === undefined) { return defaultMessage; } @@ -110,7 +110,7 @@ export const code: Validate = (value: string, options) => { return true; }; -export const richText: Validate = (value, options) => { +export const richText: Validate = (value, options) => { if (options.field.required) { const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue); if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true; @@ -120,7 +120,7 @@ export const richText: Validate = (value, options) => { return true; }; -export const checkbox: Validate = (value: boolean, options) => { +export const checkbox: Validate = (value: boolean, options) => { if ((value && typeof value !== 'boolean') || (options.field.required && typeof value !== 'boolean')) { return 'This field can only be equal to true or false.'; @@ -129,7 +129,7 @@ export const checkbox: Validate = (value: boolean, options) => { return true; }; -export const date: Validate = (value, options) => { +export const date: Validate = (value, options) => { if (value && !isNaN(Date.parse(value.toString()))) { /* eslint-disable-line */ return true; } @@ -145,17 +145,17 @@ export const date: Validate = (value, options) => { return true; }; -export const upload: Validate = (value: string, options) => { +export const upload: Validate = (value: string, options) => { if (value || !options.field.required) return true; return defaultMessage; }; -export const relationship: Validate = (value, options) => { +export const relationship: Validate = (value, options) => { if (value || !options.field.required) return true; return defaultMessage; }; -export const array: Validate = (value, options) => { +export const array: Validate = (value, options) => { if (options.field.minRows && value < options.field.minRows) { return `This field requires at least ${options.field.minRows} row(s).`; } @@ -171,7 +171,7 @@ export const array: Validate = (value, options) => { return true; }; -export const select: Validate = (value, options) => { +export const select: Validate = (value, options) => { if (Array.isArray(value) && value.some((input) => !options.field.options.some((option) => (option === input || (typeof option !== 'string' && option?.value === input))))) { return 'This field has an invalid selection'; } @@ -189,13 +189,13 @@ export const select: Validate = (value, options) => { return true; }; -export const radio: Validate = (value, options) => { +export const radio: Validate = (value, options) => { const stringValue = String(value); if ((typeof value !== 'undefined' || !options.field.required) && (options.field.options.find((option) => String(typeof option !== 'string' && option?.value) === stringValue))) return true; return defaultMessage; }; -export const blocks: Validate = (value, options) => { +export const blocks: Validate = (value, options) => { if (options.field.minRows && value < options.field.minRows) { return `This field requires at least ${options.field.minRows} row(s).`; } @@ -211,7 +211,7 @@ export const blocks: Validate = (value, options) => { return true; }; -export const point: Validate = (value: [number | string, number | string] = ['', ''], options) => { +export const point: Validate = (value: [number | string, number | string] = ['', ''], options) => { const lng = parseFloat(String(value[0])); const lat = parseFloat(String(value[1])); if ( From f5191dc7c8210bef468c1467f346bc2b72383ae6 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 29 Mar 2022 15:38:22 -0400 Subject: [PATCH 03/19] chore: more consistently passes validation args --- .../forms/Form/buildStateFromSchema.ts | 7 +++---- .../forms/field-types/Array/Array.tsx | 11 +++++++++-- .../forms/field-types/Blocks/Blocks.tsx | 17 +++++++++++------ .../elements/upload/Element/EditModal/index.tsx | 6 ++++-- src/admin/components/views/Account/index.tsx | 7 +++++-- src/admin/components/views/Global/index.tsx | 10 +++++----- .../components/views/collections/Edit/index.tsx | 10 +++++----- 7 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/admin/components/forms/Form/buildStateFromSchema.ts b/src/admin/components/forms/Form/buildStateFromSchema.ts index 66a69c8b5f..e1ed9c01e2 100644 --- a/src/admin/components/forms/Form/buildStateFromSchema.ts +++ b/src/admin/components/forms/Form/buildStateFromSchema.ts @@ -39,7 +39,6 @@ const buildStateFromSchema = async (args: Args): Promise => { const { fieldSchema, data: fullData = {}, - siblingData = {}, user, id, operation, @@ -48,7 +47,7 @@ const buildStateFromSchema = async (args: Args): Promise => { if (fieldSchema) { const validationPromises = []; - const structureFieldState = (field, passesCondition, data = {}) => { + const structureFieldState = (field, passesCondition, data = {}, siblingData = {}) => { const value = typeof data?.[field.name] !== 'undefined' ? data[field.name] : field.defaultValue; const fieldState = { @@ -156,7 +155,7 @@ const buildStateFromSchema = async (args: Args): Promise => { return { ...state, - [`${path}${field.name}`]: structureFieldState(field, passesCondition, data), + [`${path}${field.name}`]: structureFieldState(field, passesCondition, fullData, data), }; } @@ -173,7 +172,7 @@ const buildStateFromSchema = async (args: Args): Promise => { // Handle normal fields return { ...state, - [`${path}${namedField.name}`]: structureFieldState(field, passesCondition, data), + [`${path}${namedField.name}`]: structureFieldState(field, passesCondition, fullData, data), }; } diff --git a/src/admin/components/forms/field-types/Array/Array.tsx b/src/admin/components/forms/field-types/Array/Array.tsx index 047cafabb8..d80f81bba1 100644 --- a/src/admin/components/forms/field-types/Array/Array.tsx +++ b/src/admin/components/forms/field-types/Array/Array.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useReducer, useState } from 'react'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; +import { useAuth } from '@payloadcms/config-provider'; import withCondition from '../../withCondition'; import Button from '../../../elements/Button'; import DraggableSection from '../../DraggableSection'; @@ -14,6 +15,9 @@ import Banner from '../../../elements/Banner'; import FieldDescription from '../../FieldDescription'; import { Props } from './types'; +import { useDocumentInfo } from '../../../utilities/DocumentInfo'; +import { useOperation } from '../../../utilities/OperationProvider'; + import './index.scss'; const baseClass = 'field-type array'; @@ -49,6 +53,9 @@ const ArrayFieldType: React.FC = (props) => { const [rows, dispatchRows] = useReducer(reducer, []); const formContext = useForm(); + const { user } = useAuth(); + const { id } = useDocumentInfo(); + const operation = useOperation(); const { dispatchFields } = formContext; @@ -74,11 +81,11 @@ const ArrayFieldType: React.FC = (props) => { }); const addRow = useCallback(async (rowIndex) => { - const subFieldState = await buildStateFromSchema({ fieldSchema: fields }); + const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user }); dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path }); dispatchRows({ type: 'ADD', rowIndex }); setValue(value as number + 1); - }, [dispatchRows, dispatchFields, fields, path, setValue, value]); + }, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user]); const removeRow = useCallback((rowIndex) => { dispatchRows({ type: 'REMOVE', rowIndex }); diff --git a/src/admin/components/forms/field-types/Blocks/Blocks.tsx b/src/admin/components/forms/field-types/Blocks/Blocks.tsx index ac5605a1a9..ece8f20fe9 100644 --- a/src/admin/components/forms/field-types/Blocks/Blocks.tsx +++ b/src/admin/components/forms/field-types/Blocks/Blocks.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useReducer, useState } from 'react'; import { DragDropContext, Droppable } from 'react-beautiful-dnd'; +import { useAuth } from '@payloadcms/config-provider'; import { usePreferences } from '../../../utilities/Preferences'; import withCondition from '../../withCondition'; import Button from '../../../elements/Button'; @@ -18,6 +19,7 @@ import Banner from '../../../elements/Banner'; import FieldDescription from '../../FieldDescription'; import { Props } from './types'; import { DocumentPreferences } from '../../../../../preferences/types'; +import { useOperation } from '../../../utilities/OperationProvider'; import './index.scss'; @@ -55,6 +57,9 @@ const Blocks: React.FC = (props) => { const { getPreference, setPreference } = usePreferences(); const [rows, dispatchRows] = useReducer(reducer, []); const formContext = useForm(); + const { user } = useAuth(); + const { id } = useDocumentInfo(); + const operation = useOperation(); const { dispatchFields } = formContext; const memoizedValidate = useCallback((value, options) => { @@ -79,12 +84,12 @@ const Blocks: React.FC = (props) => { const addRow = useCallback(async (rowIndex, blockType) => { const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType); - const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields }); + const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields, operation, id, user }); dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType }); dispatchRows({ type: 'ADD', rowIndex, blockType }); setValue(value as number + 1); - }, [path, setValue, value, blocks, dispatchFields]); + }, [path, setValue, value, blocks, dispatchFields, operation, id, user]); const removeRow = useCallback((rowIndex) => { dispatchRows({ type: 'REMOVE', rowIndex }); @@ -97,8 +102,8 @@ const Blocks: React.FC = (props) => { dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path }); }, [dispatchRows, dispatchFields, path]); - const setCollapse = useCallback(async (id: string, collapsed: boolean) => { - dispatchRows({ type: 'SET_COLLAPSE', id, collapsed }); + const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => { + dispatchRows({ type: 'SET_COLLAPSE', rowID, collapsed }); if (preferencesKey) { const preferences: DocumentPreferences = await getPreference(preferencesKey); @@ -108,9 +113,9 @@ const Blocks: React.FC = (props) => { || []; if (!collapsed) { - newCollapsedState = newCollapsedState.filter((existingID) => existingID !== id); + newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID); } else { - newCollapsedState.push(id); + newCollapsedState.push(rowID); } setPreference(preferencesKey, { diff --git a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx index d2145b313c..68efb21bea 100644 --- a/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/upload/Element/EditModal/index.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Transforms, Element } from 'slate'; import { ReactEditor, useSlateStatic } from 'slate-react'; import { Modal } from '@faceless-ui/modal'; +import { useAuth } from '@payloadcms/config-provider'; import { SanitizedCollectionConfig } from '../../../../../../../../../collections/config/types'; import buildStateFromSchema from '../../../../../../Form/buildStateFromSchema'; import MinimalTemplate from '../../../../../../../templates/Minimal'; @@ -29,6 +30,7 @@ type Props = { export const EditModal: React.FC = ({ slug, closeModal, relatedCollectionConfig, fieldSchema, element }) => { const editor = useSlateStatic(); const [initialState, setInitialState] = useState({}); + const { user } = useAuth(); const handleUpdateEditData = useCallback((fields) => { const newNode = { @@ -47,12 +49,12 @@ export const EditModal: React.FC = ({ slug, closeModal, relatedCollection useEffect(() => { const awaitInitialState = async () => { - const state = await buildStateFromSchema({ fieldSchema, data: element?.fields }); + const state = await buildStateFromSchema({ fieldSchema, data: element?.fields, user, operation: 'update' }); setInitialState(state); }; awaitInitialState(); - }, [fieldSchema, element.fields]); + }, [fieldSchema, element.fields, user]); return ( { const { state: locationState } = useLocation<{ data: unknown }>(); @@ -16,6 +17,8 @@ const AccountView: React.FC = () => { const { setStepNav } = useStepNav(); const { user, permissions } = useAuth(); const [initialState, setInitialState] = useState({}); + const { id } = useDocumentInfo(); + const { serverURL, routes: { api }, @@ -61,12 +64,12 @@ const AccountView: React.FC = () => { useEffect(() => { const awaitInitialState = async () => { - const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender }); + const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender, operation: 'update', id, user }); setInitialState(state); }; awaitInitialState(); - }, [dataToRender, fields]); + }, [dataToRender, fields, id, user]); return ( diff --git a/src/admin/components/views/Global/index.tsx b/src/admin/components/views/Global/index.tsx index 69b189d2b7..42087cf2f4 100644 --- a/src/admin/components/views/Global/index.tsx +++ b/src/admin/components/views/Global/index.tsx @@ -17,7 +17,7 @@ const GlobalView: React.FC = (props) => { const { state: locationState } = useLocation<{data?: Record}>(); const locale = useLocale(); const { setStepNav } = useStepNav(); - const { permissions } = useAuth(); + const { permissions, user } = useAuth(); const [initialState, setInitialState] = useState({}); const { getVersions } = useDocumentInfo(); @@ -45,9 +45,9 @@ const GlobalView: React.FC = (props) => { const onSave = useCallback(async (json) => { getVersions(); - const state = await buildStateFromSchema({ fieldSchema: fields, data: json.result }); + const state = await buildStateFromSchema({ fieldSchema: fields, data: json.result, operation: 'update', user }); setInitialState(state); - }, [getVersions, fields]); + }, [getVersions, fields, user]); const [{ data, isLoading }] = usePayloadAPI( `${serverURL}${api}/globals/${slug}`, @@ -66,12 +66,12 @@ const GlobalView: React.FC = (props) => { useEffect(() => { const awaitInitialState = async () => { - const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender }); + const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender, user, operation: 'update' }); setInitialState(state); }; awaitInitialState(); - }, [dataToRender, fields]); + }, [dataToRender, fields, user]); const globalPermissions = permissions?.globals?.[slug]; diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index 6f07ea6d56..9de096fd5b 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -42,7 +42,7 @@ const EditView: React.FC = (props) => { const history = useHistory(); const { setStepNav } = useStepNav(); const [initialState, setInitialState] = useState({}); - const { permissions } = useAuth(); + const { permissions, user } = useAuth(); const { getVersions } = useDocumentInfo(); const onSave = useCallback(async (json: any) => { @@ -50,10 +50,10 @@ const EditView: React.FC = (props) => { if (!isEditing) { history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); } else { - const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc }); + const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update' }); setInitialState(state); } - }, [admin, collection, history, isEditing, getVersions]); + }, [admin, collection, history, isEditing, getVersions, user, id]); const [{ data, isLoading, isError }] = usePayloadAPI( (isEditing ? `${serverURL}${api}/${slug}/${id}` : null), @@ -97,12 +97,12 @@ const EditView: React.FC = (props) => { useEffect(() => { const awaitInitialState = async () => { - const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender }); + const state = await buildStateFromSchema({ fieldSchema: fields, data: dataToRender, user, operation: isEditing ? 'update' : 'create', id }); setInitialState(state); }; awaitInitialState(); - }, [dataToRender, fields]); + }, [dataToRender, fields, isEditing, id, user]); if (isError) { return ( From 2b1a33efba61157cb4718551853a207a795329bb Mon Sep 17 00:00:00 2001 From: James Date: Tue, 29 Mar 2022 17:57:10 -0400 Subject: [PATCH 04/19] chore: removes field from form state --- .../forms/Form/buildStateFromSchema.ts | 8 +- src/admin/components/forms/Form/index.tsx | 3 +- src/admin/components/forms/Form/types.ts | 1 - .../forms/field-types/Array/Array.tsx | 4 +- .../forms/field-types/Blocks/Blocks.tsx | 4 +- .../forms/field-types/Checkbox/index.tsx | 5 +- .../forms/field-types/Code/Code.tsx | 4 +- .../forms/field-types/DateTime/index.tsx | 4 +- .../forms/field-types/Email/index.tsx | 4 +- .../forms/field-types/Number/index.tsx | 4 +- .../forms/field-types/Password/index.tsx | 6 +- .../forms/field-types/Point/index.tsx | 4 +- .../forms/field-types/RadioGroup/index.tsx | 4 +- .../forms/field-types/Relationship/index.tsx | 4 +- .../forms/field-types/RichText/RichText.tsx | 6 +- .../forms/field-types/Select/index.tsx | 5 +- .../forms/field-types/Text/index.tsx | 7 +- .../forms/field-types/Textarea/index.tsx | 8 +- .../forms/field-types/Upload/index.tsx | 6 +- src/admin/components/forms/useField/index.tsx | 2 - src/fields/config/types.ts | 5 +- src/fields/validationPromise.ts | 2 +- src/fields/validations.ts | 131 ++++++++---------- 23 files changed, 111 insertions(+), 120 deletions(-) diff --git a/src/admin/components/forms/Form/buildStateFromSchema.ts b/src/admin/components/forms/Form/buildStateFromSchema.ts index e1ed9c01e2..6be49cb255 100644 --- a/src/admin/components/forms/Form/buildStateFromSchema.ts +++ b/src/admin/components/forms/Form/buildStateFromSchema.ts @@ -9,13 +9,13 @@ import { } from '../../../../fields/config/types'; import { Fields, Field, Data } from './types'; -const buildValidationPromise = async (fieldState: Field, options: ValidateOptions) => { +const buildValidationPromise = async (fieldState: Field, options: ValidateOptions) => { const validatedFieldState = fieldState; let validationResult: boolean | string = true; - if (fieldAffectsData(options.field) && typeof options.field.validate === 'function') { - validationResult = await options.field.validate(fieldState.value, options); + if (typeof fieldState.validate === 'function') { + validationResult = await fieldState.validate(fieldState.value, options); } if (typeof validationResult === 'string') { @@ -51,7 +51,6 @@ const buildStateFromSchema = async (args: Args): Promise => { const value = typeof data?.[field.name] !== 'undefined' ? data[field.name] : field.defaultValue; const fieldState = { - field, value, initialValue: value, valid: true, @@ -61,7 +60,6 @@ const buildStateFromSchema = async (args: Args): Promise => { }; validationPromises.push(buildValidationPromise(fieldState, { - field, data: fullData, user, siblingData, diff --git a/src/admin/components/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index 8af44e5750..9056ba1994 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -16,7 +16,7 @@ import reduceFieldsToValues from './reduceFieldsToValues'; import getSiblingDataFunc from './getSiblingData'; import getDataByPathFunc from './getDataByPath'; import wait from '../../../../utilities/wait'; -import { Field, FieldAffectingData } from '../../../../fields/config/types'; +import { Field } from '../../../../fields/config/types'; import buildInitialState from './buildInitialState'; import errorMessages from './errorMessages'; import { Context as FormContextType, Props, SubmitOptions } from './types'; @@ -86,7 +86,6 @@ const Form: React.FC = (props) => { if (typeof field.validate === 'function') { validationResult = await field.validate(field.value, { - field: (field.field as unknown as FieldAffectingData), data, siblingData: contextRef.current.getSiblingData(path), user, diff --git a/src/admin/components/forms/Form/types.ts b/src/admin/components/forms/Form/types.ts index 2ebd6f0a6b..2c2ef60e18 100644 --- a/src/admin/components/forms/Form/types.ts +++ b/src/admin/components/forms/Form/types.ts @@ -2,7 +2,6 @@ import { Field as FieldConfig, Condition, Validate } from '../../../../fields/co export type Field = { value: unknown - field: Field initialValue: unknown errorMessage?: string valid: boolean diff --git a/src/admin/components/forms/field-types/Array/Array.tsx b/src/admin/components/forms/field-types/Array/Array.tsx index d80f81bba1..9004486df8 100644 --- a/src/admin/components/forms/field-types/Array/Array.tsx +++ b/src/admin/components/forms/field-types/Array/Array.tsx @@ -62,8 +62,8 @@ const ArrayFieldType: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, minRows, maxRows, required }); + }, [maxRows, minRows, required, validate]); const [disableFormData, setDisableFormData] = useState(false); diff --git a/src/admin/components/forms/field-types/Blocks/Blocks.tsx b/src/admin/components/forms/field-types/Blocks/Blocks.tsx index ece8f20fe9..5dc6958707 100644 --- a/src/admin/components/forms/field-types/Blocks/Blocks.tsx +++ b/src/admin/components/forms/field-types/Blocks/Blocks.tsx @@ -63,8 +63,8 @@ const Blocks: React.FC = (props) => { const { dispatchFields } = formContext; const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, minRows, maxRows, required }); + }, [maxRows, minRows, required, validate]); const [disableFormData, setDisableFormData] = useState(false); diff --git a/src/admin/components/forms/field-types/Checkbox/index.tsx b/src/admin/components/forms/field-types/Checkbox/index.tsx index f53defdfb8..6bcb0b2923 100644 --- a/src/admin/components/forms/field-types/Checkbox/index.tsx +++ b/src/admin/components/forms/field-types/Checkbox/index.tsx @@ -19,6 +19,7 @@ const Checkbox: React.FC = (props) => { label, onChange, disableFormData, + required, admin: { readOnly, style, @@ -32,8 +33,8 @@ const Checkbox: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, required }); + }, [validate, required]); const { value, diff --git a/src/admin/components/forms/field-types/Code/Code.tsx b/src/admin/components/forms/field-types/Code/Code.tsx index a9fa9848e7..bbf0b70489 100644 --- a/src/admin/components/forms/field-types/Code/Code.tsx +++ b/src/admin/components/forms/field-types/Code/Code.tsx @@ -42,8 +42,8 @@ const Code: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, required }); + }, [validate, required]); const { value, diff --git a/src/admin/components/forms/field-types/DateTime/index.tsx b/src/admin/components/forms/field-types/DateTime/index.tsx index eef6121713..a357957485 100644 --- a/src/admin/components/forms/field-types/DateTime/index.tsx +++ b/src/admin/components/forms/field-types/DateTime/index.tsx @@ -35,8 +35,8 @@ const DateTime: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, required }); + }, [validate, required]); const { value, diff --git a/src/admin/components/forms/field-types/Email/index.tsx b/src/admin/components/forms/field-types/Email/index.tsx index 3ec21d110d..3ef1fa7efe 100644 --- a/src/admin/components/forms/field-types/Email/index.tsx +++ b/src/admin/components/forms/field-types/Email/index.tsx @@ -31,8 +31,8 @@ const Email: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, required }); + }, [validate, required]); const fieldType = useField({ path, diff --git a/src/admin/components/forms/field-types/Number/index.tsx b/src/admin/components/forms/field-types/Number/index.tsx index 13bca4e064..951726be68 100644 --- a/src/admin/components/forms/field-types/Number/index.tsx +++ b/src/admin/components/forms/field-types/Number/index.tsx @@ -33,8 +33,8 @@ const NumberField: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, min, max, required }); + }, [validate, min, max, required]); const { value, diff --git a/src/admin/components/forms/field-types/Password/index.tsx b/src/admin/components/forms/field-types/Password/index.tsx index 5bbd6607da..61e88232e9 100644 --- a/src/admin/components/forms/field-types/Password/index.tsx +++ b/src/admin/components/forms/field-types/Password/index.tsx @@ -24,9 +24,9 @@ const Password: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, options) => { - debugger; - return validate(value, options); - }, [validate]); + const validationResult = validate(value, { ...options, required }); + return validationResult; + }, [validate, required]); const { value, diff --git a/src/admin/components/forms/field-types/Point/index.tsx b/src/admin/components/forms/field-types/Point/index.tsx index 9143cf3ab1..f73dd305a5 100644 --- a/src/admin/components/forms/field-types/Point/index.tsx +++ b/src/admin/components/forms/field-types/Point/index.tsx @@ -33,8 +33,8 @@ const PointField: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, required }); + }, [validate, required]); const { value = [null, null], diff --git a/src/admin/components/forms/field-types/RadioGroup/index.tsx b/src/admin/components/forms/field-types/RadioGroup/index.tsx index 7285dad252..45137c09ba 100644 --- a/src/admin/components/forms/field-types/RadioGroup/index.tsx +++ b/src/admin/components/forms/field-types/RadioGroup/index.tsx @@ -36,8 +36,8 @@ const RadioGroup: React.FC = (props) => { const path = pathFromProps || name; const memoizedValidate = useCallback((value, validationOptions) => { - return validate(value, validationOptions); - }, [validate]); + return validate(value, { ...validationOptions, options, required }); + }, [validate, options, required]); const { value, diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index 3aa918e5c0..bb4d36a946 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -63,8 +63,8 @@ const Relationship: React.FC = (props) => { const [search, setSearch] = useState(''); const memoizedValidate = useCallback((value, validationOptions) => { - return validate(value, validationOptions); - }, [validate]); + return validate(value, { ...validationOptions, required }); + }, [validate, required]); const { value, diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index 98458ecb2a..b76a3bd5d1 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -3,6 +3,7 @@ import isHotkey from 'is-hotkey'; import { createEditor, Transforms, Node, Element as SlateElement, Text, BaseEditor } from 'slate'; import { ReactEditor, Editable, withReact, Slate } from 'slate-react'; import { HistoryEditor, withHistory } from 'slate-history'; +import { options } from 'joi'; import { richText } from '../../../../../fields/validations'; import useField from '../../useField'; import withCondition from '../../withCondition'; @@ -118,8 +119,8 @@ const RichText: React.FC = (props) => { }, [enabledLeaves, path, props]); const memoizedValidate = useCallback((value, validationOptions) => { - return validate(value, validationOptions); - }, [validate]); + return validate(value, { ...validationOptions, required }); + }, [validate, required]); const fieldType = useField({ path, @@ -271,7 +272,6 @@ const RichText: React.FC = (props) => { ref={editorRef} > = (props) => { label, options: optionsFromProps, hasMany, + required, admin: { readOnly, style, @@ -44,8 +45,8 @@ const Select: React.FC = (props) => { }, [optionsFromProps]); const memoizedValidate = useCallback((value, validationOptions) => { - return validate(value, validationOptions); - }, [validate]); + return validate(value, { ...validationOptions, options, hasMany, required }); + }, [validate, required, hasMany, options]); const { value, diff --git a/src/admin/components/forms/field-types/Text/index.tsx b/src/admin/components/forms/field-types/Text/index.tsx index 1e24856bd6..656d534af4 100644 --- a/src/admin/components/forms/field-types/Text/index.tsx +++ b/src/admin/components/forms/field-types/Text/index.tsx @@ -12,6 +12,8 @@ const Text: React.FC = (props) => { required, validate = text, label, + minLength, + maxLength, admin: { placeholder, readOnly, @@ -24,9 +26,10 @@ const Text: React.FC = (props) => { } = props; const path = pathFromProps || name; + const memoizedValidate = useCallback((value, options) => { - return validate(value, options); - }, [validate]); + return validate(value, { ...options, minLength, maxLength, required }); + }, [validate, minLength, maxLength, required]); const field = useField({ path, diff --git a/src/admin/components/forms/field-types/Textarea/index.tsx b/src/admin/components/forms/field-types/Textarea/index.tsx index 73bfb047de..4e9ed1b084 100644 --- a/src/admin/components/forms/field-types/Textarea/index.tsx +++ b/src/admin/components/forms/field-types/Textarea/index.tsx @@ -13,6 +13,8 @@ const Textarea: React.FC = (props) => { name, required, validate = textarea, + maxLength, + minLength, admin: { readOnly, style, @@ -28,9 +30,9 @@ const Textarea: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value, validationOptions) => { - return validate(value, validationOptions); - }, [validate]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, required, maxLength, minLength }); + }, [validate, required, maxLength, minLength]); const { value, diff --git a/src/admin/components/forms/field-types/Upload/index.tsx b/src/admin/components/forms/field-types/Upload/index.tsx index b2d170f233..ef42b8128f 100644 --- a/src/admin/components/forms/field-types/Upload/index.tsx +++ b/src/admin/components/forms/field-types/Upload/index.tsx @@ -37,9 +37,9 @@ const Upload: React.FC = (props) => { const collection = collections.find((coll) => coll.slug === relationTo); - const memoizedValidate = useCallback((value, validationOptions) => { - return validate(value, validationOptions); - }, [validate]); + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, required }); + }, [validate, required]); const field = useField({ path, diff --git a/src/admin/components/forms/useField/index.tsx b/src/admin/components/forms/useField/index.tsx index 5465fb00f2..8298735624 100644 --- a/src/admin/components/forms/useField/index.tsx +++ b/src/admin/components/forms/useField/index.tsx @@ -65,7 +65,6 @@ const useField = (options: Options): FieldType => { const validateOptions = { id, - field: field?.field, user, data: getData(), siblingData: getSiblingData(path), @@ -88,7 +87,6 @@ const useField = (options: Options): FieldType => { condition, disableFormData, dispatchFields, - field, getData, getSiblingData, id, diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 6dd3af3871..deb8116e43 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -52,15 +52,14 @@ export type Labels = { }; export type ValidateOptions = { - field: F data: Partial siblingData: Partial id?: string | number user?: Partial operation?: Operation -} +} & F; -export type Validate = (value?: T, options?: ValidateOptions) => string | true | Promise; +export type Validate = (value?: T, options?: ValidateOptions) => string | true | Promise; export type OptionObject = { label: string diff --git a/src/fields/validationPromise.ts b/src/fields/validationPromise.ts index cca72be062..492f6177aa 100644 --- a/src/fields/validationPromise.ts +++ b/src/fields/validationPromise.ts @@ -42,7 +42,7 @@ const validationPromise = async ({ if (valueToValidate === undefined) valueToValidate = field.defaultValue; const result = shouldValidate ? await field.validate(valueToValidate, { - field, + ...field, data: merge(fullOriginalDoc, fullData), siblingData: merge(originalDoc, data), id, diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 8b01649199..338b771e65 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -13,38 +13,38 @@ import { const defaultMessage = 'This field is required.'; -export const number: Validate = (value: string, options) => { +export const number: Validate = (value: string, { required, min, max }) => { const parsedValue = parseInt(value, 10); - if ((value && typeof parsedValue !== 'number') || (options.field.required && Number.isNaN(parsedValue))) { + if ((value && typeof parsedValue !== 'number') || (required && Number.isNaN(parsedValue))) { return 'Please enter a valid number.'; } - if (options.field.max && parsedValue > options.field.max) { - return `"${value}" is greater than the max allowed value of ${options.field.max}.`; + if (max && parsedValue > max) { + return `"${value}" is greater than the max allowed value of ${max}.`; } - if (options.field.min && parsedValue < options.field.min) { - return `"${value}" is less than the min allowed value of ${options.field.min}.`; + if (min && parsedValue < min) { + return `"${value}" is less than the min allowed value of ${min}.`; } - if (options.field.required && typeof parsedValue !== 'number') { + if (required && typeof parsedValue !== 'number') { return defaultMessage; } return true; }; -export const text: Validate = (value: string, options) => { - if (value && options.field.maxLength && value.length > options.field.maxLength) { - return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; +export const text: Validate = (value: string, { minLength, maxLength, required }) => { + if (value && maxLength && value.length > maxLength) { + return `This value must be shorter than the max length of ${maxLength} characters.`; } - if (value && options.field.minLength && value?.length < options.field.minLength) { - return `This value must be longer than the minimum length of ${options.field.minLength} characters.`; + if (value && minLength && value?.length < minLength) { + return `This value must be longer than the minimum length of ${minLength} characters.`; } - if (options.field.required) { + if (required) { if (typeof value !== 'string' || value?.length === 0) { return defaultMessage; } @@ -53,65 +53,57 @@ export const text: Validate = (value: string, optio return true; }; -export const password: Validate = (value: string, options) => { - if (value && options.field.maxLength && value.length > options.field.maxLength) { - return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; +export const password: Validate = (value: string, { required, maxLength, minLength }) => { + if (value && maxLength && value.length > maxLength) { + return `This value must be shorter than the max length of ${maxLength} characters.`; } - if (value && options.field.minLength && value.length < options.field.minLength) { - return `This value must be longer than the minimum length of ${options.field.minLength} characters.`; + if (value && minLength && value.length < minLength) { + return `This value must be longer than the minimum length of ${minLength} characters.`; } - if (options.field.required && !value) { + if (required && !value) { return defaultMessage; } return true; }; -export const email: Validate = (value: string, options) => { +export const email: Validate = (value: string, { required }) => { if ((value && !/\S+@\S+\.\S+/.test(value)) - || (!value && options.field.required)) { + || (!value && required)) { return 'Please enter a valid email address.'; } return true; }; -export const textarea: Validate = (value: string, options) => { - if (value && options.field.maxLength && value.length > options.field.maxLength) { - return `This value must be shorter than the max length of ${options.field.maxLength} characters.`; +export const textarea: Validate = (value: string, { required, maxLength, minLength }) => { + if (value && maxLength && value.length > maxLength) { + return `This value must be shorter than the max length of ${maxLength} characters.`; } - if (value && options.field.minLength && value.length < options.field.minLength) { - return `This value must be longer than the minimum length of ${options.field.minLength} characters.`; + if (value && minLength && value.length < minLength) { + return `This value must be longer than the minimum length of ${minLength} characters.`; } - if (options.field.required && !value) { + if (required && !value) { return defaultMessage; } return true; }; -export const wysiwyg: Validate = (value: string, options) => { - if (options.field.required && !value) { +export const code: Validate = (value: string, { required }) => { + if (required && value === undefined) { return defaultMessage; } return true; }; -export const code: Validate = (value: string, options) => { - if (options.field.required && value === undefined) { - return defaultMessage; - } - - return true; -}; - -export const richText: Validate = (value, options) => { - if (options.field.required) { +export const richText: Validate = (value, { required }) => { + if (required) { const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue); if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true; return 'This field is required.'; @@ -120,16 +112,16 @@ export const richText: Validate = (value, optio return true; }; -export const checkbox: Validate = (value: boolean, options) => { +export const checkbox: Validate = (value: boolean, { required }) => { if ((value && typeof value !== 'boolean') - || (options.field.required && typeof value !== 'boolean')) { + || (required && typeof value !== 'boolean')) { return 'This field can only be equal to true or false.'; } return true; }; -export const date: Validate = (value, options) => { +export const date: Validate = (value, { required }) => { if (value && !isNaN(Date.parse(value.toString()))) { /* eslint-disable-line */ return true; } @@ -138,50 +130,50 @@ export const date: Validate = (value, options) => { return `"${value}" is not a valid date.`; } - if (options.field.required) { + if (required) { return defaultMessage; } return true; }; -export const upload: Validate = (value: string, options) => { - if (value || !options.field.required) return true; +export const upload: Validate = (value: string, { required }) => { + if (value || !required) return true; return defaultMessage; }; -export const relationship: Validate = (value, options) => { - if (value || !options.field.required) return true; +export const relationship: Validate = (value, { required }) => { + if (value || !required) return true; return defaultMessage; }; -export const array: Validate = (value, options) => { - if (options.field.minRows && value < options.field.minRows) { - return `This field requires at least ${options.field.minRows} row(s).`; +export const array: Validate = (value, { minRows, maxRows, required }) => { + if (minRows && value < minRows) { + return `This field requires at least ${minRows} row(s).`; } - if (options.field.maxRows && value > options.field.maxRows) { - return `This field requires no more than ${options.field.maxRows} row(s).`; + if (maxRows && value > maxRows) { + return `This field requires no more than ${maxRows} row(s).`; } - if (!value && options.field.required) { + if (!value && required) { return 'This field requires at least one row.'; } return true; }; -export const select: Validate = (value, options) => { - if (Array.isArray(value) && value.some((input) => !options.field.options.some((option) => (option === input || (typeof option !== 'string' && option?.value === input))))) { +export const select: Validate = (value, { options, hasMany, required }) => { + if (Array.isArray(value) && value.some((input) => !options.some((option) => (option === input || (typeof option !== 'string' && option?.value === input))))) { return 'This field has an invalid selection'; } - if (typeof value === 'string' && !options.field.options.some((option) => (option === value || (typeof option !== 'string' && option.value === value)))) { + if (typeof value === 'string' && !options.some((option) => (option === value || (typeof option !== 'string' && option.value === value)))) { return 'This field has an invalid selection'; } - if (options.field.required && ( - (typeof value === 'undefined' || value === null) || (options.field.hasMany && Array.isArray(value) && (value as [])?.length === 0)) + if (required && ( + (typeof value === 'undefined' || value === null) || (hasMany && Array.isArray(value) && (value as [])?.length === 0)) ) { return defaultMessage; } @@ -189,40 +181,40 @@ export const select: Validate = (value, options) return true; }; -export const radio: Validate = (value, options) => { +export const radio: Validate = (value, { options, required }) => { const stringValue = String(value); - if ((typeof value !== 'undefined' || !options.field.required) && (options.field.options.find((option) => String(typeof option !== 'string' && option?.value) === stringValue))) return true; + if ((typeof value !== 'undefined' || !required) && (options.find((option) => String(typeof option !== 'string' && option?.value) === stringValue))) return true; return defaultMessage; }; -export const blocks: Validate = (value, options) => { - if (options.field.minRows && value < options.field.minRows) { - return `This field requires at least ${options.field.minRows} row(s).`; +export const blocks: Validate = (value, { maxRows, minRows, required }) => { + if (minRows && value < minRows) { + return `This field requires at least ${minRows} row(s).`; } - if (options.field.maxRows && value > options.field.maxRows) { - return `This field requires no more than ${options.field.maxRows} row(s).`; + if (maxRows && value > maxRows) { + return `This field requires no more than ${maxRows} row(s).`; } - if (!value && options.field.required) { + if (!value && required) { return 'This field requires at least one row.'; } return true; }; -export const point: Validate = (value: [number | string, number | string] = ['', ''], options) => { +export const point: Validate = (value: [number | string, number | string] = ['', ''], { required }) => { const lng = parseFloat(String(value[0])); const lat = parseFloat(String(value[1])); if ( (value[0] && value[1] && typeof lng !== 'number' && typeof lat !== 'number') - || (options.field.required && (Number.isNaN(lng) || Number.isNaN(lat))) + || (required && (Number.isNaN(lng) || Number.isNaN(lat))) || (Array.isArray(value) && value.length !== 2) ) { return 'This field requires two numbers'; } - if (!options.field.required && typeof value[0] !== typeof value[1]) { + if (!required && typeof value[0] !== typeof value[1]) { return 'This field requires two numbers or both can be empty'; } @@ -236,7 +228,6 @@ export default { email, textarea, code, - wysiwyg, richText, checkbox, date, From 60f295ba9f862ef5fcb68911fbb2b1973b040056 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 29 Mar 2022 18:37:02 -0400 Subject: [PATCH 05/19] chore: passing tests --- .../forms/Form/buildStateFromSchema.ts | 2 +- .../views/collections/Edit/Auth/APIKey.tsx | 2 +- src/fields/config/sanitize.ts | 2 +- src/fields/config/types.ts | 2 +- src/fields/validations.spec.ts | 103 ++++++++---------- 5 files changed, 50 insertions(+), 61 deletions(-) diff --git a/src/admin/components/forms/Form/buildStateFromSchema.ts b/src/admin/components/forms/Form/buildStateFromSchema.ts index 6be49cb255..b23911b308 100644 --- a/src/admin/components/forms/Form/buildStateFromSchema.ts +++ b/src/admin/components/forms/Form/buildStateFromSchema.ts @@ -9,7 +9,7 @@ import { } from '../../../../fields/config/types'; import { Fields, Field, Data } from './types'; -const buildValidationPromise = async (fieldState: Field, options: ValidateOptions) => { +const buildValidationPromise = async (fieldState: Field, options: ValidateOptions) => { const validatedFieldState = fieldState; let validationResult: boolean | string = true; diff --git a/src/admin/components/views/collections/Edit/Auth/APIKey.tsx b/src/admin/components/views/collections/Edit/Auth/APIKey.tsx index 85436dab10..71c99d782c 100644 --- a/src/admin/components/views/collections/Edit/Auth/APIKey.tsx +++ b/src/admin/components/views/collections/Edit/Auth/APIKey.tsx @@ -10,7 +10,7 @@ import GenerateConfirmation from '../../../../elements/GenerateConfirmation'; const path = 'apiKey'; const baseClass = 'api-key'; -const validate = (val) => text(val, { field: { minLength: 24, maxLength: 48 }, data: {}, siblingData: {} }); +const validate = (val) => text(val, { minLength: 24, maxLength: 48, data: {}, siblingData: {} }); const APIKey: React.FC = () => { const [initialAPIKey, setInitialAPIKey] = useState(null); diff --git a/src/fields/config/sanitize.ts b/src/fields/config/sanitize.ts index 9ceae2fe52..5a4dbf2355 100644 --- a/src/fields/config/sanitize.ts +++ b/src/fields/config/sanitize.ts @@ -43,7 +43,7 @@ const sanitizeFields = (fields, validRelationships: string[]) => { if (typeof field.validate === 'undefined') { const defaultValidate = validations[field.type]; if (defaultValidate) { - field.validate = (val) => (defaultValidate(val, { field })); + field.validate = defaultValidate; } else { field.validate = () => true; } diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index deb8116e43..b25a1aa235 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -59,7 +59,7 @@ export type ValidateOptions = { operation?: Operation } & F; -export type Validate = (value?: T, options?: ValidateOptions) => string | true | Promise; +export type Validate = (value?: T, options?: ValidateOptions>) => string | true | Promise; export type OptionObject = { label: string diff --git a/src/fields/validations.spec.ts b/src/fields/validations.spec.ts index be9a9fde61..f8cde31d18 100644 --- a/src/fields/validations.spec.ts +++ b/src/fields/validations.spec.ts @@ -6,10 +6,6 @@ const maxLengthMessage = (length: number) => `This value must be shorter than th const requiredMessage = 'This field is required.'; let options: ValidateOptions = { operation: 'create', - field: { - type: 'text', - name: 'text', - }, data: undefined, siblingData: undefined, }; @@ -23,7 +19,7 @@ describe('Field Validations', () => { }); it('should show required message', () => { const val = undefined; - const result = text(val, { ...options, field: { ...options.field, required: true } }); + const result = text(val, { ...options, required: true }); expect(result).toBe(requiredMessage); }); it('should handle undefined', () => { @@ -33,22 +29,22 @@ describe('Field Validations', () => { }); it('should validate maxLength', () => { const val = 'toolong'; - const result = text(val, { ...options, field: { ...options.field, maxLength: 5 } }); + const result = text(val, { ...options, maxLength: 5 }); expect(result).toBe(maxLengthMessage(5)); }); it('should validate minLength', () => { const val = 'short'; - const result = text(val, { ...options, field: { ...options.field, minLength: 10 } }); + const result = text(val, { ...options, minLength: 10 }); expect(result).toBe(minLengthMessage(10)); }); it('should validate maxLength with no value', () => { const val = undefined; - const result = text(val, { ...options, field: { ...options.field, maxLength: 5 } }); + const result = text(val, { ...options, maxLength: 5 }); expect(result).toBe(true); }); it('should validate minLength with no value', () => { const val = undefined; - const result = text(val, { ...options, field: { ...options.field, minLength: 10 } }); + const result = text(val, { ...options, minLength: 10 }); expect(result).toBe(true); }); }); @@ -62,7 +58,7 @@ describe('Field Validations', () => { }); it('should show required message', () => { const val = undefined; - const result = textarea(val, { ...options, field: { ...options.field, required: true } }); + const result = textarea(val, { ...options, required: true }); expect(result).toBe(requiredMessage); }); @@ -73,29 +69,30 @@ describe('Field Validations', () => { }); it('should validate maxLength', () => { const val = 'toolong'; - const result = textarea(val, { ...options, field: { ...options.field, maxLength: 5 } }); + const result = textarea(val, { ...options, maxLength: 5 }); expect(result).toBe(maxLengthMessage(5)); }); it('should validate minLength', () => { const val = 'short'; - const result = textarea(val, { ...options, field: { ...options.field, minLength: 10 } }); + const result = textarea(val, { ...options, minLength: 10 }); expect(result).toBe(minLengthMessage(10)); }); it('should validate maxLength with no value', () => { const val = undefined; - const result = textarea(val, { ...options, field: { ...options.field, maxLength: 5 } }); + const result = textarea(val, { ...options, maxLength: 5 }); expect(result).toBe(true); }); it('should validate minLength with no value', () => { const val = undefined; - const result = textarea(val, { ...options, field: { ...options.field, minLength: 10 } }); + const result = textarea(val, { ...options, minLength: 10 }); expect(result).toBe(true); }); }); describe('password', () => { - options.field = { type: 'password', name: 'test' }; + options.type = 'password'; + options.name = 'test'; it('should validate', () => { const val = 'test'; const result = password(val, options); @@ -103,7 +100,7 @@ describe('Field Validations', () => { }); it('should show required message', () => { const val = undefined; - const result = password(val, { ...options, field: { ...options.field, required: true } }); + const result = password(val, { ...options, required: true }); expect(result).toBe(requiredMessage); }); it('should handle undefined', () => { @@ -113,60 +110,52 @@ describe('Field Validations', () => { }); it('should validate maxLength', () => { const val = 'toolong'; - const result = password(val, { ...options, field: { ...options.field, maxLength: 5 } }); + const result = password(val, { ...options, maxLength: 5 }); expect(result).toBe(maxLengthMessage(5)); }); it('should validate minLength', () => { const val = 'short'; - const result = password(val, { ...options, field: { ...options.field, minLength: 10 } }); + const result = password(val, { ...options, minLength: 10 }); expect(result).toBe(minLengthMessage(10)); }); it('should validate maxLength with no value', () => { const val = undefined; - const result = password(val, { ...options, field: { ...options.field, maxLength: 5 } }); + const result = password(val, { ...options, maxLength: 5 }); expect(result).toBe(true); }); it('should validate minLength with no value', () => { const val = undefined; - const result = password(val, { ...options, field: { ...options.field, minLength: 10 } }); + const result = password(val, { ...options, minLength: 10 }); expect(result).toBe(true); }); }); describe('select', () => { - options.field = { - type: 'select', - options: ['one', 'two', 'three'], - }; + options.type = 'select'; + options.options = ['one', 'two', 'three']; const optionsRequired = { ...options, - field: { - ...options.field, - required: true, - options: [{ - value: 'one', - label: 'One', - }, { - value: 'two', - label: 'two', - }, { - value: 'three', - label: 'three', - }], - }, + required: true, + options: [{ + value: 'one', + label: 'One', + }, { + value: 'two', + label: 'two', + }, { + value: 'three', + label: 'three', + }], }; const optionsWithEmptyString = { ...options, - field: { - ...options.field, - options: [{ - value: '', - label: 'None', - }, { - value: 'option', - label: 'Option', - }], - }, + options: [{ + value: '', + label: 'None', + }, { + value: 'option', + label: 'Option', + }], }; it('should allow valid input', () => { const val = 'one'; @@ -204,35 +193,35 @@ describe('Field Validations', () => { }); it('should prevent undefined input with required and hasMany', () => { let val; - options.field.hasMany = true; + options.hasMany = true; const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); it('should prevent empty array input with required and hasMany', () => { - optionsRequired.field.hasMany = true; + optionsRequired.hasMany = true; const result = select([], optionsRequired); expect(result).not.toStrictEqual(true); }); it('should prevent empty string array input with required and hasMany', () => { - options.field.hasMany = true; + options.hasMany = true; const result = select([''], optionsRequired); expect(result).not.toStrictEqual(true); }); it('should prevent null input with required and hasMany', () => { const val = null; - options.field.hasMany = true; + options.hasMany = true; const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); it('should allow valid input with option objects', () => { const val = 'one'; - options.field.hasMany = false; + options.hasMany = false; const result = select(val, optionsRequired); expect(result).toStrictEqual(true); }); it('should prevent invalid input with option objects', () => { const val = 'bad'; - options.field.hasMany = false; + options.hasMany = false; const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); @@ -243,7 +232,7 @@ describe('Field Validations', () => { }); it('should allow empty string input with option object and required', () => { const val = ''; - optionsWithEmptyString.field.required = true; + optionsWithEmptyString.required = true; const result = select(val, optionsWithEmptyString); expect(result).toStrictEqual(true); }); @@ -259,13 +248,13 @@ describe('Field Validations', () => { }); it('should allow valid input with hasMany option objects', () => { const val = ['one', 'three']; - optionsRequired.field.hasMany = true; + optionsRequired.hasMany = true; const result = select(val, optionsRequired); expect(result).toStrictEqual(true); }); it('should prevent invalid input with hasMany option objects', () => { const val = ['three', 'bad']; - optionsRequired.field.hasMany = true; + optionsRequired.hasMany = true; const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); From 29405bbc0e3a5c3c1f3dadb2386a68e1fe159c42 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 30 Mar 2022 12:12:59 -0400 Subject: [PATCH 06/19] fix: default point validation allows not required and some edge cases --- src/fields/validations.spec.ts | 57 +++++++++++++++++++++++++++++++++- src/fields/validations.ts | 10 +++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/fields/validations.spec.ts b/src/fields/validations.spec.ts index f8cde31d18..be708828b3 100644 --- a/src/fields/validations.spec.ts +++ b/src/fields/validations.spec.ts @@ -1,4 +1,4 @@ -import { text, textarea, password, select } from './validations'; +import { text, textarea, password, select, point } from './validations'; import { ValidateOptions } from './config/types'; const minLengthMessage = (length: number) => `This value must be longer than the minimum length of ${length} characters.`; @@ -130,6 +130,61 @@ describe('Field Validations', () => { }); }); + describe('point', () => { + options.type = 'point'; + options.name = 'point'; + it('should validate numbers', () => { + const val = ['0.1', '0.2']; + const result = point(val, options); + expect(result).toBe(true); + }); + it('should validate strings that could be numbers', () => { + const val = ['0.1', '0.2']; + const result = point(val, options); + expect(result).toBe(true); + }); + it('should show required message when undefined', () => { + const val = undefined; + const result = point(val, { ...options, required: true }); + expect(result).not.toBe(true); + }); + it('should show required message when array', () => { + const val = []; + const result = point(val, { ...options, required: true }); + expect(result).not.toBe(true); + }); + it('should show required message when array of undefined', () => { + const val = [undefined, undefined]; + const result = point(val, { ...options, required: true }); + expect(result).not.toBe(true); + }); + it('should handle undefined not required', () => { + const val = undefined; + const result = password(val, options); + expect(result).toBe(true); + }); + it('should handle empty array not required', () => { + const val = []; + const result = point(val, options); + expect(result).toBe(true); + }); + it('should handle array of undefined not required', () => { + const val = [undefined, undefined]; + const result = point(val, options); + expect(result).toBe(true); + }); + it('should prevent text input', () => { + const val = ['bad', 'input']; + const result = point(val, options); + expect(result).not.toBe(true); + }); + it('should prevent missing value', () => { + const val = [0.1]; + const result = point(val, options); + expect(result).not.toBe(true); + }); + }); + describe('select', () => { options.type = 'select'; options.options = ['one', 'two', 'three']; diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 338b771e65..78475520da 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -206,16 +206,16 @@ export const blocks: Validate = (value, { maxRows, export const point: Validate = (value: [number | string, number | string] = ['', ''], { required }) => { const lng = parseFloat(String(value[0])); const lat = parseFloat(String(value[1])); - if ( + if (required && ( (value[0] && value[1] && typeof lng !== 'number' && typeof lat !== 'number') - || (required && (Number.isNaN(lng) || Number.isNaN(lat))) + || (Number.isNaN(lng) || Number.isNaN(lat)) || (Array.isArray(value) && value.length !== 2) - ) { + )) { return 'This field requires two numbers'; } - if (!required && typeof value[0] !== typeof value[1]) { - return 'This field requires two numbers or both can be empty'; + if ((value[1] && Number.isNaN(lng)) || (value[0] && Number.isNaN(lat))) { + return 'This field has an invalid input'; } return true; From 175642c07b405c53bac39e2e4e4f23743406ea0b Mon Sep 17 00:00:00 2001 From: James Date: Wed, 30 Mar 2022 15:00:19 -0400 Subject: [PATCH 07/19] chore: ensures default validate functions receive field config --- src/fields/config/sanitize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fields/config/sanitize.ts b/src/fields/config/sanitize.ts index 5a4dbf2355..47a4300e9d 100644 --- a/src/fields/config/sanitize.ts +++ b/src/fields/config/sanitize.ts @@ -43,7 +43,7 @@ const sanitizeFields = (fields, validRelationships: string[]) => { if (typeof field.validate === 'undefined') { const defaultValidate = validations[field.type]; if (defaultValidate) { - field.validate = defaultValidate; + field.validate = (val, options) => defaultValidate(val, { ...field, ...options }); } else { field.validate = () => true; } From dade9606155e7259144e283b117260bd42428608 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 30 Mar 2022 15:32:58 -0400 Subject: [PATCH 08/19] chore: demo validation with sibling data --- demo/collections/Validations.ts | 18 ++++---- .../forms/Form/buildStateFromSchema.ts | 1 + src/collections/tests/pointField.spec.js | 44 +++++++++++++------ 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/demo/collections/Validations.ts b/demo/collections/Validations.ts index 8a5b98fadf..bfbae568a2 100644 --- a/demo/collections/Validations.ts +++ b/demo/collections/Validations.ts @@ -11,18 +11,16 @@ const Validations: CollectionConfig = { }, fields: [ { - name: 'textWithOptions', + name: 'textWithSiblingValidation', type: 'text', - label: 'Text with Options', + label: 'Text with siblingData Validation', required: true, - // validate: (value: string, args) => { - // console.log(args); - // console.log(value); - // if (args?.data?.text !== 'test') { - // return 'The next field should be test'; - // } - // return true; - // }, + validate: (value: string, { data }) => { + if (data?.text !== 'test') { + return 'The next field should be test'; + } + return true; + }, }, { name: 'text', diff --git a/src/admin/components/forms/Form/buildStateFromSchema.ts b/src/admin/components/forms/Form/buildStateFromSchema.ts index b23911b308..9f7fbe7ec5 100644 --- a/src/admin/components/forms/Form/buildStateFromSchema.ts +++ b/src/admin/components/forms/Form/buildStateFromSchema.ts @@ -60,6 +60,7 @@ const buildStateFromSchema = async (args: Args): Promise => { }; validationPromises.push(buildValidationPromise(fieldState, { + ...field, data: fullData, user, siblingData, diff --git a/src/collections/tests/pointField.spec.js b/src/collections/tests/pointField.spec.js index 95674ca97c..46310f4402 100644 --- a/src/collections/tests/pointField.spec.js +++ b/src/collections/tests/pointField.spec.js @@ -34,7 +34,7 @@ describe('GeoJSON', () => { }); describe('Point Field - REST', () => { - const location = [10, 20]; + let location = [10, 20]; const localizedPoint = [30, 40]; const group = { point: [15, 25] }; let doc; @@ -115,8 +115,24 @@ describe('GeoJSON', () => { expect(hitDocs).toHaveLength(1); expect(missDocs).toHaveLength(0); }); + + it('should save with non-required point', async () => { + location = undefined; + + const create = await fetch(`${serverURL}/api/geolocation`, { + body: JSON.stringify({ location }), + headers, + method: 'post', + }); + + const { doc } = await create.json(); + + expect(doc.id).toBeDefined(); + expect(doc.location).toStrictEqual(location); + }); }); + describe('Point Field - GraphQL', () => { const url = `${serverURL}${routes.api}${routes.graphQL}`; let client = null; @@ -130,20 +146,20 @@ describe('GeoJSON', () => { // language=graphQL const query = `mutation { - createGeolocation ( - data: { - location: [${location[0]}, ${location[1]}], - localizedPoint: [${localizedPoint[0]}, ${localizedPoint[1]}], - group: { - point: [${group.point[0]}, ${group.point[1]}] - } - } - ) { - id - location - localizedPoint + createGeolocation ( + data: { + location: [${location[0]}, ${location[1]}], + localizedPoint: [${localizedPoint[0]}, ${localizedPoint[1]}], + group: { + point: [${group.point[0]}, ${group.point[1]}] + } } - }`; + ) { + id + location + localizedPoint + } + }`; const response = await client.request(query); From 42baf2e27e2ac54ecd149fc65d2a7b07572ec8a6 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 30 Mar 2022 17:04:26 -0400 Subject: [PATCH 09/19] chore: optimize getDatabByPath and getSiblingData --- .../forms/Form/buildStateFromSchema.ts | 10 +++++----- .../components/forms/Form/getDataByPath.ts | 18 ++++++------------ .../components/forms/Form/getSiblingData.ts | 19 +++++++------------ .../forms/Form/reduceFieldsToValues.ts | 5 ++--- 4 files changed, 20 insertions(+), 32 deletions(-) diff --git a/src/admin/components/forms/Form/buildStateFromSchema.ts b/src/admin/components/forms/Form/buildStateFromSchema.ts index 9f7fbe7ec5..672a8cbf52 100644 --- a/src/admin/components/forms/Form/buildStateFromSchema.ts +++ b/src/admin/components/forms/Form/buildStateFromSchema.ts @@ -47,7 +47,7 @@ const buildStateFromSchema = async (args: Args): Promise => { if (fieldSchema) { const validationPromises = []; - const structureFieldState = (field, passesCondition, data = {}, siblingData = {}) => { + const structureFieldState = (field, passesCondition, data = {}) => { const value = typeof data?.[field.name] !== 'undefined' ? data[field.name] : field.defaultValue; const fieldState = { @@ -61,9 +61,9 @@ const buildStateFromSchema = async (args: Args): Promise => { validationPromises.push(buildValidationPromise(fieldState, { ...field, - data: fullData, + fullData, user, - siblingData, + siblingData: data, id, operation, })); @@ -154,7 +154,7 @@ const buildStateFromSchema = async (args: Args): Promise => { return { ...state, - [`${path}${field.name}`]: structureFieldState(field, passesCondition, fullData, data), + [`${path}${field.name}`]: structureFieldState(field, passesCondition, data), }; } @@ -171,7 +171,7 @@ const buildStateFromSchema = async (args: Args): Promise => { // Handle normal fields return { ...state, - [`${path}${namedField.name}`]: structureFieldState(field, passesCondition, fullData, data), + [`${path}${namedField.name}`]: structureFieldState(field, passesCondition, data), }; } diff --git a/src/admin/components/forms/Form/getDataByPath.ts b/src/admin/components/forms/Form/getDataByPath.ts index 52ee905f4b..297140b5d3 100644 --- a/src/admin/components/forms/Form/getDataByPath.ts +++ b/src/admin/components/forms/Form/getDataByPath.ts @@ -1,24 +1,18 @@ import { unflatten } from 'flatley'; -import reduceFieldsToValues from './reduceFieldsToValues'; import { Fields } from './types'; const getDataByPath = (fields: Fields, path: string): T => { const pathPrefixToRemove = path.substring(0, path.lastIndexOf('.') + 1); const name = path.split('.').pop(); - const data = Object.keys(fields).reduce((matchedData, key) => { - if (key.indexOf(`${path}.`) === 0 || key === path) { - return { - ...matchedData, - [key.replace(pathPrefixToRemove, '')]: fields[key], - }; + const data = {}; + Object.keys(fields).forEach((key) => { + if (!fields[key].disableFormData && (key.indexOf(`${path}.`) === 0 || key === path)) { + data[key.replace(pathPrefixToRemove, '')] = fields[key].value; } + }); - return matchedData; - }, {}); - - const values = reduceFieldsToValues(data, true); - const unflattenedData = unflatten(values); + const unflattenedData = unflatten(data); return unflattenedData?.[name]; }; diff --git a/src/admin/components/forms/Form/getSiblingData.ts b/src/admin/components/forms/Form/getSiblingData.ts index 0952729379..fe866fe691 100644 --- a/src/admin/components/forms/Form/getSiblingData.ts +++ b/src/admin/components/forms/Form/getSiblingData.ts @@ -1,26 +1,21 @@ -import reduceFieldsToValues from './reduceFieldsToValues'; +import { unflatten } from 'flatley'; import { Fields, Data } from './types'; const getSiblingData = (fields: Fields, path: string): Data => { - let siblingFields = fields; + const siblingFields = {}; // If this field is nested // We can provide a list of sibling fields if (path.indexOf('.') > 0) { const parentFieldPath = path.substring(0, path.lastIndexOf('.') + 1); - siblingFields = Object.keys(fields).reduce((siblings, fieldKey) => { - if (fieldKey.indexOf(parentFieldPath) === 0) { - return { - ...siblings, - [fieldKey.replace(parentFieldPath, '')]: fields[fieldKey], - }; + Object.keys(fields).forEach((fieldKey) => { + if (!fields[fieldKey].disableFormData && fieldKey.indexOf(parentFieldPath) === 0) { + siblingFields[fieldKey.replace(parentFieldPath, '')] = fields[fieldKey].value; } - - return siblings; - }, {}); + }); } - return reduceFieldsToValues(siblingFields, true); + return unflatten(siblingFields, { safe: true }); }; export default getSiblingData; diff --git a/src/admin/components/forms/Form/reduceFieldsToValues.ts b/src/admin/components/forms/Form/reduceFieldsToValues.ts index 548463df54..c57f7d0b2e 100644 --- a/src/admin/components/forms/Form/reduceFieldsToValues.ts +++ b/src/admin/components/forms/Form/reduceFieldsToValues.ts @@ -1,5 +1,5 @@ import { unflatten as flatleyUnflatten } from 'flatley'; -import { Fields, Data } from './types'; +import { Data, Fields } from './types'; const reduceFieldsToValues = (fields: Fields, unflatten?: boolean): Data => { const data = {}; @@ -11,8 +11,7 @@ const reduceFieldsToValues = (fields: Fields, unflatten?: boolean): Data => { }); if (unflatten) { - const unflattened = flatleyUnflatten(data, { safe: true }); - return unflattened; + return flatleyUnflatten(data, { safe: true }); } return data; From c7eb92917635043d5b9494670c583a33ac03b6c5 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Wed, 30 Mar 2022 18:06:05 -0400 Subject: [PATCH 10/19] chore: adds tests to validate extra arg options --- demo/collections/Validations.ts | 22 +++++- src/collections/tests/validations.spec.js | 81 +++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/collections/tests/validations.spec.js diff --git a/demo/collections/Validations.ts b/demo/collections/Validations.ts index bfbae568a2..9d9f88f4c8 100644 --- a/demo/collections/Validations.ts +++ b/demo/collections/Validations.ts @@ -11,14 +11,30 @@ const Validations: CollectionConfig = { }, fields: [ { - name: 'textWithSiblingValidation', + name: 'validationOptions', type: 'text', label: 'Text with siblingData Validation', required: true, - validate: (value: string, { data }) => { + validate: (value: string, { data, siblingData, id, operation, user }) => { + if (typeof value === 'undefined') { + return 'Validation is missing value'; + } if (data?.text !== 'test') { return 'The next field should be test'; } + if (siblingData?.text !== 'test') { + return 'The next field should be test'; + } + if (!user) { + return 'ValidationOptions is missing "user"'; + } + if (typeof operation === 'undefined') { + return 'ValidationOptions is missing "operation"'; + } + if (operation === 'update' && typeof id === 'undefined') { + return 'ValidationOptions is missing "id"'; + } + return true; }, }, @@ -108,7 +124,7 @@ const Validations: CollectionConfig = { label: 'Number should be less than 20', required: true, validate: (value) => { - const result = value < 30; + const result = value < 20; if (!result) { return 'This value of this field needs to be less than 20.'; diff --git a/src/collections/tests/validations.spec.js b/src/collections/tests/validations.spec.js new file mode 100644 index 0000000000..1367e75d0c --- /dev/null +++ b/src/collections/tests/validations.spec.js @@ -0,0 +1,81 @@ +import getConfig from '../../config/load'; +import { email, password } from '../../mongoose/testCredentials'; + +require('isomorphic-fetch'); + +const { serverURL: url } = getConfig(); + +let token = null; +let headers = null; + +describe('Validations - REST', () => { + beforeAll(async (done) => { + const response = await fetch(`${url}/api/admins/login`, { + body: JSON.stringify({ + email, + password, + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'post', + }); + + const data = await response.json(); + + ({ token } = data); + headers = { + Authorization: `JWT ${token}`, + 'Content-Type': 'application/json', + }; + + done(); + }); + + describe('Validations', () => { + let validation; + + beforeAll(async (done) => { + const result = await fetch(`${url}/api/validations`, { + body: JSON.stringify({ + validationOptions: 'ok', + text: 'test', + lessThan10: 9, + greaterThan10LessThan50: 20, + atLeast3Rows: [ + { greaterThan30: 40 }, + { greaterThan30: 50 }, + { greaterThan30: 60 }, + ], + array: [ + { lessThan20: 10 }, + ], + }), + headers, + method: 'post', + }); + + const data = await result.json(); + validation = data.doc; + done(); + }); + + it('should create with custom validation', async () => { + expect(validation.id).toBeDefined(); + }); + it('should update with custom validation', async () => { + const result = await fetch(`${url}/api/validations/${validation.id}`, { + body: JSON.stringify({ + validationOptions: 'update', + }), + headers, + method: 'put', + }); + + const data = await result.json(); + validation = data.doc; + + expect(validation.validationOptions).toStrictEqual('update'); + }); + }); +}); From 9ea2777555887de426b33a0dcd7411fd9d340eec Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Thu, 31 Mar 2022 01:00:52 -0400 Subject: [PATCH 11/19] docs: add validation arguments --- docs/fields/overview.mdx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 38b7a7737e..379f80a8d2 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -62,7 +62,19 @@ In addition to being able to define access control on a document-level, you can ### Validation -You can provide your own validation function for all field types. It will be used on both the frontend and the backend, so it should not rely on any Node-specific packages. The validation function can be either synchronous or asynchronous and accepts the field's value and expects to return either `true` or a string error message to display in both API responses and within the Admin panel. +Field validation is enforced automatically based on the field type and other properties such as `required` or `min` and `max` value constraints on certain field types. This default behavior can be replaced by providing your own validate function for any field. It will be used on both the frontend and the backend, so it should not rely on any Node-specific packages. The validation function can be either synchronous or asynchronous and expects to return either `true` or a string error message to display in both API responses and within the Admin panel. + +There are two arguments available to custom validation functions. +1. The value which is currently assigned to the field +2. An optional object with dynamic properties for more complex validation having the following: + +| Property | Description | +| ------------- | -------------| +| `data` | An object of the full collection or global document | +| `siblingData` | An object of the document data limited to fields within the same parent to the field | +| `operation` | Will be "create" or "update" depending on the UI action or API call | +| `id` | The value of the collection `id`, will be `undefined` on create request | +| `user` | The currently authenticated user object | Example: ```js @@ -72,7 +84,11 @@ Example: { name: 'customerNumber', type: 'text', - validate: async (val) => { + validate: async (val, { operation }) => { + if (operation !== 'create') { + // skip validation on update + return true; + } const response = await fetch(`https://your-api.com/customers/${val}`); if (response.ok) { return true; @@ -198,7 +214,7 @@ A description can be configured three ways. - As a function that accepts an object containing the field's value, which returns a string - As a React component that accepts value as a prop -As shown above, you can simply provide a string that will show by the field, but there are use cases where you may want to create some dynamic feedback. By using a function or a component for the `description` property you can provide rich feedback in realtime the user interacts with the form. +As shown above, you can simply provide a string that will show by the field, but there are use cases where you may want to create some dynamic feedback. By using a function or a component for the `description` property you can provide realtime feedback as the user interacts with the form. **Function Example:** From 85596bbba6a5dbc15a19e15087fdea4d0176d0bd Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 1 Apr 2022 10:58:08 -0400 Subject: [PATCH 12/19] chore: export default field validation --- demo/collections/RelationshipA.ts | 4 ---- docs/fields/overview.mdx | 20 ++++++++++++++++++++ fields/validations.js | 1 + src/fields/validations.ts | 14 ++++++++++---- 4 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 fields/validations.js diff --git a/demo/collections/RelationshipA.ts b/demo/collections/RelationshipA.ts index 0e6f4f7ea3..ba78729223 100644 --- a/demo/collections/RelationshipA.ts +++ b/demo/collections/RelationshipA.ts @@ -1,5 +1,4 @@ import { CollectionConfig } from '../../src/collections/config/types'; -import { Where } from '../../src/types'; const RelationshipA: CollectionConfig = { slug: 'relationship-a', @@ -24,9 +23,6 @@ const RelationshipA: CollectionConfig = { relationTo: 'localized-posts', hasMany: true, localized: true, - // filterOptions: (args: Args): Where => ({ - // - // }), }, { name: 'postLocalizedMultiple', diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 379f80a8d2..0a744e43d2 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -101,6 +101,26 @@ Example: } ``` +When supplying a field `validate` function, Payload will use yours in place of the default. To make use of the default field validation in your custom logic you can import, call and return the result as needed. + +For example: +```js +import { text } from 'payload/fields/validations'; +const field = + { + name: 'notBad', + type: 'text', + validate: (val, args) => { + if (value === 'bad') { + return 'This cannot be "bad"'; + } + // highlight-start + return text(val, args); + // highlight-end + }, +} +``` + ### Customizable ID Collections ID fields are generated automatically by default. An explicit `id` field can be declared in the `fields` array to override this behavior. diff --git a/fields/validations.js b/fields/validations.js new file mode 100644 index 0000000000..b24ba908b1 --- /dev/null +++ b/fields/validations.js @@ -0,0 +1 @@ +export * from '../dist/fields/validations'; diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 78475520da..a8d31688f2 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -1,13 +1,19 @@ import defaultRichTextValue from './richText/defaultValue'; import { - ArrayField, BlockField, + ArrayField, + BlockField, CheckboxField, CodeField, DateField, EmailField, - NumberField, PointField, RadioField, RelationshipField, - RichTextField, SelectField, + NumberField, + PointField, + RadioField, + RelationshipField, + RichTextField, + SelectField, TextareaField, - TextField, UploadField, + TextField, + UploadField, Validate, } from './config/types'; From 845b0b37090dea9839266ca4fb3a301af4c2992c Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Fri, 1 Apr 2022 11:34:13 -0400 Subject: [PATCH 13/19] chore: top level getSiblingData --- .../components/forms/Form/getSiblingData.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/admin/components/forms/Form/getSiblingData.ts b/src/admin/components/forms/Form/getSiblingData.ts index fe866fe691..849b2a5724 100644 --- a/src/admin/components/forms/Form/getSiblingData.ts +++ b/src/admin/components/forms/Form/getSiblingData.ts @@ -1,19 +1,21 @@ import { unflatten } from 'flatley'; import { Fields, Data } from './types'; +import reduceFieldsToValues from './reduceFieldsToValues'; const getSiblingData = (fields: Fields, path: string): Data => { + if (path.indexOf('.') === -1) { + return reduceFieldsToValues(fields, true); + } const siblingFields = {}; // If this field is nested // We can provide a list of sibling fields - if (path.indexOf('.') > 0) { - const parentFieldPath = path.substring(0, path.lastIndexOf('.') + 1); - Object.keys(fields).forEach((fieldKey) => { - if (!fields[fieldKey].disableFormData && fieldKey.indexOf(parentFieldPath) === 0) { - siblingFields[fieldKey.replace(parentFieldPath, '')] = fields[fieldKey].value; - } - }); - } + const parentFieldPath = path.substring(0, path.lastIndexOf('.') + 1); + Object.keys(fields).forEach((fieldKey) => { + if (!fields[fieldKey].disableFormData && fieldKey.indexOf(parentFieldPath) === 0) { + siblingFields[fieldKey.replace(parentFieldPath, '')] = fields[fieldKey].value; + } + }); return unflatten(siblingFields, { safe: true }); }; From ab432a43dc568da0b7e65e275aed335d729600fa Mon Sep 17 00:00:00 2001 From: James Date: Mon, 4 Apr 2022 13:54:02 -0400 Subject: [PATCH 14/19] fix: #495, avoids appending version to id queries --- src/versions/drafts/appendVersionToQueryKey.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/versions/drafts/appendVersionToQueryKey.ts b/src/versions/drafts/appendVersionToQueryKey.ts index 9d6c6e0f71..d5d4dbda1d 100644 --- a/src/versions/drafts/appendVersionToQueryKey.ts +++ b/src/versions/drafts/appendVersionToQueryKey.ts @@ -9,9 +9,13 @@ export const appendVersionToQueryKey = (query: Where): Where => { }; } - return { - ...res, - [`version.${key}`]: val, - }; + if (key !== 'id') { + return { + ...res, + [`version.${key}`]: val, + }; + } + + return res; }, {}); }; From 1d4d30ce8f56d28b4d8aea8dc64faa60c8c54b99 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 4 Apr 2022 17:07:24 -0400 Subject: [PATCH 15/19] chore: revises when field validation is run --- src/admin/components/forms/Form/index.tsx | 9 +- src/admin/components/forms/useField/index.tsx | 83 +++++++------------ 2 files changed, 37 insertions(+), 55 deletions(-) diff --git a/src/admin/components/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index 9056ba1994..6988a69305 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -2,6 +2,7 @@ import React, { useReducer, useEffect, useRef, useState, useCallback, } from 'react'; +import isDeepEqual from 'deep-equal'; import { serialize } from 'object-to-formdata'; import { useHistory } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -106,7 +107,9 @@ const Form: React.FC = (props) => { await Promise.all(validationPromises); - dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState }); + if (!isDeepEqual(contextRef.current.fields, validatedFieldState)) { + dispatchFields({ type: 'REPLACE_STATE', state: validatedFieldState }); + } return isValid; }, [contextRef, id, user, operation]); @@ -365,6 +368,10 @@ const Form: React.FC = (props) => { refreshCookie(); }, 15000, [fields]); + useThrottledEffect(() => { + validateForm(); + }, 1000, [validateForm, fields]); + useEffect(() => { contextRef.current = { ...contextRef.current }; // triggers rerender of all components that subscribe to form setModified(false); diff --git a/src/admin/components/forms/useField/index.tsx b/src/admin/components/forms/useField/index.tsx index 8298735624..d8706541ad 100644 --- a/src/admin/components/forms/useField/index.tsx +++ b/src/admin/components/forms/useField/index.tsx @@ -48,56 +48,6 @@ const useField = (options: Options): FieldType => { const valid = (field && typeof field.valid === 'boolean') ? field.valid : true; const showError = valid === false && submitted; - // Method to send update field values from field component(s) - // Should only be used internally - const sendField = useCallback(async (valueToSend) => { - const fieldToDispatch = { - path, - disableFormData, - ignoreWhileFlattening, - initialValue, - validate, - condition, - value: valueToSend, - valid: false, - errorMessage: undefined, - }; - - const validateOptions = { - id, - user, - data: getData(), - siblingData: getSiblingData(path), - operation, - }; - - const validationResult = typeof validate === 'function' ? await validate(valueToSend, validateOptions) : true; - - if (typeof validationResult === 'string') { - fieldToDispatch.errorMessage = validationResult; - fieldToDispatch.valid = false; - } else { - fieldToDispatch.valid = validationResult; - } - - if (typeof dispatchFields === 'function') { - dispatchFields(fieldToDispatch); - } - }, [ - condition, - disableFormData, - dispatchFields, - getData, - getSiblingData, - id, - ignoreWhileFlattening, - initialValue, - operation, - path, - user, - validate, - ]); - // Method to return from `useField`, used to // update internal field values from field component(s) // as fast as they arrive. NOTE - this method is NOT debounced @@ -127,13 +77,38 @@ const useField = (options: Options): FieldType => { const valueToSend = enableDebouncedValue ? debouncedValue : internalValue; useEffect(() => { - if (field?.value !== valueToSend && valueToSend !== undefined) { - sendField(valueToSend); - } + const sendField = async () => { + if (field?.value !== valueToSend && valueToSend !== undefined) { + if (typeof dispatchFields === 'function') { + dispatchFields({ + ...field, + path, + disableFormData, + ignoreWhileFlattening, + initialValue, + validate, + condition, + value: valueToSend, + }); + } + } + }; + + sendField(); }, [ + condition, + disableFormData, + dispatchFields, + getData, + getSiblingData, + id, + ignoreWhileFlattening, + initialValue, + operation, path, + user, + validate, valueToSend, - sendField, field, ]); From 485991bd48c3512acca8dd94b3ab6c160bf1f153 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Mon, 4 Apr 2022 19:41:26 -0400 Subject: [PATCH 16/19] feat: filter relationship options in admin ui using filterOptions --- demo/collections/RelationshipA.ts | 10 +++ demo/collections/RelationshipB.ts | 7 ++ docs/fields/relationship.mdx | 40 +++++++++++ .../forms/field-types/Relationship/index.tsx | 67 ++++++++++++++++--- src/fields/config/schema.ts | 4 ++ src/fields/config/types.ts | 11 ++- src/fields/validations.ts | 42 ++++++++++-- src/webpack/getBaseConfig.ts | 1 + 8 files changed, 168 insertions(+), 14 deletions(-) diff --git a/demo/collections/RelationshipA.ts b/demo/collections/RelationshipA.ts index ba78729223..9dab529476 100644 --- a/demo/collections/RelationshipA.ts +++ b/demo/collections/RelationshipA.ts @@ -56,6 +56,16 @@ const RelationshipA: CollectionConfig = { hasMany: true, localized: true, }, + { + name: 'filterRelationship', + type: 'relationship', + relationTo: 'relationship-b', + filterOptions: { + disableRelation: { + not_equals: true, + }, + }, + }, { name: 'demoHiddenField', type: 'text', diff --git a/demo/collections/RelationshipB.ts b/demo/collections/RelationshipB.ts index 969565cf13..8fd50cece7 100644 --- a/demo/collections/RelationshipB.ts +++ b/demo/collections/RelationshipB.ts @@ -17,6 +17,13 @@ const RelationshipB: CollectionConfig = { name: 'title', type: 'text', }, + { + name: 'disableRelation', // used on RelationshipA.filterRelationship field + type: 'checkbox', + admin: { + position: 'sidebar', + }, + }, { name: 'post', label: 'Post', diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 9906f22cc6..9f8fa40267 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -22,6 +22,7 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma | ---------------- | ----------- | | **`name`** * | To be used as the property name when stored and retrieved from the database. | | **`relationTo`** * | Provide one or many collection `slug`s to be able to assign relationships to. | +| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). | | **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. | | **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | | **`label`** | Used as a field label in the Admin panel and to name the generated GraphQL type. | @@ -44,6 +45,45 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma The Depth parameter can be used to automatically populate related documents that are returned by the API. +### Filtering relationship options + +Options can be dynamically limited by supply a query that is used for validating input and querying relationships in the UI. The `filterOptions` property can be a `Where` query or a function that returns one. When using a function, it will be called with an argument object with the following properties: + +| Property | Description | +| ------------- | -------------| +| `relationTo` | The `slug` of the collection of the items relation | +| `data` | An object of the full collection or global document | +| `siblingData` | An object of the document data limited to fields within the same parent to the field | +| `id` | The value of the collection `id`, will be `undefined` on create request | +| `user` | The currently authenticated user object | + +```js + const relationshipField = { + name: 'purchase', + type: 'relationship', + relationTo: ['products', 'services'], + filterOptions: ({relationTo, siblingData, }) => { + // returns a Where query dynamically by the type of relationship + if (relationTo === 'products') { + return { + 'product.stock': { is_greater_than: siblingData.quantity } + } + } + if (relationTo === 'services') { + return { + 'services.isAvailable': { equals: true } + } + } + }, + }; +``` + +You can learn more about writing queries [here](/docs/queries/overview). + + + When a relationship field has both `filterOptions` and `validate` the server side validation will not enforce `filterOptions` unless you call the relationship field validate imported from `payload/fields/validations` in the validate function. + + ### How the data is saved Given the variety of options possible within the `relationship` field type, the shape of the data needed for creating and updating these fields can vary. The following sections will describe the variety of data shapes that can arise from this field. diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index bb4d36a946..d4ab3355ff 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useState, useReducer, } from 'react'; -import { useConfig } from '@payloadcms/config-provider'; +import equal from 'deep-equal'; +import { useAuth, useConfig } from '@payloadcms/config-provider'; import qs from 'qs'; import withCondition from '../../withCondition'; import ReactSelect from '../../../elements/ReactSelect'; @@ -13,18 +14,31 @@ import FieldDescription from '../../FieldDescription'; import { relationship } from '../../../../../fields/validations'; import { Where } from '../../../../../types'; import { PaginatedDocs } from '../../../../../mongoose/types'; -import { useFormProcessing } from '../../Form/context'; +import { useFormProcessing, useWatchForm } from '../../Form/context'; import optionsReducer from './optionsReducer'; import { Props, Option, ValueWithRelation, GetResults } from './types'; import { createRelationMap } from './createRelationMap'; import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback'; import './index.scss'; +import { useDocumentInfo } from '../../../utilities/DocumentInfo'; +import { filterOptionsProps } from '../../../../../fields/config/types'; const maxResultsPerRequest = 10; const baseClass = 'relationship'; +const getFilterOptionsQuery = (filterOptions, options: filterOptionsProps): Where => { + let query = {}; + if (typeof filterOptions === 'object') { + query = filterOptions; + } + if (typeof filterOptions === 'function') { + query = filterOptions(options); + } + return query; +}; + const Relationship: React.FC = (props) => { const { relationTo, @@ -34,6 +48,7 @@ const Relationship: React.FC = (props) => { required, label, hasMany, + filterOptions, admin: { readOnly, style, @@ -52,6 +67,10 @@ const Relationship: React.FC = (props) => { collections, } = useConfig(); + const { id } = useDocumentInfo(); + const { user } = useAuth(); + const { getData, getSiblingData } = useWatchForm(); + const formProcessing = useFormProcessing(); const hasMultipleRelations = Array.isArray(relationTo); @@ -59,6 +78,7 @@ const Relationship: React.FC = (props) => { const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1); const [lastLoadedPage, setLastLoadedPage] = useState(1); const [errorLoading, setErrorLoading] = useState(''); + const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>({}); const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false); const [search, setSearch] = useState(''); @@ -107,9 +127,13 @@ const Relationship: React.FC = (props) => { where: Where } = { where: { - id: { - not_in: relationMap[relation], - }, + and: [ + { + id: { + not_in: relationMap[relation], + }, + }, + ], }, limit: maxResultsPerRequest, page: lastLoadedPageToUse, @@ -118,9 +142,15 @@ const Relationship: React.FC = (props) => { }; if (searchArg) { - query.where[fieldToSearch] = { - like: searchArg, - }; + query.where.and.push({ + [fieldToSearch]: { + like: searchArg, + }, + }); + } + + if (optionFilters[relation]) { + query.where.and.push(optionFilters[relation]); } const response = await fetch(`${serverURL}${api}/${relation}?${qs.stringify(query)}`); @@ -148,7 +178,7 @@ const Relationship: React.FC = (props) => { } }, Promise.resolve()); } - }, [api, collections, serverURL, errorLoading, relationTo, hasMany, hasMultipleRelations]); + }, [relationTo, hasMany, errorLoading, collections, optionFilters, serverURL, api, hasMultipleRelations]); const findOptionsByValue = useCallback((): Option | Option[] => { if (value) { @@ -261,6 +291,25 @@ const Relationship: React.FC = (props) => { } }, [hasMany, hasMultipleRelations, relationTo, initialValue, hasLoadedValueOptions, errorLoading, collections, api, serverURL]); + useEffect(() => { + const relations = Array.isArray(relationTo) ? relationTo : [relationTo]; + const newOptionFilters = {}; + if (typeof filterOptions !== 'undefined') { + relations.forEach((relation) => { + newOptionFilters[relation] = getFilterOptionsQuery(filterOptions, { + id, + data: getData(), + siblingData: getSiblingData(path), + relationTo: relation, + user, + }); + }); + } + if (!equal(newOptionFilters, optionFilters)) { + setOptionFilters(newOptionFilters); + } + }, [relationTo, filterOptions, optionFilters, id, getData, getSiblingData, path, user]); + useEffect(() => { setHasLoadedValueOptions(false); getResults({ diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index 99db233dc1..65aa5d944e 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -208,6 +208,10 @@ export const relationship = baseField.keys({ ), name: joi.string().required(), maxDepth: joi.number(), + filterOptions: joi.alternatives().try( + joi.object(), + joi.func(), + ), }); export const blocks = baseField.keys({ diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index b25a1aa235..e449721375 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -1,7 +1,7 @@ /* eslint-disable no-use-before-define */ import { CSSProperties } from 'react'; import { Editor } from 'slate'; -import { Operation } from '../../types'; +import { Operation, Where } from '../../types'; import { TypeWithID } from '../../collections/config/types'; import { PayloadRequest } from '../../express/types'; import { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types'; @@ -202,11 +202,20 @@ export type SelectField = FieldBase & { hasMany?: boolean } +export type filterOptionsProps = { + id: string | number, + user: Partial, + data: unknown, + siblingData: unknown, + relationTo: string, +} + export type RelationshipField = FieldBase & { type: 'relationship'; relationTo: string | string[]; hasMany?: boolean; maxDepth?: number; + filterOptions?: Where | ((options: filterOptionsProps) => Where); } type RichTextPlugin = (editor: Editor) => Editor; diff --git a/src/fields/validations.ts b/src/fields/validations.ts index a8d31688f2..f39626b922 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -3,7 +3,8 @@ import { ArrayField, BlockField, CheckboxField, - CodeField, DateField, + CodeField, + DateField, EmailField, NumberField, PointField, @@ -16,6 +17,9 @@ import { UploadField, Validate, } from './config/types'; +import canUseDOM from '../utilities/canUseDOM'; +import payload from '../index'; +import { TypeWithID } from '../collections/config/types'; const defaultMessage = 'This field is required.'; @@ -148,9 +152,39 @@ export const upload: Validate = (value: string, { return defaultMessage; }; -export const relationship: Validate = (value, { required }) => { - if (value || !required) return true; - return defaultMessage; +export const relationship: Validate = async (value: string | string[], { required, relationTo, filterOptions, id, data, siblingData, user }) => { + if ((!value || (Array.isArray(value) && value.length === 0)) && required) { + return defaultMessage; + } + if (!canUseDOM && typeof filterOptions !== 'undefined' && value) { + const options = []; + const collections = typeof relationTo === 'string' ? [relationTo] : relationTo; + const values: string[] = typeof value === 'string' ? [value] : value; + await Promise.all(collections.map(async (collection) => { + const optionFilter = typeof filterOptions === 'function' ? filterOptions({ + id, + data, + siblingData, + user, + relationTo: collection, + }) : filterOptions; + const result = await payload.find({ + collection, + depth: 0, + where: { + and: [ + { id: { in: values } }, + optionFilter, + ], + }, + }); + options.concat(result.docs.map((item: TypeWithID) => String(item.id))); + })); + if (values.some((input) => options.some((option) => (option === input)))) { + return 'This field has an invalid selection'; + } + return true; + } }; export const array: Validate = (value, { minRows, maxRows, required }) => { diff --git a/src/webpack/getBaseConfig.ts b/src/webpack/getBaseConfig.ts index def6a2fa97..9bd8ed813e 100644 --- a/src/webpack/getBaseConfig.ts +++ b/src/webpack/getBaseConfig.ts @@ -56,6 +56,7 @@ export default (config: SanitizedConfig): Configuration => ({ 'payload-user-css': config.admin.css, 'payload-scss-overrides': config.admin.scss, dotenv: mockDotENVPath, + [path.resolve(__dirname, '../index.ts')]: mockModulePath, }, extensions: ['.ts', '.tsx', '.js', '.json'], }, From df934dfeff8b12857d133b2da9f441bc0285dee4 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 4 Apr 2022 21:20:21 -0400 Subject: [PATCH 17/19] feat: working PoC for reusing relationship filters in validate --- src/admin/components/forms/Form/types.ts | 1 - .../forms/field-types/Relationship/index.tsx | 14 +++-- .../forms/field-types/Upload/Add/index.tsx | 1 - .../views/CreateFirstUser/index.tsx | 1 - .../views/collections/Edit/Default.tsx | 1 - src/fields/config/types.ts | 10 +++ src/fields/performFieldOperations.ts | 4 +- src/fields/validations.ts | 63 ++++++++++++++++--- src/webpack/getBaseConfig.ts | 2 +- 9 files changed, 76 insertions(+), 21 deletions(-) diff --git a/src/admin/components/forms/Form/types.ts b/src/admin/components/forms/Form/types.ts index 2c2ef60e18..2755de8e6b 100644 --- a/src/admin/components/forms/Form/types.ts +++ b/src/admin/components/forms/Form/types.ts @@ -38,7 +38,6 @@ export type Props = { initialData?: Data waitForAutocomplete?: boolean log?: boolean - validationOperation?: 'create' | 'update' } export type SubmitOptions = { diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index d4ab3355ff..6c2ce3b80c 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -78,7 +78,7 @@ const Relationship: React.FC = (props) => { const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1); const [lastLoadedPage, setLastLoadedPage] = useState(1); const [errorLoading, setErrorLoading] = useState(''); - const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>({}); + const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>(); const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false); const [search, setSearch] = useState(''); @@ -311,11 +311,13 @@ const Relationship: React.FC = (props) => { }, [relationTo, filterOptions, optionFilters, id, getData, getSiblingData, path, user]); useEffect(() => { - setHasLoadedValueOptions(false); - getResults({ - value: initialValue, - }); - }, [initialValue, getResults]); + if (optionFilters) { + setHasLoadedValueOptions(false); + getResults({ + value: initialValue, + }); + } + }, [initialValue, getResults, optionFilters]); const classes = [ 'field-type', diff --git a/src/admin/components/forms/field-types/Upload/Add/index.tsx b/src/admin/components/forms/field-types/Upload/Add/index.tsx index 4318ce14d5..3fa0127430 100644 --- a/src/admin/components/forms/field-types/Upload/Add/index.tsx +++ b/src/admin/components/forms/field-types/Upload/Add/index.tsx @@ -53,7 +53,6 @@ const AddUploadModal: React.FC = (props) => { action={`${serverURL}${api}/${collection.slug}`} onSuccess={onSuccess} disableSuccessStatus - validationOperation="create" >
diff --git a/src/admin/components/views/CreateFirstUser/index.tsx b/src/admin/components/views/CreateFirstUser/index.tsx index bab615a805..65007628fb 100644 --- a/src/admin/components/views/CreateFirstUser/index.tsx +++ b/src/admin/components/views/CreateFirstUser/index.tsx @@ -65,7 +65,6 @@ const CreateFirstUser: React.FC = (props) => { method="post" redirect={admin} action={`${serverURL}${api}/${userSlug}/first-register`} - validationOperation="create" > = (props) => { onSuccess={onSave} disabled={!hasSavePermission} initialState={initialState} - validationOperation={isEditing ? 'update' : 'create'} >
Where); } +export type ValueWithRelation = { + relationTo: string + value: string | number +} + +export type RelationshipValue = (string | number) + | (string | number)[] + | ValueWithRelation + | ValueWithRelation[] + type RichTextPlugin = (editor: Editor) => Editor; export type RichTextCustomElement = { diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 5a95aeecb4..fe6aa0b5a3 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -116,8 +116,8 @@ export default async function performFieldOperations(this: Payload, entityConfig const hookResults = hookPromises.map((promise) => promise()); await Promise.all(hookResults); - validationPromises.forEach((promise) => promise()); - await Promise.all(validationPromises); + const validationResults = validationPromises.map((promise) => promise()); + await Promise.all(validationResults); if (errors.length > 0) { throw new ValidationError(errors); diff --git a/src/fields/validations.ts b/src/fields/validations.ts index f39626b922..7ceffffe24 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -16,6 +16,8 @@ import { TextField, UploadField, Validate, + RelationshipValue, + ValueWithRelation, } from './config/types'; import canUseDOM from '../utilities/canUseDOM'; import payload from '../index'; @@ -152,14 +154,19 @@ export const upload: Validate = (value: string, { return defaultMessage; }; -export const relationship: Validate = async (value: string | string[], { required, relationTo, filterOptions, id, data, siblingData, user }) => { +export const relationship: Validate = async (value: RelationshipValue, { required, relationTo, filterOptions, id, data, siblingData, user }) => { if ((!value || (Array.isArray(value) && value.length === 0)) && required) { return defaultMessage; } + if (!canUseDOM && typeof filterOptions !== 'undefined' && value) { - const options = []; + const options: { + [collection: string]: (string | number)[] + } = {}; + const collections = typeof relationTo === 'string' ? [relationTo] : relationTo; - const values: string[] = typeof value === 'string' ? [value] : value; + const values = Array.isArray(value) ? value : [value]; + await Promise.all(collections.map(async (collection) => { const optionFilter = typeof filterOptions === 'function' ? filterOptions({ id, @@ -168,23 +175,63 @@ export const relationship: Validate = async user, relationTo: collection, }) : filterOptions; - const result = await payload.find({ + + const valueIDs: (string | number)[] = []; + + values.forEach((val) => { + if (typeof val === 'object' && val?.value) { + valueIDs.push(val.value); + } + + if (typeof val === 'string' || typeof val === 'number') { + valueIDs.push(val); + } + }); + + const result = await payload.find({ collection, depth: 0, where: { and: [ - { id: { in: values } }, + { id: { in: valueIDs } }, optionFilter, ], }, }); - options.concat(result.docs.map((item: TypeWithID) => String(item.id))); + + options[collection] = result.docs.map((doc) => doc.id); })); - if (values.some((input) => options.some((option) => (option === input)))) { - return 'This field has an invalid selection'; + + const invalidRelationships = values.filter((val) => { + let collection: string; + let requestedID: string | number; + + if (typeof relationTo === 'string') { + collection = relationTo; + + if (typeof val === 'string' || typeof val === 'number') { + requestedID = val; + } + } + + if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) { + collection = val.relationTo; + requestedID = val.value; + } + + return options[collection].indexOf(requestedID) === -1; + }); + + if (invalidRelationships.length > 0) { + return invalidRelationships.reduce((err, invalid, i) => { + return `${err} ${JSON.stringify(invalid)}${invalidRelationships.length === i + 1 ? ',' : ''} `; + }, 'This field has the following invalid selections:') as string; } + return true; } + + return true; }; export const array: Validate = (value, { minRows, maxRows, required }) => { diff --git a/src/webpack/getBaseConfig.ts b/src/webpack/getBaseConfig.ts index 9bd8ed813e..50de8bddc8 100644 --- a/src/webpack/getBaseConfig.ts +++ b/src/webpack/getBaseConfig.ts @@ -56,7 +56,7 @@ export default (config: SanitizedConfig): Configuration => ({ 'payload-user-css': config.admin.css, 'payload-scss-overrides': config.admin.scss, dotenv: mockDotENVPath, - [path.resolve(__dirname, '../index.ts')]: mockModulePath, + [path.resolve(__dirname, '../index')]: mockModulePath, }, extensions: ['.ts', '.tsx', '.js', '.json'], }, From 1482fded9a2e700416e7d0b0c753a05f56f4aee1 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 5 Apr 2022 12:01:56 -0400 Subject: [PATCH 18/19] feat: upload field implements filterOptions --- demo/collections/RelationshipA.ts | 8 ++++ docs/fields/upload.mdx | 1 + .../forms/field-types/Relationship/index.tsx | 38 ++++++------------- .../forms/field-types/Upload/Input.tsx | 6 ++- .../Upload/SelectExisting/index.tsx | 34 +++++++++++++++-- .../Upload/SelectExisting/types.ts | 3 ++ .../forms/field-types/Upload/index.tsx | 2 + .../field-types/getFilterOptionsQuery.ts | 14 +++++++ src/fields/config/schema.ts | 4 ++ src/fields/config/types.ts | 21 +++++----- src/fields/validations.ts | 34 +++++++++++------ 11 files changed, 113 insertions(+), 52 deletions(-) create mode 100644 src/admin/components/forms/field-types/getFilterOptionsQuery.ts diff --git a/demo/collections/RelationshipA.ts b/demo/collections/RelationshipA.ts index 9dab529476..2f0c91f4c2 100644 --- a/demo/collections/RelationshipA.ts +++ b/demo/collections/RelationshipA.ts @@ -66,6 +66,14 @@ const RelationshipA: CollectionConfig = { }, }, }, + { + name: 'files', + type: 'upload', + relationTo: 'files', + filterOptions: { + type: { equals: 'Type 2' }, + }, + }, { name: 'demoHiddenField', type: 'text', diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 3ed2ee4e82..207f468966 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -27,6 +27,7 @@ keywords: upload, images media, fields, config, configuration, documentation, Co | ---------------- | ----------- | | **`name`** * | To be used as the property name when stored and retrieved from the database. | | **`*relationTo`** * | Provide a single collection `slug` to allow this field to accept a relation to. Note: the related collection must be configured to support Uploads. | +| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). | | **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | | **`label`** | Used as a field label in the Admin panel and to name the generated GraphQL type. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index 6c2ce3b80c..1b0e50fb0b 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -19,26 +19,15 @@ import optionsReducer from './optionsReducer'; import { Props, Option, ValueWithRelation, GetResults } from './types'; import { createRelationMap } from './createRelationMap'; import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback'; +import { useDocumentInfo } from '../../../utilities/DocumentInfo'; +import { getFilterOptionsQuery } from '../getFilterOptionsQuery'; import './index.scss'; -import { useDocumentInfo } from '../../../utilities/DocumentInfo'; -import { filterOptionsProps } from '../../../../../fields/config/types'; const maxResultsPerRequest = 10; const baseClass = 'relationship'; -const getFilterOptionsQuery = (filterOptions, options: filterOptionsProps): Where => { - let query = {}; - if (typeof filterOptions === 'object') { - query = filterOptions; - } - if (typeof filterOptions === 'function') { - query = filterOptions(options); - } - return query; -}; - const Relationship: React.FC = (props) => { const { relationTo, @@ -70,9 +59,7 @@ const Relationship: React.FC = (props) => { const { id } = useDocumentInfo(); const { user } = useAuth(); const { getData, getSiblingData } = useWatchForm(); - const formProcessing = useFormProcessing(); - const hasMultipleRelations = Array.isArray(relationTo); const [options, dispatchOptions] = useReducer(optionsReducer, required || hasMany ? [] : [{ value: 'null', label: 'None' }]); const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1); @@ -292,19 +279,16 @@ const Relationship: React.FC = (props) => { }, [hasMany, hasMultipleRelations, relationTo, initialValue, hasLoadedValueOptions, errorLoading, collections, api, serverURL]); useEffect(() => { - const relations = Array.isArray(relationTo) ? relationTo : [relationTo]; - const newOptionFilters = {}; - if (typeof filterOptions !== 'undefined') { - relations.forEach((relation) => { - newOptionFilters[relation] = getFilterOptionsQuery(filterOptions, { - id, - data: getData(), - siblingData: getSiblingData(path), - relationTo: relation, - user, - }); - }); + if (!filterOptions) { + return; } + const newOptionFilters = getFilterOptionsQuery(filterOptions, { + id, + data: getData(), + relationTo, + siblingData: getSiblingData(path), + user, + }); if (!equal(newOptionFilters, optionFilters)) { setOptionFilters(newOptionFilters); } diff --git a/src/admin/components/forms/field-types/Upload/Input.tsx b/src/admin/components/forms/field-types/Upload/Input.tsx index 1e61eaa8de..4005985d31 100644 --- a/src/admin/components/forms/field-types/Upload/Input.tsx +++ b/src/admin/components/forms/field-types/Upload/Input.tsx @@ -5,7 +5,7 @@ import Label from '../../Label'; import Error from '../../Error'; import FileDetails from '../../../elements/FileDetails'; import FieldDescription from '../../FieldDescription'; -import { UploadField } from '../../../../../fields/config/types'; +import { FilterOptions, UploadField } from '../../../../../fields/config/types'; import { Description } from '../../FieldDescription/types'; import { FieldTypes } from '..'; import AddModal from './Add'; @@ -33,6 +33,7 @@ export type UploadInputProps = Omit & { collection?: SanitizedCollectionConfig serverURL?: string api?: string + filterOptions: FilterOptions } const UploadInput: React.FC = (props) => { @@ -54,6 +55,7 @@ const UploadInput: React.FC = (props) => { api = '/api', collection, errorMessage, + filterOptions, } = props; const { toggle } = useModal(); @@ -160,6 +162,8 @@ const UploadInput: React.FC = (props) => { slug: selectExistingModalSlug, setValue: onChange, addModalSlug, + filterOptions, + path, }} /> = (props) => { } = {}, } = {}, slug: modalSlug, + path, + filterOptions, } = props; const { serverURL, routes: { api } } = useConfig(); + const { id } = useDocumentInfo(); + const { user } = useAuth(); + const { getData, getSiblingData } = useWatchForm(); const { closeAll, currentModal } = useModal(); const [fields] = useState(() => formatFields(collection)); const [limit, setLimit] = useState(defaultLimit); const [sort, setSort] = useState(null); const [where, setWhere] = useState(null); const [page, setPage] = useState(null); + const [optionFilters, setOptionFilters] = useState(); const classes = [ baseClass, @@ -58,12 +69,29 @@ const SelectExistingUploadModal: React.FC = (props) => { } = {}; if (page) params.page = page; - if (where) params.where = where; + if (where) params.where = { and: [where, optionFilters] }; if (sort) params.sort = sort; if (limit) params.limit = limit; setParams(params); - }, [setParams, page, sort, where, limit]); + }, [setParams, page, sort, where, limit, optionFilters]); + + useEffect(() => { + if (!filterOptions || !isOpen) { + return; + } + + const newOptionFilters = getFilterOptionsQuery(filterOptions, { + id, + relationTo: collectionSlug, + data: getData(), + siblingData: getSiblingData(path), + user, + })[collectionSlug]; + if (!equal(newOptionFilters, optionFilters)) { + setOptionFilters(newOptionFilters); + } + }, [collectionSlug, filterOptions, optionFilters, id, getData, getSiblingData, path, user, isOpen]); return ( void collection: SanitizedCollectionConfig slug: string + path + filterOptions: FilterOptions } diff --git a/src/admin/components/forms/field-types/Upload/index.tsx b/src/admin/components/forms/field-types/Upload/index.tsx index ef42b8128f..c48ff9813f 100644 --- a/src/admin/components/forms/field-types/Upload/index.tsx +++ b/src/admin/components/forms/field-types/Upload/index.tsx @@ -33,6 +33,7 @@ const Upload: React.FC = (props) => { validate = upload, relationTo, fieldTypes, + filterOptions, } = props; const collection = collections.find((coll) => coll.slug === relationTo); @@ -82,6 +83,7 @@ const Upload: React.FC = (props) => { fieldTypes={fieldTypes} name={name} relationTo={relationTo} + filterOptions={filterOptions} /> ); } diff --git a/src/admin/components/forms/field-types/getFilterOptionsQuery.ts b/src/admin/components/forms/field-types/getFilterOptionsQuery.ts new file mode 100644 index 0000000000..69ca683f1b --- /dev/null +++ b/src/admin/components/forms/field-types/getFilterOptionsQuery.ts @@ -0,0 +1,14 @@ +import { Where } from '../../../../types'; +import { FilterOptions, FilterOptionsProps } from '../../../../fields/config/types'; + +export const getFilterOptionsQuery = (filterOptions: FilterOptions, options: FilterOptionsProps): {[collection: string]: Where } => { + const { relationTo } = options; + const relations = Array.isArray(relationTo) ? relationTo : [relationTo]; + const query = {}; + if (typeof filterOptions !== 'undefined') { + relations.forEach((relation) => { + query[relation] = typeof filterOptions === 'function' ? filterOptions(options) : filterOptions; + }); + } + return query; +}; diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index 65aa5d944e..96cd8adc3c 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -185,6 +185,10 @@ export const upload = baseField.keys({ relationTo: joi.string().required(), name: joi.string().required(), maxDepth: joi.number(), + filterOptions: joi.alternatives().try( + joi.object(), + joi.func(), + ), }); export const checkbox = baseField.keys({ diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 17046f3b1c..ae3086a471 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -29,6 +29,16 @@ export type FieldAccess = (args: { export type Condition = (data: Partial, siblingData: Partial

) => boolean; +export type FilterOptionsProps = { + id: string | number, + user: Partial, + data: unknown, + siblingData: unknown, + relationTo: string | string[], +} + +export type FilterOptions = Where | ((options: FilterOptionsProps) => Where); + type Admin = { position?: 'sidebar'; width?: string; @@ -183,6 +193,7 @@ export type UploadField = FieldBase & { type: 'upload' relationTo: string maxDepth?: number + filterOptions?: FilterOptions; } type CodeAdmin = Admin & { @@ -202,20 +213,12 @@ export type SelectField = FieldBase & { hasMany?: boolean } -export type filterOptionsProps = { - id: string | number, - user: Partial, - data: unknown, - siblingData: unknown, - relationTo: string, -} - export type RelationshipField = FieldBase & { type: 'relationship'; relationTo: string | string[]; hasMany?: boolean; maxDepth?: number; - filterOptions?: Where | ((options: filterOptionsProps) => Where); + filterOptions?: FilterOptions; } export type ValueWithRelation = { diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 7ceffffe24..60bab08fed 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -17,7 +17,6 @@ import { UploadField, Validate, RelationshipValue, - ValueWithRelation, } from './config/types'; import canUseDOM from '../utilities/canUseDOM'; import payload from '../index'; @@ -90,7 +89,11 @@ export const email: Validate = (value: string, { r return true; }; -export const textarea: Validate = (value: string, { required, maxLength, minLength }) => { +export const textarea: Validate = (value: string, { + required, + maxLength, + minLength, +}) => { if (value && maxLength && value.length > maxLength) { return `This value must be shorter than the max length of ${maxLength} characters.`; } @@ -149,16 +152,7 @@ export const date: Validate = (value, { required }) return true; }; -export const upload: Validate = (value: string, { required }) => { - if (value || !required) return true; - return defaultMessage; -}; - -export const relationship: Validate = async (value: RelationshipValue, { required, relationTo, filterOptions, id, data, siblingData, user }) => { - if ((!value || (Array.isArray(value) && value.length === 0)) && required) { - return defaultMessage; - } - +const validateFilterOptions: Validate = async (value, { filterOptions, id, user, data, siblingData, relationTo }) => { if (!canUseDOM && typeof filterOptions !== 'undefined' && value) { const options: { [collection: string]: (string | number)[] @@ -234,6 +228,22 @@ export const relationship: Validate = async return true; }; +export const upload: Validate = (value: string, options) => { + if ((!value || (Array.isArray(value) && value.length === 0)) && options.required) { + return defaultMessage; + } + + return validateFilterOptions(value, options); +}; + +export const relationship: Validate = async (value: RelationshipValue, options) => { + if ((!value || (Array.isArray(value) && value.length === 0)) && options.required) { + return defaultMessage; + } + + return validateFilterOptions(value, options); +}; + export const array: Validate = (value, { minRows, maxRows, required }) => { if (minRows && value < minRows) { return `This field requires at least ${minRows} row(s).`; From 89d56a08867a086c1ff3d422ca7860c9764f516a Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 5 Apr 2022 12:21:45 -0400 Subject: [PATCH 19/19] chore: test coverage for relationship filteroptions --- src/collections/tests/relationships.spec.ts | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/collections/tests/relationships.spec.ts b/src/collections/tests/relationships.spec.ts index 0259628e6c..64f22f1b1a 100644 --- a/src/collections/tests/relationships.spec.ts +++ b/src/collections/tests/relationships.spec.ts @@ -189,5 +189,29 @@ describe('Collections - REST', () => { expect(custom.doc.id).toBe(parseFloat(customID.id)); expect(doc.customID[0].id).toBe(parseFloat(customID.id)); }); + + it('should use filterOptions to limit relationship options', async () => { + // update documentB to disable relations + await fetch(`${url}/api/relationship-b/${documentB.id}`, { + headers, + body: JSON.stringify({ + disableRelation: true, + }), + method: 'put', + }); + + // attempt to save relationship to documentB + const response = await fetch(`${url}/api/relationship-a/${documentA.id}`, { + headers, + body: JSON.stringify({ + filterRelationship: documentB.id, + }), + method: 'put', + }); + + const result = await response.json(); + + expect(result.errors).toBeDefined(); + }); }); });