diff --git a/demo/collections/RelationshipA.ts b/demo/collections/RelationshipA.ts index ba78729223..2f0c91f4c2 100644 --- a/demo/collections/RelationshipA.ts +++ b/demo/collections/RelationshipA.ts @@ -56,6 +56,24 @@ const RelationshipA: CollectionConfig = { hasMany: true, localized: true, }, + { + name: 'filterRelationship', + type: 'relationship', + relationTo: 'relationship-b', + filterOptions: { + disableRelation: { + not_equals: true, + }, + }, + }, + { + name: 'files', + type: 'upload', + relationTo: 'files', + filterOptions: { + type: { equals: 'Type 2' }, + }, + }, { name: 'demoHiddenField', type: 'text', diff --git a/demo/collections/RelationshipB.ts b/demo/collections/RelationshipB.ts index 969565cf13..8fd50cece7 100644 --- a/demo/collections/RelationshipB.ts +++ b/demo/collections/RelationshipB.ts @@ -17,6 +17,13 @@ const RelationshipB: CollectionConfig = { name: 'title', type: 'text', }, + { + name: 'disableRelation', // used on RelationshipA.filterRelationship field + type: 'checkbox', + admin: { + position: 'sidebar', + }, + }, { name: 'post', label: 'Post', diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 9906f22cc6..9f8fa40267 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -22,6 +22,7 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma | ---------------- | ----------- | | **`name`** * | To be used as the property name when stored and retrieved from the database. | | **`relationTo`** * | Provide one or many collection `slug`s to be able to assign relationships to. | +| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). | | **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. | | **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | | **`label`** | Used as a field label in the Admin panel and to name the generated GraphQL type. | @@ -44,6 +45,45 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma The Depth parameter can be used to automatically populate related documents that are returned by the API. +### Filtering relationship options + +Options can be dynamically limited by supply a query that is used for validating input and querying relationships in the UI. The `filterOptions` property can be a `Where` query or a function that returns one. When using a function, it will be called with an argument object with the following properties: + +| Property | Description | +| ------------- | -------------| +| `relationTo` | The `slug` of the collection of the items relation | +| `data` | An object of the full collection or global document | +| `siblingData` | An object of the document data limited to fields within the same parent to the field | +| `id` | The value of the collection `id`, will be `undefined` on create request | +| `user` | The currently authenticated user object | + +```js + const relationshipField = { + name: 'purchase', + type: 'relationship', + relationTo: ['products', 'services'], + filterOptions: ({relationTo, siblingData, }) => { + // returns a Where query dynamically by the type of relationship + if (relationTo === 'products') { + return { + 'product.stock': { is_greater_than: siblingData.quantity } + } + } + if (relationTo === 'services') { + return { + 'services.isAvailable': { equals: true } + } + } + }, + }; +``` + +You can learn more about writing queries [here](/docs/queries/overview). + + + When a relationship field has both `filterOptions` and `validate` the server side validation will not enforce `filterOptions` unless you call the relationship field validate imported from `payload/fields/validations` in the validate function. + + ### How the data is saved Given the variety of options possible within the `relationship` field type, the shape of the data needed for creating and updating these fields can vary. The following sections will describe the variety of data shapes that can arise from this field. diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 3ed2ee4e82..207f468966 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -27,6 +27,7 @@ keywords: upload, images media, fields, config, configuration, documentation, Co | ---------------- | ----------- | | **`name`** * | To be used as the property name when stored and retrieved from the database. | | **`*relationTo`** * | Provide a single collection `slug` to allow this field to accept a relation to. Note: the related collection must be configured to support Uploads. | +| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). | | **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | | **`label`** | Used as a field label in the Admin panel and to name the generated GraphQL type. | | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | diff --git a/src/admin/components/forms/Form/index.tsx b/src/admin/components/forms/Form/index.tsx index 93b2add275..6988a69305 100644 --- a/src/admin/components/forms/Form/index.tsx +++ b/src/admin/components/forms/Form/index.tsx @@ -368,6 +368,10 @@ const Form: React.FC = (props) => { refreshCookie(); }, 15000, [fields]); + useThrottledEffect(() => { + validateForm(); + }, 1000, [validateForm, fields]); + useEffect(() => { contextRef.current = { ...contextRef.current }; // triggers rerender of all components that subscribe to form setModified(false); diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index bb4d36a946..1b0e50fb0b 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useState, useReducer, } from 'react'; -import { useConfig } from '@payloadcms/config-provider'; +import equal from 'deep-equal'; +import { useAuth, useConfig } from '@payloadcms/config-provider'; import qs from 'qs'; import withCondition from '../../withCondition'; import ReactSelect from '../../../elements/ReactSelect'; @@ -13,11 +14,13 @@ import FieldDescription from '../../FieldDescription'; import { relationship } from '../../../../../fields/validations'; import { Where } from '../../../../../types'; import { PaginatedDocs } from '../../../../../mongoose/types'; -import { useFormProcessing } from '../../Form/context'; +import { useFormProcessing, useWatchForm } from '../../Form/context'; import optionsReducer from './optionsReducer'; import { Props, Option, ValueWithRelation, GetResults } from './types'; import { createRelationMap } from './createRelationMap'; import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback'; +import { useDocumentInfo } from '../../../utilities/DocumentInfo'; +import { getFilterOptionsQuery } from '../getFilterOptionsQuery'; import './index.scss'; @@ -34,6 +37,7 @@ const Relationship: React.FC = (props) => { required, label, hasMany, + filterOptions, admin: { readOnly, style, @@ -52,13 +56,16 @@ const Relationship: React.FC = (props) => { collections, } = useConfig(); + const { id } = useDocumentInfo(); + const { user } = useAuth(); + const { getData, getSiblingData } = useWatchForm(); const formProcessing = useFormProcessing(); - const hasMultipleRelations = Array.isArray(relationTo); const [options, dispatchOptions] = useReducer(optionsReducer, required || hasMany ? [] : [{ value: 'null', label: 'None' }]); const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1); const [lastLoadedPage, setLastLoadedPage] = useState(1); const [errorLoading, setErrorLoading] = useState(''); + const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>(); const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false); const [search, setSearch] = useState(''); @@ -107,9 +114,13 @@ const Relationship: React.FC = (props) => { where: Where } = { where: { - id: { - not_in: relationMap[relation], - }, + and: [ + { + id: { + not_in: relationMap[relation], + }, + }, + ], }, limit: maxResultsPerRequest, page: lastLoadedPageToUse, @@ -118,9 +129,15 @@ const Relationship: React.FC = (props) => { }; if (searchArg) { - query.where[fieldToSearch] = { - like: searchArg, - }; + query.where.and.push({ + [fieldToSearch]: { + like: searchArg, + }, + }); + } + + if (optionFilters[relation]) { + query.where.and.push(optionFilters[relation]); } const response = await fetch(`${serverURL}${api}/${relation}?${qs.stringify(query)}`); @@ -148,7 +165,7 @@ const Relationship: React.FC = (props) => { } }, Promise.resolve()); } - }, [api, collections, serverURL, errorLoading, relationTo, hasMany, hasMultipleRelations]); + }, [relationTo, hasMany, errorLoading, collections, optionFilters, serverURL, api, hasMultipleRelations]); const findOptionsByValue = useCallback((): Option | Option[] => { if (value) { @@ -262,11 +279,29 @@ const Relationship: React.FC = (props) => { }, [hasMany, hasMultipleRelations, relationTo, initialValue, hasLoadedValueOptions, errorLoading, collections, api, serverURL]); useEffect(() => { - setHasLoadedValueOptions(false); - getResults({ - value: initialValue, + if (!filterOptions) { + return; + } + const newOptionFilters = getFilterOptionsQuery(filterOptions, { + id, + data: getData(), + relationTo, + siblingData: getSiblingData(path), + user, }); - }, [initialValue, getResults]); + if (!equal(newOptionFilters, optionFilters)) { + setOptionFilters(newOptionFilters); + } + }, [relationTo, filterOptions, optionFilters, id, getData, getSiblingData, path, user]); + + useEffect(() => { + if (optionFilters) { + setHasLoadedValueOptions(false); + getResults({ + value: initialValue, + }); + } + }, [initialValue, getResults, optionFilters]); const classes = [ 'field-type', diff --git a/src/admin/components/forms/field-types/Upload/Input.tsx b/src/admin/components/forms/field-types/Upload/Input.tsx index 1e61eaa8de..4005985d31 100644 --- a/src/admin/components/forms/field-types/Upload/Input.tsx +++ b/src/admin/components/forms/field-types/Upload/Input.tsx @@ -5,7 +5,7 @@ import Label from '../../Label'; import Error from '../../Error'; import FileDetails from '../../../elements/FileDetails'; import FieldDescription from '../../FieldDescription'; -import { UploadField } from '../../../../../fields/config/types'; +import { FilterOptions, UploadField } from '../../../../../fields/config/types'; import { Description } from '../../FieldDescription/types'; import { FieldTypes } from '..'; import AddModal from './Add'; @@ -33,6 +33,7 @@ export type UploadInputProps = Omit & { collection?: SanitizedCollectionConfig serverURL?: string api?: string + filterOptions: FilterOptions } const UploadInput: React.FC = (props) => { @@ -54,6 +55,7 @@ const UploadInput: React.FC = (props) => { api = '/api', collection, errorMessage, + filterOptions, } = props; const { toggle } = useModal(); @@ -160,6 +162,8 @@ const UploadInput: React.FC = (props) => { slug: selectExistingModalSlug, setValue: onChange, addModalSlug, + filterOptions, + path, }} /> = (props) => { } = {}, } = {}, slug: modalSlug, + path, + filterOptions, } = props; const { serverURL, routes: { api } } = useConfig(); + const { id } = useDocumentInfo(); + const { user } = useAuth(); + const { getData, getSiblingData } = useWatchForm(); const { closeAll, currentModal } = useModal(); const [fields] = useState(() => formatFields(collection)); const [limit, setLimit] = useState(defaultLimit); const [sort, setSort] = useState(null); const [where, setWhere] = useState(null); const [page, setPage] = useState(null); + const [optionFilters, setOptionFilters] = useState(); const classes = [ baseClass, @@ -58,12 +69,29 @@ const SelectExistingUploadModal: React.FC = (props) => { } = {}; if (page) params.page = page; - if (where) params.where = where; + if (where) params.where = { and: [where, optionFilters] }; if (sort) params.sort = sort; if (limit) params.limit = limit; setParams(params); - }, [setParams, page, sort, where, limit]); + }, [setParams, page, sort, where, limit, optionFilters]); + + useEffect(() => { + if (!filterOptions || !isOpen) { + return; + } + + const newOptionFilters = getFilterOptionsQuery(filterOptions, { + id, + relationTo: collectionSlug, + data: getData(), + siblingData: getSiblingData(path), + user, + })[collectionSlug]; + if (!equal(newOptionFilters, optionFilters)) { + setOptionFilters(newOptionFilters); + } + }, [collectionSlug, filterOptions, optionFilters, id, getData, getSiblingData, path, user, isOpen]); return ( void collection: SanitizedCollectionConfig slug: string + path + filterOptions: FilterOptions } diff --git a/src/admin/components/forms/field-types/Upload/index.tsx b/src/admin/components/forms/field-types/Upload/index.tsx index ef42b8128f..c48ff9813f 100644 --- a/src/admin/components/forms/field-types/Upload/index.tsx +++ b/src/admin/components/forms/field-types/Upload/index.tsx @@ -33,6 +33,7 @@ const Upload: React.FC = (props) => { validate = upload, relationTo, fieldTypes, + filterOptions, } = props; const collection = collections.find((coll) => coll.slug === relationTo); @@ -82,6 +83,7 @@ const Upload: React.FC = (props) => { fieldTypes={fieldTypes} name={name} relationTo={relationTo} + filterOptions={filterOptions} /> ); } diff --git a/src/admin/components/forms/field-types/getFilterOptionsQuery.ts b/src/admin/components/forms/field-types/getFilterOptionsQuery.ts new file mode 100644 index 0000000000..69ca683f1b --- /dev/null +++ b/src/admin/components/forms/field-types/getFilterOptionsQuery.ts @@ -0,0 +1,14 @@ +import { Where } from '../../../../types'; +import { FilterOptions, FilterOptionsProps } from '../../../../fields/config/types'; + +export const getFilterOptionsQuery = (filterOptions: FilterOptions, options: FilterOptionsProps): {[collection: string]: Where } => { + const { relationTo } = options; + const relations = Array.isArray(relationTo) ? relationTo : [relationTo]; + const query = {}; + if (typeof filterOptions !== 'undefined') { + relations.forEach((relation) => { + query[relation] = typeof filterOptions === 'function' ? filterOptions(options) : filterOptions; + }); + } + return query; +}; diff --git a/src/admin/components/forms/useField/index.tsx b/src/admin/components/forms/useField/index.tsx index ee4c4cd3ed..d8706541ad 100644 --- a/src/admin/components/forms/useField/index.tsx +++ b/src/admin/components/forms/useField/index.tsx @@ -1,9 +1,12 @@ import { useCallback, useEffect, useState, } from 'react'; +import { useAuth } from '@payloadcms/config-provider'; import { useFormProcessing, useFormSubmitted, useFormModified, useForm } from '../Form/context'; import useDebounce from '../../../hooks/useDebounce'; import { Options, FieldType } from './types'; +import { useDocumentInfo } from '../../utilities/DocumentInfo'; +import { useOperation } from '../../utilities/OperationProvider'; const useField = (options: Options): FieldType => { const { @@ -19,10 +22,15 @@ const useField = (options: Options): FieldType => { const submitted = useFormSubmitted(); const processing = useFormProcessing(); const modified = useFormModified(); + const { user } = useAuth(); + const { id } = useDocumentInfo(); + const operation = useOperation(); const { dispatchFields, getField, + getData, + getSiblingData, setModified, } = formContext || {}; @@ -40,43 +48,6 @@ const useField = (options: Options): FieldType => { const valid = (field && typeof field.valid === 'boolean') ? field.valid : true; const showError = valid === false && submitted; - // Method to send update field values from field component(s) - // Should only be used internally - const sendField = useCallback(async (valueToSend) => { - const fieldToDispatch = { - path, - disableFormData, - ignoreWhileFlattening, - initialValue, - validate, - condition, - value: valueToSend, - valid: false, - errorMessage: undefined, - }; - - const validationResult = typeof validate === 'function' ? await validate(valueToSend) : true; - - if (typeof validationResult === 'string') { - fieldToDispatch.errorMessage = validationResult; - fieldToDispatch.valid = false; - } else { - fieldToDispatch.valid = validationResult; - } - - if (typeof dispatchFields === 'function') { - dispatchFields(fieldToDispatch); - } - }, [ - path, - dispatchFields, - validate, - disableFormData, - ignoreWhileFlattening, - initialValue, - condition, - ]); - // Method to return from `useField`, used to // update internal field values from field component(s) // as fast as they arrive. NOTE - this method is NOT debounced @@ -106,13 +77,38 @@ const useField = (options: Options): FieldType => { const valueToSend = enableDebouncedValue ? debouncedValue : internalValue; useEffect(() => { - if (field?.value !== valueToSend && valueToSend !== undefined) { - sendField(valueToSend); - } + const sendField = async () => { + if (field?.value !== valueToSend && valueToSend !== undefined) { + if (typeof dispatchFields === 'function') { + dispatchFields({ + ...field, + path, + disableFormData, + ignoreWhileFlattening, + initialValue, + validate, + condition, + value: valueToSend, + }); + } + } + }; + + sendField(); }, [ + condition, + disableFormData, + dispatchFields, + getData, + getSiblingData, + id, + ignoreWhileFlattening, + initialValue, + operation, path, + user, + validate, valueToSend, - sendField, field, ]); diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index adfc0cf48d..c2b948dbff 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -85,7 +85,6 @@ const DefaultEditView: React.FC = (props) => { onSuccess={onSave} disabled={!hasSavePermission} initialState={initialState} - validationOperation={isEditing ? 'update' : 'create'} >
{ expect(custom.doc.id).toBe(parseFloat(customID.id)); expect(doc.customID[0].id).toBe(parseFloat(customID.id)); }); + + it('should use filterOptions to limit relationship options', async () => { + // update documentB to disable relations + await fetch(`${url}/api/relationship-b/${documentB.id}`, { + headers, + body: JSON.stringify({ + disableRelation: true, + }), + method: 'put', + }); + + // attempt to save relationship to documentB + const response = await fetch(`${url}/api/relationship-a/${documentA.id}`, { + headers, + body: JSON.stringify({ + filterRelationship: documentB.id, + }), + method: 'put', + }); + + const result = await response.json(); + + expect(result.errors).toBeDefined(); + }); }); }); diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index 99db233dc1..96cd8adc3c 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -185,6 +185,10 @@ export const upload = baseField.keys({ relationTo: joi.string().required(), name: joi.string().required(), maxDepth: joi.number(), + filterOptions: joi.alternatives().try( + joi.object(), + joi.func(), + ), }); export const checkbox = baseField.keys({ @@ -208,6 +212,10 @@ export const relationship = baseField.keys({ ), name: joi.string().required(), maxDepth: joi.number(), + filterOptions: joi.alternatives().try( + joi.object(), + joi.func(), + ), }); export const blocks = baseField.keys({ diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index b25a1aa235..ae3086a471 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -1,7 +1,7 @@ /* eslint-disable no-use-before-define */ import { CSSProperties } from 'react'; import { Editor } from 'slate'; -import { Operation } from '../../types'; +import { Operation, Where } from '../../types'; import { TypeWithID } from '../../collections/config/types'; import { PayloadRequest } from '../../express/types'; import { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types'; @@ -29,6 +29,16 @@ export type FieldAccess = (args: { export type Condition = (data: Partial, siblingData: Partial

) => boolean; +export type FilterOptionsProps = { + id: string | number, + user: Partial, + data: unknown, + siblingData: unknown, + relationTo: string | string[], +} + +export type FilterOptions = Where | ((options: FilterOptionsProps) => Where); + type Admin = { position?: 'sidebar'; width?: string; @@ -183,6 +193,7 @@ export type UploadField = FieldBase & { type: 'upload' relationTo: string maxDepth?: number + filterOptions?: FilterOptions; } type CodeAdmin = Admin & { @@ -207,8 +218,19 @@ export type RelationshipField = FieldBase & { relationTo: string | string[]; hasMany?: boolean; maxDepth?: number; + filterOptions?: FilterOptions; } +export type ValueWithRelation = { + relationTo: string + value: string | number +} + +export type RelationshipValue = (string | number) + | (string | number)[] + | ValueWithRelation + | ValueWithRelation[] + type RichTextPlugin = (editor: Editor) => Editor; export type RichTextCustomElement = { diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts index 5a95aeecb4..fe6aa0b5a3 100644 --- a/src/fields/performFieldOperations.ts +++ b/src/fields/performFieldOperations.ts @@ -116,8 +116,8 @@ export default async function performFieldOperations(this: Payload, entityConfig const hookResults = hookPromises.map((promise) => promise()); await Promise.all(hookResults); - validationPromises.forEach((promise) => promise()); - await Promise.all(validationPromises); + const validationResults = validationPromises.map((promise) => promise()); + await Promise.all(validationResults); if (errors.length > 0) { throw new ValidationError(errors); diff --git a/src/fields/validations.ts b/src/fields/validations.ts index a8d31688f2..e084bd7717 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -3,12 +3,14 @@ import { ArrayField, BlockField, CheckboxField, - CodeField, DateField, + CodeField, + DateField, EmailField, NumberField, PointField, RadioField, RelationshipField, + RelationshipValue, RichTextField, SelectField, TextareaField, @@ -16,6 +18,9 @@ import { UploadField, Validate, } from './config/types'; +import { TypeWithID } from '../collections/config/types'; +import canUseDOM from '../utilities/canUseDOM'; +import payload from '../index'; const defaultMessage = 'This field is required.'; @@ -84,7 +89,11 @@ export const email: Validate = (value: string, { r return true; }; -export const textarea: Validate = (value: string, { required, maxLength, minLength }) => { +export const textarea: Validate = (value: string, { + required, + maxLength, + minLength, +}) => { if (value && maxLength && value.length > maxLength) { return `This value must be shorter than the max length of ${maxLength} characters.`; } @@ -143,14 +152,96 @@ export const date: Validate = (value, { required }) return true; }; -export const upload: Validate = (value: string, { required }) => { - if (value || !required) return true; - return defaultMessage; +const validateFilterOptions: Validate = async (value, { filterOptions, id, user, data, siblingData, relationTo }) => { + if (!canUseDOM && typeof filterOptions !== 'undefined' && value) { + const options: { + [collection: string]: (string | number)[] + } = {}; + + const collections = typeof relationTo === 'string' ? [relationTo] : relationTo; + const values = Array.isArray(value) ? value : [value]; + + await Promise.all(collections.map(async (collection) => { + const optionFilter = typeof filterOptions === 'function' ? filterOptions({ + id, + data, + siblingData, + user, + relationTo: collection, + }) : filterOptions; + + const valueIDs: (string | number)[] = []; + + values.forEach((val) => { + if (typeof val === 'object' && val?.value) { + valueIDs.push(val.value); + } + + if (typeof val === 'string' || typeof val === 'number') { + valueIDs.push(val); + } + }); + + const result = await payload.find({ + collection, + depth: 0, + where: { + and: [ + { id: { in: valueIDs } }, + optionFilter, + ], + }, + }); + + options[collection] = result.docs.map((doc) => doc.id); + })); + + const invalidRelationships = values.filter((val) => { + let collection: string; + let requestedID: string | number; + + if (typeof relationTo === 'string') { + collection = relationTo; + + if (typeof val === 'string' || typeof val === 'number') { + requestedID = val; + } + } + + if (Array.isArray(relationTo) && typeof val === 'object' && val?.relationTo) { + collection = val.relationTo; + requestedID = val.value; + } + + return options[collection].indexOf(requestedID) === -1; + }); + + if (invalidRelationships.length > 0) { + return invalidRelationships.reduce((err, invalid, i) => { + return `${err} ${JSON.stringify(invalid)}${invalidRelationships.length === i + 1 ? ',' : ''} `; + }, 'This field has the following invalid selections:') as string; + } + + return true; + } + + return true; }; -export const relationship: Validate = (value, { required }) => { - if (value || !required) return true; - return defaultMessage; +export const upload: Validate = (value: string, options) => { + if (!value && options.required) { + return defaultMessage; + } + + return validateFilterOptions(value, options); +}; + +export const relationship: Validate = async (value: RelationshipValue, options) => { + if ((!value || (Array.isArray(value) && value.length === 0)) && options.required) { + return defaultMessage; + } + + return validateFilterOptions(value, options); }; export const array: Validate = (value, { minRows, maxRows, required }) => { diff --git a/src/webpack/getBaseConfig.ts b/src/webpack/getBaseConfig.ts index def6a2fa97..50de8bddc8 100644 --- a/src/webpack/getBaseConfig.ts +++ b/src/webpack/getBaseConfig.ts @@ -56,6 +56,7 @@ export default (config: SanitizedConfig): Configuration => ({ 'payload-user-css': config.admin.css, 'payload-scss-overrides': config.admin.scss, dotenv: mockDotENVPath, + [path.resolve(__dirname, '../index')]: mockModulePath, }, extensions: ['.ts', '.tsx', '.js', '.json'], },