diff --git a/.vscode/settings.json b/.vscode/settings.json index ec1c5203ef..b1311e3825 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,7 @@ // Load .git-blame-ignore-revs file "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.changeDirectoryToWorkspaceRoot": false, "jestrunner.debugOptions": { "runtimeArgs": ["--no-deprecation"] }, diff --git a/packages/db-mongodb/src/count.ts b/packages/db-mongodb/src/count.ts index 7d313461bb..e79c5964c9 100644 --- a/packages/db-mongodb/src/count.ts +++ b/packages/db-mongodb/src/count.ts @@ -5,6 +5,7 @@ import { flattenWhereToOperators } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { getSession } from './utilities/getSession.js' 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')) } - const query = await Model.buildQuery({ + const query = await buildQuery({ + adapter: this, + collectionSlug: collection, + fields: this.payload.collections[collection].config.flattenedFields, locale, - payload: this.payload, where, }) diff --git a/packages/db-mongodb/src/countGlobalVersions.ts b/packages/db-mongodb/src/countGlobalVersions.ts index 296f2ca077..0a5b26c522 100644 --- a/packages/db-mongodb/src/countGlobalVersions.ts +++ b/packages/db-mongodb/src/countGlobalVersions.ts @@ -1,10 +1,11 @@ import type { CountOptions } from 'mongodb' import type { CountGlobalVersions } from 'payload' -import { flattenWhereToOperators } from 'payload' +import { buildVersionGlobalFields, flattenWhereToOperators } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { getSession } from './utilities/getSession.js' 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')) } - 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, - payload: this.payload, where, }) diff --git a/packages/db-mongodb/src/countVersions.ts b/packages/db-mongodb/src/countVersions.ts index 0b91f94ac6..87b7fcc55d 100644 --- a/packages/db-mongodb/src/countVersions.ts +++ b/packages/db-mongodb/src/countVersions.ts @@ -1,10 +1,11 @@ import type { CountOptions } from 'mongodb' import type { CountVersions } from 'payload' -import { flattenWhereToOperators } from 'payload' +import { buildVersionCollectionFields, flattenWhereToOperators } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { getSession } from './utilities/getSession.js' 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')) } - const query = await Model.buildQuery({ + const query = await buildQuery({ + adapter: this, + fields: buildVersionCollectionFields( + this.payload.config, + this.payload.collections[collection].config, + true, + ), locale, - payload: this.payload, where, }) diff --git a/packages/db-mongodb/src/deleteMany.ts b/packages/db-mongodb/src/deleteMany.ts index cdc5470e9b..8ba837a617 100644 --- a/packages/db-mongodb/src/deleteMany.ts +++ b/packages/db-mongodb/src/deleteMany.ts @@ -3,6 +3,7 @@ import type { DeleteMany } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { getSession } from './utilities/getSession.js' export const deleteMany: DeleteMany = async function deleteMany( @@ -14,8 +15,10 @@ export const deleteMany: DeleteMany = async function deleteMany( session: await getSession(this, req), } - const query = await Model.buildQuery({ - payload: this.payload, + const query = await buildQuery({ + adapter: this, + collectionSlug: collection, + fields: this.payload.collections[collection].config.flattenedFields, where, }) diff --git a/packages/db-mongodb/src/deleteOne.ts b/packages/db-mongodb/src/deleteOne.ts index 3d9bc07f08..d37e7c112b 100644 --- a/packages/db-mongodb/src/deleteOne.ts +++ b/packages/db-mongodb/src/deleteOne.ts @@ -3,6 +3,7 @@ import type { DeleteOne, Document } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' @@ -21,8 +22,10 @@ export const deleteOne: DeleteOne = async function deleteOne( session: await getSession(this, req), } - const query = await Model.buildQuery({ - payload: this.payload, + const query = await buildQuery({ + adapter: this, + collectionSlug: collection, + fields: this.payload.collections[collection].config.flattenedFields, where, }) diff --git a/packages/db-mongodb/src/deleteVersions.ts b/packages/db-mongodb/src/deleteVersions.ts index 5ee111f3a8..9317836848 100644 --- a/packages/db-mongodb/src/deleteVersions.ts +++ b/packages/db-mongodb/src/deleteVersions.ts @@ -1,7 +1,8 @@ -import type { DeleteVersions } from 'payload' +import { buildVersionCollectionFields, type DeleteVersions } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { getSession } from './utilities/getSession.js' 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 query = await VersionsModel.buildQuery({ + const query = await buildQuery({ + adapter: this, + fields: buildVersionCollectionFields( + this.payload.config, + this.payload.collections[collection].config, + true, + ), locale, - payload: this.payload, where, }) diff --git a/packages/db-mongodb/src/find.ts b/packages/db-mongodb/src/find.ts index d953c3484f..ab8733f87c 100644 --- a/packages/db-mongodb/src/find.ts +++ b/packages/db-mongodb/src/find.ts @@ -5,6 +5,7 @@ import { flattenWhereToOperators } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildSortParam } from './queries/buildSortParam.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.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, - payload: this.payload, where, }) diff --git a/packages/db-mongodb/src/findGlobal.ts b/packages/db-mongodb/src/findGlobal.ts index ec51dba5b1..61b61abdf7 100644 --- a/packages/db-mongodb/src/findGlobal.ts +++ b/packages/db-mongodb/src/findGlobal.ts @@ -5,6 +5,7 @@ import { combineQueries } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' @@ -14,20 +15,22 @@ export const findGlobal: FindGlobal = async function findGlobal( { slug, locale, req, select, where }, ) { const Model = this.globals + const fields = this.payload.globals.config.find((each) => each.slug === slug).flattenedFields const options: QueryOptions = { lean: true, select: buildProjectionFromSelect({ adapter: this, - fields: this.payload.globals.config.find((each) => each.slug === slug).flattenedFields, + fields, select, }), session: await getSession(this, req), } - const query = await Model.buildQuery({ + const query = await buildQuery({ + adapter: this, + fields, globalSlug: slug, locale, - payload: this.payload, where: combineQueries({ globalType: { equals: slug } }, where), }) diff --git a/packages/db-mongodb/src/findGlobalVersions.ts b/packages/db-mongodb/src/findGlobalVersions.ts index 3166d89414..b028a8d224 100644 --- a/packages/db-mongodb/src/findGlobalVersions.ts +++ b/packages/db-mongodb/src/findGlobalVersions.ts @@ -5,6 +5,7 @@ import { buildVersionGlobalFields, flattenWhereToOperators } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildSortParam } from './queries/buildSortParam.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' @@ -46,10 +47,10 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV }) } - const query = await Model.buildQuery({ - globalSlug: global, + const query = await buildQuery({ + adapter: this, + fields: versionFields, locale, - payload: this.payload, where, }) diff --git a/packages/db-mongodb/src/findOne.ts b/packages/db-mongodb/src/findOne.ts index 10d13b6fcf..8db28b3d59 100644 --- a/packages/db-mongodb/src/findOne.ts +++ b/packages/db-mongodb/src/findOne.ts @@ -3,6 +3,7 @@ import type { Document, FindOne } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' @@ -20,9 +21,11 @@ export const findOne: FindOne = async function findOne( session, } - const query = await Model.buildQuery({ + const query = await buildQuery({ + adapter: this, + collectionSlug: collection, + fields: collectionConfig.flattenedFields, locale, - payload: this.payload, where, }) diff --git a/packages/db-mongodb/src/findVersions.ts b/packages/db-mongodb/src/findVersions.ts index 19abd38d44..0f123b1f23 100644 --- a/packages/db-mongodb/src/findVersions.ts +++ b/packages/db-mongodb/src/findVersions.ts @@ -5,6 +5,7 @@ import { buildVersionCollectionFields, flattenWhereToOperators } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildSortParam } from './queries/buildSortParam.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.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, - payload: this.payload, where, }) @@ -58,7 +62,7 @@ export const findVersions: FindVersions = async function findVersions( pagination, projection: buildProjectionFromSelect({ adapter: this, - fields: buildVersionCollectionFields(this.payload.config, collectionConfig, true), + fields, select, }), sort, diff --git a/packages/db-mongodb/src/init.ts b/packages/db-mongodb/src/init.ts index e24d4f5da9..de82610f2b 100644 --- a/packages/db-mongodb/src/init.ts +++ b/packages/db-mongodb/src/init.ts @@ -12,7 +12,7 @@ import type { CollectionModel } from './types.js' import { buildCollectionSchema } from './models/buildCollectionSchema.js' import { buildGlobalModel } from './models/buildGlobalModel.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' export const init: Init = function init(this: MongooseAdapter) { diff --git a/packages/db-mongodb/src/models/buildCollectionSchema.ts b/packages/db-mongodb/src/models/buildCollectionSchema.ts index ca28ddf941..da3c40df50 100644 --- a/packages/db-mongodb/src/models/buildCollectionSchema.ts +++ b/packages/db-mongodb/src/models/buildCollectionSchema.ts @@ -4,7 +4,7 @@ import type { Payload, SanitizedCollectionConfig } from 'payload' import mongooseAggregatePaginate from 'mongoose-aggregate-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' export const buildCollectionSchema = ( @@ -44,7 +44,10 @@ export const buildCollectionSchema = ( .plugin(paginate, { useEstimatedCount: true }) .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) } diff --git a/packages/db-mongodb/src/models/buildGlobalModel.ts b/packages/db-mongodb/src/models/buildGlobalModel.ts index 20c3e22fd8..e184c6c11e 100644 --- a/packages/db-mongodb/src/models/buildGlobalModel.ts +++ b/packages/db-mongodb/src/models/buildGlobalModel.ts @@ -4,7 +4,7 @@ import mongoose from 'mongoose' import type { GlobalModel } from '../types.js' -import { getBuildQueryPlugin } from '../queries/buildQuery.js' +import { getBuildQueryPlugin } from '../queries/getBuildQueryPlugin.js' import { buildSchema } from './buildSchema.js' export const buildGlobalModel = (payload: Payload): GlobalModel | null => { diff --git a/packages/db-mongodb/src/queries/buildQuery.ts b/packages/db-mongodb/src/queries/buildQuery.ts index d468bd5e69..83953d6935 100644 --- a/packages/db-mongodb/src/queries/buildQuery.ts +++ b/packages/db-mongodb/src/queries/buildQuery.ts @@ -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' -type GetBuildQueryPluginArgs = { +export const buildQuery = async ({ + adapter, + collectionSlug, + fields, + globalSlug, + locale, + where, +}: { + adapter: MongooseAdapter collectionSlug?: string - versionsFields?: FlattenedField[] -} - -export type BuildQueryArgs = { + fields: FlattenedField[] 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, - versionsFields, -}: GetBuildQueryPluginArgs = {}) => { - return function buildQueryPlugin(schema) { - const modifiedSchema = schema - async function buildQuery({ - globalSlug, - locale, - payload, - where, - }: BuildQueryArgs): Promise> { - 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 = buildQuery - } +}) => { + const result = await parseParams({ + collectionSlug, + fields, + globalSlug, + locale, + parentIsLocalized: false, + payload: adapter.payload, + where, + }) + + return result } diff --git a/packages/db-mongodb/src/queries/getBuildQueryPlugin.ts b/packages/db-mongodb/src/queries/getBuildQueryPlugin.ts new file mode 100644 index 0000000000..4ee8a39e85 --- /dev/null +++ b/packages/db-mongodb/src/queries/getBuildQueryPlugin.ts @@ -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> { + 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 + } +} diff --git a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts index 3b7dfd085b..75297996ea 100644 --- a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts +++ b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts @@ -100,7 +100,6 @@ export const sanitizeQueryValue = ({ } => { let formattedValue = val let formattedOperator = operator - if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) { const segments = path.split('.') segments.shift() diff --git a/packages/db-mongodb/src/queryDrafts.ts b/packages/db-mongodb/src/queryDrafts.ts index 3ea8cb34d7..0216d24fd0 100644 --- a/packages/db-mongodb/src/queryDrafts.ts +++ b/packages/db-mongodb/src/queryDrafts.ts @@ -5,6 +5,7 @@ import { buildVersionCollectionFields, combineQueries, flattenWhereToOperators } import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildSortParam } from './queries/buildSortParam.js' import { buildJoinAggregation } from './utilities/buildJoinAggregation.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 versionQuery = await VersionModel.buildQuery({ + const fields = buildVersionCollectionFields(this.payload.config, collectionConfig, true) + const versionQuery = await buildQuery({ + adapter: this, + fields, locale, - payload: this.payload, where: combinedWhere, }) const projection = buildProjectionFromSelect({ adapter: this, - fields: buildVersionCollectionFields(this.payload.config, collectionConfig, true), + fields, select, }) // useEstimatedCount is faster, but not accurate, as it ignores any filters. It is thus set to true if there are no filters. diff --git a/packages/db-mongodb/src/types.ts b/packages/db-mongodb/src/types.ts index 89c60eab09..b521efd8a2 100644 --- a/packages/db-mongodb/src/types.ts +++ b/packages/db-mongodb/src/types.ts @@ -35,7 +35,7 @@ import type { UploadField, } from 'payload' -import type { BuildQueryArgs } from './queries/buildQuery.js' +import type { BuildQueryArgs } from './queries/getBuildQueryPlugin.js' export interface CollectionModel extends Model, diff --git a/packages/db-mongodb/src/updateGlobalVersion.ts b/packages/db-mongodb/src/updateGlobalVersion.ts index af1453840b..55e090a867 100644 --- a/packages/db-mongodb/src/updateGlobalVersion.ts +++ b/packages/db-mongodb/src/updateGlobalVersion.ts @@ -4,6 +4,7 @@ import { buildVersionGlobalFields, type TypeWithID, type UpdateGlobalVersionArgs import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' @@ -26,22 +27,23 @@ export async function updateGlobalVersion( const currentGlobal = this.payload.config.globals.find((global) => global.slug === globalSlug) const fields = buildVersionGlobalFields(this.payload.config, currentGlobal) - + const flattenedFields = buildVersionGlobalFields(this.payload.config, currentGlobal, true) const options: QueryOptions = { ...optionsArgs, lean: true, new: true, projection: buildProjectionFromSelect({ adapter: this, - fields: buildVersionGlobalFields(this.payload.config, currentGlobal, true), + fields: flattenedFields, select, }), session: await getSession(this, req), } - const query = await VersionModel.buildQuery({ + const query = await buildQuery({ + adapter: this, + fields: flattenedFields, locale, - payload: this.payload, where: whereToUse, }) diff --git a/packages/db-mongodb/src/updateOne.ts b/packages/db-mongodb/src/updateOne.ts index 555d46925b..bc3115816f 100644 --- a/packages/db-mongodb/src/updateOne.ts +++ b/packages/db-mongodb/src/updateOne.ts @@ -3,6 +3,7 @@ import type { UpdateOne } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' import { handleError } from './utilities/handleError.js' @@ -28,9 +29,11 @@ export const updateOne: UpdateOne = async function updateOne( 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, - payload: this.payload, where, }) diff --git a/packages/db-mongodb/src/updateVersion.ts b/packages/db-mongodb/src/updateVersion.ts index 9514e7cb73..59373f38e1 100644 --- a/packages/db-mongodb/src/updateVersion.ts +++ b/packages/db-mongodb/src/updateVersion.ts @@ -4,6 +4,7 @@ import { buildVersionCollectionFields, type UpdateVersion } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildQuery } from './queries/buildQuery.js' import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js' import { getSession } from './utilities/getSession.js' import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' @@ -19,25 +20,28 @@ export const updateVersion: UpdateVersion = async function updateVersion( this.payload.collections[collection].config, ) + const flattenedFields = buildVersionCollectionFields( + this.payload.config, + this.payload.collections[collection].config, + true, + ) + const options: QueryOptions = { ...optionsArgs, lean: true, new: true, projection: buildProjectionFromSelect({ adapter: this, - fields: buildVersionCollectionFields( - this.payload.config, - this.payload.collections[collection].config, - true, - ), + fields: flattenedFields, select, }), session: await getSession(this, req), } - const query = await VersionModel.buildQuery({ + const query = await buildQuery({ + adapter: this, + fields: flattenedFields, locale, - payload: this.payload, where: whereToUse, }) diff --git a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts index 4cf3dde140..4af139b5a7 100644 --- a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts +++ b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts @@ -1,10 +1,17 @@ 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 type { MongooseAdapter } from '../index.js' +import { buildQuery } from '../queries/buildQuery.js' import { buildSortParam } from '../queries/buildSortParam.js' type BuildJoinAggregationArgs = { @@ -33,11 +40,16 @@ export const buildJoinAggregation = async ({ query, versions, }: BuildJoinAggregationArgs): Promise => { - if (Object.keys(collectionConfig.joins).length === 0 || joins === false) { + if ( + (Object.keys(collectionConfig.joins).length === 0 && + collectionConfig.polymorphicJoins.length == 0) || + joins === false + ) { return } const joinConfig = adapter.payload.collections[collection].config.joins + const polymorphicJoinsConfig = adapter.payload.collections[collection].config.polymorphicJoins const aggregate: PipelineStage[] = [ { $sort: { createdAt: -1 }, @@ -56,10 +68,151 @@ export const buildJoinAggregation = async ({ }) } + for (const join of polymorphicJoinsConfig) { + 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] || {} + + 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]) { - const joinModel = adapter.collections[join.field.collection] - if (projection && !projection[join.joinPath]) { continue } @@ -75,6 +228,12 @@ export const buildJoinAggregation = async ({ 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({ config: adapter.payload.config, fields: adapter.payload.collections[slug].config.flattenedFields, diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts index 8d32cdf5a1..d4cf87df3e 100644 --- a/packages/drizzle/src/find/traverseFields.ts +++ b/packages/drizzle/src/find/traverseFields.ts @@ -1,7 +1,8 @@ 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 { sql } from 'drizzle-orm' +import { and, asc, desc, eq, or, sql } from 'drizzle-orm' import { fieldIsVirtual, fieldShouldBeLocalized } from 'payload/shared' import toSnakeCase from 'to-snake-case' @@ -10,11 +11,49 @@ import type { Result } from './buildFindManyArgs.js' import buildQuery from '../queries/buildQuery.js' import { getTableAlias } from '../queries/getTableAlias.js' +import { operatorMap } from '../queries/operatorMap.js' import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js' import { jsonAggBuildObject } from '../utilities/json.js' import { rawConstraint } from '../utilities/rawConstraint.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 + type TraverseFieldArgs = { _locales: Result adapter: DrizzleAdapter @@ -359,126 +398,230 @@ export const traverseFields = ({ limit += 1 } - const fields = adapter.payload.collections[field.collection].config.flattenedFields - - const joinCollectionTableName = adapter.tableNameMap.get(toSnakeCase(field.collection)) - - const joins: BuildQueryJoinAliases = [] - - const currentIDColumn = versions - ? adapter.tables[currentTableName].parent - : adapter.tables[currentTableName].id - - let joinQueryWhere: Where - - if (Array.isArray(field.targetField.relationTo)) { - joinQueryWhere = { - [field.on]: { - equals: { - relationTo: collectionSlug, - value: rawConstraint(currentIDColumn), - }, - }, - } - } else { - joinQueryWhere = { - [field.on]: { - equals: rawConstraint(currentIDColumn), - }, - } - } - - if (where && Object.keys(where).length) { - joinQueryWhere = { - and: [joinQueryWhere, where], - } - } - const columnName = `${path.replaceAll('.', '_')}${field.name}` - const subQueryAlias = `${columnName}_alias` - - const { newAliasTable } = getTableAlias({ - adapter, - tableName: joinCollectionTableName, - }) - - const { - orderBy, - selectFields, - where: subQueryWhere, - } = buildQuery({ - adapter, - aliasTable: newAliasTable, - fields, - joins, - locale, - // Parent is never localized, as we're passing the `fields` of a **different** collection here. This means that the - // parent localization "boundary" is crossed, and we're now in the context of the joined collection. - parentIsLocalized: false, - selectLocale: true, - sort, - tableName: joinCollectionTableName, - where: joinQueryWhere, - }) - - const chainedMethods: ChainedMethods = [] - - joins.forEach(({ type, condition, table }) => { - chainedMethods.push({ - args: [table, condition], - method: type ?? 'leftJoin', - }) - }) - - if (limit !== 0) { - chainedMethods.push({ - args: [limit], - method: 'limit', - }) - } - - if (page && limit !== 0) { - const offset = (page - 1) * limit - 1 - if (offset > 0) { - chainedMethods.push({ - args: [offset], - method: 'offset', - }) - } - } - const db = adapter.drizzle as LibSQLDatabase - for (let key in selectFields) { - const val = selectFields[key] + if (Array.isArray(field.collection)) { + let currentQuery: null | SQLSelect = null + const onPath = field.on.split('.').join('_') - if (val.table && getNameFromDrizzleTable(val.table) === joinCollectionTableName) { - delete selectFields[key] - key = key.split('.').pop() - selectFields[key] = newAliasTable[key] + if (Array.isArray(sort)) { + throw new Error('Not implemented') } - } - const subQuery = chainMethods({ - methods: chainedMethods, - query: db - .select(selectFields as any) - .from(newAliasTable) - .where(subQueryWhere) - .orderBy(() => orderBy.map(({ column, order }) => order(column))), - }).as(subQueryAlias) + let sanitizedSort = sort - currentArgs.extras[columnName] = sql`${db - .select({ - result: jsonAggBuildObject(adapter, { - id: sql.raw(`"${subQueryAlias}".id`), - ...(selectFields._locale && { - locale: sql.raw(`"${subQueryAlias}".${selectFields._locale.name}`), + 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 joinCollectionTableName = adapter.tableNameMap.get(toSnakeCase(field.collection)) + + const joins: BuildQueryJoinAliases = [] + + const currentIDColumn = versions + ? adapter.tables[currentTableName].parent + : adapter.tables[currentTableName].id + + let joinQueryWhere: Where + + if (Array.isArray(field.targetField.relationTo)) { + joinQueryWhere = { + [field.on]: { + equals: { + relationTo: collectionSlug, + value: rawConstraint(currentIDColumn), + }, + }, + } + } else { + joinQueryWhere = { + [field.on]: { + equals: rawConstraint(currentIDColumn), + }, + } + } + + if (where && Object.keys(where).length) { + joinQueryWhere = { + and: [joinQueryWhere, where], + } + } + + const columnName = `${path.replaceAll('.', '_')}${field.name}` + + const subQueryAlias = `${columnName}_alias` + + const { newAliasTable } = getTableAlias({ + adapter, + tableName: joinCollectionTableName, }) - .from(sql`${subQuery}`)}`.as(subQueryAlias) + + const { + orderBy, + selectFields, + where: subQueryWhere, + } = buildQuery({ + adapter, + aliasTable: newAliasTable, + fields, + joins, + locale, + parentIsLocalized, + selectLocale: true, + sort, + tableName: joinCollectionTableName, + where: joinQueryWhere, + }) + + const chainedMethods: ChainedMethods = [] + + joins.forEach(({ type, condition, table }) => { + chainedMethods.push({ + args: [table, condition], + method: type ?? 'leftJoin', + }) + }) + + if (page && limit !== 0) { + const offset = (page - 1) * limit - 1 + if (offset > 0) { + chainedMethods.push({ + args: [offset], + method: 'offset', + }) + } + } + + if (limit !== 0) { + chainedMethods.push({ + args: [limit], + method: 'limit', + }) + } + + const db = adapter.drizzle as LibSQLDatabase + + for (let key in selectFields) { + const val = selectFields[key] + + if (val.table && getNameFromDrizzleTable(val.table) === joinCollectionTableName) { + delete selectFields[key] + key = key.split('.').pop() + selectFields[key] = newAliasTable[key] + } + } + + const subQuery = chainMethods({ + methods: chainedMethods, + query: db + .select(selectFields as any) + .from(newAliasTable) + .where(subQueryWhere) + .orderBy(() => orderBy.map(({ column, order }) => order(column))), + }).as(subQueryAlias) + + currentArgs.extras[columnName] = sql`${db + .select({ + result: jsonAggBuildObject(adapter, { + id: sql.raw(`"${subQueryAlias}".id`), + ...(selectFields._locale && { + locale: sql.raw(`"${subQueryAlias}".${selectFields._locale.name}`), + }), + }), + }) + .from(sql`${subQuery}`)}`.as(subQueryAlias) + } break } diff --git a/packages/drizzle/src/transform/read/traverseFields.ts b/packages/drizzle/src/transform/read/traverseFields.ts index c9cd95a48b..3bb4aedb2c 100644 --- a/packages/drizzle/src/transform/read/traverseFields.ts +++ b/packages/drizzle/src/transform/read/traverseFields.ts @@ -436,9 +436,14 @@ export const traverseFields = >({ } else { const hasNextPage = limit !== 0 && fieldData.length > limit fieldResult = { - docs: (hasNextPage ? fieldData.slice(0, limit) : fieldData).map(({ id }) => ({ - id, - })), + docs: (hasNextPage ? fieldData.slice(0, limit) : fieldData).map( + ({ id, relationTo }) => { + if (relationTo) { + return { relationTo, value: id } + } + return { id } + }, + ), hasNextPage, } } diff --git a/packages/graphql/src/schema/buildObjectType.ts b/packages/graphql/src/schema/buildObjectType.ts index 3ca78b177a..ba8903cf9e 100644 --- a/packages/graphql/src/schema/buildObjectType.ts +++ b/packages/graphql/src/schema/buildObjectType.ts @@ -254,7 +254,9 @@ export function buildObjectType({ name: joinName, fields: { 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 }, }, @@ -270,7 +272,9 @@ export function buildObjectType({ type: GraphQLString, }, where: { - type: graphqlResult.collections[field.collection].graphQL.whereInputType, + type: Array.isArray(field.collection) + ? GraphQLJSON + : graphqlResult.collections[field.collection].graphQL.whereInputType, }, }, extensions: { @@ -286,6 +290,10 @@ export function buildObjectType({ [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({ collection, depth: 0, diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index a2bcc33a96..6a127de063 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -1,6 +1,7 @@ import type { ImportMap } from '../../bin/generateImportMap/index.js' import type { SanitizedConfig } from '../../config/types.js' import type { PaginatedDocs } from '../../database/types.js' +import type { CollectionSlug } from '../../index.js' import type { PayloadRequest, Sort, Where } from '../../types/index.js' export type DefaultServerFunctionArgs = { @@ -48,10 +49,15 @@ export type ListQuery = { } export type BuildTableStateArgs = { - collectionSlug: string + collectionSlug: string | string[] columns?: { accessor: string; active: boolean }[] docs?: PaginatedDocs['docs'] enableRowSelections?: boolean + parent?: { + collectionSlug: CollectionSlug + id: number | string + joinPath: string + } query?: ListQuery renderRowTypes?: boolean req: PayloadRequest diff --git a/packages/payload/src/collections/config/client.ts b/packages/payload/src/collections/config/client.ts index bdd68b2643..9170a0234d 100644 --- a/packages/payload/src/collections/config/client.ts +++ b/packages/payload/src/collections/config/client.ts @@ -17,7 +17,7 @@ import { createClientFields } from '../../fields/config/client.js' export type ServerOnlyCollectionProperties = keyof Pick< SanitizedCollectionConfig, - 'access' | 'custom' | 'endpoints' | 'flattenedFields' | 'hooks' | 'joins' + 'access' | 'custom' | 'endpoints' | 'flattenedFields' | 'hooks' | 'joins' | 'polymorphicJoins' > export type ServerOnlyCollectionAdminProperties = keyof Pick< @@ -68,6 +68,7 @@ const serverOnlyCollectionProperties: Partial[] 'endpoints', 'custom', 'joins', + 'polymorphicJoins', 'flattenedFields', // `upload` // `admin` diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index dde7032599..28a78a3bf8 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -1,7 +1,12 @@ // @ts-strict-ignore import type { LoginWithUsernameOptions } from '../../auth/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 { getBaseAuthFields } from '../../auth/getAuthFields.js' @@ -44,6 +49,7 @@ export const sanitizeCollection = async ( const validRelationships = _validRelationships ?? config.collections.map((c) => c.slug) ?? [] const joins: SanitizedJoins = {} + const polymorphicJoins: SanitizedJoin[] = [] sanitized.fields = await sanitizeFields({ collectionConfig: sanitized, config, @@ -51,6 +57,7 @@ export const sanitizeCollection = async ( joinPath: '', joins, parentIsLocalized: false, + polymorphicJoins, richTextSanitizationPromises, validRelationships, }) @@ -234,6 +241,7 @@ export const sanitizeCollection = async ( const sanitizedConfig = sanitized as SanitizedCollectionConfig sanitizedConfig.joins = joins + sanitizedConfig.polymorphicJoins = polymorphicJoins sanitizedConfig.flattenedFields = flattenAllFields({ fields: sanitizedConfig.fields }) diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 8fb21ca793..9ca4472c06 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -548,6 +548,12 @@ export interface SanitizedCollectionConfig * Object of collections to join 'Join Fields object keyed by collection */ joins: SanitizedJoins + + /** + * List of all polymorphic join fields + */ + polymorphicJoins: SanitizedJoin[] + slug: CollectionSlug upload: SanitizedUploadConfig versions: SanitizedCollectionVersions diff --git a/packages/payload/src/database/getLocalizedPaths.ts b/packages/payload/src/database/getLocalizedPaths.ts index 07a9e9ea62..1547f476a9 100644 --- a/packages/payload/src/database/getLocalizedPaths.ts +++ b/packages/payload/src/database/getLocalizedPaths.ts @@ -107,6 +107,18 @@ export function getLocalizedPaths({ 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) { lastIncompletePath.path = currentPath const idField: Field = { diff --git a/packages/payload/src/database/sanitizeJoinQuery.ts b/packages/payload/src/database/sanitizeJoinQuery.ts index 79badbaa97..55915ab521 100644 --- a/packages/payload/src/database/sanitizeJoinQuery.ts +++ b/packages/payload/src/database/sanitizeJoinQuery.ts @@ -1,5 +1,5 @@ // @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 executeAccess from '../auth/executeAccess.js' @@ -14,6 +14,70 @@ type Args = { 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[] + 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 * * Combines the access result for joined collection @@ -37,50 +101,30 @@ export const sanitizeJoinQuery = async ({ const promises: Promise[] = [] for (const collectionSlug in collectionConfig.joins) { - for (const { field, joinPath } of collectionConfig.joins[collectionSlug]) { - if (joinsQuery[joinPath] === false) { - continue - } + for (const join of collectionConfig.joins[collectionSlug]) { + await sanitizeJoinFieldQuery({ + collectionSlug, + errors, + join, + joinsQuery, + overrideAccess, + promises, + req, + }) + } + } - 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, - 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) - } + for (const join of collectionConfig.polymorphicJoins) { + for (const collectionSlug of join.field.collection) { + await sanitizeJoinFieldQuery({ + collectionSlug, + errors, + join, + joinsQuery, + overrideAccess, + promises, + req, + }) } } diff --git a/packages/payload/src/fields/config/client.ts b/packages/payload/src/fields/config/client.ts index 5eea4c12ba..8dfa46d766 100644 --- a/packages/payload/src/fields/config/client.ts +++ b/packages/payload/src/fields/config/client.ts @@ -311,7 +311,7 @@ export const createClientField = ({ const field = clientField as JoinFieldClient field.targetField = { - relationTo: field.targetField.relationTo, + relationTo: field.targetField?.relationTo, } break diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index f3f6430bee..127bb56fe7 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -1,7 +1,11 @@ // @ts-strict-ignore 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 { Field } from './types.js' @@ -33,6 +37,7 @@ type Args = { */ joins?: SanitizedJoins 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. @@ -59,6 +64,7 @@ export const sanitizeFields = async ({ joinPath = '', joins, parentIsLocalized, + polymorphicJoins, requireFieldLevelRichTextEditor = false, richTextSanitizationPromises, validRelationships, @@ -104,7 +110,7 @@ export const sanitizeFields = async ({ } if (field.type === 'join') { - sanitizeJoinField({ config, field, joinPath, joins, parentIsLocalized }) + sanitizeJoinField({ config, field, joinPath, joins, parentIsLocalized, polymorphicJoins }) } if (field.type === 'relationship' || field.type === 'upload') { @@ -265,6 +271,7 @@ export const sanitizeFields = async ({ : joinPath, joins, parentIsLocalized: parentIsLocalized || fieldIsLocalized(field), + polymorphicJoins, requireFieldLevelRichTextEditor, richTextSanitizationPromises, validRelationships, @@ -285,6 +292,7 @@ export const sanitizeFields = async ({ joinPath: tabHasName(tab) ? `${joinPath ? joinPath + '.' : ''}${tab.name}` : joinPath, joins, parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized), + polymorphicJoins, requireFieldLevelRichTextEditor, richTextSanitizationPromises, validRelationships, diff --git a/packages/payload/src/fields/config/sanitizeJoinField.ts b/packages/payload/src/fields/config/sanitizeJoinField.ts index aa79294005..84add39a5d 100644 --- a/packages/payload/src/fields/config/sanitizeJoinField.ts +++ b/packages/payload/src/fields/config/sanitizeJoinField.ts @@ -18,12 +18,16 @@ export const sanitizeJoinField = ({ joinPath, joins, parentIsLocalized, + polymorphicJoins, + validateOnly, }: { config: Config field: FlattenedJoinField | JoinField joinPath?: string joins?: SanitizedJoins 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 if (typeof joins === 'undefined') { @@ -38,6 +42,32 @@ export const sanitizeJoinField = ({ parentIsLocalized, 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( (collection) => collection.slug === field.collection, ) @@ -109,6 +139,10 @@ export const sanitizeJoinField = ({ throw new InvalidFieldJoin(join.field) } + if (validateOnly) { + return + } + join.targetField = joinRelationship // override the join field localized property to use whatever the relationship field has diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 1a6db09b3c..9d4ad6838c 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1478,7 +1478,7 @@ export type JoinField = { /** * The slug of the collection to relate with. */ - collection: CollectionSlug + collection: CollectionSlug | CollectionSlug[] defaultLimit?: number defaultSort?: Sort defaultValue?: never @@ -1504,6 +1504,7 @@ export type JoinField = { * A string for the field in the collection being joined to. */ on: string + sanitizedMany?: JoinField[] type: 'join' validate?: never where?: Where diff --git a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts index 5f45d85c26..3319fe055a 100644 --- a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts +++ b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts @@ -22,6 +22,7 @@ type PopulateArgs = { showHiddenFields: boolean } +// TODO: this function is mess, refactor logic const populate = async ({ currentDepth, data, @@ -41,14 +42,24 @@ const populate = async ({ const dataToUpdate = dataReference let relation if (field.type === 'join') { - relation = field.collection + relation = Array.isArray(field.collection) ? data.relationTo : field.collection } else { relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo } + const relatedCollection = req.payload.collections[relation] 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 const shouldPopulate = depth && currentDepth <= depth @@ -90,11 +101,19 @@ const populate = async ({ if (field.type !== 'join' && Array.isArray(field.relationTo)) { dataToUpdate[field.name][key][index].value = relationshipValue } else { - dataToUpdate[field.name][key][index] = relationshipValue + if (field.type === 'join' && Array.isArray(field.collection)) { + dataToUpdate[field.name][key][index].value = relationshipValue + } else { + dataToUpdate[field.name][key][index] = relationshipValue + } } } else if (typeof index === 'number' || typeof key === 'string') { if (field.type === 'join') { - dataToUpdate[field.name].docs[index ?? key] = relationshipValue + if (!Array.isArray(field.collection)) { + dataToUpdate[field.name].docs[index ?? key] = relationshipValue + } else { + dataToUpdate[field.name].docs[index ?? key].value = relationshipValue + } } else if (Array.isArray(field.relationTo)) { dataToUpdate[field.name][index ?? key].value = relationshipValue } else { @@ -103,7 +122,11 @@ const populate = async ({ } else if (field.type !== 'join' && Array.isArray(field.relationTo)) { dataToUpdate[field.name].value = relationshipValue } else { - dataToUpdate[field.name] = relationshipValue + if (field.type === 'join' && Array.isArray(field.collection)) { + dataToUpdate[field.name].value = relationshipValue + } else { + dataToUpdate[field.name] = relationshipValue + } } } } @@ -185,7 +208,10 @@ export const relationshipPopulationPromise = async ({ if (relatedDoc) { await populate({ currentDepth, - data: relatedDoc?.id ? relatedDoc.id : relatedDoc, + data: + !(field.type === 'join' && Array.isArray(field.collection)) && relatedDoc?.id + ? relatedDoc.id + : relatedDoc, dataReference: resultingDoc, depth: populateDepth, draft, diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 0f61fdd545..1f9f9a11f2 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1246,6 +1246,7 @@ export type { FlattenedBlocksField, FlattenedField, FlattenedGroupField, + FlattenedJoinField, FlattenedTabAsField, GroupField, GroupFieldClient, diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index 3460f7e83d..c363a3f903 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -88,7 +88,7 @@ function generateEntitySelectSchemas( function generateCollectionJoinsSchemas(collections: SanitizedCollectionConfig[]): JSONSchema4 { const properties = [...collections].reduce>( - (acc, { slug, joins }) => { + (acc, { slug, joins, polymorphicJoins }) => { const schema = { type: 'object', 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) { acc[slug] = schema } @@ -387,6 +395,44 @@ export function fieldsToJSONSchema( } case 'join': { + let items: JSONSchema4 + + if (Array.isArray(field.collection)) { + items = { + oneOf: field.collection.map((collection) => ({ + type: 'object', + additionalProperties: false, + properties: { + relationTo: { + const: collection, + }, + value: { + oneOf: [ + { + type: collectionIDFieldTypes[collection], + }, + { + $ref: `#/definitions/${collection}`, + }, + ], + }, + }, + required: ['collectionSlug', 'value'], + })), + } + } else { + items = { + oneOf: [ + { + type: collectionIDFieldTypes[field.collection], + }, + { + $ref: `#/definitions/${field.collection}`, + }, + ], + } + } + fieldSchema = { ...baseFieldSchema, type: withNullableJSONSchemaType('object', false), @@ -394,16 +440,7 @@ export function fieldsToJSONSchema( properties: { docs: { type: withNullableJSONSchemaType('array', false), - items: { - oneOf: [ - { - type: collectionIDFieldTypes[field.collection], - }, - { - $ref: `#/definitions/${field.collection}`, - }, - ], - }, + items, }, hasNextPage: { type: withNullableJSONSchemaType('boolean', false) }, }, diff --git a/packages/ui/src/elements/RelationshipTable/index.scss b/packages/ui/src/elements/RelationshipTable/index.scss index 1aa70f7e4c..eabd7c1d47 100644 --- a/packages/ui/src/elements/RelationshipTable/index.scss +++ b/packages/ui/src/elements/RelationshipTable/index.scss @@ -18,6 +18,12 @@ padding-bottom: var(--base); } + &__add-new-polymorphic .btn__label { + display: flex; + text-wrap: nowrap; + align-items: center; + } + .table { table { width: 100%; diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx index d3250fb1a7..e45d2ecfeb 100644 --- a/packages/ui/src/elements/RelationshipTable/index.tsx +++ b/packages/ui/src/elements/RelationshipTable/index.tsx @@ -1,5 +1,12 @@ '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 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 { useEffectEvent } from '../../hooks/useEffectEvent.js' import { ChevronIcon } from '../../icons/Chevron/index.js' +import { PlusIcon } from '../../icons/Plus/index.js' import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' import { ListQueryProvider } from '../../providers/ListQuery/index.js' @@ -17,9 +25,10 @@ import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js' import { AnimateHeight } from '../AnimateHeight/index.js' -import { ColumnSelector } from '../ColumnSelector/index.js' import './index.scss' +import { ColumnSelector } from '../ColumnSelector/index.js' import { useDocumentDrawer } from '../DocumentDrawer/index.js' +import { Popup, PopupList } from '../Popup/index.js' import { RelationshipProvider } from '../Table/RelationshipProvider/index.js' import { TableColumnsProvider } from '../TableColumns/index.js' import { DrawerLink } from './cells/DrawerLink/index.js' @@ -37,7 +46,12 @@ type RelationshipTableComponentProps = { readonly initialData?: PaginatedDocs readonly initialDrawerData?: DocumentDrawerProps['initialData'] 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 = (props) => { @@ -51,10 +65,11 @@ export const RelationshipTable: React.FC = (pro initialData: initialDataFromProps, initialDrawerData, Label, + parent, relationTo, } = props const [Table, setTable] = useState(null) - const { getEntityConfig } = useConfig() + const { config, getEntityConfig } = useConfig() const { permissions } = useAuth() @@ -86,6 +101,9 @@ export const RelationshipTable: React.FC = (pro const [collectionConfig] = useState(() => getEntityConfig({ collectionSlug: relationTo })) + const [selectedCollection, setSelectedCollection] = useState( + Array.isArray(relationTo) ? undefined : relationTo, + ) const [isLoadingTable, setIsLoadingTable] = useState(!disableTable) const [data, setData] = useState(initialData) const [columnState, setColumnState] = useState() @@ -95,8 +113,8 @@ export const RelationshipTable: React.FC = (pro const renderTable = useCallback( async (docs?: PaginatedDocs['docs']) => { const newQuery: ListQuery = { - limit: String(field.defaultLimit || collectionConfig.admin.pagination.defaultLimit), - sort: field.defaultSort || collectionConfig.defaultSort, + limit: String(field?.defaultLimit || collectionConfig?.admin?.pagination?.defaultLimit), + sort: field.defaultSort || collectionConfig?.defaultSort, ...(query || {}), where: { ...(query?.where || {}) }, } @@ -122,6 +140,7 @@ export const RelationshipTable: React.FC = (pro columns: defaultColumns, docs, enableRowSelections: false, + parent, query: newQuery, renderRowTypes: true, tableAppearance: 'condensed', @@ -136,12 +155,13 @@ export const RelationshipTable: React.FC = (pro field.defaultLimit, field.defaultSort, field.admin.defaultColumns, - collectionConfig.admin.pagination.defaultLimit, - collectionConfig.defaultSort, + collectionConfig?.admin?.pagination?.defaultLimit, + collectionConfig?.defaultSort, query, filterOptions, getTableState, relationTo, + parent, ], ) @@ -155,9 +175,10 @@ export const RelationshipTable: React.FC = (pro handleTableRender(query, disableTable) }, [query, disableTable]) - const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer, openDrawer }] = useDocumentDrawer({ - collectionSlug: relationTo, - }) + const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer, isDrawerOpen, openDrawer }] = + useDocumentDrawer({ + collectionSlug: selectedCollection, + }) const onDrawerSave = useCallback( (args) => { @@ -174,12 +195,13 @@ export const RelationshipTable: React.FC = (pro void renderTable(withNewOrUpdatedDoc) }, - [data.docs, renderTable], + [data?.docs, renderTable], ) const onDrawerCreate = useCallback( (args) => { closeDrawer() + void onDrawerSave(args) }, [closeDrawer, onDrawerSave], @@ -190,23 +212,80 @@ export const RelationshipTable: React.FC = (pro const newDocs = data.docs.filter((doc) => doc.id !== args.id) 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 (
{Label}
- {canCreate && ( + {!Array.isArray(relationTo) && canCreate && ( {i18n.t('fields:addNew')} )} + + {Array.isArray(relationTo) && ( + + + {i18n.t('fields:addNew')} + + + } + buttonType="custom" + horizontalAlign="center" + render={({ close: closePopup }) => ( + + {relationTo.map((relatedCollection) => { + if (permissions.collections[relatedCollection].create) { + return ( + { + closePopup() + setSelectedCollection(relatedCollection) + }} + > + {getTranslation( + config.collections.find((each) => each.slug === relatedCollection) + .labels.singular, + i18n, + )} + + ) + } + + return null + })} + + )} + size="medium" + /> + + )} = (pro

{t('general:loading')}

) : ( - {data.docs && data.docs.length === 0 && ( + {data?.docs && data.docs.length === 0 && (

{i18n.t('general:noResults', { - label: getTranslation(collectionConfig?.labels?.plural, i18n), + label: Array.isArray(relationTo) + ? i18n.t('general:documents') + : getTranslation(collectionConfig?.labels?.plural, i18n), })}

{canCreate && ( @@ -242,7 +323,7 @@ export const RelationshipTable: React.FC = (pro )}
)} - {data.docs && data.docs.length > 0 && ( + {data?.docs && data.docs.length > 0 && ( = (pro onQueryChange={setQuery} > = (pro id={`${baseClass}-columns`} >
- + {collectionConfig && ( + + )}
{Table} diff --git a/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx b/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx new file mode 100644 index 0000000000..916ce7836f --- /dev/null +++ b/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx @@ -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 + 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 = { + // 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 = ( + + ) + + 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 ( + + } + key={`${i}-${index}`} + /> + ) + }) + : [], + } + + acc.push(column) + + return acc + }, []) + + if (beforeRows) { + sorted.unshift(...beforeRows) + } + + return sorted +} diff --git a/packages/ui/src/elements/TableColumns/index.tsx b/packages/ui/src/elements/TableColumns/index.tsx index 4c79730a7b..5d7120e7a0 100644 --- a/packages/ui/src/elements/TableColumns/index.tsx +++ b/packages/ui/src/elements/TableColumns/index.tsx @@ -25,7 +25,7 @@ export const useTableColumns = (): ITableColumns => useContext(TableColumnContex type Props = { readonly children: React.ReactNode - readonly collectionSlug: string + readonly collectionSlug: string | string[] readonly columnState: Column[] readonly docs: any[] readonly enableRowSelections?: boolean @@ -68,7 +68,9 @@ export const TableColumnsProvider: React.FC = ({ collectionSlug, }) - const prevCollection = React.useRef(collectionSlug) + const prevCollection = React.useRef( + Array.isArray(collectionSlug) ? collectionSlug[0] : collectionSlug, + ) const { getPreference } = usePreferences() const [tableColumns, setTableColumns] = React.useState(columnState) @@ -232,14 +234,15 @@ export const TableColumnsProvider: React.FC = ({ React.useEffect(() => { const sync = async () => { - const collectionHasChanged = prevCollection.current !== collectionSlug + const defaultCollection = Array.isArray(collectionSlug) ? collectionSlug[0] : collectionSlug + const collectionHasChanged = prevCollection.current !== defaultCollection if (collectionHasChanged || !listPreferences) { const currentPreferences = await getPreference<{ columns: ListPreferences['columns'] }>(preferenceKey) - prevCollection.current = collectionSlug + prevCollection.current = defaultCollection if (currentPreferences?.columns) { // setTableColumns() diff --git a/packages/ui/src/fields/Join/index.tsx b/packages/ui/src/fields/Join/index.tsx index 0eb3d1469d..1a75eff971 100644 --- a/packages/ui/src/fields/Join/index.tsx +++ b/packages/ui/src/fields/Join/index.tsx @@ -160,11 +160,13 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => { } } - const where = { - [on]: { - equals: value, - }, - } + const where = Array.isArray(collection) + ? {} + : { + [on]: { + equals: value, + }, + } if (field.where) { return { @@ -173,10 +175,12 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => { } 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 relatedCollection = getEntityConfig({ collectionSlug: field.collection }) + const relatedCollection = getEntityConfig({ + collectionSlug: Array.isArray(field.collection) ? field.collection[0] : field.collection, + }) return getInitialDrawerData({ collectionSlug: docConfig?.slug, @@ -216,6 +220,15 @@ const JoinFieldComponent: JoinFieldClientComponent = (props) => { )} } + parent={ + Array.isArray(collection) + ? { + id: docID, + collectionSlug: docConfig.slug, + joinPath: path, + } + : undefined + } relationTo={collection} /> collection.slug === collectionSlug, - ) + if (!Array.isArray(collectionSlug)) { + if (req.payload.collections[collectionSlug]) { + collectionConfig = req.payload.collections[collectionSlug].config + clientCollectionConfig = clientConfig.collections.find( + (collection) => collection.slug === collectionSlug, + ) + } } const listPreferences = await upsertPreferences({ - key: `${collectionSlug}-list`, + key: Array.isArray(collectionSlug) + ? `${parent.collectionSlug}-${parent.joinPath}` + : `${collectionSlug}-list`, req, value: { columns, @@ -151,25 +157,78 @@ export const buildTableState = async ( // lookup docs, if desired, i.e. within `join` field which initialize with `depth: 0` if (!docs || query) { - data = await payload.find({ - collection: collectionSlug, - depth: 0, - limit: query?.limit ? parseInt(query.limit, 10) : undefined, - locale: req.locale, - overrideAccess: false, - page: query?.page ? parseInt(query.page, 10) : undefined, - sort: query?.sort, - user: req.user, - where: query?.where, - }) + if (Array.isArray(collectionSlug)) { + if (!parent) { + throw new APIError('Unexpected array of collectionSlug, parent must be providen') + } - docs = data.docs + 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({ + collection: collectionSlug, + depth: 0, + limit: query?.limit ? parseInt(query.limit, 10) : undefined, + locale: req.locale, + overrideAccess: false, + page: query?.page ? parseInt(query.page, 10) : undefined, + sort: query?.sort, + user: req.user, + where: query?.where, + }) + docs = data.docs + } } const { columnState, Table } = renderTable({ clientCollectionConfig, + clientConfig, 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, docs, enableRowSelections, @@ -177,10 +236,16 @@ export const buildTableState = async ( payload, renderRowTypes, 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 { data, diff --git a/packages/ui/src/utilities/renderTable.tsx b/packages/ui/src/utilities/renderTable.tsx index 835dea1eb3..912b9dfeb6 100644 --- a/packages/ui/src/utilities/renderTable.tsx +++ b/packages/ui/src/utilities/renderTable.tsx @@ -1,5 +1,7 @@ import type { ClientCollectionConfig, + ClientConfig, + ClientField, CollectionConfig, Field, ImportMap, @@ -10,13 +12,14 @@ import type { } from 'payload' 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 import type { Column } from '../exports/client/index.js' import { RenderServerComponent } from '../elements/RenderServerComponent/index.js' import { buildColumnState } from '../elements/TableColumns/buildColumnState.js' +import { buildPolymorphicColumnState } from '../elements/TableColumns/buildPolymorphicColumnState.js' import { filterFields } from '../elements/TableColumns/filterFields.js' import { getInitialColumns } from '../elements/TableColumns/getInitialColumns.js' @@ -50,7 +53,9 @@ export const renderFilters = ( export const renderTable = ({ clientCollectionConfig, + clientConfig, collectionConfig, + collections, columnPreferences, columns: columnsFromArgs, customCellProps, @@ -62,8 +67,10 @@ export const renderTable = ({ tableAppearance, useAsTitle, }: { - clientCollectionConfig: ClientCollectionConfig - collectionConfig: SanitizedCollectionConfig + clientCollectionConfig?: ClientCollectionConfig + clientConfig?: ClientConfig + collectionConfig?: SanitizedCollectionConfig + collections?: string[] columnPreferences: ListPreferences['columns'] columns?: ListPreferences['columns'] customCellProps?: Record @@ -80,31 +87,72 @@ export const renderTable = ({ Table: React.ReactNode } => { // Ensure that columns passed as args comply with the field config, i.e. `hidden`, `disableListColumn`, etc. - const columns = columnsFromArgs - ? columnsFromArgs?.filter((column) => - flattenTopLevelFields(clientCollectionConfig.fields, true)?.some( - (field) => 'name' in field && field.name === column.accessor, - ), - ) - : getInitialColumns( - filterFields(clientCollectionConfig.fields), - useAsTitle, - clientCollectionConfig?.admin?.defaultColumns, - ) - const columnState = buildColumnState({ - clientCollectionConfig, - collectionConfig, - columnPreferences, - columns, - enableRowSelections, - i18n, - // sortColumnProps, - customCellProps, - docs, - payload, - useAsTitle, - }) + 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 + ? columnsFromArgs?.filter((column) => + flattenTopLevelFields(clientCollectionConfig.fields, true)?.some( + (field) => 'name' in field && field.name === column.accessor, + ), + ) + : getInitialColumns( + filterFields(clientCollectionConfig.fields), + useAsTitle, + clientCollectionConfig?.admin?.defaultColumns, + ) + + columnState = buildColumnState({ + clientCollectionConfig, + collectionConfig, + columnPreferences, + columns, + enableRowSelections, + i18n, + // sortColumnProps, + customCellProps, + docs, + payload, + useAsTitle, + }) + } const columnsToUse = [...columnState] @@ -119,8 +167,15 @@ export const renderTable = ({ hidden: true, }, Heading: i18n.t('version:type'), - renderedCells: docs.map((_, i) => ( - {getTranslation(clientCollectionConfig.labels.singular, i18n)} + renderedCells: docs.map((doc, i) => ( + + {getTranslation( + collections + ? payload.collections[doc.relationTo].config.labels.singular + : clientCollectionConfig.labels.singular, + i18n, + )} + )), } as Column) } diff --git a/test/joins/config.ts b/test/joins/config.ts index 1ec77a48d5..8a55144c19 100644 --- a/test/joins/config.ts +++ b/test/joins/config.ts @@ -220,6 +220,120 @@ export default buildConfigWithDefaults({ }, ], }, + { + slug: 'multiple-collections-parents', + fields: [ + { + type: 'join', + name: 'children', + collection: ['multiple-collections-1', 'multiple-collections-2'], + on: 'parent', + admin: { + defaultColumns: ['title', 'name', 'description'], + }, + }, + ], + }, + { + slug: 'multiple-collections-1', + admin: { useAsTitle: 'title' }, + fields: [ + { + type: 'relationship', + relationTo: 'multiple-collections-parents', + name: 'parent', + }, + { + name: 'title', + type: 'text', + }, + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: 'multiple-collections-2', + admin: { useAsTitle: 'title' }, + fields: [ + { + type: 'relationship', + relationTo: 'multiple-collections-parents', + name: 'parent', + }, + { + name: 'title', + type: 'text', + }, + { + name: 'description', + type: 'text', + }, + ], + }, + + { + slug: 'folders', + fields: [ + { + type: 'relationship', + relationTo: 'folders', + name: 'folder', + }, + { + name: 'title', + type: 'text', + }, + { + type: 'join', + name: 'children', + collection: ['folders', 'example-pages', 'example-posts'], + on: 'folder', + admin: { + defaultColumns: ['title', 'name', 'description'], + }, + }, + ], + }, + { + slug: 'example-pages', + admin: { useAsTitle: 'title' }, + fields: [ + { + type: 'relationship', + relationTo: 'folders', + name: 'folder', + }, + { + name: 'title', + type: 'text', + }, + { + name: 'name', + type: 'text', + }, + ], + }, + { + slug: 'example-posts', + admin: { useAsTitle: 'title' }, + fields: [ + { + type: 'relationship', + relationTo: 'folders', + name: 'folder', + }, + { + name: 'title', + type: 'text', + }, + { + name: 'description', + type: 'text', + }, + ], + }, ], localization: { locales: [ diff --git a/test/joins/e2e.spec.ts b/test/joins/e2e.spec.ts index 9ee0a154d9..fa5e111ae7 100644 --- a/test/joins/e2e.spec.ts +++ b/test/joins/e2e.spec.ts @@ -36,9 +36,11 @@ const { beforeAll, beforeEach, describe } = test describe('Join Field', () => { let page: Page let categoriesURL: AdminUrlUtil + let foldersURL: AdminUrlUtil let uploadsURL: AdminUrlUtil let categoriesJoinRestrictedURL: AdminUrlUtil let categoryID: number | string + let rootFolderID: number | string beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(TEST_TIMEOUT_LONG) @@ -50,6 +52,7 @@ describe('Join Field', () => { categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug) uploadsURL = new AdminUrlUtil(serverURL, uploadsSlug) categoriesJoinRestrictedURL = new AdminUrlUtil(serverURL, categoriesJoinRestrictedSlug) + foldersURL = new AdminUrlUtil(serverURL, 'folders') const context = await browser.newContext() page = await context.newPage() @@ -86,6 +89,9 @@ describe('Join Field', () => { } ;({ id: categoryID } = docs[0]) + + const folder = await payload.find({ collection: 'folders', sort: 'createdAt', depth: 0 }) + rootFolderID = folder.docs[0]!.id }) test('should populate joined relationships in table cells of list view', async () => { @@ -469,6 +475,43 @@ describe('Join Field', () => { await expect(joinField.locator('.cell-canRead')).not.toContainText('false') }) + test('should render join field with array of collections', async () => { + await page.goto(foldersURL.edit(rootFolderID)) + const joinField = page.locator('#field-children.field-type.join') + await expect(joinField).toBeVisible() + await expect( + joinField.locator('.relationship-table tbody .row-1 .cell-collection .pill__label'), + ).toHaveText('Folder') + await expect( + joinField.locator('.relationship-table tbody .row-3 .cell-collection .pill__label'), + ).toHaveText('Example Post') + await expect( + joinField.locator('.relationship-table tbody .row-5 .cell-collection .pill__label'), + ).toHaveText('Example Page') + }) + + test('should create a new document from join field with array of collections', async () => { + await page.goto(foldersURL.edit(rootFolderID)) + const joinField = page.locator('#field-children.field-type.join') + await expect(joinField).toBeVisible() + + const addNewPopupBtn = joinField.locator('.relationship-table__add-new-polymorphic') + await expect(addNewPopupBtn).toBeVisible() + await addNewPopupBtn.click() + const pageOption = joinField.locator('.relationship-table__relation-button--example-pages') + await expect(pageOption).toHaveText('Example Page') + await pageOption.click() + await page.locator('.drawer__content input#field-title').fill('Some new page') + await page.locator('.drawer__content #action-save').click() + + await expect( + joinField.locator('.relationship-table tbody .row-1 .cell-collection .pill__label'), + ).toHaveText('Example Page') + await expect( + joinField.locator('.relationship-table tbody .row-1 .cell-title .drawer-link__cell'), + ).toHaveText('Some new page') + }) + test('should render create-first-user with when users collection has a join field and hide it', async () => { await payload.delete({ collection: 'users', where: {} }) const url = new AdminUrlUtil(serverURL, 'users') diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts index e56603c2e2..12b535e01c 100644 --- a/test/joins/int.spec.ts +++ b/test/joins/int.spec.ts @@ -1153,6 +1153,123 @@ describe('Joins Field', () => { expect(joinedDoc2.id).toBe(depthJoin_3.id) }) + + describe('Array of collection', () => { + it('should join across multiple collections', async () => { + let parent = await payload.create({ + collection: 'multiple-collections-parents', + depth: 0, + data: {}, + }) + + const child_1 = await payload.create({ + collection: 'multiple-collections-1', + depth: 0, + data: { + parent, + title: 'doc-1', + }, + }) + + const child_2 = await payload.create({ + collection: 'multiple-collections-2', + depth: 0, + data: { + parent, + title: 'doc-2', + }, + }) + + parent = await payload.findByID({ + collection: 'multiple-collections-parents', + id: parent.id, + depth: 0, + }) + + expect(parent.children.docs[0].value).toBe(child_2.id) + expect(parent.children.docs[0]?.relationTo).toBe('multiple-collections-2') + expect(parent.children.docs[1]?.value).toBe(child_1.id) + expect(parent.children.docs[1]?.relationTo).toBe('multiple-collections-1') + + parent = await payload.findByID({ + collection: 'multiple-collections-parents', + id: parent.id, + depth: 1, + }) + + expect(parent.children.docs[0].value.id).toBe(child_2.id) + expect(parent.children.docs[0]?.relationTo).toBe('multiple-collections-2') + expect(parent.children.docs[1]?.value.id).toBe(child_1.id) + expect(parent.children.docs[1]?.relationTo).toBe('multiple-collections-1') + + // Sorting across collections + parent = await payload.findByID({ + collection: 'multiple-collections-parents', + id: parent.id, + depth: 1, + joins: { + children: { + sort: 'title', + }, + }, + }) + + expect(parent.children.docs[0]?.value.title).toBe('doc-1') + expect(parent.children.docs[1]?.value.title).toBe('doc-2') + + parent = await payload.findByID({ + collection: 'multiple-collections-parents', + id: parent.id, + depth: 1, + joins: { + children: { + sort: '-title', + }, + }, + }) + + expect(parent.children.docs[0]?.value.title).toBe('doc-2') + expect(parent.children.docs[1]?.value.title).toBe('doc-1') + + // WHERE across collections + parent = await payload.findByID({ + collection: 'multiple-collections-parents', + id: parent.id, + depth: 1, + joins: { + children: { + where: { + title: { + equals: 'doc-1', + }, + }, + }, + }, + }) + + expect(parent.children?.docs).toHaveLength(1) + expect(parent.children.docs[0]?.value.title).toBe('doc-1') + + // WHERE by _relationTo (join for specific collectionSlug) + parent = await payload.findByID({ + collection: 'multiple-collections-parents', + id: parent.id, + depth: 1, + joins: { + children: { + where: { + relationTo: { + equals: 'multiple-collections-2', + }, + }, + }, + }, + }) + + expect(parent.children?.docs).toHaveLength(1) + expect(parent.children.docs[0]?.value.title).toBe('doc-2') + }) + }) }) async function createPost(overrides?: Partial, locale?: Config['locale']) { diff --git a/test/joins/payload-types.ts b/test/joins/payload-types.ts index 58a0983786..13392fa615 100644 --- a/test/joins/payload-types.ts +++ b/test/joins/payload-types.ts @@ -84,6 +84,12 @@ export interface Config { 'depth-joins-1': DepthJoins1; 'depth-joins-2': DepthJoins2; 'depth-joins-3': DepthJoins3; + 'multiple-collections-parents': MultipleCollectionsParent; + 'multiple-collections-1': MultipleCollections1; + 'multiple-collections-2': MultipleCollections2; + folders: Folder; + 'example-pages': ExamplePage; + 'example-posts': ExamplePost; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -135,6 +141,12 @@ export interface Config { 'depth-joins-2': { joins: 'depth-joins-1'; }; + 'multiple-collections-parents': { + children: 'multiple-collections-1' | 'multiple-collections-2'; + }; + folders: { + children: 'folders' | 'example-pages' | 'example-posts'; + }; }; collectionsSelect: { users: UsersSelect | UsersSelect; @@ -155,6 +167,12 @@ export interface Config { 'depth-joins-1': DepthJoins1Select | DepthJoins1Select; 'depth-joins-2': DepthJoins2Select | DepthJoins2Select; 'depth-joins-3': DepthJoins3Select | DepthJoins3Select; + 'multiple-collections-parents': MultipleCollectionsParentsSelect | MultipleCollectionsParentsSelect; + 'multiple-collections-1': MultipleCollections1Select | MultipleCollections1Select; + 'multiple-collections-2': MultipleCollections2Select | MultipleCollections2Select; + folders: FoldersSelect | FoldersSelect; + 'example-pages': ExamplePagesSelect | ExamplePagesSelect; + 'example-posts': ExamplePostsSelect | ExamplePostsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -581,6 +599,108 @@ export interface DepthJoins3 { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "multiple-collections-parents". + */ +export interface MultipleCollectionsParent { + id: string; + children?: { + docs?: + | ( + | { + relationTo?: 'multiple-collections-1'; + value: string | MultipleCollections1; + } + | { + relationTo?: 'multiple-collections-2'; + value: string | MultipleCollections2; + } + )[] + | null; + hasNextPage?: boolean | null; + } | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "multiple-collections-1". + */ +export interface MultipleCollections1 { + id: string; + parent?: (string | null) | MultipleCollectionsParent; + title?: string | null; + name?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "multiple-collections-2". + */ +export interface MultipleCollections2 { + id: string; + parent?: (string | null) | MultipleCollectionsParent; + title?: string | null; + description?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "folders". + */ +export interface Folder { + id: string; + folder?: (string | null) | Folder; + title?: string | null; + children?: { + docs?: + | ( + | { + relationTo?: 'folders'; + value: string | Folder; + } + | { + relationTo?: 'example-pages'; + value: string | ExamplePage; + } + | { + relationTo?: 'example-posts'; + value: string | ExamplePost; + } + )[] + | null; + hasNextPage?: boolean | null; + } | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "example-pages". + */ +export interface ExamplePage { + id: string; + folder?: (string | null) | Folder; + title?: string | null; + name?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "example-posts". + */ +export interface ExamplePost { + id: string; + folder?: (string | null) | Folder; + title?: string | null; + description?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -659,6 +779,30 @@ export interface PayloadLockedDocument { | ({ relationTo: 'depth-joins-3'; value: string | DepthJoins3; + } | null) + | ({ + relationTo: 'multiple-collections-parents'; + value: string | MultipleCollectionsParent; + } | null) + | ({ + relationTo: 'multiple-collections-1'; + value: string | MultipleCollections1; + } | null) + | ({ + relationTo: 'multiple-collections-2'; + value: string | MultipleCollections2; + } | null) + | ({ + relationTo: 'folders'; + value: string | Folder; + } | null) + | ({ + relationTo: 'example-pages'; + value: string | ExamplePage; + } | null) + | ({ + relationTo: 'example-posts'; + value: string | ExamplePost; } | null); globalSlug?: string | null; user: { @@ -958,6 +1102,70 @@ export interface DepthJoins3Select { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "multiple-collections-parents_select". + */ +export interface MultipleCollectionsParentsSelect { + 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 { + 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 { + 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 { + 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 { + 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 { + folder?: T; + title?: T; + description?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". diff --git a/test/joins/seed.ts b/test/joins/seed.ts index 90bd8dad00..d0c6177803 100644 --- a/test/joins/seed.ts +++ b/test/joins/seed.ts @@ -146,6 +146,74 @@ export const seed = async (_payload: Payload) => { category: restrictedCategory.id, }, }) + + const root_folder = await _payload.create({ + collection: 'folders', + data: { + folder: null, + title: 'Root folder', + }, + }) + + const page_1 = await _payload.create({ + collection: 'example-pages', + data: { title: 'page 1', name: 'Andrew', folder: root_folder }, + }) + + const post_1 = await _payload.create({ + collection: 'example-posts', + data: { title: 'page 1', description: 'This is post 1', folder: root_folder }, + }) + + const page_2 = await _payload.create({ + collection: 'example-pages', + data: { title: 'page 2', name: 'Sophia', folder: root_folder }, + }) + + const page_3 = await _payload.create({ + collection: 'example-pages', + data: { title: 'page 3', name: 'Michael', folder: root_folder }, + }) + + const post_2 = await _payload.create({ + collection: 'example-posts', + data: { title: 'post 2', description: 'This is post 2', folder: root_folder }, + }) + + const post_3 = await _payload.create({ + collection: 'example-posts', + data: { title: 'post 3', description: 'This is post 3', folder: root_folder }, + }) + + const sub_folder_1 = await _payload.create({ + collection: 'folders', + data: { folder: root_folder, title: 'Sub Folder 1' }, + }) + + const page_4 = await _payload.create({ + collection: 'example-pages', + data: { title: 'page 4', name: 'Emma', folder: sub_folder_1 }, + }) + + const post_4 = await _payload.create({ + collection: 'example-posts', + data: { title: 'post 4', description: 'This is post 4', folder: sub_folder_1 }, + }) + + const sub_folder_2 = await _payload.create({ + collection: 'folders', + data: { folder: root_folder, title: 'Sub Folder 2' }, + }) + + const page_5 = await _payload.create({ + collection: 'example-pages', + data: { title: 'page 5', name: 'Liam', folder: sub_folder_2 }, + }) + + const post_5 = await _payload.create({ + collection: 'example-posts', + data: { title: 'post 5', description: 'This is post 5', folder: sub_folder_2 }, + }) } export async function clearAndSeedEverything(_payload: Payload) { diff --git a/tsconfig.base.json b/tsconfig.base.json index 115d214e77..ffd7ec771c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,21 +16,13 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "jsx": "preserve", - "lib": [ - "DOM", - "DOM.Iterable", - "ES2022" - ], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "outDir": "${configDir}/dist", "resolveJsonModule": true, "skipLibCheck": true, "emitDeclarationOnly": true, "sourceMap": true, - "types": [ - "jest", - "node", - "@types/jest" - ], + "types": ["jest", "node", "@types/jest"], "incremental": true, "isolatedModules": true, "plugins": [ @@ -51,33 +43,19 @@ "@payloadcms/richtext-lexical/client": [ "./packages/richtext-lexical/src/exports/client/index.ts" ], - "@payloadcms/richtext-lexical/rsc": [ - "./packages/richtext-lexical/src/exports/server/rsc.ts" - ], - "@payloadcms/richtext-slate/rsc": [ - "./packages/richtext-slate/src/exports/server/rsc.ts" - ], + "@payloadcms/richtext-lexical/rsc": ["./packages/richtext-lexical/src/exports/server/rsc.ts"], + "@payloadcms/richtext-slate/rsc": ["./packages/richtext-slate/src/exports/server/rsc.ts"], "@payloadcms/richtext-slate/client": [ "./packages/richtext-slate/src/exports/client/index.ts" ], - "@payloadcms/plugin-seo/client": [ - "./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-search/client": [ - "./packages/plugin-search/src/exports/client.ts" - ], + "@payloadcms/plugin-seo/client": ["./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-search/client": ["./packages/plugin-search/src/exports/client.ts"], "@payloadcms/plugin-form-builder/client": [ "./packages/plugin-form-builder/src/exports/client.ts" ], - "@payloadcms/plugin-multi-tenant/rsc": [ - "./packages/plugin-multi-tenant/src/exports/rsc.ts" - ], + "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], "@payloadcms/plugin-multi-tenant/utilities": [ "./packages/plugin-multi-tenant/src/exports/utilities.ts" ], @@ -87,21 +65,10 @@ "@payloadcms/plugin-multi-tenant/client": [ "./packages/plugin-multi-tenant/src/exports/client.ts" ], - "@payloadcms/plugin-multi-tenant": [ - "./packages/plugin-multi-tenant/src/index.ts" - ], - "@payloadcms/next": [ - "./packages/next/src/exports/*" - ] + "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], + "@payloadcms/next": ["./packages/next/src/exports/*"] } }, - "include": [ - "${configDir}/src" - ], - "exclude": [ - "${configDir}/dist", - "${configDir}/build", - "${configDir}/temp", - "**/*.spec.ts" - ] + "include": ["${configDir}/src"], + "exclude": ["${configDir}/dist", "${configDir}/build", "${configDir}/temp", "**/*.spec.ts"] }