* feat: optimize collection list relationship queries (#749)
* feat: optimize collection list relationship queries
This commit is contained in:
@@ -26,7 +26,10 @@ const Table: React.FC<Props> = ({ columns, data }) => {
|
|||||||
{data && data.map((row, rowIndex) => (
|
{data && data.map((row, rowIndex) => (
|
||||||
<tr key={rowIndex}>
|
<tr key={rowIndex}>
|
||||||
{columns.map((col, colIndex) => (
|
{columns.map((col, colIndex) => (
|
||||||
<td key={colIndex}>
|
<td
|
||||||
|
key={colIndex}
|
||||||
|
className={col.accessor}
|
||||||
|
>
|
||||||
{col.components.renderCell(row, row[col.accessor])}
|
{col.components.renderCell(row, row[col.accessor])}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.relationship-cell {
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
@@ -1,56 +1,67 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useConfig } from '../../../../../../utilities/Config';
|
import { useConfig } from '../../../../../../utilities/Config';
|
||||||
|
import useIntersect from '../../../../../../../hooks/useIntersect';
|
||||||
|
import { useListRelationships } from '../../../RelationshipProvider';
|
||||||
|
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
type Value = { relationTo: string, value: number | string };
|
||||||
|
const baseClass = 'relationship-cell';
|
||||||
|
|
||||||
const RelationshipCell = (props) => {
|
const RelationshipCell = (props) => {
|
||||||
const { field, data: cellData } = props;
|
const { field, data: cellData } = props;
|
||||||
const { relationTo } = field;
|
const { collections, routes } = useConfig();
|
||||||
const { collections } = useConfig();
|
const [intersectionRef, entry] = useIntersect();
|
||||||
const [data, setData] = useState();
|
const [values, setValues] = useState<Value[]>([]);
|
||||||
|
const { getRelationships, documents } = useListRelationships();
|
||||||
|
const [hasRequested, setHasRequested] = useState(false);
|
||||||
|
|
||||||
|
const isAboveViewport = entry?.boundingClientRect?.top > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasManyRelations = Array.isArray(relationTo);
|
if (cellData && isAboveViewport && !hasRequested) {
|
||||||
|
const formattedValues: Value[] = [];
|
||||||
|
|
||||||
if (cellData) {
|
const arrayCellData = Array.isArray(cellData) ? cellData : [cellData];
|
||||||
if (Array.isArray(cellData)) {
|
arrayCellData.slice(0, (arrayCellData.length < 3 ? arrayCellData.length : 3)).forEach((cell) => {
|
||||||
setData(cellData.reduce((newData, value) => {
|
if (typeof cell === 'object' && 'relationTo' in cell && 'value' in cell) {
|
||||||
const relation = hasManyRelations ? value?.relationTo : relationTo;
|
formattedValues.push(cell);
|
||||||
const doc = hasManyRelations ? value.value : value;
|
|
||||||
|
|
||||||
const collection = collections.find((coll) => coll.slug === relation);
|
|
||||||
|
|
||||||
if (collection) {
|
|
||||||
const useAsTitle = collection.admin.useAsTitle ? collection.admin.useAsTitle : 'id';
|
|
||||||
let title: string;
|
|
||||||
if (typeof doc === 'string') {
|
|
||||||
title = doc;
|
|
||||||
} else {
|
|
||||||
title = doc?.[useAsTitle] ? doc[useAsTitle] : doc;
|
|
||||||
}
|
}
|
||||||
|
if ((typeof cell === 'number' || typeof cell === 'string') && typeof field.relationTo === 'string') {
|
||||||
return newData ? `${newData}, ${title}` : title;
|
formattedValues.push({
|
||||||
|
value: cell,
|
||||||
|
relationTo: field.relationTo,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return newData;
|
getRelationships(formattedValues);
|
||||||
}, ''));
|
setHasRequested(true);
|
||||||
} else {
|
setValues(formattedValues);
|
||||||
const relation = hasManyRelations ? cellData?.relationTo : relationTo;
|
|
||||||
const doc = hasManyRelations ? cellData.value : cellData;
|
|
||||||
const collection = collections.find((coll) => coll.slug === relation);
|
|
||||||
|
|
||||||
if (collection && doc) {
|
|
||||||
const useAsTitle = collection.admin.useAsTitle ? collection.admin.useAsTitle : 'id';
|
|
||||||
|
|
||||||
setData(doc[useAsTitle] ? doc[useAsTitle] : doc);
|
|
||||||
}
|
}
|
||||||
}
|
}, [cellData, field, collections, isAboveViewport, routes.api, hasRequested, getRelationships]);
|
||||||
}
|
|
||||||
}, [cellData, relationTo, field, collections]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<div
|
||||||
{data}
|
className={baseClass}
|
||||||
|
ref={intersectionRef}
|
||||||
|
>
|
||||||
|
{values.map(({ relationTo, value }, i) => {
|
||||||
|
const document = documents[relationTo][value];
|
||||||
|
const relatedCollection = collections.find(({ slug }) => slug === relationTo);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{document && document[relatedCollection.admin.useAsTitle] ? document[relatedCollection.admin.useAsTitle] : `Untitled - ID: ${value}`}
|
||||||
|
{values.length > i + 1 && ', '}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
{!cellData && !values && hasRequested && (
|
||||||
|
<React.Fragment>
|
||||||
|
{`No <${field.label}>`}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RelationshipCell;
|
export default RelationshipCell;
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import querystring from 'qs';
|
||||||
|
import { requests } from '../../../../../../../api';
|
||||||
|
import { useConfig } from '../../../../../../utilities/Config';
|
||||||
|
|
||||||
|
const UploadCell = ({ data, field }) => {
|
||||||
|
const [cell, setCell] = useState<string>();
|
||||||
|
const { routes } = useConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUpload = async () => {
|
||||||
|
const params = {
|
||||||
|
depth: 0,
|
||||||
|
limit: 1,
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
equals: data.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const url = `${routes.api}/${field.relationTo}`;
|
||||||
|
const query = querystring.stringify(params, { addQueryPrefix: true });
|
||||||
|
const request = await requests.get(`${url}${query}`);
|
||||||
|
const result = await request.json();
|
||||||
|
|
||||||
|
if (result?.docs.length === 1) {
|
||||||
|
setCell(result.docs[0].filename);
|
||||||
|
} else {
|
||||||
|
setCell(`Untitled - ${data}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUpload();
|
||||||
|
// get the doc
|
||||||
|
}, [data, field.relationTo, routes.api]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
const UploadCell = ({ data }) => (
|
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<span>
|
<span>
|
||||||
{data?.filename}
|
{ cell }
|
||||||
</span>
|
</span>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default UploadCell;
|
export default UploadCell;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { useConfig } from '../../../utilities/Config';
|
import { useConfig } from '../../../utilities/Config';
|
||||||
import UploadGallery from '../../../elements/UploadGallery';
|
import UploadGallery from '../../../elements/UploadGallery';
|
||||||
import Eyebrow from '../../../elements/Eyebrow';
|
import Eyebrow from '../../../elements/Eyebrow';
|
||||||
@@ -13,6 +13,7 @@ import { Props } from './types';
|
|||||||
import ViewDescription from '../../../elements/ViewDescription';
|
import ViewDescription from '../../../elements/ViewDescription';
|
||||||
import PerPage from '../../../elements/PerPage';
|
import PerPage from '../../../elements/PerPage';
|
||||||
import { Gutter } from '../../../elements/Gutter';
|
import { Gutter } from '../../../elements/Gutter';
|
||||||
|
import { RelationshipProvider } from './RelationshipProvider';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
@@ -43,7 +44,6 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
|
|
||||||
const { routes: { admin } } = useConfig();
|
const { routes: { admin } } = useConfig();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { pathname, search } = useLocation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={baseClass}>
|
<div className={baseClass}>
|
||||||
@@ -73,14 +73,14 @@ const DefaultList: React.FC<Props> = (props) => {
|
|||||||
enableSort={Boolean(upload)}
|
enableSort={Boolean(upload)}
|
||||||
/>
|
/>
|
||||||
{(data.docs && data.docs.length > 0) && (
|
{(data.docs && data.docs.length > 0) && (
|
||||||
<React.Fragment
|
<React.Fragment>
|
||||||
key={`${pathname}${search}`}
|
|
||||||
>
|
|
||||||
{!upload && (
|
{!upload && (
|
||||||
|
<RelationshipProvider>
|
||||||
<Table
|
<Table
|
||||||
data={data.docs}
|
data={data.docs}
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
/>
|
/>
|
||||||
|
</RelationshipProvider>
|
||||||
)}
|
)}
|
||||||
{upload && (
|
{upload && (
|
||||||
<UploadGallery
|
<UploadGallery
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useReducer, useRef } from 'react';
|
||||||
|
import querystring from 'qs';
|
||||||
|
import { useConfig } from '../../../../utilities/Config';
|
||||||
|
import { requests } from '../../../../../api';
|
||||||
|
import { TypeWithID } from '../../../../../../collections/config/types';
|
||||||
|
import { reducer } from './reducer';
|
||||||
|
import useDebounce from '../../../../../hooks/useDebounce';
|
||||||
|
|
||||||
|
export type Documents = {
|
||||||
|
[slug: string]: {
|
||||||
|
[id: string | number]: TypeWithID | null | 'loading'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListRelationshipContext = {
|
||||||
|
getRelationships: (docs: {
|
||||||
|
relationTo: string,
|
||||||
|
value: number | string
|
||||||
|
}[]) => void;
|
||||||
|
documents: Documents
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = createContext({} as ListRelationshipContext);
|
||||||
|
|
||||||
|
export const RelationshipProvider: React.FC<{children?: React.ReactNode}> = ({ children }) => {
|
||||||
|
const [documents, dispatchDocuments] = useReducer(reducer, {});
|
||||||
|
const debouncedDocuments = useDebounce(documents, 100);
|
||||||
|
const config = useConfig();
|
||||||
|
const {
|
||||||
|
serverURL,
|
||||||
|
routes: { api },
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Object.entries(debouncedDocuments).forEach(([slug, docs]) => {
|
||||||
|
const idsToLoad: (string | number)[] = [];
|
||||||
|
|
||||||
|
Object.entries(docs).forEach(([id, value]) => {
|
||||||
|
if (value === null) {
|
||||||
|
idsToLoad.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (idsToLoad.length > 0) {
|
||||||
|
const url = `${serverURL}${api}/${slug}`;
|
||||||
|
const params = {
|
||||||
|
depth: 0,
|
||||||
|
'where[id][in]': idsToLoad,
|
||||||
|
pagination: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = querystring.stringify(params, { addQueryPrefix: true });
|
||||||
|
requests.get(`${url}${query}`).then(async (res) => {
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.docs) {
|
||||||
|
dispatchDocuments({ type: 'ADD_LOADED', docs: result.docs, relationTo: slug });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [serverURL, api, debouncedDocuments]);
|
||||||
|
|
||||||
|
const getRelationships = useCallback(async (relationships: { relationTo: string, value: number | string }[]) => {
|
||||||
|
dispatchDocuments({ type: 'REQUEST', docs: relationships });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={{ getRelationships, documents }}>
|
||||||
|
{children}
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useListRelationships = (): ListRelationshipContext => useContext(Context);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Documents } from './index';
|
||||||
|
import { TypeWithID } from '../../../../../../collections/config/types';
|
||||||
|
|
||||||
|
type RequestDocuments = {
|
||||||
|
type: 'REQUEST',
|
||||||
|
docs: { relationTo: string, value: number | string }[],
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddLoadedDocuments = {
|
||||||
|
type: 'ADD_LOADED',
|
||||||
|
relationTo: string,
|
||||||
|
docs: TypeWithID[],
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action = RequestDocuments | AddLoadedDocuments;
|
||||||
|
|
||||||
|
export function reducer(state: Documents, action: Action): Documents {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'REQUEST': {
|
||||||
|
const newState = { ...state };
|
||||||
|
|
||||||
|
action.docs.forEach(({ relationTo, value }) => {
|
||||||
|
if (typeof newState[relationTo] !== 'object') {
|
||||||
|
newState[relationTo] = {};
|
||||||
|
}
|
||||||
|
newState[relationTo][value] = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ADD_LOADED': {
|
||||||
|
const newState = { ...state };
|
||||||
|
if (typeof newState[action.relationTo] !== 'object') {
|
||||||
|
newState[action.relationTo] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(action.docs)) {
|
||||||
|
action.docs.forEach((doc) => {
|
||||||
|
newState[action.relationTo][doc.id] = doc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = {
|
const params = {
|
||||||
depth: 1,
|
depth: 0,
|
||||||
draft: 'true',
|
draft: 'true',
|
||||||
page: undefined,
|
page: undefined,
|
||||||
sort: undefined,
|
sort: undefined,
|
||||||
@@ -107,7 +107,7 @@ const ListView: React.FC<ListIndexProps> = (props) => {
|
|||||||
setTableColumns(buildColumns(collection, currentPreferences?.columns));
|
setTableColumns(buildColumns(collection, currentPreferences?.columns));
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 10 });
|
const params = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 0 });
|
||||||
|
|
||||||
const search = {
|
const search = {
|
||||||
...params,
|
...params,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
import payload from '../../src';
|
import payload from '../../src';
|
||||||
|
|
||||||
const expressApp = express();
|
const expressApp = express();
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
await payload.init({
|
await payload.init({
|
||||||
secret: 'SECRET_KEY',
|
secret: uuid(),
|
||||||
mongoURL: process.env.MONGO_URL || 'mongodb://localhost/payload',
|
mongoURL: process.env.MONGO_URL || 'mongodb://localhost/payload',
|
||||||
express: expressApp,
|
express: expressApp,
|
||||||
email: {
|
email: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CollectionConfig } from '../../../src/collections/config/types';
|
import type { CollectionConfig } from '../../../src/collections/config/types';
|
||||||
import { buildConfig } from '../buildConfig';
|
import { buildConfig } from '../buildConfig';
|
||||||
|
import { devUser } from '../../credentials';
|
||||||
|
|
||||||
export const slug = 'fields-relationship';
|
export const slug = 'fields-relationship';
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ export default buildConfig({
|
|||||||
collections: [
|
collections: [
|
||||||
{
|
{
|
||||||
slug,
|
slug,
|
||||||
|
admin: {
|
||||||
|
defaultColumns: ['relationship', 'relationshipRestricted', 'with-existing-relations'],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
@@ -94,6 +98,13 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
onInit: async (payload) => {
|
onInit: async (payload) => {
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: devUser.email,
|
||||||
|
password: devUser.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
// Create docs to relate to
|
// Create docs to relate to
|
||||||
const { id: relationOneDocId } = await payload.create<RelationOne>({
|
const { id: relationOneDocId } = await payload.create<RelationOne>({
|
||||||
collection: relationOneSlug,
|
collection: relationOneSlug,
|
||||||
@@ -129,10 +140,9 @@ export default buildConfig({
|
|||||||
name: 'relation-title',
|
name: 'relation-title',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await payload.create<RelationOne>({
|
await payload.create<FieldsRelationship>({
|
||||||
collection: slug,
|
collection: slug,
|
||||||
data: {
|
data: {
|
||||||
name: 'with-existing-relations',
|
|
||||||
relationship: relationOneDocId,
|
relationship: relationOneDocId,
|
||||||
relationshipRestricted: restrictedDocId,
|
relationshipRestricted: restrictedDocId,
|
||||||
relationshipWithTitle: relationWithTitleDocId,
|
relationshipWithTitle: relationWithTitleDocId,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import payload from '../../../src';
|
|||||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||||
import { firstRegister, saveDocAndAssert } from '../helpers';
|
import { login, saveDocAndAssert } from '../helpers';
|
||||||
import type {
|
import type {
|
||||||
FieldsRelationship as CollectionWithRelationships,
|
FieldsRelationship as CollectionWithRelationships,
|
||||||
RelationOne,
|
RelationOne,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
relationWithTitleSlug,
|
relationWithTitleSlug,
|
||||||
slug,
|
slug,
|
||||||
} from './config';
|
} from './config';
|
||||||
|
import wait from '../../../src/utilities/wait';
|
||||||
|
|
||||||
const { beforeAll, describe } = test;
|
const { beforeAll, describe } = test;
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ describe('fields - relationship', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await firstRegister({ page, serverURL });
|
await login({ page, serverURL });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create relationship', async () => {
|
test('should create relationship', async () => {
|
||||||
@@ -216,6 +217,30 @@ describe('fields - relationship', () => {
|
|||||||
|
|
||||||
await expect(options).toHaveCount(2); // None + 1 Doc
|
await expect(options).toHaveCount(2); // None + 1 Doc
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should show id on relation in list view', async () => {
|
||||||
|
await page.goto(url.list);
|
||||||
|
await wait(1000);
|
||||||
|
const cells = page.locator('.relationship');
|
||||||
|
const relationship = cells.nth(0);
|
||||||
|
await expect(relationship).toHaveText(relationOneDoc.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show useAsTitle on relation in list view', async () => {
|
||||||
|
await page.goto(url.list);
|
||||||
|
wait(110);
|
||||||
|
const cells = page.locator('.relationshipWithTitle');
|
||||||
|
const relationship = cells.nth(0);
|
||||||
|
await expect(relationship).toHaveText(relationWithTitle.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show untitled ID on restricted relation in list view', async () => {
|
||||||
|
await page.goto(url.list);
|
||||||
|
wait(110);
|
||||||
|
const cells = page.locator('.relationship');
|
||||||
|
const relationship = cells.nth(0);
|
||||||
|
await expect(relationship).toHaveText(relationOneDoc.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user