From 31bc4c65321d2e540f52c03248abc3cc62fb99d2 Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Thu, 14 Jul 2022 16:51:41 -0400 Subject: [PATCH] * feat: optimize collection list relationship queries (#749) * feat: optimize collection list relationship queries --- src/admin/components/elements/Table/index.tsx | 5 +- .../Cell/field-types/Relationship/index.scss | 3 + .../Cell/field-types/Relationship/index.tsx | 91 +++++++++++-------- .../List/Cell/field-types/Upload/index.tsx | 52 +++++++++-- .../views/collections/List/Default.tsx | 10 +- .../List/RelationshipProvider/index.tsx | 74 +++++++++++++++ .../List/RelationshipProvider/reducer.ts | 51 +++++++++++ .../views/collections/List/index.tsx | 4 +- test/dev/server.ts | 3 +- test/e2e/fields-relationship/config.ts | 14 ++- test/e2e/fields-relationship/index.spec.ts | 29 +++++- 11 files changed, 275 insertions(+), 61 deletions(-) create mode 100644 src/admin/components/views/collections/List/Cell/field-types/Relationship/index.scss create mode 100644 src/admin/components/views/collections/List/RelationshipProvider/index.tsx create mode 100644 src/admin/components/views/collections/List/RelationshipProvider/reducer.ts diff --git a/src/admin/components/elements/Table/index.tsx b/src/admin/components/elements/Table/index.tsx index 48c895a45..24047169d 100644 --- a/src/admin/components/elements/Table/index.tsx +++ b/src/admin/components/elements/Table/index.tsx @@ -26,7 +26,10 @@ const Table: React.FC = ({ columns, data }) => { {data && data.map((row, rowIndex) => ( {columns.map((col, colIndex) => ( - + {col.components.renderCell(row, row[col.accessor])} ))} diff --git a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.scss b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.scss new file mode 100644 index 000000000..ebdd21e31 --- /dev/null +++ b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.scss @@ -0,0 +1,3 @@ +.relationship-cell { + min-width: 250px; +} diff --git a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx index cf47ace1b..1a657d6ed 100644 --- a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx +++ b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx @@ -1,55 +1,66 @@ import React, { useState, useEffect } from 'react'; 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 { field, data: cellData } = props; - const { relationTo } = field; - const { collections } = useConfig(); - const [data, setData] = useState(); + const { collections, routes } = useConfig(); + const [intersectionRef, entry] = useIntersect(); + const [values, setValues] = useState([]); + const { getRelationships, documents } = useListRelationships(); + const [hasRequested, setHasRequested] = useState(false); + + const isAboveViewport = entry?.boundingClientRect?.top > 0; useEffect(() => { - const hasManyRelations = Array.isArray(relationTo); + if (cellData && isAboveViewport && !hasRequested) { + const formattedValues: Value[] = []; - if (cellData) { - if (Array.isArray(cellData)) { - setData(cellData.reduce((newData, value) => { - const relation = hasManyRelations ? value?.relationTo : relationTo; - 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; - } - - return newData ? `${newData}, ${title}` : title; - } - - return newData; - }, '')); - } else { - 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); + const arrayCellData = Array.isArray(cellData) ? cellData : [cellData]; + arrayCellData.slice(0, (arrayCellData.length < 3 ? arrayCellData.length : 3)).forEach((cell) => { + if (typeof cell === 'object' && 'relationTo' in cell && 'value' in cell) { + formattedValues.push(cell); } - } + if ((typeof cell === 'number' || typeof cell === 'string') && typeof field.relationTo === 'string') { + formattedValues.push({ + value: cell, + relationTo: field.relationTo, + }); + } + }); + getRelationships(formattedValues); + setHasRequested(true); + setValues(formattedValues); } - }, [cellData, relationTo, field, collections]); + }, [cellData, field, collections, isAboveViewport, routes.api, hasRequested, getRelationships]); return ( - - {data} - +
+ {values.map(({ relationTo, value }, i) => { + const document = documents[relationTo][value]; + const relatedCollection = collections.find(({ slug }) => slug === relationTo); + return ( + + {document && document[relatedCollection.admin.useAsTitle] ? document[relatedCollection.admin.useAsTitle] : `Untitled - ID: ${value}`} + {values.length > i + 1 && ', '} + + ); + })} + {!cellData && !values && hasRequested && ( + + {`No <${field.label}>`} + + )} +
); }; diff --git a/src/admin/components/views/collections/List/Cell/field-types/Upload/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Upload/index.tsx index 6f6403037..fb0c0aafd 100644 --- a/src/admin/components/views/collections/List/Cell/field-types/Upload/index.tsx +++ b/src/admin/components/views/collections/List/Cell/field-types/Upload/index.tsx @@ -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 }) => ( - - - {data?.filename} - - -); +const UploadCell = ({ data, field }) => { + const [cell, setCell] = useState(); + 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 ( + + + + { cell } + + + ); +}; export default UploadCell; diff --git a/src/admin/components/views/collections/List/Default.tsx b/src/admin/components/views/collections/List/Default.tsx index 61400345f..a0fe58bcc 100644 --- a/src/admin/components/views/collections/List/Default.tsx +++ b/src/admin/components/views/collections/List/Default.tsx @@ -1,5 +1,5 @@ import React, { Fragment } from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { useConfig } from '../../../utilities/Config'; import UploadGallery from '../../../elements/UploadGallery'; import Eyebrow from '../../../elements/Eyebrow'; @@ -13,6 +13,7 @@ import { Props } from './types'; import ViewDescription from '../../../elements/ViewDescription'; import PerPage from '../../../elements/PerPage'; import { Gutter } from '../../../elements/Gutter'; +import { RelationshipProvider } from './RelationshipProvider'; import './index.scss'; @@ -43,7 +44,6 @@ const DefaultList: React.FC = (props) => { const { routes: { admin } } = useConfig(); const history = useHistory(); - const { pathname, search } = useLocation(); return (
@@ -73,14 +73,14 @@ const DefaultList: React.FC = (props) => { enableSort={Boolean(upload)} /> {(data.docs && data.docs.length > 0) && ( - + {!upload && ( + + )} {upload && ( 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 ( + + {children} + + ); +}; + +export const useListRelationships = (): ListRelationshipContext => useContext(Context); diff --git a/src/admin/components/views/collections/List/RelationshipProvider/reducer.ts b/src/admin/components/views/collections/List/RelationshipProvider/reducer.ts new file mode 100644 index 000000000..b3b5d1821 --- /dev/null +++ b/src/admin/components/views/collections/List/RelationshipProvider/reducer.ts @@ -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; + } + } +} diff --git a/src/admin/components/views/collections/List/index.tsx b/src/admin/components/views/collections/List/index.tsx index bad52584f..701935ffa 100644 --- a/src/admin/components/views/collections/List/index.tsx +++ b/src/admin/components/views/collections/List/index.tsx @@ -76,7 +76,7 @@ const ListView: React.FC = (props) => { useEffect(() => { const params = { - depth: 1, + depth: 0, draft: 'true', page: undefined, sort: undefined, @@ -107,7 +107,7 @@ const ListView: React.FC = (props) => { 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 = { ...params, diff --git a/test/dev/server.ts b/test/dev/server.ts index 0980822ae..dce593b10 100644 --- a/test/dev/server.ts +++ b/test/dev/server.ts @@ -1,12 +1,13 @@ /* eslint-disable no-console */ import express from 'express'; +import { v4 as uuid } from 'uuid'; import payload from '../../src'; const expressApp = express(); const init = async () => { await payload.init({ - secret: 'SECRET_KEY', + secret: uuid(), mongoURL: process.env.MONGO_URL || 'mongodb://localhost/payload', express: expressApp, email: { diff --git a/test/e2e/fields-relationship/config.ts b/test/e2e/fields-relationship/config.ts index 6124c0bf1..88cfeeafe 100644 --- a/test/e2e/fields-relationship/config.ts +++ b/test/e2e/fields-relationship/config.ts @@ -1,5 +1,6 @@ import type { CollectionConfig } from '../../../src/collections/config/types'; import { buildConfig } from '../buildConfig'; +import { devUser } from '../../credentials'; export const slug = 'fields-relationship'; @@ -38,6 +39,9 @@ export default buildConfig({ collections: [ { slug, + admin: { + defaultColumns: ['relationship', 'relationshipRestricted', 'with-existing-relations'], + }, fields: [ { type: 'relationship', @@ -94,6 +98,13 @@ export default buildConfig({ }, ], onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }); // Create docs to relate to const { id: relationOneDocId } = await payload.create({ collection: relationOneSlug, @@ -129,10 +140,9 @@ export default buildConfig({ name: 'relation-title', }, }); - await payload.create({ + await payload.create({ collection: slug, data: { - name: 'with-existing-relations', relationship: relationOneDocId, relationshipRestricted: restrictedDocId, relationshipWithTitle: relationWithTitleDocId, diff --git a/test/e2e/fields-relationship/index.spec.ts b/test/e2e/fields-relationship/index.spec.ts index e2559a759..6f659ec83 100644 --- a/test/e2e/fields-relationship/index.spec.ts +++ b/test/e2e/fields-relationship/index.spec.ts @@ -4,7 +4,7 @@ import payload from '../../../src'; import { mapAsync } from '../../../src/utilities/mapAsync'; import { AdminUrlUtil } from '../../helpers/adminUrlUtil'; import { initPayloadTest } from '../../helpers/configHelpers'; -import { firstRegister, saveDocAndAssert } from '../helpers'; +import { login, saveDocAndAssert } from '../helpers'; import type { FieldsRelationship as CollectionWithRelationships, RelationOne, @@ -19,6 +19,7 @@ import { relationWithTitleSlug, slug, } from './config'; +import wait from '../../../src/utilities/wait'; const { beforeAll, describe } = test; @@ -97,7 +98,7 @@ describe('fields - relationship', () => { }, }); - await firstRegister({ page, serverURL }); + await login({ page, serverURL }); }); test('should create relationship', async () => { @@ -216,6 +217,30 @@ describe('fields - relationship', () => { 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); + }); }); });