Files
payload/packages/ui/src/elements/RelationshipTable/index.tsx
Sasha 6d36a28cdc feat: join field across many collections (#10919)
This feature allows you to specify `collection` for the join field as
array.
This can be useful for example to describe relationship linking like
this:
```ts
{
  slug: 'folders',
  fields: [
    {
      type: 'join',
      on: 'folder',
      collection: ['files', 'documents', 'folders'],
      name: 'children',
    },
    {
      type: 'relationship',
      relationTo: 'folders',
      name: 'folder',
    },
  ],
},
{
  slug: 'files',
  upload: true,
  fields: [
    {
      type: 'relationship',
      relationTo: 'folders',
      name: 'folder',
    },
  ],
},
{
  slug: 'documents',
  fields: [
    {
      type: 'relationship',
      relationTo: 'folders',
      name: 'folder',
    },
  ],
},
```

Documents and files can be placed to folders and folders themselves can
be nested to other folders (root folders just have `folder` as `null`).

Output type of `Folder`:
```ts
export interface Folder {
  id: string;
  children?: {
    docs?:
      | (
          | {
              relationTo?: 'files';
              value: string | File;
            }
          | {
              relationTo?: 'documents';
              value: string | Document;
            }
          | {
              relationTo?: 'folders';
              value: string | Folder;
            }
        )[]
      | null;
    hasNextPage?: boolean | null;
  } | null;
  folder?: (string | null) | Folder;
  updatedAt: string;
  createdAt: string;
}
```

While you could instead have many join fields (for example
`childrenFolders`, `childrenFiles`) etc - this doesn't allow you to
sort/filter and paginate things across many collections, which isn't
trivial. With SQL we use `UNION ALL` query to achieve that.

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-02-18 21:53:45 +02:00

375 lines
12 KiB
TypeScript

'use client'
import type {
CollectionSlug,
Column,
JoinFieldClient,
ListQuery,
PaginatedDocs,
Where,
} from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useCallback, useEffect, useState } from 'react'
import type { DocumentDrawerProps } from '../DocumentDrawer/types.js'
import { Button } from '../../elements/Button/index.js'
import { Pill } from '../../elements/Pill/index.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { ChevronIcon } from '../../icons/Chevron/index.js'
import { PlusIcon } from '../../icons/Plus/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { ListQueryProvider } from '../../providers/ListQuery/index.js'
import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js'
import { AnimateHeight } from '../AnimateHeight/index.js'
import './index.scss'
import { ColumnSelector } from '../ColumnSelector/index.js'
import { useDocumentDrawer } from '../DocumentDrawer/index.js'
import { Popup, PopupList } from '../Popup/index.js'
import { RelationshipProvider } from '../Table/RelationshipProvider/index.js'
import { TableColumnsProvider } from '../TableColumns/index.js'
import { DrawerLink } from './cells/DrawerLink/index.js'
import { RelationshipTablePagination } from './Pagination.js'
const baseClass = 'relationship-table'
type RelationshipTableComponentProps = {
readonly AfterInput?: React.ReactNode
readonly allowCreate?: boolean
readonly BeforeInput?: React.ReactNode
readonly disableTable?: boolean
readonly field: JoinFieldClient
readonly filterOptions?: Where
readonly initialData?: PaginatedDocs
readonly initialDrawerData?: DocumentDrawerProps['initialData']
readonly Label?: React.ReactNode
readonly parent?: {
collectionSlug: CollectionSlug
id: number | string
joinPath: string
}
readonly relationTo: string | string[]
}
export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (props) => {
const {
AfterInput,
allowCreate = true,
BeforeInput,
disableTable = false,
field,
filterOptions,
initialData: initialDataFromProps,
initialDrawerData,
Label,
parent,
relationTo,
} = props
const [Table, setTable] = useState<React.ReactNode>(null)
const { config, getEntityConfig } = useConfig()
const { permissions } = useAuth()
const [initialData] = useState<PaginatedDocs>(() => {
if (initialDataFromProps) {
return {
...initialDataFromProps,
docs: Array.isArray(initialDataFromProps.docs)
? initialDataFromProps.docs.reduce((acc, doc) => {
if (typeof doc === 'string') {
return [
...acc,
{
id: doc,
},
]
}
return [...acc, doc]
}, [])
: [],
}
}
})
const { i18n, t } = useTranslation()
const [query, setQuery] = useState<ListQuery>()
const [openColumnSelector, setOpenColumnSelector] = useState(false)
const [collectionConfig] = useState(() => getEntityConfig({ collectionSlug: relationTo }))
const [selectedCollection, setSelectedCollection] = useState(
Array.isArray(relationTo) ? undefined : relationTo,
)
const [isLoadingTable, setIsLoadingTable] = useState(!disableTable)
const [data, setData] = useState<PaginatedDocs>(initialData)
const [columnState, setColumnState] = useState<Column[]>()
const { getTableState } = useServerFunctions()
const renderTable = useCallback(
async (docs?: PaginatedDocs['docs']) => {
const newQuery: ListQuery = {
limit: String(field?.defaultLimit || collectionConfig?.admin?.pagination?.defaultLimit),
sort: field.defaultSort || collectionConfig?.defaultSort,
...(query || {}),
where: { ...(query?.where || {}) },
}
if (filterOptions) {
newQuery.where = hoistQueryParamsToAnd(newQuery.where, filterOptions)
}
// map columns from string[] to ListPreferences['columns']
const defaultColumns = field.admin.defaultColumns
? field.admin.defaultColumns.map((accessor) => ({
accessor,
active: true,
}))
: undefined
const {
data: newData,
state: newColumnState,
Table: NewTable,
} = await getTableState({
collectionSlug: relationTo,
columns: defaultColumns,
docs,
enableRowSelections: false,
parent,
query: newQuery,
renderRowTypes: true,
tableAppearance: 'condensed',
})
setData(newData)
setTable(NewTable)
setColumnState(newColumnState)
setIsLoadingTable(false)
},
[
field.defaultLimit,
field.defaultSort,
field.admin.defaultColumns,
collectionConfig?.admin?.pagination?.defaultLimit,
collectionConfig?.defaultSort,
query,
filterOptions,
getTableState,
relationTo,
parent,
],
)
const handleTableRender = useEffectEvent((query: ListQuery, disableTable: boolean) => {
if (!disableTable && (!Table || query)) {
void renderTable()
}
})
useEffect(() => {
handleTableRender(query, disableTable)
}, [query, disableTable])
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer, isDrawerOpen, openDrawer }] =
useDocumentDrawer({
collectionSlug: selectedCollection,
})
const onDrawerSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
const foundDocIndex = data?.docs?.findIndex((doc) => doc.id === args.doc.id)
let withNewOrUpdatedDoc: PaginatedDocs['docs'] = undefined
if (foundDocIndex !== -1) {
const newDocs = [...data.docs]
newDocs[foundDocIndex] = args.doc
withNewOrUpdatedDoc = newDocs
} else {
withNewOrUpdatedDoc = [args.doc, ...data.docs]
}
void renderTable(withNewOrUpdatedDoc)
},
[data?.docs, renderTable],
)
const onDrawerCreate = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
closeDrawer()
void onDrawerSave(args)
},
[closeDrawer, onDrawerSave],
)
const onDrawerDelete = useCallback<DocumentDrawerProps['onDelete']>(
(args) => {
const newDocs = data.docs.filter((doc) => doc.id !== args.id)
void renderTable(newDocs)
},
[data?.docs, renderTable],
)
const preferenceKey = `${Array.isArray(relationTo) ? `${parent.collectionSlug}-${parent.joinPath}` : relationTo}-list`
const canCreate =
allowCreate !== false &&
permissions?.collections?.[Array.isArray(relationTo) ? relationTo[0] : relationTo]?.create
useEffect(() => {
if (Array.isArray(relationTo) && selectedCollection) {
openDrawer()
}
}, [selectedCollection, openDrawer, relationTo])
useEffect(() => {
if (Array.isArray(relationTo) && !isDrawerOpen && selectedCollection) {
setSelectedCollection(undefined)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDrawerOpen])
return (
<div className={baseClass}>
<div className={`${baseClass}__header`}>
{Label}
<div className={`${baseClass}__actions`}>
{!Array.isArray(relationTo) && canCreate && (
<DocumentDrawerToggler className={`${baseClass}__add-new`}>
{i18n.t('fields:addNew')}
</DocumentDrawerToggler>
)}
{Array.isArray(relationTo) && (
<Fragment>
<Popup
button={
<Button buttonStyle="none" className={`${baseClass}__add-new-polymorphic`}>
{i18n.t('fields:addNew')}
<PlusIcon />
</Button>
}
buttonType="custom"
horizontalAlign="center"
render={({ close: closePopup }) => (
<PopupList.ButtonGroup>
{relationTo.map((relatedCollection) => {
if (permissions.collections[relatedCollection].create) {
return (
<PopupList.Button
className={`${baseClass}__relation-button--${relatedCollection}`}
key={relatedCollection}
onClick={() => {
closePopup()
setSelectedCollection(relatedCollection)
}}
>
{getTranslation(
config.collections.find((each) => each.slug === relatedCollection)
.labels.singular,
i18n,
)}
</PopupList.Button>
)
}
return null
})}
</PopupList.ButtonGroup>
)}
size="medium"
/>
</Fragment>
)}
<Pill
aria-controls={`${baseClass}-columns`}
aria-expanded={openColumnSelector}
className={`${baseClass}__toggle-columns ${
openColumnSelector ? `${baseClass}__buttons-active` : ''
}`}
icon={<ChevronIcon direction={openColumnSelector ? 'up' : 'down'} />}
onClick={() => setOpenColumnSelector(!openColumnSelector)}
pillStyle="light"
>
{t('general:columns')}
</Pill>
</div>
</div>
{BeforeInput}
{isLoadingTable ? (
<p>{t('general:loading')}</p>
) : (
<Fragment>
{data?.docs && data.docs.length === 0 && (
<div className={`${baseClass}__no-results`}>
<p>
{i18n.t('general:noResults', {
label: Array.isArray(relationTo)
? i18n.t('general:documents')
: getTranslation(collectionConfig?.labels?.plural, i18n),
})}
</p>
{canCreate && (
<Button onClick={openDrawer}>
{i18n.t('general:createNewLabel', {
label: getTranslation(collectionConfig?.labels?.singular, i18n),
})}
</Button>
)}
</div>
)}
{data?.docs && data.docs.length > 0 && (
<RelationshipProvider>
<ListQueryProvider
data={data}
defaultLimit={
field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit
}
modifySearchParams={false}
onQueryChange={setQuery}
>
<TableColumnsProvider
collectionSlug={Array.isArray(relationTo) ? relationTo[0] : relationTo}
columnState={columnState}
docs={data.docs}
LinkedCellOverride={
<DrawerLink onDrawerDelete={onDrawerDelete} onDrawerSave={onDrawerSave} />
}
preferenceKey={preferenceKey}
renderRowTypes
setTable={setTable}
sortColumnProps={{
appearance: 'condensed',
}}
tableAppearance="condensed"
>
<AnimateHeight
className={`${baseClass}__columns`}
height={openColumnSelector ? 'auto' : 0}
id={`${baseClass}-columns`}
>
<div className={`${baseClass}__columns-inner`}>
{collectionConfig && (
<ColumnSelector collectionSlug={collectionConfig.slug} />
)}
</div>
</AnimateHeight>
{Table}
<RelationshipTablePagination />
</TableColumnsProvider>
</ListQueryProvider>
</RelationshipProvider>
)}
</Fragment>
)}
{AfterInput}
<DocumentDrawer initialData={initialDrawerData} onSave={onDrawerCreate} />
</div>
)
}