merge: relationship options filter

This commit is contained in:
James
2022-04-05 14:59:01 -04:00
19 changed files with 368 additions and 71 deletions

View File

@@ -368,6 +368,10 @@ const Form: React.FC<Props> = (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);

View File

@@ -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> = (props) => {
required,
label,
hasMany,
filterOptions,
admin: {
readOnly,
style,
@@ -52,13 +56,16 @@ const Relationship: React.FC<Props> = (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> = (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> = (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> = (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> = (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',

View File

@@ -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<UploadField, 'type'> & {
collection?: SanitizedCollectionConfig
serverURL?: string
api?: string
filterOptions: FilterOptions
}
const UploadInput: React.FC<UploadInputProps> = (props) => {
@@ -54,6 +55,7 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
api = '/api',
collection,
errorMessage,
filterOptions,
} = props;
const { toggle } = useModal();
@@ -160,6 +162,8 @@ const UploadInput: React.FC<UploadInputProps> = (props) => {
slug: selectExistingModalSlug,
setValue: onChange,
addModalSlug,
filterOptions,
path,
}}
/>
<FieldDescription

View File

@@ -1,6 +1,8 @@
import React, { Fragment, useState, useEffect } from 'react';
import equal from 'deep-equal';
import { Modal, useModal } from '@faceless-ui/modal';
import { useConfig } from '@payloadcms/config-provider';
import { useAuth, useConfig } from '@payloadcms/config-provider';
import { Where } from '../../../../../../types';
import MinimalTemplate from '../../../../templates/Minimal';
import Button from '../../../../elements/Button';
import usePayloadAPI from '../../../../../hooks/usePayloadAPI';
@@ -12,6 +14,9 @@ import PerPage from '../../../../elements/PerPage';
import formatFields from '../../../../views/collections/List/formatFields';
import './index.scss';
import { getFilterOptionsQuery } from '../../getFilterOptionsQuery';
import { useDocumentInfo } from '../../../../utilities/DocumentInfo';
import { useWatchForm } from '../../../Form/context';
const baseClass = 'select-existing-upload-modal';
@@ -29,15 +34,21 @@ const SelectExistingUploadModal: React.FC<Props> = (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<Where>();
const classes = [
baseClass,
@@ -58,12 +69,29 @@ const SelectExistingUploadModal: React.FC<Props> = (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 (
<Modal

View File

@@ -1,7 +1,10 @@
import { SanitizedCollectionConfig } from '../../../../../../collections/config/types';
import { FilterOptions } from '../../../../../../fields/config/types';
export type Props = {
setValue: (val: { id: string } | null) => void
collection: SanitizedCollectionConfig
slug: string
path
filterOptions: FilterOptions
}

View File

@@ -33,6 +33,7 @@ const Upload: React.FC<Props> = (props) => {
validate = upload,
relationTo,
fieldTypes,
filterOptions,
} = props;
const collection = collections.find((coll) => coll.slug === relationTo);
@@ -82,6 +83,7 @@ const Upload: React.FC<Props> = (props) => {
fieldTypes={fieldTypes}
name={name}
relationTo={relationTo}
filterOptions={filterOptions}
/>
);
}

View File

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

View File

@@ -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 = <T extends unknown>(options: Options): FieldType<T> => {
const {
@@ -19,10 +22,15 @@ const useField = <T extends unknown>(options: Options): FieldType<T> => {
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 = <T extends unknown>(options: Options): FieldType<T> => {
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 = <T extends unknown>(options: Options): FieldType<T> => {
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,
]);

View File

@@ -85,7 +85,6 @@ const DefaultEditView: React.FC<Props> = (props) => {
onSuccess={onSave}
disabled={!hasSavePermission}
initialState={initialState}
validationOperation={isEditing ? 'update' : 'create'}
>
<div className={`${baseClass}__main`}>
<Meta

View File

@@ -189,5 +189,29 @@ describe('Collections - REST', () => {
expect(custom.doc.id).toBe(parseFloat(customID.id));
expect(doc.customID[0].id).toBe(parseFloat(customID.id));
});
it('should use filterOptions to limit relationship options', async () => {
// update documentB to disable relations
await fetch(`${url}/api/relationship-b/${documentB.id}`, {
headers,
body: JSON.stringify({
disableRelation: true,
}),
method: 'put',
});
// attempt to save relationship to documentB
const response = await fetch(`${url}/api/relationship-a/${documentA.id}`, {
headers,
body: JSON.stringify({
filterRelationship: documentB.id,
}),
method: 'put',
});
const result = await response.json();
expect(result.errors).toBeDefined();
});
});
});

View File

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

View File

@@ -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<T extends TypeWithID = any, P = any> = (args: {
export type Condition<T extends TypeWithID = any, P = any> = (data: Partial<T>, siblingData: Partial<P>) => boolean;
export type FilterOptionsProps = {
id: string | number,
user: Partial<User>,
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 = {

View File

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

View File

@@ -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<unknown, unknown, EmailField> = (value: string, { r
return true;
};
export const textarea: Validate<unknown, unknown, TextareaField> = (value: string, { required, maxLength, minLength }) => {
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.`;
}
@@ -143,14 +152,96 @@ export const date: Validate<unknown, unknown, DateField> = (value, { required })
return true;
};
export const upload: Validate<unknown, unknown, UploadField> = (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<TypeWithID>({
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<unknown, unknown, RelationshipField> = (value, { required }) => {
if (value || !required) return true;
return defaultMessage;
export const upload: Validate<unknown, unknown, UploadField> = (value: string, options) => {
if (!value && options.required) {
return defaultMessage;
}
return validateFilterOptions(value, options);
};
export const relationship: Validate<unknown, unknown, RelationshipField> = async (value: RelationshipValue, options) => {
if ((!value || (Array.isArray(value) && value.length === 0)) && options.required) {
return defaultMessage;
}
return validateFilterOptions(value, options);
};
export const array: Validate<unknown, unknown, ArrayField> = (value, { minRows, maxRows, required }) => {

View File

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