diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 4b46f8c383b..31dbe9deeb4 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -52,7 +52,6 @@ jobs: plugin-form-builder plugin-nested-docs plugin-redirects - plugin-relationship-object-ids plugin-search plugin-sentry plugin-seo diff --git a/docs/fields/join.mdx b/docs/fields/join.mdx new file mode 100644 index 00000000000..53ac2f218b7 --- /dev/null +++ b/docs/fields/join.mdx @@ -0,0 +1,140 @@ +--- +title: Join Field +label: Join +order: 140 +desc: The Join field provides the ability to work on related documents. Learn how to use Join field, see examples and options. +keywords: join, relationship, junction, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs +--- + +The Join Field is used to make Relationship fields in the opposite direction. It is used to show the relationship from +the other side. The field itself acts as a virtual field, in that no new data is stored on the collection with a Join +field. Instead, the Admin UI surfaces the related documents for a better editing experience and is surfaced by Payload's APIs. + +The Join field is useful in scenarios including: + +- To see related `Products` on an `Order` +- To view and edit `Posts` belonging to a `Category` +- To work with any bi-directional relationship data + +For the Join field to work, you must have an existing [relationship](./relationship) field in the collection you are +joining. This will reference the collection and path of the field of the related documents. +To add a Relationship Field, set the `type` to `join` in your [Field Config](./overview): + +```ts +import type { Field } from 'payload/types' + +export const MyJoinField: Field = { + // highlight-start + name: 'relatedPosts', + type: 'join', + collection: 'posts', + on: 'category', + // highlight-end +} + +// relationship field in another collection: +export const MyRelationshipField: Field = { + name: 'category', + type: 'relationship', + relationTo: 'categories', +} +``` + +In this example, the field is defined to show the related `posts` when added to a `category` collection. The `on` property is used to +specify the relationship field name of the field that relates to the collection document. + +## Config Options + +| Option | Description | +|------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`collection`** \* | The `slug`s having the relationship field. | +| **`on`** \* | The relationship field name of the field that relates to collection document. Use dot notation for nested paths, like 'myGroup.relationName'. | +| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth) | +| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. | +| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). | +| **`access`** | Provide Field Access Control to denote what users can see and do with this field's data. [More details](../access-control/fields). | +| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | +| **`required`** | Require this field to have a value. | +| **`admin`** | Admin-specific configuration. | +| **`custom`** | Extension point for adding custom data (e.g. for plugins) | +| **`typescriptSchema`** | Override field type generation with providing a JSON schema | + +_\* An asterisk denotes that a property is required._ + +## Join Field Data + +When a document is returned that for a Join field is populated with related documents. The structure returned is an object with: + +- `docs` an array of related documents or only IDs if the depth is reached +- `hasNextPage` a boolean indicating if there are additional documents + +```json +{ + "id": "66e3431a3f23e684075aae9c", + "relatedPosts": { + "docs": [ + { + "id": "66e3431a3f23e684075aaeb9", + // other fields... + "category": "66e3431a3f23e684075aae9c", + }, + // { ... } + ], + "hasNextPage": false + }, + // other fields... +} +``` + +## Query Options + +The Join Field supports custom queries to filter, sort, and limit the related documents that will be returned. In addition to the specific query options for each Join Field, you can pass `joins: false` to disable all Join Field from returning. This is useful for performance reasons when you don't need the related documents. + +The following query options are supported: + +| Property | Description | +|-------------|--------------------------------------------------------------| +| **`limit`** | The maximum related documents to be returned, default is 10. | +| **`where`** | An optional `Where` query to filter joined documents. | +| **`sort`** | A string used to order related results | + +These can be applied to the local API, GraphQL, and REST API. + +### Local API + +By adding `joins` to the local API you can customize the request for each join field by the `name` of the field. + +```js +const result = await db.findOne('categories', { + where: { + title: { + equals: 'My Category' + } + }, + joins: { + relatedPosts: { + limit: 5, + where: { + title: { + equals: 'My Post' + } + }, + sort: 'title' + } + } +}) +``` + +### Rest API + +The rest API supports the same query options as the local API. You can use the `joins` query parameter to customize the request for each join field by the `name` of the field. For example, an API call to get a document with the related posts limited to 5 and sorted by title: + +`/api/categories/${id}?joins[relatedPosts][limit]=5&joins[relatedPosts][sort]=title` + +You can specify as many `joins` parameters as needed for the same or different join fields for a single request. + +### GraphQL + +Coming soon. + diff --git a/package.json b/package.json index e85a2ecfa1b..9534fd6b8fb 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"", "build:plugin-nested-docs": "turbo build --filter \"@payloadcms/plugin-nested-docs\"", "build:plugin-redirects": "turbo build --filter \"@payloadcms/plugin-redirects\"", - "build:plugin-relationship-object-ids": "turbo build --filter \"@payloadcms/plugin-relationship-object-ids\"", "build:plugin-search": "turbo build --filter \"@payloadcms/plugin-search\"", "build:plugin-sentry": "turbo build --filter \"@payloadcms/plugin-sentry\"", "build:plugin-seo": "turbo build --filter \"@payloadcms/plugin-seo\"", diff --git a/packages/db-mongodb/package.json b/packages/db-mongodb/package.json index e4e91203b35..601484a2e12 100644 --- a/packages/db-mongodb/package.json +++ b/packages/db-mongodb/package.json @@ -38,13 +38,14 @@ "bson-objectid": "2.0.4", "http-status": "1.6.2", "mongoose": "6.12.3", + "mongoose-aggregate-paginate-v2": "1.0.6", "mongoose-paginate-v2": "1.7.22", "prompts": "2.4.2", "uuid": "10.0.0" }, "devDependencies": { "@payloadcms/eslint-config": "workspace:*", - "@types/mongoose-aggregate-paginate-v2": "1.0.9", + "@types/mongoose-aggregate-paginate-v2": "1.0.6", "mongodb": "4.17.1", "mongodb-memory-server": "^9", "payload": "workspace:*" diff --git a/packages/db-mongodb/src/create.ts b/packages/db-mongodb/src/create.ts index f01a9d6af35..d1ac9a6829c 100644 --- a/packages/db-mongodb/src/create.ts +++ b/packages/db-mongodb/src/create.ts @@ -3,6 +3,7 @@ import type { Create, Document, PayloadRequest } from 'payload' import type { MongooseAdapter } from './index.js' import { handleError } from './utilities/handleError.js' +import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { withSession } from './withSession.js' export const create: Create = async function create( @@ -12,8 +13,15 @@ export const create: Create = async function create( const Model = this.collections[collection] const options = await withSession(this, req) let doc + + const sanitizedData = sanitizeRelationshipIDs({ + config: this.payload.config, + data, + fields: this.payload.collections[collection].config.fields, + }) + try { - ;[doc] = await Model.create([data], options) + ;[doc] = await Model.create([sanitizedData], options) } catch (error) { handleError({ collection, error, req }) } diff --git a/packages/db-mongodb/src/createGlobal.ts b/packages/db-mongodb/src/createGlobal.ts index f199b621c5a..d57c180f33d 100644 --- a/packages/db-mongodb/src/createGlobal.ts +++ b/packages/db-mongodb/src/createGlobal.ts @@ -3,6 +3,7 @@ import type { CreateGlobal, PayloadRequest } from 'payload' import type { MongooseAdapter } from './index.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { withSession } from './withSession.js' export const createGlobal: CreateGlobal = async function createGlobal( @@ -10,10 +11,16 @@ export const createGlobal: CreateGlobal = async function createGlobal( { slug, data, req = {} as PayloadRequest }, ) { const Model = this.globals - const global = { - globalType: slug, - ...data, - } + + const global = sanitizeRelationshipIDs({ + config: this.payload.config, + data: { + globalType: slug, + ...data, + }, + fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields, + }) + const options = await withSession(this, req) let [result] = (await Model.create([global], options)) as any diff --git a/packages/db-mongodb/src/createGlobalVersion.ts b/packages/db-mongodb/src/createGlobalVersion.ts index a4c849b6ad8..c149b7882f7 100644 --- a/packages/db-mongodb/src/createGlobalVersion.ts +++ b/packages/db-mongodb/src/createGlobalVersion.ts @@ -1,7 +1,13 @@ -import type { CreateGlobalVersion, Document, PayloadRequest } from 'payload' +import { + buildVersionGlobalFields, + type CreateGlobalVersion, + type Document, + type PayloadRequest, +} from 'payload' import type { MongooseAdapter } from './index.js' +import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { withSession } from './withSession.js' export const createGlobalVersion: CreateGlobalVersion = async function createGlobalVersion( @@ -21,22 +27,25 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo const VersionModel = this.versions[globalSlug] const options = await withSession(this, req) - const [doc] = await VersionModel.create( - [ - { - autosave, - createdAt, - latest: true, - parent, - publishedLocale, - snapshot, - updatedAt, - version: versionData, - }, - ], - options, - req, - ) + const data = sanitizeRelationshipIDs({ + config: this.payload.config, + data: { + autosave, + createdAt, + latest: true, + parent, + publishedLocale, + snapshot, + updatedAt, + version: versionData, + }, + fields: buildVersionGlobalFields( + this.payload.config, + this.payload.config.globals.find((global) => global.slug === globalSlug), + ), + }) + + const [doc] = await VersionModel.create([data], options, req) await VersionModel.updateMany( { diff --git a/packages/db-mongodb/src/createVersion.ts b/packages/db-mongodb/src/createVersion.ts index 2839ea41e58..747f359b67c 100644 --- a/packages/db-mongodb/src/createVersion.ts +++ b/packages/db-mongodb/src/createVersion.ts @@ -1,7 +1,13 @@ -import type { CreateVersion, Document, PayloadRequest } from 'payload' +import { + buildVersionCollectionFields, + type CreateVersion, + type Document, + type PayloadRequest, +} from 'payload' import type { MongooseAdapter } from './index.js' +import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { withSession } from './withSession.js' export const createVersion: CreateVersion = async function createVersion( @@ -21,22 +27,25 @@ export const createVersion: CreateVersion = async function createVersion( const VersionModel = this.versions[collectionSlug] const options = await withSession(this, req) - const [doc] = await VersionModel.create( - [ - { - autosave, - createdAt, - latest: true, - parent, - publishedLocale, - snapshot, - updatedAt, - version: versionData, - }, - ], - options, - req, - ) + const data = sanitizeRelationshipIDs({ + config: this.payload.config, + data: { + autosave, + createdAt, + latest: true, + parent, + publishedLocale, + snapshot, + updatedAt, + version: versionData, + }, + fields: buildVersionCollectionFields( + this.payload.config, + this.payload.collections[collectionSlug].config, + ), + }) + + const [doc] = await VersionModel.create([data], options, req) await VersionModel.updateMany( { @@ -48,7 +57,7 @@ export const createVersion: CreateVersion = async function createVersion( }, { parent: { - $eq: parent, + $eq: data.parent, }, }, { diff --git a/packages/db-mongodb/src/find.ts b/packages/db-mongodb/src/find.ts index 4fc321b96a9..137279eabef 100644 --- a/packages/db-mongodb/src/find.ts +++ b/packages/db-mongodb/src/find.ts @@ -6,12 +6,24 @@ import { flattenWhereToOperators } from 'payload' import type { MongooseAdapter } from './index.js' import { buildSortParam } from './queries/buildSortParam.js' +import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' import { withSession } from './withSession.js' export const find: Find = async function find( this: MongooseAdapter, - { collection, limit, locale, page, pagination, req = {} as PayloadRequest, sort: sortArg, where }, + { + collection, + joins = {}, + limit, + locale, + page, + pagination, + projection, + req = {} as PayloadRequest, + sort: sortArg, + where, + }, ) { const Model = this.collections[collection] const collectionConfig = this.payload.collections[collection].config @@ -50,6 +62,7 @@ export const find: Find = async function find( options, page, pagination, + projection, sort, useEstimatedCount, } @@ -88,7 +101,24 @@ export const find: Find = async function find( } } - const result = await Model.paginate(query, paginationOptions) + let result + + const aggregate = await buildJoinAggregation({ + adapter: this, + collection, + collectionConfig, + joins, + limit, + locale, + query, + }) + // build join aggregation + if (aggregate) { + result = await Model.aggregatePaginate(Model.aggregate(aggregate), paginationOptions) + } else { + result = await Model.paginate(query, paginationOptions) + } + const docs = JSON.parse(JSON.stringify(result.docs)) return { diff --git a/packages/db-mongodb/src/findOne.ts b/packages/db-mongodb/src/findOne.ts index c0fc96265d2..d2be5165678 100644 --- a/packages/db-mongodb/src/findOne.ts +++ b/packages/db-mongodb/src/findOne.ts @@ -3,14 +3,16 @@ import type { Document, FindOne, PayloadRequest } from 'payload' import type { MongooseAdapter } from './index.js' +import { buildJoinAggregation } from './utilities/buildJoinAggregation.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' import { withSession } from './withSession.js' export const findOne: FindOne = async function findOne( this: MongooseAdapter, - { collection, locale, req = {} as PayloadRequest, where }, + { collection, joins, locale, req = {} as PayloadRequest, where }, ) { const Model = this.collections[collection] + const collectionConfig = this.payload.collections[collection].config const options: MongooseQueryOptions = { ...(await withSession(this, req)), lean: true, @@ -22,7 +24,22 @@ export const findOne: FindOne = async function findOne( where, }) - const doc = await Model.findOne(query, {}, options) + const aggregate = await buildJoinAggregation({ + adapter: this, + collection, + collectionConfig, + joins, + limit: 1, + locale, + query, + }) + + let doc + if (aggregate) { + ;[doc] = await Model.aggregate(aggregate, options) + } else { + doc = await Model.findOne(query, {}, options) + } if (!doc) { return null diff --git a/packages/db-mongodb/src/init.ts b/packages/db-mongodb/src/init.ts index e679e98408c..f307fb0e41e 100644 --- a/packages/db-mongodb/src/init.ts +++ b/packages/db-mongodb/src/init.ts @@ -2,6 +2,7 @@ import type { PaginateOptions } from 'mongoose' import type { Init, SanitizedCollectionConfig } from 'payload' import mongoose from 'mongoose' +import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2' import paginate from 'mongoose-paginate-v2' import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload' @@ -40,12 +41,16 @@ export const init: Init = function init(this: MongooseAdapter) { }), ) + if (Object.keys(collection.joins).length > 0) { + versionSchema.plugin(mongooseAggregatePaginate) + } + const model = mongoose.model( versionModelName, versionSchema, this.autoPluralization === true ? undefined : versionModelName, ) as CollectionModel - // this.payload.versions[collection.slug] = model; + this.versions[collection.slug] = model } diff --git a/packages/db-mongodb/src/models/buildCollectionSchema.ts b/packages/db-mongodb/src/models/buildCollectionSchema.ts index 02856095447..5a191dab481 100644 --- a/packages/db-mongodb/src/models/buildCollectionSchema.ts +++ b/packages/db-mongodb/src/models/buildCollectionSchema.ts @@ -1,6 +1,7 @@ import type { PaginateOptions, Schema } from 'mongoose' import type { SanitizedCollectionConfig, SanitizedConfig } from 'payload' +import mongooseAggregatePaginate from 'mongoose-aggregate-paginate-v2' import paginate from 'mongoose-paginate-v2' import { getBuildQueryPlugin } from '../queries/buildQuery.js' @@ -42,5 +43,9 @@ export const buildCollectionSchema = ( .plugin(paginate, { useEstimatedCount: true }) .plugin(getBuildQueryPlugin({ collectionSlug: collection.slug })) + if (Object.keys(collection.joins).length > 0) { + schema.plugin(mongooseAggregatePaginate) + } + return schema } diff --git a/packages/db-mongodb/src/queries/buildSearchParams.ts b/packages/db-mongodb/src/queries/buildSearchParams.ts index 4e4b559330d..875d18795fa 100644 --- a/packages/db-mongodb/src/queries/buildSearchParams.ts +++ b/packages/db-mongodb/src/queries/buildSearchParams.ts @@ -165,7 +165,7 @@ export async function buildSearchParam({ const subQuery = priorQueryResult.value const result = await SubModel.find(subQuery, subQueryOptions) - const $in = result.map((doc) => doc._id.toString()) + const $in = result.map((doc) => doc._id) // If it is the last recursion // then pass through the search param diff --git a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts index 600d046bad7..024d9c7177c 100644 --- a/packages/db-mongodb/src/queries/sanitizeQueryValue.ts +++ b/packages/db-mongodb/src/queries/sanitizeQueryValue.ts @@ -1,5 +1,6 @@ import type { Field, TabAsField } from 'payload' +import ObjectIdImport from 'bson-objectid' import mongoose from 'mongoose' import { createArrayFromCommaDelineated } from 'payload' @@ -11,6 +12,8 @@ type SanitizeQueryValueArgs = { val: any } +const ObjectId = (ObjectIdImport.default || + ObjectIdImport) as unknown as typeof ObjectIdImport.default export const sanitizeQueryValue = ({ field, hasCustomID, @@ -26,21 +29,49 @@ export const sanitizeQueryValue = ({ let formattedOperator = operator // Disregard invalid _ids - if (path === '_id' && typeof val === 'string' && val.split(',').length === 1) { - if (!hasCustomID) { - const isValid = mongoose.Types.ObjectId.isValid(val) - - if (!isValid) { - return { operator: formattedOperator, val: undefined } + if (path === '_id') { + if (typeof val === 'string' && val.split(',').length === 1) { + if (!hasCustomID) { + const isValid = mongoose.Types.ObjectId.isValid(val) + + if (!isValid) { + return { operator: formattedOperator, val: undefined } + } else { + if (['in', 'not_in'].includes(operator)) { + formattedValue = createArrayFromCommaDelineated(formattedValue).map((id) => + ObjectId(id), + ) + } else { + formattedValue = ObjectId(val) + } + } } - } - if (field.type === 'number') { - const parsedNumber = parseFloat(val) + if (field.type === 'number') { + const parsedNumber = parseFloat(val) - if (Number.isNaN(parsedNumber)) { - return { operator: formattedOperator, val: undefined } + if (Number.isNaN(parsedNumber)) { + return { operator: formattedOperator, val: undefined } + } } + } else if (Array.isArray(val)) { + formattedValue = formattedValue.reduce((formattedValues, inVal) => { + const newValues = [inVal] + if (!hasCustomID) { + if (mongoose.Types.ObjectId.isValid(inVal)) { + newValues.push(ObjectId(inVal)) + } + } + + if (field.type === 'number') { + const parsedNumber = parseFloat(inVal) + if (!Number.isNaN(parsedNumber)) { + newValues.push(parsedNumber) + } + } + + return [...formattedValues, ...newValues] + }, []) } } @@ -86,6 +117,13 @@ export const sanitizeQueryValue = ({ formattedValue.value && formattedValue.relationTo ) { + const { value } = formattedValue + const isValid = mongoose.Types.ObjectId.isValid(value) + + if (isValid) { + formattedValue.value = ObjectId(value) + } + return { rawQuery: { $and: [ @@ -96,11 +134,11 @@ export const sanitizeQueryValue = ({ } } - if (operator === 'in' && Array.isArray(formattedValue)) { + if (['in', 'not_in'].includes(operator) && Array.isArray(formattedValue)) { formattedValue = formattedValue.reduce((formattedValues, inVal) => { const newValues = [inVal] if (mongoose.Types.ObjectId.isValid(inVal)) { - newValues.push(new mongoose.Types.ObjectId(inVal)) + newValues.push(ObjectId(inVal)) } const parsedNumber = parseFloat(inVal) @@ -111,6 +149,12 @@ export const sanitizeQueryValue = ({ return [...formattedValues, ...newValues] }, []) } + + if (operator === 'contains' && typeof formattedValue === 'string') { + if (mongoose.Types.ObjectId.isValid(formattedValue)) { + formattedValue = ObjectId(formattedValue) + } + } } // Set up specific formatting necessary by operators @@ -152,7 +196,7 @@ export const sanitizeQueryValue = ({ } if (path !== '_id' || (path === '_id' && hasCustomID && field.type === 'text')) { - if (operator === 'contains') { + if (operator === 'contains' && !mongoose.Types.ObjectId.isValid(formattedValue)) { formattedValue = { $options: 'i', $regex: formattedValue.replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'), diff --git a/packages/db-mongodb/src/types.ts b/packages/db-mongodb/src/types.ts index b48b7759c45..3bd4ea0d5b6 100644 --- a/packages/db-mongodb/src/types.ts +++ b/packages/db-mongodb/src/types.ts @@ -1,4 +1,11 @@ -import type { IndexDefinition, IndexOptions, Model, PaginateModel, SchemaOptions } from 'mongoose' +import type { + AggregatePaginateModel, + IndexDefinition, + IndexOptions, + Model, + PaginateModel, + SchemaOptions, +} from 'mongoose' import type { ArrayField, BlocksField, @@ -9,6 +16,7 @@ import type { EmailField, Field, GroupField, + JoinField, JSONField, NumberField, Payload, @@ -27,7 +35,10 @@ import type { import type { BuildQueryArgs } from './queries/buildQuery.js' -export interface CollectionModel extends Model, PaginateModel { +export interface CollectionModel + extends Model, + PaginateModel, + AggregatePaginateModel { /** buildQuery is used to transform payload's where operator into what can be used by mongoose (e.g. id => _id) */ buildQuery: (args: BuildQueryArgs) => Promise> // TODO: Delete this } @@ -83,6 +94,7 @@ export type FieldToSchemaMap = { date: FieldGeneratorFunction email: FieldGeneratorFunction group: FieldGeneratorFunction + join: FieldGeneratorFunction json: FieldGeneratorFunction number: FieldGeneratorFunction point: FieldGeneratorFunction diff --git a/packages/db-mongodb/src/updateGlobal.ts b/packages/db-mongodb/src/updateGlobal.ts index 19e943a6dde..66bcdd4aff2 100644 --- a/packages/db-mongodb/src/updateGlobal.ts +++ b/packages/db-mongodb/src/updateGlobal.ts @@ -3,6 +3,7 @@ import type { PayloadRequest, UpdateGlobal } from 'payload' import type { MongooseAdapter } from './index.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { withSession } from './withSession.js' export const updateGlobal: UpdateGlobal = async function updateGlobal( @@ -17,7 +18,14 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal( } let result - result = await Model.findOneAndUpdate({ globalType: slug }, data, options) + + const sanitizedData = sanitizeRelationshipIDs({ + config: this.payload.config, + data, + fields: this.payload.config.globals.find((global) => global.slug === slug).fields, + }) + + result = await Model.findOneAndUpdate({ globalType: slug }, sanitizedData, options) result = JSON.parse(JSON.stringify(result)) diff --git a/packages/db-mongodb/src/updateGlobalVersion.ts b/packages/db-mongodb/src/updateGlobalVersion.ts index dc575b65fbd..10618033d4b 100644 --- a/packages/db-mongodb/src/updateGlobalVersion.ts +++ b/packages/db-mongodb/src/updateGlobalVersion.ts @@ -1,21 +1,27 @@ -import type { PayloadRequest, TypeWithID, UpdateGlobalVersionArgs } from 'payload' +import { + buildVersionGlobalFields, + type PayloadRequest, + type TypeWithID, + type UpdateGlobalVersionArgs, +} from 'payload' import type { MongooseAdapter } from './index.js' +import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { withSession } from './withSession.js' export async function updateGlobalVersion( this: MongooseAdapter, { id, - global, + global: globalSlug, locale, req = {} as PayloadRequest, versionData, where, }: UpdateGlobalVersionArgs, ) { - const VersionModel = this.versions[global] + const VersionModel = this.versions[globalSlug] const whereToUse = where || { id: { equals: id } } const options = { ...(await withSession(this, req)), @@ -29,7 +35,16 @@ export async function updateGlobalVersion( where: whereToUse, }) - const doc = await VersionModel.findOneAndUpdate(query, versionData, options) + const sanitizedData = sanitizeRelationshipIDs({ + config: this.payload.config, + data: versionData, + fields: buildVersionGlobalFields( + this.payload.config, + this.payload.config.globals.find((global) => global.slug === globalSlug), + ), + }) + + const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options) const result = JSON.parse(JSON.stringify(doc)) diff --git a/packages/db-mongodb/src/updateOne.ts b/packages/db-mongodb/src/updateOne.ts index efee0b8fc0e..0265f477013 100644 --- a/packages/db-mongodb/src/updateOne.ts +++ b/packages/db-mongodb/src/updateOne.ts @@ -4,6 +4,7 @@ import type { MongooseAdapter } from './index.js' import { handleError } from './utilities/handleError.js' import { sanitizeInternalFields } from './utilities/sanitizeInternalFields.js' +import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { withSession } from './withSession.js' export const updateOne: UpdateOne = async function updateOne( @@ -26,8 +27,14 @@ export const updateOne: UpdateOne = async function updateOne( let result + const sanitizedData = sanitizeRelationshipIDs({ + config: this.payload.config, + data, + fields: this.payload.collections[collection].config.fields, + }) + try { - result = await Model.findOneAndUpdate(query, data, options) + result = await Model.findOneAndUpdate(query, sanitizedData, options) } catch (error) { handleError({ collection, error, req }) } diff --git a/packages/db-mongodb/src/updateVersion.ts b/packages/db-mongodb/src/updateVersion.ts index 84757e71764..da45bbb60b4 100644 --- a/packages/db-mongodb/src/updateVersion.ts +++ b/packages/db-mongodb/src/updateVersion.ts @@ -1,7 +1,8 @@ -import type { PayloadRequest, UpdateVersion } from 'payload' +import { buildVersionCollectionFields, type PayloadRequest, type UpdateVersion } from 'payload' import type { MongooseAdapter } from './index.js' +import { sanitizeRelationshipIDs } from './utilities/sanitizeRelationshipIDs.js' import { withSession } from './withSession.js' export const updateVersion: UpdateVersion = async function updateVersion( @@ -22,7 +23,16 @@ export const updateVersion: UpdateVersion = async function updateVersion( where: whereToUse, }) - const doc = await VersionModel.findOneAndUpdate(query, versionData, options) + const sanitizedData = sanitizeRelationshipIDs({ + config: this.payload.config, + data: versionData, + fields: buildVersionCollectionFields( + this.payload.config, + this.payload.collections[collection].config, + ), + }) + + const doc = await VersionModel.findOneAndUpdate(query, sanitizedData, options) const result = JSON.parse(JSON.stringify(doc)) diff --git a/packages/db-mongodb/src/utilities/buildJoinAggregation.ts b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts new file mode 100644 index 00000000000..05c261747c1 --- /dev/null +++ b/packages/db-mongodb/src/utilities/buildJoinAggregation.ts @@ -0,0 +1,174 @@ +import type { PipelineStage } from 'mongoose' +import type { CollectionSlug, JoinQuery, SanitizedCollectionConfig, Where } from 'payload' + +import type { MongooseAdapter } from '../index.js' + +import { buildSortParam } from '../queries/buildSortParam.js' + +type BuildJoinAggregationArgs = { + adapter: MongooseAdapter + collection: CollectionSlug + collectionConfig: SanitizedCollectionConfig + joins: JoinQuery + // the number of docs to get at the top collection level + limit?: number + locale: string + // the where clause for the top collection + query?: Where +} + +export const buildJoinAggregation = async ({ + adapter, + collection, + collectionConfig, + joins, + limit, + locale, + query, +}: BuildJoinAggregationArgs): Promise => { + if (Object.keys(collectionConfig.joins).length === 0 || joins === false) { + return + } + + const joinConfig = adapter.payload.collections[collection].config.joins + const aggregate: PipelineStage[] = [ + { + $sort: { createdAt: -1 }, + }, + ] + + if (query) { + aggregate.push({ + $match: query, + }) + } + + if (limit) { + aggregate.push({ + $limit: limit, + }) + } + + for (const slug of Object.keys(joinConfig)) { + for (const join of joinConfig[slug]) { + const joinModel = adapter.collections[join.field.collection] + + const { + limit: limitJoin = 10, + sort: sortJoin, + where: whereJoin, + } = joins?.[join.schemaPath] || {} + + const sort = buildSortParam({ + config: adapter.payload.config, + fields: adapter.payload.collections[slug].config.fields, + locale, + sort: sortJoin || collectionConfig.defaultSort, + timestamps: true, + }) + const sortProperty = Object.keys(sort)[0] + const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1 + + const $match = await joinModel.buildQuery({ + locale, + payload: adapter.payload, + where: whereJoin, + }) + + const pipeline: Exclude[] = [ + { $match }, + { + $sort: { [sortProperty]: sortDirection }, + }, + ] + + if (limitJoin > 0) { + pipeline.push({ + $limit: limitJoin + 1, + }) + } + + if (adapter.payload.config.localization && locale === 'all') { + adapter.payload.config.localization.localeCodes.forEach((code) => { + const as = `${join.schemaPath}${code}` + + aggregate.push( + { + $lookup: { + as: `${as}.docs`, + foreignField: `${join.field.on}${code}`, + from: slug, + localField: '_id', + pipeline, + }, + }, + { + $addFields: { + [`${as}.docs`]: { + $map: { + as: 'doc', + in: '$$doc._id', + input: `$${as}.docs`, + }, + }, // Slicing the docs to match the limit + [`${as}.hasNextPage`]: { + $gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE], + }, // Boolean indicating if more docs than limit + }, + }, + ) + if (limitJoin > 0) { + aggregate.push({ + $addFields: { + [`${as}.docs`]: { + $slice: [`$${as}.docs`, limitJoin], + }, + }, + }) + } + }) + } else { + const localeSuffix = + join.field.localized && adapter.payload.config.localization && locale ? `.${locale}` : '' + const as = `${join.schemaPath}${localeSuffix}` + + aggregate.push( + { + $lookup: { + as: `${as}.docs`, + foreignField: `${join.field.on}${localeSuffix}`, + from: slug, + localField: '_id', + pipeline, + }, + }, + { + $addFields: { + [`${as}.docs`]: { + $map: { + as: 'doc', + in: '$$doc._id', + input: `$${as}.docs`, + }, + }, // Slicing the docs to match the limit + [`${as}.hasNextPage`]: { + $gt: [{ $size: `$${as}.docs` }, limitJoin || Number.MAX_VALUE], + }, // Boolean indicating if more docs than limit + }, + }, + ) + if (limitJoin > 0) { + aggregate.push({ + $addFields: { + [`${as}.docs`]: { + $slice: [`$${as}.docs`, limitJoin], + }, + }, + }) + } + } + } + } + + return aggregate +} diff --git a/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts b/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts new file mode 100644 index 00000000000..7b33c56bfcf --- /dev/null +++ b/packages/db-mongodb/src/utilities/sanitizeRelationshipIDs.ts @@ -0,0 +1,140 @@ +import type { CollectionConfig, Field, SanitizedConfig, TraverseFieldsCallback } from 'payload' + +import mongoose from 'mongoose' +import { traverseFields } from 'payload' +import { fieldAffectsData } from 'payload/shared' + +type Args = { + config: SanitizedConfig + data: Record + fields: Field[] +} + +interface RelationObject { + relationTo: string + value: number | string +} + +function isValidRelationObject(value: unknown): value is RelationObject { + return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value +} + +const convertValue = ({ + relatedCollection, + value, +}: { + relatedCollection: CollectionConfig + value: number | string +}): mongoose.Types.ObjectId | number | string => { + const customIDField = relatedCollection.fields.find( + (field) => fieldAffectsData(field) && field.name === 'id', + ) + + if (!customIDField) { + return new mongoose.Types.ObjectId(value) + } + + return value +} + +const sanitizeRelationship = ({ config, field, locale, ref, value }) => { + let relatedCollection: CollectionConfig | undefined + let result = value + + const hasManyRelations = typeof field.relationTo !== 'string' + + if (!hasManyRelations) { + relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo) + } + + if (Array.isArray(value)) { + result = value.map((val) => { + // Handle has many + if (relatedCollection && val && (typeof val === 'string' || typeof val === 'number')) { + return convertValue({ + relatedCollection, + value: val, + }) + } + + // Handle has many - polymorphic + if (isValidRelationObject(val)) { + const relatedCollectionForSingleValue = config.collections?.find( + ({ slug }) => slug === val.relationTo, + ) + + if (relatedCollectionForSingleValue) { + return { + relationTo: val.relationTo, + value: convertValue({ + relatedCollection: relatedCollectionForSingleValue, + value: val.value, + }), + } + } + } + + return val + }) + } + + // Handle has one - polymorphic + if (isValidRelationObject(value)) { + relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo) + + if (relatedCollection) { + result = { + relationTo: value.relationTo, + value: convertValue({ relatedCollection, value: value.value }), + } + } + } + + // Handle has one + if (relatedCollection && value && (typeof value === 'string' || typeof value === 'number')) { + result = convertValue({ + relatedCollection, + value, + }) + } + if (locale) { + ref[locale] = result + } else { + ref[field.name] = result + } +} + +export const sanitizeRelationshipIDs = ({ + config, + data, + fields, +}: Args): Record => { + const sanitize: TraverseFieldsCallback = ({ field, ref }) => { + if (field.type === 'relationship' || field.type === 'upload') { + // handle localized relationships + if (config.localization && field.localized) { + const locales = config.localization.locales + const fieldRef = ref[field.name] + for (const { code } of locales) { + if (ref[field.name]?.[code]) { + const value = ref[field.name][code] + sanitizeRelationship({ config, field, locale: code, ref: fieldRef, value }) + } + } + } else { + // handle non-localized relationships + sanitizeRelationship({ + config, + field, + locale: undefined, + ref, + value: ref[field.name], + }) + } + } + } + + traverseFields({ callback: sanitize, fields, ref: data }) + + return data +} diff --git a/packages/db-mongodb/src/withSession.ts b/packages/db-mongodb/src/withSession.ts index 6171a499909..c9f43254e79 100644 --- a/packages/db-mongodb/src/withSession.ts +++ b/packages/db-mongodb/src/withSession.ts @@ -10,7 +10,7 @@ import type { MongooseAdapter } from './index.js' export async function withSession( db: MongooseAdapter, req: PayloadRequest, -): Promise<{ session: ClientSession } | object> { +): Promise<{ session: ClientSession } | Record> { let transactionID = req.transactionID if (transactionID instanceof Promise) { diff --git a/packages/db-sqlite/src/schema/build.ts b/packages/db-sqlite/src/schema/build.ts index c668a99cd86..6b6a34d400c 100644 --- a/packages/db-sqlite/src/schema/build.ts +++ b/packages/db-sqlite/src/schema/build.ts @@ -35,7 +35,15 @@ export type BaseExtraConfig = Record< }) => ForeignKeyBuilder | IndexBuilder | UniqueConstraintBuilder > -export type RelationMap = Map +export type RelationMap = Map< + string, + { + localized: boolean + relationName?: string + target: string + type: 'many' | 'one' + } +> type Args = { adapter: SQLiteAdapter @@ -144,9 +152,9 @@ export const buildTable = ({ const localizedRelations = new Map() const nonLocalizedRelations = new Map() - relationsToBuild.forEach(({ type, localized, target }, key) => { + relationsToBuild.forEach(({ type, localized, relationName, target }, key) => { const map = localized ? localizedRelations : nonLocalizedRelations - map.set(key, { type, target }) + map.set(key, { type, relationName, target }) }) if (timestamps) { @@ -458,7 +466,7 @@ export const buildTable = ({ adapter.relations[`relations_${tableName}`] = relations(table, ({ many, one }) => { const result: Record> = {} - nonLocalizedRelations.forEach(({ type, target }, key) => { + nonLocalizedRelations.forEach(({ type, relationName, target }, key) => { if (type === 'one') { result[key] = one(adapter.tables[target], { fields: [table[key]], @@ -467,7 +475,7 @@ export const buildTable = ({ }) } if (type === 'many') { - result[key] = many(adapter.tables[target], { relationName: key }) + result[key] = many(adapter.tables[target], { relationName: relationName || key }) } }) diff --git a/packages/db-sqlite/src/schema/traverseFields.ts b/packages/db-sqlite/src/schema/traverseFields.ts index 9ee227b6d5c..e034ab97bd2 100644 --- a/packages/db-sqlite/src/schema/traverseFields.ts +++ b/packages/db-sqlite/src/schema/traverseFields.ts @@ -888,6 +888,21 @@ export const traverseFields = ({ break + case 'join': { + // fieldName could be 'posts' or 'group_posts' + // using on as the key for the relation + const localized = adapter.payload.config.localization && field.localized + const target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${localized ? adapter.localesSuffix : ''}` + relationsToBuild.set(fieldName, { + type: 'many', + // joins are not localized on the parent table + localized: false, + relationName: toSnakeCase(field.on), + target, + }) + break + } + default: break } diff --git a/packages/drizzle/src/count.ts b/packages/drizzle/src/count.ts index ec9bea16536..3d015768b21 100644 --- a/packages/drizzle/src/count.ts +++ b/packages/drizzle/src/count.ts @@ -16,7 +16,7 @@ export const count: Count = async function count( const db = this.sessions[await req?.transactionID]?.db || this.drizzle - const { joins, where } = await buildQuery({ + const { joins, where } = buildQuery({ adapter: this, fields: collectionConfig.fields, locale, diff --git a/packages/drizzle/src/deleteOne.ts b/packages/drizzle/src/deleteOne.ts index 8a73eabf12b..5b49a755380 100644 --- a/packages/drizzle/src/deleteOne.ts +++ b/packages/drizzle/src/deleteOne.ts @@ -12,7 +12,7 @@ import { transform } from './transform/read/index.js' export const deleteOne: DeleteOne = async function deleteOne( this: DrizzleAdapter, - { collection: collectionSlug, req = {} as PayloadRequest, where: whereArg }, + { collection: collectionSlug, joins: joinQuery, req = {} as PayloadRequest, where: whereArg }, ) { const db = this.sessions[await req?.transactionID]?.db || this.drizzle const collection = this.payload.collections[collectionSlug].config @@ -21,7 +21,7 @@ export const deleteOne: DeleteOne = async function deleteOne( let docToDelete: Record - const { joins, selectFields, where } = await buildQuery({ + const { joins, selectFields, where } = buildQuery({ adapter: this, fields: collection.fields, locale: req.locale, @@ -48,6 +48,7 @@ export const deleteOne: DeleteOne = async function deleteOne( adapter: this, depth: 0, fields: collection.fields, + joinQuery, tableName, }) @@ -61,6 +62,7 @@ export const deleteOne: DeleteOne = async function deleteOne( config: this.payload.config, data: docToDelete, fields: collection.fields, + joinQuery, }) await this.deleteWhere({ diff --git a/packages/drizzle/src/find.ts b/packages/drizzle/src/find.ts index 2e49973182e..549ed6e13c2 100644 --- a/packages/drizzle/src/find.ts +++ b/packages/drizzle/src/find.ts @@ -10,6 +10,7 @@ export const find: Find = async function find( this: DrizzleAdapter, { collection, + joins, limit, locale, page = 1, @@ -27,6 +28,7 @@ export const find: Find = async function find( return findMany({ adapter: this, fields: collectionConfig.fields, + joins, limit, locale, page, diff --git a/packages/drizzle/src/find/buildFindManyArgs.ts b/packages/drizzle/src/find/buildFindManyArgs.ts index 1f8f2728a3b..278bfcd780a 100644 --- a/packages/drizzle/src/find/buildFindManyArgs.ts +++ b/packages/drizzle/src/find/buildFindManyArgs.ts @@ -1,7 +1,7 @@ import type { DBQueryConfig } from 'drizzle-orm' -import type { Field } from 'payload' +import type { Field, JoinQuery } from 'payload' -import type { DrizzleAdapter } from '../types.js' +import type { BuildQueryJoinAliases, DrizzleAdapter } from '../types.js' import { traverseFields } from './traverseFields.js' @@ -9,6 +9,12 @@ type BuildFindQueryArgs = { adapter: DrizzleAdapter depth: number fields: Field[] + joinQuery?: JoinQuery + /** + * The joins array will be mutated by pushing any joins needed for the where queries of join field joins + */ + joins?: BuildQueryJoinAliases + locale?: string tableName: string } @@ -24,6 +30,9 @@ export const buildFindManyArgs = ({ adapter, depth, fields, + joinQuery, + joins = [], + locale, tableName, }: BuildFindQueryArgs): Record => { const result: Result = { @@ -79,6 +88,9 @@ export const buildFindManyArgs = ({ currentTableName: tableName, depth, fields, + joinQuery, + joins, + locale, path: '', tablePath: '', topLevelArgs: result, diff --git a/packages/drizzle/src/find/findMany.ts b/packages/drizzle/src/find/findMany.ts index 5d5fad0a297..a5500642b88 100644 --- a/packages/drizzle/src/find/findMany.ts +++ b/packages/drizzle/src/find/findMany.ts @@ -19,6 +19,7 @@ type Args = { export const findMany = async function find({ adapter, fields, + joins: joinQuery, limit: limitArg, locale, page = 1, @@ -42,7 +43,7 @@ export const findMany = async function find({ limit = undefined } - const { joins, orderBy, selectFields, where } = await buildQuery({ + const { joins, orderBy, selectFields, where } = buildQuery({ adapter, fields, locale, @@ -67,6 +68,8 @@ export const findMany = async function find({ adapter, depth: 0, fields, + joinQuery, + joins, tableName, }) @@ -151,6 +154,7 @@ export const findMany = async function find({ config: adapter.payload.config, data, fields, + joinQuery, }) }) diff --git a/packages/drizzle/src/find/traverseFields.ts b/packages/drizzle/src/find/traverseFields.ts index ddbeacb6bee..e9c00c81b9e 100644 --- a/packages/drizzle/src/find/traverseFields.ts +++ b/packages/drizzle/src/find/traverseFields.ts @@ -1,11 +1,15 @@ -import type { Field } from 'payload' +import type { DBQueryConfig } from 'drizzle-orm' +import type { Field, JoinQuery } from 'payload' import { fieldAffectsData, tabHasName } from 'payload/shared' import toSnakeCase from 'to-snake-case' -import type { DrizzleAdapter } from '../types.js' +import type { BuildQueryJoinAliases, DrizzleAdapter } from '../types.js' import type { Result } from './buildFindManyArgs.js' +import { buildOrderBy } from '../queries/buildOrderBy.js' +import buildQuery from '../queries/buildQuery.js' + type TraverseFieldArgs = { _locales: Result adapter: DrizzleAdapter @@ -13,6 +17,9 @@ type TraverseFieldArgs = { currentTableName: string depth?: number fields: Field[] + joinQuery: JoinQuery + joins?: BuildQueryJoinAliases + locale?: string path: string tablePath: string topLevelArgs: Record @@ -26,6 +33,9 @@ export const traverseFields = ({ currentTableName, depth, fields, + joinQuery = {}, + joins, + locale, path, tablePath, topLevelArgs, @@ -54,6 +64,8 @@ export const traverseFields = ({ currentTableName, depth, fields: field.fields, + joinQuery, + joins, path, tablePath, topLevelArgs, @@ -75,6 +87,8 @@ export const traverseFields = ({ currentTableName, depth, fields: tab.fields, + joinQuery, + joins, path: tabPath, tablePath: tabTablePath, topLevelArgs, @@ -120,6 +134,7 @@ export const traverseFields = ({ currentTableName: arrayTableName, depth, fields: field.fields, + joinQuery, path: '', tablePath: '', topLevelArgs, @@ -177,6 +192,7 @@ export const traverseFields = ({ currentTableName: tableName, depth, fields: block.fields, + joinQuery, path: '', tablePath: '', topLevelArgs, @@ -195,6 +211,8 @@ export const traverseFields = ({ currentTableName, depth, fields: field.fields, + joinQuery, + joins, path: `${path}${field.name}_`, tablePath: `${tablePath}${toSnakeCase(field.name)}_`, topLevelArgs, @@ -204,6 +222,67 @@ export const traverseFields = ({ break } + case 'join': { + // when `joinsQuery` is false, do not join + if (joinQuery === false) { + break + } + const { + limit: limitArg = 10, + sort, + where, + } = joinQuery[`${path.replaceAll('_', '.')}${field.name}`] || {} + let limit = limitArg + if (limit !== 0) { + // get an additional document and slice it later to determine if there is a next page + limit += 1 + } + const fields = adapter.payload.collections[field.collection].config.fields + const joinTableName = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${ + field.localized && adapter.payload.config.localization ? adapter.localesSuffix : '' + }` + const selectFields = {} + + const orderBy = buildOrderBy({ + adapter, + fields, + joins: [], + locale, + selectFields, + sort, + tableName: joinTableName, + }) + const withJoin: DBQueryConfig<'many', true, any, any> = { + columns: selectFields, + orderBy: () => [orderBy.order(orderBy.column)], + } + if (limit) { + withJoin.limit = limit + } + + if (field.localized) { + withJoin.columns._locale = true + withJoin.columns._parentID = true + } else { + withJoin.columns.id = true + } + + if (where) { + const { where: joinWhere } = buildQuery({ + adapter, + fields, + joins, + locale, + sort, + tableName: joinTableName, + where, + }) + withJoin.where = () => joinWhere + } + currentArgs.with[`${path.replaceAll('.', '_')}${field.name}`] = withJoin + break + } + default: { break } diff --git a/packages/drizzle/src/findOne.ts b/packages/drizzle/src/findOne.ts index 4a6c165dcd7..78da901a5c9 100644 --- a/packages/drizzle/src/findOne.ts +++ b/packages/drizzle/src/findOne.ts @@ -8,7 +8,7 @@ import { findMany } from './find/findMany.js' export async function findOne( this: DrizzleAdapter, - { collection, locale, req = {} as PayloadRequest, where }: FindOneArgs, + { collection, joins, locale, req = {} as PayloadRequest, where }: FindOneArgs, ): Promise { const collectionConfig: SanitizedCollectionConfig = this.payload.collections[collection].config @@ -17,6 +17,7 @@ export async function findOne( const { docs } = await findMany({ adapter: this, fields: collectionConfig.fields, + joins, limit: 1, locale, page: 1, diff --git a/packages/drizzle/src/postgres/schema/build.ts b/packages/drizzle/src/postgres/schema/build.ts index 2cbf8a00c0c..8aab6c058f1 100644 --- a/packages/drizzle/src/postgres/schema/build.ts +++ b/packages/drizzle/src/postgres/schema/build.ts @@ -138,9 +138,9 @@ export const buildTable = ({ const localizedRelations = new Map() const nonLocalizedRelations = new Map() - relationsToBuild.forEach(({ type, localized, target }, key) => { + relationsToBuild.forEach(({ type, localized, relationName, target }, key) => { const map = localized ? localizedRelations : nonLocalizedRelations - map.set(key, { type, target }) + map.set(key, { type, relationName, target }) }) if (timestamps) { @@ -444,7 +444,7 @@ export const buildTable = ({ adapter.relations[`relations_${tableName}`] = relations(table, ({ many, one }) => { const result: Record> = {} - nonLocalizedRelations.forEach(({ type, target }, key) => { + nonLocalizedRelations.forEach(({ type, relationName, target }, key) => { if (type === 'one') { result[key] = one(adapter.tables[target], { fields: [table[key]], @@ -453,7 +453,7 @@ export const buildTable = ({ }) } if (type === 'many') { - result[key] = many(adapter.tables[target], { relationName: key }) + result[key] = many(adapter.tables[target], { relationName: relationName || key }) } }) diff --git a/packages/drizzle/src/postgres/schema/traverseFields.ts b/packages/drizzle/src/postgres/schema/traverseFields.ts index b4966f39b48..7a80a4db58e 100644 --- a/packages/drizzle/src/postgres/schema/traverseFields.ts +++ b/packages/drizzle/src/postgres/schema/traverseFields.ts @@ -897,6 +897,21 @@ export const traverseFields = ({ break + case 'join': { + // fieldName could be 'posts' or 'group_posts' + // using on as the key for the relation + const localized = adapter.payload.config.localization && field.localized + const target = `${adapter.tableNameMap.get(toSnakeCase(field.collection))}${localized ? adapter.localesSuffix : ''}` + relationsToBuild.set(fieldName, { + type: 'many', + // joins are not localized on the parent table + localized: false, + relationName: toSnakeCase(field.on), + target, + }) + break + } + default: break } diff --git a/packages/drizzle/src/postgres/types.ts b/packages/drizzle/src/postgres/types.ts index 84aeb4a5ae7..93057a1b4f0 100644 --- a/packages/drizzle/src/postgres/types.ts +++ b/packages/drizzle/src/postgres/types.ts @@ -31,7 +31,15 @@ export type BaseExtraConfig = Record< (cols: GenericColumns) => ForeignKeyBuilder | IndexBuilder | UniqueConstraintBuilder > -export type RelationMap = Map +export type RelationMap = Map< + string, + { + localized: boolean + relationName?: string + target: string + type: 'many' | 'one' + } +> export type GenericColumn = PgColumn< ColumnBaseConfig, diff --git a/packages/drizzle/src/queries/buildAndOrConditions.ts b/packages/drizzle/src/queries/buildAndOrConditions.ts index d156e55a5f2..38ac89e29b7 100644 --- a/packages/drizzle/src/queries/buildAndOrConditions.ts +++ b/packages/drizzle/src/queries/buildAndOrConditions.ts @@ -6,7 +6,7 @@ import type { BuildQueryJoinAliases } from './buildQuery.js' import { parseParams } from './parseParams.js' -export async function buildAndOrConditions({ +export function buildAndOrConditions({ adapter, fields, joins, @@ -24,7 +24,7 @@ export async function buildAndOrConditions({ selectFields: Record tableName: string where: Where[] -}): Promise { +}): SQL[] { const completedConditions = [] // Loop over all AND / OR operations and add them to the AND / OR query param // Operations should come through as an array @@ -32,7 +32,7 @@ export async function buildAndOrConditions({ for (const condition of where) { // If the operation is properly formatted as an object if (typeof condition === 'object') { - const result = await parseParams({ + const result = parseParams({ adapter, fields, joins, diff --git a/packages/drizzle/src/queries/buildOrderBy.ts b/packages/drizzle/src/queries/buildOrderBy.ts new file mode 100644 index 00000000000..e90c229a0df --- /dev/null +++ b/packages/drizzle/src/queries/buildOrderBy.ts @@ -0,0 +1,82 @@ +import type { Field } from 'payload' + +import { asc, desc } from 'drizzle-orm' + +import type { DrizzleAdapter, GenericColumn } from '../types.js' +import type { BuildQueryJoinAliases, BuildQueryResult } from './buildQuery.js' + +import { getTableColumnFromPath } from './getTableColumnFromPath.js' + +type Args = { + adapter: DrizzleAdapter + fields: Field[] + joins: BuildQueryJoinAliases + locale?: string + selectFields: Record + sort?: string + tableName: string +} + +/** + * Gets the order by column and direction constructed from the sort argument adds the column to the select fields and joins if necessary + */ +export const buildOrderBy = ({ + adapter, + fields, + joins, + locale, + selectFields, + sort, + tableName, +}: Args): BuildQueryResult['orderBy'] => { + const orderBy: BuildQueryResult['orderBy'] = { + column: null, + order: null, + } + + if (sort) { + let sortPath + + if (sort[0] === '-') { + sortPath = sort.substring(1) + orderBy.order = desc + } else { + sortPath = sort + orderBy.order = asc + } + + try { + const { columnName: sortTableColumnName, table: sortTable } = getTableColumnFromPath({ + adapter, + collectionPath: sortPath, + fields, + joins, + locale, + pathSegments: sortPath.replace(/__/g, '.').split('.'), + selectFields, + tableName, + value: sortPath, + }) + orderBy.column = sortTable?.[sortTableColumnName] + } catch (err) { + // continue + } + } + + if (!orderBy?.column) { + orderBy.order = desc + const createdAt = adapter.tables[tableName]?.createdAt + + if (createdAt) { + orderBy.column = createdAt + } else { + orderBy.column = adapter.tables[tableName].id + } + } + + if (orderBy.column) { + selectFields.sort = orderBy.column + } + + return orderBy +} diff --git a/packages/drizzle/src/queries/buildQuery.ts b/packages/drizzle/src/queries/buildQuery.ts index 7d565b86aab..dff3f9fc436 100644 --- a/packages/drizzle/src/queries/buildQuery.ts +++ b/packages/drizzle/src/queries/buildQuery.ts @@ -1,12 +1,10 @@ -import type { SQL } from 'drizzle-orm' +import type { asc, desc, SQL } from 'drizzle-orm' import type { PgTableWithColumns } from 'drizzle-orm/pg-core' import type { Field, Where } from 'payload' -import { asc, desc } from 'drizzle-orm' - import type { DrizzleAdapter, GenericColumn, GenericTable } from '../types.js' -import { getTableColumnFromPath } from './getTableColumnFromPath.js' +import { buildOrderBy } from './buildOrderBy.js' import { parseParams } from './parseParams.js' export type BuildQueryJoinAliases = { @@ -17,13 +15,14 @@ export type BuildQueryJoinAliases = { type BuildQueryArgs = { adapter: DrizzleAdapter fields: Field[] + joins?: BuildQueryJoinAliases locale?: string sort?: string tableName: string where: Where } -type Result = { +export type BuildQueryResult = { joins: BuildQueryJoinAliases orderBy: { column: GenericColumn @@ -32,72 +31,33 @@ type Result = { selectFields: Record where: SQL } -const buildQuery = async function buildQuery({ +const buildQuery = function buildQuery({ adapter, fields, + joins = [], locale, sort, tableName, where: incomingWhere, -}: BuildQueryArgs): Promise { +}: BuildQueryArgs): BuildQueryResult { const selectFields: Record = { id: adapter.tables[tableName].id, } - const joins: BuildQueryJoinAliases = [] - - const orderBy: Result['orderBy'] = { - column: null, - order: null, - } - - if (sort) { - let sortPath - - if (sort[0] === '-') { - sortPath = sort.substring(1) - orderBy.order = desc - } else { - sortPath = sort - orderBy.order = asc - } - - try { - const { columnName: sortTableColumnName, table: sortTable } = getTableColumnFromPath({ - adapter, - collectionPath: sortPath, - fields, - joins, - locale, - pathSegments: sortPath.replace(/__/g, '.').split('.'), - selectFields, - tableName, - value: sortPath, - }) - orderBy.column = sortTable?.[sortTableColumnName] - } catch (err) { - // continue - } - } - if (!orderBy?.column) { - orderBy.order = desc - const createdAt = adapter.tables[tableName]?.createdAt - - if (createdAt) { - orderBy.column = createdAt - } else { - orderBy.column = adapter.tables[tableName].id - } - } - - if (orderBy.column) { - selectFields.sort = orderBy.column - } + const orderBy = buildOrderBy({ + adapter, + fields, + joins, + locale, + selectFields, + sort, + tableName, + }) let where: SQL if (incomingWhere && Object.keys(incomingWhere).length > 0) { - where = await parseParams({ + where = parseParams({ adapter, fields, joins, diff --git a/packages/drizzle/src/queries/parseParams.ts b/packages/drizzle/src/queries/parseParams.ts index fb5e3dd308c..bb08ddb76bb 100644 --- a/packages/drizzle/src/queries/parseParams.ts +++ b/packages/drizzle/src/queries/parseParams.ts @@ -22,7 +22,7 @@ type Args = { where: Where } -export async function parseParams({ +export function parseParams({ adapter, fields, joins, @@ -30,7 +30,7 @@ export async function parseParams({ selectFields, tableName, where, -}: Args): Promise { +}: Args): SQL { let result: SQL const constraints: SQL[] = [] @@ -46,7 +46,7 @@ export async function parseParams({ conditionOperator = or } if (Array.isArray(condition)) { - const builtConditions = await buildAndOrConditions({ + const builtConditions = buildAndOrConditions({ adapter, fields, joins, diff --git a/packages/drizzle/src/transform/read/index.ts b/packages/drizzle/src/transform/read/index.ts index 8e5ad3a144b..b418c3fb7aa 100644 --- a/packages/drizzle/src/transform/read/index.ts +++ b/packages/drizzle/src/transform/read/index.ts @@ -1,4 +1,4 @@ -import type { Field, SanitizedConfig, TypeWithID } from 'payload' +import type { Field, JoinQuery, SanitizedConfig, TypeWithID } from 'payload' import type { DrizzleAdapter } from '../../types.js' @@ -12,6 +12,7 @@ type TransformArgs = { data: Record fallbackLocale?: false | string fields: Field[] + joinQuery?: JoinQuery locale?: string } @@ -22,6 +23,7 @@ export const transform = | TypeWithID>({ config, data, fields, + joinQuery, }: TransformArgs): T => { let relationships: Record[]> = {} let texts: Record[]> = {} @@ -55,6 +57,7 @@ export const transform = | TypeWithID>({ deletions, fieldPrefix: '', fields, + joinQuery, numbers, path: '', relationships, diff --git a/packages/drizzle/src/transform/read/traverseFields.ts b/packages/drizzle/src/transform/read/traverseFields.ts index f0da9821cff..e782439ada3 100644 --- a/packages/drizzle/src/transform/read/traverseFields.ts +++ b/packages/drizzle/src/transform/read/traverseFields.ts @@ -1,4 +1,4 @@ -import type { Field, SanitizedConfig, TabAsField } from 'payload' +import type { Field, JoinQuery, SanitizedConfig, TabAsField } from 'payload' import { fieldAffectsData } from 'payload/shared' @@ -38,6 +38,10 @@ type TraverseFieldsArgs = { * An array of Payload fields to traverse */ fields: (Field | TabAsField)[] + /** + * + */ + joinQuery?: JoinQuery /** * All hasMany number fields, as returned by Drizzle, keyed on an object by field path */ @@ -74,6 +78,7 @@ export const traverseFields = >({ deletions, fieldPrefix, fields, + joinQuery, numbers, path, relationships, @@ -93,6 +98,7 @@ export const traverseFields = >({ deletions, fieldPrefix, fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })), + joinQuery, numbers, path, relationships, @@ -115,6 +121,7 @@ export const traverseFields = >({ deletions, fieldPrefix, fields: field.fields, + joinQuery, numbers, path, relationships, @@ -386,6 +393,44 @@ export const traverseFields = >({ } } + if (field.type === 'join') { + const { limit = 10 } = joinQuery?.[`${fieldPrefix.replaceAll('_', '.')}${field.name}`] || {} + + let fieldResult: + | { docs: unknown[]; hasNextPage: boolean } + | Record + if (Array.isArray(fieldData)) { + if (field.localized) { + fieldResult = fieldData.reduce((joinResult, row) => { + if (typeof row._locale === 'string') { + if (!joinResult[row._locale]) { + joinResult[row._locale] = { + docs: [], + hasNextPage: false, + } + } + joinResult[row._locale].docs.push(row._parentID) + } + + return joinResult + }, {}) + Object.keys(fieldResult).forEach((locale) => { + fieldResult[locale].hasNextPage = fieldResult[locale].docs.length > limit + fieldResult[locale].docs = fieldResult[locale].docs.slice(0, limit) + }) + } else { + const hasNextPage = limit !== 0 && fieldData.length > limit + fieldResult = { + docs: hasNextPage ? fieldData.slice(0, limit) : fieldData, + hasNextPage, + } + } + } + + result[field.name] = fieldResult + return result + } + if (field.type === 'text' && field?.hasMany) { const textPathMatch = texts[`${sanitizedPath}${field.name}`] if (!textPathMatch) { diff --git a/packages/drizzle/src/update.ts b/packages/drizzle/src/update.ts index 1e9e506c85a..0526b78b57a 100644 --- a/packages/drizzle/src/update.ts +++ b/packages/drizzle/src/update.ts @@ -10,7 +10,7 @@ import { upsertRow } from './upsertRow/index.js' export const updateOne: UpdateOne = async function updateOne( this: DrizzleAdapter, - { id, collection: collectionSlug, data, draft, locale, req, where: whereArg }, + { id, collection: collectionSlug, data, draft, joins: joinQuery, locale, req, where: whereArg }, ) { const db = this.sessions[await req?.transactionID]?.db || this.drizzle const collection = this.payload.collections[collectionSlug].config @@ -18,7 +18,7 @@ export const updateOne: UpdateOne = async function updateOne( const whereToUse = whereArg || { id: { equals: id } } let idToUpdate = id - const { joins, selectFields, where } = await buildQuery({ + const { joins, selectFields, where } = buildQuery({ adapter: this, fields: collection.fields, locale, @@ -46,6 +46,7 @@ export const updateOne: UpdateOne = async function updateOne( data, db, fields: collection.fields, + joinQuery, operation: 'update', req, tableName, diff --git a/packages/drizzle/src/updateGlobalVersion.ts b/packages/drizzle/src/updateGlobalVersion.ts index 5c557881411..3353d885aa0 100644 --- a/packages/drizzle/src/updateGlobalVersion.ts +++ b/packages/drizzle/src/updateGlobalVersion.ts @@ -37,7 +37,7 @@ export async function updateGlobalVersion( const fields = buildVersionGlobalFields(this.payload.config, globalConfig) - const { where } = await buildQuery({ + const { where } = buildQuery({ adapter: this, fields, locale, diff --git a/packages/drizzle/src/updateVersion.ts b/packages/drizzle/src/updateVersion.ts index bd0f0a7449d..25e285f9072 100644 --- a/packages/drizzle/src/updateVersion.ts +++ b/packages/drizzle/src/updateVersion.ts @@ -34,7 +34,7 @@ export async function updateVersion( const fields = buildVersionCollectionFields(this.payload.config, collectionConfig) - const { where } = await buildQuery({ + const { where } = buildQuery({ adapter: this, fields, locale, diff --git a/packages/drizzle/src/upsertRow/index.ts b/packages/drizzle/src/upsertRow/index.ts index 8be87df58c5..722fe88e9df 100644 --- a/packages/drizzle/src/upsertRow/index.ts +++ b/packages/drizzle/src/upsertRow/index.ts @@ -20,6 +20,7 @@ export const upsertRow = async | TypeWithID>( db, fields, ignoreResult, + joinQuery, operation, path = '', req, @@ -413,6 +414,7 @@ export const upsertRow = async | TypeWithID>( adapter, depth: 0, fields, + joinQuery, tableName, }) @@ -429,6 +431,7 @@ export const upsertRow = async | TypeWithID>( config: adapter.payload.config, data: doc, fields, + joinQuery, }) return result diff --git a/packages/drizzle/src/upsertRow/types.ts b/packages/drizzle/src/upsertRow/types.ts index 972182d0ecf..de29ff04238 100644 --- a/packages/drizzle/src/upsertRow/types.ts +++ b/packages/drizzle/src/upsertRow/types.ts @@ -1,5 +1,5 @@ import type { SQL } from 'drizzle-orm' -import type { Field, PayloadRequest } from 'payload' +import type { Field, JoinQuery, PayloadRequest } from 'payload' import type { DrizzleAdapter, DrizzleTransaction, GenericColumn } from '../types.js' @@ -13,6 +13,7 @@ type BaseArgs = { * @default false */ ignoreResult?: boolean + joinQuery?: JoinQuery path?: string req: PayloadRequest tableName: string @@ -20,6 +21,7 @@ type BaseArgs = { type CreateArgs = { id?: never + joinQuery?: never operation: 'create' upsertTarget?: never where?: never @@ -27,6 +29,7 @@ type CreateArgs = { type UpdateArgs = { id?: number | string + joinQuery?: JoinQuery operation: 'update' upsertTarget?: GenericColumn where?: SQL diff --git a/packages/next/src/routes/rest/collections/find.ts b/packages/next/src/routes/rest/collections/find.ts index ac38d2de692..4634629bffa 100644 --- a/packages/next/src/routes/rest/collections/find.ts +++ b/packages/next/src/routes/rest/collections/find.ts @@ -1,4 +1,4 @@ -import type { Where } from 'payload' +import type { JoinQuery, Where } from 'payload' import httpStatus from 'http-status' import { findOperation } from 'payload' @@ -7,11 +7,13 @@ import { isNumber } from 'payload/shared' import type { CollectionRouteHandler } from '../types.js' import { headersWithCors } from '../../../utilities/headersWithCors.js' +import { sanitizeJoinParams } from '../utilities/sanitizeJoinParams.js' export const find: CollectionRouteHandler = async ({ collection, req }) => { - const { depth, draft, limit, page, sort, where } = req.query as { + const { depth, draft, joins, limit, page, sort, where } = req.query as { depth?: string draft?: string + joins?: JoinQuery limit?: string page?: string sort?: string @@ -22,6 +24,7 @@ export const find: CollectionRouteHandler = async ({ collection, req }) => { collection, depth: isNumber(depth) ? Number(depth) : undefined, draft: draft === 'true', + joins: sanitizeJoinParams(joins), limit: isNumber(limit) ? Number(limit) : undefined, page: isNumber(page) ? Number(page) : undefined, req, diff --git a/packages/next/src/routes/rest/collections/findByID.ts b/packages/next/src/routes/rest/collections/findByID.ts index 571738363e2..55eebedd655 100644 --- a/packages/next/src/routes/rest/collections/findByID.ts +++ b/packages/next/src/routes/rest/collections/findByID.ts @@ -1,3 +1,5 @@ +import type { JoinQuery } from 'payload' + import httpStatus from 'http-status' import { findByIDOperation } from 'payload' import { isNumber } from 'payload/shared' @@ -6,6 +8,7 @@ import type { CollectionRouteHandlerWithID } from '../types.js' import { headersWithCors } from '../../../utilities/headersWithCors.js' import { sanitizeCollectionID } from '../utilities/sanitizeCollectionID.js' +import { sanitizeJoinParams } from '../utilities/sanitizeJoinParams.js' export const findByID: CollectionRouteHandlerWithID = async ({ id: incomingID, @@ -26,6 +29,7 @@ export const findByID: CollectionRouteHandlerWithID = async ({ collection, depth: isNumber(depth) ? Number(depth) : undefined, draft: searchParams.get('draft') === 'true', + joins: sanitizeJoinParams(req.query.joins as JoinQuery), req, }) diff --git a/packages/next/src/routes/rest/utilities/sanitizeJoinParams.ts b/packages/next/src/routes/rest/utilities/sanitizeJoinParams.ts new file mode 100644 index 00000000000..148404bb315 --- /dev/null +++ b/packages/next/src/routes/rest/utilities/sanitizeJoinParams.ts @@ -0,0 +1,31 @@ +import type { JoinQuery } from 'payload' + +import { isNumber } from 'payload/shared' + +/** + * Convert request JoinQuery object from strings to numbers + * @param joins + */ +export const sanitizeJoinParams = ( + joins: + | { + [schemaPath: string]: { + limit?: unknown + sort?: string + where?: unknown + } + } + | false = {}, +): JoinQuery => { + const joinQuery = {} + + Object.keys(joins).forEach((schemaPath) => { + joinQuery[schemaPath] = { + limit: isNumber(joins[schemaPath]?.limit) ? Number(joins[schemaPath].limit) : undefined, + sort: joins[schemaPath]?.sort ? joins[schemaPath].sort : undefined, + where: joins[schemaPath]?.where ? joins[schemaPath].where : undefined, + } + }) + + return joinQuery +} diff --git a/packages/next/src/views/Versions/buildColumns.tsx b/packages/next/src/views/Versions/buildColumns.tsx index 08c3529541f..d7f8d87398b 100644 --- a/packages/next/src/views/Versions/buildColumns.tsx +++ b/packages/next/src/views/Versions/buildColumns.tsx @@ -34,23 +34,28 @@ export const buildVersionColumns = ({ field: { name: '', type: 'date', + admin: { + components: { + Cell: { + type: 'client', + Component: null, + RenderedComponent: ( + + ), + }, + Label: { + type: 'client', + Component: null, + }, + }, + }, }, }, - components: { - Cell: { - type: 'client', - Component: null, - RenderedComponent: ( - - ), - }, - Heading: , - }, - Label: '', + Heading: , }, { accessor: 'id', @@ -59,17 +64,22 @@ export const buildVersionColumns = ({ field: { name: '', type: 'text', + admin: { + components: { + Cell: { + type: 'client', + Component: null, + RenderedComponent: , + }, + Label: { + type: 'client', + Component: null, + }, + }, + }, }, }, - components: { - Cell: { - type: 'client', - Component: null, - RenderedComponent: , - }, - Heading: , - }, - Label: '', + Heading: , }, ] @@ -84,23 +94,27 @@ export const buildVersionColumns = ({ field: { name: '', type: 'checkbox', + admin: { + components: { + Cell: { + type: 'client', + Component: null, + RenderedComponent: ( + + ), + }, + Label: { + type: 'client', + Component: null, + }, + }, + }, }, }, - components: { - Cell: { - type: 'client', - Component: null, - RenderedComponent: ( - - ), - }, - - Heading: , - }, - Label: '', + Heading: , }) } diff --git a/packages/payload/src/admin/fields/Join.ts b/packages/payload/src/admin/fields/Join.ts new file mode 100644 index 00000000000..6dfbb96d165 --- /dev/null +++ b/packages/payload/src/admin/fields/Join.ts @@ -0,0 +1,39 @@ +import type { MarkOptional } from 'ts-essentials' + +import type { JoinField, JoinFieldClient } from '../../fields/config/types.js' +import type { FieldErrorClientComponent, FieldErrorServerComponent } from '../forms/Error.js' +import type { + ClientFieldBase, + FieldClientComponent, + FieldServerComponent, + ServerFieldBase, +} from '../forms/Field.js' +import type { + FieldDescriptionClientComponent, + FieldDescriptionServerComponent, + FieldLabelClientComponent, + FieldLabelServerComponent, +} from '../types.js' + +type JoinFieldClientWithoutType = MarkOptional + +export type JoinFieldClientProps = ClientFieldBase + +export type JoinFieldServerProps = ServerFieldBase + +export type JoinFieldServerComponent = FieldServerComponent + +export type JoinFieldClientComponent = FieldClientComponent + +export type JoinFieldLabelServerComponent = FieldLabelServerComponent + +export type JoinFieldLabelClientComponent = FieldLabelClientComponent + +export type JoinFieldDescriptionServerComponent = FieldDescriptionServerComponent + +export type JoinFieldDescriptionClientComponent = + FieldDescriptionClientComponent + +export type JoinFieldErrorServerComponent = FieldErrorServerComponent + +export type JoinFieldErrorClientComponent = FieldErrorClientComponent diff --git a/packages/payload/src/admin/types.ts b/packages/payload/src/admin/types.ts index 60b8080cd3c..ba612444a2c 100644 --- a/packages/payload/src/admin/types.ts +++ b/packages/payload/src/admin/types.ts @@ -129,6 +129,19 @@ export type { export type { HiddenFieldProps } from './fields/Hidden.js' +export type { + JoinFieldClientComponent, + JoinFieldClientProps, + JoinFieldDescriptionClientComponent, + JoinFieldDescriptionServerComponent, + JoinFieldErrorClientComponent, + JoinFieldErrorServerComponent, + JoinFieldLabelClientComponent, + JoinFieldLabelServerComponent, + JoinFieldServerComponent, + JoinFieldServerProps, +} from './fields/Join.js' + export type { JSONFieldClientComponent, JSONFieldClientProps, diff --git a/packages/payload/src/collections/config/client.ts b/packages/payload/src/collections/config/client.ts index aa7aca4eaa9..cb545edb128 100644 --- a/packages/payload/src/collections/config/client.ts +++ b/packages/payload/src/collections/config/client.ts @@ -6,7 +6,7 @@ import type { SanitizedCollectionConfig } from './types.js' export type ServerOnlyCollectionProperties = keyof Pick< SanitizedCollectionConfig, - 'access' | 'custom' | 'endpoints' | 'hooks' + 'access' | 'custom' | 'endpoints' | 'hooks' | 'joins' > export type ServerOnlyCollectionAdminProperties = keyof Pick< @@ -58,7 +58,7 @@ export type ClientCollectionConfig = { livePreview?: Omit } & Omit< SanitizedCollectionConfig['admin'], - 'components' | 'description' | 'livePreview' | ServerOnlyCollectionAdminProperties + 'components' | 'description' | 'joins' | 'livePreview' | ServerOnlyCollectionAdminProperties > fields: ClientField[] } & Omit diff --git a/packages/payload/src/collections/config/sanitize.ts b/packages/payload/src/collections/config/sanitize.ts index 4fe2b4ffe13..43ac041232b 100644 --- a/packages/payload/src/collections/config/sanitize.ts +++ b/packages/payload/src/collections/config/sanitize.ts @@ -1,6 +1,6 @@ import type { LoginWithUsernameOptions } from '../../auth/types.js' import type { Config, SanitizedConfig } from '../../config/types.js' -import type { CollectionConfig, SanitizedCollectionConfig } from './types.js' +import type { CollectionConfig, SanitizedCollectionConfig, SanitizedJoins } from './types.js' import { getBaseAuthFields } from '../../auth/getAuthFields.js' import { TimestampsRequired } from '../../errors/TimestampsRequired.js' @@ -36,12 +36,15 @@ export const sanitizeCollection = async ( // ///////////////////////////////// const validRelationships = config.collections.map((c) => c.slug) || [] + const joins: SanitizedJoins = {} sanitized.fields = await sanitizeFields({ collectionConfig: sanitized, config, fields: sanitized.fields, + joins, parentIsLocalized: false, richTextSanitizationPromises, + schemaPath: '', validRelationships, }) @@ -193,5 +196,9 @@ export const sanitizeCollection = async ( validateUseAsTitle(sanitized) - return sanitized as SanitizedCollectionConfig + const sanitizedConfig = sanitized as SanitizedCollectionConfig + + sanitizedConfig.joins = joins + + return sanitizedConfig } diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 8cdf508cf3d..c132d2528c6 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -29,7 +29,7 @@ import type { StaticLabel, } from '../../config/types.js' import type { DBIdentifierName } from '../../database/types.js' -import type { Field } from '../../fields/config/types.js' +import type { Field, JoinField } from '../../fields/config/types.js' import type { CollectionSlug, JsonObject, @@ -478,6 +478,21 @@ export type CollectionConfig = { versions?: boolean | IncomingCollectionVersions } +export type SanitizedJoin = { + /** + * The field configuration defining the join + */ + field: JoinField + /** + * The schemaPath of the join field in dot notation + */ + schemaPath: string +} + +export type SanitizedJoins = { + [collectionSlug: string]: SanitizedJoin[] +} + export interface SanitizedCollectionConfig extends Omit< DeepRequired, @@ -486,6 +501,10 @@ export interface SanitizedCollectionConfig auth: Auth endpoints: Endpoint[] | false fields: Field[] + /** + * Object of collections to join 'Join Fields object keyed by collection + */ + joins: SanitizedJoins upload: SanitizedUploadConfig versions: SanitizedCollectionVersions } diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts index fc812f58662..ca64c28c97d 100644 --- a/packages/payload/src/collections/operations/find.ts +++ b/packages/payload/src/collections/operations/find.ts @@ -1,6 +1,6 @@ import type { AccessResult } from '../../config/types.js' import type { PaginatedDocs } from '../../database/types.js' -import type { CollectionSlug } from '../../index.js' +import type { CollectionSlug, JoinQuery } from '../../index.js' import type { PayloadRequest, Where } from '../../types/index.js' import type { Collection, DataFromCollectionSlug } from '../config/types.js' @@ -21,6 +21,7 @@ export type Arguments = { disableErrors?: boolean draft?: boolean includeLockStatus?: boolean + joins?: JoinQuery limit?: number overrideAccess?: boolean page?: number @@ -62,6 +63,7 @@ export const findOperation = async ( disableErrors, draft: draftsEnabled, includeLockStatus, + joins, limit, overrideAccess, page, @@ -142,6 +144,7 @@ export const findOperation = async ( result = await payload.db.find>({ collection: collectionConfig.slug, + joins, limit: sanitizedLimit, locale, page: sanitizedPage, diff --git a/packages/payload/src/collections/operations/findByID.ts b/packages/payload/src/collections/operations/findByID.ts index 8aa70046e37..4196d974e6e 100644 --- a/packages/payload/src/collections/operations/findByID.ts +++ b/packages/payload/src/collections/operations/findByID.ts @@ -1,5 +1,5 @@ import type { FindOneArgs } from '../../database/types.js' -import type { CollectionSlug } from '../../index.js' +import type { CollectionSlug, JoinQuery } from '../../index.js' import type { PayloadRequest } from '../../types/index.js' import type { Collection, DataFromCollectionSlug } from '../config/types.js' @@ -19,6 +19,7 @@ export type Arguments = { draft?: boolean id: number | string includeLockStatus?: boolean + joins?: JoinQuery overrideAccess?: boolean req: PayloadRequest showHiddenFields?: boolean @@ -55,6 +56,7 @@ export const findByIDOperation = async ( disableErrors, draft: draftEnabled = false, includeLockStatus, + joins, overrideAccess = false, req: { fallbackLocale, locale, t }, req, @@ -76,6 +78,7 @@ export const findByIDOperation = async ( const findOneArgs: FindOneArgs = { collection: collectionConfig.slug, + joins, locale, req: { transactionID: req.transactionID, diff --git a/packages/payload/src/collections/operations/local/find.ts b/packages/payload/src/collections/operations/local/find.ts index 5b0f599a1c6..09051ca6dc3 100644 --- a/packages/payload/src/collections/operations/local/find.ts +++ b/packages/payload/src/collections/operations/local/find.ts @@ -1,5 +1,5 @@ import type { PaginatedDocs } from '../../../database/types.js' -import type { CollectionSlug, Payload, TypedLocale } from '../../../index.js' +import type { CollectionSlug, JoinQuery, Payload, TypedLocale } from '../../../index.js' import type { Document, PayloadRequest, RequestContext, Where } from '../../../types/index.js' import type { DataFromCollectionSlug } from '../../config/types.js' @@ -19,6 +19,7 @@ export type Options = { draft?: boolean fallbackLocale?: TypedLocale includeLockStatus?: boolean + joins?: JoinQuery limit?: number locale?: 'all' | TypedLocale overrideAccess?: boolean @@ -42,6 +43,7 @@ export async function findLocal( disableErrors, draft = false, includeLockStatus, + joins, limit, overrideAccess = true, page, @@ -66,6 +68,7 @@ export async function findLocal( disableErrors, draft, includeLockStatus, + joins, limit, overrideAccess, page, diff --git a/packages/payload/src/collections/operations/local/findByID.ts b/packages/payload/src/collections/operations/local/findByID.ts index fc949198555..693a6b123dc 100644 --- a/packages/payload/src/collections/operations/local/findByID.ts +++ b/packages/payload/src/collections/operations/local/findByID.ts @@ -1,4 +1,4 @@ -import type { CollectionSlug, Payload, TypedLocale } from '../../../index.js' +import type { CollectionSlug, JoinQuery, Payload, TypedLocale } from '../../../index.js' import type { Document, PayloadRequest, RequestContext } from '../../../types/index.js' import type { DataFromCollectionSlug } from '../../config/types.js' @@ -19,6 +19,7 @@ export type Options = { fallbackLocale?: TypedLocale id: number | string includeLockStatus?: boolean + joins?: JoinQuery locale?: 'all' | TypedLocale overrideAccess?: boolean req?: PayloadRequest @@ -38,6 +39,7 @@ export default async function findByIDLocal( disableErrors = false, draft = false, includeLockStatus, + joins, overrideAccess = true, showHiddenFields, } = options @@ -58,6 +60,7 @@ export default async function findByIDLocal( disableErrors, draft, includeLockStatus, + joins, overrideAccess, req: await createLocalReq(options, payload), showHiddenFields, diff --git a/packages/payload/src/database/types.ts b/packages/payload/src/database/types.ts index f3857abeb72..8f12e33af9b 100644 --- a/packages/payload/src/database/types.ts +++ b/packages/payload/src/database/types.ts @@ -1,5 +1,5 @@ import type { TypeWithID } from '../collections/config/types.js' -import type { Document, Payload, PayloadRequest, Where } from '../types/index.js' +import type { Document, JoinQuery, Payload, PayloadRequest, Where } from '../types/index.js' import type { TypeWithVersion } from '../versions/types.js' export type { TypeWithVersion } @@ -49,7 +49,7 @@ export interface BaseDatabaseAdapter { */ destroy?: Destroy - find: (args: FindArgs) => Promise> + find: Find findGlobal: FindGlobal @@ -185,6 +185,7 @@ export type QueryDrafts = (args: QueryDraftsArgs) => Promise(args: FindOneArgs) => Promise req: PayloadRequest skip?: number sort?: string @@ -375,6 +378,7 @@ export type UpdateOneArgs = { collection: string data: Record draft?: boolean + joins?: JoinQuery locale?: string req: PayloadRequest } & ( @@ -392,6 +396,7 @@ export type UpdateOne = (args: UpdateOneArgs) => Promise export type DeleteOneArgs = { collection: string + joins?: JoinQuery req: PayloadRequest where: Where } @@ -400,6 +405,7 @@ export type DeleteOne = (args: DeleteOneArgs) => Promise export type DeleteManyArgs = { collection: string + joins?: JoinQuery req: PayloadRequest where: Where } diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 4b87a824228..ca57474f577 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -1,16 +1,17 @@ import { deepMergeSimple } from '@payloadcms/translations/utilities' -import type { CollectionConfig } from '../../collections/config/types.js' +import type { CollectionConfig, SanitizedJoins } from '../../collections/config/types.js' import type { Config, SanitizedConfig } from '../../config/types.js' import type { Field } from './types.js' import { + APIError, DuplicateFieldName, InvalidFieldName, InvalidFieldRelationship, + MissingEditorProp, MissingFieldType, } from '../../errors/index.js' -import { MissingEditorProp } from '../../errors/MissingEditorProp.js' import { formatLabels, toWords } from '../../utilities/formatLabels.js' import { baseBeforeDuplicateArrays } from '../baseFields/baseBeforeDuplicateArrays.js' import { baseBlockFields } from '../baseFields/baseBlockFields.js' @@ -24,6 +25,10 @@ type Args = { config: Config existingFieldNames?: Set fields: Field[] + /** + * When not passed in, assume that join are not supported (globals, arrays, blocks) + */ + joins?: SanitizedJoins parentIsLocalized: boolean /** * 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. @@ -37,6 +42,7 @@ type Args = { * so that you can sanitize them together, after the config has been sanitized. */ richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise> + schemaPath?: string /** * If not null, will validate that upload and relationship fields do not relate to a collection that is not in this array. * This validation will be skipped if validRelationships is null. @@ -45,19 +51,22 @@ type Args = { } export const sanitizeFields = async ({ - collectionConfig, config, existingFieldNames = new Set(), fields, + joins, parentIsLocalized, requireFieldLevelRichTextEditor = false, richTextSanitizationPromises, + schemaPath: schemaPathArg, validRelationships, }: Args): Promise => { if (!fields) { return [] } + let schemaPath = schemaPathArg + for (let i = 0; i < fields.length; i++) { const field = fields[i] @@ -94,6 +103,25 @@ export const sanitizeFields = async ({ field.defaultValue = false } + if (field.type === 'join') { + // the `joins` arg is not passed for globals or for when recursing on fields that do not allow a join field + if (typeof joins === 'undefined') { + throw new APIError('Join fields cannot be added to arrays, blocks or globals.') + } + if (!field.maxDepth) { + field.maxDepth = 1 + } + const join = { + field, + schemaPath: `${schemaPath || ''}${schemaPath ? '.' : ''}${field.name}`, + } + if (!joins[field.collection]) { + joins[field.collection] = [join] + } else { + joins[field.collection].push(join) + } + } + if (field.type === 'relationship' || field.type === 'upload') { if (validRelationships) { const relationships = Array.isArray(field.relationTo) @@ -250,13 +278,18 @@ export const sanitizeFields = async ({ } if ('fields' in field && field.fields) { + if ('name' in field && field.name) { + schemaPath = `${schemaPath || ''}${schemaPath ? '.' : ''}${field.name}` + } field.fields = await sanitizeFields({ config, existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames, fields: field.fields, + joins, parentIsLocalized: parentIsLocalized || field.localized, requireFieldLevelRichTextEditor, richTextSanitizationPromises, + schemaPath, validRelationships, }) } @@ -264,17 +297,22 @@ export const sanitizeFields = async ({ if (field.type === 'tabs') { for (let j = 0; j < field.tabs.length; j++) { const tab = field.tabs[j] - if (tabHasName(tab) && typeof tab.label === 'undefined') { - tab.label = toWords(tab.name) + if (tabHasName(tab)) { + schemaPath = `${schemaPath || ''}${schemaPath ? '.' : ''}${tab.name}` + if (typeof tab.label === 'undefined') { + tab.label = toWords(tab.name) + } } tab.fields = await sanitizeFields({ config, existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames, fields: tab.fields, + joins, parentIsLocalized: parentIsLocalized || (tabHasName(tab) && tab.localized), requireFieldLevelRichTextEditor, richTextSanitizationPromises, + schemaPath, validRelationships, }) field.tabs[j] = tab diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 3d627db4f97..516c7c2fec8 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -6,6 +6,12 @@ import type { JSONSchema4 } from 'json-schema' import type { CSSProperties } from 'react' import type { DeepUndefinable } from 'ts-essentials' +import type { + JoinFieldErrorClientComponent, + JoinFieldErrorServerComponent, + JoinFieldLabelClientComponent, + JoinFieldLabelServerComponent, +} from '../../admin/fields/Join.js' import type { FieldClientComponent, FieldServerComponent } from '../../admin/forms/Field.js' import type { RichTextAdapter, RichTextAdapterProvider } from '../../admin/RichText.js' import type { @@ -1387,6 +1393,49 @@ export type PointFieldClient = { } & FieldBaseClient & Pick +/** + * A virtual field that loads in related collections by querying a relationship or upload field. + */ +export type JoinField = { + access?: { + create?: never + read?: FieldAccess + update?: never + } + admin?: { + components?: { + Error?: CustomComponent + Label?: CustomComponent + } & Admin['components'] + disableBulkEdit?: never + readOnly?: never + } & Admin + /** + * The slug of the collection to relate with. + */ + collection: CollectionSlug + defaultValue?: never + hidden?: false + index?: never + maxDepth?: number + /** + * A string for the field in the collection being joined to. + */ + on: string + type: 'join' + validate?: never +} & FieldBase + +export type JoinFieldClient = { + admin?: { + components?: { + Label?: MappedComponent + } & AdminClient['components'] + } & AdminClient & + Pick +} & FieldBaseClient & + Pick + export type Field = | ArrayField | BlocksField @@ -1396,6 +1445,7 @@ export type Field = | DateField | EmailField | GroupField + | JoinField | JSONField | NumberField | PointField @@ -1419,6 +1469,7 @@ export type ClientField = | DateFieldClient | EmailFieldClient | GroupFieldClient + | JoinFieldClient | JSONFieldClient | NumberFieldClient | PointFieldClient @@ -1467,6 +1518,7 @@ export type FieldAffectingData = | DateField | EmailField | GroupField + | JoinField | JSONField | NumberField | PointField @@ -1487,6 +1539,7 @@ export type FieldAffectingDataClient = | DateFieldClient | EmailFieldClient | GroupFieldClient + | JoinFieldClient | JSONFieldClient | NumberFieldClient | PointFieldClient @@ -1566,7 +1619,7 @@ export type FieldWithMany = RelationshipField | SelectField export type FieldWithManyClient = RelationshipFieldClient | SelectFieldClient export type FieldWithMaxDepth = RelationshipField | UploadField -export type FieldWithMaxDepthClient = RelationshipFieldClient | UploadFieldClient +export type FieldWithMaxDepthClient = JoinFieldClient | RelationshipFieldClient | UploadFieldClient export function fieldHasSubFields( field: TField, @@ -1619,7 +1672,8 @@ export function fieldHasMaxDepth( field: TField, ): field is TField & (TField extends ClientField ? FieldWithMaxDepthClient : FieldWithMaxDepth) { return ( - (field.type === 'upload' || field.type === 'relationship') && typeof field.maxDepth === 'number' + (field.type === 'upload' || field.type === 'relationship' || field.type === 'join') && + typeof field.maxDepth === 'number' ) } diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 3a5e015e59e..6091440b4b8 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -292,7 +292,7 @@ export const promise = async ({ }) } - if (field.type === 'relationship' || field.type === 'upload') { + if (field.type === 'relationship' || field.type === 'upload' || field.type === 'join') { populationPromises.push( relationshipPopulationPromise({ currentDepth, diff --git a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts index 386c4cf132f..8d2599094da 100644 --- a/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts +++ b/packages/payload/src/fields/hooks/afterRead/relationshipPopulationPromise.ts @@ -1,5 +1,5 @@ import type { PayloadRequest } from '../../../types/index.js' -import type { RelationshipField, UploadField } from '../../config/types.js' +import type { JoinField, RelationshipField, UploadField } from '../../config/types.js' import { createDataloaderCacheKey } from '../../../collections/dataloader.js' import { fieldHasMaxDepth, fieldSupportsMany } from '../../config/types.js' @@ -11,7 +11,7 @@ type PopulateArgs = { depth: number draft: boolean fallbackLocale: null | string - field: RelationshipField | UploadField + field: JoinField | RelationshipField | UploadField index?: number key?: string locale: null | string @@ -36,11 +36,16 @@ const populate = async ({ showHiddenFields, }: PopulateArgs) => { const dataToUpdate = dataReference - const relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo + let relation + if (field.type === 'join') { + relation = field.collection + } else { + relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo + } const relatedCollection = req.payload.collections[relation] if (relatedCollection) { - let id = Array.isArray(field.relationTo) ? data.value : data + let id = field.type !== 'join' && Array.isArray(field.relationTo) ? data.value : data let relationshipValue const shouldPopulate = depth && currentDepth <= depth @@ -74,20 +79,21 @@ const populate = async ({ // ids are visible regardless of access controls relationshipValue = id } - if (typeof index === 'number' && typeof key === 'string') { - if (Array.isArray(field.relationTo)) { + if (field.type !== 'join' && Array.isArray(field.relationTo)) { dataToUpdate[field.name][key][index].value = relationshipValue } else { dataToUpdate[field.name][key][index] = relationshipValue } } else if (typeof index === 'number' || typeof key === 'string') { - if (Array.isArray(field.relationTo)) { + if (field.type === 'join') { + dataToUpdate[field.name].docs[index ?? key] = relationshipValue + } else if (Array.isArray(field.relationTo)) { dataToUpdate[field.name][index ?? key].value = relationshipValue } else { dataToUpdate[field.name][index ?? key] = relationshipValue } - } else if (Array.isArray(field.relationTo)) { + } else if (field.type !== 'join' && Array.isArray(field.relationTo)) { dataToUpdate[field.name].value = relationshipValue } else { dataToUpdate[field.name] = relationshipValue @@ -100,7 +106,7 @@ type PromiseArgs = { depth: number draft: boolean fallbackLocale: null | string - field: RelationshipField | UploadField + field: JoinField | RelationshipField | UploadField locale: null | string overrideAccess: boolean req: PayloadRequest @@ -124,7 +130,7 @@ export const relationshipPopulationPromise = async ({ const populateDepth = fieldHasMaxDepth(field) && field.maxDepth < depth ? field.maxDepth : depth const rowPromises = [] - if (fieldSupportsMany(field) && field.hasMany) { + if (field.type === 'join' || (fieldSupportsMany(field) && field.hasMany)) { if ( field.localized && locale === 'all' && @@ -155,13 +161,19 @@ export const relationshipPopulationPromise = async ({ }) } }) - } else if (Array.isArray(siblingDoc[field.name])) { - siblingDoc[field.name].forEach((relatedDoc, index) => { + } else if ( + Array.isArray(siblingDoc[field.name]) || + Array.isArray(siblingDoc[field.name]?.docs) + ) { + ;(Array.isArray(siblingDoc[field.name]) + ? siblingDoc[field.name] + : siblingDoc[field.name].docs + ).forEach((relatedDoc, index) => { const rowPromise = async () => { if (relatedDoc) { await populate({ currentDepth, - data: relatedDoc, + data: 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 ff79a25e523..f6137555b94 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -52,6 +52,7 @@ import type { Options as FindGlobalVersionsOptions } from './globals/operations/ import type { Options as RestoreGlobalVersionOptions } from './globals/operations/local/restoreVersion.js' import type { Options as UpdateGlobalOptions } from './globals/operations/local/update.js' import type { JsonObject } from './types/index.js' +import type { TraverseFieldsCallback } from './utilities/traverseFields.js' import type { TypeWithVersion } from './versions/types.js' import { decrypt, encrypt } from './auth/crypto.js' @@ -63,9 +64,9 @@ import { consoleEmailAdapter } from './email/consoleEmailAdapter.js' import { fieldAffectsData } from './fields/config/types.js' import localGlobalOperations from './globals/operations/local/index.js' import { checkDependencies } from './utilities/dependencies/dependencyChecker.js' -import flattenFields from './utilities/flattenTopLevelFields.js' import { getLogger } from './utilities/logger.js' import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js' +import { traverseFields } from './utilities/traverseFields.js' export interface GeneratedTypes { authUntyped: { @@ -454,16 +455,23 @@ export class BasePayload { } this.config.collections.forEach((collection) => { - const customID = flattenFields(collection.fields).find( - (field) => fieldAffectsData(field) && field.name === 'id', - ) - - let customIDType - - if (customID?.type === 'number' || customID?.type === 'text') { - customIDType = customID.type + let customIDType = undefined + const findCustomID: TraverseFieldsCallback = ({ field, next }) => { + if (['array', 'blocks'].includes(field.type)) { + next() + return + } + if (!fieldAffectsData(field)) { + return + } + if (field.name === 'id') { + customIDType = field.type + return true + } } + traverseFields({ callback: findCustomID, fields: collection.fields }) + this.collections[collection.slug] = { config: collection, customIDType, @@ -874,6 +882,8 @@ export type { GroupField, GroupFieldClient, HookName, + JoinField, + JoinFieldClient, JSONField, JSONFieldClient, Labels, @@ -1017,6 +1027,8 @@ export { isValidID } from './utilities/isValidID.js' export { killTransaction } from './utilities/killTransaction.js' export { mapAsync } from './utilities/mapAsync.js' export { mergeListSearchAndWhere } from './utilities/mergeListSearchAndWhere.js' +export { traverseFields } from './utilities/traverseFields.js' +export type { TraverseFieldsCallback } from './utilities/traverseFields.js' export { buildVersionCollectionFields } from './versions/buildCollectionFields.js' export { buildVersionGlobalFields } from './versions/buildGlobalFields.js' export { checkDependencies } @@ -1026,6 +1038,6 @@ export { enforceMaxVersions } from './versions/enforceMaxVersions.js' export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js' export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js' export { saveVersion } from './versions/saveVersion.js' -export type { TypeWithVersion } from './versions/types.js' +export type { TypeWithVersion } from './versions/types.js' export { deepMergeSimple } from '@payloadcms/translations/utilities' diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index 91d5e502ff6..3e6dd64434a 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -92,7 +92,7 @@ export type Operator = (typeof validOperators)[number] // Makes it so things like passing new Date() will error export type JsonValue = JsonArray | JsonObject | unknown //Date | JsonArray | JsonObject | boolean | null | number | string // TODO: Evaluate proper, strong type for this -export interface JsonArray extends Array {} +export type JsonArray = Array export interface JsonObject { [key: string]: any @@ -109,6 +109,19 @@ export type Where = { or?: Where[] } +/** + * Applies pagination for join fields for including collection relationships + */ +export type JoinQuery = + | { + [schemaPath: string]: { + limit?: number + sort?: string + where?: Where + } + } + | false + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Document = any diff --git a/packages/payload/src/utilities/configToJSONSchema.ts b/packages/payload/src/utilities/configToJSONSchema.ts index b446e1ef2fc..e0a37466cf5 100644 --- a/packages/payload/src/utilities/configToJSONSchema.ts +++ b/packages/payload/src/utilities/configToJSONSchema.ts @@ -299,6 +299,30 @@ export function fieldsToJSONSchema( break } + case 'join': { + fieldSchema = { + type: withNullableJSONSchemaType('object', false), + additionalProperties: false, + properties: { + docs: { + type: withNullableJSONSchemaType('array', false), + items: { + oneOf: [ + { + type: collectionIDFieldTypes[field.collection], + }, + { + $ref: `#/definitions/${field.collection}`, + }, + ], + }, + }, + hasNextPage: { type: withNullableJSONSchemaType('boolean', false) }, + }, + } + break + } + case 'upload': case 'relationship': { if (Array.isArray(field.relationTo)) { diff --git a/packages/payload/src/utilities/traverseFields.ts b/packages/payload/src/utilities/traverseFields.ts new file mode 100644 index 00000000000..791746274f9 --- /dev/null +++ b/packages/payload/src/utilities/traverseFields.ts @@ -0,0 +1,88 @@ +import type { Field } from '../fields/config/types.js' + +import { fieldHasSubFields } from '../fields/config/types.js' + +export type TraverseFieldsCallback = (args: { + /** + * The current field + */ + field: Field + /** + * Function that when called will skip the current field and continue to the next + */ + next?: () => void + /** + * The parent reference object + */ + parentRef?: Record | unknown + /** + * The current reference object + */ + ref?: Record | unknown +}) => boolean | void + +type TraverseFieldsArgs = { + callback: TraverseFieldsCallback + fields: Field[] + parentRef?: Record | unknown + ref?: Record | unknown +} + +/** + * Iterate a recurse an array of fields, calling a callback for each field + * + * @param fields + * @param callback callback called for each field, discontinue looping if callback returns truthy + * @param ref + * @param parentRef + */ +export const traverseFields = ({ + callback, + fields, + parentRef = {}, + ref = {}, +}: TraverseFieldsArgs): void => { + fields.some((field) => { + let skip = false + const next = () => { + skip = true + } + if (callback && callback({ field, next, parentRef, ref })) { + return true + } + if (skip) { + return false + } + if (fieldHasSubFields(field)) { + const parentRef = ref + if ('name' in field && field.name) { + if (typeof ref[field.name] === 'undefined') { + if (field.type === 'array') { + if (field.localized) { + ref[field.name] = {} + } else { + ref[field.name] = [] + } + } + if (field.type === 'group') { + ref[field.name] = {} + } + } + ref = ref[field.name] + } + traverseFields({ callback, fields: field.fields, parentRef, ref }) + } + + if (field.type === 'tabs' && 'tabs' in field) { + field.tabs.forEach((tab) => { + if ('name' in tab && tab.name) { + if (typeof ref[tab.name] === 'undefined') { + ref[tab.name] = {} + } + ref = ref[tab.name] + } + traverseFields({ callback, fields: tab.fields, parentRef, ref }) + }) + } + }) +} diff --git a/packages/payload/src/versions/saveVersion.ts b/packages/payload/src/versions/saveVersion.ts index ade83fa26e7..161c245d0c4 100644 --- a/packages/payload/src/versions/saveVersion.ts +++ b/packages/payload/src/versions/saveVersion.ts @@ -56,6 +56,8 @@ export const saveVersion = async ({ ;({ docs } = await payload.db.findVersions({ ...findVersionArgs, collection: collection.slug, + limit: 1, + pagination: false, req, where: { parent: { @@ -67,6 +69,8 @@ export const saveVersion = async ({ ;({ docs } = await payload.db.findGlobalVersions({ ...findVersionArgs, global: global.slug, + limit: 1, + pagination: false, req, })) } @@ -131,7 +135,9 @@ export const saveVersion = async ({ if (publishSpecificLocale && snapshot) { const snapshotData = deepCopyObjectSimple(snapshot) - if (snapshotData._id) {delete snapshotData._id} + if (snapshotData._id) { + delete snapshotData._id + } snapshotData._status = 'draft' diff --git a/packages/plugin-relationship-object-ids/.gitignore b/packages/plugin-relationship-object-ids/.gitignore deleted file mode 100644 index 4baaac85f04..00000000000 --- a/packages/plugin-relationship-object-ids/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -.env -dist -demo/uploads -build -.DS_Store -package-lock.json diff --git a/packages/plugin-relationship-object-ids/.swcrc b/packages/plugin-relationship-object-ids/.swcrc deleted file mode 100644 index 14463f4b08b..00000000000 --- a/packages/plugin-relationship-object-ids/.swcrc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/swcrc", - "sourceMaps": true, - "jsc": { - "target": "esnext", - "parser": { - "syntax": "typescript", - "tsx": true, - "dts": true - } - }, - "module": { - "type": "es6" - } -} diff --git a/packages/plugin-relationship-object-ids/README.md b/packages/plugin-relationship-object-ids/README.md deleted file mode 100644 index a0fc9fe3fbe..00000000000 --- a/packages/plugin-relationship-object-ids/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Payload Relationship ObjectID Plugin - -This plugin automatically enables all Payload `relationship` and `upload` field types to be stored as `ObjectID`s in MongoDB. - -Minimum required version of Payload: `1.9.5` - -## What it does - -It injects a `beforeChange` field hook into each `relationship` and `upload` field, which converts string-based IDs to `ObjectID`s immediately prior to storage. - -By default, it also injects an `afterRead` field hook into the above fields, which ensures that the values are re-formatted back to strings after having been read from the database. - -#### Usage - -Simply import and install the plugin to make it work: - -```ts -import { relationshipsAsObjectID } from '@payloadcms/plugin-relationship-object-ids' -import { buildConfig } from 'payload' - -export default buildConfig({ - // your config here - plugins: [ - // Call the plugin within your `plugins` array - relationshipsAsObjectID({ - // Optionally keep relationship values as ObjectID - // when they are retrieved from the database. - keepAfterRead: true, - }), - ], -}) -``` - -### Migration - -Note - this plugin will only store newly created or resaved documents' relations as `ObjectID`s. It will not modify any of your existing data. If you'd like to convert existing data into an `ObjectID` format, you should write a migration script to loop over all documents in your database and then simply resave each one. - -### Support - -If you need help with this plugin, [join our Discord](https://t.co/30APlsQUPB) and we'd be happy to give you a hand. diff --git a/packages/plugin-relationship-object-ids/eslint.config.js b/packages/plugin-relationship-object-ids/eslint.config.js deleted file mode 100644 index 2dee1a001d2..00000000000 --- a/packages/plugin-relationship-object-ids/eslint.config.js +++ /dev/null @@ -1,19 +0,0 @@ -import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js' - -/** @typedef {import('eslint').Linter.FlatConfig} */ -let FlatConfig - -/** @type {FlatConfig[]} */ -export const index = [ - ...rootEslintConfig, - { - languageOptions: { - parserOptions: { - ...rootParserOptions, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, -] - -export default index diff --git a/packages/plugin-relationship-object-ids/package.json b/packages/plugin-relationship-object-ids/package.json deleted file mode 100644 index eed501e9183..00000000000 --- a/packages/plugin-relationship-object-ids/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@payloadcms/plugin-relationship-object-ids", - "version": "3.0.0-beta.104", - "description": "A Payload plugin to store all relationship IDs as ObjectIDs", - "repository": { - "type": "git", - "url": "https://github.com/payloadcms/payload.git", - "directory": "packages/plugin-relationship-object-ids" - }, - "license": "MIT", - "author": "Payload (https://payloadcms.com)", - "maintainers": [ - { - "name": "Payload", - "email": "info@payloadcms.com", - "url": "https://payloadcms.com" - } - ], - "type": "module", - "exports": { - ".": { - "import": "./src/index.ts", - "types": "./src/index.ts", - "default": "./src/index.ts" - } - }, - "main": "./src/index.ts", - "types": "./src/index.ts", - "files": [ - "dist" - ], - "scripts": { - "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc", - "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", - "build:types": "tsc --emitDeclarationOnly --outDir dist", - "clean": "rimraf {dist,*.tsbuildinfo}", - "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", - "lint": "eslint .", - "lint:fix": "eslint . --fix", - "prepublishOnly": "pnpm clean && pnpm turbo build" - }, - "devDependencies": { - "@payloadcms/eslint-config": "workspace:*", - "payload": "workspace:*" - }, - "peerDependencies": { - "mongoose": "6.12.3", - "payload": "workspace:*" - }, - "publishConfig": { - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "homepage:": "https://payloadcms.com" -} diff --git a/packages/plugin-relationship-object-ids/src/hooks/afterRead.ts b/packages/plugin-relationship-object-ids/src/hooks/afterRead.ts deleted file mode 100644 index e9cc5fd3797..00000000000 --- a/packages/plugin-relationship-object-ids/src/hooks/afterRead.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { CollectionConfig, Config, FieldHook, RelationshipField, UploadField } from 'payload' - -import mongoose from 'mongoose' -import { fieldAffectsData } from 'payload/shared' - -const convertValue = ({ - relatedCollection, - value, -}: { - relatedCollection: CollectionConfig - value: number | string -}): mongoose.Types.ObjectId | number | string => { - const customIDField = relatedCollection.fields.find( - (field) => fieldAffectsData(field) && field.name === 'id', - ) - - if (!customIDField && mongoose.Types.ObjectId.isValid(value)) { - return value.toString() - } - - return value -} - -interface RelationObject { - relationTo: string - value: number | string -} - -function isValidRelationObject(value: unknown): value is RelationObject { - return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value -} - -interface Args { - config: Config - field: RelationshipField | UploadField -} - -export const getAfterReadHook = - ({ config, field }: Args): FieldHook => - ({ value }) => { - let relatedCollection: CollectionConfig | undefined - - const hasManyRelations = typeof field.relationTo !== 'string' - - if (!hasManyRelations) { - relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo) - } - - if (Array.isArray(value)) { - return value.map((val) => { - // Handle has many - if (relatedCollection && val) { - return convertValue({ - relatedCollection, - value: val, - }) - } - - // Handle has many - polymorphic - if (isValidRelationObject(val)) { - const relatedCollectionForSingleValue = config.collections?.find( - ({ slug }) => slug === val.relationTo, - ) - - if (relatedCollectionForSingleValue) { - return { - relationTo: val.relationTo, - value: convertValue({ - relatedCollection: relatedCollectionForSingleValue, - value: val.value, - }), - } - } - } - - return val - }) - } - - // Handle has one - polymorphic - if (isValidRelationObject(value)) { - relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo) - - if (relatedCollection) { - return { - relationTo: value.relationTo, - value: convertValue({ relatedCollection, value: value.value }), - } - } - } - - // Handle has one - if (relatedCollection && value) { - return convertValue({ - relatedCollection, - value, - }) - } - - return value - } diff --git a/packages/plugin-relationship-object-ids/src/hooks/beforeChange.ts b/packages/plugin-relationship-object-ids/src/hooks/beforeChange.ts deleted file mode 100644 index 50065509901..00000000000 --- a/packages/plugin-relationship-object-ids/src/hooks/beforeChange.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { CollectionConfig, Config, FieldHook, RelationshipField, UploadField } from 'payload' - -import mongoose from 'mongoose' -import { fieldAffectsData } from 'payload/shared' - -const convertValue = ({ - relatedCollection, - value, -}: { - relatedCollection: CollectionConfig - value: number | string -}): mongoose.Types.ObjectId | number | string => { - const customIDField = relatedCollection.fields.find( - (field) => fieldAffectsData(field) && field.name === 'id', - ) - - if (!customIDField) { - return new mongoose.Types.ObjectId(value) - } - - return value -} - -interface RelationObject { - relationTo: string - value: number | string -} - -function isValidRelationObject(value: unknown): value is RelationObject { - return typeof value === 'object' && value !== null && 'relationTo' in value && 'value' in value -} - -export const getBeforeChangeHook = - ({ config, field }: { config: Config; field: RelationshipField | UploadField }): FieldHook => - ({ value }) => { - let relatedCollection: CollectionConfig | undefined - - const hasManyRelations = typeof field.relationTo !== 'string' - - if (!hasManyRelations) { - relatedCollection = config.collections?.find(({ slug }) => slug === field.relationTo) - } - - if (Array.isArray(value)) { - return value.map((val) => { - // Handle has many - if (relatedCollection && val && (typeof val === 'string' || typeof val === 'number')) { - return convertValue({ - relatedCollection, - value: val, - }) - } - - // Handle has many - polymorphic - if (isValidRelationObject(val)) { - const relatedCollectionForSingleValue = config.collections?.find( - ({ slug }) => slug === val.relationTo, - ) - - if (relatedCollectionForSingleValue) { - return { - relationTo: val.relationTo, - value: convertValue({ - relatedCollection: relatedCollectionForSingleValue, - value: val.value, - }), - } - } - } - - return val - }) - } - - // Handle has one - polymorphic - if (isValidRelationObject(value)) { - relatedCollection = config.collections?.find(({ slug }) => slug === value.relationTo) - - if (relatedCollection) { - return { - relationTo: value.relationTo, - value: convertValue({ relatedCollection, value: value.value }), - } - } - } - - // Handle has one - if (relatedCollection && value && (typeof value === 'string' || typeof value === 'number')) { - return convertValue({ - relatedCollection, - value, - }) - } - - return value - } diff --git a/packages/plugin-relationship-object-ids/src/index.ts b/packages/plugin-relationship-object-ids/src/index.ts deleted file mode 100644 index c35e06701d1..00000000000 --- a/packages/plugin-relationship-object-ids/src/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Config, Field, FieldHook } from 'payload' - -import { getAfterReadHook } from './hooks/afterRead.js' -import { getBeforeChangeHook } from './hooks/beforeChange.js' - -interface TraverseFieldsArgs { - config: Config - fields: Field[] - keepAfterRead: boolean -} - -const traverseFields = ({ config, fields, keepAfterRead }: TraverseFieldsArgs): Field[] => { - return fields.map((field) => { - if (field.type === 'relationship' || field.type === 'upload') { - const afterRead: FieldHook[] = [...(field.hooks?.afterRead || [])] - - if (!keepAfterRead) { - afterRead.unshift(getAfterReadHook({ config, field })) - } - - return { - ...field, - hooks: { - ...(field.hooks || {}), - afterRead, - beforeChange: [ - ...(field.hooks?.beforeChange || []), - getBeforeChangeHook({ config, field }), - ], - }, - } - } - - if ('fields' in field) { - return { - ...field, - fields: traverseFields({ config, fields: field.fields, keepAfterRead }), - } - } - - if (field.type === 'tabs') { - return { - ...field, - tabs: field.tabs.map((tab) => { - return { - ...tab, - fields: traverseFields({ config, fields: tab.fields, keepAfterRead }), - } - }), - } - } - - if (field.type === 'blocks') { - return { - ...field, - blocks: field.blocks.map((block) => { - return { - ...block, - fields: traverseFields({ config, fields: block.fields, keepAfterRead }), - } - }), - } - } - - return field - }) -} - -interface Args { - /* - If you want to keep ObjectIDs as ObjectIDs after read, you can enable this flag. - By default, all relationship ObjectIDs are stringified within the AfterRead hook. - */ - keepAfterRead?: boolean -} - -export const relationshipsAsObjectID = - (args?: Args) => - (config: Config): Config => { - const keepAfterRead = typeof args?.keepAfterRead === 'boolean' ? args.keepAfterRead : false - - return { - ...config, - collections: (config.collections || []).map((collection) => { - return { - ...collection, - fields: traverseFields({ - config, - fields: collection.fields, - keepAfterRead, - }), - } - }), - globals: (config.globals || []).map((global) => { - return { - ...global, - fields: traverseFields({ - config, - fields: global.fields, - keepAfterRead, - }), - } - }), - } - } diff --git a/packages/plugin-relationship-object-ids/tsconfig.json b/packages/plugin-relationship-object-ids/tsconfig.json deleted file mode 100644 index 79d535b16e7..00000000000 --- a/packages/plugin-relationship-object-ids/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "composite": true, // Make sure typescript knows that this module depends on their references - "noEmit": false /* Do not emit outputs. */, - "emitDeclarationOnly": true, - "outDir": "./dist" /* Specify an output folder for all emitted files. */, - "rootDir": "./src" /* Specify the root folder within your source files. */ - }, - "exclude": [ - "dist", - "build", - "tests", - "test", - "node_modules", - "eslint.config.js", - "src/**/*.spec.js", - "src/**/*.spec.jsx", - "src/**/*.spec.ts", - "src/**/*.spec.tsx" - ], - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], - "references": [{ "path": "../payload" }] -} diff --git a/packages/ui/src/elements/ColumnSelector/index.tsx b/packages/ui/src/elements/ColumnSelector/index.tsx index 061cf359ca7..fce30b5b601 100644 --- a/packages/ui/src/elements/ColumnSelector/index.tsx +++ b/packages/ui/src/elements/ColumnSelector/index.tsx @@ -53,9 +53,20 @@ export const ColumnSelector: React.FC = ({ collectionSlug }) => { return null } - const { accessor, active, Label } = col + const { + accessor, + active, + cellProps: { + field: { + admin: { + // @ts-expect-error // TODO: `Label` does not exist on the UI field + components: { Label } = {}, + } = {}, + } = {}, + }, + } = col - if (col.accessor === '_select') { + if (col.accessor === '_select' || Label === null) { return null } diff --git a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx index d9ebf4ba391..38fa1cd4f80 100644 --- a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx @@ -25,6 +25,8 @@ export const DocumentDrawerContent: React.FC = ({ disableActions, drawerSlug, Header, + initialData, + initialState, onDelete: onDeleteFromProps, onDuplicate: onDuplicateFromProps, onSave: onSaveFromProps, @@ -136,6 +138,8 @@ export const DocumentDrawerContent: React.FC = ({ disableActions={disableActions} disableLeaveWithoutSaving id={docID} + initialData={initialData} + initialState={initialState} isEditing={isEditing} onDelete={onDelete} onDrawerCreate={() => { diff --git a/packages/ui/src/elements/DocumentDrawer/types.ts b/packages/ui/src/elements/DocumentDrawer/types.ts index de7aa0d4c4b..5337c7148f1 100644 --- a/packages/ui/src/elements/DocumentDrawer/types.ts +++ b/packages/ui/src/elements/DocumentDrawer/types.ts @@ -1,3 +1,4 @@ +import type { Data, FormState } from 'payload' import type React from 'react' import type { HTMLAttributes } from 'react' @@ -10,6 +11,8 @@ export type DocumentDrawerProps = { readonly disableActions?: boolean readonly drawerSlug?: string readonly id?: null | number | string + readonly initialData?: Data + readonly initialState?: FormState readonly onDelete?: DocumentInfoContext['onDelete'] readonly onDuplicate?: DocumentInfoContext['onDuplicate'] readonly onSave?: DocumentInfoContext['onSave'] @@ -24,7 +27,7 @@ export type DocumentTogglerProps = { readonly disabled?: boolean readonly drawerSlug?: string readonly id?: string -} & HTMLAttributes +} & Readonly> export type UseDocumentDrawer = (args: { collectionSlug: string; id?: number | string }) => [ React.FC>, // drawer diff --git a/packages/ui/src/elements/Hamburger/index.scss b/packages/ui/src/elements/Hamburger/index.scss index 5ba074905b3..ec67bf9d613 100644 --- a/packages/ui/src/elements/Hamburger/index.scss +++ b/packages/ui/src/elements/Hamburger/index.scss @@ -18,6 +18,7 @@ transition-property: box-shadow, background-color; transition-duration: 100ms; transition-timing-function: cubic-bezier(0, 0.2, 0.2, 1); + --hamburger-size: var(--base); &:hover { background-color: var(--theme-elevation-100); @@ -36,5 +37,8 @@ &__close-icon { width: var(--hamburger-size); height: var(--hamburger-size); + display: flex; + align-items: center; + justify-content: center; } } diff --git a/packages/ui/src/elements/ListControls/index.scss b/packages/ui/src/elements/ListControls/index.scss index 70bb0e972ea..8a11e6c72b2 100644 --- a/packages/ui/src/elements/ListControls/index.scss +++ b/packages/ui/src/elements/ListControls/index.scss @@ -32,12 +32,6 @@ } } - &__buttons-active { - svg { - transform: rotate(180deg); - } - } - .column-selector, .where-builder, .sort-complex { diff --git a/packages/ui/src/elements/ListControls/index.tsx b/packages/ui/src/elements/ListControls/index.tsx index d22b66c8e3d..d7fb1354c46 100644 --- a/packages/ui/src/elements/ListControls/index.tsx +++ b/packages/ui/src/elements/ListControls/index.tsx @@ -125,7 +125,7 @@ export const ListControls: React.FC = (props) => {
{ return void handleSearchChange(search) }} @@ -153,10 +153,8 @@ export const ListControls: React.FC = (props) => { } + className={`${baseClass}__toggle-columns`} + icon={} onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined) } @@ -168,10 +166,8 @@ export const ListControls: React.FC = (props) => { } + className={`${baseClass}__toggle-where`} + icon={} onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)} pillStyle="light" > diff --git a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx index 7714b9a584c..135a02386d7 100644 --- a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx @@ -27,7 +27,7 @@ import { TableColumnsProvider } from '../TableColumns/index.js' import { ViewDescription } from '../ViewDescription/index.js' import { baseClass } from './index.js' -const hoistQueryParamsToAnd = (where: Where, queryParams: Where) => { +export const hoistQueryParamsToAnd = (where: Where, queryParams: Where) => { if ('and' in where) { where.and.push(queryParams) } else if ('or' in where) { diff --git a/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss b/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss index d8f88530705..3b478585598 100644 --- a/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss +++ b/packages/ui/src/elements/Localizer/LocalizerLabel/index.scss @@ -6,8 +6,7 @@ white-space: nowrap; display: flex; padding-inline-start: base(0.4); - padding-inline-end: base(0.1); - gap: 0; + padding-inline-end: base(0.4); background-color: var(--theme-elevation-100); border-radius: var(--style-radius-s); @@ -16,12 +15,17 @@ } &__chevron { - padding: base(0.1); .stroke { stroke: currentColor; } } + &__current { + display: flex; + align-items: center; + gap: base(0.3); + } + button { color: currentColor; padding: 0; diff --git a/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx b/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx index 400f739fdf4..d5d50d8e286 100644 --- a/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx +++ b/packages/ui/src/elements/Localizer/LocalizerLabel/index.tsx @@ -23,10 +23,12 @@ export const LocalizerLabel: React.FC<{ className={[baseClass, className].filter(Boolean).join(' ')} >
{`${t('general:locale')}:`} 
- - {`${getTranslation(locale.label, i18n)}`} - - +
+ + {`${getTranslation(locale.label, i18n)}`} + + +
) } diff --git a/packages/ui/src/elements/Pagination/index.tsx b/packages/ui/src/elements/Pagination/index.tsx index d2f7e021283..ed1330ed7c7 100644 --- a/packages/ui/src/elements/Pagination/index.tsx +++ b/packages/ui/src/elements/Pagination/index.tsx @@ -52,7 +52,7 @@ export const Pagination: React.FC = (props) => { totalPages = null, } = props - if (!totalPages || totalPages <= 1) { + if (!hasNextPage && !hasPrevPage) { return null } @@ -82,6 +82,7 @@ export const Pagination: React.FC = (props) => { if (currentPage - numberOfNeighbors - 1 >= 2) { nodes.unshift({ type: 'Separator' }) } + // Add first page if necessary if (currentPage > numberOfNeighbors + 1) { nodes.unshift({ @@ -98,6 +99,7 @@ export const Pagination: React.FC = (props) => { if (currentPage + numberOfNeighbors + 1 < totalPages) { nodes.push({ type: 'Separator' }) } + // Add last page if necessary if (rangeEndIndex < totalPages) { nodes.push({ diff --git a/packages/ui/src/elements/PerPage/index.tsx b/packages/ui/src/elements/PerPage/index.tsx index d105ef40ef3..7e33354cf58 100644 --- a/packages/ui/src/elements/PerPage/index.tsx +++ b/packages/ui/src/elements/PerPage/index.tsx @@ -13,11 +13,11 @@ const baseClass = 'per-page' const defaultLimits = collectionDefaults.admin.pagination.limits export type PerPageProps = { - defaultLimit?: number - handleChange?: (limit: number) => void - limit: number - limits: number[] - resetPage?: boolean + readonly defaultLimit?: number + readonly handleChange?: (limit: number) => void + readonly limit: number + readonly limits: number[] + readonly resetPage?: boolean } export const PerPage: React.FC = ({ @@ -61,7 +61,7 @@ export const PerPage: React.FC = ({ > {limitNumber === limitToUse && (
- +
)}   diff --git a/packages/ui/src/elements/Pill/index.scss b/packages/ui/src/elements/Pill/index.scss index 674242bf973..9507e587edc 100644 --- a/packages/ui/src/elements/Pill/index.scss +++ b/packages/ui/src/elements/Pill/index.scss @@ -15,6 +15,7 @@ padding: 0 base(0.4); align-items: center; flex-shrink: 0; + gap: base(0.2); &--rounded { border-radius: var(--style-radius-l); @@ -54,7 +55,8 @@ &--has-icon { padding-inline-start: base(0.4); - padding-inline-end: base(0.1); + padding-inline-end: base(0.3); + svg { display: block; } diff --git a/packages/ui/src/elements/ReactSelect/index.scss b/packages/ui/src/elements/ReactSelect/index.scss index abab4bb10e8..29409f6d237 100644 --- a/packages/ui/src/elements/ReactSelect/index.scss +++ b/packages/ui/src/elements/ReactSelect/index.scss @@ -12,6 +12,10 @@ flex-wrap: nowrap; } + .rs__indicators { + gap: calc(var(--base) / 4); + } + .rs__indicator { padding: 0px 4px; cursor: pointer; diff --git a/packages/ui/src/elements/RelationshipTable/TableWrapper.tsx b/packages/ui/src/elements/RelationshipTable/TableWrapper.tsx new file mode 100644 index 00000000000..3ecdedccff2 --- /dev/null +++ b/packages/ui/src/elements/RelationshipTable/TableWrapper.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { ClientCollectionConfig } from 'payload' + +import React, { Fragment } from 'react' + +import { useListQuery } from '../../providers/ListQuery/index.js' +import { Pagination } from '../Pagination/index.js' +import { Table } from '../Table/index.js' + +type RelationshipTableComponentProps = { + readonly collectionConfig: ClientCollectionConfig +} + +export const RelationshipTableWrapper: React.FC = (props) => { + const { collectionConfig } = props + + const { data, handlePageChange } = useListQuery() + + return ( + + + { + void handlePageChange(e) + }} + page={data.page || 1} + prevPage={data.prevPage || undefined} + totalPages={data.totalPages} + /> + + ) +} diff --git a/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.scss b/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.scss new file mode 100644 index 00000000000..0df588c8cc9 --- /dev/null +++ b/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.scss @@ -0,0 +1,4 @@ +.drawer-link { + display: flex; + gap: calc(var(--base) / 2); +} diff --git a/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.tsx b/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.tsx new file mode 100644 index 00000000000..e47a6f93eb1 --- /dev/null +++ b/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.tsx @@ -0,0 +1,53 @@ +'use client' +import type { CellComponentProps, JoinFieldClient } from 'payload' + +import React, { useCallback } from 'react' + +import type { DocumentDrawerProps } from '../../../DocumentDrawer/types.js' + +import { EditIcon } from '../../../../icons/Edit/index.js' +import { useDocumentDrawer } from '../../../DocumentDrawer/index.js' +import { DefaultCell } from '../../../Table/DefaultCell/index.js' +import { useTableCell } from '../../../Table/index.js' +import './index.scss' + +export const DrawerLink: React.FC< + { + readonly onDrawerSave?: DocumentDrawerProps['onSave'] + } & CellComponentProps +> = (props) => { + const context = useTableCell() + const { field, onDrawerSave: onDrawerSaveFromProps } = props + + const { + cellProps, + customCellContext: { collectionSlug }, + rowData, + } = context + + const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({ + id: rowData.id, + collectionSlug, + }) + + const onDrawerSave = useCallback( + (args) => { + closeDrawer() + + if (typeof onDrawerSaveFromProps === 'function') { + void onDrawerSaveFromProps(args) + } + }, + [closeDrawer, onDrawerSaveFromProps], + ) + + return ( +
+ + + + + +
+ ) +} diff --git a/packages/ui/src/elements/RelationshipTable/index.scss b/packages/ui/src/elements/RelationshipTable/index.scss new file mode 100644 index 00000000000..271e41bc3f7 --- /dev/null +++ b/packages/ui/src/elements/RelationshipTable/index.scss @@ -0,0 +1,26 @@ +.relationship-table { + position: relative; + + &__header { + display: flex; + justify-content: space-between; + margin-bottom: var(--base); + } + + &__actions { + display: flex; + align-items: center; + gap: var(--base); + } + + &__columns-inner { + padding-bottom: var(--base); + } + + .table { + th, + td:first-child { + min-width: 0; + } + } +} diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx new file mode 100644 index 00000000000..f8ed36a2b35 --- /dev/null +++ b/packages/ui/src/elements/RelationshipTable/index.tsx @@ -0,0 +1,286 @@ +'use client' +import type { + ClientCollectionConfig, + ClientField, + JoinFieldClient, + PaginatedDocs, + Where, +} from 'payload' + +import React, { useCallback, useEffect, useState } from 'react' +import AnimateHeightImport from 'react-animate-height' + +const AnimateHeight = AnimateHeightImport.default || AnimateHeightImport + +import { getTranslation } from '@payloadcms/translations' + +import type { DocumentDrawerProps } from '../DocumentDrawer/types.js' + +import { Pill } from '../../elements/Pill/index.js' +import { usePayloadAPI } from '../../hooks/usePayloadAPI.js' +import { ChevronIcon } from '../../icons/Chevron/index.js' +import { useConfig } from '../../providers/Config/index.js' +import { useDocumentInfo } from '../../providers/DocumentInfo/index.js' +import { ListQueryProvider } from '../../providers/ListQuery/index.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { ColumnSelector } from '../ColumnSelector/index.js' +import { useDocumentDrawer } from '../DocumentDrawer/index.js' +import { hoistQueryParamsToAnd } from '../ListDrawer/DrawerContent.js' +import { RelationshipProvider } from '../Table/RelationshipProvider/index.js' +import { TableColumnsProvider } from '../TableColumns/index.js' +import { DrawerLink } from './cells/DrawerLink/index.js' +import './index.scss' +import { RelationshipTableWrapper } from './TableWrapper.js' + +const baseClass = 'relationship-table' + +type RelationshipTableComponentProps = { + readonly field: JoinFieldClient + readonly filterOptions?: boolean | Where + readonly initialData?: PaginatedDocs + readonly Label?: React.ReactNode + readonly relationTo: string +} + +export const RelationshipTable: React.FC = (props) => { + const { field, filterOptions, initialData: initialDataFromProps, Label, relationTo } = props + + const { + config: { + routes: { api }, + serverURL, + }, + getEntityConfig, + } = useConfig() + + const { id: docID } = useDocumentInfo() + + const [initialData, setInitialData] = useState(initialDataFromProps) + + const { i18n, t } = useTranslation() + + const [limit, setLimit] = useState() + const [sort, setSort] = useState(undefined) + const [page, setPage] = useState(1) + const [where, setWhere] = useState(null) + const [search, setSearch] = useState('') + const [openColumnSelector, setOpenColumnSelector] = useState(false) + + const [collectionConfig] = useState( + () => getEntityConfig({ collectionSlug: relationTo }) as ClientCollectionConfig, + ) + + const apiURL = `${serverURL}${api}/${collectionConfig.slug}` + + const [{ data }, { setParams }] = usePayloadAPI(apiURL, { + initialData, + initialParams: { + depth: 0, + }, + }) + + useEffect(() => { + const { + admin: { listSearchableFields, useAsTitle } = {} as ClientCollectionConfig['admin'], + versions, + } = collectionConfig + + const params: { + cacheBust?: number + depth?: number + draft?: string + limit?: number + page?: number + search?: string + sort?: string + where?: unknown + } = { + depth: 0, + } + + let copyOfWhere = { ...(where || {}) } + + if (filterOptions && typeof filterOptions !== 'boolean') { + copyOfWhere = hoistQueryParamsToAnd(copyOfWhere, filterOptions) + } + + if (search) { + const searchAsConditions = (listSearchableFields || [useAsTitle]).map((fieldName) => { + return { + [fieldName]: { + like: search, + }, + } + }, []) + + if (searchAsConditions.length > 0) { + const searchFilter: Where = { + or: [...searchAsConditions], + } + + copyOfWhere = hoistQueryParamsToAnd(copyOfWhere, searchFilter) + } + } + + if (limit) { + params.limit = limit + } + if (page) { + params.page = page + } + if (sort) { + params.sort = sort + } + if (copyOfWhere) { + params.where = copyOfWhere + } + if (versions?.drafts) { + params.draft = 'true' + } + + setParams(params) + }, [page, sort, where, search, collectionConfig, filterOptions, initialData, limit, setParams]) + + const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({ + collectionSlug: relationTo, + }) + + const onDrawerSave = useCallback( + (args) => { + const foundDocIndex = data?.docs?.findIndex((doc) => doc.id === args.doc.id) + + if (foundDocIndex !== -1) { + const newDocs = [...data.docs] + newDocs[foundDocIndex] = args.doc + setInitialData({ + ...data, + docs: newDocs, + }) + } else { + setInitialData({ + ...data, + docs: [args.doc, ...data.docs], + }) + } + }, + [data], + ) + + const onDrawerCreate = useCallback( + (args) => { + closeDrawer() + void onDrawerSave(args) + }, + [closeDrawer, onDrawerSave], + ) + + const preferenceKey = `${relationTo}-list` + + return ( +
+
+ {Label} +
+ {i18n.t('fields:addNew')} + } + onClick={() => setOpenColumnSelector(!openColumnSelector)} + pillStyle="light" + > + {t('general:columns')} + +
+
+ + + {getTranslation(collectionConfig.labels.singular, i18n)} + ), + }, + Label: null, + }, + }, + } as ClientField, + }, + Heading: i18n.t('version:type'), + }, + ]} + cellProps={[ + {}, + { + field: { + admin: { + components: { + Cell: { + type: 'client', + RenderedComponent: , + }, + }, + }, + } as ClientField, + link: false, + }, + ]} + collectionSlug={relationTo} + preferenceKey={preferenceKey} + sortColumnProps={{ + appearance: 'condensed', + }} + > + {/* @ts-expect-error TODO: get this CJS import to work, eslint keeps removing the type assertion */} + +
+ +
+
+ +
+
+
+ +
+ ) +} diff --git a/packages/ui/src/elements/SortColumn/index.scss b/packages/ui/src/elements/SortColumn/index.scss index 1f3bc784bbb..e3b65b4263e 100644 --- a/packages/ui/src/elements/SortColumn/index.scss +++ b/packages/ui/src/elements/SortColumn/index.scss @@ -2,7 +2,7 @@ .sort-column { display: flex; - gap: calc(var(--base) / 4); + gap: calc(var(--base) / 2); align-items: center; &__label { @@ -24,6 +24,7 @@ &__buttons { display: flex; align-items: center; + gap: calc(var(--base) / 4); } &__button { @@ -54,4 +55,12 @@ visibility: visible; } } + + &--appearance-condensed { + gap: calc(var(--base) / 4); + + .sort-column__buttons { + gap: 0; + } + } } diff --git a/packages/ui/src/elements/SortColumn/index.tsx b/packages/ui/src/elements/SortColumn/index.tsx index 3702d52b88e..9a5e294e5e8 100644 --- a/packages/ui/src/elements/SortColumn/index.tsx +++ b/packages/ui/src/elements/SortColumn/index.tsx @@ -9,6 +9,7 @@ import { useTranslation } from '../../providers/Translation/index.js' import './index.scss' export type SortColumnProps = { + readonly appearance?: 'condensed' | 'default' readonly disable?: boolean readonly Label: React.ReactNode readonly label?: FieldBase['label'] @@ -18,7 +19,7 @@ export type SortColumnProps = { const baseClass = 'sort-column' export const SortColumn: React.FC = (props) => { - const { name, disable = false, Label, label } = props + const { name, appearance, disable = false, Label, label } = props const { handleSortChange, params } = useListQuery() const { t } = useTranslation() @@ -38,7 +39,11 @@ export const SortColumn: React.FC = (props) => { } return ( -
+
{Label} {!disable && (
diff --git a/packages/ui/src/elements/Table/DefaultCell/fields/Relationship/index.scss b/packages/ui/src/elements/Table/DefaultCell/fields/Relationship/index.scss index ebdd21e3165..e69de29bb2d 100644 --- a/packages/ui/src/elements/Table/DefaultCell/fields/Relationship/index.scss +++ b/packages/ui/src/elements/Table/DefaultCell/fields/Relationship/index.scss @@ -1,3 +0,0 @@ -.relationship-cell { - min-width: 250px; -} diff --git a/packages/ui/src/elements/Table/DefaultCell/fields/Relationship/index.tsx b/packages/ui/src/elements/Table/DefaultCell/fields/Relationship/index.tsx index 44967e7df1f..f8dfdca3da6 100644 --- a/packages/ui/src/elements/Table/DefaultCell/fields/Relationship/index.tsx +++ b/packages/ui/src/elements/Table/DefaultCell/fields/Relationship/index.tsx @@ -1,8 +1,13 @@ 'use client' -import type { DefaultCellComponentProps, RelationshipFieldClient, UploadFieldClient } from 'payload' +import type { + DefaultCellComponentProps, + JoinFieldClient, + RelationshipFieldClient, + UploadFieldClient, +} from 'payload' import { getTranslation } from '@payloadcms/translations' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useIntersect } from '../../../../../hooks/useIntersect.js' import { useConfig } from '../../../../../providers/Config/index.js' @@ -17,15 +22,26 @@ type Value = { relationTo: string; value: number | string } const baseClass = 'relationship-cell' const totalToShow = 3 -export interface RelationshipCellProps - extends DefaultCellComponentProps {} +export type RelationshipCellProps = DefaultCellComponentProps< + any, + JoinFieldClient | RelationshipFieldClient | UploadFieldClient +> export const RelationshipCell: React.FC = ({ - cellData, + cellData: cellDataFromProps, customCellContext, field, - field: { label, relationTo }, + field: { label }, }) => { + // conditionally extract relationTo both both relationship and join fields + const relationTo = + ('relationTo' in field && field.relationTo) || ('collection' in field && field.collection) + + // conditionally extract docs from join fields + const cellData = useMemo(() => { + return 'collection' in field ? cellDataFromProps?.docs : cellDataFromProps + }, [cellDataFromProps, field]) + const { config } = useConfig() const { collections, routes } = config const [intersectionRef, entry] = useIntersect() diff --git a/packages/ui/src/elements/Table/DefaultCell/fields/index.tsx b/packages/ui/src/elements/Table/DefaultCell/fields/index.tsx index e2ab85a3b54..b06973adadb 100644 --- a/packages/ui/src/elements/Table/DefaultCell/fields/index.tsx +++ b/packages/ui/src/elements/Table/DefaultCell/fields/index.tsx @@ -17,6 +17,7 @@ export const cellComponents = { code: CodeCell, date: DateCell, File: FileCell, + join: RelationshipCell, json: JSONCell, radio: SelectCell, relationship: RelationshipCell, diff --git a/packages/ui/src/elements/Table/RenderCell.tsx b/packages/ui/src/elements/Table/RenderCell.tsx new file mode 100644 index 00000000000..6fd10afe2e4 --- /dev/null +++ b/packages/ui/src/elements/Table/RenderCell.tsx @@ -0,0 +1,41 @@ +import type { CellComponentProps } from 'payload' + +import React, { useMemo } from 'react' + +import type { Column } from './index.js' + +import { RenderComponent } from '../../providers/Config/RenderComponent.js' +import { deepMergeSimple } from '../../utilities/deepMerge.js' +import { TableCellProvider } from './TableCellProvider/index.js' + +export const RenderCell: React.FC<{ + readonly cellProps?: Partial + readonly col: Column + readonly colIndex: number + readonly customCellContext?: Record + readonly row: Record +}> = (props) => { + const { cellProps: cellPropsFromProps, col, colIndex, customCellContext, row } = props + + const cellProps: Partial = useMemo( + () => deepMergeSimple(col?.cellProps || {}, cellPropsFromProps || {}), + [cellPropsFromProps, col.cellProps], + ) + + return ( +
+ ) +} diff --git a/packages/ui/src/elements/Table/index.scss b/packages/ui/src/elements/Table/index.scss index 45add9832b6..aeed1b9108b 100644 --- a/packages/ui/src/elements/Table/index.scss +++ b/packages/ui/src/elements/Table/index.scss @@ -5,6 +5,10 @@ overflow: auto; max-width: 100%; + table { + min-width: 100%; + } + thead { color: var(--theme-elevation-400); @@ -45,6 +49,51 @@ outline-offset: var(--accessibility-outline-offset); } + &--appearance-condensed { + thead { + th:first-child { + border-top-left-radius: $style-radius-s; + } + + th:last-child { + border-top-right-radius: $style-radius-s; + } + + background: var(--theme-elevation-50); + } + + tbody { + tr { + &:nth-child(odd) { + background: transparent; + border-radius: 0; + } + } + } + + th, + td { + padding: base(0.3) base(0.3); + + &:first-child { + padding-inline-start: base(0.6); + } + + &:last-child { + padding-inline-end: base(0.6); + } + } + + th { + padding: base(0.3); + } + + tr td, + th { + border: 0.5px solid var(--theme-elevation-100); + } + } + @include mid-break { th, td { diff --git a/packages/ui/src/elements/Table/index.tsx b/packages/ui/src/elements/Table/index.tsx index be8d769a9bd..0b30a1dfa64 100644 --- a/packages/ui/src/elements/Table/index.tsx +++ b/packages/ui/src/elements/Table/index.tsx @@ -1,13 +1,13 @@ 'use client' -import type { CellComponentProps, ClientField, MappedComponent } from 'payload' +import type { CellComponentProps, ClientField } from 'payload' import React from 'react' export * from './TableCellProvider/index.js' -import { RenderComponent } from '../../providers/Config/RenderComponent.js' import { useTableColumns } from '../TableColumns/index.js' import './index.scss' +import { RenderCell } from './RenderCell.js' import { TableCellProvider } from './TableCellProvider/index.js' export { TableCellProvider } @@ -18,22 +18,24 @@ export type Column = { readonly accessor: string readonly active: boolean readonly cellProps?: Partial - readonly components: { - Cell: MappedComponent - Heading: React.ReactNode - } - readonly Label: React.ReactNode + readonly Heading: React.ReactNode } export type Props = { + readonly appearance?: 'condensed' | 'default' readonly columns?: Column[] readonly customCellContext?: Record readonly data: Record[] readonly fields: ClientField[] } -export const Table: React.FC = ({ columns: columnsFromProps, customCellContext, data }) => { - const { columns: columnsFromContext } = useTableColumns() +export const Table: React.FC = ({ + appearance, + columns: columnsFromProps, + customCellContext, + data, +}) => { + const { cellProps, columns: columnsFromContext } = useTableColumns() const columns = columnsFromProps || columnsFromContext @@ -44,13 +46,17 @@ export const Table: React.FC = ({ columns: columnsFromProps, customCellCo } return ( -
+
+ + + +
{activeColumns.map((col, i) => ( ))} @@ -64,26 +70,18 @@ export const Table: React.FC = ({ columns: columnsFromProps, customCellCo (colIndex === 0 && col.accessor !== '_select') || (colIndex === 1 && activeColumns[0]?.accessor === '_select') - const cellProps = { - link: isLink, - ...(col.cellProps || {}), - } - return ( - + ) })} diff --git a/packages/ui/src/elements/TableColumns/buildColumnState.tsx b/packages/ui/src/elements/TableColumns/buildColumnState.tsx index 36be475ebfe..cdbad6194a2 100644 --- a/packages/ui/src/elements/TableColumns/buildColumnState.tsx +++ b/packages/ui/src/elements/TableColumns/buildColumnState.tsx @@ -1,14 +1,10 @@ 'use client' -import type { - CellComponentProps, - ClientField, - SanitizedCollectionConfig, - StaticLabel, -} from 'payload' +import type { ClientField, SanitizedCollectionConfig, StaticLabel } from 'payload' import React from 'react' import type { ColumnPreferences } from '../../providers/ListInfo/index.js' +import type { SortColumnProps } from '../SortColumn/index.js' import type { Column } from '../Table/index.js' import { FieldLabel } from '../../fields/FieldLabel/index.js' @@ -19,16 +15,26 @@ import { SortColumn } from '../SortColumn/index.js' import { DefaultCell } from '../Table/DefaultCell/index.js' type Args = { - cellProps: Partial[] + beforeRows?: Column[] columnPreferences: ColumnPreferences columns?: ColumnPreferences enableRowSelections: boolean + enableRowTypes?: boolean fields: ClientField[] + sortColumnProps?: Partial useAsTitle: SanitizedCollectionConfig['admin']['useAsTitle'] } export const buildColumnState = (args: Args): Column[] => { - const { cellProps, columnPreferences, columns, enableRowSelections, fields, useAsTitle } = args + const { + beforeRows, + columnPreferences, + columns, + enableRowSelections, + fields, + sortColumnProps, + useAsTitle, + } = args let sortedFieldMap = flattenFieldMap(fields) @@ -114,6 +120,7 @@ export const buildColumnState = (args: Args): Column[] => { Label={Label} label={'label' in field ? (field.label as StaticLabel) : undefined} name={'name' in field ? field.name : undefined} + {...(sortColumnProps || {})} /> ) @@ -124,19 +131,21 @@ export const buildColumnState = (args: Args): Column[] => { cellProps: { field: { ...(field || ({} as ClientField)), - ...(cellProps?.[index]?.field || ({} as ClientField)), + admin: { + ...(field.admin || {}), + components: { + ...(field.admin?.components || {}), + Cell: field.admin?.components?.Cell || { + type: 'client', + Component: DefaultCell, + RenderedComponent: null, + }, + Label, + }, + }, } as ClientField, - ...cellProps?.[index], }, - components: { - Cell: field.admin?.components?.Cell || { - type: 'client', - Component: DefaultCell, - RenderedComponent: null, - }, - Heading, - }, - Label, + Heading, } acc.push(column) @@ -149,17 +158,27 @@ export const buildColumnState = (args: Args): Column[] => { sorted.unshift({ accessor: '_select', active: true, - components: { - Cell: { - type: 'client', - Component: null, - RenderedComponent: , - }, - Heading: , + cellProps: { + field: { + admin: { + components: { + Cell: { + type: 'client', + Component: null, + RenderedComponent: , + }, + Label: null, + }, + }, + } as ClientField, }, - Label: null, + Heading: , }) } + 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 c8a65944b2d..930a2950996 100644 --- a/packages/ui/src/elements/TableColumns/index.tsx +++ b/packages/ui/src/elements/TableColumns/index.tsx @@ -4,6 +4,7 @@ import type { CellComponentProps, SanitizedCollectionConfig } from 'payload' import React, { createContext, useCallback, useContext, useState } from 'react' import type { ColumnPreferences } from '../../providers/ListInfo/index.js' +import type { SortColumnProps } from '../SortColumn/index.js' import type { Column } from '../Table/index.js' import { useConfig } from '../../providers/Config/index.js' @@ -13,6 +14,7 @@ import { filterFields } from './filterFields.js' import { getInitialColumns } from './getInitialColumns.js' export interface ITableColumns { + cellProps?: Partial[] columns: Column[] moveColumn: (args: { fromIndex: number; toIndex: number }) => void setActiveColumns: (columns: string[]) => void @@ -28,29 +30,31 @@ export type ListPreferences = { } type Props = { + readonly beforeRows?: Column[] readonly cellProps?: Partial[] readonly children: React.ReactNode readonly collectionSlug: string readonly enableRowSelections?: boolean readonly listPreferences?: ListPreferences readonly preferenceKey: string + readonly sortColumnProps?: Partial } export const TableColumnsProvider: React.FC = ({ + beforeRows, cellProps, children, collectionSlug, enableRowSelections = false, listPreferences, preferenceKey, + sortColumnProps, }) => { const { config: { collections }, } = useConfig() - const collectionConfig = collections.find( - (collectionConfig) => collectionConfig.slug === collectionSlug, - ) + const collectionConfig = collections.find((c) => c.slug === collectionSlug) const { admin: { defaultColumns, useAsTitle }, @@ -66,11 +70,12 @@ export const TableColumnsProvider: React.FC = ({ const [tableColumns, setTableColumns] = React.useState(() => buildColumnState({ - cellProps, + beforeRows, columnPreferences: listPreferences?.columns, columns: initialColumns, enableRowSelections, fields, + sortColumnProps, useAsTitle, }), ) @@ -90,11 +95,9 @@ export const TableColumnsProvider: React.FC = ({ const moveColumn = useCallback( (args: { fromIndex: number; toIndex: number }) => { const { fromIndex, toIndex } = args - const withMovedColumn = [...tableColumns] const [columnToMove] = withMovedColumn.splice(fromIndex, 1) withMovedColumn.splice(toIndex, 0, columnToMove) - setTableColumns(withMovedColumn) updateColumnPreferences(withMovedColumn) }, @@ -152,11 +155,12 @@ export const TableColumnsProvider: React.FC = ({ if (currentPreferences?.columns) { setTableColumns( buildColumnState({ - cellProps, + beforeRows, columnPreferences: currentPreferences?.columns, columns: initialColumns, - enableRowSelections: true, + enableRowSelections, fields, + sortColumnProps, useAsTitle, }), ) @@ -170,16 +174,19 @@ export const TableColumnsProvider: React.FC = ({ getPreference, collectionSlug, fields, - cellProps, defaultColumns, useAsTitle, listPreferences, initialColumns, + beforeRows, + enableRowSelections, + sortColumnProps, ]) return ( { + const { + field, + field: { + name, + _path: pathFromProps, + admin: { + components: { Label }, + }, + collection, + label, + on, + }, + } = props + + const { id: docID } = useDocumentInfo() + + const { path: pathFromContext, readOnly: readOnlyFromContext } = useFieldProps() + + const { path, value } = useField({ + path: pathFromContext ?? pathFromProps ?? name, + }) + + const filterOptions: Where = useMemo( + () => ({ + [on]: { + in: [docID || null], + }, + }), + [docID, on], + ) + + return ( +
+ + + + } + relationTo={collection} + /> +
+ ) +} + +export const JoinField = withCondition(JoinFieldComponent) diff --git a/packages/ui/src/fields/Relationship/select-components/SingleValue/index.tsx b/packages/ui/src/fields/Relationship/select-components/SingleValue/index.tsx index 87c4a5b4776..a1a300f6358 100644 --- a/packages/ui/src/fields/Relationship/select-components/SingleValue/index.tsx +++ b/packages/ui/src/fields/Relationship/select-components/SingleValue/index.tsx @@ -35,45 +35,42 @@ export const SingleValue: React.FC< const hasReadPermission = Boolean(permissions?.collections?.[relationTo]?.read?.permission) return ( - - -
-
-
{children}
- {relationTo && hasReadPermission && ( - - - - )} -
+ +
+
+
{children}
+ {relationTo && hasReadPermission && ( + + + + )}
- - +
+
) } diff --git a/packages/ui/src/fields/index.tsx b/packages/ui/src/fields/index.tsx index 990cb009425..a1147f375e5 100644 --- a/packages/ui/src/fields/index.tsx +++ b/packages/ui/src/fields/index.tsx @@ -12,6 +12,7 @@ import { DateTimeField } from './DateTime/index.js' import { EmailField } from './Email/index.js' import { GroupField } from './Group/index.js' import { HiddenField } from './Hidden/index.js' +import { JoinField } from './Join/index.js' import { JSONField } from './JSON/index.js' import { NumberField } from './Number/index.js' import { PasswordField } from './Password/index.js' @@ -44,6 +45,7 @@ export const fieldComponents: FieldTypesComponents = { email: EmailField, group: GroupField, hidden: HiddenField, + join: JoinField, json: JSONField, number: NumberField, password: PasswordField, diff --git a/packages/ui/src/hooks/usePayloadAPI.ts b/packages/ui/src/hooks/usePayloadAPI.ts index ee81c88a2cd..6677289a07f 100644 --- a/packages/ui/src/hooks/usePayloadAPI.ts +++ b/packages/ui/src/hooks/usePayloadAPI.ts @@ -73,6 +73,7 @@ export const usePayloadAPI: UsePayloadAPI = (url, options = {}) => { } const json = await response.json() + setData(json) setIsLoading(false) } catch (error) { diff --git a/packages/ui/src/icons/Chevron/index.scss b/packages/ui/src/icons/Chevron/index.scss index d57b6d72a18..a826dd4e82b 100644 --- a/packages/ui/src/icons/Chevron/index.scss +++ b/packages/ui/src/icons/Chevron/index.scss @@ -1,6 +1,9 @@ @import '../../scss/styles'; .icon--chevron { + height: calc(var(--base) / 2); + width: calc(var(--base) / 2); + .stroke { fill: none; stroke: currentColor; @@ -8,8 +11,13 @@ vector-effect: non-scaling-stroke; } - &--size-large { + &.icon--size-large { height: var(--base); width: var(--base); } + + &.icon--size-small { + height: 8px; + width: 8px; + } } diff --git a/packages/ui/src/icons/Chevron/index.tsx b/packages/ui/src/icons/Chevron/index.tsx index 693f4152516..eb102cabf9f 100644 --- a/packages/ui/src/icons/Chevron/index.tsx +++ b/packages/ui/src/icons/Chevron/index.tsx @@ -3,15 +3,15 @@ import React from 'react' import './index.scss' export const ChevronIcon: React.FC<{ - className?: string - direction?: 'down' | 'left' | 'right' | 'up' - size?: 'large' | 'small' + readonly className?: string + readonly direction?: 'down' | 'left' | 'right' | 'up' + readonly size?: 'large' | 'small' }> = ({ className, direction, size }) => ( - + ) diff --git a/packages/ui/src/providers/Config/createClientConfig/collections.tsx b/packages/ui/src/providers/Config/createClientConfig/collections.tsx index 97b47d7141b..5afd1dfa178 100644 --- a/packages/ui/src/providers/Config/createClientConfig/collections.tsx +++ b/packages/ui/src/providers/Config/createClientConfig/collections.tsx @@ -24,6 +24,7 @@ const serverOnlyCollectionProperties: Partial[] 'access', 'endpoints', 'custom', + 'joins', // `upload` // `admin` // are all handled separately diff --git a/packages/ui/src/providers/Config/createClientConfig/fields.tsx b/packages/ui/src/providers/Config/createClientConfig/fields.tsx index 52318ab6174..3d81051dcfb 100644 --- a/packages/ui/src/providers/Config/createClientConfig/fields.tsx +++ b/packages/ui/src/providers/Config/createClientConfig/fields.tsx @@ -320,6 +320,10 @@ export const createClientField = ({ break } + // case 'joins': { + // + // } + case 'select': case 'radio': { const field = clientField as RadioFieldClient | SelectFieldClient diff --git a/packages/ui/src/providers/DocumentInfo/index.tsx b/packages/ui/src/providers/DocumentInfo/index.tsx index 716dca54abe..ea7f4f91687 100644 --- a/packages/ui/src/providers/DocumentInfo/index.tsx +++ b/packages/ui/src/providers/DocumentInfo/index.tsx @@ -39,7 +39,7 @@ export const useDocumentInfo = (): DocumentInfoContext => useContext(Context) const DocumentInfo: React.FC< { - children: React.ReactNode + readonly children: React.ReactNode } & DocumentInfoProps > = ({ children, ...props }) => { const { diff --git a/packages/ui/src/providers/DocumentInfo/types.ts b/packages/ui/src/providers/DocumentInfo/types.ts index 6e152a4daec..f842759cc26 100644 --- a/packages/ui/src/providers/DocumentInfo/types.ts +++ b/packages/ui/src/providers/DocumentInfo/types.ts @@ -17,43 +17,43 @@ import type { import type React from 'react' export type DocumentInfoProps = { - action?: string - AfterDocument?: React.ReactNode - AfterFields?: React.ReactNode - apiURL?: string - BeforeDocument?: React.ReactNode - BeforeFields?: React.ReactNode - collectionSlug?: SanitizedCollectionConfig['slug'] - disableActions?: boolean - disableCreate?: boolean - disableLeaveWithoutSaving?: boolean - docPermissions?: DocumentPermissions - globalSlug?: SanitizedGlobalConfig['slug'] - hasPublishPermission?: boolean - hasSavePermission?: boolean - id: null | number | string - initialData?: Data - initialState?: FormState - isEditing?: boolean - onDelete?: (args: { + readonly action?: string + readonly AfterDocument?: React.ReactNode + readonly AfterFields?: React.ReactNode + readonly apiURL?: string + readonly BeforeDocument?: React.ReactNode + readonly BeforeFields?: React.ReactNode + readonly collectionSlug?: SanitizedCollectionConfig['slug'] + readonly disableActions?: boolean + readonly disableCreate?: boolean + readonly disableLeaveWithoutSaving?: boolean + readonly docPermissions?: DocumentPermissions + readonly globalSlug?: SanitizedGlobalConfig['slug'] + readonly hasPublishPermission?: boolean + readonly hasSavePermission?: boolean + readonly id: null | number | string + readonly initialData?: Data + readonly initialState?: FormState + readonly isEditing?: boolean + readonly onDelete?: (args: { collectionConfig?: ClientCollectionConfig id: string }) => Promise | void - onDrawerCreate?: () => void + readonly onDrawerCreate?: () => void /* only available if `redirectAfterDuplicate` is `false` */ - onDuplicate?: (args: { + readonly onDuplicate?: (args: { collectionConfig?: ClientCollectionConfig doc: TypeWithID }) => Promise | void - onLoadError?: (data?: any) => Promise | void - onSave?: (args: { + readonly onLoadError?: (data?: any) => Promise | void + readonly onSave?: (args: { collectionConfig?: ClientCollectionConfig doc: TypeWithID operation: 'create' | 'update' result: Data }) => Promise | void - redirectAfterDelete?: boolean - redirectAfterDuplicate?: boolean + readonly redirectAfterDelete?: boolean + readonly redirectAfterDuplicate?: boolean } export type DocumentInfoContext = { diff --git a/packages/ui/src/providers/ListQuery/index.tsx b/packages/ui/src/providers/ListQuery/index.tsx index 224f96dbbb4..86ab04cce91 100644 --- a/packages/ui/src/providers/ListQuery/index.tsx +++ b/packages/ui/src/providers/ListQuery/index.tsx @@ -14,11 +14,11 @@ import { useSearchParams } from '../SearchParams/index.js' export type ColumnPreferences = Pick[] type PropHandlers = { - handlePageChange?: (page: number) => Promise | void - handlePerPageChange?: (limit: number) => Promise | void - handleSearchChange?: (search: string) => Promise | void - handleSortChange?: (sort: string) => Promise | void - handleWhereChange?: (where: Where) => Promise | void + readonly handlePageChange?: (page: number) => Promise | void + readonly handlePerPageChange?: (limit: number) => Promise | void + readonly handleSearchChange?: (search: string) => Promise | void + readonly handleSortChange?: (sort: string) => Promise | void + readonly handleWhereChange?: (where: Where) => Promise | void } type ContextHandlers = { diff --git a/packages/ui/src/utilities/deepMerge.ts b/packages/ui/src/utilities/deepMerge.ts new file mode 100644 index 00000000000..6de2acfdbd4 --- /dev/null +++ b/packages/ui/src/utilities/deepMerge.ts @@ -0,0 +1,25 @@ +/** + * Very simple, but fast deepMerge implementation. Only deepMerges objects, not arrays and clones everything. + * Do not use this if your object contains any complex objects like React Components, or if you would like to combine Arrays. + * If you only have simple objects and need a fast deepMerge, this is the function for you. + * + * obj2 takes precedence over obj1 - thus if obj2 has a key that obj1 also has, obj2's value will be used. + * + * @param obj1 base object + * @param obj2 object to merge "into" obj1 + */ +export function deepMergeSimple(obj1: object, obj2: object): T { + const output = { ...obj1 } + + for (const key in obj2) { + if (Object.prototype.hasOwnProperty.call(obj2, key)) { + if (typeof obj2[key] === 'object' && !Array.isArray(obj2[key]) && obj1[key]) { + output[key] = deepMergeSimple(obj1[key], obj2[key]) + } else { + output[key] = obj2[key] + } + } + } + + return output as T +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6df2ddc36cb..f948b37c695 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: mongoose: specifier: 6.12.3 version: 6.12.3(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0)) + mongoose-aggregate-paginate-v2: + specifier: 1.0.6 + version: 1.0.6 mongoose-paginate-v2: specifier: 1.7.22 version: 1.7.22 @@ -282,8 +285,8 @@ importers: specifier: workspace:* version: link:../eslint-config '@types/mongoose-aggregate-paginate-v2': - specifier: 1.0.9 - version: 1.0.9(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0)) + specifier: 1.0.6 + version: 1.0.6(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0)) mongodb: specifier: 4.17.1 version: 4.17.1(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0)) @@ -1060,19 +1063,6 @@ importers: specifier: workspace:* version: link:../payload - packages/plugin-relationship-object-ids: - dependencies: - mongoose: - specifier: 6.12.3 - version: 6.12.3(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0)) - devDependencies: - '@payloadcms/eslint-config': - specifier: workspace:* - version: link:../eslint-config - payload: - specifier: workspace:* - version: link:../payload - packages/plugin-search: dependencies: '@payloadcms/ui': @@ -1712,9 +1702,6 @@ importers: '@payloadcms/plugin-redirects': specifier: workspace:* version: link:../packages/plugin-redirects - '@payloadcms/plugin-relationship-object-ids': - specifier: workspace:* - version: link:../packages/plugin-relationship-object-ids '@payloadcms/plugin-search': specifier: workspace:* version: link:../packages/plugin-search @@ -4449,8 +4436,8 @@ packages: '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - '@types/mongoose-aggregate-paginate-v2@1.0.9': - resolution: {integrity: sha512-YKDKtSuE1vzMY/SAtlDTWJr52UhTYdrOypCqyx7T2xFYEWfybLnV98m4ZoVgYJH0XowVl7Y2Gnn6p1sF+3NbLA==} + '@types/mongoose-aggregate-paginate-v2@1.0.6': + resolution: {integrity: sha512-EXkgB/nJ1x3UcoEk1pD67+uXtijveHZtbg2H3wtZk2SnCFBB5cMw7MQRu9/GgyEP/KKXuWFt1JABv7m+Kls0ug==} '@types/node@20.12.5': resolution: {integrity: sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==} @@ -7459,6 +7446,10 @@ packages: snappy: optional: true + mongoose-aggregate-paginate-v2@1.0.6: + resolution: {integrity: sha512-UuALu+mjhQa1K9lMQvjLL3vm3iALvNw8PQNIh2gp1b+tO5hUa0NC0Wf6/8QrT9PSJVTihXaD8hQVy3J4e0jO0Q==} + engines: {node: '>=4.0.0'} + mongoose-paginate-v2@1.7.22: resolution: {integrity: sha512-xW5GugkE21DJiu9e13EOxKt4ejEKQkRP/S1PkkXRjnk2rRZVKBcld1nPV+VJ/YCPfm8hb3sz9OvI7O38RmixkA==} engines: {node: '>=4.0.0'} @@ -13075,7 +13066,7 @@ snapshots: '@types/minimist@1.2.5': {} - '@types/mongoose-aggregate-paginate-v2@1.0.9(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0))': + '@types/mongoose-aggregate-paginate-v2@1.0.6(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0))': dependencies: mongoose: 6.12.3(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0)) transitivePeerDependencies: @@ -16719,6 +16710,8 @@ snapshots: '@aws-sdk/credential-providers': 3.630.0(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0)) '@mongodb-js/saslprep': 1.1.8 + mongoose-aggregate-paginate-v2@1.0.6: {} + mongoose-paginate-v2@1.7.22: {} mongoose@6.12.3(@aws-sdk/client-sso-oidc@3.629.0(@aws-sdk/client-sts@3.629.0)): diff --git a/scripts/lib/publishList.ts b/scripts/lib/publishList.ts index 426dd033b7b..0c8c977a84f 100644 --- a/scripts/lib/publishList.ts +++ b/scripts/lib/publishList.ts @@ -44,7 +44,6 @@ export const packagePublishList = [ 'plugin-search', 'plugin-seo', 'plugin-stripe', - 'plugin-relationship-object-ids', // Unpublished // 'plugin-sentry' diff --git a/test/config/collections/Joins/index.ts b/test/config/collections/Joins/index.ts new file mode 100644 index 00000000000..e21d8fd023a --- /dev/null +++ b/test/config/collections/Joins/index.ts @@ -0,0 +1,18 @@ +import type { CollectionConfig } from 'payload' + +export const postsSlug = 'joins' +export const PostsCollection: CollectionConfig = { + slug: postsSlug, + admin: { + useAsTitle: 'text', + }, + fields: [ + { + name: 'text', + type: 'text', + }, + ], + versions: { + drafts: true, + }, +} diff --git a/test/databaseAdapter.ts b/test/databaseAdapter.ts index a36c0d0c13e..f4910a3dc7b 100644 --- a/test/databaseAdapter.ts +++ b/test/databaseAdapter.ts @@ -1,16 +1,13 @@ +// DO NOT MODIFY. This file is automatically generated in initDevAndTest.ts - // DO NOT MODIFY. This file is automatically generated in initDevAndTest.ts +import { mongooseAdapter } from '@payloadcms/db-mongodb' - - import { mongooseAdapter } from '@payloadcms/db-mongodb' - - export const databaseAdapter = mongooseAdapter({ - url: - process.env.MONGODB_MEMORY_SERVER_URI || - process.env.DATABASE_URI || - 'mongodb://127.0.0.1/payloadtests', - collation: { - strength: 1, - }, - }) - \ No newline at end of file +export const databaseAdapter = mongooseAdapter({ + url: + process.env.MONGODB_MEMORY_SERVER_URI || + process.env.DATABASE_URI || + 'mongodb://127.0.0.1/payloadtests', + collation: { + strength: 1, + }, +}) diff --git a/test/helpers.ts b/test/helpers.ts index ae5e630823f..3bd6fbbd84a 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -198,7 +198,9 @@ export async function openNav(page: Page): Promise { // check to see if the nav is already open and if not, open it // use the `--nav-open` modifier class to check if the nav is open // this will prevent clicking nav links that are bleeding off the screen - if (await page.locator('.template-default.template-default--nav-open').isVisible()) {return} + if (await page.locator('.template-default.template-default--nav-open').isVisible()) { + return + } // playwright: get first element with .nav-toggler which is VISIBLE (not hidden), could be 2 elements with .nav-toggler on mobile and desktop but only one is visible await page.locator('.nav-toggler >> visible=true').click() await expect(page.locator('.template-default.template-default--nav-open')).toBeVisible() @@ -221,7 +223,9 @@ export async function openCreateDocDrawer(page: Page, fieldSelector: string): Pr } export async function closeNav(page: Page): Promise { - if (!(await page.locator('.template-default.template-default--nav-open').isVisible())) {return} + if (!(await page.locator('.template-default.template-default--nav-open').isVisible())) { + return + } await page.locator('.nav-toggler >> visible=true').click() await expect(page.locator('.template-default.template-default--nav-open')).toBeHidden() } diff --git a/test/helpers/NextRESTClient.ts b/test/helpers/NextRESTClient.ts index c885e8b20c7..a7922cfc931 100644 --- a/test/helpers/NextRESTClient.ts +++ b/test/helpers/NextRESTClient.ts @@ -1,4 +1,4 @@ -import type { SanitizedConfig, Where } from 'payload' +import type { JoinQuery, SanitizedConfig, Where } from 'payload' import type { ParsedQs } from 'qs-esm' import { @@ -18,6 +18,7 @@ type RequestOptions = { query?: { depth?: number fallbackLocale?: string + joins?: JoinQuery limit?: number locale?: string page?: number @@ -61,7 +62,7 @@ export class NextRESTClient { constructor(config: SanitizedConfig) { this.config = config - if (config?.serverURL) this.serverURL = config.serverURL + if (config?.serverURL) {this.serverURL = config.serverURL} this._GET = createGET(config) this._POST = createPOST(config) this._DELETE = createDELETE(config) @@ -150,33 +151,6 @@ export class NextRESTClient { return this._GRAPHQL_POST(request) } - async PATCH(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise { - const { slug, params, url } = this.generateRequestParts(path) - const { query, ...rest } = options - const queryParams = generateQueryString(query, params) - - const request = new Request(`${url}${queryParams}`, { - ...rest, - headers: this.buildHeaders(options), - method: 'PATCH', - }) - return this._PATCH(request, { params: { slug } }) - } - - async POST( - path: ValidPath, - options: FileArg & RequestInit & RequestOptions = {}, - ): Promise { - const { slug, params, url } = this.generateRequestParts(path) - const queryParams = generateQueryString({}, params) - const request = new Request(`${url}${queryParams}`, { - ...options, - headers: this.buildHeaders(options), - method: 'POST', - }) - return this._POST(request, { params: { slug } }) - } - async login({ slug, credentials, @@ -205,4 +179,31 @@ export class NextRESTClient { return result } + + async PATCH(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise { + const { slug, params, url } = this.generateRequestParts(path) + const { query, ...rest } = options + const queryParams = generateQueryString(query, params) + + const request = new Request(`${url}${queryParams}`, { + ...rest, + headers: this.buildHeaders(options), + method: 'PATCH', + }) + return this._PATCH(request, { params: { slug } }) + } + + async POST( + path: ValidPath, + options: FileArg & RequestInit & RequestOptions = {}, + ): Promise { + const { slug, params, url } = this.generateRequestParts(path) + const queryParams = generateQueryString({}, params) + const request = new Request(`${url}${queryParams}`, { + ...options, + headers: this.buildHeaders(options), + method: 'POST', + }) + return this._POST(request, { params: { slug } }) + } } diff --git a/test/helpers/e2e/navigateToFirstCellLink.ts b/test/helpers/e2e/navigateToFirstCellLink.ts index 5a31a4b81d6..9212a26e724 100644 --- a/test/helpers/e2e/navigateToFirstCellLink.ts +++ b/test/helpers/e2e/navigateToFirstCellLink.ts @@ -1,7 +1,7 @@ import type { Page } from '@playwright/test' -export async function navigateToListCellLink(page: Page, selector = '.cell-id') { - const cellLink = page.locator(`${selector} a`).first() +export async function navigateToListCellLink(page: Page) { + const cellLink = page.locator(`tbody tr:first-child td a`).first() const linkURL = await cellLink.getAttribute('href') await cellLink.click() await page.waitForURL(`**${linkURL}`) diff --git a/test/helpers/e2e/reorderColumns.ts b/test/helpers/e2e/reorderColumns.ts index da50ca1a299..453dc2f0a8c 100644 --- a/test/helpers/e2e/reorderColumns.ts +++ b/test/helpers/e2e/reorderColumns.ts @@ -19,8 +19,12 @@ export const reorderColumns = async ( togglerSelector?: string }, ) => { - await page.locator(togglerSelector).click() - const columnContainer = page.locator(columnContainerSelector) + const columnContainer = page.locator(columnContainerSelector).first() + const isAlreadyOpen = await columnContainer.isVisible() + + if (!isAlreadyOpen) { + await page.locator(togglerSelector).first().click() + } await expect(page.locator(`${columnContainerSelector}.rah-static--height-auto`)).toBeVisible() @@ -36,7 +40,7 @@ export const reorderColumns = async ( }) .boundingBox() - if (!fromBoundingBox || !toBoundingBox) return + if (!fromBoundingBox || !toBoundingBox) {return} // drag the "from" column to the left of the "to" column await page.mouse.move(fromBoundingBox.x + 2, fromBoundingBox.y + 2, { steps: 10 }) @@ -49,7 +53,7 @@ export const reorderColumns = async ( columnContainer.locator('.column-selector .column-selector__column').first(), ).toHaveText(fromColumn) - await expect(page.locator('table thead tr th').nth(1)).toHaveText(fromColumn) + await expect(page.locator('table thead tr th').nth(1).first()).toHaveText(fromColumn) // TODO: This wait makes sure the preferences are actually saved. Just waiting for the UI to update is not enough. We should replace this wait await wait(1000) } diff --git a/test/joins/collections/Categories.ts b/test/joins/collections/Categories.ts new file mode 100644 index 00000000000..6044db17966 --- /dev/null +++ b/test/joins/collections/Categories.ts @@ -0,0 +1,36 @@ +import type { CollectionConfig } from 'payload' + +import { categoriesSlug, postsSlug } from '../shared.js' + +export const Categories: CollectionConfig = { + slug: categoriesSlug, + admin: { + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'relatedPosts', + label: 'Related Posts', + type: 'join', + collection: postsSlug, + on: 'category', + }, + { + name: 'group', + type: 'group', + fields: [ + { + name: 'relatedPosts', + label: 'Related Posts (Group)', + type: 'join', + collection: postsSlug, + on: 'group.category', + }, + ], + }, + ], +} diff --git a/test/joins/collections/Posts.ts b/test/joins/collections/Posts.ts new file mode 100644 index 00000000000..01d3e3fd12d --- /dev/null +++ b/test/joins/collections/Posts.ts @@ -0,0 +1,33 @@ +import type { CollectionConfig } from 'payload' + +import { categoriesSlug, postsSlug } from '../shared.js' + +export const Posts: CollectionConfig = { + slug: postsSlug, + admin: { + useAsTitle: 'title', + defaultColumns: ['title', 'category', 'updatedAt', 'createdAt'], + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'category', + type: 'relationship', + relationTo: categoriesSlug, + }, + { + name: 'group', + type: 'group', + fields: [ + { + name: 'category', + type: 'relationship', + relationTo: categoriesSlug, + }, + ], + }, + ], +} diff --git a/test/joins/config.ts b/test/joins/config.ts new file mode 100644 index 00000000000..c1815cdccde --- /dev/null +++ b/test/joins/config.ts @@ -0,0 +1,68 @@ +import { fileURLToPath } from 'node:url' +import path from 'path' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { Categories } from './collections/Categories.js' +import { Posts } from './collections/Posts.js' +import { seed } from './seed.js' +import { localizedCategoriesSlug, localizedPostsSlug } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + collections: [ + Posts, + Categories, + { + slug: localizedPostsSlug, + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + }, + { + name: 'category', + type: 'relationship', + localized: true, + relationTo: localizedCategoriesSlug, + }, + ], + }, + { + slug: localizedCategoriesSlug, + admin: { + useAsTitle: 'name', + }, + fields: [ + { + name: 'name', + type: 'text', + }, + { + name: 'relatedPosts', + type: 'join', + collection: localizedPostsSlug, + on: 'category', + localized: true, + }, + ], + }, + ], + localization: { + locales: ['en', 'es'], + defaultLocale: 'en', + }, + onInit: async (payload) => { + if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') { + await seed(payload) + } + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/joins/e2e.spec.ts b/test/joins/e2e.spec.ts new file mode 100644 index 00000000000..6e127743c74 --- /dev/null +++ b/test/joins/e2e.spec.ts @@ -0,0 +1,186 @@ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { reorderColumns } from 'helpers/e2e/reorderColumns.js' +import * as path from 'path' +import { fileURLToPath } from 'url' + +import { ensureCompilationIsDone, exactText, initPageConsoleErrorCatch } from '../helpers.js' +import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' +import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js' +import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' +import { TEST_TIMEOUT_LONG } from '../playwright.config.js' +import { categoriesSlug, postsSlug } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +test.describe('Admin Panel', () => { + let page: Page + let categoriesURL: AdminUrlUtil + let postsURL: AdminUrlUtil + + test.beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + + const { payload, serverURL } = await initPayloadE2ENoConfig({ dirname }) + postsURL = new AdminUrlUtil(serverURL, postsSlug) + categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug) + + const context = await browser.newContext() + page = await context.newPage() + initPageConsoleErrorCatch(page) + await ensureCompilationIsDone({ page, serverURL }) + }) + + test('should populate joined relationships in table cells of list view', async () => { + await page.goto(categoriesURL.list) + await expect + .poll( + async () => + await page + .locator('tbody tr:first-child td.cell-relatedPosts', { + hasText: exactText('Test Post 3, Test Post 2, Test Post 1'), + }) + .isVisible(), + ) + .toBeTruthy() + }) + + test('should render initial rows within relationship table', async () => { + await navigateToDoc(page, categoriesURL) + const joinField = page.locator('.field-type.join').first() + await expect(joinField).toBeVisible() + const columns = await joinField.locator('.relationship-table tbody tr').count() + expect(columns).toBe(3) + }) + + test('should render collection type in first column of relationship table', async () => { + await navigateToDoc(page, categoriesURL) + const joinField = page.locator('.field-type.join').first() + await expect(joinField).toBeVisible() + const collectionTypeColumn = joinField.locator('thead tr th#heading-collection:first-child') + const text = collectionTypeColumn + await expect(text).toHaveText('Type') + const cells = joinField.locator('.relationship-table tbody tr td:first-child .pill__label') + + const count = await cells.count() + + for (let i = 0; i < count; i++) { + const element = cells.nth(i) + // Perform actions on each element + await expect(element).toBeVisible() + await expect(element).toHaveText('Post') + } + }) + + test('should render drawer toggler without document link in second column of relationship table', async () => { + await navigateToDoc(page, categoriesURL) + const joinField = page.locator('.field-type.join').first() + await expect(joinField).toBeVisible() + const actionColumn = joinField.locator('tbody tr td:nth-child(2)').first() + const toggler = actionColumn.locator('button.doc-drawer__toggler') + await expect(toggler).toBeVisible() + const link = actionColumn.locator('a') + await expect(link).toBeHidden() + + await reorderColumns(page, { + togglerSelector: '.relationship-table__toggle-columns', + columnContainerSelector: '.relationship-table__columns', + fromColumn: 'Category', + toColumn: 'Title', + }) + + const newActionColumn = joinField.locator('tbody tr td:nth-child(2)').first() + const newToggler = newActionColumn.locator('button.doc-drawer__toggler') + await expect(newToggler).toBeVisible() + const newLink = newActionColumn.locator('a') + await expect(newLink).toBeHidden() + + // put columns back in original order for the next test + await reorderColumns(page, { + togglerSelector: '.relationship-table__toggle-columns', + columnContainerSelector: '.relationship-table__columns', + fromColumn: 'Title', + toColumn: 'Category', + }) + }) + + test('should sort relationship table by clicking on column headers', async () => { + await navigateToDoc(page, categoriesURL) + const joinField = page.locator('.field-type.join').first() + await expect(joinField).toBeVisible() + const titleColumn = joinField.locator('thead tr th#heading-title') + const titleAscButton = titleColumn.locator('button.sort-column__asc') + await expect(titleAscButton).toBeVisible() + await titleAscButton.click() + await expect(joinField.locator('tbody tr:first-child td:nth-child(2)')).toHaveText( + 'Test Post 1', + ) + + const titleDescButton = titleColumn.locator('button.sort-column__desc') + await expect(titleDescButton).toBeVisible() + await titleDescButton.click() + await expect(joinField.locator('tbody tr:first-child td:nth-child(2)')).toHaveText( + 'Test Post 3', + ) + }) + + test('should update relationship table when new document is created', async () => { + await navigateToDoc(page, categoriesURL) + const joinField = page.locator('.field-type.join').first() + await expect(joinField).toBeVisible() + + const addButton = joinField.locator('.relationship-table__actions button.doc-drawer__toggler', { + hasText: exactText('Add new'), + }) + + await expect(addButton).toBeVisible() + + await addButton.click() + const drawer = page.locator('[id^=doc-drawer_posts_1_]') + await expect(drawer).toBeVisible() + const categoryField = drawer.locator('#field-category') + await expect(categoryField).toBeVisible() + const categoryValue = categoryField.locator('.relationship--single-value__text') + await expect(categoryValue).toHaveText('example') + const titleField = drawer.locator('#field-title') + await expect(titleField).toBeVisible() + await titleField.fill('Test Post 4') + await drawer.locator('button[id="action-save"]').click() + await expect(drawer).toBeHidden() + await expect( + joinField.locator('tbody tr td:nth-child(2)', { + hasText: exactText('Test Post 4'), + }), + ).toBeVisible() + }) + + test('should update relationship table when document is updated', async () => { + await navigateToDoc(page, categoriesURL) + const joinField = page.locator('.field-type.join').first() + await expect(joinField).toBeVisible() + const editButton = joinField.locator( + 'tbody tr:first-child td:nth-child(2) button.doc-drawer__toggler', + ) + await expect(editButton).toBeVisible() + await editButton.click() + const drawer = page.locator('[id^=doc-drawer_posts_1_]') + await expect(drawer).toBeVisible() + const titleField = drawer.locator('#field-title') + await expect(titleField).toBeVisible() + await titleField.fill('Test Post 1 Updated') + await drawer.locator('button[id="action-save"]').click() + await expect(drawer).toBeHidden() + await expect(joinField.locator('tbody tr:first-child td:nth-child(2)')).toHaveText( + 'Test Post 1 Updated', + ) + }) + + test('should render empty relationship table when creating new document', async () => { + await page.goto(categoriesURL.create) + const joinField = page.locator('.field-type.join').first() + await expect(joinField).toBeVisible() + await expect(joinField.locator('.relationship-table tbody tr')).toBeHidden() + }) +}) diff --git a/test/plugin-relationship-object-ids/eslint.config.js b/test/joins/eslint.config.js similarity index 87% rename from test/plugin-relationship-object-ids/eslint.config.js rename to test/joins/eslint.config.js index 32974a6d1e4..b61ca8e1404 100644 --- a/test/plugin-relationship-object-ids/eslint.config.js +++ b/test/joins/eslint.config.js @@ -1,5 +1,5 @@ import { rootParserOptions } from '../../eslint.config.js' -import testEslintConfig from '../eslint.config.js' +import { testEslintConfig } from '../eslint.config.js' /** @typedef {import('eslint').Linter.FlatConfig} */ let FlatConfig diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts new file mode 100644 index 00000000000..d1860551e9d --- /dev/null +++ b/test/joins/int.spec.ts @@ -0,0 +1,231 @@ +import type { Payload } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import type { NextRESTClient } from '../helpers/NextRESTClient.js' +import type { Category, Post } from './payload-types.js' + +import { devUser } from '../credentials.js' +import { initPayloadInt } from '../helpers/initPayloadInt.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +let payload: Payload +let token: string +let restClient: NextRESTClient + +const { email, password } = devUser + +describe('Joins Field', () => { + let category: Category + // --__--__--__--__--__--__--__--__--__ + // Boilerplate test setup/teardown + // --__--__--__--__--__--__--__--__--__ + beforeAll(async () => { + ;({ payload, restClient } = await initPayloadInt(dirname)) + + const data = await restClient + .POST('/users/login', { + body: JSON.stringify({ + email, + password, + }), + }) + .then((res) => res.json()) + + token = data.token + + category = await payload.create({ + collection: 'categories', + data: { + name: 'paginate example', + group: {}, + }, + }) + + for (let i = 0; i < 15; i++) { + await createPost({ + title: `test ${i}`, + category: category.id, + group: { + category: category.id, + }, + }) + } + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + it('should populate joins using findByID', async () => { + const categoryWithPosts = await payload.findByID({ + id: category.id, + joins: { + 'group.relatedPosts': { + sort: '-title', + }, + }, + collection: 'categories', + }) + + expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10) + expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('id') + expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('title') + expect(categoryWithPosts.group.relatedPosts.docs[0].title).toStrictEqual('test 9') + }) + + it('should populate relationships in joins', async () => { + const { docs } = await payload.find({ + limit: 1, + collection: 'posts', + }) + + expect(docs[0].category.id).toBeDefined() + expect(docs[0].category.name).toBeDefined() + expect(docs[0].category.relatedPosts.docs).toHaveLength(10) + }) + + it('should filter joins using where query', async () => { + const categoryWithPosts = await payload.findByID({ + id: category.id, + joins: { + relatedPosts: { + sort: '-title', + where: { + title: { + equals: 'test 9', + }, + }, + }, + }, + collection: 'categories', + }) + + expect(categoryWithPosts.relatedPosts.docs).toHaveLength(1) + expect(categoryWithPosts.relatedPosts.hasNextPage).toStrictEqual(false) + }) + + it('should populate joins using find', async () => { + const result = await payload.find({ + collection: 'categories', + }) + + const [categoryWithPosts] = result.docs + + expect(categoryWithPosts.group.relatedPosts.docs).toHaveLength(10) + expect(categoryWithPosts.group.relatedPosts.docs[0]).toHaveProperty('title') + expect(categoryWithPosts.group.relatedPosts.docs[0].title).toBe('test 14') + }) + + describe('Joins with localization', () => { + let localizedCategory: Category + + beforeAll(async () => { + localizedCategory = await payload.create({ + collection: 'localized-categories', + locale: 'en', + data: { + name: 'localized category', + }, + }) + const post1 = await payload.create({ + collection: 'localized-posts', + locale: 'en', + data: { + title: 'english post 1', + category: localizedCategory.id, + }, + }) + await payload.update({ + collection: 'localized-posts', + id: post1.id, + locale: 'es', + data: { + title: 'spanish post', + category: localizedCategory.id, + }, + }) + await payload.create({ + collection: 'localized-posts', + locale: 'en', + data: { + title: 'english post 2', + category: localizedCategory.id, + }, + }) + }) + + it('should populate joins using findByID with localization on the relationship', async () => { + const enCategory = await payload.findByID({ + id: localizedCategory.id, + collection: 'localized-categories', + locale: 'en', + }) + const esCategory = await payload.findByID({ + id: localizedCategory.id, + collection: 'localized-categories', + locale: 'es', + }) + expect(enCategory.relatedPosts.docs).toHaveLength(2) + expect(esCategory.relatedPosts.docs).toHaveLength(1) + }) + }) + + describe('REST', () => { + it('should have simple paginate for joins', async () => { + const query = { + depth: 1, + where: { + name: { equals: 'paginate example' }, + }, + joins: { + relatedPosts: { + sort: 'createdAt', + limit: 4, + }, + }, + } + const pageWithLimit = await restClient.GET(`/categories`, { query }).then((res) => res.json()) + + query.joins.relatedPosts.limit = 0 + const unlimited = await restClient.GET(`/categories`, { query }).then((res) => res.json()) + + expect(pageWithLimit.docs[0].relatedPosts.docs).toHaveLength(4) + expect(pageWithLimit.docs[0].relatedPosts.docs[0].title).toStrictEqual('test 0') + expect(pageWithLimit.docs[0].relatedPosts.hasNextPage).toStrictEqual(true) + + expect(unlimited.docs[0].relatedPosts.docs).toHaveLength(15) + expect(unlimited.docs[0].relatedPosts.docs[0].title).toStrictEqual('test 0') + expect(unlimited.docs[0].relatedPosts.hasNextPage).toStrictEqual(false) + }) + + it('should sort joins', async () => { + const response = await restClient + .GET(`/categories/${category.id}?joins[relatedPosts][sort]=-title`) + .then((res) => res.json()) + expect(response.relatedPosts.docs[0].title).toStrictEqual('test 9') + }) + + it('should query in on collections with joins', async () => { + const response = await restClient + .GET(`/categories?where[id][in]=${category.id}`) + .then((res) => res.json()) + expect(response.docs[0].name).toStrictEqual(category.name) + }) + }) +}) + +async function createPost(overrides?: Partial) { + return payload.create({ + collection: 'posts', + data: { + title: 'test', + ...overrides, + }, + }) +} diff --git a/test/plugin-relationship-object-ids/payload-types.ts b/test/joins/payload-types.ts similarity index 72% rename from test/plugin-relationship-object-ids/payload-types.ts rename to test/joins/payload-types.ts index 6242468568b..9099e3e9ac6 100644 --- a/test/plugin-relationship-object-ids/payload-types.ts +++ b/test/joins/payload-types.ts @@ -11,10 +11,10 @@ export interface Config { users: UserAuthOperations; }; collections: { - uploads: Upload; - pages: Page; posts: Post; - relations: Relation; + categories: Category; + 'localized-posts': LocalizedPost; + 'localized-categories': LocalizedCategory; users: User; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -23,7 +23,7 @@ export interface Config { defaultIDType: string; }; globals: {}; - locale: null; + locale: 'en' | 'es'; user: User & { collection: 'users'; }; @@ -48,72 +48,60 @@ export interface UserAuthOperations { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "uploads". + * via the `definition` "posts". */ -export interface Upload { +export interface Post { id: string; + title?: string | null; + category?: (string | null) | Category; + group?: { + category?: (string | null) | Category; + }; updatedAt: string; createdAt: string; - url?: string | null; - thumbnailURL?: string | null; - filename?: string | null; - mimeType?: string | null; - filesize?: number | null; - width?: number | null; - height?: number | null; - focalX?: number | null; - focalY?: number | null; } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "pages". + * via the `definition` "categories". */ -export interface Page { +export interface Category { id: string; - title: string; + name?: string | null; + relatedPosts?: { + docs?: (string | Post)[] | null; + hasNextPage?: boolean | null; + } | null; + group?: { + relatedPosts?: { + docs?: (string | Post)[] | null; + hasNextPage?: boolean | null; + } | null; + }; updatedAt: string; createdAt: string; } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "posts". + * via the `definition` "localized-posts". */ -export interface Post { +export interface LocalizedPost { id: string; - title: string; + title?: string | null; + category?: (string | null) | LocalizedCategory; updatedAt: string; createdAt: string; } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "relations". + * via the `definition` "localized-categories". */ -export interface Relation { +export interface LocalizedCategory { id: string; - hasOne?: (string | null) | Post; - hasOnePoly?: - | ({ - relationTo: 'pages'; - value: string | Page; - } | null) - | ({ - relationTo: 'posts'; - value: string | Post; - } | null); - hasMany?: (string | Post)[] | null; - hasManyPoly?: - | ( - | { - relationTo: 'pages'; - value: string | Page; - } - | { - relationTo: 'posts'; - value: string | Post; - } - )[] - | null; - upload?: (string | null) | Upload; + name?: string | null; + relatedPosts?: { + docs?: (string | LocalizedPost)[] | null; + hasNextPage?: boolean | null; + } | null; updatedAt: string; createdAt: string; } diff --git a/test/joins/schema.graphql b/test/joins/schema.graphql new file mode 100644 index 00000000000..2d6f2788ee2 --- /dev/null +++ b/test/joins/schema.graphql @@ -0,0 +1,1708 @@ +type Query { + Post(id: String!, draft: Boolean): Post + Posts(draft: Boolean, where: Post_where, limit: Int, page: Int, sort: String): Posts + countPosts(draft: Boolean, where: Post_where): countPosts + docAccessPost(id: String!): postsDocAccess + Category(id: String!, draft: Boolean): Category + Categories(draft: Boolean, where: Category_where, limit: Int, page: Int, sort: String): Categories + countCategories(draft: Boolean, where: Category_where): countCategories + docAccessCategory(id: String!): categoriesDocAccess + User(id: String!, draft: Boolean): User + Users(draft: Boolean, where: User_where, limit: Int, page: Int, sort: String): Users + countUsers(draft: Boolean, where: User_where): countUsers + docAccessUser(id: String!): usersDocAccess + meUser: usersMe + initializedUser: Boolean + PayloadPreference(id: String!, draft: Boolean): PayloadPreference + PayloadPreferences(draft: Boolean, where: PayloadPreference_where, limit: Int, page: Int, sort: String): PayloadPreferences + countPayloadPreferences(draft: Boolean, where: PayloadPreference_where): countPayloadPreferences + docAccessPayloadPreference(id: String!): payload_preferencesDocAccess + Access: Access +} + +type Post { + id: String + title: String + category: Category + updatedAt: DateTime + createdAt: DateTime +} + +type Category { + id: String + name: String + updatedAt: DateTime + createdAt: DateTime +} + +""" +A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. +""" +scalar DateTime + +type Posts { + docs: [Post] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input Post_where { + title: Post_title_operator + category: Post_category_operator + updatedAt: Post_updatedAt_operator + createdAt: Post_createdAt_operator + id: Post_id_operator + AND: [Post_where_and] + OR: [Post_where_or] +} + +input Post_title_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input Post_category_operator { + equals: JSON + not_equals: JSON + in: [JSON] + not_in: [JSON] + all: [JSON] + exists: Boolean +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +input Post_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input Post_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input Post_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input Post_where_and { + title: Post_title_operator + category: Post_category_operator + updatedAt: Post_updatedAt_operator + createdAt: Post_createdAt_operator + id: Post_id_operator + AND: [Post_where_and] + OR: [Post_where_or] +} + +input Post_where_or { + title: Post_title_operator + category: Post_category_operator + updatedAt: Post_updatedAt_operator + createdAt: Post_createdAt_operator + id: Post_id_operator + AND: [Post_where_and] + OR: [Post_where_or] +} + +type countPosts { + totalDocs: Int +} + +type postsDocAccess { + fields: PostsDocAccessFields + create: PostsCreateDocAccess + read: PostsReadDocAccess + update: PostsUpdateDocAccess + delete: PostsDeleteDocAccess +} + +type PostsDocAccessFields { + title: PostsDocAccessFields_title + category: PostsDocAccessFields_category + updatedAt: PostsDocAccessFields_updatedAt + createdAt: PostsDocAccessFields_createdAt +} + +type PostsDocAccessFields_title { + create: PostsDocAccessFields_title_Create + read: PostsDocAccessFields_title_Read + update: PostsDocAccessFields_title_Update + delete: PostsDocAccessFields_title_Delete +} + +type PostsDocAccessFields_title_Create { + permission: Boolean! +} + +type PostsDocAccessFields_title_Read { + permission: Boolean! +} + +type PostsDocAccessFields_title_Update { + permission: Boolean! +} + +type PostsDocAccessFields_title_Delete { + permission: Boolean! +} + +type PostsDocAccessFields_category { + create: PostsDocAccessFields_category_Create + read: PostsDocAccessFields_category_Read + update: PostsDocAccessFields_category_Update + delete: PostsDocAccessFields_category_Delete +} + +type PostsDocAccessFields_category_Create { + permission: Boolean! +} + +type PostsDocAccessFields_category_Read { + permission: Boolean! +} + +type PostsDocAccessFields_category_Update { + permission: Boolean! +} + +type PostsDocAccessFields_category_Delete { + permission: Boolean! +} + +type PostsDocAccessFields_updatedAt { + create: PostsDocAccessFields_updatedAt_Create + read: PostsDocAccessFields_updatedAt_Read + update: PostsDocAccessFields_updatedAt_Update + delete: PostsDocAccessFields_updatedAt_Delete +} + +type PostsDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type PostsDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type PostsDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type PostsDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type PostsDocAccessFields_createdAt { + create: PostsDocAccessFields_createdAt_Create + read: PostsDocAccessFields_createdAt_Read + update: PostsDocAccessFields_createdAt_Update + delete: PostsDocAccessFields_createdAt_Delete +} + +type PostsDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type PostsDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type PostsDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type PostsDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type PostsCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type PostsReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type PostsUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PostsDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type Categories { + docs: [Category] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input Category_where { + name: Category_name_operator + updatedAt: Category_updatedAt_operator + createdAt: Category_createdAt_operator + id: Category_id_operator + AND: [Category_where_and] + OR: [Category_where_or] +} + +input Category_name_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input Category_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input Category_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input Category_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input Category_where_and { + name: Category_name_operator + updatedAt: Category_updatedAt_operator + createdAt: Category_createdAt_operator + id: Category_id_operator + AND: [Category_where_and] + OR: [Category_where_or] +} + +input Category_where_or { + name: Category_name_operator + updatedAt: Category_updatedAt_operator + createdAt: Category_createdAt_operator + id: Category_id_operator + AND: [Category_where_and] + OR: [Category_where_or] +} + +type countCategories { + totalDocs: Int +} + +type categoriesDocAccess { + fields: CategoriesDocAccessFields + create: CategoriesCreateDocAccess + read: CategoriesReadDocAccess + update: CategoriesUpdateDocAccess + delete: CategoriesDeleteDocAccess +} + +type CategoriesDocAccessFields { + name: CategoriesDocAccessFields_name + posts: CategoriesDocAccessFields_posts + updatedAt: CategoriesDocAccessFields_updatedAt + createdAt: CategoriesDocAccessFields_createdAt +} + +type CategoriesDocAccessFields_name { + create: CategoriesDocAccessFields_name_Create + read: CategoriesDocAccessFields_name_Read + update: CategoriesDocAccessFields_name_Update + delete: CategoriesDocAccessFields_name_Delete +} + +type CategoriesDocAccessFields_name_Create { + permission: Boolean! +} + +type CategoriesDocAccessFields_name_Read { + permission: Boolean! +} + +type CategoriesDocAccessFields_name_Update { + permission: Boolean! +} + +type CategoriesDocAccessFields_name_Delete { + permission: Boolean! +} + +type CategoriesDocAccessFields_posts { + create: CategoriesDocAccessFields_posts_Create + read: CategoriesDocAccessFields_posts_Read + update: CategoriesDocAccessFields_posts_Update + delete: CategoriesDocAccessFields_posts_Delete +} + +type CategoriesDocAccessFields_posts_Create { + permission: Boolean! +} + +type CategoriesDocAccessFields_posts_Read { + permission: Boolean! +} + +type CategoriesDocAccessFields_posts_Update { + permission: Boolean! +} + +type CategoriesDocAccessFields_posts_Delete { + permission: Boolean! +} + +type CategoriesDocAccessFields_updatedAt { + create: CategoriesDocAccessFields_updatedAt_Create + read: CategoriesDocAccessFields_updatedAt_Read + update: CategoriesDocAccessFields_updatedAt_Update + delete: CategoriesDocAccessFields_updatedAt_Delete +} + +type CategoriesDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type CategoriesDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type CategoriesDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type CategoriesDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type CategoriesDocAccessFields_createdAt { + create: CategoriesDocAccessFields_createdAt_Create + read: CategoriesDocAccessFields_createdAt_Read + update: CategoriesDocAccessFields_createdAt_Update + delete: CategoriesDocAccessFields_createdAt_Delete +} + +type CategoriesDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type CategoriesDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type CategoriesDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type CategoriesDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type CategoriesCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type CategoriesReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type CategoriesUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type CategoriesDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type User { + id: String + updatedAt: DateTime + createdAt: DateTime + email: EmailAddress! + resetPasswordToken: String + resetPasswordExpiration: DateTime + salt: String + hash: String + loginAttempts: Float + lockUntil: DateTime + password: String! +} + +""" +A field whose value conforms to the standard internet email address format as specified in HTML Spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address. +""" +scalar EmailAddress @specifiedBy(url: "https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address") + +type Users { + docs: [User] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input User_where { + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +input User_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input User_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input User_email_operator { + equals: EmailAddress + not_equals: EmailAddress + like: EmailAddress + contains: EmailAddress + in: [EmailAddress] + not_in: [EmailAddress] + all: [EmailAddress] +} + +input User_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input User_where_and { + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +input User_where_or { + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +type countUsers { + totalDocs: Int +} + +type usersDocAccess { + fields: UsersDocAccessFields + create: UsersCreateDocAccess + read: UsersReadDocAccess + update: UsersUpdateDocAccess + delete: UsersDeleteDocAccess + unlock: UsersUnlockDocAccess +} + +type UsersDocAccessFields { + updatedAt: UsersDocAccessFields_updatedAt + createdAt: UsersDocAccessFields_createdAt + email: UsersDocAccessFields_email + password: UsersDocAccessFields_password +} + +type UsersDocAccessFields_updatedAt { + create: UsersDocAccessFields_updatedAt_Create + read: UsersDocAccessFields_updatedAt_Read + update: UsersDocAccessFields_updatedAt_Update + delete: UsersDocAccessFields_updatedAt_Delete +} + +type UsersDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt { + create: UsersDocAccessFields_createdAt_Create + read: UsersDocAccessFields_createdAt_Read + update: UsersDocAccessFields_createdAt_Update + delete: UsersDocAccessFields_createdAt_Delete +} + +type UsersDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_email { + create: UsersDocAccessFields_email_Create + read: UsersDocAccessFields_email_Read + update: UsersDocAccessFields_email_Update + delete: UsersDocAccessFields_email_Delete +} + +type UsersDocAccessFields_email_Create { + permission: Boolean! +} + +type UsersDocAccessFields_email_Read { + permission: Boolean! +} + +type UsersDocAccessFields_email_Update { + permission: Boolean! +} + +type UsersDocAccessFields_email_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_password { + create: UsersDocAccessFields_password_Create + read: UsersDocAccessFields_password_Read + update: UsersDocAccessFields_password_Update + delete: UsersDocAccessFields_password_Delete +} + +type UsersDocAccessFields_password_Create { + permission: Boolean! +} + +type UsersDocAccessFields_password_Read { + permission: Boolean! +} + +type UsersDocAccessFields_password_Update { + permission: Boolean! +} + +type UsersDocAccessFields_password_Delete { + permission: Boolean! +} + +type UsersCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUnlockDocAccess { + permission: Boolean! + where: JSONObject +} + +type usersMe { + collection: String + exp: Int + strategy: String + token: String + user: User +} + +type PayloadPreference { + id: String + user: PayloadPreference_User_Relationship! + key: String + value: JSON + updatedAt: DateTime + createdAt: DateTime +} + +type PayloadPreference_User_Relationship { + relationTo: PayloadPreference_User_RelationTo + value: PayloadPreference_User +} + +enum PayloadPreference_User_RelationTo { + users +} + +union PayloadPreference_User = User + +type PayloadPreferences { + docs: [PayloadPreference] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input PayloadPreference_where { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +input PayloadPreference_user_Relation { + relationTo: PayloadPreference_user_Relation_RelationTo + value: JSON +} + +enum PayloadPreference_user_Relation_RelationTo { + users +} + +input PayloadPreference_key_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadPreference_value_operator { + equals: JSON + not_equals: JSON + like: JSON + contains: JSON + within: JSON + intersects: JSON + exists: Boolean +} + +input PayloadPreference_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadPreference_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadPreference_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadPreference_where_and { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +input PayloadPreference_where_or { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +type countPayloadPreferences { + totalDocs: Int +} + +type payload_preferencesDocAccess { + fields: PayloadPreferencesDocAccessFields + create: PayloadPreferencesCreateDocAccess + read: PayloadPreferencesReadDocAccess + update: PayloadPreferencesUpdateDocAccess + delete: PayloadPreferencesDeleteDocAccess +} + +type PayloadPreferencesDocAccessFields { + user: PayloadPreferencesDocAccessFields_user + key: PayloadPreferencesDocAccessFields_key + value: PayloadPreferencesDocAccessFields_value + updatedAt: PayloadPreferencesDocAccessFields_updatedAt + createdAt: PayloadPreferencesDocAccessFields_createdAt +} + +type PayloadPreferencesDocAccessFields_user { + create: PayloadPreferencesDocAccessFields_user_Create + read: PayloadPreferencesDocAccessFields_user_Read + update: PayloadPreferencesDocAccessFields_user_Update + delete: PayloadPreferencesDocAccessFields_user_Delete +} + +type PayloadPreferencesDocAccessFields_user_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key { + create: PayloadPreferencesDocAccessFields_key_Create + read: PayloadPreferencesDocAccessFields_key_Read + update: PayloadPreferencesDocAccessFields_key_Update + delete: PayloadPreferencesDocAccessFields_key_Delete +} + +type PayloadPreferencesDocAccessFields_key_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value { + create: PayloadPreferencesDocAccessFields_value_Create + read: PayloadPreferencesDocAccessFields_value_Read + update: PayloadPreferencesDocAccessFields_value_Update + delete: PayloadPreferencesDocAccessFields_value_Delete +} + +type PayloadPreferencesDocAccessFields_value_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt { + create: PayloadPreferencesDocAccessFields_updatedAt_Create + read: PayloadPreferencesDocAccessFields_updatedAt_Read + update: PayloadPreferencesDocAccessFields_updatedAt_Update + delete: PayloadPreferencesDocAccessFields_updatedAt_Delete +} + +type PayloadPreferencesDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt { + create: PayloadPreferencesDocAccessFields_createdAt_Create + read: PayloadPreferencesDocAccessFields_createdAt_Read + update: PayloadPreferencesDocAccessFields_createdAt_Update + delete: PayloadPreferencesDocAccessFields_createdAt_Delete +} + +type PayloadPreferencesDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type Access { + canAccessAdmin: Boolean! + posts: postsAccess + categories: categoriesAccess + users: usersAccess + payload_preferences: payload_preferencesAccess +} + +type postsAccess { + fields: PostsFields + create: PostsCreateAccess + read: PostsReadAccess + update: PostsUpdateAccess + delete: PostsDeleteAccess +} + +type PostsFields { + title: PostsFields_title + category: PostsFields_category + updatedAt: PostsFields_updatedAt + createdAt: PostsFields_createdAt +} + +type PostsFields_title { + create: PostsFields_title_Create + read: PostsFields_title_Read + update: PostsFields_title_Update + delete: PostsFields_title_Delete +} + +type PostsFields_title_Create { + permission: Boolean! +} + +type PostsFields_title_Read { + permission: Boolean! +} + +type PostsFields_title_Update { + permission: Boolean! +} + +type PostsFields_title_Delete { + permission: Boolean! +} + +type PostsFields_category { + create: PostsFields_category_Create + read: PostsFields_category_Read + update: PostsFields_category_Update + delete: PostsFields_category_Delete +} + +type PostsFields_category_Create { + permission: Boolean! +} + +type PostsFields_category_Read { + permission: Boolean! +} + +type PostsFields_category_Update { + permission: Boolean! +} + +type PostsFields_category_Delete { + permission: Boolean! +} + +type PostsFields_updatedAt { + create: PostsFields_updatedAt_Create + read: PostsFields_updatedAt_Read + update: PostsFields_updatedAt_Update + delete: PostsFields_updatedAt_Delete +} + +type PostsFields_updatedAt_Create { + permission: Boolean! +} + +type PostsFields_updatedAt_Read { + permission: Boolean! +} + +type PostsFields_updatedAt_Update { + permission: Boolean! +} + +type PostsFields_updatedAt_Delete { + permission: Boolean! +} + +type PostsFields_createdAt { + create: PostsFields_createdAt_Create + read: PostsFields_createdAt_Read + update: PostsFields_createdAt_Update + delete: PostsFields_createdAt_Delete +} + +type PostsFields_createdAt_Create { + permission: Boolean! +} + +type PostsFields_createdAt_Read { + permission: Boolean! +} + +type PostsFields_createdAt_Update { + permission: Boolean! +} + +type PostsFields_createdAt_Delete { + permission: Boolean! +} + +type PostsCreateAccess { + permission: Boolean! + where: JSONObject +} + +type PostsReadAccess { + permission: Boolean! + where: JSONObject +} + +type PostsUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type PostsDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type categoriesAccess { + fields: CategoriesFields + create: CategoriesCreateAccess + read: CategoriesReadAccess + update: CategoriesUpdateAccess + delete: CategoriesDeleteAccess +} + +type CategoriesFields { + name: CategoriesFields_name + posts: CategoriesFields_posts + updatedAt: CategoriesFields_updatedAt + createdAt: CategoriesFields_createdAt +} + +type CategoriesFields_name { + create: CategoriesFields_name_Create + read: CategoriesFields_name_Read + update: CategoriesFields_name_Update + delete: CategoriesFields_name_Delete +} + +type CategoriesFields_name_Create { + permission: Boolean! +} + +type CategoriesFields_name_Read { + permission: Boolean! +} + +type CategoriesFields_name_Update { + permission: Boolean! +} + +type CategoriesFields_name_Delete { + permission: Boolean! +} + +type CategoriesFields_posts { + create: CategoriesFields_posts_Create + read: CategoriesFields_posts_Read + update: CategoriesFields_posts_Update + delete: CategoriesFields_posts_Delete +} + +type CategoriesFields_posts_Create { + permission: Boolean! +} + +type CategoriesFields_posts_Read { + permission: Boolean! +} + +type CategoriesFields_posts_Update { + permission: Boolean! +} + +type CategoriesFields_posts_Delete { + permission: Boolean! +} + +type CategoriesFields_updatedAt { + create: CategoriesFields_updatedAt_Create + read: CategoriesFields_updatedAt_Read + update: CategoriesFields_updatedAt_Update + delete: CategoriesFields_updatedAt_Delete +} + +type CategoriesFields_updatedAt_Create { + permission: Boolean! +} + +type CategoriesFields_updatedAt_Read { + permission: Boolean! +} + +type CategoriesFields_updatedAt_Update { + permission: Boolean! +} + +type CategoriesFields_updatedAt_Delete { + permission: Boolean! +} + +type CategoriesFields_createdAt { + create: CategoriesFields_createdAt_Create + read: CategoriesFields_createdAt_Read + update: CategoriesFields_createdAt_Update + delete: CategoriesFields_createdAt_Delete +} + +type CategoriesFields_createdAt_Create { + permission: Boolean! +} + +type CategoriesFields_createdAt_Read { + permission: Boolean! +} + +type CategoriesFields_createdAt_Update { + permission: Boolean! +} + +type CategoriesFields_createdAt_Delete { + permission: Boolean! +} + +type CategoriesCreateAccess { + permission: Boolean! + where: JSONObject +} + +type CategoriesReadAccess { + permission: Boolean! + where: JSONObject +} + +type CategoriesUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type CategoriesDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type usersAccess { + fields: UsersFields + create: UsersCreateAccess + read: UsersReadAccess + update: UsersUpdateAccess + delete: UsersDeleteAccess + unlock: UsersUnlockAccess +} + +type UsersFields { + updatedAt: UsersFields_updatedAt + createdAt: UsersFields_createdAt + email: UsersFields_email + password: UsersFields_password +} + +type UsersFields_updatedAt { + create: UsersFields_updatedAt_Create + read: UsersFields_updatedAt_Read + update: UsersFields_updatedAt_Update + delete: UsersFields_updatedAt_Delete +} + +type UsersFields_updatedAt_Create { + permission: Boolean! +} + +type UsersFields_updatedAt_Read { + permission: Boolean! +} + +type UsersFields_updatedAt_Update { + permission: Boolean! +} + +type UsersFields_updatedAt_Delete { + permission: Boolean! +} + +type UsersFields_createdAt { + create: UsersFields_createdAt_Create + read: UsersFields_createdAt_Read + update: UsersFields_createdAt_Update + delete: UsersFields_createdAt_Delete +} + +type UsersFields_createdAt_Create { + permission: Boolean! +} + +type UsersFields_createdAt_Read { + permission: Boolean! +} + +type UsersFields_createdAt_Update { + permission: Boolean! +} + +type UsersFields_createdAt_Delete { + permission: Boolean! +} + +type UsersFields_email { + create: UsersFields_email_Create + read: UsersFields_email_Read + update: UsersFields_email_Update + delete: UsersFields_email_Delete +} + +type UsersFields_email_Create { + permission: Boolean! +} + +type UsersFields_email_Read { + permission: Boolean! +} + +type UsersFields_email_Update { + permission: Boolean! +} + +type UsersFields_email_Delete { + permission: Boolean! +} + +type UsersFields_password { + create: UsersFields_password_Create + read: UsersFields_password_Read + update: UsersFields_password_Update + delete: UsersFields_password_Delete +} + +type UsersFields_password_Create { + permission: Boolean! +} + +type UsersFields_password_Read { + permission: Boolean! +} + +type UsersFields_password_Update { + permission: Boolean! +} + +type UsersFields_password_Delete { + permission: Boolean! +} + +type UsersCreateAccess { + permission: Boolean! + where: JSONObject +} + +type UsersReadAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type UsersDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUnlockAccess { + permission: Boolean! + where: JSONObject +} + +type payload_preferencesAccess { + fields: PayloadPreferencesFields + create: PayloadPreferencesCreateAccess + read: PayloadPreferencesReadAccess + update: PayloadPreferencesUpdateAccess + delete: PayloadPreferencesDeleteAccess +} + +type PayloadPreferencesFields { + user: PayloadPreferencesFields_user + key: PayloadPreferencesFields_key + value: PayloadPreferencesFields_value + updatedAt: PayloadPreferencesFields_updatedAt + createdAt: PayloadPreferencesFields_createdAt +} + +type PayloadPreferencesFields_user { + create: PayloadPreferencesFields_user_Create + read: PayloadPreferencesFields_user_Read + update: PayloadPreferencesFields_user_Update + delete: PayloadPreferencesFields_user_Delete +} + +type PayloadPreferencesFields_user_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_key { + create: PayloadPreferencesFields_key_Create + read: PayloadPreferencesFields_key_Read + update: PayloadPreferencesFields_key_Update + delete: PayloadPreferencesFields_key_Delete +} + +type PayloadPreferencesFields_key_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_value { + create: PayloadPreferencesFields_value_Create + read: PayloadPreferencesFields_value_Read + update: PayloadPreferencesFields_value_Update + delete: PayloadPreferencesFields_value_Delete +} + +type PayloadPreferencesFields_value_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt { + create: PayloadPreferencesFields_updatedAt_Create + read: PayloadPreferencesFields_updatedAt_Read + update: PayloadPreferencesFields_updatedAt_Update + delete: PayloadPreferencesFields_updatedAt_Delete +} + +type PayloadPreferencesFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt { + create: PayloadPreferencesFields_createdAt_Create + read: PayloadPreferencesFields_createdAt_Read + update: PayloadPreferencesFields_createdAt_Update + delete: PayloadPreferencesFields_createdAt_Delete +} + +type PayloadPreferencesFields_createdAt_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesCreateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesReadAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type Mutation { + createPost(data: mutationPostInput!, draft: Boolean): Post + updatePost(id: String!, autosave: Boolean, data: mutationPostUpdateInput!, draft: Boolean): Post + deletePost(id: String!): Post + duplicatePost(id: String!): Post + createCategory(data: mutationCategoryInput!, draft: Boolean): Category + updateCategory(id: String!, autosave: Boolean, data: mutationCategoryUpdateInput!, draft: Boolean): Category + deleteCategory(id: String!): Category + duplicateCategory(id: String!): Category + createUser(data: mutationUserInput!, draft: Boolean): User + updateUser(id: String!, autosave: Boolean, data: mutationUserUpdateInput!, draft: Boolean): User + deleteUser(id: String!): User + refreshTokenUser: usersRefreshedUser + logoutUser: String + unlockUser(email: String!): Boolean! + loginUser(email: String!, password: String): usersLoginResult + forgotPasswordUser(disableEmail: Boolean, expiration: Int, email: String!): Boolean! + resetPasswordUser(password: String, token: String): usersResetPassword + verifyEmailUser(token: String): Boolean + createPayloadPreference(data: mutationPayloadPreferenceInput!, draft: Boolean): PayloadPreference + updatePayloadPreference(id: String!, autosave: Boolean, data: mutationPayloadPreferenceUpdateInput!, draft: Boolean): PayloadPreference + deletePayloadPreference(id: String!): PayloadPreference + duplicatePayloadPreference(id: String!): PayloadPreference +} + +input mutationPostInput { + title: String + category: String + updatedAt: String + createdAt: String +} + +input mutationPostUpdateInput { + title: String + category: String + updatedAt: String + createdAt: String +} + +input mutationCategoryInput { + name: String + updatedAt: String + createdAt: String +} + +input mutationCategoryUpdateInput { + name: String + updatedAt: String + createdAt: String +} + +input mutationUserInput { + updatedAt: String + createdAt: String + email: String! + resetPasswordToken: String + resetPasswordExpiration: String + salt: String + hash: String + loginAttempts: Float + lockUntil: String + password: String! +} + +input mutationUserUpdateInput { + updatedAt: String + createdAt: String + email: String + resetPasswordToken: String + resetPasswordExpiration: String + salt: String + hash: String + loginAttempts: Float + lockUntil: String + password: String +} + +type usersRefreshedUser { + exp: Int + refreshedToken: String + strategy: String + user: usersJWT +} + +type usersJWT { + email: EmailAddress! + collection: String! +} + +type usersLoginResult { + exp: Int + token: String + user: User +} + +type usersResetPassword { + token: String + user: User +} + +input mutationPayloadPreferenceInput { + user: PayloadPreference_UserRelationshipInput + key: String + value: JSON + updatedAt: String + createdAt: String +} + +input PayloadPreference_UserRelationshipInput { + relationTo: PayloadPreference_UserRelationshipInputRelationTo + value: JSON +} + +enum PayloadPreference_UserRelationshipInputRelationTo { + users +} + +input mutationPayloadPreferenceUpdateInput { + user: PayloadPreferenceUpdate_UserRelationshipInput + key: String + value: JSON + updatedAt: String + createdAt: String +} + +input PayloadPreferenceUpdate_UserRelationshipInput { + relationTo: PayloadPreferenceUpdate_UserRelationshipInputRelationTo + value: JSON +} + +enum PayloadPreferenceUpdate_UserRelationshipInputRelationTo { + users +} \ No newline at end of file diff --git a/test/joins/seed.ts b/test/joins/seed.ts new file mode 100644 index 00000000000..26656958687 --- /dev/null +++ b/test/joins/seed.ts @@ -0,0 +1,65 @@ +import type { Payload } from 'payload' + +import { devUser } from '../credentials.js' +import { seedDB } from '../helpers/seed.js' +import { categoriesSlug, collectionSlugs, postsSlug } from './shared.js' + +export const seed = async (_payload) => { + await _payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + + const category = await _payload.create({ + collection: categoriesSlug, + data: { + name: 'example', + group: {}, + }, + }) + + await _payload.create({ + collection: postsSlug, + data: { + category: category.id, + group: { + category: category.id, + }, + title: 'Test Post 1', + }, + }) + + await _payload.create({ + collection: postsSlug, + data: { + category: category.id, + group: { + category: category.id, + }, + title: 'Test Post 2', + }, + }) + + await _payload.create({ + collection: postsSlug, + data: { + category: category.id, + group: { + category: category.id, + }, + title: 'Test Post 3', + }, + }) +} + +export async function clearAndSeedEverything(_payload: Payload) { + return await seedDB({ + _payload, + collectionSlugs, + seedFunction: seed, + snapshotKey: 'adminTest', + }) +} diff --git a/test/joins/shared.ts b/test/joins/shared.ts new file mode 100644 index 00000000000..cf4af79035a --- /dev/null +++ b/test/joins/shared.ts @@ -0,0 +1,14 @@ +export const categoriesSlug = 'categories' + +export const postsSlug = 'posts' + +export const localizedPostsSlug = 'localized-posts' + +export const localizedCategoriesSlug = 'localized-categories' + +export const collectionSlugs = [ + categoriesSlug, + postsSlug, + localizedPostsSlug, + localizedCategoriesSlug, +] diff --git a/test/plugin-relationship-object-ids/tsconfig.eslint.json b/test/joins/tsconfig.eslint.json similarity index 100% rename from test/plugin-relationship-object-ids/tsconfig.eslint.json rename to test/joins/tsconfig.eslint.json diff --git a/test/plugin-relationship-object-ids/tsconfig.json b/test/joins/tsconfig.json similarity index 100% rename from test/plugin-relationship-object-ids/tsconfig.json rename to test/joins/tsconfig.json diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index 7c4e81eae39..f3955c11915 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -256,6 +256,10 @@ describe('Localization', () => { async function fillValues(data: Partial) { const { description: descVal, title: titleVal } = data - if (titleVal) {await page.locator('#field-title').fill(titleVal)} - if (descVal) {await page.locator('#field-description').fill(descVal)} + if (titleVal) { + await page.locator('#field-title').fill(titleVal) + } + if (descVal) { + await page.locator('#field-description').fill(descVal) + } } diff --git a/test/package.json b/test/package.json index 475a1193736..e74ad2e68a9 100644 --- a/test/package.json +++ b/test/package.json @@ -43,7 +43,6 @@ "@payloadcms/plugin-form-builder": "workspace:*", "@payloadcms/plugin-nested-docs": "workspace:*", "@payloadcms/plugin-redirects": "workspace:*", - "@payloadcms/plugin-relationship-object-ids": "workspace:*", "@payloadcms/plugin-search": "workspace:*", "@payloadcms/plugin-sentry": "workspace:*", "@payloadcms/plugin-seo": "workspace:*", diff --git a/test/plugin-relationship-object-ids/.gitignore b/test/plugin-relationship-object-ids/.gitignore deleted file mode 100644 index 3f549faf910..00000000000 --- a/test/plugin-relationship-object-ids/.gitignore +++ /dev/null @@ -1 +0,0 @@ -uploads diff --git a/test/plugin-relationship-object-ids/config.ts b/test/plugin-relationship-object-ids/config.ts deleted file mode 100644 index 09cdc01f406..00000000000 --- a/test/plugin-relationship-object-ids/config.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { relationshipsAsObjectID } from '@payloadcms/plugin-relationship-object-ids' -import path from 'path' -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) -import { fileURLToPath } from 'node:url' - -import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' - -export default buildConfigWithDefaults({ - admin: { - importMap: { - baseDir: path.resolve(dirname), - }, - }, - collections: [ - { - slug: 'uploads', - upload: true, - fields: [], - }, - { - slug: 'pages', - fields: [ - { - name: 'title', - type: 'text', - required: true, - }, - ], - }, - { - slug: 'posts', - fields: [ - { - name: 'title', - type: 'text', - required: true, - }, - ], - }, - { - slug: 'relations', - fields: [ - { - name: 'hasOne', - type: 'relationship', - relationTo: 'posts', - filterOptions: ({ id }) => ({ id: { not_equals: id } }), - }, - { - name: 'hasOnePoly', - type: 'relationship', - relationTo: ['pages', 'posts'], - }, - { - name: 'hasMany', - type: 'relationship', - relationTo: 'posts', - hasMany: true, - }, - { - name: 'hasManyPoly', - type: 'relationship', - relationTo: ['pages', 'posts'], - hasMany: true, - }, - { - name: 'upload', - type: 'upload', - relationTo: 'uploads', - }, - ], - }, - ], - plugins: [relationshipsAsObjectID()], - onInit: async (payload) => { - if (payload.db.name === 'mongoose') { - await payload.create({ - collection: 'users', - data: { - email: 'dev@payloadcms.com', - password: 'test', - }, - }) - - const page = await payload.create({ - collection: 'pages', - data: { - title: 'page', - }, - }) - - const post1 = await payload.create({ - collection: 'posts', - data: { - title: 'post 1', - }, - }) - - const post2 = await payload.create({ - collection: 'posts', - data: { - title: 'post 2', - }, - }) - - const upload = await payload.create({ - collection: 'uploads', - data: {}, - filePath: path.resolve(dirname, './payload-logo.png'), - }) - - await payload.create({ - collection: 'relations', - depth: 0, - data: { - hasOne: post1.id, - hasOnePoly: { relationTo: 'pages', value: page.id }, - hasMany: [post1.id, post2.id], - hasManyPoly: [ - { relationTo: 'posts', value: post1.id }, - { relationTo: 'pages', value: page.id }, - ], - upload: upload.id, - }, - }) - - await payload.create({ - collection: 'relations', - depth: 0, - data: { - hasOnePoly: { relationTo: 'pages', value: page.id }, - }, - }) - } - }, - typescript: { - outputFile: path.resolve(dirname, 'payload-types.ts'), - }, -}) diff --git a/test/plugin-relationship-object-ids/int.spec.ts b/test/plugin-relationship-object-ids/int.spec.ts deleted file mode 100644 index aab6ab1e9c9..00000000000 --- a/test/plugin-relationship-object-ids/int.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { Payload } from 'payload' - -import path from 'path' -import { fileURLToPath } from 'url' - -import type { Post, Relation } from './payload-types.js' - -import { initPayloadInt } from '../helpers/initPayloadInt.js' - -const filename = fileURLToPath(import.meta.url) -const dirname = path.dirname(filename) - -describe('Relationship Object IDs Plugin', () => { - let relations: Relation[] - let posts: Post[] - let payload: Payload - - beforeAll(async () => { - ;({ payload } = await initPayloadInt(dirname)) - }) - - it('seeds data accordingly', async () => { - // eslint-disable-next-line jest/no-conditional-in-test - if (payload.db.name === 'mongoose') { - const relationsQuery = await payload.find({ - collection: 'relations', - sort: 'createdAt', - }) - - relations = relationsQuery.docs - - const postsQuery = await payload.find({ - collection: 'posts', - sort: 'createdAt', - }) - - posts = postsQuery.docs - - expect(relationsQuery.totalDocs).toStrictEqual(2) - expect(postsQuery.totalDocs).toStrictEqual(2) - } - }) - - it('stores relations as object ids', async () => { - // eslint-disable-next-line jest/no-conditional-in-test - if (payload.db.name === 'mongoose') { - const docs = await payload.db.collections.relations.find() - expect(typeof docs[0].hasOne).toBe('object') - expect(typeof docs[0].hasOnePoly.value).toBe('object') - expect(typeof docs[0].hasMany[0]).toBe('object') - expect(typeof docs[0].hasManyPoly[0].value).toBe('object') - expect(typeof docs[0].upload).toBe('object') - } - }) - - it('can query by relationship id', async () => { - // eslint-disable-next-line jest/no-conditional-in-test - if (payload.db.name === 'mongoose') { - const { totalDocs } = await payload.find({ - collection: 'relations', - where: { - hasOne: { - equals: posts[0].id, - }, - }, - }) - - expect(totalDocs).toStrictEqual(1) - } - }) - - it('populates relations', () => { - // eslint-disable-next-line jest/no-conditional-in-test - if (payload.db.name === 'mongoose') { - const populatedPostTitle = - // eslint-disable-next-line jest/no-conditional-in-test - typeof relations[0].hasOne === 'object' ? relations[0].hasOne.title : undefined - expect(populatedPostTitle).toBeDefined() - - const populatedUploadFilename = - // eslint-disable-next-line jest/no-conditional-in-test - typeof relations[0].upload === 'object' ? relations[0].upload.filename : undefined - - expect(populatedUploadFilename).toBeDefined() - } - }) - - it('can query by nested property', async () => { - // eslint-disable-next-line jest/no-conditional-in-test - if (payload.db.name === 'mongoose') { - const { totalDocs } = await payload.find({ - collection: 'relations', - where: { - 'hasOne.title': { - equals: 'post 1', - }, - }, - }) - - expect(totalDocs).toStrictEqual(1) - } - }) - - it('can query using the "in" operator', async () => { - // eslint-disable-next-line jest/no-conditional-in-test - if (payload.db.name === 'mongoose') { - const { totalDocs } = await payload.find({ - collection: 'relations', - where: { - hasMany: { - in: [posts[0].id], - }, - }, - }) - - expect(totalDocs).toStrictEqual(1) - } - }) -}) diff --git a/test/plugin-relationship-object-ids/payload-logo.png b/test/plugin-relationship-object-ids/payload-logo.png deleted file mode 100644 index 1a66766146d..00000000000 Binary files a/test/plugin-relationship-object-ids/payload-logo.png and /dev/null differ diff --git a/test/setupProd.ts b/test/setupProd.ts index 6f94abefd61..e9cc523a12f 100644 --- a/test/setupProd.ts +++ b/test/setupProd.ts @@ -25,7 +25,6 @@ export const tgzToPkgNameMap = { '@payloadcms/plugin-form-builder': 'payloadcms-plugin-form-builder-*', '@payloadcms/plugin-nested-docs': 'payloadcms-plugin-nested-docs-*', '@payloadcms/plugin-redirects': 'payloadcms-plugin-redirects-*', - '@payloadcms/plugin-relationship-object-ids': 'payloadcms-plugin-relationship-object-ids-*', '@payloadcms/plugin-search': 'payloadcms-plugin-search-*', '@payloadcms/plugin-sentry': 'payloadcms-plugin-sentry-*', '@payloadcms/plugin-seo': 'payloadcms-plugin-seo-*',
- {col.components.Heading} + {col.Heading}
- - - -