feat: dynamically populates richtext relationships

* feat: adds relationship field to test searchable input

* fix: searching on relationship fields properly fetches results

* chore: more dry relationship field

* feat: sets default access control to requiring a user to be logged in

* feat: dynamically populates richtext relationships

* feat: allows depth param in graphql richText field

* feat: ensures relationship input is initialized with up to 3 related collections
This commit is contained in:
James Mikrut
2021-04-18 15:29:54 -04:00
committed by GitHub
parent b86c3daa99
commit 353042467f
17 changed files with 259 additions and 35 deletions

View File

@@ -246,19 +246,44 @@ const Relationship: React.FC<Props> = (props) => {
useEffect(() => {
const getFirstResults = async () => {
const relation = relations[0];
const res = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&depth=0`);
const res = await fetch(`${serverURL}${api}/${relations[0]}?limit=${maxResultsPerRequest}&depth=0`);
if (res.ok) {
const data: PaginatedDocs = await res.json();
addOptions(data, relation);
addOptions(data, relations[0]);
if (!data.hasNextPage) {
setLastFullyLoadedRelation(relations.indexOf(relation));
} else {
setLastLoadedPage(2);
setLastFullyLoadedRelation(0);
if (relations[1]) {
const secondRes = await fetch(`${serverURL}${api}/${relations[1]}?limit=${maxResultsPerRequest}&depth=0`);
if (res.ok) {
const secondData: PaginatedDocs = await secondRes.json();
addOptions(secondData, relations[1]);
if (!secondData.hasNextPage) {
setLastFullyLoadedRelation(1);
if (relations[2]) {
const thirdRes = await fetch(`${serverURL}${api}/${relations[2]}?limit=${maxResultsPerRequest}&depth=0`);
if (res.ok) {
const thirdData: PaginatedDocs = await thirdRes.json();
addOptions(thirdData, relations[2]);
if (!thirdData.hasNextPage) {
setLastFullyLoadedRelation(2);
}
}
}
}
}
}
}
setHasLoadedFirstOptions(true);

View File

@@ -2,10 +2,8 @@ import React, { Fragment, useState, useEffect } from 'react';
import { useConfig, useAuth } from '@payloadcms/config-provider';
import { useWatchForm } from '../../../../../../Form/context';
import Relationship from '../../../../../Relationship';
import Number from '../../../../../Number';
import Select from '../../../../../Select';
const createOptions = (collections, permissions) => collections.reduce((options, collection) => {
if (permissions?.collections?.[collection.slug]?.read?.permission && collection?.admin?.enableRichTextRelationship) {
return [
@@ -21,7 +19,7 @@ const createOptions = (collections, permissions) => collections.reduce((options,
}, []);
const RelationshipFields = () => {
const { collections, maxDepth } = useConfig();
const { collections } = useConfig();
const { permissions } = useAuth();
const [options, setOptions] = useState(() => createOptions(collections, permissions));
@@ -49,13 +47,6 @@ const RelationshipFields = () => {
required
/>
)}
<Number
required
name="depth"
label="Depth"
min={0}
max={maxDepth}
/>
</Fragment>
);
};

View File

@@ -15,19 +15,16 @@ import { requests } from '../../../../../../../api';
import './index.scss';
const initialFormData = {
depth: 0,
};
const initialFormData = {};
const baseClass = 'relationship-rich-text-button';
const insertRelationship = (editor, { value, relationTo, depth }) => {
const insertRelationship = (editor, { value, relationTo }) => {
const text = { text: ' ' };
const relationship = {
type: 'relationship',
value,
depth,
relationTo,
children: [
text,
@@ -58,13 +55,13 @@ const RelationshipButton: React.FC<{path: string}> = ({ path }) => {
const [hasEnabledCollections] = useState(() => collections.find(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship));
const modalSlug = `${path}-add-relationship`;
const handleAddRelationship = useCallback(async (_, { relationTo, value, depth }) => {
const handleAddRelationship = useCallback(async (_, { relationTo, value }) => {
setLoading(true);
const res = await requests.get(`${serverURL}${api}/${relationTo}/${value}?depth=${depth}`);
const res = await requests.get(`${serverURL}${api}/${relationTo}/${value}?depth=0`);
const json = await res.json();
insertRelationship(editor, { value: json, depth, relationTo });
insertRelationship(editor, { value: { id: json.id }, relationTo });
closeAll();
setRenderModal(false);
setLoading(false);

View File

@@ -6,7 +6,7 @@
align-items: flex-start;
background: $color-background-gray;
max-width: base(15);
margin-bottom: $baseline;
margin-bottom: base(.5);
svg {
width: base(1.25);

View File

@@ -1,16 +1,26 @@
import React, { useState } from 'react';
import { useConfig } from '@payloadcms/config-provider';
import RelationshipIcon from '../../../../../../icons/Relationship';
import usePayloadAPI from '../../../../../../../hooks/usePayloadAPI';
import './index.scss';
const baseClass = 'rich-text-relationship';
const initialParams = {
depth: 0,
};
const Element = ({ attributes, children, element }) => {
const { relationTo, value } = element;
const { collections } = useConfig();
const { collections, serverURL, routes: { api } } = useConfig();
const [relatedCollection] = useState(() => collections.find((coll) => coll.slug === relationTo));
const [{ data }] = usePayloadAPI(
`${serverURL}${api}/${relatedCollection.slug}/${value?.id}`,
{ initialParams },
);
return (
<div
className={baseClass}
@@ -24,7 +34,7 @@ const Element = ({ attributes, children, element }) => {
{' '}
Relationship
</div>
<h5>{value[relatedCollection?.admin?.useAsTitle || 'id']}</h5>
<h5>{data[relatedCollection?.admin?.useAsTitle || 'id']}</h5>
</div>
{children}
</div>

View File

@@ -0,0 +1,3 @@
import { PayloadRequest } from '../express/types';
export default ({ req: { user } }: { req: PayloadRequest}): boolean => Boolean(user);

View File

@@ -1,8 +1,12 @@
import { PayloadRequest } from '../../express/types';
import defaultAccess from '../../auth/defaultAccess';
export const defaults = {
access: {
unlock: ({ req: { user } }: { req: PayloadRequest}): boolean => Boolean(user),
create: defaultAccess,
read: defaultAccess,
update: defaultAccess,
delete: defaultAccess,
unlock: defaultAccess,
},
timestamps: true,
admin: {

View File

@@ -57,6 +57,10 @@ async function findByID(incomingArgs: Arguments): Promise<Document> {
// /////////////////////////////////////
const accessResults = !overrideAccess ? await executeAccess({ req, disableErrors, id }, collectionConfig.access.read) : true;
// If errors are disabled, and access returns false, return null
if (accessResults === false) return null;
const hasWhereAccess = typeof accessResults === 'object';
const queryToBuild: { where: Where } = {

View File

@@ -3,7 +3,6 @@ import executeAccess from '../auth/executeAccess';
import { Field, RelationshipField, fieldSupportsMany } from './config/types';
import { Payload } from '..';
type PopulateArgs = {
depth: number
currentDepth: number

View File

@@ -0,0 +1,134 @@
import { Collection } from '../collections/config/types';
import { Payload } from '..';
import { RichTextField } from './config/types';
import { PayloadRequest } from '../express/types';
type Arguments = {
data: unknown
overrideAccess?: boolean
depth: number
currentDepth?: number
payload: Payload
field: RichTextField
req: PayloadRequest
}
type RecurseRichTextArgs = {
children: unknown[]
overrideAccess: boolean
depth: number
currentDepth: number
payload: Payload
field: RichTextField
req: PayloadRequest
promises: Promise<void>[]
}
const populate = async ({
id,
collection,
data,
overrideAccess,
depth,
currentDepth,
payload,
req,
}: Arguments & {
id: string,
collection: Collection
}) => {
const dataRef = data as Record<string, unknown>;
const doc = await payload.operations.collections.findByID({
req: {
...req,
payloadAPI: 'local',
},
collection,
id,
currentDepth: currentDepth + 1,
overrideAccess,
disableErrors: true,
depth,
});
if (doc) {
dataRef.value = doc;
} else {
dataRef.value = null;
}
};
const recurseRichText = ({
req,
children,
payload,
overrideAccess = false,
depth,
currentDepth = 0,
field,
promises,
}: RecurseRichTextArgs) => {
if (Array.isArray(children)) {
(children as any[]).forEach((element) => {
const collection = payload.collections[element?.relationTo];
if (element.type === 'relationship'
&& element?.value?.id
&& collection
&& (depth && currentDepth <= depth)) {
promises.push(populate({
req,
id: element.value.id,
data: element,
overrideAccess,
depth,
currentDepth,
payload,
field,
collection,
}));
}
if (element?.children) {
recurseRichText({
req,
children: element.children,
payload,
overrideAccess,
depth,
currentDepth,
field,
promises,
});
}
});
}
};
const richTextRelationshipPromise = ({
req,
data,
payload,
overrideAccess,
depth,
currentDepth,
field,
}: Arguments) => async (): Promise<void> => {
const promises = [];
recurseRichText({
req,
children: data[field.name],
payload,
overrideAccess,
depth,
currentDepth,
field,
promises,
});
await Promise.all(promises);
};
export default richTextRelationshipPromise;

View File

@@ -5,6 +5,7 @@ import { Field, fieldHasSubFields, fieldIsArrayType, fieldIsBlockType, HookName
import { Operation } from '../types';
import { PayloadRequest } from '../express/types';
import { Payload } from '..';
import richTextRelationshipPromise from './richTextRelationshipPromise';
type Arguments = {
fields: Field[]
@@ -91,8 +92,22 @@ const traverseFields = (args: Arguments): void => {
if (data[field.name] === '') dataCopy[field.name] = false;
}
if (field.type === 'richText' && typeof data[field.name] === 'string') {
dataCopy[field.name] = JSON.parse(data[field.name] as string);
if (field.type === 'richText') {
if (typeof data[field.name] === 'string') {
dataCopy[field.name] = JSON.parse(data[field.name] as string);
}
if ((field.admin?.elements?.includes('relationship') || !field?.admin?.elements) && hook === 'afterRead') {
relationshipPopulations.push(richTextRelationshipPromise({
req,
data,
payload,
overrideAccess,
depth,
field,
currentDepth,
}));
}
}
const hasLocalizedValue = (typeof data?.[field.name] === 'object' && data?.[field.name] !== null)

View File

@@ -2,6 +2,7 @@ import { toWords } from '../../utilities/formatLabels';
import { PayloadCollectionConfig } from '../../collections/config/types';
import sanitizeFields from '../../fields/config/sanitize';
import { PayloadGlobalConfig, GlobalConfig } from './types';
import defaultAccess from '../../auth/defaultAccess';
const sanitizeGlobals = (collections: PayloadCollectionConfig[], globals: PayloadGlobalConfig[]): GlobalConfig[] => {
const sanitizedGlobals = globals.map((global) => {
@@ -17,6 +18,9 @@ const sanitizeGlobals = (collections: PayloadCollectionConfig[], globals: Payloa
if (!sanitizedGlobal.access) sanitizedGlobal.access = {};
if (!sanitizedGlobal.admin) sanitizedGlobal.admin = {};
if (!sanitizedGlobal.access.read) sanitizedGlobal.access.read = defaultAccess;
if (!sanitizedGlobal.access.update) sanitizedGlobal.access.update = defaultAccess;
if (!sanitizedGlobal.hooks.beforeValidate) sanitizedGlobal.hooks.beforeValidate = [];
if (!sanitizedGlobal.hooks.beforeChange) sanitizedGlobal.hooks.beforeChange = [];
if (!sanitizedGlobal.hooks.afterChange) sanitizedGlobal.hooks.afterChange = [];

View File

@@ -13,12 +13,13 @@ import {
GraphQLUnionType,
} from 'graphql';
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars';
import { Field, RadioField, RelationshipField, SelectField, UploadField, optionIsObject, ArrayField, GroupField, BlockField, RowField } from '../../fields/config/types';
import { Field, RadioField, RelationshipField, SelectField, UploadField, optionIsObject, ArrayField, GroupField, RichTextField } from '../../fields/config/types';
import formatName from '../utilities/formatName';
import combineParentName from '../utilities/combineParentName';
import withNullableType from './withNullableType';
import { BaseFields } from '../../collections/graphql/types';
import { toWords } from '../../utilities/formatLabels';
import createRichTextRelationshipPromise from '../../fields/richTextRelationshipPromise';
type LocaleInputType = {
locale: {
@@ -40,9 +41,31 @@ function buildObjectType(name: string, fields: Field[], parentName: string, base
text: (field: Field) => ({ type: withNullableType(field, GraphQLString) }),
email: (field: Field) => ({ type: withNullableType(field, EmailAddressResolver) }),
textarea: (field: Field) => ({ type: withNullableType(field, GraphQLString) }),
richText: (field: Field) => ({ type: withNullableType(field, GraphQLJSON) }),
code: (field: Field) => ({ type: withNullableType(field, GraphQLString) }),
date: (field: Field) => ({ type: withNullableType(field, DateTimeResolver) }),
richText: (field: RichTextField) => ({
type: withNullableType(field, GraphQLJSON),
async resolve(parent, args, context) {
if (args.depth > 0) {
const richTextRelationshipPromise = createRichTextRelationshipPromise({
req: context.req,
data: parent,
payload: context.req.payload,
depth: args.depth,
field,
});
await richTextRelationshipPromise();
}
return parent[field.name];
},
args: {
depth: {
type: GraphQLInt,
},
},
}),
upload: (field: UploadField) => {
const { relationTo, label } = field;