From 1b4b5707bfa731bedc5d9ca49ac9f425932b999c Mon Sep 17 00:00:00 2001 From: James Mikrut Date: Tue, 5 Apr 2022 14:51:28 -0400 Subject: [PATCH] feat: extended validation function arguments (#494) * feat: WIP extended validation function arguments * chore: optimizes validation extended args * chore: more consistently passes validation args * chore: removes field from form state * chore: passing tests * fix: default point validation allows not required and some edge cases * chore: ensures default validate functions receive field config * chore: demo validation with sibling data * chore: optimize getDatabByPath and getSiblingData * chore: adds tests to validate extra arg options * docs: add validation arguments * chore: export default field validation * chore: top level getSiblingData * fix: #495, avoids appending version to id queries * chore: revises when field validation is run * chore: restore original admin field validation Co-authored-by: Dan Ribbens --- demo/collections/Validations.ts | 32 ++- docs/fields/overview.mdx | 42 ++- fields/validations.js | 1 + .../forms/DraggableSection/index.tsx | 4 +- .../forms/Form/buildStateFromSchema.spec.js | 2 +- .../forms/Form/buildStateFromSchema.ts | 43 ++- .../components/forms/Form/getDataByPath.ts | 18 +- .../components/forms/Form/getSiblingData.ts | 29 +- src/admin/components/forms/Form/index.tsx | 29 +- .../forms/Form/reduceFieldsToValues.ts | 5 +- src/admin/components/forms/Form/types.ts | 5 +- .../components/forms/RenderFields/index.tsx | 150 +++++------ .../components/forms/RenderFields/types.ts | 7 - .../forms/field-types/Array/Array.tsx | 20 +- .../forms/field-types/Blocks/Blocks.tsx | 33 ++- .../forms/field-types/Checkbox/index.tsx | 7 +- .../forms/field-types/Code/Code.tsx | 9 +- .../forms/field-types/DateTime/index.tsx | 5 +- .../forms/field-types/Email/index.tsx | 5 +- .../forms/field-types/Number/index.tsx | 7 +- .../forms/field-types/Password/index.tsx | 4 +- .../forms/field-types/Point/index.tsx | 5 +- .../forms/field-types/RadioGroup/index.tsx | 7 +- .../forms/field-types/Relationship/index.tsx | 5 +- .../forms/field-types/RichText/RichText.tsx | 7 +- .../upload/Element/EditModal/index.tsx | 6 +- .../forms/field-types/Select/index.tsx | 9 +- .../forms/field-types/Text/index.tsx | 10 +- .../forms/field-types/Textarea/index.tsx | 11 +- .../forms/field-types/Upload/Add/index.tsx | 1 + .../forms/field-types/Upload/index.tsx | 5 +- .../utilities/OperationProvider/index.tsx | 7 + .../components/views/Account/Default.tsx | 213 +++++++-------- src/admin/components/views/Account/index.tsx | 7 +- .../views/CreateFirstUser/index.tsx | 1 + src/admin/components/views/Global/Default.tsx | 249 +++++++++--------- src/admin/components/views/Global/index.tsx | 10 +- .../views/collections/Edit/Auth/APIKey.tsx | 2 +- .../views/collections/Edit/Default.tsx | 136 +++++----- .../views/collections/Edit/index.tsx | 10 +- src/collections/tests/pointField.spec.ts | 44 +++- src/collections/tests/validations.spec.js | 81 ++++++ src/fields/config/sanitize.ts | 2 +- src/fields/config/types.ts | 12 +- src/fields/traverseFields.ts | 27 +- src/fields/validationPromise.ts | 34 ++- src/fields/validations.spec.ts | 162 ++++++++---- src/fields/validations.ts | 155 +++++------ 48 files changed, 992 insertions(+), 683 deletions(-) create mode 100644 fields/validations.js create mode 100644 src/admin/components/utilities/OperationProvider/index.tsx create mode 100644 src/collections/tests/validations.spec.js diff --git a/demo/collections/Validations.ts b/demo/collections/Validations.ts index cf8bd9525..9d9f88f4c 100644 --- a/demo/collections/Validations.ts +++ b/demo/collections/Validations.ts @@ -10,6 +10,34 @@ const Validations: CollectionConfig = { read: () => true, }, fields: [ + { + name: 'validationOptions', + type: 'text', + label: 'Text with siblingData Validation', + required: true, + 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; + }, + }, { name: 'text', type: 'text', @@ -58,7 +86,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.'; @@ -96,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/docs/fields/overview.mdx b/docs/fields/overview.mdx index 38b7a7737..0a744e43d 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; @@ -85,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. @@ -198,7 +234,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:** diff --git a/fields/validations.js b/fields/validations.js new file mode 100644 index 000000000..b24ba908b --- /dev/null +++ b/fields/validations.js @@ -0,0 +1 @@ +export * from '../dist/fields/validations'; diff --git a/src/admin/components/forms/DraggableSection/index.tsx b/src/admin/components/forms/DraggableSection/index.tsx index 86f021c99..f3f8d1b82 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) => { > { 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 bb57849e9..672a8cbf5 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 fieldState.validate === 'function') { + validationResult = await fieldState.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 Args = { + fieldSchema: FieldSchema[] + data?: Data, + siblingData?: Data, + user?: User, + id?: string | number, + operation?: 'create' | 'update' +} + +const buildStateFromSchema = async (args: Args): Promise => { + const { + fieldSchema, + data: fullData = {}, + user, + id, + operation, + } = args; + if (fieldSchema) { const validationPromises = []; @@ -35,7 +59,14 @@ const buildStateFromSchema = async (fieldSchema: FieldSchema[], fullData: Data = passesCondition, }; - validationPromises.push(buildValidationPromise(fieldState, field)); + validationPromises.push(buildValidationPromise(fieldState, { + ...field, + fullData, + user, + siblingData: data, + id, + operation, + })); return fieldState; }; diff --git a/src/admin/components/forms/Form/getDataByPath.ts b/src/admin/components/forms/Form/getDataByPath.ts index 52ee905f4..297140b5d 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 095272937..849b2a572 100644 --- a/src/admin/components/forms/Form/getSiblingData.ts +++ b/src/admin/components/forms/Form/getSiblingData.ts @@ -1,26 +1,23 @@ -import reduceFieldsToValues from './reduceFieldsToValues'; +import { unflatten } from 'flatley'; import { Fields, Data } from './types'; +import reduceFieldsToValues from './reduceFieldsToValues'; const getSiblingData = (fields: Fields, path: string): Data => { - let siblingFields = fields; + 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); - siblingFields = Object.keys(fields).reduce((siblings, fieldKey) => { - if (fieldKey.indexOf(parentFieldPath) === 0) { - return { - ...siblings, - [fieldKey.replace(parentFieldPath, '')]: fields[fieldKey], - }; - } + 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 siblings; - }, {}); - } - - return reduceFieldsToValues(siblingFields, true); + return unflatten(siblingFields, { safe: true }); }; export default getSiblingData; diff --git a/src/admin/components/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index 1101c62dd..93b2add27 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -2,11 +2,13 @@ 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'; 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'; @@ -15,13 +17,13 @@ import reduceFieldsToValues from './reduceFieldsToValues'; import getSiblingDataFunc from './getSiblingData'; import getDataByPathFunc from './getDataByPath'; import wait from '../../../../utilities/wait'; +import { Field } 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 } from '../../../../fields/config/types'; +import { useOperation } from '../../utilities/OperationProvider'; const baseClass = 'form'; @@ -45,7 +47,9 @@ const Form: React.FC = (props) => { const history = useHistory(); const locale = useLocale(); - const { refreshCookie } = useAuth(); + const { refreshCookie, user } = useAuth(); + const { id } = useDocumentInfo(); + const operation = useOperation(); const [modified, setModified] = useState(false); const [processing, setProcessing] = useState(false); @@ -70,6 +74,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 +86,13 @@ 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, { + data, + siblingData: contextRef.current.getSiblingData(path), + user, + id, + operation, + }); } if (typeof validationResult === 'string') { @@ -96,10 +107,12 @@ 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]); + }, [contextRef, id, user, operation]); const submit = useCallback(async (options: SubmitOptions = {}, e): Promise => { const { @@ -313,10 +326,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 }); contextRef.current = { ...initContextState } as FormContextType; dispatchFields({ type: 'REPLACE_STATE', state }); - }, []); + }, [id, user, operation]); contextRef.current.dispatchFields = dispatchFields; contextRef.current.submit = submit; diff --git a/src/admin/components/forms/Form/reduceFieldsToValues.ts b/src/admin/components/forms/Form/reduceFieldsToValues.ts index 548463df5..c57f7d0b2 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; diff --git a/src/admin/components/forms/Form/types.ts b/src/admin/components/forms/Form/types.ts index cde5d0793..2c2ef60e1 100644 --- a/src/admin/components/forms/Form/types.ts +++ b/src/admin/components/forms/Form/types.ts @@ -1,11 +1,11 @@ -import { Field as FieldConfig, Condition } from '../../../../fields/config/types'; +import { Field as FieldConfig, Condition, Validate } from '../../../../fields/config/types'; export type Field = { value: unknown initialValue: unknown errorMessage?: string valid: boolean - validate?: (val: unknown) => Promise | boolean | string + validate?: Validate disableFormData?: boolean ignoreWhileFlattening?: boolean condition?: Condition @@ -38,6 +38,7 @@ export type Props = { initialData?: Data waitForAutocomplete?: boolean log?: boolean + validationOperation?: 'create' | 'update' } export type SubmitOptions = { diff --git a/src/admin/components/forms/RenderFields/index.tsx b/src/admin/components/forms/RenderFields/index.tsx index 21dc16d68..028f658b0 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 3bdd3045d..72d3151e1 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/Array/Array.tsx b/src/admin/components/forms/field-types/Array/Array.tsx index 9de5c2985..9004486df 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, { useEffect, useReducer, useCallback, useState } from 'react'; +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,15 +53,17 @@ 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; 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, minRows, maxRows, required }); + }, [maxRows, minRows, required, validate]); const [disableFormData, setDisableFormData] = useState(false); @@ -75,11 +81,11 @@ const ArrayFieldType: React.FC = (props) => { }); const addRow = useCallback(async (rowIndex) => { - const subFieldState = await buildStateFromSchema(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 946931257..5dc695870 100644 --- a/src/admin/components/forms/field-types/Blocks/Blocks.tsx +++ b/src/admin/components/forms/field-types/Blocks/Blocks.tsx @@ -1,8 +1,7 @@ -import React, { - useEffect, useReducer, useCallback, useState, -} from 'react'; +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'; @@ -20,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'; @@ -57,17 +57,14 @@ 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) => { - 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, minRows, maxRows, required }); + }, [maxRows, minRows, required, validate]); const [disableFormData, setDisableFormData] = useState(false); @@ -87,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(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 }); @@ -105,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); @@ -116,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/Checkbox/index.tsx b/src/admin/components/forms/field-types/Checkbox/index.tsx index e5897aff9..6bcb0b292 100644 --- a/src/admin/components/forms/field-types/Checkbox/index.tsx +++ b/src/admin/components/forms/field-types/Checkbox/index.tsx @@ -15,11 +15,11 @@ const Checkbox: React.FC = (props) => { const { name, path: pathFromProps, - required, validate = checkbox, label, onChange, disableFormData, + required, admin: { readOnly, style, @@ -32,9 +32,8 @@ const Checkbox: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, required }); }, [validate, required]); const { diff --git a/src/admin/components/forms/field-types/Code/Code.tsx b/src/admin/components/forms/field-types/Code/Code.tsx index d107305f9..bbf0b7048 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, 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 840b4f1b8..a35795748 100644 --- a/src/admin/components/forms/field-types/DateTime/index.tsx +++ b/src/admin/components/forms/field-types/DateTime/index.tsx @@ -34,9 +34,8 @@ const DateTime: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, required }); }, [validate, required]); const { diff --git a/src/admin/components/forms/field-types/Email/index.tsx b/src/admin/components/forms/field-types/Email/index.tsx index 8299af5b1..3ef1fa7ef 100644 --- a/src/admin/components/forms/field-types/Email/index.tsx +++ b/src/admin/components/forms/field-types/Email/index.tsx @@ -30,9 +30,8 @@ const Email: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, required }); }, [validate, required]); const fieldType = useField({ diff --git a/src/admin/components/forms/field-types/Number/index.tsx b/src/admin/components/forms/field-types/Number/index.tsx index 9a9445da7..951726be6 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, 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 3206fd34c..61e88232e 100644 --- a/src/admin/components/forms/field-types/Password/index.tsx +++ b/src/admin/components/forms/field-types/Password/index.tsx @@ -23,8 +23,8 @@ const Password: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); + const memoizedValidate = useCallback((value, options) => { + const validationResult = validate(value, { ...options, required }); return validationResult; }, [validate, required]); diff --git a/src/admin/components/forms/field-types/Point/index.tsx b/src/admin/components/forms/field-types/Point/index.tsx index 6985d66c8..f73dd305a 100644 --- a/src/admin/components/forms/field-types/Point/index.tsx +++ b/src/admin/components/forms/field-types/Point/index.tsx @@ -32,9 +32,8 @@ const PointField: React.FC = (props) => { const path = pathFromProps || name; - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, required }); }, [validate, required]); const { diff --git a/src/admin/components/forms/field-types/RadioGroup/index.tsx b/src/admin/components/forms/field-types/RadioGroup/index.tsx index 50c349fc1..45137c09b 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, 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 450c63719..bb4d36a94 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -62,9 +62,8 @@ 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; + const memoizedValidate = useCallback((value, validationOptions) => { + return validate(value, { ...validationOptions, required }); }, [validate, required]); const { diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index 0cacaf77e..b76a3bd5d 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'; @@ -117,9 +118,8 @@ const RichText: React.FC = (props) => { ); }, [enabledLeaves, path, props]); - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; + const memoizedValidate = useCallback((value, validationOptions) => { + return validate(value, { ...validationOptions, required }); }, [validate, required]); const fieldType = useField({ @@ -272,7 +272,6 @@ const RichText: React.FC = (props) => { ref={editorRef} > = ({ 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, element?.fields); + const state = await buildStateFromSchema({ fieldSchema, data: element?.fields, user, operation: 'update' }); setInitialState(state); }; awaitInitialState(); - }, [fieldSchema, element.fields]); + }, [fieldSchema, element.fields, user]); return ( = (props) => { const { path: pathFromProps, name, - required, validate = select, label, options: optionsFromProps, hasMany, + required, admin: { readOnly, style, @@ -44,10 +44,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, 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 2a16f505f..656d534af 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'; @@ -12,6 +12,8 @@ const Text: React.FC = (props) => { required, validate = text, label, + minLength, + maxLength, admin: { placeholder, readOnly, @@ -25,9 +27,13 @@ const Text: React.FC = (props) => { const path = pathFromProps || name; + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, minLength, maxLength, required }); + }, [validate, minLength, maxLength, required]); + 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 c72794e10..4e9ed1b08 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, @@ -24,16 +26,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, options) => { + return validate(value, { ...options, required, maxLength, minLength }); + }, [validate, required, maxLength, minLength]); 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 3fa012743..4318ce14d 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 67c139b8f..ef42b8128 100644 --- a/src/admin/components/forms/field-types/Upload/index.tsx +++ b/src/admin/components/forms/field-types/Upload/index.tsx @@ -37,9 +37,8 @@ const Upload: React.FC = (props) => { const collection = collections.find((coll) => coll.slug === relationTo); - const memoizedValidate = useCallback((value) => { - const validationResult = validate(value, { required }); - return validationResult; + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, required }); }, [validate, required]); const field = useField({ diff --git a/src/admin/components/utilities/OperationProvider/index.tsx b/src/admin/components/utilities/OperationProvider/index.tsx new file mode 100644 index 000000000..e5b1a6c02 --- /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 db52b693c..81cfa6e03 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,115 +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/Account/index.tsx b/src/admin/components/views/Account/index.tsx index 5b4b992f6..c09ee446f 100644 --- a/src/admin/components/views/Account/index.tsx +++ b/src/admin/components/views/Account/index.tsx @@ -9,6 +9,7 @@ import DefaultAccount from './Default'; import buildStateFromSchema from '../../forms/Form/buildStateFromSchema'; import RenderCustomComponent from '../../utilities/RenderCustomComponent'; import { NegativeFieldGutterProvider } from '../../forms/FieldTypeGutter/context'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; const AccountView: React.FC = () => { 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(fields, 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/CreateFirstUser/index.tsx b/src/admin/components/views/CreateFirstUser/index.tsx index 65007628f..bab615a80 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) => { )} {!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/Global/index.tsx b/src/admin/components/views/Global/index.tsx index ad668bf1d..42087cf2f 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(fields, 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(fields, 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/Auth/APIKey.tsx b/src/admin/components/views/collections/Edit/Auth/APIKey.tsx index 9f2bb6cbf..71c99d782 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, { minLength: 24, maxLength: 48 }); +const validate = (val) => text(val, { 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 c6886d722..adfc0cf48 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,31 +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 && ( @@ -168,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) && ( @@ -189,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 && (
  • @@ -242,11 +243,12 @@ const DefaultEditView: React.FC = (props) => { )}
- )} + )} +
- -
+ + )} ); diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index 29fdcfabe..9de096fd5 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(collection.fields, 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(fields, 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 ( diff --git a/src/collections/tests/pointField.spec.ts b/src/collections/tests/pointField.spec.ts index 95674ca97..46310f440 100644 --- a/src/collections/tests/pointField.spec.ts +++ b/src/collections/tests/pointField.spec.ts @@ -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); diff --git a/src/collections/tests/validations.spec.js b/src/collections/tests/validations.spec.js new file mode 100644 index 000000000..1367e75d0 --- /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'); + }); + }); +}); diff --git a/src/fields/config/sanitize.ts b/src/fields/config/sanitize.ts index 03c140305..47a4300e9 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, options) => defaultValidate(val, { ...field, ...options }); } else { field.validate = () => true; } diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index c7ad136fc..b25a1aa23 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,15 @@ export type Labels = { plural: string; }; -export type Validate = (value?: T, options?: any) => string | true | Promise; +export type ValidateOptions = { + data: Partial + siblingData: Partial + id?: string | number + user?: Partial + operation?: Operation +} & F; + +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 b4e381de7..668706969 100644 --- a/src/fields/traverseFields.ts +++ b/src/fields/traverseFields.ts @@ -1,7 +1,14 @@ 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 '..'; @@ -372,21 +379,31 @@ const traverseFields = (args: Arguments): void => { validationPromises.push(() => validationPromise({ errors, hook, - newData: { [field.name]: newRowCount }, - existingData: { [field.name]: existingRowCount }, + data: { [field.name]: newRowCount }, + fullData, + originalDoc: { [field.name]: existingRowCount }, + fullOriginalDoc, field, path, skipValidation: skipValidationFromHere, + user: req.user, + operation, + id, })); } else if (fieldAffectsData(field)) { validationPromises.push(() => validationPromise({ errors, hook, - newData: data, - existingData: originalDoc, + data, + fullData, + originalDoc, + fullOriginalDoc, field, path, skipValidation: skipValidationFromHere, + user: req.user, + operation, + id, })); } } diff --git a/src/fields/validationPromise.ts b/src/fields/validationPromise.ts index 6cd37ebf8..492f6177a 100644 --- a/src/fields/validationPromise.ts +++ b/src/fields/validationPromise.ts @@ -1,3 +1,6 @@ +import merge from 'deepmerge'; +import { User } from '../auth'; +import { Operation } from '../types'; import { HookName, FieldAffectingData } from './config/types'; type Arguments = { @@ -5,30 +8,47 @@ type Arguments = { field: FieldAffectingData path: string errors: {message: string, field: string}[] - newData: Record - existingData: Record + data: Record + fullData: Record + originalDoc: Record + fullOriginalDoc: Record + id?: string | number skipValidation?: boolean + user: User + operation: Operation } const validationPromise = async ({ errors, hook, - newData, - existingData, + originalDoc, + fullOriginalDoc, + data, + fullData, + 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; - 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) : true; + const result = shouldValidate ? await field.validate(valueToValidate, { + ...field, + data: merge(fullOriginalDoc, fullData), + siblingData: merge(originalDoc, data), + 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 8c0c6cb70..be708828b 100644 --- a/src/fields/validations.spec.ts +++ b/src/fields/validations.spec.ts @@ -1,133 +1,195 @@ - -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.`; 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', + 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, minLength: 10 }); expect(result).toBe(true); }); }); describe('password', () => { + options.type = 'password'; + options.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, 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, 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, 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, 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, minLength: 10 }); expect(result).toBe(true); }); }); + 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', () => { - const arrayOptions = { - options: ['one', 'two', 'three'], - }; + options.type = 'select'; + options.options = ['one', 'two', 'three']; const optionsRequired = { + ...options, required: true, options: [{ value: 'one', @@ -141,6 +203,7 @@ describe('Field Validations', () => { }], }; const optionsWithEmptyString = { + ...options, options: [{ value: '', label: 'None', @@ -151,27 +214,27 @@ describe('Field Validations', () => { }; 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 +242,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.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.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.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.hasMany = true; + const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); it('should allow valid input with option objects', () => { const val = 'one'; + options.hasMany = false; const result = select(val, optionsRequired); expect(result).toStrictEqual(true); }); it('should prevent invalid input with option objects', () => { const val = 'bad'; + options.hasMany = false; const result = select(val, optionsRequired); expect(result).not.toStrictEqual(true); }); @@ -218,27 +287,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.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.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.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 06f1c7bc6..a8d31688f 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -1,40 +1,56 @@ 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, { required, min, max }) => { const parsedValue = parseInt(value, 10); - if ((value && typeof parsedValue !== 'number') || (options.required && Number.isNaN(parsedValue))) { + if ((value && typeof parsedValue !== 'number') || (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 (max && parsedValue > max) { + return `"${value}" is greater than the max allowed value of ${max}.`; } - if (options.min && parsedValue < options.min) { - return `"${value}" is less than the min allowed value of ${options.min}.`; + if (min && parsedValue < min) { + return `"${value}" is less than the min allowed value of ${min}.`; } - if (options.required && typeof parsedValue !== 'number') { + if (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, { 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.minLength && value?.length < options.minLength) { - return `This value must be longer than the minimum length of ${options.minLength} characters.`; + if (value && minLength && value?.length < minLength) { + return `This value must be longer than the minimum length of ${minLength} characters.`; } - if (options.required) { + if (required) { if (typeof value !== 'string' || value?.length === 0) { return defaultMessage; } @@ -43,65 +59,57 @@ 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, { 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.minLength && value.length < options.minLength) { - return `This value must be longer than the minimum length of ${options.minLength} characters.`; + if (value && minLength && value.length < minLength) { + return `This value must be longer than the minimum length of ${minLength} characters.`; } - if (options.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.required)) { + || (!value && 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, { 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.minLength && value.length < options.minLength) { - return `This value must be longer than the minimum length of ${options.minLength} characters.`; + if (value && minLength && value.length < minLength) { + return `This value must be longer than the minimum length of ${minLength} characters.`; } - if (options.required && !value) { + if (required && !value) { return defaultMessage; } return true; }; -export const wysiwyg: Validate = (value: string, options = {}) => { - if (options.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.required && value === undefined) { - return defaultMessage; - } - - return true; -}; - -export const richText: Validate = (value, options = {}) => { - if (options.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.'; @@ -110,16 +118,16 @@ export const richText: Validate = (value, options = {}) => { return true; }; -export const checkbox: Validate = (value: boolean, options = {}) => { +export const checkbox: Validate = (value: boolean, { required }) => { if ((value && typeof value !== 'boolean') - || (options.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; } @@ -128,50 +136,50 @@ export const date: Validate = (value, options = {}) => { return `"${value}" is not a valid date.`; } - if (options.required) { + if (required) { return defaultMessage; } return true; }; -export const upload: Validate = (value: string, options = {}) => { - if (value || !options.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.required) return true; +export const relationship: Validate = (value, { required }) => { + if (value || !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, { minRows, maxRows, required }) => { + if (minRows && value < minRows) { + return `This field requires at least ${minRows} row(s).`; } - if (options.maxRows && value > options.maxRows) { - return `This field requires no more than ${options.maxRows} row(s).`; + if (maxRows && value > maxRows) { + return `This field requires no more than ${maxRows} row(s).`; } - if (!value && options.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.options.some((option) => (option === input || 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.options.some((option) => (option === value || 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.required && ( - (typeof value === 'undefined' || value === null) || (options.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; } @@ -179,41 +187,41 @@ 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.required) && (options.options.find((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.minRows && value < options.minRows) { - return `This field requires at least ${options.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.maxRows && value > options.maxRows) { - return `This field requires no more than ${options.maxRows} row(s).`; + if (maxRows && value > maxRows) { + return `This field requires no more than ${maxRows} row(s).`; } - if (!value && options.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 ( + if (required && ( (value[0] && value[1] && typeof lng !== 'number' && typeof lat !== 'number') - || (options.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 (!options.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; @@ -226,7 +234,6 @@ export default { email, textarea, code, - wysiwyg, richText, checkbox, date,