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

@@ -19,6 +19,7 @@
// Load .git-blame-ignore-revs file // Load .git-blame-ignore-revs file
"gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".git-blame-ignore-revs"], "gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".git-blame-ignore-revs"],
"jestrunner.jestCommand": "pnpm exec cross-env NODE_OPTIONS=\"--no-deprecation\" node 'node_modules/jest/bin/jest.js'", "jestrunner.jestCommand": "pnpm exec cross-env NODE_OPTIONS=\"--no-deprecation\" node 'node_modules/jest/bin/jest.js'",
"jestrunner.changeDirectoryToWorkspaceRoot": false,
"jestrunner.debugOptions": { "jestrunner.debugOptions": {
"runtimeArgs": ["--no-deprecation"] "runtimeArgs": ["--no-deprecation"]
}, },

View File

@@ -5,6 +5,7 @@ import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
export const count: Count = async function count( export const count: Count = async function count(
@@ -23,9 +24,11 @@ export const count: Count = async function count(
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near')) hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
} }
const query = await Model.buildQuery({ const query = await buildQuery({
adapter: this,
collectionSlug: collection,
fields: this.payload.collections[collection].config.flattenedFields,
locale, locale,
payload: this.payload,
where, where,
}) })

View File

@@ -1,10 +1,11 @@
import type { CountOptions } from 'mongodb' import type { CountOptions } from 'mongodb'
import type { CountGlobalVersions } from 'payload' import type { CountGlobalVersions } from 'payload'
import { flattenWhereToOperators } from 'payload' import { buildVersionGlobalFields, flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions( export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(
@@ -23,9 +24,14 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near')) hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
} }
const query = await Model.buildQuery({ const query = await buildQuery({
adapter: this,
fields: buildVersionGlobalFields(
this.payload.config,
this.payload.globals.config.find((each) => each.slug === global),
true,
),
locale, locale,
payload: this.payload,
where, where,
}) })

View File

@@ -1,10 +1,11 @@
import type { CountOptions } from 'mongodb' import type { CountOptions } from 'mongodb'
import type { CountVersions } from 'payload' import type { CountVersions } from 'payload'
import { flattenWhereToOperators } from 'payload' import { buildVersionCollectionFields, flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
export const countVersions: CountVersions = async function countVersions( export const countVersions: CountVersions = async function countVersions(
@@ -23,9 +24,14 @@ export const countVersions: CountVersions = async function countVersions(
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near')) hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'))
} }
const query = await Model.buildQuery({ const query = await buildQuery({
adapter: this,
fields: buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collection].config,
true,
),
locale, locale,
payload: this.payload,
where, where,
}) })

View File

@@ -3,6 +3,7 @@ import type { DeleteMany } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
export const deleteMany: DeleteMany = async function deleteMany( export const deleteMany: DeleteMany = async function deleteMany(
@@ -14,8 +15,10 @@ export const deleteMany: DeleteMany = async function deleteMany(
session: await getSession(this, req), session: await getSession(this, req),
} }
const query = await Model.buildQuery({ const query = await buildQuery({
payload: this.payload, adapter: this,
collectionSlug: collection,
fields: this.payload.collections[collection].config.flattenedFields,
where, where,
}) })

View File

@@ -3,6 +3,7 @@ import type { DeleteOne, Document } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
@@ -21,8 +22,10 @@ export const deleteOne: DeleteOne = async function deleteOne(
session: await getSession(this, req), session: await getSession(this, req),
} }
const query = await Model.buildQuery({ const query = await buildQuery({
payload: this.payload, adapter: this,
collectionSlug: collection,
fields: this.payload.collections[collection].config.flattenedFields,
where, where,
}) })

View File

@@ -1,7 +1,8 @@
import type { DeleteVersions } from 'payload' import { buildVersionCollectionFields, type DeleteVersions } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
export const deleteVersions: DeleteVersions = async function deleteVersions( export const deleteVersions: DeleteVersions = async function deleteVersions(
@@ -12,9 +13,14 @@ export const deleteVersions: DeleteVersions = async function deleteVersions(
const session = await getSession(this, req) const session = await getSession(this, req)
const query = await VersionsModel.buildQuery({ const query = await buildQuery({
adapter: this,
fields: buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collection].config,
true,
),
locale, locale,
payload: this.payload,
where, where,
}) })

View File

@@ -5,6 +5,7 @@ import { flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildSortParam } from './queries/buildSortParam.js' import { buildSortParam } from './queries/buildSortParam.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
@@ -50,9 +51,11 @@ export const find: Find = async function find(
}) })
} }
const query = await Model.buildQuery({ const query = await buildQuery({
adapter: this,
collectionSlug: collection,
fields: this.payload.collections[collection].config.flattenedFields,
locale, locale,
payload: this.payload,
where, where,
}) })

View File

@@ -5,6 +5,7 @@ import { combineQueries } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js'
@@ -14,20 +15,22 @@ export const findGlobal: FindGlobal = async function findGlobal(
{ slug, locale, req, select, where }, { slug, locale, req, select, where },
) { ) {
const Model = this.globals const Model = this.globals
const fields = this.payload.globals.config.find((each) => each.slug === slug).flattenedFields
const options: QueryOptions = { const options: QueryOptions = {
lean: true, lean: true,
select: buildProjectionFromSelect({ select: buildProjectionFromSelect({
adapter: this, adapter: this,
fields: this.payload.globals.config.find((each) => each.slug === slug).flattenedFields, fields,
select, select,
}), }),
session: await getSession(this, req), session: await getSession(this, req),
} }
const query = await Model.buildQuery({ const query = await buildQuery({
adapter: this,
fields,
globalSlug: slug, globalSlug: slug,
locale, locale,
payload: this.payload,
where: combineQueries({ globalType: { equals: slug } }, where), where: combineQueries({ globalType: { equals: slug } }, where),
}) })

View File

@@ -5,6 +5,7 @@ import { buildVersionGlobalFields, flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildSortParam } from './queries/buildSortParam.js' import { buildSortParam } from './queries/buildSortParam.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
@@ -46,10 +47,10 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
}) })
} }
const query = await Model.buildQuery({ const query = await buildQuery({
globalSlug: global, adapter: this,
fields: versionFields,
locale, locale,
payload: this.payload,
where, where,
}) })

View File

@@ -3,6 +3,7 @@ import type { Document, FindOne } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
@@ -20,9 +21,11 @@ export const findOne: FindOne = async function findOne(
session, session,
} }
const query = await Model.buildQuery({ const query = await buildQuery({
adapter: this,
collectionSlug: collection,
fields: collectionConfig.flattenedFields,
locale, locale,
payload: this.payload,
where, where,
}) })

View File

@@ -5,6 +5,7 @@ import { buildVersionCollectionFields, flattenWhereToOperators } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildSortParam } from './queries/buildSortParam.js' import { buildSortParam } from './queries/buildSortParam.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
@@ -41,9 +42,12 @@ export const findVersions: FindVersions = async function findVersions(
}) })
} }
const query = await Model.buildQuery({ const fields = buildVersionCollectionFields(this.payload.config, collectionConfig, true)
const query = await buildQuery({
adapter: this,
fields,
locale, locale,
payload: this.payload,
where, where,
}) })
@@ -58,7 +62,7 @@ export const findVersions: FindVersions = async function findVersions(
pagination, pagination,
projection: buildProjectionFromSelect({ projection: buildProjectionFromSelect({
adapter: this, adapter: this,
fields: buildVersionCollectionFields(this.payload.config, collectionConfig, true), fields,
select, select,
}), }),
sort, sort,

View File

@@ -12,7 +12,7 @@ import type { CollectionModel } from './types.js'
import { buildCollectionSchema } from './models/buildCollectionSchema.js' import { buildCollectionSchema } from './models/buildCollectionSchema.js'
import { buildGlobalModel } from './models/buildGlobalModel.js' import { buildGlobalModel } from './models/buildGlobalModel.js'
import { buildSchema } from './models/buildSchema.js' import { buildSchema } from './models/buildSchema.js'
import { getBuildQueryPlugin } from './queries/buildQuery.js' import { getBuildQueryPlugin } from './queries/getBuildQueryPlugin.js'
import { getDBName } from './utilities/getDBName.js' import { getDBName } from './utilities/getDBName.js'
export const init: Init = function init(this: MongooseAdapter) { export const init: Init = function init(this: MongooseAdapter) {

View File

@@ -4,7 +4,7 @@ import type { Payload, SanitizedCollectionConfig } from 'payload'
import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2' import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2'
import paginate from 'mongoose-paginate-v2' import paginate from 'mongoose-paginate-v2'
import { getBuildQueryPlugin } from '../queries/buildQuery.js' import { getBuildQueryPlugin } from '../queries/getBuildQueryPlugin.js'
import { buildSchema } from './buildSchema.js' import { buildSchema } from './buildSchema.js'
export const buildCollectionSchema = ( export const buildCollectionSchema = (
@@ -44,7 +44,10 @@ export const buildCollectionSchema = (
.plugin<any, PaginateOptions>(paginate, { useEstimatedCount: true }) .plugin<any, PaginateOptions>(paginate, { useEstimatedCount: true })
.plugin(getBuildQueryPlugin({ collectionSlug: collection.slug })) .plugin(getBuildQueryPlugin({ collectionSlug: collection.slug }))
if (Object.keys(collection.joins).length > 0) { if (
Object.keys(collection.joins).length > 0 ||
Object.keys(collection.polymorphicJoins).length > 0
) {
schema.plugin(mongooseAggregatePaginate) schema.plugin(mongooseAggregatePaginate)
} }

View File

@@ -4,7 +4,7 @@ import mongoose from 'mongoose'
import type { GlobalModel } from '../types.js' import type { GlobalModel } from '../types.js'
import { getBuildQueryPlugin } from '../queries/buildQuery.js' import { getBuildQueryPlugin } from '../queries/getBuildQueryPlugin.js'
import { buildSchema } from './buildSchema.js' import { buildSchema } from './buildSchema.js'
export const buildGlobalModel = (payload: Payload): GlobalModel | null => { export const buildGlobalModel = (payload: Payload): GlobalModel | null => {

View File

@@ -1,63 +1,33 @@
import type { FlattenedField, Payload, Where } from 'payload' import type { FlattenedField, Where } from 'payload'
import { QueryError } from 'payload' import type { MongooseAdapter } from '../index.js'
import { parseParams } from './parseParams.js' import { parseParams } from './parseParams.js'
type GetBuildQueryPluginArgs = { export const buildQuery = async ({
collectionSlug?: string adapter,
versionsFields?: FlattenedField[]
}
export type BuildQueryArgs = {
globalSlug?: string
locale?: string
payload: Payload
where: Where
}
// This plugin asynchronously builds a list of Mongoose query constraints
// which can then be used in subsequent Mongoose queries.
export const getBuildQueryPlugin = ({
collectionSlug, collectionSlug,
versionsFields, fields,
}: GetBuildQueryPluginArgs = {}) => {
return function buildQueryPlugin(schema) {
const modifiedSchema = schema
async function buildQuery({
globalSlug, globalSlug,
locale, locale,
payload,
where, where,
}: BuildQueryArgs): Promise<Record<string, unknown>> { }: {
let fields = versionsFields adapter: MongooseAdapter
if (!fields) { collectionSlug?: string
if (globalSlug) { fields: FlattenedField[]
const globalConfig = payload.globals.config.find(({ slug }) => slug === globalSlug) globalSlug?: string
fields = globalConfig.flattenedFields locale?: string
} where: Where
if (collectionSlug) { }) => {
const collectionConfig = payload.collections[collectionSlug].config
fields = collectionConfig.flattenedFields
}
}
const errors = []
const result = await parseParams({ const result = await parseParams({
collectionSlug, collectionSlug,
fields, fields,
globalSlug, globalSlug,
locale, locale,
parentIsLocalized: false, parentIsLocalized: false,
payload, payload: adapter.payload,
where, where,
}) })
if (errors.length > 0) {
throw new QueryError(errors)
}
return result return result
} }
modifiedSchema.statics.buildQuery = buildQuery
}
}

View File

@@ -0,0 +1,65 @@
import type { FlattenedField, Payload, Where } from 'payload'
import { QueryError } from 'payload'
import { parseParams } from './parseParams.js'
type GetBuildQueryPluginArgs = {
collectionSlug?: string
versionsFields?: FlattenedField[]
}
export type BuildQueryArgs = {
globalSlug?: string
locale?: string
payload: Payload
where: Where
}
// This plugin asynchronously builds a list of Mongoose query constraints
// which can then be used in subsequent Mongoose queries.
// Deprecated in favor of using simpler buildQuery directly
export const getBuildQueryPlugin = ({
collectionSlug,
versionsFields,
}: GetBuildQueryPluginArgs = {}) => {
return function buildQueryPlugin(schema) {
const modifiedSchema = schema
async function schemaBuildQuery({
globalSlug,
locale,
payload,
where,
}: BuildQueryArgs): Promise<Record<string, unknown>> {
let fields = versionsFields
if (!fields) {
if (globalSlug) {
const globalConfig = payload.globals.config.find(({ slug }) => slug === globalSlug)
fields = globalConfig.flattenedFields
}
if (collectionSlug) {
const collectionConfig = payload.collections[collectionSlug].config
fields = collectionConfig.flattenedFields
}
}
const errors = []
const result = await parseParams({
collectionSlug,
fields,
globalSlug,
locale,
parentIsLocalized: false,
payload,
where,
})
if (errors.length > 0) {
throw new QueryError(errors)
}
return result
}
modifiedSchema.statics.buildQuery = schemaBuildQuery
}
}

View File

@@ -100,7 +100,6 @@ export const sanitizeQueryValue = ({
} => { } => {
let formattedValue = val let formattedValue = val
let formattedOperator = operator let formattedOperator = operator
if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) { if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) {
const segments = path.split('.') const segments = path.split('.')
segments.shift() segments.shift()

View File

@@ -5,6 +5,7 @@ import { buildVersionCollectionFields, combineQueries, flattenWhereToOperators }
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildSortParam } from './queries/buildSortParam.js' import { buildSortParam } from './queries/buildSortParam.js'
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
@@ -41,15 +42,17 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
const combinedWhere = combineQueries({ latest: { equals: true } }, where) const combinedWhere = combineQueries({ latest: { equals: true } }, where)
const versionQuery = await VersionModel.buildQuery({ const fields = buildVersionCollectionFields(this.payload.config, collectionConfig, true)
const versionQuery = await buildQuery({
adapter: this,
fields,
locale, locale,
payload: this.payload,
where: combinedWhere, where: combinedWhere,
}) })
const projection = buildProjectionFromSelect({ const projection = buildProjectionFromSelect({
adapter: this, adapter: this,
fields: buildVersionCollectionFields(this.payload.config, collectionConfig, true), fields,
select, select,
}) })
// useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters.

View File

@@ -35,7 +35,7 @@ import type {
UploadField, UploadField,
} from 'payload' } from 'payload'
import type { BuildQueryArgs } from './queries/buildQuery.js' import type { BuildQueryArgs } from './queries/getBuildQueryPlugin.js'
export interface CollectionModel export interface CollectionModel
extends Model<any>, extends Model<any>,

View File

@@ -4,6 +4,7 @@ import { buildVersionGlobalFields, type TypeWithID, type UpdateGlobalVersionArgs
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
@@ -26,22 +27,23 @@ export async function updateGlobalVersion<T extends TypeWithID>(
const currentGlobal = this.payload.config.globals.find((global) => global.slug === globalSlug) const currentGlobal = this.payload.config.globals.find((global) => global.slug === globalSlug)
const fields = buildVersionGlobalFields(this.payload.config, currentGlobal) const fields = buildVersionGlobalFields(this.payload.config, currentGlobal)
const flattenedFields = buildVersionGlobalFields(this.payload.config, currentGlobal, true)
const options: QueryOptions = { const options: QueryOptions = {
...optionsArgs, ...optionsArgs,
lean: true, lean: true,
new: true, new: true,
projection: buildProjectionFromSelect({ projection: buildProjectionFromSelect({
adapter: this, adapter: this,
fields: buildVersionGlobalFields(this.payload.config, currentGlobal, true), fields: flattenedFields,
select, select,
}), }),
session: await getSession(this, req), session: await getSession(this, req),
} }
const query = await VersionModel.buildQuery({ const query = await buildQuery({
adapter: this,
fields: flattenedFields,
locale, locale,
payload: this.payload,
where: whereToUse, where: whereToUse,
}) })

View File

@@ -3,6 +3,7 @@ import type { UpdateOne } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
import { handleError } from './utilities/handleError.js' import { handleError } from './utilities/handleError.js'
@@ -28,9 +29,11 @@ export const updateOne: UpdateOne = async function updateOne(
session: await getSession(this, req), session: await getSession(this, req),
} }
const query = await Model.buildQuery({ const query = await buildQuery({
adapter: this,
collectionSlug: collection,
fields: this.payload.collections[collection].config.flattenedFields,
locale, locale,
payload: this.payload,
where, where,
}) })

View File

@@ -4,6 +4,7 @@ import { buildVersionCollectionFields, type UpdateVersion } from 'payload'
import type { MongooseAdapter } from './index.js' import type { MongooseAdapter } from './index.js'
import { buildQuery } from './queries/buildQuery.js'
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
import { getSession } from './utilities/getSession.js' import { getSession } from './utilities/getSession.js'
import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js'
@@ -19,25 +20,28 @@ export const updateVersion: UpdateVersion = async function updateVersion(
this.payload.collections[collection].config, this.payload.collections[collection].config,
) )
const flattenedFields = buildVersionCollectionFields(
this.payload.config,
this.payload.collections[collection].config,
true,
)
const options: QueryOptions = { const options: QueryOptions = {
...optionsArgs, ...optionsArgs,
lean: true, lean: true,
new: true, new: true,
projection: buildProjectionFromSelect({ projection: buildProjectionFromSelect({
adapter: this, adapter: this,
fields: buildVersionCollectionFields( fields: flattenedFields,
this.payload.config,
this.payload.collections[collection].config,
true,
),
select, select,
}), }),
session: await getSession(this, req), session: await getSession(this, req),
} }
const query = await VersionModel.buildQuery({ const query = await buildQuery({
adapter: this,
fields: flattenedFields,
locale, locale,
payload: this.payload,
where: whereToUse, where: whereToUse,
}) })

View File

@@ -1,10 +1,17 @@
import type { PipelineStage } from 'mongoose' import type { PipelineStage } from 'mongoose'
import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload' import type {
CollectionSlug,
FlattenedField,
JoinQuery,
SanitizedCollectionConfig,
Where,
} from 'payload'
import { fieldShouldBeLocalized } from 'payload/shared' import { fieldShouldBeLocalized } from 'payload/shared'
import type { MongooseAdapter } from '../index.js' import type { MongooseAdapter } from '../index.js'
import { buildQuery } from '../queries/buildQuery.js'
import { buildSortParam } from '../queries/buildSortParam.js' import { buildSortParam } from '../queries/buildSortParam.js'
type BuildJoinAggregationArgs = { type BuildJoinAggregationArgs = {
@@ -33,11 +40,16 @@ export const buildJoinAggregation = async ({
query, query,
versions, versions,
}: BuildJoinAggregationArgs): Promise<PipelineStage[] | undefined> => { }: BuildJoinAggregationArgs): Promise<PipelineStage[] | undefined> => {
if (Object.keys(collectionConfig.joins).length === 0 || joins === false) { if (
(Object.keys(collectionConfig.joins).length === 0 &&
collectionConfig.polymorphicJoins.length == 0) ||
joins === false
) {
return return
} }
const joinConfig = adapter.payload.collections[collection].config.joins const joinConfig = adapter.payload.collections[collection].config.joins
const polymorphicJoinsConfig = adapter.payload.collections[collection].config.polymorphicJoins
const aggregate: PipelineStage[] = [ const aggregate: PipelineStage[] = [
{ {
$sort: { createdAt: -1 }, $sort: { createdAt: -1 },
@@ -56,10 +68,7 @@ export const buildJoinAggregation = async ({
}) })
} }
for (const slug of Object.keys(joinConfig)) { for (const join of polymorphicJoinsConfig) {
for (const join of joinConfig[slug]) {
const joinModel = adapter.collections[join.field.collection]
if (projection && !projection[join.joinPath]) { if (projection && !projection[join.joinPath]) {
continue continue
} }
@@ -75,6 +84,156 @@ export const buildJoinAggregation = async ({
where: whereJoin, where: whereJoin,
} = joins?.[join.joinPath] || {} } = joins?.[join.joinPath] || {}
const aggregatedFields: FlattenedField[] = []
for (const collectionSlug of join.field.collection) {
for (const field of adapter.payload.collections[collectionSlug].config.flattenedFields) {
if (!aggregatedFields.some((eachField) => eachField.name === field.name)) {
aggregatedFields.push(field)
}
}
}
const sort = buildSortParam({
config: adapter.payload.config,
fields: aggregatedFields,
locale,
sort: sortJoin,
timestamps: true,
})
const $match = await buildQuery({
adapter,
fields: aggregatedFields,
locale,
where: whereJoin,
})
const sortProperty = Object.keys(sort)[0]
const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
const projectSort = sortProperty !== '_id' && sortProperty !== 'relationTo'
const aliases: string[] = []
const as = join.joinPath
for (const collectionSlug of join.field.collection) {
const alias = `${as}.docs.${collectionSlug}`
aliases.push(alias)
aggregate.push({
$lookup: {
as: alias,
from: adapter.collections[collectionSlug].collection.name,
let: {
root_id_: '$_id',
},
pipeline: [
{
$addFields: {
relationTo: {
$literal: collectionSlug,
},
},
},
{
$match: {
$and: [
{
$expr: {
$eq: [`$${join.field.on}`, '$$root_id_'],
},
},
$match,
],
},
},
{
$sort: {
[sortProperty]: sortDirection,
},
},
{
// Unfortunately, we can't use $skip here because we can lose data, instead we do $slice then
$limit: page ? page * limitJoin : limitJoin,
},
{
$project: {
value: '$_id',
...(projectSort && {
[sortProperty]: 1,
}),
relationTo: 1,
},
},
],
},
})
}
aggregate.push({
$addFields: {
[`${as}.docs`]: {
$concatArrays: aliases.map((alias) => `$${alias}`),
},
},
})
aggregate.push({
$set: {
[`${as}.docs`]: {
$sortArray: {
input: `$${as}.docs`,
sortBy: {
[sortProperty]: sortDirection,
},
},
},
},
})
const sliceValue = page ? [(page - 1) * limitJoin, limitJoin] : [limitJoin]
aggregate.push({
$set: {
[`${as}.docs`]: {
$slice: [`$${as}.docs`, ...sliceValue],
},
},
})
aggregate.push({
$addFields: {
[`${as}.hasNextPage`]: {
$gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE],
},
},
})
}
for (const slug of Object.keys(joinConfig)) {
for (const join of joinConfig[slug]) {
if (projection && !projection[join.joinPath]) {
continue
}
if (joins?.[join.joinPath] === false) {
continue
}
const {
limit: limitJoin = join.field.defaultLimit ?? 10,
page,
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
where: whereJoin,
} = joins?.[join.joinPath] || {}
if (Array.isArray(join.field.collection)) {
throw new Error('Unreachable')
}
const joinModel = adapter.collections[join.field.collection]
const sort = buildSortParam({ const sort = buildSortParam({
config: adapter.payload.config, config: adapter.payload.config,
fields: adapter.payload.collections[slug].config.flattenedFields, fields: adapter.payload.collections[slug].config.flattenedFields,

View File

@@ -1,7 +1,8 @@
import type { LibSQLDatabase } from 'drizzle-orm/libsql' import type { LibSQLDatabase } from 'drizzle-orm/libsql'
import type { SQLiteSelectBase } from 'drizzle-orm/sqlite-core'
import type { FlattenedField, JoinQuery, SelectMode, SelectType, Where } from 'payload' import type { FlattenedField, JoinQuery, SelectMode, SelectType, Where } from 'payload'
import { sql } from 'drizzle-orm' import { and, asc, desc, eq, or, sql } from 'drizzle-orm'
import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared' import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared'
import toSnakeCase from 'to-snake-case' import toSnakeCase from 'to-snake-case'
@@ -10,11 +11,49 @@ import type { Result } from './buildFindManyArgs.js'
import buildQuery from '../queries/buildQuery.js' import buildQuery from '../queries/buildQuery.js'
import { getTableAlias } from '../queries/getTableAlias.js' import { getTableAlias } from '../queries/getTableAlias.js'
import { operatorMap } from '../queries/operatorMap.js'
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js' import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
import { jsonAggBuildObject } from '../utilities/json.js' import { jsonAggBuildObject } from '../utilities/json.js'
import { rawConstraint } from '../utilities/rawConstraint.js' import { rawConstraint } from '../utilities/rawConstraint.js'
import { chainMethods } from './chainMethods.js' import { chainMethods } from './chainMethods.js'
const flattenAllWherePaths = (where: Where, paths: string[]) => {
for (const k in where) {
if (['AND', 'OR'].includes(k.toUpperCase())) {
if (Array.isArray(where[k])) {
for (const whereField of where[k]) {
flattenAllWherePaths(whereField, paths)
}
}
} else {
// TODO: explore how to support arrays/relationship querying.
paths.push(k.split('.').join('_'))
}
}
}
const buildSQLWhere = (where: Where, alias: string) => {
for (const k in where) {
if (['AND', 'OR'].includes(k.toUpperCase())) {
if (Array.isArray(where[k])) {
const op = 'AND' === k.toUpperCase() ? and : or
const accumulated = []
for (const whereField of where[k]) {
accumulated.push(buildSQLWhere(whereField, alias))
}
return op(...accumulated)
}
} else {
const payloadOperator = Object.keys(where[k])[0]
const value = where[k][payloadOperator]
return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value)
}
}
}
type SQLSelect = SQLiteSelectBase<any, any, any, any>
type TraverseFieldArgs = { type TraverseFieldArgs = {
_locales: Result _locales: Result
adapter: DrizzleAdapter adapter: DrizzleAdapter
@@ -359,6 +398,111 @@ export const traverseFields = ({
limit += 1 limit += 1
} }
const columnName = `${path.replaceAll('.', '_')}${field.name}`
const db = adapter.drizzle as LibSQLDatabase
if (Array.isArray(field.collection)) {
let currentQuery: null | SQLSelect = null
const onPath = field.on.split('.').join('_')
if (Array.isArray(sort)) {
throw new Error('Not implemented')
}
let sanitizedSort = sort
if (!sanitizedSort) {
if (
field.collection.some((collection) =>
adapter.payload.collections[collection].config.fields.some(
(f) => f.type === 'date' && f.name === 'createdAt',
),
)
) {
sanitizedSort = '-createdAt'
} else {
sanitizedSort = 'id'
}
}
const sortOrder = sanitizedSort.startsWith('-') ? desc : asc
sanitizedSort = sanitizedSort.replace('-', '')
const sortPath = sanitizedSort.split('.').join('_')
const wherePaths: string[] = []
if (where) {
flattenAllWherePaths(where, wherePaths)
}
for (const collection of field.collection) {
const joinCollectionTableName = adapter.tableNameMap.get(toSnakeCase(collection))
const table = adapter.tables[joinCollectionTableName]
const sortColumn = table[sortPath]
const selectFields = {
id: adapter.tables[joinCollectionTableName].id,
parent: sql`${adapter.tables[joinCollectionTableName][onPath]}`.as(onPath),
relationTo: sql`${collection}`.as('relationTo'),
sortPath: sql`${sortColumn ? sortColumn : null}`.as('sortPath'),
}
// Select for WHERE and Fallback NULL
for (const path of wherePaths) {
if (adapter.tables[joinCollectionTableName][path]) {
selectFields[path] = sql`${adapter.tables[joinCollectionTableName][path]}`.as(path)
// Allow to filter by collectionSlug
} else if (path !== 'relationTo') {
selectFields[path] = sql`null`.as(path)
}
}
const query = db.select(selectFields).from(adapter.tables[joinCollectionTableName])
if (currentQuery === null) {
currentQuery = query as unknown as SQLSelect
} else {
currentQuery = currentQuery.unionAll(query) as SQLSelect
}
}
const subQueryAlias = `${columnName}_subquery`
let sqlWhere = eq(
adapter.tables[currentTableName].id,
sql.raw(`"${subQueryAlias}"."${onPath}"`),
)
if (where && Object.keys(where).length > 0) {
sqlWhere = and(sqlWhere, buildSQLWhere(where, subQueryAlias))
}
currentQuery = currentQuery.orderBy(sortOrder(sql`"sortPath"`)) as SQLSelect
if (page && limit !== 0) {
const offset = (page - 1) * limit
if (offset > 0) {
currentQuery = currentQuery.offset(offset) as SQLSelect
}
}
if (limit) {
currentQuery = currentQuery.limit(limit) as SQLSelect
}
currentArgs.extras[columnName] = sql`${db
.select({
id: jsonAggBuildObject(adapter, {
id: sql.raw(`"${subQueryAlias}"."id"`),
relationTo: sql.raw(`"${subQueryAlias}"."relationTo"`),
}),
})
.from(sql`${currentQuery.as(subQueryAlias)}`)
.where(sqlWhere)}`.as(columnName)
} else {
const fields = adapter.payload.collections[field.collection].config.flattenedFields const fields = adapter.payload.collections[field.collection].config.flattenedFields
const joinCollectionTableName = adapter.tableNameMap.get(toSnakeCase(field.collection)) const joinCollectionTableName = adapter.tableNameMap.get(toSnakeCase(field.collection))
@@ -413,9 +557,7 @@ export const traverseFields = ({
fields, fields,
joins, joins,
locale, locale,
// Parent is never localized, as we're passing the `fields` of a **different** collection here. This means that the parentIsLocalized,
// parent localization "boundary" is crossed, and we're now in the context of the joined collection.
parentIsLocalized: false,
selectLocale: true, selectLocale: true,
sort, sort,
tableName: joinCollectionTableName, tableName: joinCollectionTableName,
@@ -431,13 +573,6 @@ export const traverseFields = ({
}) })
}) })
if (limit !== 0) {
chainedMethods.push({
args: [limit],
method: 'limit',
})
}
if (page && limit !== 0) { if (page && limit !== 0) {
const offset = (page - 1) * limit - 1 const offset = (page - 1) * limit - 1
if (offset > 0) { if (offset > 0) {
@@ -448,6 +583,13 @@ export const traverseFields = ({
} }
} }
if (limit !== 0) {
chainedMethods.push({
args: [limit],
method: 'limit',
})
}
const db = adapter.drizzle as LibSQLDatabase const db = adapter.drizzle as LibSQLDatabase
for (let key in selectFields) { for (let key in selectFields) {
@@ -479,6 +621,7 @@ export const traverseFields = ({
}), }),
}) })
.from(sql`${subQuery}`)}`.as(subQueryAlias) .from(sql`${subQuery}`)}`.as(subQueryAlias)
}
break break
} }

View File

@@ -436,9 +436,14 @@ export const traverseFields = <T extends Record<string, unknown>>({
} else { } else {
const hasNextPage = limit !== 0 && fieldData.length > limit const hasNextPage = limit !== 0 && fieldData.length > limit
fieldResult = { fieldResult = {
docs: (hasNextPage ? fieldData.slice(0, limit) : fieldData).map(({ id }) => ({ docs: (hasNextPage ? fieldData.slice(0, limit) : fieldData).map(
id, ({ id, relationTo }) => {
})), if (relationTo) {
return { relationTo, value: id }
}
return { id }
},
),
hasNextPage, hasNextPage,
} }
} }

View File

@@ -254,7 +254,9 @@ export function buildObjectType({
name: joinName, name: joinName,
fields: { fields: {
docs: { docs: {
type: new GraphQLList(graphqlResult.collections[field.collection].graphQL.type), type: Array.isArray(field.collection)
? GraphQLJSON
: new GraphQLList(graphqlResult.collections[field.collection].graphQL.type),
}, },
hasNextPage: { type: GraphQLBoolean }, hasNextPage: { type: GraphQLBoolean },
}, },
@@ -270,7 +272,9 @@ export function buildObjectType({
type: GraphQLString, type: GraphQLString,
}, },
where: { where: {
type: graphqlResult.collections[field.collection].graphQL.whereInputType, type: Array.isArray(field.collection)
? GraphQLJSON
: graphqlResult.collections[field.collection].graphQL.whereInputType,
}, },
}, },
extensions: { extensions: {
@@ -286,6 +290,10 @@ export function buildObjectType({
[field.on]: { equals: parent._id ?? parent.id }, [field.on]: { equals: parent._id ?? parent.id },
}) })
if (Array.isArray(collection)) {
throw new Error('GraphQL with array of join.field.collection is not implemented')
}
return await req.payload.find({ return await req.payload.find({
collection, collection,
depth: 0, depth: 0,

View File

@@ -1,6 +1,7 @@
import type { ImportMap } from '../../bin/generateImportMap/index.js' import type { ImportMap } from '../../bin/generateImportMap/index.js'
import type { SanitizedConfig } from '../../config/types.js' import type { SanitizedConfig } from '../../config/types.js'
import type { PaginatedDocs } from '../../database/types.js' import type { PaginatedDocs } from '../../database/types.js'
import type { CollectionSlug } from '../../index.js'
import type { PayloadRequest, Sort, Where } from '../../types/index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js'
export type DefaultServerFunctionArgs = { export type DefaultServerFunctionArgs = {
@@ -48,10 +49,15 @@ export type ListQuery = {
} }
export type BuildTableStateArgs = { export type BuildTableStateArgs = {
collectionSlug: string collectionSlug: string | string[]
columns?: { accessor: string; active: boolean }[] columns?: { accessor: string; active: boolean }[]
docs?: PaginatedDocs['docs'] docs?: PaginatedDocs['docs']
enableRowSelections?: boolean enableRowSelections?: boolean
parent?: {
collectionSlug: CollectionSlug
id: number | string
joinPath: string
}
query?: ListQuery query?: ListQuery
renderRowTypes?: boolean renderRowTypes?: boolean
req: PayloadRequest req: PayloadRequest

View File

@@ -17,7 +17,7 @@ import { createClientFields } from '../../fields/config/client.js'
export type ServerOnlyCollectionProperties = keyof Pick< export type ServerOnlyCollectionProperties = keyof Pick<
SanitizedCollectionConfig, SanitizedCollectionConfig,
'access' | 'custom' | 'endpoints' | 'flattenedFields' | 'hooks' | 'joins' 'access' | 'custom' | 'endpoints' | 'flattenedFields' | 'hooks' | 'joins' | 'polymorphicJoins'
> >
export type ServerOnlyCollectionAdminProperties = keyof Pick< export type ServerOnlyCollectionAdminProperties = keyof Pick<
@@ -68,6 +68,7 @@ const serverOnlyCollectionProperties: Partial<ServerOnlyCollectionProperties>[]
'endpoints', 'endpoints',
'custom', 'custom',
'joins', 'joins',
'polymorphicJoins',
'flattenedFields', 'flattenedFields',
// `upload` // `upload`
// `admin` // `admin`

View File

@@ -1,7 +1,12 @@
// @ts-strict-ignore // @ts-strict-ignore
import type { LoginWithUsernameOptions } from '../../auth/types.js' import type { LoginWithUsernameOptions } from '../../auth/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js' import type { Config, SanitizedConfig } from '../../config/types.js'
import type { CollectionConfig, SanitizedCollectionConfig, SanitizedJoins } from './types.js' import type {
CollectionConfig,
SanitizedCollectionConfig,
SanitizedJoin,
SanitizedJoins,
} from './types.js'
import { authCollectionEndpoints } from '../../auth/endpoints/index.js' import { authCollectionEndpoints } from '../../auth/endpoints/index.js'
import { getBaseAuthFields } from '../../auth/getAuthFields.js' import { getBaseAuthFields } from '../../auth/getAuthFields.js'
@@ -44,6 +49,7 @@ export const sanitizeCollection = async (
const validRelationships = _validRelationships ?? config.collections.map((c) => c.slug) ?? [] const validRelationships = _validRelationships ?? config.collections.map((c) => c.slug) ?? []
const joins: SanitizedJoins = {} const joins: SanitizedJoins = {}
const polymorphicJoins: SanitizedJoin[] = []
sanitized.fields = await sanitizeFields({ sanitized.fields = await sanitizeFields({
collectionConfig: sanitized, collectionConfig: sanitized,
config, config,
@@ -51,6 +57,7 @@ export const sanitizeCollection = async (
joinPath: '', joinPath: '',
joins, joins,
parentIsLocalized: false, parentIsLocalized: false,
polymorphicJoins,
richTextSanitizationPromises, richTextSanitizationPromises,
validRelationships, validRelationships,
}) })
@@ -234,6 +241,7 @@ export const sanitizeCollection = async (
const sanitizedConfig = sanitized as SanitizedCollectionConfig const sanitizedConfig = sanitized as SanitizedCollectionConfig
sanitizedConfig.joins = joins sanitizedConfig.joins = joins
sanitizedConfig.polymorphicJoins = polymorphicJoins
sanitizedConfig.flattenedFields = flattenAllFields({ fields: sanitizedConfig.fields }) sanitizedConfig.flattenedFields = flattenAllFields({ fields: sanitizedConfig.fields })

View File

@@ -548,6 +548,12 @@ export interface SanitizedCollectionConfig
* Object of collections to join 'Join Fields object keyed by collection * Object of collections to join 'Join Fields object keyed by collection
*/ */
joins: SanitizedJoins joins: SanitizedJoins
/**
* List of all polymorphic join fields
*/
polymorphicJoins: SanitizedJoin[]
slug: CollectionSlug slug: CollectionSlug
upload: SanitizedUploadConfig upload: SanitizedUploadConfig
versions: SanitizedCollectionVersions versions: SanitizedCollectionVersions

View File

@@ -107,6 +107,18 @@ export function getLocalizedPaths({
return paths return paths
} }
if (currentPath === 'relationTo') {
lastIncompletePath.path = currentPath
lastIncompletePath.complete = true
lastIncompletePath.field = {
name: 'relationTo',
type: 'select',
options: Object.keys(payload.collections),
}
return paths
}
if (!matchedField && currentPath === 'id' && i === pathSegments.length - 1) { if (!matchedField && currentPath === 'id' && i === pathSegments.length - 1) {
lastIncompletePath.path = currentPath lastIncompletePath.path = currentPath
const idField: Field = { const idField: Field = {

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore // @ts-strict-ignore
import type { SanitizedCollectionConfig } from '../collections/config/types.js' import type { SanitizedCollectionConfig, SanitizedJoin } from '../collections/config/types.js'
import type { JoinQuery, PayloadRequest } from '../types/index.js' import type { JoinQuery, PayloadRequest } from '../types/index.js'
import executeAccess from '../auth/executeAccess.js' import executeAccess from '../auth/executeAccess.js'
@@ -14,6 +14,70 @@ type Args = {
req: PayloadRequest req: PayloadRequest
} }
const sanitizeJoinFieldQuery = async ({
collectionSlug,
errors,
join,
joinsQuery,
overrideAccess,
promises,
req,
}: {
collectionSlug: string
errors: { path: string }[]
join: SanitizedJoin
joinsQuery: JoinQuery
overrideAccess: boolean
promises: Promise<void>[]
req: PayloadRequest
}) => {
const { joinPath } = join
if (joinsQuery[joinPath] === false) {
return
}
const joinCollectionConfig = req.payload.collections[collectionSlug].config
const accessResult = !overrideAccess
? await executeAccess({ disableErrors: true, req }, joinCollectionConfig.access.read)
: true
if (accessResult === false) {
joinsQuery[joinPath] = false
return
}
if (!joinsQuery[joinPath]) {
joinsQuery[joinPath] = {}
}
const joinQuery = joinsQuery[joinPath]
if (!joinQuery.where) {
joinQuery.where = {}
}
if (join.field.where) {
joinQuery.where = combineQueries(joinQuery.where, join.field.where)
}
promises.push(
validateQueryPaths({
collectionConfig: joinCollectionConfig,
errors,
overrideAccess,
req,
// incoming where input, but we shouldn't validate generated from the access control.
where: joinQuery.where,
}),
)
if (typeof accessResult === 'object') {
joinQuery.where = combineQueries(joinQuery.where, accessResult)
}
}
/** /**
* * Validates `where` for each join * * Validates `where` for each join
* * Combines the access result for joined collection * * Combines the access result for joined collection
@@ -37,51 +101,31 @@ export const sanitizeJoinQuery = async ({
const promises: Promise<void>[] = [] const promises: Promise<void>[] = []
for (const collectionSlug in collectionConfig.joins) { for (const collectionSlug in collectionConfig.joins) {
for (const { field, joinPath } of collectionConfig.joins[collectionSlug]) { for (const join of collectionConfig.joins[collectionSlug]) {
if (joinsQuery[joinPath] === false) { await sanitizeJoinFieldQuery({
continue collectionSlug,
}
const joinCollectionConfig = req.payload.collections[collectionSlug].config
const accessResult = !overrideAccess
? await executeAccess({ disableErrors: true, req }, joinCollectionConfig.access.read)
: true
if (accessResult === false) {
joinsQuery[joinPath] = false
continue
}
if (!joinsQuery[joinPath]) {
joinsQuery[joinPath] = {}
}
const joinQuery = joinsQuery[joinPath]
if (!joinQuery.where) {
joinQuery.where = {}
}
if (field.where) {
joinQuery.where = combineQueries(joinQuery.where, field.where)
}
promises.push(
validateQueryPaths({
collectionConfig: joinCollectionConfig,
errors, errors,
join,
joinsQuery,
overrideAccess, overrideAccess,
promises,
req, req,
// incoming where input, but we shouldn't validate generated from the access control. })
where: joinQuery.where,
}),
)
if (typeof accessResult === 'object') {
joinQuery.where = combineQueries(joinQuery.where, accessResult)
} }
} }
for (const join of collectionConfig.polymorphicJoins) {
for (const collectionSlug of join.field.collection) {
await sanitizeJoinFieldQuery({
collectionSlug,
errors,
join,
joinsQuery,
overrideAccess,
promises,
req,
})
}
} }
await Promise.all(promises) await Promise.all(promises)

View File

@@ -311,7 +311,7 @@ export const createClientField = ({
const field = clientField as JoinFieldClient const field = clientField as JoinFieldClient
field.targetField = { field.targetField = {
relationTo: field.targetField.relationTo, relationTo: field.targetField?.relationTo,
} }
break break

View File

@@ -1,7 +1,11 @@
// @ts-strict-ignore // @ts-strict-ignore
import { deepMergeSimple } from '@payloadcms/translations/utilities' import { deepMergeSimple } from '@payloadcms/translations/utilities'
import type { CollectionConfig, SanitizedJoins } from '../../collections/config/types.js' import type {
CollectionConfig,
SanitizedJoin,
SanitizedJoins,
} from '../../collections/config/types.js'
import type { Config, SanitizedConfig } from '../../config/types.js' import type { Config, SanitizedConfig } from '../../config/types.js'
import type { Field } from './types.js' import type { Field } from './types.js'
@@ -33,6 +37,7 @@ type Args = {
*/ */
joins?: SanitizedJoins joins?: SanitizedJoins
parentIsLocalized: boolean parentIsLocalized: boolean
polymorphicJoins?: SanitizedJoin[]
/** /**
* If true, a richText field will require an editor property to be set, as the sanitizeFields function will not add it from the payload config if not present. * If true, a richText field will require an editor property to be set, as the sanitizeFields function will not add it from the payload config if not present.
@@ -59,6 +64,7 @@ export const sanitizeFields = async ({
joinPath = '', joinPath = '',
joins, joins,
parentIsLocalized, parentIsLocalized,
polymorphicJoins,
requireFieldLevelRichTextEditor = false, requireFieldLevelRichTextEditor = false,
richTextSanitizationPromises, richTextSanitizationPromises,
validRelationships, validRelationships,
@@ -104,7 +110,7 @@ export const sanitizeFields = async ({
} }
if (field.type === 'join') { if (field.type === 'join') {
sanitizeJoinField({ config, field, joinPath, joins, parentIsLocalized }) sanitizeJoinField({ config, field, joinPath, joins, parentIsLocalized, polymorphicJoins })
} }
if (field.type === 'relationship' || field.type === 'upload') { if (field.type === 'relationship' || field.type === 'upload') {
@@ -265,6 +271,7 @@ export const sanitizeFields = async ({
: joinPath, : joinPath,
joins, joins,
parentIsLocalized: parentIsLocalized || fieldIsLocalized(field), parentIsLocalized: parentIsLocalized || fieldIsLocalized(field),
polymorphicJoins,
requireFieldLevelRichTextEditor, requireFieldLevelRichTextEditor,
richTextSanitizationPromises, richTextSanitizationPromises,
validRelationships, validRelationships,
@@ -285,6 +292,7 @@ export const sanitizeFields = async ({
joinPath: tabHasName(tab) ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath, joinPath: tabHasName(tab) ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath,
joins, joins,
parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized), parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized),
polymorphicJoins,
requireFieldLevelRichTextEditor, requireFieldLevelRichTextEditor,
richTextSanitizationPromises, richTextSanitizationPromises,
validRelationships, validRelationships,

View File

@@ -18,12 +18,16 @@ export const sanitizeJoinField = ({
joinPath, joinPath,
joins, joins,
parentIsLocalized, parentIsLocalized,
polymorphicJoins,
validateOnly,
}: { }: {
config: Config config: Config
field: FlattenedJoinField | JoinField field: FlattenedJoinField | JoinField
joinPath?: string joinPath?: string
joins?: SanitizedJoins joins?: SanitizedJoins
parentIsLocalized: boolean parentIsLocalized: boolean
polymorphicJoins?: SanitizedJoin[]
validateOnly?: boolean
}) => { }) => {
// the `joins` arg is not passed for globals or when recursing on fields that do not allow a join field // the `joins` arg is not passed for globals or when recursing on fields that do not allow a join field
if (typeof joins === 'undefined') { if (typeof joins === 'undefined') {
@@ -38,6 +42,32 @@ export const sanitizeJoinField = ({
parentIsLocalized, parentIsLocalized,
targetField: undefined, targetField: undefined,
} }
if (Array.isArray(field.collection)) {
for (const collection of field.collection) {
const sanitizedField = {
...field,
collection,
} as FlattenedJoinField
sanitizeJoinField({
config,
field: sanitizedField,
joinPath,
joins,
parentIsLocalized,
polymorphicJoins,
validateOnly: true,
})
}
if (Array.isArray(polymorphicJoins)) {
polymorphicJoins.push(join)
}
return
}
const joinCollection = config.collections.find( const joinCollection = config.collections.find(
(collection) => collection.slug === field.collection, (collection) => collection.slug === field.collection,
) )
@@ -109,6 +139,10 @@ export const sanitizeJoinField = ({
throw new InvalidFieldJoin(join.field) throw new InvalidFieldJoin(join.field)
} }
if (validateOnly) {
return
}
join.targetField = joinRelationship join.targetField = joinRelationship
// override the join field localized property to use whatever the relationship field has // override the join field localized property to use whatever the relationship field has

View File

@@ -1478,7 +1478,7 @@ export type JoinField = {
/** /**
* The slug of the collection to relate with. * The slug of the collection to relate with.
*/ */
collection: CollectionSlug collection: CollectionSlug | CollectionSlug[]
defaultLimit?: number defaultLimit?: number
defaultSort?: Sort defaultSort?: Sort
defaultValue?: never defaultValue?: never
@@ -1504,6 +1504,7 @@ export type JoinField = {
* A string for the field in the collection being joined to. * A string for the field in the collection being joined to.
*/ */
on: string on: string
sanitizedMany?: JoinField[]
type: 'join' type: 'join'
validate?: never validate?: never
where?: Where where?: Where

View File

@@ -22,6 +22,7 @@ type PopulateArgs = {
showHiddenFields: boolean showHiddenFields: boolean
} }
// TODO: this function is mess, refactor logic
const populate = async ({ const populate = async ({
currentDepth, currentDepth,
data, data,
@@ -41,14 +42,24 @@ const populate = async ({
const dataToUpdate = dataReference const dataToUpdate = dataReference
let relation let relation
if (field.type === 'join') { if (field.type === 'join') {
relation = field.collection relation = Array.isArray(field.collection) ? data.relationTo : field.collection
} else { } else {
relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo
} }
const relatedCollection = req.payload.collections[relation] const relatedCollection = req.payload.collections[relation]
if (relatedCollection) { if (relatedCollection) {
let id = field.type !== 'join' && Array.isArray(field.relationTo) ? data.value : data let id: unknown
if (field.type === 'join' && Array.isArray(field.collection)) {
id = data.value
} else if (field.type !== 'join' && Array.isArray(field.relationTo)) {
id = data.value
} else {
id = data
}
let relationshipValue let relationshipValue
const shouldPopulate = depth && currentDepth <= depth const shouldPopulate = depth && currentDepth <= depth
@@ -89,12 +100,20 @@ const populate = async ({
if (typeof index === 'number' && typeof key === 'string') { if (typeof index === 'number' && typeof key === 'string') {
if (field.type !== 'join' && Array.isArray(field.relationTo)) { if (field.type !== 'join' && Array.isArray(field.relationTo)) {
dataToUpdate[field.name][key][index].value = relationshipValue dataToUpdate[field.name][key][index].value = relationshipValue
} else {
if (field.type === 'join' && Array.isArray(field.collection)) {
dataToUpdate[field.name][key][index].value = relationshipValue
} else { } else {
dataToUpdate[field.name][key][index] = relationshipValue dataToUpdate[field.name][key][index] = relationshipValue
} }
}
} else if (typeof index === 'number' || typeof key === 'string') { } else if (typeof index === 'number' || typeof key === 'string') {
if (field.type === 'join') { if (field.type === 'join') {
if (!Array.isArray(field.collection)) {
dataToUpdate[field.name].docs[index ?? key] = relationshipValue dataToUpdate[field.name].docs[index ?? key] = relationshipValue
} else {
dataToUpdate[field.name].docs[index ?? key].value = relationshipValue
}
} else if (Array.isArray(field.relationTo)) { } else if (Array.isArray(field.relationTo)) {
dataToUpdate[field.name][index ?? key].value = relationshipValue dataToUpdate[field.name][index ?? key].value = relationshipValue
} else { } else {
@@ -102,11 +121,15 @@ const populate = async ({
} }
} else if (field.type !== 'join' && Array.isArray(field.relationTo)) { } else if (field.type !== 'join' && Array.isArray(field.relationTo)) {
dataToUpdate[field.name].value = relationshipValue dataToUpdate[field.name].value = relationshipValue
} else {
if (field.type === 'join' && Array.isArray(field.collection)) {
dataToUpdate[field.name].value = relationshipValue
} else { } else {
dataToUpdate[field.name] = relationshipValue dataToUpdate[field.name] = relationshipValue
} }
} }
} }
}
type PromiseArgs = { type PromiseArgs = {
currentDepth: number currentDepth: number
@@ -185,7 +208,10 @@ export const relationshipPopulationPromise = async ({
if (relatedDoc) { if (relatedDoc) {
await populate({ await populate({
currentDepth, currentDepth,
data: relatedDoc?.id ? relatedDoc.id : relatedDoc, data:
!(field.type === 'join' && Array.isArray(field.collection)) && relatedDoc?.id
? relatedDoc.id
: relatedDoc,
dataReference: resultingDoc, dataReference: resultingDoc,
depth: populateDepth, depth: populateDepth,
draft, draft,

View File

@@ -1246,6 +1246,7 @@ export type {
FlattenedBlocksField, FlattenedBlocksField,
FlattenedField, FlattenedField,
FlattenedGroupField, FlattenedGroupField,
FlattenedJoinField,
FlattenedTabAsField, FlattenedTabAsField,
GroupField, GroupField,
GroupFieldClient, GroupFieldClient,

View File

@@ -88,7 +88,7 @@ function generateEntitySelectSchemas(
function generateCollectionJoinsSchemas(collections: SanitizedCollectionConfig[]): JSONSchema4 { function generateCollectionJoinsSchemas(collections: SanitizedCollectionConfig[]): JSONSchema4 {
const properties = [...collections].reduce<Record<string, JSONSchema4>>( const properties = [...collections].reduce<Record<string, JSONSchema4>>(
(acc, { slug, joins }) => { (acc, { slug, joins, polymorphicJoins }) => {
const schema = { const schema = {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -106,6 +106,14 @@ function generateCollectionJoinsSchemas(collections: SanitizedCollectionConfig[]
} }
} }
for (const join of polymorphicJoins) {
schema.properties[join.joinPath] = {
type: 'string',
enum: join.field.collection,
}
schema.required.push(join.joinPath)
}
if (Object.keys(schema.properties).length > 0) { if (Object.keys(schema.properties).length > 0) {
acc[slug] = schema acc[slug] = schema
} }
@@ -387,14 +395,33 @@ export function fieldsToJSONSchema(
} }
case 'join': { case 'join': {
fieldSchema = { let items: JSONSchema4
...baseFieldSchema,
type: withNullableJSONSchemaType('object', false), if (Array.isArray(field.collection)) {
items = {
oneOf: field.collection.map((collection) => ({
type: 'object',
additionalProperties: false, additionalProperties: false,
properties: { properties: {
docs: { relationTo: {
type: withNullableJSONSchemaType('array', false), const: collection,
items: { },
value: {
oneOf: [
{
type: collectionIDFieldTypes[collection],
},
{
$ref: `#/definitions/${collection}`,
},
],
},
},
required: ['collectionSlug', 'value'],
})),
}
} else {
items = {
oneOf: [ oneOf: [
{ {
type: collectionIDFieldTypes[field.collection], type: collectionIDFieldTypes[field.collection],
@@ -403,7 +430,17 @@ export function fieldsToJSONSchema(
$ref: `#/definitions/${field.collection}`, $ref: `#/definitions/${field.collection}`,
}, },
], ],
}, }
}
fieldSchema = {
...baseFieldSchema,
type: withNullableJSONSchemaType('object', false),
additionalProperties: false,
properties: {
docs: {
type: withNullableJSONSchemaType('array', false),
items,
}, },
hasNextPage: { type: withNullableJSONSchemaType('boolean', false) }, hasNextPage: { type: withNullableJSONSchemaType('boolean', false) },
}, },

View File

@@ -18,6 +18,12 @@
padding-bottom: var(--base); padding-bottom: var(--base);
} }
&__add-new-polymorphic .btn__label {
display: flex;
text-wrap: nowrap;
align-items: center;
}
.table { .table {
table { table {
width: 100%; width: 100%;

View File

@@ -1,5 +1,12 @@
'use client' 'use client'
import type { Column, JoinFieldClient, ListQuery, PaginatedDocs, Where } from 'payload' import type {
CollectionSlug,
Column,
JoinFieldClient,
ListQuery,
PaginatedDocs,
Where,
} from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useCallback, useEffect, useState } from 'react' import React, { Fragment, useCallback, useEffect, useState } from 'react'
@@ -10,6 +17,7 @@ import { Button } from '../../elements/Button/index.js'
import { Pill } from '../../elements/Pill/index.js' import { Pill } from '../../elements/Pill/index.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js' import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { ChevronIcon } from '../../icons/Chevron/index.js' import { ChevronIcon } from '../../icons/Chevron/index.js'
import { PlusIcon } from '../../icons/Plus/index.js'
import { useAuth } from '../../providers/Auth/index.js' import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { ListQueryProvider } from '../../providers/ListQuery/index.js' import { ListQueryProvider } from '../../providers/ListQuery/index.js'
@@ -17,9 +25,10 @@ import { useServerFunctions } from '../../providers/ServerFunctions/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js' import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js'
import { AnimateHeight } from '../AnimateHeight/index.js' import { AnimateHeight } from '../AnimateHeight/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js'
import './index.scss' import './index.scss'
import { ColumnSelector } from '../ColumnSelector/index.js'
import { useDocumentDrawer } from '../DocumentDrawer/index.js' import { useDocumentDrawer } from '../DocumentDrawer/index.js'
import { Popup, PopupList } from '../Popup/index.js'
import { RelationshipProvider } from '../Table/RelationshipProvider/index.js' import { RelationshipProvider } from '../Table/RelationshipProvider/index.js'
import { TableColumnsProvider } from '../TableColumns/index.js' import { TableColumnsProvider } from '../TableColumns/index.js'
import { DrawerLink } from './cells/DrawerLink/index.js' import { DrawerLink } from './cells/DrawerLink/index.js'
@@ -37,7 +46,12 @@ type RelationshipTableComponentProps = {
readonly initialData?: PaginatedDocs readonly initialData?: PaginatedDocs
readonly initialDrawerData?: DocumentDrawerProps['initialData'] readonly initialDrawerData?: DocumentDrawerProps['initialData']
readonly Label?: React.ReactNode readonly Label?: React.ReactNode
readonly relationTo: string readonly parent?: {
collectionSlug: CollectionSlug
id: number | string
joinPath: string
}
readonly relationTo: string | string[]
} }
export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (props) => { export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (props) => {
@@ -51,10 +65,11 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
initialData: initialDataFromProps, initialData: initialDataFromProps,
initialDrawerData, initialDrawerData,
Label, Label,
parent,
relationTo, relationTo,
} = props } = props
const [Table, setTable] = useState<React.ReactNode>(null) const [Table, setTable] = useState<React.ReactNode>(null)
const { getEntityConfig } = useConfig() const { config, getEntityConfig } = useConfig()
const { permissions } = useAuth() const { permissions } = useAuth()
@@ -86,6 +101,9 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
const [collectionConfig] = useState(() => getEntityConfig({ collectionSlug: relationTo })) const [collectionConfig] = useState(() => getEntityConfig({ collectionSlug: relationTo }))
const [selectedCollection, setSelectedCollection] = useState(
Array.isArray(relationTo) ? undefined : relationTo,
)
const [isLoadingTable, setIsLoadingTable] = useState(!disableTable) const [isLoadingTable, setIsLoadingTable] = useState(!disableTable)
const [data, setData] = useState<PaginatedDocs>(initialData) const [data, setData] = useState<PaginatedDocs>(initialData)
const [columnState, setColumnState] = useState<Column[]>() const [columnState, setColumnState] = useState<Column[]>()
@@ -95,8 +113,8 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
const renderTable = useCallback( const renderTable = useCallback(
async (docs?: PaginatedDocs['docs']) => { async (docs?: PaginatedDocs['docs']) => {
const newQuery: ListQuery = { const newQuery: ListQuery = {
limit: String(field.defaultLimit || collectionConfig.admin.pagination.defaultLimit), limit: String(field?.defaultLimit || collectionConfig?.admin?.pagination?.defaultLimit),
sort: field.defaultSort || collectionConfig.defaultSort, sort: field.defaultSort || collectionConfig?.defaultSort,
...(query || {}), ...(query || {}),
where: { ...(query?.where || {}) }, where: { ...(query?.where || {}) },
} }
@@ -122,6 +140,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
columns: defaultColumns, columns: defaultColumns,
docs, docs,
enableRowSelections: false, enableRowSelections: false,
parent,
query: newQuery, query: newQuery,
renderRowTypes: true, renderRowTypes: true,
tableAppearance: 'condensed', tableAppearance: 'condensed',
@@ -136,12 +155,13 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
field.defaultLimit, field.defaultLimit,
field.defaultSort, field.defaultSort,
field.admin.defaultColumns, field.admin.defaultColumns,
collectionConfig.admin.pagination.defaultLimit, collectionConfig?.admin?.pagination?.defaultLimit,
collectionConfig.defaultSort, collectionConfig?.defaultSort,
query, query,
filterOptions, filterOptions,
getTableState, getTableState,
relationTo, relationTo,
parent,
], ],
) )
@@ -155,8 +175,9 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
handleTableRender(query, disableTable) handleTableRender(query, disableTable)
}, [query, disableTable]) }, [query, disableTable])
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer, openDrawer }] = useDocumentDrawer({ const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer, isDrawerOpen, openDrawer }] =
collectionSlug: relationTo, useDocumentDrawer({
collectionSlug: selectedCollection,
}) })
const onDrawerSave = useCallback<DocumentDrawerProps['onSave']>( const onDrawerSave = useCallback<DocumentDrawerProps['onSave']>(
@@ -174,12 +195,13 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
void renderTable(withNewOrUpdatedDoc) void renderTable(withNewOrUpdatedDoc)
}, },
[data.docs, renderTable], [data?.docs, renderTable],
) )
const onDrawerCreate = useCallback<DocumentDrawerProps['onSave']>( const onDrawerCreate = useCallback<DocumentDrawerProps['onSave']>(
(args) => { (args) => {
closeDrawer() closeDrawer()
void onDrawerSave(args) void onDrawerSave(args)
}, },
[closeDrawer, onDrawerSave], [closeDrawer, onDrawerSave],
@@ -190,23 +212,80 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
const newDocs = data.docs.filter((doc) => doc.id !== args.id) const newDocs = data.docs.filter((doc) => doc.id !== args.id)
void renderTable(newDocs) void renderTable(newDocs)
}, },
[data.docs, renderTable], [data?.docs, renderTable],
) )
const preferenceKey = `${relationTo}-list` const preferenceKey = `${Array.isArray(relationTo) ? `${parent.collectionSlug}-${parent.joinPath}` : relationTo}-list`
const canCreate = allowCreate !== false && permissions?.collections?.[relationTo]?.create 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 ( return (
<div className={baseClass}> <div className={baseClass}>
<div className={`${baseClass}__header`}> <div className={`${baseClass}__header`}>
{Label} {Label}
<div className={`${baseClass}__actions`}> <div className={`${baseClass}__actions`}>
{canCreate && ( {!Array.isArray(relationTo) && canCreate && (
<DocumentDrawerToggler className={`${baseClass}__add-new`}> <DocumentDrawerToggler className={`${baseClass}__add-new`}>
{i18n.t('fields:addNew')} {i18n.t('fields:addNew')}
</DocumentDrawerToggler> </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 <Pill
aria-controls={`${baseClass}-columns`} aria-controls={`${baseClass}-columns`}
aria-expanded={openColumnSelector} aria-expanded={openColumnSelector}
@@ -226,11 +305,13 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
<p>{t('general:loading')}</p> <p>{t('general:loading')}</p>
) : ( ) : (
<Fragment> <Fragment>
{data.docs && data.docs.length === 0 && ( {data?.docs && data.docs.length === 0 && (
<div className={`${baseClass}__no-results`}> <div className={`${baseClass}__no-results`}>
<p> <p>
{i18n.t('general:noResults', { {i18n.t('general:noResults', {
label: getTranslation(collectionConfig?.labels?.plural, i18n), label: Array.isArray(relationTo)
? i18n.t('general:documents')
: getTranslation(collectionConfig?.labels?.plural, i18n),
})} })}
</p> </p>
{canCreate && ( {canCreate && (
@@ -242,7 +323,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
)} )}
</div> </div>
)} )}
{data.docs && data.docs.length > 0 && ( {data?.docs && data.docs.length > 0 && (
<RelationshipProvider> <RelationshipProvider>
<ListQueryProvider <ListQueryProvider
data={data} data={data}
@@ -253,7 +334,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
onQueryChange={setQuery} onQueryChange={setQuery}
> >
<TableColumnsProvider <TableColumnsProvider
collectionSlug={relationTo} collectionSlug={Array.isArray(relationTo) ? relationTo[0] : relationTo}
columnState={columnState} columnState={columnState}
docs={data.docs} docs={data.docs}
LinkedCellOverride={ LinkedCellOverride={
@@ -273,7 +354,9 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
id={`${baseClass}-columns`} id={`${baseClass}-columns`}
> >
<div className={`${baseClass}__columns-inner`}> <div className={`${baseClass}__columns-inner`}>
{collectionConfig && (
<ColumnSelector collectionSlug={collectionConfig.slug} /> <ColumnSelector collectionSlug={collectionConfig.slug} />
)}
</div> </div>
</AnimateHeight> </AnimateHeight>
{Table} {Table}

View File

@@ -0,0 +1,303 @@
// Dirty copy of buildColumnState.tsx with some changes to not break things
import type { I18nClient } from '@payloadcms/translations'
import type {
ClientField,
Column,
DefaultCellComponentProps,
DefaultServerCellComponentProps,
Field,
ListPreferences,
PaginatedDocs,
Payload,
SanitizedCollectionConfig,
StaticLabel,
} from 'payload'
import { MissingEditorProp } from 'payload'
import {
fieldIsHiddenOrDisabled,
fieldIsID,
fieldIsPresentationalOnly,
flattenTopLevelFields,
} from 'payload/shared'
import React from 'react'
import type { SortColumnProps } from '../SortColumn/index.js'
import {
RenderCustomComponent,
RenderDefaultCell,
SortColumn,
// eslint-disable-next-line payload/no-imports-from-exports-dir
} from '../../exports/client/index.js'
import { RenderServerComponent } from '../RenderServerComponent/index.js'
import { filterFields } from './filterFields.js'
type Args = {
beforeRows?: Column[]
columnPreferences: ListPreferences['columns']
columns?: ListPreferences['columns']
customCellProps: DefaultCellComponentProps['customCellProps']
docs: PaginatedDocs['docs']
enableRowSelections: boolean
enableRowTypes?: boolean
fields: ClientField[]
i18n: I18nClient
payload: Payload
sortColumnProps?: Partial<SortColumnProps>
useAsTitle: SanitizedCollectionConfig['admin']['useAsTitle']
}
export const buildPolymorphicColumnState = (args: Args): Column[] => {
const {
beforeRows,
columnPreferences,
columns,
customCellProps,
docs,
enableRowSelections,
fields,
i18n,
payload,
sortColumnProps,
useAsTitle,
} = args
// clientFields contains the fake `id` column
let sortedFieldMap = flattenTopLevelFields(filterFields(fields), true) as ClientField[]
let _sortedFieldMap = flattenTopLevelFields(filterFields(fields), true) as Field[] // TODO: think of a way to avoid this additional flatten
// place the `ID` field first, if it exists
// do the same for the `useAsTitle` field with precedence over the `ID` field
// then sort the rest of the fields based on the `defaultColumns` or `columnPreferences`
const idFieldIndex = sortedFieldMap?.findIndex((field) => fieldIsID(field))
if (idFieldIndex > -1) {
const idField = sortedFieldMap.splice(idFieldIndex, 1)[0]
sortedFieldMap.unshift(idField)
}
const useAsTitleFieldIndex = useAsTitle
? sortedFieldMap.findIndex((field) => 'name' in field && field.name === useAsTitle)
: -1
if (useAsTitleFieldIndex > -1) {
const useAsTitleField = sortedFieldMap.splice(useAsTitleFieldIndex, 1)[0]
sortedFieldMap.unshift(useAsTitleField)
}
const sortTo = columnPreferences || columns
const sortFieldMap = (fieldMap, sortTo) =>
fieldMap?.sort((a, b) => {
const aIndex = sortTo.findIndex((column) => 'name' in a && column.accessor === a.name)
const bIndex = sortTo.findIndex((column) => 'name' in b && column.accessor === b.name)
if (aIndex === -1 && bIndex === -1) {
return 0
}
if (aIndex === -1) {
return 1
}
if (bIndex === -1) {
return -1
}
return aIndex - bIndex
})
if (sortTo) {
// sort the fields to the order of `defaultColumns` or `columnPreferences`
sortedFieldMap = sortFieldMap(sortedFieldMap, sortTo)
_sortedFieldMap = sortFieldMap(_sortedFieldMap, sortTo) // TODO: think of a way to avoid this additional sort
}
const activeColumnsIndices = []
const sorted: Column[] = sortedFieldMap?.reduce((acc, field, index) => {
if (fieldIsHiddenOrDisabled(field) && !fieldIsID(field)) {
return acc
}
const _field = _sortedFieldMap.find(
(f) => 'name' in field && 'name' in f && f.name === field.name,
)
const columnPreference = columnPreferences?.find(
(preference) => field && 'name' in field && preference.accessor === field.name,
)
let active = false
if (columnPreference) {
active = columnPreference.active
} else if (columns && Array.isArray(columns) && columns.length > 0) {
active = columns.find(
(column) => field && 'name' in field && column.accessor === field.name,
)?.active
} else if (activeColumnsIndices.length < 4) {
active = true
}
if (active && !activeColumnsIndices.includes(index)) {
activeColumnsIndices.push(index)
}
// const CustomLabelToRender =
// _field &&
// 'admin' in _field &&
// 'components' in _field.admin &&
// 'Label' in _field.admin.components &&
// _field.admin.components.Label !== undefined // let it return `null`
// ? _field.admin.components.Label
// : undefined
// // TODO: customComponent will be optional in v4
// const clientProps: Omit<ClientComponentProps, 'customComponents'> = {
// field,
// }
// const customLabelServerProps: Pick<
// ServerComponentProps,
// 'clientField' | 'collectionSlug' | 'field' | 'i18n' | 'payload'
// > = {
// clientField: field,
// collectionSlug: collectionConfig.slug,
// field: _field,
// i18n,
// payload,
// }
const CustomLabel = undefined
const fieldAffectsDataSubFields =
field &&
field.type &&
(field.type === 'array' || field.type === 'group' || field.type === 'blocks')
const Heading = (
<SortColumn
disable={fieldAffectsDataSubFields || fieldIsPresentationalOnly(field) || undefined}
Label={CustomLabel}
label={field && 'label' in field ? (field.label as StaticLabel) : undefined}
name={'name' in field ? field.name : undefined}
{...(sortColumnProps || {})}
/>
)
const column: Column = {
accessor: 'name' in field ? field.name : undefined,
active,
CustomLabel,
field,
Heading,
renderedCells: active
? docs.map((doc, i) => {
const isLinkedColumn = index === activeColumnsIndices[0]
const collectionSlug = doc.relationTo
doc = doc.value
const baseCellClientProps: DefaultCellComponentProps = {
cellData: undefined,
collectionSlug,
customCellProps,
field,
rowData: undefined,
}
const cellClientProps: DefaultCellComponentProps = {
...baseCellClientProps,
cellData: 'name' in field ? doc[field.name] : undefined,
link: isLinkedColumn,
rowData: doc,
}
const cellServerProps: DefaultServerCellComponentProps = {
cellData: cellClientProps.cellData,
className: baseCellClientProps.className,
collectionConfig: payload.collections[collectionSlug].config,
collectionSlug,
columnIndex: baseCellClientProps.columnIndex,
customCellProps: baseCellClientProps.customCellProps,
field: _field,
i18n,
link: cellClientProps.link,
onClick: baseCellClientProps.onClick,
payload,
rowData: doc,
}
let CustomCell = null
if (_field?.type === 'richText') {
if (!_field?.editor) {
throw new MissingEditorProp(_field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof _field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
if (!_field.admin) {
_field.admin = {}
}
if (!_field.admin.components) {
_field.admin.components = {}
}
CustomCell = RenderServerComponent({
clientProps: cellClientProps,
Component: _field.editor.CellComponent,
importMap: payload.importMap,
serverProps: cellServerProps,
})
} else {
const CustomCellComponent = _field?.admin?.components?.Cell
if (CustomCellComponent) {
CustomCell = RenderServerComponent({
clientProps: cellClientProps,
Component: CustomCellComponent,
importMap: payload.importMap,
serverProps: cellServerProps,
})
} else {
CustomCell = undefined
}
}
return (
<RenderCustomComponent
CustomComponent={CustomCell}
Fallback={
<RenderDefaultCell
clientProps={cellClientProps}
columnIndex={index}
enableRowSelections={enableRowSelections}
isLinkedColumn={isLinkedColumn}
/>
}
key={`${i}-${index}`}
/>
)
})
: [],
}
acc.push(column)
return acc
}, [])
if (beforeRows) {
sorted.unshift(...beforeRows)
}
return sorted
}

View File

@@ -25,7 +25,7 @@ export const useTableColumns = (): ITableColumns => useContext(TableColumnContex
type Props = { type Props = {
readonly children: React.ReactNode readonly children: React.ReactNode
readonly collectionSlug: string readonly collectionSlug: string | string[]
readonly columnState: Column[] readonly columnState: Column[]
readonly docs: any[] readonly docs: any[]
readonly enableRowSelections?: boolean readonly enableRowSelections?: boolean
@@ -68,7 +68,9 @@ export const TableColumnsProvider: React.FC<Props> = ({
collectionSlug, collectionSlug,
}) })
const prevCollection = React.useRef<SanitizedCollectionConfig['slug']>(collectionSlug) const prevCollection = React.useRef<SanitizedCollectionConfig['slug']>(
Array.isArray(collectionSlug) ? collectionSlug[0] : collectionSlug,
)
const { getPreference } = usePreferences() const { getPreference } = usePreferences()
const [tableColumns, setTableColumns] = React.useState(columnState) const [tableColumns, setTableColumns] = React.useState(columnState)
@@ -232,14 +234,15 @@ export const TableColumnsProvider: React.FC<Props> = ({
React.useEffect(() => { React.useEffect(() => {
const sync = async () => { const sync = async () => {
const collectionHasChanged = prevCollection.current !== collectionSlug const defaultCollection = Array.isArray(collectionSlug) ? collectionSlug[0] : collectionSlug
const collectionHasChanged = prevCollection.current !== defaultCollection
if (collectionHasChanged || !listPreferences) { if (collectionHasChanged || !listPreferences) {
const currentPreferences = await getPreference<{ const currentPreferences = await getPreference<{
columns: ListPreferences['columns'] columns: ListPreferences['columns']
}>(preferenceKey) }>(preferenceKey)
prevCollection.current = collectionSlug prevCollection.current = defaultCollection
if (currentPreferences?.columns) { if (currentPreferences?.columns) {
// setTableColumns() // setTableColumns()

View File

@@ -160,7 +160,9 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
} }
} }
const where = { const where = Array.isArray(collection)
? {}
: {
[on]: { [on]: {
equals: value, equals: value,
}, },
@@ -173,10 +175,12 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
} }
return where return where
}, [docID, field.targetField.relationTo, field.where, on, docConfig?.slug]) }, [docID, collection, field.targetField.relationTo, field.where, on, docConfig?.slug])
const initialDrawerData = useMemo(() => { const initialDrawerData = useMemo(() => {
const relatedCollection = getEntityConfig({ collectionSlug: field.collection }) const relatedCollection = getEntityConfig({
collectionSlug: Array.isArray(field.collection) ? field.collection[0] : field.collection,
})
return getInitialDrawerData({ return getInitialDrawerData({
collectionSlug: docConfig?.slug, collectionSlug: docConfig?.slug,
@@ -216,6 +220,15 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => {
)} )}
</h4> </h4>
} }
parent={
Array.isArray(collection)
? {
id: docID,
collectionSlug: docConfig.slug,
joinPath: path,
}
: undefined
}
relationTo={collection} relationTo={collection}
/> />
<RenderCustomComponent <RenderCustomComponent

View File

@@ -7,9 +7,10 @@ import type {
ListPreferences, ListPreferences,
PaginatedDocs, PaginatedDocs,
SanitizedCollectionConfig, SanitizedCollectionConfig,
Where,
} from 'payload' } from 'payload'
import { formatErrors } from 'payload' import { APIError, formatErrors } from 'payload'
import { isNumber } from 'payload/shared' import { isNumber } from 'payload/shared'
import { getClientConfig } from './getClientConfig.js' import { getClientConfig } from './getClientConfig.js'
@@ -73,6 +74,7 @@ export const buildTableState = async (
columns, columns,
docs: docsFromArgs, docs: docsFromArgs,
enableRowSelections, enableRowSelections,
parent,
query, query,
renderRowTypes, renderRowTypes,
req, req,
@@ -128,15 +130,19 @@ export const buildTableState = async (
let collectionConfig: SanitizedCollectionConfig let collectionConfig: SanitizedCollectionConfig
let clientCollectionConfig: ClientCollectionConfig let clientCollectionConfig: ClientCollectionConfig
if (!Array.isArray(collectionSlug)) {
if (req.payload.collections[collectionSlug]) { if (req.payload.collections[collectionSlug]) {
collectionConfig = req.payload.collections[collectionSlug].config collectionConfig = req.payload.collections[collectionSlug].config
clientCollectionConfig = clientConfig.collections.find( clientCollectionConfig = clientConfig.collections.find(
(collection) => collection.slug === collectionSlug, (collection) => collection.slug === collectionSlug,
) )
} }
}
const listPreferences = await upsertPreferences<ListPreferences>({ const listPreferences = await upsertPreferences<ListPreferences>({
key: `${collectionSlug}-list`, key: Array.isArray(collectionSlug)
? `${parent.collectionSlug}-${parent.joinPath}`
: `${collectionSlug}-list`,
req, req,
value: { value: {
columns, columns,
@@ -151,6 +157,57 @@ export const buildTableState = async (
// lookup docs, if desired, i.e. within `join` field which initialize with `depth: 0` // lookup docs, if desired, i.e. within `join` field which initialize with `depth: 0`
if (!docs || query) { if (!docs || query) {
if (Array.isArray(collectionSlug)) {
if (!parent) {
throw new APIError('Unexpected array of collectionSlug, parent must be providen')
}
const select = {}
let currentSelectRef = select
const segments = parent.joinPath.split('.')
for (let i = 0; i < segments.length; i++) {
currentSelectRef[segments[i]] = i === segments.length - 1 ? true : {}
currentSelectRef = currentSelectRef[segments[i]]
}
const joinQuery: { limit?: number; page?: number; sort?: string; where?: Where } = {
sort: query?.sort as string,
where: query?.where,
}
if (query) {
if (!Number.isNaN(Number(query.limit))) {
joinQuery.limit = Number(query.limit)
}
if (!Number.isNaN(Number(query.page))) {
joinQuery.limit = Number(query.limit)
}
}
let parentDoc = await payload.findByID({
id: parent.id,
collection: parent.collectionSlug,
depth: 1,
joins: {
[parent.joinPath]: joinQuery,
},
overrideAccess: false,
select,
user: req.user,
})
for (let i = 0; i < segments.length; i++) {
if (i === segments.length - 1) {
data = parentDoc[segments[i]]
docs = data.docs
} else {
parentDoc = parentDoc[segments[i]]
}
}
} else {
data = await payload.find({ data = await payload.find({
collection: collectionSlug, collection: collectionSlug,
depth: 0, depth: 0,
@@ -162,14 +219,16 @@ export const buildTableState = async (
user: req.user, user: req.user,
where: query?.where, where: query?.where,
}) })
docs = data.docs docs = data.docs
} }
}
const { columnState, Table } = renderTable({ const { columnState, Table } = renderTable({
clientCollectionConfig, clientCollectionConfig,
clientConfig,
collectionConfig, collectionConfig,
columnPreferences: undefined, // TODO, might not be needed collections: Array.isArray(collectionSlug) ? collectionSlug : undefined,
columnPreferences: Array.isArray(collectionSlug) ? listPreferences?.columns : undefined, // TODO, might not be neededcolumns,
columns, columns,
docs, docs,
enableRowSelections, enableRowSelections,
@@ -177,10 +236,16 @@ export const buildTableState = async (
payload, payload,
renderRowTypes, renderRowTypes,
tableAppearance, tableAppearance,
useAsTitle: collectionConfig.admin.useAsTitle, useAsTitle: Array.isArray(collectionSlug)
? payload.collections[collectionSlug[0]]?.config?.admin?.useAsTitle
: collectionConfig?.admin?.useAsTitle,
}) })
const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap) let renderedFilters
if (collectionConfig) {
renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap)
}
return { return {
data, data,

View File

@@ -1,5 +1,7 @@
import type { import type {
ClientCollectionConfig, ClientCollectionConfig,
ClientConfig,
ClientField,
CollectionConfig, CollectionConfig,
Field, Field,
ImportMap, ImportMap,
@@ -10,13 +12,14 @@ import type {
} from 'payload' } from 'payload'
import { getTranslation, type I18nClient } from '@payloadcms/translations' import { getTranslation, type I18nClient } from '@payloadcms/translations'
import { fieldIsHiddenOrDisabled, flattenTopLevelFields } from 'payload/shared' import { fieldAffectsData, fieldIsHiddenOrDisabled, flattenTopLevelFields } from 'payload/shared'
// eslint-disable-next-line payload/no-imports-from-exports-dir // eslint-disable-next-line payload/no-imports-from-exports-dir
import type { Column } from '../exports/client/index.js' import type { Column } from '../exports/client/index.js'
import { RenderServerComponent } from '../elements/RenderServerComponent/index.js' import { RenderServerComponent } from '../elements/RenderServerComponent/index.js'
import { buildColumnState } from '../elements/TableColumns/buildColumnState.js' import { buildColumnState } from '../elements/TableColumns/buildColumnState.js'
import { buildPolymorphicColumnState } from '../elements/TableColumns/buildPolymorphicColumnState.js'
import { filterFields } from '../elements/TableColumns/filterFields.js' import { filterFields } from '../elements/TableColumns/filterFields.js'
import { getInitialColumns } from '../elements/TableColumns/getInitialColumns.js' import { getInitialColumns } from '../elements/TableColumns/getInitialColumns.js'
@@ -50,7 +53,9 @@ export const renderFilters = (
export const renderTable = ({ export const renderTable = ({
clientCollectionConfig, clientCollectionConfig,
clientConfig,
collectionConfig, collectionConfig,
collections,
columnPreferences, columnPreferences,
columns: columnsFromArgs, columns: columnsFromArgs,
customCellProps, customCellProps,
@@ -62,8 +67,10 @@ export const renderTable = ({
tableAppearance, tableAppearance,
useAsTitle, useAsTitle,
}: { }: {
clientCollectionConfig: ClientCollectionConfig clientCollectionConfig?: ClientCollectionConfig
collectionConfig: SanitizedCollectionConfig clientConfig?: ClientConfig
collectionConfig?: SanitizedCollectionConfig
collections?: string[]
columnPreferences: ListPreferences['columns'] columnPreferences: ListPreferences['columns']
columns?: ListPreferences['columns'] columns?: ListPreferences['columns']
customCellProps?: Record<string, any> customCellProps?: Record<string, any>
@@ -80,6 +87,46 @@ export const renderTable = ({
Table: React.ReactNode Table: React.ReactNode
} => { } => {
// Ensure that columns passed as args comply with the field config, i.e. `hidden`, `disableListColumn`, etc. // Ensure that columns passed as args comply with the field config, i.e. `hidden`, `disableListColumn`, etc.
let columnState: Column[]
if (collections) {
const fields: ClientField[] = []
for (const collection of collections) {
const config = clientConfig.collections.find((each) => each.slug === collection)
for (const field of filterFields(config.fields)) {
if (fieldAffectsData(field)) {
if (fields.some((each) => fieldAffectsData(each) && each.name === field.name)) {
continue
}
}
fields.push(field)
}
}
const columns = columnsFromArgs
? columnsFromArgs?.filter((column) =>
flattenTopLevelFields(fields, true)?.some(
(field) => 'name' in field && field.name === column.accessor,
),
)
: getInitialColumns(fields, useAsTitle, [])
columnState = buildPolymorphicColumnState({
columnPreferences,
columns,
enableRowSelections,
fields,
i18n,
// sortColumnProps,
customCellProps,
docs,
payload,
useAsTitle,
})
} else {
const columns = columnsFromArgs const columns = columnsFromArgs
? columnsFromArgs?.filter((column) => ? columnsFromArgs?.filter((column) =>
flattenTopLevelFields(clientCollectionConfig.fields, true)?.some( flattenTopLevelFields(clientCollectionConfig.fields, true)?.some(
@@ -92,7 +139,7 @@ export const renderTable = ({
clientCollectionConfig?.admin?.defaultColumns, clientCollectionConfig?.admin?.defaultColumns,
) )
const columnState = buildColumnState({ columnState = buildColumnState({
clientCollectionConfig, clientCollectionConfig,
collectionConfig, collectionConfig,
columnPreferences, columnPreferences,
@@ -105,6 +152,7 @@ export const renderTable = ({
payload, payload,
useAsTitle, useAsTitle,
}) })
}
const columnsToUse = [...columnState] const columnsToUse = [...columnState]
@@ -119,8 +167,15 @@ export const renderTable = ({
hidden: true, hidden: true,
}, },
Heading: i18n.t('version:type'), Heading: i18n.t('version:type'),
renderedCells: docs.map((_, i) => ( renderedCells: docs.map((doc, i) => (
<Pill key={i}>{getTranslation(clientCollectionConfig.labels.singular, i18n)}</Pill> <Pill key={i}>
{getTranslation(
collections
? payload.collections[doc.relationTo].config.labels.singular
: clientCollectionConfig.labels.singular,
i18n,
)}
</Pill>
)), )),
} as Column) } as Column)
} }

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: { localization: {
locales: [ locales: [

View File

@@ -36,9 +36,11 @@ const { beforeAll, beforeEach, describe } = test
describe('Join Field', () => { describe('Join Field', () => {
let page: Page let page: Page
let categoriesURL: AdminUrlUtil let categoriesURL: AdminUrlUtil
let foldersURL: AdminUrlUtil
let uploadsURL: AdminUrlUtil let uploadsURL: AdminUrlUtil
let categoriesJoinRestrictedURL: AdminUrlUtil let categoriesJoinRestrictedURL: AdminUrlUtil
let categoryID: number | string let categoryID: number | string
let rootFolderID: number | string
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) testInfo.setTimeout(TEST_TIMEOUT_LONG)
@@ -50,6 +52,7 @@ describe('Join Field', () => {
categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug) categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug)
uploadsURL = new AdminUrlUtil(serverURL, uploadsSlug) uploadsURL = new AdminUrlUtil(serverURL, uploadsSlug)
categoriesJoinRestrictedURL = new AdminUrlUtil(serverURL, categoriesJoinRestrictedSlug) categoriesJoinRestrictedURL = new AdminUrlUtil(serverURL, categoriesJoinRestrictedSlug)
foldersURL = new AdminUrlUtil(serverURL, 'folders')
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -86,6 +89,9 @@ describe('Join Field', () => {
} }
;({ id: categoryID } = docs[0]) ;({ 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 () => { 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') 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 () => { test('should render create-first-user with when users collection has a join field and hide it', async () => {
await payload.delete({ collection: 'users', where: {} }) await payload.delete({ collection: 'users', where: {} })
const url = new AdminUrlUtil(serverURL, 'users') const url = new AdminUrlUtil(serverURL, 'users')

View File

@@ -1153,6 +1153,123 @@ describe('Joins Field', () => {
expect(joinedDoc2.id).toBe(depthJoin_3.id) 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']) { async function createPost(overrides?: Partial<Post>, locale?: Config['locale']) {

View File

@@ -84,6 +84,12 @@ export interface Config {
'depth-joins-1': DepthJoins1; 'depth-joins-1': DepthJoins1;
'depth-joins-2': DepthJoins2; 'depth-joins-2': DepthJoins2;
'depth-joins-3': DepthJoins3; '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-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration; 'payload-migrations': PayloadMigration;
@@ -135,6 +141,12 @@ export interface Config {
'depth-joins-2': { 'depth-joins-2': {
joins: 'depth-joins-1'; joins: 'depth-joins-1';
}; };
'multiple-collections-parents': {
children: 'multiple-collections-1' | 'multiple-collections-2';
};
folders: {
children: 'folders' | 'example-pages' | 'example-posts';
};
}; };
collectionsSelect: { collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
@@ -155,6 +167,12 @@ export interface Config {
'depth-joins-1': DepthJoins1Select<false> | DepthJoins1Select<true>; 'depth-joins-1': DepthJoins1Select<false> | DepthJoins1Select<true>;
'depth-joins-2': DepthJoins2Select<false> | DepthJoins2Select<true>; 'depth-joins-2': DepthJoins2Select<false> | DepthJoins2Select<true>;
'depth-joins-3': DepthJoins3Select<false> | DepthJoins3Select<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-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -581,6 +599,108 @@ export interface DepthJoins3 {
updatedAt: string; updatedAt: string;
createdAt: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
@@ -659,6 +779,30 @@ export interface PayloadLockedDocument {
| ({ | ({
relationTo: 'depth-joins-3'; relationTo: 'depth-joins-3';
value: string | DepthJoins3; 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); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
@@ -958,6 +1102,70 @@ export interface DepthJoins3Select<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: 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 * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select". * via the `definition` "payload-locked-documents_select".

View File

@@ -146,6 +146,74 @@ export const seed = async (_payload: Payload) => {
category: restrictedCategory.id, 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) { export async function clearAndSeedEverything(_payload: Payload) {

View File

@@ -16,21 +16,13 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"jsx": "preserve", "jsx": "preserve",
"lib": [ "lib": ["DOM", "DOM.Iterable", "ES2022"],
"DOM",
"DOM.Iterable",
"ES2022"
],
"outDir": "${configDir}/dist", "outDir": "${configDir}/dist",
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"sourceMap": true, "sourceMap": true,
"types": [ "types": ["jest", "node", "@types/jest"],
"jest",
"node",
"@types/jest"
],
"incremental": true, "incremental": true,
"isolatedModules": true, "isolatedModules": true,
"plugins": [ "plugins": [
@@ -51,33 +43,19 @@
"@payloadcms/richtext-lexical/client": [ "@payloadcms/richtext-lexical/client": [
"./packages/richtext-lexical/src/exports/client/index.ts" "./packages/richtext-lexical/src/exports/client/index.ts"
], ],
"@payloadcms/richtext-lexical/rsc": [ "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"],
"./packages/richtext-lexical/src/exports/server/rsc.ts" "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"],
],
"@payloadcms/richtext-slate/rsc": [
"./packages/richtext-slate/src/exports/server/rsc.ts"
],
"@payloadcms/richtext-slate/client": [ "@payloadcms/richtext-slate/client": [
"./packages/richtext-slate/src/exports/client/index.ts" "./packages/richtext-slate/src/exports/client/index.ts"
], ],
"@payloadcms/plugin-seo/client": [ "@payloadcms/plugin-seo/client": ["./packages/plugin-seo/src/exports/client.ts"],
"./packages/plugin-seo/src/exports/client.ts" "@payloadcms/plugin-sentry/client": ["./packages/plugin-sentry/src/exports/client.ts"],
], "@payloadcms/plugin-stripe/client": ["./packages/plugin-stripe/src/exports/client.ts"],
"@payloadcms/plugin-sentry/client": [ "@payloadcms/plugin-search/client": ["./packages/plugin-search/src/exports/client.ts"],
"./packages/plugin-sentry/src/exports/client.ts"
],
"@payloadcms/plugin-stripe/client": [
"./packages/plugin-stripe/src/exports/client.ts"
],
"@payloadcms/plugin-search/client": [
"./packages/plugin-search/src/exports/client.ts"
],
"@payloadcms/plugin-form-builder/client": [ "@payloadcms/plugin-form-builder/client": [
"./packages/plugin-form-builder/src/exports/client.ts" "./packages/plugin-form-builder/src/exports/client.ts"
], ],
"@payloadcms/plugin-multi-tenant/rsc": [ "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"],
"./packages/plugin-multi-tenant/src/exports/rsc.ts"
],
"@payloadcms/plugin-multi-tenant/utilities": [ "@payloadcms/plugin-multi-tenant/utilities": [
"./packages/plugin-multi-tenant/src/exports/utilities.ts" "./packages/plugin-multi-tenant/src/exports/utilities.ts"
], ],
@@ -87,21 +65,10 @@
"@payloadcms/plugin-multi-tenant/client": [ "@payloadcms/plugin-multi-tenant/client": [
"./packages/plugin-multi-tenant/src/exports/client.ts" "./packages/plugin-multi-tenant/src/exports/client.ts"
], ],
"@payloadcms/plugin-multi-tenant": [ "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"],
"./packages/plugin-multi-tenant/src/index.ts" "@payloadcms/next": ["./packages/next/src/exports/*"]
],
"@payloadcms/next": [
"./packages/next/src/exports/*"
]
} }
}, },
"include": [ "include": ["${configDir}/src"],
"${configDir}/src" "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"]
],
"exclude": [
"${configDir}/dist",
"${configDir}/build",
"${configDir}/temp",
"**/*.spec.ts"
]
} }