Merge branch 'master' of github.com:payloadcms/payload into feat/revisions
This commit is contained in:
@@ -13,6 +13,7 @@ const ReactSelect: React.FC<Props> = (props) => {
|
||||
onChange,
|
||||
value,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
} = props;
|
||||
|
||||
const classes = [
|
||||
@@ -23,6 +24,7 @@ const ReactSelect: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -17,4 +17,5 @@ export type Props = {
|
||||
isDisabled?: boolean
|
||||
onInputChange?: (val: string) => void
|
||||
onMenuScrollToBottom?: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@import '../../../../../scss/styles.scss';
|
||||
|
||||
.condition-value-relationship {
|
||||
&__error-loading {
|
||||
border: 1px solid $color-red;
|
||||
min-height: base(2);
|
||||
padding: base(.5) base(.75);
|
||||
background-color: $color-red;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useReducer, useState, useCallback, useEffect } from 'react';
|
||||
import { useConfig } from '@payloadcms/config-provider';
|
||||
import { Props, Option, ValueWithRelation } from './types';
|
||||
import optionsReducer from './optionsReducer';
|
||||
import useDebounce from '../../../../../hooks/useDebounce';
|
||||
import ReactSelect from '../../../ReactSelect';
|
||||
import { Value } from '../../../ReactSelect/types';
|
||||
import { PaginatedDocs } from '../../../../../../collections/config/types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'condition-value-relationship';
|
||||
|
||||
const maxResultsPerRequest = 10;
|
||||
|
||||
const RelationshipField: React.FC<Props> = (props) => {
|
||||
const { onChange, value, relationTo, hasMany } = props;
|
||||
|
||||
const {
|
||||
serverURL,
|
||||
routes: {
|
||||
api,
|
||||
},
|
||||
collections,
|
||||
} = useConfig();
|
||||
|
||||
const hasMultipleRelations = Array.isArray(relationTo);
|
||||
const [options, dispatchOptions] = useReducer(optionsReducer, []);
|
||||
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1);
|
||||
const [lastLoadedPage, setLastLoadedPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [errorLoading, setErrorLoading] = useState('');
|
||||
const [hasLoadedFirstOptions, setHasLoadedFirstOptions] = useState(false);
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const addOptions = useCallback((data, relation) => {
|
||||
const collection = collections.find((coll) => coll.slug === relation);
|
||||
dispatchOptions({ type: 'ADD', data, relation, hasMultipleRelations, collection });
|
||||
}, [collections, hasMultipleRelations]);
|
||||
|
||||
const getResults = useCallback(async ({
|
||||
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
|
||||
lastLoadedPage: lastLoadedPageArg,
|
||||
search: searchArg,
|
||||
}) => {
|
||||
let lastLoadedPageToUse = typeof lastLoadedPageArg !== 'undefined' ? lastLoadedPageArg : 1;
|
||||
const lastFullyLoadedRelationToUse = typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1;
|
||||
|
||||
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
|
||||
const relationsToFetch = lastFullyLoadedRelationToUse === -1 ? relations : relations.slice(lastFullyLoadedRelationToUse + 1);
|
||||
|
||||
let resultsFetched = 0;
|
||||
|
||||
if (!errorLoading) {
|
||||
relationsToFetch.reduce(async (priorRelation, relation) => {
|
||||
await priorRelation;
|
||||
|
||||
if (resultsFetched < 10) {
|
||||
const collection = collections.find((coll) => coll.slug === relation);
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
|
||||
const searchParam = searchArg ? `&where[${fieldToSearch}][like]=${searchArg}` : '';
|
||||
|
||||
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data: PaginatedDocs = await response.json();
|
||||
if (data.docs.length > 0) {
|
||||
resultsFetched += data.docs.length;
|
||||
addOptions(data, relation);
|
||||
setLastLoadedPage(data.page);
|
||||
|
||||
if (!data.nextPage) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation));
|
||||
|
||||
// If there are more relations to search, need to reset lastLoadedPage to 1
|
||||
// both locally within function and state
|
||||
if (relations.indexOf(relation) + 1 < relations.length) {
|
||||
lastLoadedPageToUse = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setErrorLoading('An error has occurred.');
|
||||
}
|
||||
}
|
||||
}, Promise.resolve());
|
||||
}
|
||||
}, [addOptions, api, collections, serverURL, errorLoading, relationTo]);
|
||||
|
||||
const findOptionsByValue = useCallback((): Option | Option[] => {
|
||||
if (value) {
|
||||
if (hasMany) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((val) => {
|
||||
if (hasMultipleRelations) {
|
||||
let matchedOption: Option;
|
||||
|
||||
options.forEach((opt) => {
|
||||
if (opt.options) {
|
||||
opt.options.some((subOpt) => {
|
||||
if (subOpt?.value === val.value) {
|
||||
matchedOption = subOpt;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return matchedOption;
|
||||
}
|
||||
|
||||
return options.find((opt) => opt.value === val);
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (hasMultipleRelations) {
|
||||
let matchedOption: Option;
|
||||
|
||||
const valueWithRelation = value as ValueWithRelation;
|
||||
|
||||
options.forEach((opt) => {
|
||||
if (opt?.options) {
|
||||
opt.options.some((subOpt) => {
|
||||
if (subOpt?.value === valueWithRelation.value) {
|
||||
matchedOption = subOpt;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return matchedOption;
|
||||
}
|
||||
|
||||
return options.find((opt) => opt.value === value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [hasMany, hasMultipleRelations, value, options]);
|
||||
|
||||
const handleInputChange = useCallback((newSearch) => {
|
||||
if (search !== newSearch) {
|
||||
setSearch(newSearch);
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const addOptionByID = useCallback(async (id, relation) => {
|
||||
if (!errorLoading && id !== 'null') {
|
||||
const response = await fetch(`${serverURL}${api}/${relation}/${id}?depth=0`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
addOptions({ docs: [data] }, relation);
|
||||
} else {
|
||||
console.error(`There was a problem loading the document with ID of ${id}.`);
|
||||
}
|
||||
}
|
||||
}, [addOptions, api, errorLoading, serverURL]);
|
||||
|
||||
// ///////////////////////////
|
||||
// Get results when search input changes
|
||||
// ///////////////////////////
|
||||
|
||||
useEffect(() => {
|
||||
dispatchOptions({
|
||||
type: 'CLEAR',
|
||||
required: true,
|
||||
});
|
||||
|
||||
setHasLoadedFirstOptions(true);
|
||||
setLastLoadedPage(1);
|
||||
setLastFullyLoadedRelation(-1);
|
||||
getResults({ search: debouncedSearch });
|
||||
}, [getResults, debouncedSearch, relationTo]);
|
||||
|
||||
// ///////////////////////////
|
||||
// Format options once first options have been retrieved
|
||||
// ///////////////////////////
|
||||
|
||||
useEffect(() => {
|
||||
if (value && hasLoadedFirstOptions) {
|
||||
if (hasMany) {
|
||||
const matchedOptions = findOptionsByValue();
|
||||
|
||||
(matchedOptions as Value[] || []).forEach((option, i) => {
|
||||
if (!option) {
|
||||
if (hasMultipleRelations) {
|
||||
addOptionByID(value[i].value, value[i].relationTo);
|
||||
} else {
|
||||
addOptionByID(value[i], relationTo);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const matchedOption = findOptionsByValue();
|
||||
|
||||
if (!matchedOption) {
|
||||
if (hasMultipleRelations) {
|
||||
const valueWithRelation = value as ValueWithRelation;
|
||||
addOptionByID(valueWithRelation.value, valueWithRelation.relationTo);
|
||||
} else {
|
||||
addOptionByID(value, relationTo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [addOptionByID, findOptionsByValue, hasMany, hasMultipleRelations, relationTo, value, hasLoadedFirstOptions]);
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
errorLoading && 'error-loading',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const valueToRender = (findOptionsByValue() || value) as Value;
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{!errorLoading && (
|
||||
<ReactSelect
|
||||
placeholder="Select a value"
|
||||
onInputChange={handleInputChange}
|
||||
onChange={(selected) => {
|
||||
if (hasMany) {
|
||||
onChange(selected ? selected.map((option) => {
|
||||
if (hasMultipleRelations) {
|
||||
return {
|
||||
relationTo: option.relationTo,
|
||||
value: option.value,
|
||||
};
|
||||
}
|
||||
|
||||
return option.value;
|
||||
}) : null);
|
||||
} else if (hasMultipleRelations) {
|
||||
onChange({
|
||||
relationTo: selected.relationTo,
|
||||
value: selected.value,
|
||||
});
|
||||
} else {
|
||||
onChange(selected.value);
|
||||
}
|
||||
}}
|
||||
onMenuScrollToBottom={() => {
|
||||
getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 });
|
||||
}}
|
||||
value={valueToRender}
|
||||
options={options}
|
||||
isMulti={hasMany}
|
||||
/>
|
||||
)}
|
||||
{errorLoading && (
|
||||
<div className={`${baseClass}__error-loading`}>
|
||||
{errorLoading}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelationshipField;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Option, Action } from './types';
|
||||
|
||||
const reduceToIDs = (options) => options.reduce((ids, option) => {
|
||||
if (option.options) {
|
||||
return [
|
||||
...ids,
|
||||
...reduceToIDs(option.options),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...ids,
|
||||
option.id,
|
||||
];
|
||||
}, []);
|
||||
|
||||
const optionsReducer = (state: Option[], action: Action): Option[] => {
|
||||
switch (action.type) {
|
||||
case 'CLEAR': {
|
||||
return action.required ? [] : [{ value: 'null', label: 'None' }];
|
||||
}
|
||||
|
||||
case 'ADD': {
|
||||
const { hasMultipleRelations, collection, relation, data } = action;
|
||||
|
||||
const labelKey = collection.admin.useAsTitle || 'id';
|
||||
|
||||
const loadedIDs = reduceToIDs(state);
|
||||
|
||||
if (!hasMultipleRelations) {
|
||||
return [
|
||||
...state,
|
||||
...data.docs.reduce((docs, doc) => {
|
||||
if (loadedIDs.indexOf(doc.id) === -1) {
|
||||
loadedIDs.push(doc.id);
|
||||
return [
|
||||
...docs,
|
||||
{
|
||||
label: doc[labelKey],
|
||||
value: doc.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
return docs;
|
||||
}, []),
|
||||
];
|
||||
}
|
||||
|
||||
const newOptions = [...state];
|
||||
const optionsToAddTo = newOptions.find((optionGroup) => optionGroup.label === collection.labels.plural);
|
||||
|
||||
const newSubOptions = data.docs.reduce((docs, doc) => {
|
||||
if (loadedIDs.indexOf(doc.id) === -1) {
|
||||
loadedIDs.push(doc.id);
|
||||
|
||||
return [
|
||||
...docs,
|
||||
{
|
||||
label: doc[labelKey],
|
||||
relationTo: relation,
|
||||
value: doc.id,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return docs;
|
||||
}, []);
|
||||
|
||||
if (optionsToAddTo) {
|
||||
optionsToAddTo.options = [
|
||||
...optionsToAddTo.options,
|
||||
...newSubOptions,
|
||||
];
|
||||
} else {
|
||||
newOptions.push({
|
||||
label: collection.labels.plural,
|
||||
options: newSubOptions,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return newOptions;
|
||||
}
|
||||
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default optionsReducer;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { RelationshipField } from '../../../../../../fields/config/types';
|
||||
import { PaginatedDocs, SanitizedCollectionConfig } from '../../../../../../collections/config/types';
|
||||
|
||||
export type Props = {
|
||||
onChange: (val: unknown) => void,
|
||||
value: unknown,
|
||||
} & RelationshipField
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
value: string
|
||||
relationTo?: string
|
||||
options?: Option[]
|
||||
}
|
||||
|
||||
type CLEAR = {
|
||||
type: 'CLEAR'
|
||||
required: boolean
|
||||
}
|
||||
|
||||
type ADD = {
|
||||
type: 'ADD'
|
||||
data: PaginatedDocs
|
||||
relation: string
|
||||
hasMultipleRelations: boolean
|
||||
collection: SanitizedCollectionConfig
|
||||
}
|
||||
|
||||
export type Action = CLEAR | ADD
|
||||
|
||||
export type ValueWithRelation = {
|
||||
relationTo: string
|
||||
value: string
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Button from '../../Button';
|
||||
import Date from './Date';
|
||||
import Number from './Number';
|
||||
import Text from './Text';
|
||||
import Relationship from './Relationship';
|
||||
import useDebounce from '../../../../hooks/useDebounce';
|
||||
import { FieldCondition } from '../types';
|
||||
|
||||
@@ -15,6 +16,7 @@ const valueFields = {
|
||||
Date,
|
||||
Number,
|
||||
Text,
|
||||
Relationship,
|
||||
};
|
||||
|
||||
const baseClass = 'condition';
|
||||
@@ -93,6 +95,7 @@ const Condition: React.FC<Props> = (props) => {
|
||||
DefaultComponent={ValueComponent}
|
||||
componentProps={{
|
||||
...activeField?.props,
|
||||
operator: operatorValue,
|
||||
value: internalValue,
|
||||
onChange: setInternalValue,
|
||||
}}
|
||||
|
||||
@@ -100,7 +100,7 @@ const fieldTypeConditions = {
|
||||
operators: [...base],
|
||||
},
|
||||
relationship: {
|
||||
component: 'Text',
|
||||
component: 'Relationship',
|
||||
operators: [...base],
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
|
||||
export type DescriptionFunction = (value: unknown) => string
|
||||
|
||||
export type DescriptionComponent = React.ComponentType<{value: unknown}>
|
||||
export type DescriptionComponent = React.ComponentType<{ value: unknown }>
|
||||
|
||||
type Description = string | DescriptionFunction | DescriptionComponent
|
||||
export type Description = string | DescriptionFunction | DescriptionComponent
|
||||
|
||||
export type Props = {
|
||||
description?: Description
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Data } from '../../Form/types';
|
||||
import { ArrayField, Labels, Field, Description } from '../../../../../fields/config/types';
|
||||
import { ArrayField, Labels, Field } from '../../../../../fields/config/types';
|
||||
import { FieldTypes } from '..';
|
||||
import { FieldPermissions } from '../../../../../auth/types';
|
||||
import { Description } from '../../FieldDescription/types';
|
||||
|
||||
export type Props = Omit<ArrayField, 'type'> & {
|
||||
path?: string
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Data } from '../../Form/types';
|
||||
import { BlockField, Labels, Block, Description } from '../../../../../fields/config/types';
|
||||
import { BlockField, Labels, Block } from '../../../../../fields/config/types';
|
||||
import { FieldTypes } from '..';
|
||||
import { FieldPermissions } from '../../../../../auth/types';
|
||||
import { Description } from '../../FieldDescription/types';
|
||||
|
||||
export type Props = Omit<BlockField, 'type'> & {
|
||||
path?: string
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
.field-type.email {
|
||||
margin-bottom: $baseline;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
@include formInput;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Description, Validate } from '../../../../../fields/config/types';
|
||||
import { Validate } from '../../../../../fields/config/types';
|
||||
import { Description } from '../../FieldDescription/types';
|
||||
|
||||
export type Props = {
|
||||
autoComplete?: string
|
||||
|
||||
@@ -85,13 +85,12 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
|
||||
lastLoadedPage: lastLoadedPageArg,
|
||||
search: searchArg,
|
||||
} = {
|
||||
lastFullyLoadedRelation: -1,
|
||||
lastLoadedPage: 1,
|
||||
search: '',
|
||||
}) => {
|
||||
let lastLoadedPageToUse = typeof lastLoadedPageArg !== 'undefined' ? lastLoadedPageArg : 1;
|
||||
const lastFullyLoadedRelationToUse = typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1;
|
||||
|
||||
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
|
||||
const relationsToFetch = lastFullyLoadedRelationArg === -1 ? relations : relations.slice(lastFullyLoadedRelationArg);
|
||||
const relationsToFetch = lastFullyLoadedRelationToUse === -1 ? relations : relations.slice(lastFullyLoadedRelationToUse + 1);
|
||||
|
||||
let resultsFetched = 0;
|
||||
|
||||
@@ -104,7 +103,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
|
||||
const searchParam = searchArg ? `&where[${fieldToSearch}][like]=${searchArg}` : '';
|
||||
|
||||
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageArg}&depth=0${searchParam}`);
|
||||
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data: PaginatedDocs = await response.json();
|
||||
@@ -115,6 +114,12 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
|
||||
if (!data.nextPage) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation));
|
||||
|
||||
// If there are more relations to search, need to reset lastLoadedPage to 1
|
||||
// both locally within function and state
|
||||
if (relations.indexOf(relation) + 1 < relations.length) {
|
||||
lastLoadedPageToUse = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -201,15 +206,6 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
}
|
||||
}, [addOptions, api, errorLoading, serverURL]);
|
||||
|
||||
// ///////////////////////////
|
||||
// Get first results
|
||||
// ///////////////////////////
|
||||
|
||||
useEffect(() => {
|
||||
getResults();
|
||||
setHasLoadedFirstOptions(true);
|
||||
}, [addOptions, api, required, relationTo, serverURL, getResults]);
|
||||
|
||||
// ///////////////////////////
|
||||
// Get results when search input changes
|
||||
// ///////////////////////////
|
||||
@@ -220,6 +216,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
required,
|
||||
});
|
||||
|
||||
setHasLoadedFirstOptions(true);
|
||||
setLastLoadedPage(1);
|
||||
setLastFullyLoadedRelation(-1);
|
||||
getResults({ search: debouncedSearch });
|
||||
@@ -311,7 +308,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
}
|
||||
} : undefined}
|
||||
onMenuScrollToBottom={() => {
|
||||
getResults({ lastFullyLoadedRelation: lastFullyLoadedRelation + 1, lastLoadedPage });
|
||||
getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 });
|
||||
}}
|
||||
value={valueToRender}
|
||||
showError={showError}
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
|
||||
.rich-text-link__button {
|
||||
@extend %btn-reset;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -31,7 +31,7 @@ const RelationshipCell = (props) => {
|
||||
const doc = hasManyRelations ? cellData.value : cellData;
|
||||
const collection = collections.find((coll) => coll.slug === relation);
|
||||
|
||||
if (collection) {
|
||||
if (collection && doc) {
|
||||
const useAsTitle = collection.admin.useAsTitle ? collection.admin.useAsTitle : 'id';
|
||||
|
||||
setData(doc[useAsTitle]);
|
||||
|
||||
Reference in New Issue
Block a user