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 <dan.ribbens@gmail.com>
This commit is contained in:
James Mikrut
2022-04-05 14:51:28 -04:00
committed by GitHub
parent bb3080aef7
commit 1b4b5707bf
48 changed files with 992 additions and 683 deletions

View File

@@ -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.';

View File

@@ -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:**

1
fields/validations.js Normal file
View File

@@ -0,0 +1 @@
export * from '../dist/fields/validations';

View File

@@ -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) => {
} = props;
const [isHovered, setIsHovered] = useState(false);
const { operation } = useRenderedFields();
const classes = [
baseClass,
@@ -105,7 +104,6 @@ const DraggableSection: React.FC<Props> = (props) => {
>
<NegativeFieldGutterProvider allow={false}>
<RenderFields
operation={operation}
readOnly={readOnly}
fieldTypes={fieldTypes}
key={rowIndex}

View File

@@ -11,7 +11,7 @@ describe('Form - buildStateFromSchema', () => {
defaultValue,
},
];
const state = await buildStateFromSchema(fieldSchema, {});
const state = await buildStateFromSchema({ fieldSchema });
expect(state.text.value).toBe(defaultValue);
});
});

View File

@@ -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<unknown, unknown, unknown>) => {
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<Fields> => {
type Args = {
fieldSchema: FieldSchema[]
data?: Data,
siblingData?: Data,
user?: User,
id?: string | number,
operation?: 'create' | 'update'
}
const buildStateFromSchema = async (args: Args): Promise<Fields> => {
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;
};

View File

@@ -1,24 +1,18 @@
import { unflatten } from 'flatley';
import reduceFieldsToValues from './reduceFieldsToValues';
import { Fields } from './types';
const getDataByPath = <T = unknown>(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];
};

View File

@@ -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],
};
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;

View File

@@ -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> = (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> = (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> = (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> = (props) => {
await Promise.all(validationPromises);
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<void> => {
const {
@@ -313,10 +326,10 @@ const Form: React.FC<Props> = (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;

View File

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

View File

@@ -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> | 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 = {

View File

@@ -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> = (props) => {
const {
fieldSchema,
@@ -21,30 +18,17 @@ const RenderFields: React.FC<Props> = (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,8 +48,7 @@ const RenderFields: React.FC<Props> = (props) => {
className={classes}
>
{hasRendered && (
<RenderedFieldContext.Provider value={contextValue}>
{fieldSchema.map((field, i) => {
fieldSchema.map((field, i) => {
const fieldIsPresentational = fieldIsPresentationalOnly(field);
let FieldComponent = fieldTypes[field.type];
@@ -136,8 +119,7 @@ const RenderFields: React.FC<Props> = (props) => {
}
return null;
})}
</RenderedFieldContext.Provider>
})
)}
</div>
);

View File

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

View File

@@ -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> = (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> = (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 });

View File

@@ -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> = (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> = (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> = (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> = (props) => {
|| [];
if (!collapsed) {
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== id);
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
} else {
newCollapsedState.push(id);
newCollapsedState.push(rowID);
}
setPreference(preferencesKey, {

View File

@@ -15,11 +15,11 @@ const Checkbox: React.FC<Props> = (props) => {
const {
name,
path: pathFromProps,
required,
validate = checkbox,
label,
onChange,
disableFormData,
required,
admin: {
readOnly,
style,
@@ -32,9 +32,8 @@ const Checkbox: React.FC<Props> = (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 {

View File

@@ -29,8 +29,6 @@ const Code: React.FC<Props> = (props) => {
condition,
} = {},
label,
minLength,
maxLength,
} = props;
const [highlighter] = useState(() => {
@@ -43,10 +41,9 @@ const Code: React.FC<Props> = (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,

View File

@@ -34,9 +34,8 @@ const DateTime: React.FC<Props> = (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 {

View File

@@ -30,9 +30,8 @@ const Email: React.FC<Props> = (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({

View File

@@ -32,10 +32,9 @@ const NumberField: React.FC<Props> = (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,

View File

@@ -23,8 +23,8 @@ const Password: React.FC<Props> = (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]);

View File

@@ -32,9 +32,8 @@ const PointField: React.FC<Props> = (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 {

View File

@@ -35,10 +35,9 @@ const RadioGroup: React.FC<Props> = (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,

View File

@@ -62,9 +62,8 @@ const Relationship: React.FC<Props> = (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 {

View File

@@ -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> = (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> = (props) => {
ref={editorRef}
>
<Editable
className={`${baseClass}__input`}
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={placeholder}

View File

@@ -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<Props> = ({ 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<Props> = ({ 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 (
<Modal

View File

@@ -21,11 +21,11 @@ const Select: React.FC<Props> = (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> = (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,

View File

@@ -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> = (props) => {
required,
validate = text,
label,
minLength,
maxLength,
admin: {
placeholder,
readOnly,
@@ -25,9 +27,13 @@ const Text: React.FC<Props> = (props) => {
const path = pathFromProps || name;
const memoizedValidate = useCallback((value, options) => {
return validate(value, { ...options, minLength, maxLength, required });
}, [validate, minLength, maxLength, required]);
const field = useField<string>({
path,
validate,
validate: memoizedValidate,
enableDebouncedValue: true,
condition,
});

View File

@@ -13,6 +13,8 @@ const Textarea: React.FC<Props> = (props) => {
name,
required,
validate = textarea,
maxLength,
minLength,
admin: {
readOnly,
style,
@@ -24,16 +26,13 @@ const Textarea: React.FC<Props> = (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,

View File

@@ -53,6 +53,7 @@ const AddUploadModal: React.FC<Props> = (props) => {
action={`${serverURL}${api}/${collection.slug}`}
onSuccess={onSuccess}
disableSuccessStatus
validationOperation="create"
>
<header className={`${baseClass}__header`}>
<div>

View File

@@ -37,9 +37,8 @@ const Upload: React.FC<Props> = (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({

View File

@@ -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);

View File

@@ -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,6 +56,7 @@ const DefaultAccount: React.FC<Props> = (props) => {
<Loading />
)}
{!isLoading && (
<OperationContext.Provider value="update">
<Form
className={`${baseClass}__form`}
method="put"
@@ -85,7 +87,6 @@ const DefaultAccount: React.FC<Props> = (props) => {
operation="update"
/>
<RenderFields
operation="update"
permissions={permissions.fields}
readOnly={!hasSavePermission}
filter={(field) => field?.admin?.position !== 'sidebar'}
@@ -115,7 +116,6 @@ const DefaultAccount: React.FC<Props> = (props) => {
</div>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
operation="update"
permissions={permissions.fields}
readOnly={!hasSavePermission}
filter={(field) => field?.admin?.position === 'sidebar'}
@@ -164,6 +164,7 @@ const DefaultAccount: React.FC<Props> = (props) => {
</div>
</div>
</Form>
</OperationContext.Provider>
)}
</div>
);

View File

@@ -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 (
<NegativeFieldGutterProvider allow>

View File

@@ -65,6 +65,7 @@ const CreateFirstUser: React.FC<Props> = (props) => {
method="post"
redirect={admin}
action={`${serverURL}${api}/${userSlug}/first-register`}
validationOperation="create"
>
<NegativeFieldGutterProvider allow>
<RenderFields

View File

@@ -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,6 +52,7 @@ const DefaultGlobalView: React.FC<Props> = (props) => {
<Loading />
)}
{!isLoading && (
<OperationContext.Provider value="update">
<Form
className={`${baseClass}__form`}
method="post"
@@ -83,7 +85,6 @@ const DefaultGlobalView: React.FC<Props> = (props) => {
)}
</header>
<RenderFields
operation="update"
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => (!field.admin.position || (field.admin.position && field.admin.position !== 'sidebar'))}
@@ -137,7 +138,6 @@ const DefaultGlobalView: React.FC<Props> = (props) => {
</React.Fragment>
)}
<RenderFields
operation="update"
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => field.admin.position === 'sidebar'}
@@ -179,6 +179,7 @@ const DefaultGlobalView: React.FC<Props> = (props) => {
</div>
</div>
</Form>
</OperationContext.Provider>
)}
</div>
);

View File

@@ -17,7 +17,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
const { state: locationState } = useLocation<{data?: Record<string, unknown>}>();
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<IndexProps> = (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<IndexProps> = (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];

View File

@@ -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);

View File

@@ -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,6 +77,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
<Loading />
)}
{!isLoading && (
<OperationContext.Provider value={operation}>
<Form
className={`${baseClass}__form`}
method={id ? 'put' : 'post'}
@@ -83,6 +85,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
onSuccess={onSave}
disabled={!hasSavePermission}
initialState={initialState}
validationOperation={isEditing ? 'update' : 'create'}
>
<div className={`${baseClass}__main`}>
<Meta
@@ -117,7 +120,6 @@ const DefaultEditView: React.FC<Props> = (props) => {
/>
)}
<RenderFields
operation={operation}
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => (!field?.admin?.position || (field?.admin?.position !== 'sidebar'))}
@@ -190,7 +192,6 @@ const DefaultEditView: React.FC<Props> = (props) => {
</React.Fragment>
)}
<RenderFields
operation={isEditing ? 'update' : 'create'}
readOnly={!hasSavePermission}
permissions={permissions.fields}
filter={(field) => field?.admin?.position === 'sidebar'}
@@ -247,6 +248,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
</div>
</div>
</Form>
</OperationContext.Provider>
)}
</div>
);

View File

@@ -42,7 +42,7 @@ const EditView: React.FC<IndexProps> = (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<IndexProps> = (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<IndexProps> = (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 (

View File

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

View File

@@ -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');
});
});
});

View File

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

View File

@@ -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<T extends TypeWithID = any, P = any, S = any> = {
value?: P,
@@ -49,7 +51,15 @@ export type Labels = {
plural: string;
};
export type Validate<T = any> = (value?: T, options?: any) => string | true | Promise<string | true>;
export type ValidateOptions<T, S, F> = {
data: Partial<T>
siblingData: Partial<S>
id?: string | number
user?: Partial<User>
operation?: Operation
} & F;
export type Validate<T = any, S = any, F = any> = (value?: T, options?: ValidateOptions<F, S, Partial<F>>) => string | true | Promise<string | true>;
export type OptionObject = {
label: string

View File

@@ -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,
}));
}
}

View File

@@ -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<string, unknown>
existingData: Record<string, unknown>
data: Record<string, unknown>
fullData: Record<string, unknown>
originalDoc: Record<string, unknown>
fullOriginalDoc: Record<string, unknown>
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<string | boolean> => {
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({

View File

@@ -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<any, any, any> = {
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);
});
});

View File

@@ -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<unknown, unknown, NumberField> = (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<unknown, unknown, TextField> = (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<unknown, unknown, TextField> = (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<unknown, unknown, EmailField> = (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<unknown, unknown, TextareaField> = (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<unknown, unknown, CodeField> = (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<unknown, unknown, RichTextField> = (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<unknown, unknown, CheckboxField> = (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<unknown, unknown, DateField> = (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<unknown, unknown, UploadField> = (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<unknown, unknown, RelationshipField> = (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<unknown, unknown, ArrayField> = (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<unknown, unknown, SelectField> = (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<unknown, unknown, RadioField> = (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<unknown, unknown, BlockField> = (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<unknown, unknown, PointField> = (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,