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>
This commit is contained in:
Sasha
2025-02-18 21:53:45 +02:00
committed by GitHub
parent 88548fcbe6
commit 6d36a28cdc
53 changed files with 2056 additions and 421 deletions

View File

@@ -220,6 +220,120 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: 'multiple-collections-parents',
fields: [
{
type: 'join',
name: 'children',
collection: ['multiple-collections-1', 'multiple-collections-2'],
on: 'parent',
admin: {
defaultColumns: ['title', 'name', 'description'],
},
},
],
},
{
slug: 'multiple-collections-1',
admin: { useAsTitle: 'title' },
fields: [
{
type: 'relationship',
relationTo: 'multiple-collections-parents',
name: 'parent',
},
{
name: 'title',
type: 'text',
},
{
name: 'name',
type: 'text',
},
],
},
{
slug: 'multiple-collections-2',
admin: { useAsTitle: 'title' },
fields: [
{
type: 'relationship',
relationTo: 'multiple-collections-parents',
name: 'parent',
},
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'text',
},
],
},
{
slug: 'folders',
fields: [
{
type: 'relationship',
relationTo: 'folders',
name: 'folder',
},
{
name: 'title',
type: 'text',
},
{
type: 'join',
name: 'children',
collection: ['folders', 'example-pages', 'example-posts'],
on: 'folder',
admin: {
defaultColumns: ['title', 'name', 'description'],
},
},
],
},
{
slug: 'example-pages',
admin: { useAsTitle: 'title' },
fields: [
{
type: 'relationship',
relationTo: 'folders',
name: 'folder',
},
{
name: 'title',
type: 'text',
},
{
name: 'name',
type: 'text',
},
],
},
{
slug: 'example-posts',
admin: { useAsTitle: 'title' },
fields: [
{
type: 'relationship',
relationTo: 'folders',
name: 'folder',
},
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'text',
},
],
},
],
localization: {
locales: [

View File

@@ -36,9 +36,11 @@ const { beforeAll, beforeEach, describe } = test
describe('Join Field', () => {
let page: Page
let categoriesURL: AdminUrlUtil
let foldersURL: AdminUrlUtil
let uploadsURL: AdminUrlUtil
let categoriesJoinRestrictedURL: AdminUrlUtil
let categoryID: number | string
let rootFolderID: number | string
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
@@ -50,6 +52,7 @@ describe('Join Field', () => {
categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug)
uploadsURL = new AdminUrlUtil(serverURL, uploadsSlug)
categoriesJoinRestrictedURL = new AdminUrlUtil(serverURL, categoriesJoinRestrictedSlug)
foldersURL = new AdminUrlUtil(serverURL, 'folders')
const context = await browser.newContext()
page = await context.newPage()
@@ -86,6 +89,9 @@ describe('Join Field', () => {
}
;({ id: categoryID } = docs[0])
const folder = await payload.find({ collection: 'folders', sort: 'createdAt', depth: 0 })
rootFolderID = folder.docs[0]!.id
})
test('should populate joined relationships in table cells of list view', async () => {
@@ -469,6 +475,43 @@ describe('Join Field', () => {
await expect(joinField.locator('.cell-canRead')).not.toContainText('false')
})
test('should render join field with array of collections', async () => {
await page.goto(foldersURL.edit(rootFolderID))
const joinField = page.locator('#field-children.field-type.join')
await expect(joinField).toBeVisible()
await expect(
joinField.locator('.relationship-table tbody .row-1 .cell-collection .pill__label'),
).toHaveText('Folder')
await expect(
joinField.locator('.relationship-table tbody .row-3 .cell-collection .pill__label'),
).toHaveText('Example Post')
await expect(
joinField.locator('.relationship-table tbody .row-5 .cell-collection .pill__label'),
).toHaveText('Example Page')
})
test('should create a new document from join field with array of collections', async () => {
await page.goto(foldersURL.edit(rootFolderID))
const joinField = page.locator('#field-children.field-type.join')
await expect(joinField).toBeVisible()
const addNewPopupBtn = joinField.locator('.relationship-table__add-new-polymorphic')
await expect(addNewPopupBtn).toBeVisible()
await addNewPopupBtn.click()
const pageOption = joinField.locator('.relationship-table__relation-button--example-pages')
await expect(pageOption).toHaveText('Example Page')
await pageOption.click()
await page.locator('.drawer__content input#field-title').fill('Some new page')
await page.locator('.drawer__content #action-save').click()
await expect(
joinField.locator('.relationship-table tbody .row-1 .cell-collection .pill__label'),
).toHaveText('Example Page')
await expect(
joinField.locator('.relationship-table tbody .row-1 .cell-title .drawer-link__cell'),
).toHaveText('Some new page')
})
test('should render create-first-user with when users collection has a join field and hide it', async () => {
await payload.delete({ collection: 'users', where: {} })
const url = new AdminUrlUtil(serverURL, 'users')

View File

@@ -1153,6 +1153,123 @@ describe('Joins Field', () => {
expect(joinedDoc2.id).toBe(depthJoin_3.id)
})
describe('Array of collection', () => {
it('should join across multiple collections', async () => {
let parent = await payload.create({
collection: 'multiple-collections-parents',
depth: 0,
data: {},
})
const child_1 = await payload.create({
collection: 'multiple-collections-1',
depth: 0,
data: {
parent,
title: 'doc-1',
},
})
const child_2 = await payload.create({
collection: 'multiple-collections-2',
depth: 0,
data: {
parent,
title: 'doc-2',
},
})
parent = await payload.findByID({
collection: 'multiple-collections-parents',
id: parent.id,
depth: 0,
})
expect(parent.children.docs[0].value).toBe(child_2.id)
expect(parent.children.docs[0]?.relationTo).toBe('multiple-collections-2')
expect(parent.children.docs[1]?.value).toBe(child_1.id)
expect(parent.children.docs[1]?.relationTo).toBe('multiple-collections-1')
parent = await payload.findByID({
collection: 'multiple-collections-parents',
id: parent.id,
depth: 1,
})
expect(parent.children.docs[0].value.id).toBe(child_2.id)
expect(parent.children.docs[0]?.relationTo).toBe('multiple-collections-2')
expect(parent.children.docs[1]?.value.id).toBe(child_1.id)
expect(parent.children.docs[1]?.relationTo).toBe('multiple-collections-1')
// Sorting across collections
parent = await payload.findByID({
collection: 'multiple-collections-parents',
id: parent.id,
depth: 1,
joins: {
children: {
sort: 'title',
},
},
})
expect(parent.children.docs[0]?.value.title).toBe('doc-1')
expect(parent.children.docs[1]?.value.title).toBe('doc-2')
parent = await payload.findByID({
collection: 'multiple-collections-parents',
id: parent.id,
depth: 1,
joins: {
children: {
sort: '-title',
},
},
})
expect(parent.children.docs[0]?.value.title).toBe('doc-2')
expect(parent.children.docs[1]?.value.title).toBe('doc-1')
// WHERE across collections
parent = await payload.findByID({
collection: 'multiple-collections-parents',
id: parent.id,
depth: 1,
joins: {
children: {
where: {
title: {
equals: 'doc-1',
},
},
},
},
})
expect(parent.children?.docs).toHaveLength(1)
expect(parent.children.docs[0]?.value.title).toBe('doc-1')
// WHERE by _relationTo (join for specific collectionSlug)
parent = await payload.findByID({
collection: 'multiple-collections-parents',
id: parent.id,
depth: 1,
joins: {
children: {
where: {
relationTo: {
equals: 'multiple-collections-2',
},
},
},
},
})
expect(parent.children?.docs).toHaveLength(1)
expect(parent.children.docs[0]?.value.title).toBe('doc-2')
})
})
})
async function createPost(overrides?: Partial<Post>, locale?: Config['locale']) {

View File

@@ -84,6 +84,12 @@ export interface Config {
'depth-joins-1': DepthJoins1;
'depth-joins-2': DepthJoins2;
'depth-joins-3': DepthJoins3;
'multiple-collections-parents': MultipleCollectionsParent;
'multiple-collections-1': MultipleCollections1;
'multiple-collections-2': MultipleCollections2;
folders: Folder;
'example-pages': ExamplePage;
'example-posts': ExamplePost;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -135,6 +141,12 @@ export interface Config {
'depth-joins-2': {
joins: 'depth-joins-1';
};
'multiple-collections-parents': {
children: 'multiple-collections-1' | 'multiple-collections-2';
};
folders: {
children: 'folders' | 'example-pages' | 'example-posts';
};
};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
@@ -155,6 +167,12 @@ export interface Config {
'depth-joins-1': DepthJoins1Select<false> | DepthJoins1Select<true>;
'depth-joins-2': DepthJoins2Select<false> | DepthJoins2Select<true>;
'depth-joins-3': DepthJoins3Select<false> | DepthJoins3Select<true>;
'multiple-collections-parents': MultipleCollectionsParentsSelect<false> | MultipleCollectionsParentsSelect<true>;
'multiple-collections-1': MultipleCollections1Select<false> | MultipleCollections1Select<true>;
'multiple-collections-2': MultipleCollections2Select<false> | MultipleCollections2Select<true>;
folders: FoldersSelect<false> | FoldersSelect<true>;
'example-pages': ExamplePagesSelect<false> | ExamplePagesSelect<true>;
'example-posts': ExamplePostsSelect<false> | ExamplePostsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -581,6 +599,108 @@ export interface DepthJoins3 {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "multiple-collections-parents".
*/
export interface MultipleCollectionsParent {
id: string;
children?: {
docs?:
| (
| {
relationTo?: 'multiple-collections-1';
value: string | MultipleCollections1;
}
| {
relationTo?: 'multiple-collections-2';
value: string | MultipleCollections2;
}
)[]
| null;
hasNextPage?: boolean | null;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "multiple-collections-1".
*/
export interface MultipleCollections1 {
id: string;
parent?: (string | null) | MultipleCollectionsParent;
title?: string | null;
name?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "multiple-collections-2".
*/
export interface MultipleCollections2 {
id: string;
parent?: (string | null) | MultipleCollectionsParent;
title?: string | null;
description?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "folders".
*/
export interface Folder {
id: string;
folder?: (string | null) | Folder;
title?: string | null;
children?: {
docs?:
| (
| {
relationTo?: 'folders';
value: string | Folder;
}
| {
relationTo?: 'example-pages';
value: string | ExamplePage;
}
| {
relationTo?: 'example-posts';
value: string | ExamplePost;
}
)[]
| null;
hasNextPage?: boolean | null;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "example-pages".
*/
export interface ExamplePage {
id: string;
folder?: (string | null) | Folder;
title?: string | null;
name?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "example-posts".
*/
export interface ExamplePost {
id: string;
folder?: (string | null) | Folder;
title?: string | null;
description?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -659,6 +779,30 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'depth-joins-3';
value: string | DepthJoins3;
} | null)
| ({
relationTo: 'multiple-collections-parents';
value: string | MultipleCollectionsParent;
} | null)
| ({
relationTo: 'multiple-collections-1';
value: string | MultipleCollections1;
} | null)
| ({
relationTo: 'multiple-collections-2';
value: string | MultipleCollections2;
} | null)
| ({
relationTo: 'folders';
value: string | Folder;
} | null)
| ({
relationTo: 'example-pages';
value: string | ExamplePage;
} | null)
| ({
relationTo: 'example-posts';
value: string | ExamplePost;
} | null);
globalSlug?: string | null;
user: {
@@ -958,6 +1102,70 @@ export interface DepthJoins3Select<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "multiple-collections-parents_select".
*/
export interface MultipleCollectionsParentsSelect<T extends boolean = true> {
children?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "multiple-collections-1_select".
*/
export interface MultipleCollections1Select<T extends boolean = true> {
parent?: T;
title?: T;
name?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "multiple-collections-2_select".
*/
export interface MultipleCollections2Select<T extends boolean = true> {
parent?: T;
title?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "folders_select".
*/
export interface FoldersSelect<T extends boolean = true> {
folder?: T;
title?: T;
children?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "example-pages_select".
*/
export interface ExamplePagesSelect<T extends boolean = true> {
folder?: T;
title?: T;
name?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "example-posts_select".
*/
export interface ExamplePostsSelect<T extends boolean = true> {
folder?: T;
title?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -146,6 +146,74 @@ export const seed = async (_payload: Payload) => {
category: restrictedCategory.id,
},
})
const root_folder = await _payload.create({
collection: 'folders',
data: {
folder: null,
title: 'Root folder',
},
})
const page_1 = await _payload.create({
collection: 'example-pages',
data: { title: 'page 1', name: 'Andrew', folder: root_folder },
})
const post_1 = await _payload.create({
collection: 'example-posts',
data: { title: 'page 1', description: 'This is post 1', folder: root_folder },
})
const page_2 = await _payload.create({
collection: 'example-pages',
data: { title: 'page 2', name: 'Sophia', folder: root_folder },
})
const page_3 = await _payload.create({
collection: 'example-pages',
data: { title: 'page 3', name: 'Michael', folder: root_folder },
})
const post_2 = await _payload.create({
collection: 'example-posts',
data: { title: 'post 2', description: 'This is post 2', folder: root_folder },
})
const post_3 = await _payload.create({
collection: 'example-posts',
data: { title: 'post 3', description: 'This is post 3', folder: root_folder },
})
const sub_folder_1 = await _payload.create({
collection: 'folders',
data: { folder: root_folder, title: 'Sub Folder 1' },
})
const page_4 = await _payload.create({
collection: 'example-pages',
data: { title: 'page 4', name: 'Emma', folder: sub_folder_1 },
})
const post_4 = await _payload.create({
collection: 'example-posts',
data: { title: 'post 4', description: 'This is post 4', folder: sub_folder_1 },
})
const sub_folder_2 = await _payload.create({
collection: 'folders',
data: { folder: root_folder, title: 'Sub Folder 2' },
})
const page_5 = await _payload.create({
collection: 'example-pages',
data: { title: 'page 5', name: 'Liam', folder: sub_folder_2 },
})
const post_5 = await _payload.create({
collection: 'example-posts',
data: { title: 'post 5', description: 'This is post 5', folder: sub_folder_2 },
})
}
export async function clearAndSeedEverything(_payload: Payload) {