Merge branch 'master' of github.com:payloadcms/payload into feat/revisions

This commit is contained in:
James
2021-11-23 14:32:26 -05:00
41 changed files with 1028 additions and 215 deletions

View File

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

View File

@@ -17,4 +17,5 @@ export type Props = {
isDisabled?: boolean
onInputChange?: (val: string) => void
onMenuScrollToBottom?: () => void
placeholder?: string
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,7 +100,7 @@ const fieldTypeConditions = {
operators: [...base],
},
relationship: {
component: 'Text',
component: 'Relationship',
operators: [...base],
},
select: {

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
.field-type.email {
margin-bottom: $baseline;
position: relative;
input {
@include formInput;

View File

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

View File

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

View File

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

View File

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