diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index 605709a10d0..00ae8b74873 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -7,17 +7,21 @@ import type { SanitizedJoins, } from '../../collections/config/types.js' import type { Config, SanitizedConfig } from '../../config/types.js' +import type { GlobalConfig } from '../../globals/config/types.js' import type { Field } from './types.js' import { DuplicateFieldName, + InvalidConfiguration, InvalidFieldName, InvalidFieldRelationship, MissingEditorProp, MissingFieldType, } from '../../errors/index.js' import { ReservedFieldName } from '../../errors/ReservedFieldName.js' +import { flattenAllFields } from '../../utilities/flattenAllFields.js' import { formatLabels, toWords } from '../../utilities/formatLabels.js' +import { getFieldByPath } from '../../utilities/getFieldByPath.js' import { baseBlockFields } from '../baseFields/baseBlockFields.js' import { baseIDField } from '../baseFields/baseIDField.js' import { baseTimezoneField } from '../baseFields/timezone/baseField.js' @@ -31,13 +35,19 @@ import { reservedVerifyFieldNames, } from './reservedFieldNames.js' import { sanitizeJoinField } from './sanitizeJoinField.js' -import { fieldAffectsData as _fieldAffectsData, fieldIsLocalized, tabHasName } from './types.js' +import { + fieldAffectsData as _fieldAffectsData, + fieldIsLocalized, + fieldIsVirtual, + tabHasName, +} from './types.js' type Args = { collectionConfig?: CollectionConfig config: Config existingFieldNames?: Set fields: Field[] + globalConfig?: GlobalConfig /** * Used to prevent unnecessary sanitization of fields that are not top-level. */ @@ -72,6 +82,7 @@ export const sanitizeFields = async ({ config, existingFieldNames = new Set(), fields, + globalConfig, isTopLevelField = true, joinPath = '', joins, @@ -416,6 +427,51 @@ export const sanitizeFields = async ({ fields.splice(++i, 0, timezoneField) } + + if ('virtual' in field && typeof field.virtual === 'string') { + const virtualField = field + const fields = (collectionConfig || globalConfig)?.fields + if (fields) { + let flattenFields = flattenAllFields({ fields }) + const paths = field.virtual.split('.') + let isHasMany = false + + for (const [i, segment] of paths.entries()) { + const field = flattenFields.find((e) => e.name === segment) + if (!field) { + break + } + + if (field.type === 'group' || field.type === 'tab' || field.type === 'array') { + flattenFields = field.flattenedFields + } else if ( + (field.type === 'relationship' || field.type === 'upload') && + i !== paths.length - 1 && + typeof field.relationTo === 'string' + ) { + if ( + field.hasMany && + (virtualField.type === 'text' || + virtualField.type === 'number' || + virtualField.type === 'select') + ) { + if (isHasMany) { + throw new InvalidConfiguration( + `Virtual field ${virtualField.name} in ${globalConfig ? `global ${globalConfig.slug}` : `collection ${collectionConfig?.slug}`} references 2 or more hasMany relationships on the path ${virtualField.virtual} which is not allowed.`, + ) + } + + isHasMany = true + virtualField.hasMany = true + } + const relatedCollection = config.collections?.find((e) => e.slug === field.relationTo) + if (relatedCollection) { + flattenFields = flattenAllFields({ fields: relatedCollection.fields }) + } + } + } + } + } } return fields diff --git a/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts b/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts index 5c8386a0b4a..0a47d096dbd 100644 --- a/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts +++ b/packages/payload/src/fields/hooks/afterRead/virtualFieldPopulationPromise.ts @@ -8,6 +8,7 @@ export const virtualFieldPopulationPromise = async ({ draft, fallbackLocale, fields, + hasMany, locale, overrideAccess, ref, @@ -19,12 +20,14 @@ export const virtualFieldPopulationPromise = async ({ draft: boolean fallbackLocale: string fields: FlattenedField[] + hasMany?: boolean locale: string name: string overrideAccess: boolean ref: any req: PayloadRequest segments: string[] + shift?: boolean showHiddenFields: boolean siblingDoc: Record }): Promise => { @@ -42,7 +45,14 @@ export const virtualFieldPopulationPromise = async ({ // Final step if (segments.length === 0) { - siblingDoc[name] = currentValue + if (hasMany) { + if (!Array.isArray(siblingDoc[name])) { + siblingDoc[name] = [] + } + ;(siblingDoc[name] as any[]).push(currentValue) + } else { + siblingDoc[name] = currentValue + } return } @@ -74,26 +84,8 @@ export const virtualFieldPopulationPromise = async ({ if ( (currentField.type === 'relationship' || currentField.type === 'upload') && - typeof currentField.relationTo === 'string' && - !currentField.hasMany + typeof currentField.relationTo === 'string' ) { - let docID: number | string - - if (typeof currentValue === 'object' && currentValue) { - docID = currentValue.id - } else { - docID = currentValue - } - - if (segments[0] === 'id' && segments.length === 0) { - siblingDoc[name] = docID - return - } - - if (typeof docID !== 'string' && typeof docID !== 'number') { - return - } - const select = {} let currentSelectRef: any = select const currentFields = req.payload.collections[currentField.relationTo]?.config.flattenedFields @@ -112,6 +104,91 @@ export const virtualFieldPopulationPromise = async ({ } } + if (currentField.hasMany) { + if (!Array.isArray(currentValue)) { + return + } + + const docIDs = currentValue + .map((e) => { + if (!e) { + return null + } + if (typeof e === 'object') { + return e.id + } + return e + }) + .filter((e) => typeof e === 'string' || typeof e === 'number') + + if (segments[0] === 'id' && segments.length === 0) { + siblingDoc[name] = docIDs + return + } + + const collectionSlug = currentField.relationTo + + const populatedDocs = await Promise.all( + docIDs.map((docID) => { + return req.payloadDataLoader.load( + createDataloaderCacheKey({ + collectionSlug, + currentDepth: 0, + depth: 0, + docID, + draft, + fallbackLocale, + locale, + overrideAccess, + select, + showHiddenFields, + transactionID: req.transactionID as number, + }), + ) + }), + ) + + for (const doc of populatedDocs) { + if (!doc) { + continue + } + + await virtualFieldPopulationPromise({ + name, + draft, + fallbackLocale, + fields: req.payload.collections[currentField.relationTo]!.config.flattenedFields, + hasMany: true, + locale, + overrideAccess, + ref: doc, + req, + segments: [...segments], + showHiddenFields, + siblingDoc, + }) + } + + return + } + + let docID: number | string + + if (typeof currentValue === 'object' && currentValue) { + docID = currentValue.id + } else { + docID = currentValue + } + + if (segments[0] === 'id' && segments.length === 0) { + siblingDoc[name] = docID + return + } + + if (typeof docID !== 'string' && typeof docID !== 'number') { + return + } + const populatedDoc = await req.payloadDataLoader.load( createDataloaderCacheKey({ collectionSlug: currentField.relationTo, @@ -137,6 +214,7 @@ export const virtualFieldPopulationPromise = async ({ draft, fallbackLocale, fields: req.payload.collections[currentField.relationTo]!.config.flattenedFields, + hasMany, locale, overrideAccess, ref: populatedDoc, diff --git a/packages/payload/src/globals/config/sanitize.ts b/packages/payload/src/globals/config/sanitize.ts index dbb1795b5fa..d79d3f580a1 100644 --- a/packages/payload/src/globals/config/sanitize.ts +++ b/packages/payload/src/globals/config/sanitize.ts @@ -81,6 +81,7 @@ export const sanitizeGlobal = async ( global.fields = await sanitizeFields({ config, fields: global.fields, + globalConfig: global, parentIsLocalized: false, richTextSanitizationPromises, validRelationships, diff --git a/test/database/getConfig.ts b/test/database/getConfig.ts index ea3cbde13aa..b4face455fe 100644 --- a/test/database/getConfig.ts +++ b/test/database/getConfig.ts @@ -607,6 +607,16 @@ export const getConfig: () => Partial = () => ({ type: 'text', virtual: 'post.title', }, + { + name: 'postsTitles', + type: 'text', + virtual: 'posts.title', + }, + { + name: 'postCategoriesTitles', + type: 'text', + virtual: 'post.categories.title', + }, { name: 'postTitleHidden', type: 'text', @@ -643,6 +653,12 @@ export const getConfig: () => Partial = () => ({ type: 'relationship', relationTo: 'posts', }, + { + name: 'posts', + type: 'relationship', + relationTo: 'posts', + hasMany: true, + }, { name: 'customID', type: 'relationship', diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index cd3f313aca8..ee75016b664 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -2988,6 +2988,58 @@ describe('database', () => { }) expect(docs).toHaveLength(1) }) + + it('should automatically add hasMany: true to a virtual field that references a hasMany relationship', () => { + const field = payload.collections['virtual-relations'].config.fields.find( + // eslint-disable-next-line jest/no-conditional-in-test + (each) => 'name' in each && each.name === 'postsTitles', + )! + + // eslint-disable-next-line jest/no-conditional-in-test + expect('hasMany' in field && field.hasMany).toBe(true) + }) + + it('should the value populate with hasMany: true relationship field', async () => { + await payload.delete({ collection: 'categories', where: {} }) + await payload.delete({ collection: 'posts', where: {} }) + await payload.delete({ collection: 'virtual-relations', where: {} }) + + const post1 = await payload.create({ collection: 'posts', data: { title: 'post 1' } }) + const post2 = await payload.create({ collection: 'posts', data: { title: 'post 2' } }) + + const res = await payload.create({ + collection: 'virtual-relations', + depth: 0, + data: { posts: [post1.id, post2.id] }, + }) + expect(res.postsTitles).toEqual(['post 1', 'post 2']) + }) + + it('should the value populate with nested hasMany: true relationship field', async () => { + await payload.delete({ collection: 'categories', where: {} }) + await payload.delete({ collection: 'posts', where: {} }) + await payload.delete({ collection: 'virtual-relations', where: {} }) + + const category_1 = await payload.create({ + collection: 'categories', + data: { title: 'category 1' }, + }) + const category_2 = await payload.create({ + collection: 'categories', + data: { title: 'category 2' }, + }) + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'post 1', categories: [category_1.id, category_2.id] }, + }) + + const res = await payload.create({ + collection: 'virtual-relations', + depth: 0, + data: { post: post1.id }, + }) + expect(res.postCategoriesTitles).toEqual(['category 1', 'category 2']) + }) }) it('should convert numbers to text', async () => { diff --git a/test/database/payload-types.ts b/test/database/payload-types.ts index 4aa780ddf97..3fa9fa2a189 100644 --- a/test/database/payload-types.ts +++ b/test/database/payload-types.ts @@ -327,7 +327,7 @@ export interface RelationA { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -353,7 +353,7 @@ export interface RelationB { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -450,6 +450,8 @@ export interface Place { export interface VirtualRelation { id: string; postTitle?: string | null; + postsTitles?: string[] | null; + postCategoriesTitles?: string[] | null; postTitleHidden?: string | null; postCategoryTitle?: string | null; postCategoryID?: @@ -473,6 +475,7 @@ export interface VirtualRelation { | null; postLocalized?: string | null; post?: (string | null) | Post; + posts?: (string | Post)[] | null; customID?: (string | null) | CustomId; customIDValue?: string | null; updatedAt: string; @@ -1046,6 +1049,8 @@ export interface PlacesSelect { */ export interface VirtualRelationsSelect { postTitle?: T; + postsTitles?: T; + postCategoriesTitles?: T; postTitleHidden?: T; postCategoryTitle?: T; postCategoryID?: T; @@ -1053,6 +1058,7 @@ export interface VirtualRelationsSelect { postID?: T; postLocalized?: T; post?: T; + posts?: T; customID?: T; customIDValue?: T; updatedAt?: T;