diff --git a/demo/collections/DefaultValues.ts b/demo/collections/DefaultValues.ts index 898fe7b253..352ebcd3c4 100644 --- a/demo/collections/DefaultValues.ts +++ b/demo/collections/DefaultValues.ts @@ -112,7 +112,6 @@ const DefaultValues: CollectionConfig = { label: 'Group', name: 'group', defaultValue: { - nestedText1: 'this should take priority', nestedText2: 'nested default text 2', nestedText3: 'neat', }, @@ -124,6 +123,7 @@ const DefaultValues: CollectionConfig = { name: 'nestedText1', label: 'Nested Text 1', type: 'text', + defaultValue: 'this should take priority', }, { name: 'nestedText2', diff --git a/src/auth/operations/login.ts b/src/auth/operations/login.ts index 3cfc7cbe92..2ff28e0bf1 100644 --- a/src/auth/operations/login.ts +++ b/src/auth/operations/login.ts @@ -9,6 +9,7 @@ import { Field, fieldHasSubFields, fieldAffectsData } from '../../fields/config/ import { User } from '../types'; import { Collection } from '../../collections/config/types'; import { Payload } from '../..'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Result = { user?: User, @@ -171,15 +172,12 @@ async function login(this: Payload, incomingArgs: Arguments): Promise { // afterRead - Fields // ///////////////////////////////////// - user = await this.performFieldOperations(collectionConfig, { + user = await afterRead({ depth, - req, - id: user.id, - data: user, - hook: 'afterRead', - operation: 'read', + doc: user, + entityConfig: collectionConfig, overrideAccess, - flattenLocales: true, + req, showHiddenFields, }); diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index 35215a77b9..fa748fbf09 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -12,6 +12,10 @@ import { Document } from '../../types'; import { Payload } from '../..'; import { fieldAffectsData } from '../../fields/config/types'; import uploadFile from '../../uploads/uploadFile'; +import { beforeChange } from '../../fields/hooks/beforeChange'; +import { beforeValidate } from '../../fields/hooks/beforeValidate'; +import { afterChange } from '../../fields/hooks/afterChange'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { collection: Collection @@ -99,12 +103,13 @@ async function create(this: Payload, incomingArgs: Arguments): Promise // beforeValidate - Fields // ///////////////////////////////////// - data = await this.performFieldOperations(collectionConfig, { + data = await beforeValidate({ data, - req, - hook: 'beforeValidate', + doc: {}, + entityConfig: collectionConfig, operation: 'create', overrideAccess, + req, }); // ///////////////////////////////////// @@ -139,13 +144,13 @@ async function create(this: Payload, incomingArgs: Arguments): Promise // beforeChange - Fields // ///////////////////////////////////// - const resultWithLocales = await this.performFieldOperations(collectionConfig, { + const resultWithLocales = await beforeChange({ data, - hook: 'beforeChange', + doc: {}, + docWithLocales: {}, + entityConfig: collectionConfig, operation: 'create', req, - overrideAccess, - unflattenLocales: true, skipValidation: shouldSaveDraft, }); @@ -214,14 +219,12 @@ async function create(this: Payload, incomingArgs: Arguments): Promise // afterRead - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(collectionConfig, { + result = await afterRead({ depth, - req, - data: result, - hook: 'afterRead', - operation: 'create', + doc: result, + entityConfig: collectionConfig, overrideAccess, - flattenLocales: true, + req, showHiddenFields, }); @@ -242,14 +245,12 @@ async function create(this: Payload, incomingArgs: Arguments): Promise // afterChange - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(collectionConfig, { - data: result, - hook: 'afterChange', + result = await afterChange({ + data, + doc: result, + entityConfig: collectionConfig, operation: 'create', req, - depth, - overrideAccess, - showHiddenFields, }); // ///////////////////////////////////// diff --git a/src/collections/operations/delete.ts b/src/collections/operations/delete.ts index 14155309f5..47c3d81438 100644 --- a/src/collections/operations/delete.ts +++ b/src/collections/operations/delete.ts @@ -10,6 +10,7 @@ import { Document, Where } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; import { FileData } from '../../uploads/types'; import fileExists from '../../uploads/fileExists'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { depth?: number @@ -173,14 +174,12 @@ async function deleteQuery(incomingArgs: Arguments): Promise { // afterRead - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(collectionConfig, { + result = await afterRead({ depth, - req, - data: result, - hook: 'afterRead', - operation: 'delete', + doc: result, + entityConfig: collectionConfig, overrideAccess, - flattenLocales: true, + req, showHiddenFields, }); diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index 3fc1472322..3c4ee95501 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -9,6 +9,7 @@ import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; import { buildSortParam } from '../../mongoose/buildSortParam'; import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable'; import { AccessResult } from '../../config/types'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { collection: Collection @@ -169,20 +170,14 @@ async function find(incomingArgs: Arguments): Promis result = { ...result, - docs: await Promise.all(result.docs.map(async (data) => this.performFieldOperations( - collectionConfig, - { - depth, - data, - req, - id: data.id, - hook: 'afterRead', - operation: 'read', - overrideAccess, - flattenLocales: true, - showHiddenFields, - }, - ))), + docs: await Promise.all(result.docs.map(async (doc) => afterRead({ + depth, + doc, + entityConfig: collectionConfig, + overrideAccess, + req, + showHiddenFields, + }))), }; // ///////////////////////////////////// diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts index 33e4060f69..d50172b669 100644 --- a/src/collections/operations/findByID.ts +++ b/src/collections/operations/findByID.ts @@ -9,6 +9,7 @@ import executeAccess from '../../auth/executeAccess'; import { Where } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { collection: Collection @@ -149,16 +150,13 @@ async function findByID(this: Payload, incomingArgs: // afterRead - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(collectionConfig, { - depth, - req, - id, - data: result, - hook: 'afterRead', - operation: 'read', + result = await afterRead({ currentDepth, + doc: result, + depth, + entityConfig: collectionConfig, overrideAccess, - flattenLocales: true, + req, showHiddenFields, }); diff --git a/src/collections/operations/findVersionByID.ts b/src/collections/operations/findVersionByID.ts index 5463e4c230..39d1b10174 100644 --- a/src/collections/operations/findVersionByID.ts +++ b/src/collections/operations/findVersionByID.ts @@ -9,6 +9,7 @@ import executeAccess from '../../auth/executeAccess'; import { Where } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; import { TypeWithVersion } from '../../versions/types'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { collection: Collection @@ -113,16 +114,13 @@ async function findVersionByID = any>(this: Payload // afterRead - Fields // ///////////////////////////////////// - result.version = await this.performFieldOperations(collectionConfig, { - depth, - req, - id, - data: result.version, - hook: 'afterRead', - operation: 'read', + result.version = await afterRead({ currentDepth, + depth, + doc: result.version, + entityConfig: collectionConfig, overrideAccess, - flattenLocales: true, + req, showHiddenFields, }); diff --git a/src/collections/operations/findVersions.ts b/src/collections/operations/findVersions.ts index 3432092440..f516813233 100644 --- a/src/collections/operations/findVersions.ts +++ b/src/collections/operations/findVersions.ts @@ -9,6 +9,7 @@ import { buildSortParam } from '../../mongoose/buildSortParam'; import { PaginatedDocs } from '../../mongoose/types'; import { TypeWithVersion } from '../../versions/types'; import { Payload } from '../../index'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { collection: Collection @@ -131,21 +132,14 @@ async function findVersions = any>(this: Payload, a ...result, docs: await Promise.all(result.docs.map(async (data) => ({ ...data, - version: await this.performFieldOperations( - collectionConfig, - { - depth, - data: data.version, - req, - id: data.version.id, - hook: 'afterRead', - operation: 'read', - overrideAccess, - flattenLocales: true, - showHiddenFields, - isVersion: true, - }, - ), + version: await afterRead({ + depth, + doc: data.version, + entityConfig: collectionConfig, + overrideAccess, + req, + showHiddenFields, + }), }))), }; diff --git a/src/collections/operations/restoreVersion.ts b/src/collections/operations/restoreVersion.ts index 1fde3d1705..dc8e2c3bc1 100644 --- a/src/collections/operations/restoreVersion.ts +++ b/src/collections/operations/restoreVersion.ts @@ -8,6 +8,8 @@ import { Payload } from '../../index'; import { hasWhereAccessResult } from '../../auth/types'; import { Where } from '../../types'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; +import { afterChange } from '../../fields/hooks/afterChange'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { collection: Collection @@ -114,15 +116,12 @@ async function restoreVersion(this: Payload, args: A // afterRead - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(collectionConfig, { - id: parentDocID, + result = await afterRead({ depth, + doc: result, + entityConfig: collectionConfig, req, - data: result, - hook: 'afterRead', - operation: 'update', overrideAccess, - flattenLocales: true, showHiddenFields, }); @@ -143,15 +142,12 @@ async function restoreVersion(this: Payload, args: A // afterChange - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(collectionConfig, { + result = await afterChange({ data: result, - hook: 'afterChange', + doc: result, + entityConfig: collectionConfig, operation: 'update', req, - id: parentDocID, - depth, - overrideAccess, - showHiddenFields, }); // ///////////////////////////////////// diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 98ed7929ff..6d1ab15227 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -12,6 +12,10 @@ import { saveCollectionVersion } from '../../versions/saveCollectionVersion'; import uploadFile from '../../uploads/uploadFile'; import cleanUpFailedVersion from '../../versions/cleanUpFailedVersion'; import { ensurePublishedCollectionVersion } from '../../versions/ensurePublishedCollectionVersion'; +import { beforeChange } from '../../fields/hooks/beforeChange'; +import { beforeValidate } from '../../fields/hooks/beforeValidate'; +import { afterChange } from '../../fields/hooks/afterChange'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { collection: Collection @@ -110,15 +114,12 @@ async function update(this: Payload, incomingArgs: Arguments): Promise docWithLocales = JSON.stringify(docWithLocales); docWithLocales = JSON.parse(docWithLocales); - const originalDoc = await this.performFieldOperations(collectionConfig, { - id, + const originalDoc = await afterRead({ depth: 0, + doc: docWithLocales, + entityConfig: collectionConfig, req, - data: docWithLocales, - hook: 'afterRead', - operation: 'update', overrideAccess: true, - flattenLocales: true, showHiddenFields, }); @@ -139,14 +140,14 @@ async function update(this: Payload, incomingArgs: Arguments): Promise // beforeValidate - Fields // ///////////////////////////////////// - data = await this.performFieldOperations(collectionConfig, { + data = await beforeValidate({ data, - req, + doc: originalDoc, + entityConfig: collectionConfig, id, - originalDoc, - hook: 'beforeValidate', operation: 'update', overrideAccess, + req, }); // // ///////////////////////////////////// @@ -183,16 +184,14 @@ async function update(this: Payload, incomingArgs: Arguments): Promise // beforeChange - Fields // ///////////////////////////////////// - let result = await this.performFieldOperations(collectionConfig, { + let result = await beforeChange({ data, - req, - id, - originalDoc, - hook: 'beforeChange', - operation: 'update', - overrideAccess, - unflattenLocales: true, + doc: originalDoc, docWithLocales, + entityConfig: collectionConfig, + id, + operation: 'update', + req, skipValidation: shouldSaveDraft, }); @@ -266,8 +265,8 @@ async function update(this: Payload, incomingArgs: Arguments): Promise : error; } - result = JSON.stringify(result); - result = JSON.parse(result); + const resultString = JSON.stringify(result); + result = JSON.parse(resultString); // custom id type reset result.id = result._id; @@ -279,15 +278,12 @@ async function update(this: Payload, incomingArgs: Arguments): Promise // afterRead - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(collectionConfig, { - id, + result = await afterRead({ depth, + doc: result, + entityConfig: collectionConfig, req, - data: result, - hook: 'afterRead', - operation: 'update', overrideAccess, - flattenLocales: true, showHiddenFields, }); @@ -308,15 +304,12 @@ async function update(this: Payload, incomingArgs: Arguments): Promise // afterChange - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(collectionConfig, { - data: result, - hook: 'afterChange', + result = await afterChange({ + data, + doc: result, + entityConfig: collectionConfig, operation: 'update', req, - id, - depth, - overrideAccess, - showHiddenFields, }); // ///////////////////////////////////// diff --git a/src/fields/accessPromise.ts b/src/fields/accessPromise.ts deleted file mode 100644 index 5039aa5f16..0000000000 --- a/src/fields/accessPromise.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Payload } from '..'; -import { HookName, FieldAffectingData } from './config/types'; -import relationshipPopulationPromise from './relationshipPopulationPromise'; -import { Operation } from '../types'; -import { PayloadRequest } from '../express/types'; - -type Arguments = { - data: Record - fullData: Record - originalDoc: Record - field: FieldAffectingData - operation: Operation - overrideAccess: boolean - req: PayloadRequest - id: string | number - relationshipPopulations: (() => Promise)[] - depth: number - currentDepth: number - hook: HookName - payload: Payload - showHiddenFields: boolean -} - -const accessPromise = async ({ - data, - fullData, - field, - operation, - overrideAccess, - req, - id, - relationshipPopulations, - depth, - currentDepth, - hook, - payload, - showHiddenFields, - originalDoc, -}: Arguments): Promise => { - const resultingData = data; - - let accessOperation; - - if (hook === 'afterRead') { - accessOperation = 'read'; - } else if (hook === 'beforeValidate') { - if (operation === 'update') accessOperation = 'update'; - if (operation === 'create') accessOperation = 'create'; - } - - if (field.access && field.access[accessOperation]) { - const result = overrideAccess ? true : await field.access[accessOperation]({ req, id, siblingData: data, data: fullData, doc: originalDoc }); - - if (!result) { - delete resultingData[field.name]; - } - } - - if ((field.type === 'relationship' || field.type === 'upload') && hook === 'afterRead') { - relationshipPopulations.push(relationshipPopulationPromise({ - showHiddenFields, - data, - field, - depth, - currentDepth, - req, - overrideAccess, - payload, - })); - } -}; - -export default accessPromise; diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 44f90ea00d..1b1acc23f5 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -22,7 +22,7 @@ export type FieldHook = (args: Fie export type FieldAccess = (args: { req: PayloadRequest - id?: string + id?: string | number data?: Partial siblingData?: Partial

doc?: T @@ -228,6 +228,10 @@ export type ValueWithRelation = { value: string | number } +export function valueIsValueWithRelation(value: unknown): value is ValueWithRelation { + return typeof value === 'object' && 'relationTo' in value && 'value' in value; +} + export type RelationshipValue = (string | number) | (string | number)[] | ValueWithRelation diff --git a/src/fields/hookPromise.ts b/src/fields/hookPromise.ts deleted file mode 100644 index bb1e2c9794..0000000000 --- a/src/fields/hookPromise.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { PayloadRequest } from '../express/types'; -import { Operation } from '../types'; -import { HookName, FieldAffectingData, FieldHook } from './config/types'; - -type Arguments = { - data: Record - field: FieldAffectingData - hook: HookName - req: PayloadRequest - operation: Operation - fullOriginalDoc: Record - fullData: Record - flattenLocales: boolean - isVersion: boolean -} - -type ExecuteHookArguments = { - currentHook: FieldHook - value: unknown -} & Arguments; - -const executeHook = async ({ - currentHook, - fullOriginalDoc, - fullData, - data, - operation, - req, - value, -}: ExecuteHookArguments) => { - let hookedValue = await currentHook({ - value, - originalDoc: fullOriginalDoc, - data: fullData, - siblingData: data, - operation, - req, - }); - - if (typeof hookedValue === 'undefined') { - hookedValue = value; - } - - return hookedValue; -}; - -const hookPromise = async (args: Arguments): Promise => { - const { - field, - hook, - req, - flattenLocales, - data, - } = args; - - if (field.hooks && field.hooks[hook]) { - await field.hooks[hook].reduce(async (priorHook, currentHook) => { - await priorHook; - - const shouldRunHookOnAllLocales = hook === 'afterRead' - && field.localized - && (req.locale === 'all' || !flattenLocales) - && typeof data[field.name] === 'object'; - - if (shouldRunHookOnAllLocales) { - const hookPromises = Object.entries(data[field.name]).map(([locale, value]) => (async () => { - const hookedValue = await executeHook({ - ...args, - currentHook, - value, - }); - - if (hookedValue !== undefined) { - data[field.name][locale] = hookedValue; - } - })()); - - await Promise.all(hookPromises); - } else { - const hookedValue = await executeHook({ - ...args, - value: data[field.name], - currentHook, - }); - - if (hookedValue !== undefined) { - data[field.name] = hookedValue; - } - } - }, Promise.resolve()); - } -}; - -export default hookPromise; diff --git a/src/fields/hooks/afterChange/index.ts b/src/fields/hooks/afterChange/index.ts new file mode 100644 index 0000000000..a4851bb430 --- /dev/null +++ b/src/fields/hooks/afterChange/index.ts @@ -0,0 +1,40 @@ +import { SanitizedCollectionConfig } from '../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../globals/config/types'; +import { PayloadRequest } from '../../../express/types'; +import { traverseFields } from './traverseFields'; +import deepCopyObject from '../../../utilities/deepCopyObject'; + +type Args = { + data: Record + doc: Record + entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig + operation: 'create' | 'update' + req: PayloadRequest +} + +export const afterChange = async ({ + data, + doc: incomingDoc, + entityConfig, + operation, + req, +}: Args): Promise> => { + const promises = []; + + const doc = deepCopyObject(incomingDoc); + + traverseFields({ + data, + doc, + fields: entityConfig.fields, + operation, + promises, + req, + siblingDoc: doc, + siblingData: data, + }); + + await Promise.all(promises); + + return doc; +}; diff --git a/src/fields/hooks/afterChange/promise.ts b/src/fields/hooks/afterChange/promise.ts new file mode 100644 index 0000000000..5a8b08ba16 --- /dev/null +++ b/src/fields/hooks/afterChange/promise.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-param-reassign */ +import { PayloadRequest } from '../../../express/types'; +import { Field, fieldAffectsData } from '../../config/types'; +import { traverseFields } from './traverseFields'; + +type Args = { + data: Record + doc: Record + field: Field + operation: 'create' | 'update' + promises: Promise[] + req: PayloadRequest + siblingData: Record + siblingDoc: Record +} + +// This function is responsible for the following actions, in order: +// - Execute field hooks + +export const promise = async ({ + data, + doc, + field, + operation, + promises, + req, + siblingData, + siblingDoc, +}: Args): Promise => { + if (fieldAffectsData(field)) { + // Execute hooks + if (field.hooks?.afterChange) { + await field.hooks.afterChange.reduce(async (priorHook, currentHook) => { + await priorHook; + + const hookedValue = await currentHook({ + value: siblingData[field.name], + originalDoc: doc, + data, + siblingData, + operation, + req, + }); + + if (hookedValue !== undefined) { + siblingDoc[field.name] = hookedValue; + } + }, Promise.resolve()); + } + } + + // Traverse subfields + switch (field.type) { + case 'group': { + traverseFields({ + data, + doc, + fields: field.fields, + operation, + promises, + req, + siblingData: siblingData[field.name] as Record || {}, + siblingDoc: siblingDoc[field.name] as Record, + }); + + break; + } + + case 'array': { + const rows = siblingDoc[field.name]; + + if (Array.isArray(rows)) { + rows.forEach((row, i) => { + traverseFields({ + data, + doc, + fields: field.fields, + operation, + promises, + req, + siblingData: siblingData[field.name]?.[i] || {}, + siblingDoc: { ...row } || {}, + }); + }); + } + break; + } + + case 'blocks': { + const rows = siblingDoc[field.name]; + + if (Array.isArray(rows)) { + rows.forEach((row, i) => { + const block = field.blocks.find((blockType) => blockType.slug === row.blockType); + + if (block) { + traverseFields({ + data, + doc, + fields: block.fields, + operation, + promises, + req, + siblingData: siblingData[field.name]?.[i] || {}, + siblingDoc: { ...row } || {}, + }); + } + }); + } + + break; + } + + case 'row': { + traverseFields({ + data, + doc, + fields: field.fields, + operation, + promises, + req, + siblingData: siblingData || {}, + siblingDoc: { ...siblingDoc }, + }); + + break; + } + + default: { + break; + } + } +}; diff --git a/src/fields/hooks/afterChange/traverseFields.ts b/src/fields/hooks/afterChange/traverseFields.ts new file mode 100644 index 0000000000..2da6d8cd4b --- /dev/null +++ b/src/fields/hooks/afterChange/traverseFields.ts @@ -0,0 +1,38 @@ +import { Field } from '../../config/types'; +import { promise } from './promise'; +import { PayloadRequest } from '../../../express/types'; + +type Args = { + data: Record + doc: Record + fields: Field[] + operation: 'create' | 'update' + promises: Promise[] + req: PayloadRequest + siblingData: Record + siblingDoc: Record +} + +export const traverseFields = ({ + data, + doc, + fields, + operation, + promises, + req, + siblingData, + siblingDoc, +}: Args): void => { + fields.forEach((field) => { + promises.push(promise({ + data, + doc, + field, + operation, + promises, + req, + siblingData, + siblingDoc, + })); + }); +}; diff --git a/src/fields/hooks/afterRead/index.ts b/src/fields/hooks/afterRead/index.ts new file mode 100644 index 0000000000..2d083956e3 --- /dev/null +++ b/src/fields/hooks/afterRead/index.ts @@ -0,0 +1,62 @@ +import { SanitizedCollectionConfig } from '../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../globals/config/types'; +import { PayloadRequest } from '../../../express/types'; +import { traverseFields } from './traverseFields'; +import deepCopyObject from '../../../utilities/deepCopyObject'; + +type Args = { + currentDepth?: number + depth: number + doc: Record + entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig + flattenLocales?: boolean + req: PayloadRequest + overrideAccess: boolean + showHiddenFields: boolean +} + +export async function afterRead(args: Args): Promise { + const { + currentDepth: incomingCurrentDepth, + depth: incomingDepth, + doc: incomingDoc, + entityConfig, + flattenLocales = true, + req, + overrideAccess, + showHiddenFields, + } = args; + + const doc = deepCopyObject(incomingDoc); + const fieldPromises = []; + const populationPromises = []; + + let depth = 0; + + if (req.payloadAPI === 'REST' || req.payloadAPI === 'local') { + depth = (incomingDepth || incomingDepth === 0) ? parseInt(String(incomingDepth), 10) : req.payload.config.defaultDepth; + + if (depth > req.payload.config.maxDepth) depth = req.payload.config.maxDepth; + } + + const currentDepth = incomingCurrentDepth || 1; + + traverseFields({ + currentDepth, + depth, + doc, + fields: entityConfig.fields, + fieldPromises, + flattenLocales, + overrideAccess, + populationPromises, + req, + siblingDoc: doc, + showHiddenFields, + }); + + await Promise.all(fieldPromises); + await Promise.all(populationPromises); + + return doc; +} diff --git a/src/fields/hooks/afterRead/promise.ts b/src/fields/hooks/afterRead/promise.ts new file mode 100644 index 0000000000..7f2d5f8b94 --- /dev/null +++ b/src/fields/hooks/afterRead/promise.ts @@ -0,0 +1,265 @@ +/* eslint-disable no-param-reassign */ +import { Field, fieldAffectsData } from '../../config/types'; +import { PayloadRequest } from '../../../express/types'; +import { traverseFields } from './traverseFields'; +import richTextRelationshipPromise from '../../richText/relationshipPromise'; +import relationshipPopulationPromise from './relationshipPopulationPromise'; + +type Args = { + currentDepth: number + depth: number + doc: Record + field: Field + fieldPromises: Promise[] + flattenLocales: boolean + populationPromises: Promise[] + req: PayloadRequest + overrideAccess: boolean + siblingDoc: Record + showHiddenFields: boolean +} + +// This function is responsible for the following actions, in order: +// - Remove hidden fields from response +// - Flatten locales into requested locale +// - Sanitize outgoing data (point field, etc) +// - Execute field hooks +// - Execute read access control +// - Populate relationships + +export const promise = async ({ + currentDepth, + depth, + doc, + field, + fieldPromises, + flattenLocales, + overrideAccess, + populationPromises, + req, + siblingDoc, + showHiddenFields, +}: Args): Promise => { + if (fieldAffectsData(field) && field.hidden && typeof siblingDoc[field.name] !== 'undefined' && !showHiddenFields) { + delete siblingDoc[field.name]; + } + + const hasLocalizedValue = flattenLocales + && fieldAffectsData(field) + && (typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null) + && field.name + && field.localized + && req.locale !== 'all'; + + if (hasLocalizedValue) { + let localizedValue = siblingDoc[field.name][req.locale]; + if (typeof localizedValue === 'undefined' && req.fallbackLocale) localizedValue = siblingDoc[field.name][req.fallbackLocale]; + if (typeof localizedValue === 'undefined' && field.type === 'group') localizedValue = {}; + if (typeof localizedValue === 'undefined') localizedValue = null; + siblingDoc[field.name] = localizedValue; + } + + // Sanitize outgoing data + switch (field.type) { + case 'group': { + // Fill groups with empty objects so fields with hooks within groups can populate + // themselves virtually as necessary + if (typeof siblingDoc[field.name] === 'undefined') { + siblingDoc[field.name] = {}; + } + + break; + } + + case 'richText': { + if (((field.admin?.elements?.includes('relationship') || field.admin?.elements?.includes('upload')) || !field?.admin?.elements)) { + populationPromises.push(richTextRelationshipPromise({ + currentDepth, + depth, + field, + overrideAccess, + req, + siblingDoc, + showHiddenFields, + })); + } + + break; + } + + case 'point': { + const pointDoc = siblingDoc[field.name] as any; + if (Array.isArray(pointDoc?.coordinates) && pointDoc.coordinates.length === 2) { + siblingDoc[field.name] = pointDoc.coordinates; + } + + break; + } + + default: { + break; + } + } + + if (fieldAffectsData(field)) { + // Execute hooks + if (field.hooks?.afterRead) { + await field.hooks.afterRead.reduce(async (priorHook, currentHook) => { + await priorHook; + + const shouldRunHookOnAllLocales = field.localized + && (req.locale === 'all' || !flattenLocales) + && typeof siblingDoc[field.name] === 'object'; + + if (shouldRunHookOnAllLocales) { + const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) => (async () => { + const hookedValue = await currentHook({ + value, + originalDoc: doc, + data: doc, + siblingData: siblingDoc[field.name], + operation: 'read', + req, + }); + + if (hookedValue !== undefined) { + siblingDoc[field.name][locale] = hookedValue; + } + })()); + + await Promise.all(hookPromises); + } else { + const hookedValue = await currentHook({ + value: siblingDoc[field.name], + originalDoc: doc, + data: doc, + siblingData: siblingDoc[field.name], + operation: 'read', + req, + }); + + if (hookedValue !== undefined) { + siblingDoc[field.name] = hookedValue; + } + } + }, Promise.resolve()); + } + + // Execute access control + if (field.access && field.access.read) { + const result = overrideAccess ? true : await field.access.read({ req, id: doc.id as string | number, siblingData: siblingDoc, data: doc, doc }); + + if (!result) { + delete siblingDoc[field.name]; + } + } + + if (field.type === 'relationship' || field.type === 'upload') { + populationPromises.push(relationshipPopulationPromise({ + currentDepth, + depth, + field, + overrideAccess, + req, + showHiddenFields, + siblingDoc, + })); + } + } + + switch (field.type) { + case 'group': { + let groupDoc = siblingDoc[field.name] as Record; + if (typeof siblingDoc[field.name] !== 'object') groupDoc = {}; + + traverseFields({ + currentDepth, + depth, + doc, + fieldPromises, + fields: field.fields, + flattenLocales, + overrideAccess, + populationPromises, + req, + siblingDoc: groupDoc, + showHiddenFields, + }); + + break; + } + + case 'array': { + const rows = siblingDoc[field.name]; + + if (Array.isArray(rows)) { + rows.forEach((row, i) => { + traverseFields({ + currentDepth, + depth, + doc, + fields: field.fields, + fieldPromises, + flattenLocales, + overrideAccess, + populationPromises, + req, + siblingDoc: row || {}, + showHiddenFields, + }); + }); + } + break; + } + + case 'blocks': { + const rows = siblingDoc[field.name]; + + if (Array.isArray(rows)) { + rows.forEach((row, i) => { + const block = field.blocks.find((blockType) => blockType.slug === row.blockType); + + if (block) { + traverseFields({ + currentDepth, + depth, + doc, + fields: block.fields, + fieldPromises, + flattenLocales, + overrideAccess, + populationPromises, + req, + siblingDoc: row || {}, + showHiddenFields, + }); + } + }); + } + + break; + } + + case 'row': { + traverseFields({ + currentDepth, + depth, + doc, + fieldPromises, + fields: field.fields, + flattenLocales, + overrideAccess, + populationPromises, + req, + siblingDoc, + showHiddenFields, + }); + + break; + } + + default: { + break; + } + } +}; diff --git a/src/fields/relationshipPopulationPromise.ts b/src/fields/hooks/afterRead/relationshipPopulationPromise.ts similarity index 80% rename from src/fields/relationshipPopulationPromise.ts rename to src/fields/hooks/afterRead/relationshipPopulationPromise.ts index 5eba1d1df5..35195bc708 100644 --- a/src/fields/relationshipPopulationPromise.ts +++ b/src/fields/hooks/afterRead/relationshipPopulationPromise.ts @@ -1,6 +1,5 @@ -import { PayloadRequest } from '../express/types'; -import { RelationshipField, fieldSupportsMany, fieldHasMaxDepth, UploadField } from './config/types'; -import { Payload } from '..'; +import { PayloadRequest } from '../../../express/types'; +import { RelationshipField, fieldSupportsMany, fieldHasMaxDepth, UploadField } from '../../config/types'; type PopulateArgs = { depth: number @@ -11,7 +10,6 @@ type PopulateArgs = { data: Record field: RelationshipField | UploadField index?: number - payload: Payload showHiddenFields: boolean } @@ -24,13 +22,12 @@ const populate = async ({ data, field, index, - payload, showHiddenFields, }: PopulateArgs) => { const dataToUpdate = dataReference; const relation = Array.isArray(field.relationTo) ? (data.relationTo as string) : field.relationTo; - const relatedCollection = payload.collections[relation]; + const relatedCollection = req.payload.collections[relation]; if (relatedCollection) { let idString = Array.isArray(field.relationTo) ? data.value : data; @@ -42,7 +39,7 @@ const populate = async ({ let populatedRelationship; if (depth && currentDepth <= depth) { - populatedRelationship = await payload.findByID({ + populatedRelationship = await req.payload.findByID({ req, collection: relatedCollection.config.slug, id: idString as string, @@ -72,33 +69,31 @@ const populate = async ({ }; type PromiseArgs = { - data: Record + siblingDoc: Record field: RelationshipField | UploadField depth: number currentDepth: number req: PayloadRequest overrideAccess: boolean - payload: Payload showHiddenFields: boolean } -const relationshipPopulationPromise = ({ - data, +const relationshipPopulationPromise = async ({ + siblingDoc, field, depth, currentDepth, req, overrideAccess, - payload, showHiddenFields, -}: PromiseArgs) => async (): Promise => { - const resultingData = data; +}: PromiseArgs): Promise => { + const resultingDoc = siblingDoc; const populateDepth = fieldHasMaxDepth(field) && field.maxDepth < depth ? field.maxDepth : depth; - if (fieldSupportsMany(field) && field.hasMany && Array.isArray(data[field.name])) { + if (fieldSupportsMany(field) && field.hasMany && Array.isArray(siblingDoc[field.name])) { const rowPromises = []; - data[field.name].forEach((relatedDoc, index) => { + siblingDoc[field.name].forEach((relatedDoc, index) => { const rowPromise = async () => { if (relatedDoc) { await populate({ @@ -107,10 +102,9 @@ const relationshipPopulationPromise = ({ req, overrideAccess, data: relatedDoc, - dataReference: resultingData, + dataReference: resultingDoc, field, index, - payload, showHiddenFields, }); } @@ -120,16 +114,15 @@ const relationshipPopulationPromise = ({ }); await Promise.all(rowPromises); - } else if (data[field.name]) { + } else if (siblingDoc[field.name]) { await populate({ depth: populateDepth, currentDepth, req, overrideAccess, - dataReference: resultingData, - data: data[field.name], + dataReference: resultingDoc, + data: siblingDoc[field.name], field, - payload, showHiddenFields, }); } diff --git a/src/fields/hooks/afterRead/traverseFields.ts b/src/fields/hooks/afterRead/traverseFields.ts new file mode 100644 index 0000000000..1fcad81a8c --- /dev/null +++ b/src/fields/hooks/afterRead/traverseFields.ts @@ -0,0 +1,47 @@ +import { Field } from '../../config/types'; +import { promise } from './promise'; +import { PayloadRequest } from '../../../express/types'; + +type Args = { + currentDepth: number + depth: number + doc: Record + fieldPromises: Promise[] + fields: Field[] + flattenLocales: boolean + populationPromises: Promise[] + req: PayloadRequest + overrideAccess: boolean + siblingDoc: Record + showHiddenFields: boolean +} + +export const traverseFields = ({ + currentDepth, + depth, + doc, + fieldPromises, + fields, + flattenLocales, + overrideAccess, + populationPromises, + req, + siblingDoc, + showHiddenFields, +}: Args): void => { + fields.forEach((field) => { + fieldPromises.push(promise({ + currentDepth, + depth, + doc, + field, + fieldPromises, + flattenLocales, + overrideAccess, + populationPromises, + req, + siblingDoc, + showHiddenFields, + })); + }); +}; diff --git a/src/fields/hooks/beforeChange/index.ts b/src/fields/hooks/beforeChange/index.ts new file mode 100644 index 0000000000..ed1ba991bf --- /dev/null +++ b/src/fields/hooks/beforeChange/index.ts @@ -0,0 +1,62 @@ +import { SanitizedCollectionConfig } from '../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../globals/config/types'; +import { Operation } from '../../../types'; +import { PayloadRequest } from '../../../express/types'; +import { traverseFields } from './traverseFields'; +import { ValidationError } from '../../../errors'; +import deepCopyObject from '../../../utilities/deepCopyObject'; + +type Args = { + data: Record + doc: Record + docWithLocales: Record + entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig + id?: string | number + operation: Operation + req: PayloadRequest + skipValidation?: boolean +} + +export const beforeChange = async ({ + data: incomingData, + doc, + docWithLocales, + entityConfig, + id, + operation, + req, + skipValidation, +}: Args): Promise> => { + const data = deepCopyObject(incomingData); + const promises = []; + const mergeLocaleActions = []; + const errors: { message: string, field: string }[] = []; + + traverseFields({ + data, + doc, + docWithLocales, + errors, + id, + operation, + path: '', + mergeLocaleActions, + promises, + req, + siblingData: data, + siblingDoc: doc, + siblingDocWithLocales: docWithLocales, + fields: entityConfig.fields, + skipValidation, + }); + + await Promise.all(promises); + + if (errors.length > 0) { + throw new ValidationError(errors); + } + + mergeLocaleActions.forEach((action) => action()); + + return data; +}; diff --git a/src/fields/hooks/beforeChange/promise.ts b/src/fields/hooks/beforeChange/promise.ts new file mode 100644 index 0000000000..91e5646de7 --- /dev/null +++ b/src/fields/hooks/beforeChange/promise.ts @@ -0,0 +1,285 @@ +/* eslint-disable no-param-reassign */ +import merge from 'deepmerge'; +import { Field, fieldAffectsData } from '../../config/types'; +import { Operation } from '../../../types'; +import { PayloadRequest } from '../../../express/types'; +import getValueWithDefault from '../../getDefaultValue'; +import { traverseFields } from './traverseFields'; + +type Args = { + data: Record + doc: Record + docWithLocales: Record + errors: { message: string, field: string }[] + field: Field + id?: string | number + mergeLocaleActions: (() => void)[] + operation: Operation + path: string + promises: Promise[] + req: PayloadRequest + siblingData: Record + siblingDoc: Record + siblingDocWithLocales?: Record + skipValidation: boolean +} + +// This function is responsible for the following actions, in order: +// - Run condition +// - Merge original document data into incoming data +// - Compute default values for undefined fields +// - Execute field hooks +// - Validate data +// - Transform data for storage +// - Unflatten locales + +export const promise = async ({ + data, + doc, + docWithLocales, + errors, + field, + id, + mergeLocaleActions, + operation, + path, + promises, + req, + siblingData, + siblingDoc, + siblingDocWithLocales, + skipValidation, +}: Args): Promise => { + const passesCondition = (field.admin?.condition) ? field.admin.condition(data, siblingData) : true; + const skipValidationFromHere = skipValidation || !passesCondition; + + if (fieldAffectsData(field)) { + if (typeof siblingData[field.name] === 'undefined') { + // If no incoming data, but existing document data is found, merge it in + if (typeof siblingDoc[field.name] !== 'undefined') { + if (field.localized && typeof siblingDoc[field.name] === 'object') { + siblingData[field.name] = siblingDoc[field.name][req.locale]; + } else { + siblingData[field.name] = siblingDoc[field.name]; + } + + // Otherwise compute default value + } else if (typeof field.defaultValue !== 'undefined') { + siblingData[field.name] = await getValueWithDefault({ + value: siblingData[field.name], + defaultValue: field.defaultValue, + locale: req.locale, + user: req.user, + }); + } + } + + // Execute hooks + if (field.hooks?.beforeChange) { + await field.hooks.beforeChange.reduce(async (priorHook, currentHook) => { + await priorHook; + + const hookedValue = await currentHook({ + value: siblingData[field.name], + originalDoc: doc, + data, + siblingData, + operation, + req, + }); + + if (hookedValue !== undefined) { + siblingData[field.name] = hookedValue; + } + }, Promise.resolve()); + } + + // Validate + if (!skipValidationFromHere && field.validate) { + let valueToValidate; + + if (['array', 'blocks'].includes(field.type)) { + const rows = siblingData[field.name]; + valueToValidate = Array.isArray(rows) ? rows.length : 0; + } else { + valueToValidate = siblingData[field.name]; + } + + const validationResult = await field.validate(valueToValidate, { + ...field, + data: merge(doc, data), + siblingData: merge(siblingDoc, siblingData), + id, + operation, + user: req.user, + payload: req.payload, + }); + + if (typeof validationResult === 'string') { + errors.push({ + message: validationResult, + field: `${path}${field.name}`, + }); + } + } + + // Push merge locale action if applicable + if (field.localized) { + mergeLocaleActions.push(() => { + const localeData = req.payload.config.localization.locales.reduce((locales, localeID) => { + let valueToSet = siblingData[field.name]; + + if (localeID !== req.locale) { + valueToSet = siblingDocWithLocales?.[field.name]?.[localeID]; + } + + if (typeof valueToSet !== 'undefined') { + return { + ...locales, + [localeID]: valueToSet, + }; + } + + return locales; + }, {}); + + // If there are locales with data, set the data + if (Object.keys(localeData).length > 0) { + siblingData[field.name] = localeData; + } + }); + } + } + + switch (field.type) { + case 'point': { + // Transform point data for storage + if (Array.isArray(siblingData[field.name]) && siblingData[field.name][0] !== null && siblingData[field.name][1] !== null) { + siblingData[field.name] = { + type: 'Point', + coordinates: [ + parseFloat(siblingData[field.name][0]), + parseFloat(siblingData[field.name][1]), + ], + }; + } + + break; + } + + case 'group': { + let groupData = siblingData[field.name] as Record; + let groupDoc = siblingDoc[field.name] as Record; + let groupDocWithLocales = siblingDocWithLocales[field.name] as Record; + + if (typeof siblingData[field.name] !== 'object') groupData = {}; + if (typeof siblingDoc[field.name] !== 'object') groupDoc = {}; + if (typeof siblingDocWithLocales[field.name] !== 'object') groupDocWithLocales = {}; + + traverseFields({ + data, + doc, + docWithLocales, + errors, + fields: field.fields, + id, + mergeLocaleActions, + operation, + path: `${path}${field.name}.`, + promises, + req, + siblingData: groupData, + siblingDoc: groupDoc, + siblingDocWithLocales: groupDocWithLocales, + skipValidation: skipValidationFromHere, + }); + + break; + } + + case 'array': { + const rows = siblingData[field.name]; + + if (Array.isArray(rows)) { + rows.forEach((row, i) => { + traverseFields({ + data, + doc, + docWithLocales, + errors, + fields: field.fields, + id, + mergeLocaleActions, + operation, + path: `${path}${field.name}.${i}.`, + promises, + req, + siblingData: row, + siblingDoc: siblingDoc[field.name]?.[i] || {}, + siblingDocWithLocales: siblingDocWithLocales[field.name]?.[i] || {}, + skipValidation: skipValidationFromHere, + }); + }); + } + break; + } + + case 'blocks': { + const rows = siblingData[field.name]; + + if (Array.isArray(rows)) { + rows.forEach((row, i) => { + const block = field.blocks.find((blockType) => blockType.slug === row.blockType); + + if (block) { + traverseFields({ + data, + doc, + docWithLocales, + errors, + fields: block.fields, + id, + mergeLocaleActions, + operation, + path: `${path}${field.name}.${i}.`, + promises, + req, + siblingData: row, + siblingDoc: siblingDoc[field.name]?.[i] || {}, + siblingDocWithLocales: siblingDocWithLocales[field.name]?.[i] || {}, + skipValidation: skipValidationFromHere, + }); + } + }); + } + + break; + } + + case 'row': { + traverseFields({ + data, + doc, + docWithLocales, + errors, + fields: field.fields, + id, + mergeLocaleActions, + operation, + path, + promises, + req, + siblingData, + siblingDoc, + siblingDocWithLocales, + skipValidation: skipValidationFromHere, + }); + + break; + } + + default: { + break; + } + } +}; diff --git a/src/fields/hooks/beforeChange/traverseFields.ts b/src/fields/hooks/beforeChange/traverseFields.ts new file mode 100644 index 0000000000..2c25e8695a --- /dev/null +++ b/src/fields/hooks/beforeChange/traverseFields.ts @@ -0,0 +1,60 @@ +import { Field } from '../../config/types'; +import { promise } from './promise'; +import { Operation } from '../../../types'; +import { PayloadRequest } from '../../../express/types'; + +type Args = { + data: Record + doc: Record + docWithLocales: Record + errors: { message: string, field: string }[] + fields: Field[] + id?: string | number + mergeLocaleActions: (() => void)[] + operation: Operation + path: string + promises: Promise[] + req: PayloadRequest + siblingData: Record + siblingDoc: Record + siblingDocWithLocales: Record + skipValidation?: boolean +} + +export const traverseFields = ({ + data, + doc, + docWithLocales, + errors, + fields, + id, + mergeLocaleActions, + operation, + path, + promises, + req, + siblingData, + siblingDoc, + siblingDocWithLocales, + skipValidation, +}: Args): void => { + fields.forEach((field) => { + promises.push(promise({ + data, + doc, + docWithLocales, + errors, + field, + id, + mergeLocaleActions, + operation, + path, + promises, + req, + siblingData, + siblingDoc, + siblingDocWithLocales, + skipValidation, + })); + }); +}; diff --git a/src/fields/hooks/beforeValidate/index.ts b/src/fields/hooks/beforeValidate/index.ts new file mode 100644 index 0000000000..b69645e23e --- /dev/null +++ b/src/fields/hooks/beforeValidate/index.ts @@ -0,0 +1,45 @@ +import { SanitizedCollectionConfig } from '../../../collections/config/types'; +import { SanitizedGlobalConfig } from '../../../globals/config/types'; +import { PayloadRequest } from '../../../express/types'; +import { traverseFields } from './traverseFields'; +import deepCopyObject from '../../../utilities/deepCopyObject'; + +type Args = { + data: Record + doc: Record + entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig + id?: string | number + operation: 'create' | 'update' + overrideAccess: boolean + req: PayloadRequest +} + +export const beforeValidate = async ({ + data: incomingData, + doc, + entityConfig, + id, + operation, + overrideAccess, + req, +}: Args): Promise> => { + const promises = []; + const data = deepCopyObject(incomingData); + + traverseFields({ + data, + doc, + fields: entityConfig.fields, + id, + operation, + overrideAccess, + promises, + req, + siblingData: data, + siblingDoc: doc, + }); + + await Promise.all(promises); + + return data; +}; diff --git a/src/fields/hooks/beforeValidate/promise.ts b/src/fields/hooks/beforeValidate/promise.ts new file mode 100644 index 0000000000..b1983f1651 --- /dev/null +++ b/src/fields/hooks/beforeValidate/promise.ts @@ -0,0 +1,273 @@ +/* eslint-disable no-param-reassign */ +import { PayloadRequest } from '../../../express/types'; +import { Field, fieldAffectsData, valueIsValueWithRelation } from '../../config/types'; +import { traverseFields } from './traverseFields'; + +type Args = { + data: Record + doc: Record + field: Field + id?: string | number + operation: 'create' | 'update' + overrideAccess: boolean + promises: Promise[] + req: PayloadRequest + siblingData: Record + siblingDoc: Record +} + +// This function is responsible for the following actions, in order: +// - Sanitize incoming data +// - Execute field hooks +// - Execute field access control + +export const promise = async ({ + data, + doc, + field, + id, + operation, + overrideAccess, + promises, + req, + siblingData, + siblingDoc, +}: Args): Promise => { + if (fieldAffectsData(field)) { + if (field.name === 'id') { + if (field.type === 'number' && typeof siblingData[field.name] === 'string') { + const value = siblingData[field.name] as string; + + siblingData[field.name] = parseFloat(value); + } + + if (field.type === 'text' && typeof siblingData[field.name]?.toString === 'function' && typeof siblingData[field.name] !== 'string') { + siblingData[field.name] = siblingData[field.name].toString(); + } + } + + // Sanitize incoming data + switch (field.type) { + case 'number': { + if (typeof siblingData[field.name] === 'string') { + const value = siblingData[field.name] as string; + const trimmed = value.trim(); + siblingData[field.name] = (trimmed.length === 0) ? null : parseFloat(trimmed); + } + + break; + } + + case 'checkbox': { + if (siblingData[field.name] === 'true') siblingData[field.name] = true; + if (siblingData[field.name] === 'false') siblingData[field.name] = false; + if (siblingData[field.name] === '') siblingData[field.name] = false; + + break; + } + + case 'richText': { + if (typeof siblingData[field.name] === 'string') { + try { + const richTextJSON = JSON.parse(siblingData[field.name] as string); + siblingData[field.name] = richTextJSON; + } catch { + // Disregard this data as it is not valid. + // Will be reported to user by field validation + } + } + + break; + } + + case 'relationship': + case 'upload': { + if (siblingData[field.name] === '' || siblingData[field.name] === 'none' || siblingData[field.name] === 'null' || siblingData[field.name] === null) { + if (field.type === 'relationship' && field.hasMany === true) { + siblingData[field.name] = []; + } else { + siblingData[field.name] = null; + } + } + + const value = siblingData[field.name]; + + if (Array.isArray(field.relationTo)) { + if (Array.isArray(value)) { + value.forEach((relatedDoc: { value: unknown, relationTo: string }, i) => { + const relatedCollection = req.payload.config.collections.find((collection) => collection.slug === relatedDoc.relationTo); + const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); + if (relationshipIDField?.type === 'number') { + siblingData[field.name][i] = { ...relatedDoc, value: parseFloat(relatedDoc.value as string) }; + } + }); + } + if (field.type === 'relationship' && field.hasMany !== true && valueIsValueWithRelation(value)) { + const relatedCollection = req.payload.config.collections.find((collection) => collection.slug === value.relationTo); + const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); + if (relationshipIDField?.type === 'number') { + siblingData[field.name] = { ...value, value: parseFloat(value.value as string) }; + } + } + } else { + if (Array.isArray(value)) { + value.forEach((relatedDoc: unknown, i) => { + const relatedCollection = req.payload.config.collections.find((collection) => collection.slug === field.relationTo); + const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); + if (relationshipIDField?.type === 'number') { + siblingData[field.name][i] = parseFloat(relatedDoc as string); + } + }); + } + if (field.type === 'relationship' && field.hasMany !== true && value) { + const relatedCollection = req.payload.config.collections.find((collection) => collection.slug === field.relationTo); + const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); + if (relationshipIDField?.type === 'number') { + siblingData[field.name] = parseFloat(value as string); + } + } + } + break; + } + + case 'array': + case 'blocks': { + // Handle cases of arrays being intentionally set to 0 + if (siblingData[field.name] === '0' || siblingData[field.name] === 0 || siblingData[field.name] === null) { + siblingData[field.name] = []; + } + + break; + } + + default: { + break; + } + } + + // Execute hooks + if (field.hooks?.beforeValidate) { + await field.hooks.beforeValidate.reduce(async (priorHook, currentHook) => { + await priorHook; + + const hookedValue = await currentHook({ + value: siblingData[field.name], + originalDoc: doc, + data, + siblingData, + operation, + req, + }); + + if (hookedValue !== undefined) { + siblingData[field.name] = hookedValue; + } + }, Promise.resolve()); + } + + // Execute access control + if (field.access && field.access[operation]) { + const result = overrideAccess ? true : await field.access[operation]({ req, id, siblingData, data, doc }); + + if (!result) { + delete siblingData[field.name]; + } + } + } + + // Traverse subfields + switch (field.type) { + case 'group': { + let groupData = siblingData[field.name] as Record; + let groupDoc = siblingDoc[field.name] as Record; + + if (typeof siblingData[field.name] !== 'object') groupData = {}; + if (typeof siblingDoc[field.name] !== 'object') groupDoc = {}; + + traverseFields({ + data, + doc, + fields: field.fields, + id, + operation, + overrideAccess, + promises, + req, + siblingData: groupData, + siblingDoc: groupDoc, + }); + + break; + } + + case 'array': { + const rows = siblingData[field.name]; + + if (Array.isArray(rows)) { + rows.forEach((row, i) => { + traverseFields({ + data, + doc, + fields: field.fields, + id, + operation, + overrideAccess, + promises, + req, + siblingData: row, + siblingDoc: siblingDoc[field.name]?.[i] || {}, + }); + }); + } + break; + } + + case 'blocks': { + const rows = siblingData[field.name]; + + if (Array.isArray(rows)) { + rows.forEach((row, i) => { + const block = field.blocks.find((blockType) => blockType.slug === row.blockType); + + if (block) { + traverseFields({ + data, + doc, + fields: block.fields, + id, + operation, + overrideAccess, + promises, + req, + siblingData: row, + siblingDoc: siblingDoc[field.name]?.[i] || {}, + }); + } + }); + } + + break; + } + + case 'row': { + traverseFields({ + data, + doc, + fields: field.fields, + id, + operation, + overrideAccess, + promises, + req, + siblingData, + siblingDoc, + }); + + break; + } + + default: { + break; + } + } +}; diff --git a/src/fields/hooks/beforeValidate/traverseFields.ts b/src/fields/hooks/beforeValidate/traverseFields.ts new file mode 100644 index 0000000000..eff408587f --- /dev/null +++ b/src/fields/hooks/beforeValidate/traverseFields.ts @@ -0,0 +1,44 @@ +import { PayloadRequest } from '../../../express/types'; +import { Field } from '../../config/types'; +import { promise } from './promise'; + +type Args = { + data: Record + doc: Record + fields: Field[] + id?: string | number + operation: 'create' | 'update' + overrideAccess: boolean + promises: Promise[] + req: PayloadRequest + siblingData: Record + siblingDoc: Record +} + +export const traverseFields = ({ + data, + doc, + fields, + id, + operation, + overrideAccess, + promises, + req, + siblingData, + siblingDoc, +}: Args): void => { + fields.forEach((field) => { + promises.push(promise({ + data, + doc, + field, + id, + operation, + overrideAccess, + promises, + req, + siblingData, + siblingDoc, + })); + }); +}; diff --git a/src/fields/performFieldOperations.ts b/src/fields/performFieldOperations.ts deleted file mode 100644 index 884c12fa97..0000000000 --- a/src/fields/performFieldOperations.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Payload } from '..'; -import { ValidationError } from '../errors'; -import sanitizeFallbackLocale from '../localization/sanitizeFallbackLocale'; -import traverseFields from './traverseFields'; -import { SanitizedCollectionConfig } from '../collections/config/types'; -import { SanitizedGlobalConfig } from '../globals/config/types'; -import { Operation } from '../types'; -import { PayloadRequest } from '../express/types'; -import { HookName } from './config/types'; -import deepCopyObject from '../utilities/deepCopyObject'; - -type Arguments = { - data: Record - operation: Operation - hook?: HookName - req: PayloadRequest - overrideAccess: boolean - flattenLocales?: boolean - unflattenLocales?: boolean - originalDoc?: Record - docWithLocales?: Record - id?: string | number - showHiddenFields?: boolean - depth?: number - currentDepth?: number - isVersion?: boolean - skipValidation?: boolean -} - -export default async function performFieldOperations(this: Payload, entityConfig: SanitizedCollectionConfig | SanitizedGlobalConfig, args: Arguments): Promise { - const { - data, - originalDoc: fullOriginalDoc, - docWithLocales, - operation, - hook, - req, - id, - req: { - payloadAPI, - locale, - }, - overrideAccess, - flattenLocales, - unflattenLocales = false, - showHiddenFields = false, - isVersion = false, - skipValidation = false, - } = args; - - const fullData = deepCopyObject(data); - - const fallbackLocale = sanitizeFallbackLocale(req.fallbackLocale); - - let depth = 0; - - if (payloadAPI === 'REST' || payloadAPI === 'local') { - depth = (args.depth || args.depth === 0) ? parseInt(String(args.depth), 10) : this.config.defaultDepth; - - if (depth > this.config.maxDepth) depth = this.config.maxDepth; - } - - const currentDepth = args.currentDepth || 1; - - // Maintain a top-level list of promises - // so that all async field access / validations / hooks - // can run in parallel - const valuePromises = []; - const validationPromises = []; - const accessPromises = []; - const relationshipPopulations = []; - const hookPromises = []; - const unflattenLocaleActions = []; - const transformActions = []; - const errors: { message: string, field: string }[] = []; - - // ////////////////////////////////////////// - // Entry point for field validation - // ////////////////////////////////////////// - - traverseFields({ - fields: entityConfig.fields, - data: fullData, - originalDoc: fullOriginalDoc, - path: '', - flattenLocales, - locale, - fallbackLocale, - accessPromises, - operation, - overrideAccess, - req, - id, - relationshipPopulations, - depth, - currentDepth, - hook, - hookPromises, - fullOriginalDoc, - fullData, - valuePromises, - validationPromises, - errors, - payload: this, - showHiddenFields, - unflattenLocales, - unflattenLocaleActions, - transformActions, - docWithLocales, - isVersion, - skipValidation, - }); - - if (hook === 'afterRead') { - transformActions.forEach((action) => action()); - } - - const hookResults = hookPromises.map((promise) => promise()); - await Promise.all(hookResults); - - const valueResults = valuePromises.map((promise) => promise()); - await Promise.all(valueResults); - - const validationResults = validationPromises.map((promise) => promise()); - await Promise.all(validationResults); - - if (errors.length > 0) { - throw new ValidationError(errors); - } - - if (hook === 'beforeChange') { - transformActions.forEach((action) => action()); - } - - unflattenLocaleActions.forEach((action) => action()); - - const accessResults = accessPromises.map((promise) => promise()); - await Promise.all(accessResults); - - const relationshipPopulationResults = relationshipPopulations.map((population) => population()); - await Promise.all(relationshipPopulationResults); - - return fullData; -} diff --git a/src/fields/richText/populate.ts b/src/fields/richText/populate.ts index ad019ef39d..82382da217 100644 --- a/src/fields/richText/populate.ts +++ b/src/fields/richText/populate.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ import { Collection } from '../../collections/config/types'; -import { Payload } from '../..'; import { RichTextField, Field } from '../config/types'; import { PayloadRequest } from '../../express/types'; @@ -10,7 +9,6 @@ type Arguments = { key: string | number depth: number currentDepth?: number - payload: Payload field: RichTextField req: PayloadRequest showHiddenFields: boolean @@ -24,7 +22,6 @@ export const populate = async ({ overrideAccess, depth, currentDepth, - payload, req, showHiddenFields, }: Omit & { @@ -34,7 +31,7 @@ export const populate = async ({ }): Promise => { const dataRef = data as Record; - const doc = await payload.findByID({ + const doc = await req.payload.findByID({ req, collection: collection.config.slug, id, diff --git a/src/fields/richText/recurseNestedFields.ts b/src/fields/richText/recurseNestedFields.ts index 921c28028f..4bdb19738c 100644 --- a/src/fields/richText/recurseNestedFields.ts +++ b/src/fields/richText/recurseNestedFields.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ -import { Payload } from '../..'; import { Field, fieldHasSubFields, fieldIsArrayType, fieldAffectsData } from '../config/types'; import { PayloadRequest } from '../../express/types'; import { populate } from './populate'; @@ -10,7 +9,6 @@ type NestedRichTextFieldsArgs = { data: unknown fields: Field[] req: PayloadRequest - payload: Payload overrideAccess: boolean depth: number currentDepth?: number @@ -22,7 +20,6 @@ export const recurseNestedFields = ({ data, fields, req, - payload, overrideAccess = false, depth, currentDepth = 0, @@ -34,7 +31,7 @@ export const recurseNestedFields = ({ if (field.hasMany && Array.isArray(data[field.name])) { if (Array.isArray(field.relationTo)) { data[field.name].forEach(({ relationTo, value }, i) => { - const collection = payload.collections[relationTo]; + const collection = req.payload.collections[relationTo]; if (collection) { promises.push(populate({ id: value, @@ -45,7 +42,6 @@ export const recurseNestedFields = ({ overrideAccess, depth, currentDepth, - payload, req, showHiddenFields, })); @@ -53,7 +49,7 @@ export const recurseNestedFields = ({ }); } else { data[field.name].forEach((id, i) => { - const collection = payload.collections[field.relationTo as string]; + const collection = req.payload.collections[field.relationTo as string]; if (collection) { promises.push(populate({ id, @@ -64,7 +60,6 @@ export const recurseNestedFields = ({ overrideAccess, depth, currentDepth, - payload, req, showHiddenFields, })); @@ -72,7 +67,7 @@ export const recurseNestedFields = ({ }); } } else if (Array.isArray(field.relationTo) && data[field.name]?.value && data[field.name]?.relationTo) { - const collection = payload.collections[data[field.name].relationTo]; + const collection = req.payload.collections[data[field.name].relationTo]; promises.push(populate({ id: data[field.name].value, field, @@ -82,14 +77,13 @@ export const recurseNestedFields = ({ overrideAccess, depth, currentDepth, - payload, req, showHiddenFields, })); } } if (typeof data[field.name] !== 'undefined' && typeof field.relationTo === 'string') { - const collection = payload.collections[field.relationTo]; + const collection = req.payload.collections[field.relationTo]; promises.push(populate({ id: data[field.name], field, @@ -99,7 +93,6 @@ export const recurseNestedFields = ({ overrideAccess, depth, currentDepth, - payload, req, showHiddenFields, })); @@ -111,7 +104,6 @@ export const recurseNestedFields = ({ data: data[field.name], fields: field.fields, req, - payload, overrideAccess, depth, currentDepth, @@ -123,7 +115,6 @@ export const recurseNestedFields = ({ data, fields: field.fields, req, - payload, overrideAccess, depth, currentDepth, @@ -140,7 +131,6 @@ export const recurseNestedFields = ({ data: data[field.name][i], fields: block.fields, req, - payload, overrideAccess, depth, currentDepth, @@ -157,7 +147,6 @@ export const recurseNestedFields = ({ data: data[field.name][i], fields: field.fields, req, - payload, overrideAccess, depth, currentDepth, @@ -173,7 +162,6 @@ export const recurseNestedFields = ({ recurseRichText({ req, children: node.children, - payload, overrideAccess, depth, currentDepth, diff --git a/src/fields/richText/relationshipPromise.ts b/src/fields/richText/relationshipPromise.ts index 8d3d963fbe..f6a4bfbc0d 100644 --- a/src/fields/richText/relationshipPromise.ts +++ b/src/fields/richText/relationshipPromise.ts @@ -1,17 +1,15 @@ -import { Payload } from '../..'; import { RichTextField } from '../config/types'; import { PayloadRequest } from '../../express/types'; import { recurseNestedFields } from './recurseNestedFields'; import { populate } from './populate'; -type Arguments = { - data: unknown - overrideAccess?: boolean - depth: number +type Args = { currentDepth?: number - payload: Payload + depth: number field: RichTextField + overrideAccess?: boolean req: PayloadRequest + siblingDoc: Record showHiddenFields: boolean } @@ -20,7 +18,6 @@ type RecurseRichTextArgs = { overrideAccess: boolean depth: number currentDepth: number - payload: Payload field: RichTextField req: PayloadRequest promises: Promise[] @@ -30,7 +27,6 @@ type RecurseRichTextArgs = { export const recurseRichText = ({ req, children, - payload, overrideAccess = false, depth, currentDepth = 0, @@ -40,7 +36,7 @@ export const recurseRichText = ({ }: RecurseRichTextArgs): void => { if (Array.isArray(children)) { (children as any[]).forEach((element) => { - const collection = payload.collections[element?.relationTo]; + const collection = req.payload.collections[element?.relationTo]; if ((element.type === 'relationship' || element.type === 'upload') && element?.value?.id @@ -52,7 +48,6 @@ export const recurseRichText = ({ data: element.fields || {}, fields: field.admin.upload.collections[element.relationTo].fields, req, - payload, overrideAccess, depth, currentDepth, @@ -67,7 +62,6 @@ export const recurseRichText = ({ overrideAccess, depth, currentDepth, - payload, field, collection, showHiddenFields, @@ -76,14 +70,13 @@ export const recurseRichText = ({ if (element?.children) { recurseRichText({ - req, children: element.children, - payload, - overrideAccess, - depth, currentDepth, + depth, field, + overrideAccess, promises, + req, showHiddenFields, }); } @@ -91,27 +84,25 @@ export const recurseRichText = ({ } }; -const richTextRelationshipPromise = ({ - req, - data, - payload, - overrideAccess, - depth, +const richTextRelationshipPromise = async ({ currentDepth, + depth, field, + overrideAccess, + req, + siblingDoc, showHiddenFields, -}: Arguments) => async (): Promise => { +}: Args): Promise => { const promises = []; recurseRichText({ - req, - children: data[field.name], - payload, - overrideAccess, - depth, + children: siblingDoc[field.name] as unknown[], currentDepth, + depth, field, + overrideAccess, promises, + req, showHiddenFields, }); diff --git a/src/fields/traverseFields.ts b/src/fields/traverseFields.ts deleted file mode 100644 index 105e0e4a6e..0000000000 --- a/src/fields/traverseFields.ts +++ /dev/null @@ -1,427 +0,0 @@ -import validationPromise from './validationPromise'; -import accessPromise from './accessPromise'; -import hookPromise from './hookPromise'; -import { - Field, - fieldHasSubFields, - fieldIsArrayType, - fieldIsBlockType, - fieldAffectsData, - HookName, -} from './config/types'; -import { Operation } from '../types'; -import { PayloadRequest } from '../express/types'; -import { Payload } from '..'; -import richTextRelationshipPromise from './richText/relationshipPromise'; -import getValueWithDefault from './getDefaultValue'; - -type Arguments = { - fields: Field[] - data: Record - originalDoc: Record - path: string - flattenLocales: boolean - locale: string - fallbackLocale: string - accessPromises: (() => Promise)[] - operation: Operation - overrideAccess: boolean - req: PayloadRequest - id?: string | number - relationshipPopulations: (() => Promise)[] - depth: number - currentDepth: number - hook: HookName - hookPromises: (() => Promise)[] - fullOriginalDoc: Record - fullData: Record - valuePromises: (() => Promise)[] - validationPromises: (() => Promise)[] - errors: { message: string, field: string }[] - payload: Payload - showHiddenFields: boolean - unflattenLocales: boolean - unflattenLocaleActions: (() => void)[] - transformActions: (() => void)[] - docWithLocales?: Record - skipValidation?: boolean - isVersion: boolean -} - -const traverseFields = (args: Arguments): void => { - const { - fields, - data = {}, - originalDoc = {}, - path, - flattenLocales, - locale, - fallbackLocale, - accessPromises, - operation, - overrideAccess, - req, - id, - relationshipPopulations, - depth, - currentDepth, - hook, - hookPromises, - fullOriginalDoc, - fullData, - valuePromises, - validationPromises, - errors, - payload, - showHiddenFields, - unflattenLocaleActions, - unflattenLocales, - transformActions, - docWithLocales = {}, - skipValidation, - isVersion, - } = args; - - fields.forEach((field) => { - const dataCopy = data; - - if (hook === 'afterRead') { - if (field.type === 'group') { - // Fill groups with empty objects so fields with hooks within groups can populate - // themselves virtually as necessary - if (typeof data[field.name] === 'undefined' && typeof originalDoc[field.name] === 'undefined') { - data[field.name] = {}; - } - } - - if (fieldAffectsData(field) && field.hidden && typeof data[field.name] !== 'undefined' && !showHiddenFields) { - delete data[field.name]; - } - - if (field.type === 'point') { - transformActions.push(() => { - if (data[field.name]?.coordinates && Array.isArray(data[field.name].coordinates) && data[field.name].coordinates.length === 2) { - data[field.name] = data[field.name].coordinates; - } - }); - } - } - - if ((field.type === 'upload' || field.type === 'relationship') - && (data[field.name] === '' || data[field.name] === 'none' || data[field.name] === 'null')) { - if (field.type === 'relationship' && field.hasMany === true) { - dataCopy[field.name] = []; - } else { - dataCopy[field.name] = null; - } - } - - if (field.type === 'relationship' && field.hasMany && (data[field.name] === '' || data[field.name] === 'none' || data[field.name] === 'null' || data[field.name] === null)) { - dataCopy[field.name] = []; - } - - if (field.type === 'number' && typeof data[field.name] === 'string') { - const trimmed = data[field.name].trim(); - dataCopy[field.name] = (trimmed.length === 0) ? null : parseFloat(trimmed); - } - - if (fieldAffectsData(field) && field.name === 'id') { - if (field.type === 'number' && typeof data[field.name] === 'string') { - dataCopy[field.name] = parseFloat(data[field.name]); - } - if (field.type === 'text' && typeof data[field.name]?.toString === 'function' && typeof data[field.name] !== 'string') { - dataCopy[field.name] = dataCopy[field.name].toString(); - } - } - - if (field.type === 'checkbox') { - if (data[field.name] === 'true') dataCopy[field.name] = true; - if (data[field.name] === 'false') dataCopy[field.name] = false; - if (data[field.name] === '') dataCopy[field.name] = false; - } - - if (field.type === 'richText') { - if (typeof data[field.name] === 'string') { - try { - const richTextJSON = JSON.parse(data[field.name] as string); - dataCopy[field.name] = richTextJSON; - } catch { - // Disregard this data as it is not valid. - // Will be reported to user by field validation - } - } - - if (((field.admin?.elements?.includes('relationship') || field.admin?.elements?.includes('upload')) || !field?.admin?.elements) && hook === 'afterRead') { - relationshipPopulations.push(richTextRelationshipPromise({ - req, - data, - payload, - overrideAccess, - depth, - field, - currentDepth, - showHiddenFields, - })); - } - } - - const hasLocalizedValue = fieldAffectsData(field) - && (typeof data?.[field.name] === 'object' && data?.[field.name] !== null) - && field.name - && field.localized - && locale !== 'all' - && flattenLocales; - - if (hasLocalizedValue) { - let localizedValue = data[field.name][locale]; - if (typeof localizedValue === 'undefined' && fallbackLocale) localizedValue = data[field.name][fallbackLocale]; - if (typeof localizedValue === 'undefined' && field.type === 'group') localizedValue = {}; - if (typeof localizedValue === 'undefined') localizedValue = null; - dataCopy[field.name] = localizedValue; - } - - if (fieldAffectsData(field) && field.localized && unflattenLocales) { - unflattenLocaleActions.push(() => { - const localeData = payload.config.localization.locales.reduce((locales, localeID) => { - let valueToSet; - - if (localeID === locale) { - if (typeof data[field.name] !== 'undefined') { - valueToSet = data[field.name]; - } else if (docWithLocales?.[field.name]?.[localeID]) { - valueToSet = docWithLocales?.[field.name]?.[localeID]; - } - } else { - valueToSet = docWithLocales?.[field.name]?.[localeID]; - } - - if (typeof valueToSet !== 'undefined') { - return { - ...locales, - [localeID]: valueToSet, - }; - } - - return locales; - }, {}); - - // If there are locales with data, set the data - if (Object.keys(localeData).length > 0) { - data[field.name] = localeData; - } - }); - } - - if (fieldAffectsData(field)) { - accessPromises.push(() => accessPromise({ - data, - fullData, - originalDoc, - field, - operation, - overrideAccess, - req, - id, - relationshipPopulations, - depth, - currentDepth, - hook, - payload, - showHiddenFields, - })); - - hookPromises.push(() => hookPromise({ - data, - field, - hook, - req, - operation, - fullOriginalDoc, - fullData, - flattenLocales, - isVersion, - })); - } - - - const passesCondition = (field.admin?.condition && hook === 'beforeChange') ? field.admin.condition(fullData, data) : true; - const skipValidationFromHere = skipValidation || !passesCondition; - - if (fieldHasSubFields(field)) { - if (!fieldAffectsData(field)) { - traverseFields({ - ...args, - fields: field.fields, - skipValidation: skipValidationFromHere, - }); - } else if (fieldIsArrayType(field)) { - if (Array.isArray(data[field.name])) { - for (let i = 0; i < data[field.name].length; i += 1) { - if (typeof (data[field.name][i]) === 'undefined') { - data[field.name][i] = {}; - } - - traverseFields({ - ...args, - fields: field.fields, - data: data[field.name][i] || {}, - originalDoc: originalDoc?.[field.name]?.[i], - docWithLocales: docWithLocales?.[field.name]?.[i], - path: `${path}${field.name}.${i}.`, - skipValidation: skipValidationFromHere, - showHiddenFields, - }); - } - } - } else { - traverseFields({ - ...args, - fields: field.fields, - data: data[field.name] as Record, - originalDoc: originalDoc[field.name], - docWithLocales: docWithLocales?.[field.name], - path: `${path}${field.name}.`, - skipValidation: skipValidationFromHere, - showHiddenFields, - }); - } - } - - if (fieldIsBlockType(field)) { - if (Array.isArray(data[field.name])) { - (data[field.name] as Record[]).forEach((rowData, i) => { - const block = field.blocks.find((blockType) => blockType.slug === rowData.blockType); - - if (block) { - traverseFields({ - ...args, - fields: block.fields, - data: rowData || {}, - originalDoc: originalDoc?.[field.name]?.[i], - docWithLocales: docWithLocales?.[field.name]?.[i], - path: `${path}${field.name}.${i}.`, - skipValidation: skipValidationFromHere, - showHiddenFields, - }); - } - }); - } - } - - if (hook === 'beforeChange' && fieldAffectsData(field)) { - const updatedData = data; - - if (data?.[field.name] === undefined && originalDoc?.[field.name] === undefined && field.defaultValue) { - valuePromises.push(async () => { - let valueToUpdate = data?.[field.name]; - - if (typeof valueToUpdate === 'undefined' && typeof originalDoc?.[field.name] !== 'undefined') { - valueToUpdate = originalDoc?.[field.name]; - } - - const value = await getValueWithDefault({ value: valueToUpdate, defaultValue: field.defaultValue, locale, user: req.user }); - updatedData[field.name] = value; - }); - } - - if (field.type === 'relationship' || field.type === 'upload') { - if (Array.isArray(field.relationTo)) { - if (Array.isArray(dataCopy[field.name])) { - dataCopy[field.name].forEach((relatedDoc: { value: unknown, relationTo: string }, i) => { - const relatedCollection = payload.config.collections.find((collection) => collection.slug === relatedDoc.relationTo); - const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); - if (relationshipIDField?.type === 'number') { - dataCopy[field.name][i] = { ...relatedDoc, value: parseFloat(relatedDoc.value as string) }; - } - }); - } - if (field.type === 'relationship' && field.hasMany !== true && dataCopy[field.name]?.relationTo) { - const relatedCollection = payload.config.collections.find((collection) => collection.slug === dataCopy[field.name].relationTo); - const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); - if (relationshipIDField?.type === 'number') { - dataCopy[field.name] = { ...dataCopy[field.name], value: parseFloat(dataCopy[field.name].value as string) }; - } - } - } else { - if (Array.isArray(dataCopy[field.name])) { - dataCopy[field.name].forEach((relatedDoc: unknown, i) => { - const relatedCollection = payload.config.collections.find((collection) => collection.slug === field.relationTo); - const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); - if (relationshipIDField?.type === 'number') { - dataCopy[field.name][i] = parseFloat(relatedDoc as string); - } - }); - } - if (field.type === 'relationship' && field.hasMany !== true && dataCopy[field.name]) { - const relatedCollection = payload.config.collections.find((collection) => collection.slug === field.relationTo); - const relationshipIDField = relatedCollection.fields.find((collectionField) => fieldAffectsData(collectionField) && collectionField.name === 'id'); - if (relationshipIDField?.type === 'number') { - dataCopy[field.name] = parseFloat(dataCopy[field.name]); - } - } - } - } - - if (field.type === 'point' && data[field.name]) { - transformActions.push(() => { - if (Array.isArray(data[field.name]) && data[field.name][0] !== null && data[field.name][1] !== null) { - data[field.name] = { - type: 'Point', - coordinates: [ - parseFloat(data[field.name][0]), - parseFloat(data[field.name][1]), - ], - }; - } - }); - } - - if (field.type === 'array' || field.type === 'blocks') { - const hasRowsOfNewData = Array.isArray(data[field.name]); - const newRowCount = hasRowsOfNewData ? (data[field.name] as Record[]).length : undefined; - - // Handle cases of arrays being intentionally set to 0 - if (data[field.name] === '0' || data[field.name] === 0 || data[field.name] === null) { - updatedData[field.name] = []; - } - - const hasRowsOfExistingData = Array.isArray(originalDoc[field.name]); - const existingRowCount = hasRowsOfExistingData ? originalDoc[field.name].length : 0; - - validationPromises.push(() => validationPromise({ - errors, - hook, - data: { [field.name]: newRowCount }, - fullData, - originalDoc: { [field.name]: existingRowCount }, - fullOriginalDoc, - field, - path, - skipValidation: skipValidationFromHere, - payload: req.payload, - user: req.user, - operation, - id, - })); - } else if (fieldAffectsData(field)) { - validationPromises.push(() => validationPromise({ - errors, - hook, - data, - fullData, - originalDoc, - fullOriginalDoc, - field, - path, - skipValidation: skipValidationFromHere, - user: req.user, - operation, - id, - payload: req.payload, - })); - } - } - }); -}; - -export default traverseFields; diff --git a/src/fields/validationPromise.ts b/src/fields/validationPromise.ts deleted file mode 100644 index 3d7d47d560..0000000000 --- a/src/fields/validationPromise.ts +++ /dev/null @@ -1,67 +0,0 @@ -import merge from 'deepmerge'; -import { Payload } from '..'; -import { User } from '../auth'; -import { Operation } from '../types'; -import { HookName, FieldAffectingData } from './config/types'; - -type Arguments = { - hook: HookName - field: FieldAffectingData - path: string - errors: {message: string, field: string}[] - data: Record - fullData: Record - originalDoc: Record - fullOriginalDoc: Record - id?: string | number - skipValidation?: boolean - user: User - operation: Operation - payload: Payload -} - -const validationPromise = async ({ - errors, - hook, - originalDoc, - fullOriginalDoc, - data, - fullData, - id, - field, - path, - skipValidation, - user, - operation, - payload, -}: Arguments): Promise => { - if (hook !== 'beforeChange' || skipValidation) return true; - - const hasCondition = field.admin && field.admin.condition; - const shouldValidate = field.validate && !hasCondition; - - let valueToValidate = data?.[field.name]; - if (valueToValidate === undefined) valueToValidate = originalDoc?.[field.name]; - if (valueToValidate === undefined) valueToValidate = field.defaultValue; - - const result = shouldValidate ? await field.validate(valueToValidate, { - ...field, - data: merge(fullOriginalDoc, fullData), - siblingData: merge(originalDoc, data), - id, - operation, - user, - payload, - }) : true; - - if (typeof result === 'string') { - errors.push({ - message: result, - field: `${path}${field.name}`, - }); - } - - return result; -}; - -export default validationPromise; diff --git a/src/globals/operations/findOne.ts b/src/globals/operations/findOne.ts index 2a4aa21e3e..d27eab6b3b 100644 --- a/src/globals/operations/findOne.ts +++ b/src/globals/operations/findOne.ts @@ -4,6 +4,7 @@ import { Where } from '../../types'; import { AccessResult } from '../../config/types'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable'; +import { afterRead } from '../../fields/hooks/afterRead'; async function findOne(args) { const { globals: { Model } } = this; @@ -79,7 +80,7 @@ async function findOne(args) { } // ///////////////////////////////////// - // 3. Execute before collection hook + // Execute before global hook // ///////////////////////////////////// await globalConfig.hooks.beforeRead.reduce(async (priorHook, hook) => { @@ -92,21 +93,20 @@ async function findOne(args) { }, Promise.resolve()); // ///////////////////////////////////// - // 4. Execute field-level hooks and access + // Execute field-level hooks and access // ///////////////////////////////////// - doc = await this.performFieldOperations(globalConfig, { - data: doc, - hook: 'afterRead', - operation: 'read', - req, + doc = await afterRead({ depth, - flattenLocales: true, + doc, + entityConfig: globalConfig, + req, + overrideAccess, showHiddenFields, }); // ///////////////////////////////////// - // 5. Execute after collection hook + // Execute after global hook // ///////////////////////////////////// await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { @@ -119,7 +119,7 @@ async function findOne(args) { }, Promise.resolve()); // ///////////////////////////////////// - // 6. Return results + // Return results // ///////////////////////////////////// return doc; diff --git a/src/globals/operations/findVersionByID.ts b/src/globals/operations/findVersionByID.ts index d6a07c606f..f06ded0698 100644 --- a/src/globals/operations/findVersionByID.ts +++ b/src/globals/operations/findVersionByID.ts @@ -7,6 +7,7 @@ import { Where } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; import { TypeWithVersion } from '../../versions/types'; import { SanitizedGlobalConfig } from '../config/types'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { globalConfig: SanitizedGlobalConfig @@ -105,16 +106,12 @@ async function findVersionByID = any>(args: Argumen // afterRead - Fields // ///////////////////////////////////// - result.version = await this.performFieldOperations(globalConfig, { + result.version = await afterRead({ depth, + doc: result.version, + entityConfig: globalConfig, req, - id, - data: result.version, - hook: 'afterRead', - operation: 'read', - currentDepth, overrideAccess, - flattenLocales: true, showHiddenFields, }); diff --git a/src/globals/operations/findVersions.ts b/src/globals/operations/findVersions.ts index f4238aeb85..e53ebc4cdc 100644 --- a/src/globals/operations/findVersions.ts +++ b/src/globals/operations/findVersions.ts @@ -8,6 +8,7 @@ import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; import { buildSortParam } from '../../mongoose/buildSortParam'; import { TypeWithVersion } from '../../versions/types'; import { SanitizedGlobalConfig } from '../config/types'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { globalConfig: SanitizedGlobalConfig @@ -108,21 +109,14 @@ async function findVersions = any>(args: Arguments) ...paginatedDocs, docs: await Promise.all(paginatedDocs.docs.map(async (data) => ({ ...data, - version: await this.performFieldOperations( - globalConfig, - { - depth, - data: data.version, - req, - id: data.version.id, - hook: 'afterRead', - operation: 'read', - overrideAccess, - flattenLocales: true, - showHiddenFields, - isVersion: true, - }, - ), + version: await afterRead({ + depth, + doc: data.version, + entityConfig: globalConfig, + req, + overrideAccess, + showHiddenFields, + }), }))), }; diff --git a/src/globals/operations/restoreVersion.ts b/src/globals/operations/restoreVersion.ts index 0d4ffdf5cf..e47f88ede8 100644 --- a/src/globals/operations/restoreVersion.ts +++ b/src/globals/operations/restoreVersion.ts @@ -6,6 +6,8 @@ import { TypeWithVersion } from '../../versions/types'; import { SanitizedGlobalConfig } from '../config/types'; import { Payload } from '../..'; import { NotFound } from '../../errors'; +import { afterChange } from '../../fields/hooks/afterChange'; +import { afterRead } from '../../fields/hooks/afterRead'; export type Arguments = { globalConfig: SanitizedGlobalConfig @@ -83,22 +85,20 @@ async function restoreVersion = any>(this: Payload, // afterRead - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(globalConfig, { - data: result, - hook: 'afterRead', - operation: 'read', - req, + result = await afterRead({ depth, - showHiddenFields, - flattenLocales: true, + doc: result, + entityConfig: globalConfig, + req, overrideAccess, + showHiddenFields, }); // ///////////////////////////////////// // afterRead - Global // ///////////////////////////////////// - await globalConfig.hooks.afterChange.reduce(async (priorHook, hook) => { + await globalConfig.hooks.afterRead.reduce(async (priorHook, hook) => { await priorHook; result = await hook({ @@ -111,14 +111,12 @@ async function restoreVersion = any>(this: Payload, // afterChange - Fields // ///////////////////////////////////// - result = await this.performFieldOperations(globalConfig, { + result = await afterChange({ data: result, - hook: 'afterChange', + doc: result, + entityConfig: globalConfig, operation: 'update', req, - depth, - overrideAccess, - showHiddenFields, }); // ///////////////////////////////////// diff --git a/src/globals/operations/update.ts b/src/globals/operations/update.ts index e49ca2adaf..57ba9ee789 100644 --- a/src/globals/operations/update.ts +++ b/src/globals/operations/update.ts @@ -8,6 +8,10 @@ import { saveGlobalDraft } from '../../versions/drafts/saveGlobalDraft'; import { ensurePublishedGlobalVersion } from '../../versions/ensurePublishedGlobalVersion'; import cleanUpFailedVersion from '../../versions/cleanUpFailedVersion'; import { hasWhereAccessResult } from '../../auth'; +import { beforeChange } from '../../fields/hooks/beforeChange'; +import { beforeValidate } from '../../fields/hooks/beforeValidate'; +import { afterChange } from '../../fields/hooks/afterChange'; +import { afterRead } from '../../fields/hooks/afterRead'; async function update(this: Payload, args): Promise { const { globals: { Model } } = this; @@ -63,26 +67,24 @@ async function update(this: Payload, args): Promise< // ///////////////////////////////////// let global: any = await Model.findOne(query); - let globalJSON; + let globalJSON: Record = {}; if (global) { globalJSON = global.toJSON({ virtuals: true }); - globalJSON = JSON.stringify(globalJSON); - globalJSON = JSON.parse(globalJSON); + const globalJSONString = JSON.stringify(globalJSON); + globalJSON = JSON.parse(globalJSONString); if (globalJSON._id) { delete globalJSON._id; } } - const originalDoc = await this.performFieldOperations(globalConfig, { + const originalDoc = await afterRead({ depth, + doc: globalJSON, + entityConfig: globalConfig, req, - data: globalJSON, - hook: 'afterRead', - operation: 'update', overrideAccess: true, - flattenLocales: true, showHiddenFields, }); @@ -90,13 +92,13 @@ async function update(this: Payload, args): Promise< // beforeValidate - Fields // ///////////////////////////////////// - data = await this.performFieldOperations(globalConfig, { + data = await beforeValidate({ data, - req, - originalDoc, - hook: 'beforeValidate', + doc: originalDoc, + entityConfig: globalConfig, operation: 'update', overrideAccess, + req, }); // ///////////////////////////////////// @@ -131,15 +133,13 @@ async function update(this: Payload, args): Promise< // beforeChange - Fields // ///////////////////////////////////// - const result = await this.performFieldOperations(globalConfig, { + const result = await beforeChange({ data, - req, - hook: 'beforeChange', - operation: 'update', - unflattenLocales: true, - originalDoc, + doc: originalDoc, docWithLocales: globalJSON, - overrideAccess, + entityConfig: globalConfig, + operation: 'update', + req, skipValidation: shouldSaveDraft, }); @@ -205,15 +205,13 @@ async function update(this: Payload, args): Promise< // afterRead - Fields // ///////////////////////////////////// - global = await this.performFieldOperations(globalConfig, { - data: global, - hook: 'afterRead', - operation: 'read', - req, + global = await afterRead({ depth, - showHiddenFields, - flattenLocales: true, + doc: global, + entityConfig: globalConfig, + req, overrideAccess, + showHiddenFields, }); // ///////////////////////////////////// @@ -233,14 +231,12 @@ async function update(this: Payload, args): Promise< // afterChange - Fields // ///////////////////////////////////// - global = await this.performFieldOperations(globalConfig, { - data: global, - hook: 'afterChange', + global = await afterChange({ + data, + doc: global, + entityConfig: globalConfig, operation: 'update', req, - depth, - overrideAccess, - showHiddenFields, }); // ///////////////////////////////////// diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 4d1ff6643a..0fb961128f 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -14,7 +14,7 @@ import { GraphQLUnionType, } from 'graphql'; import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'; -import { Field, RadioField, RelationshipField, SelectField, UploadField, optionIsObject, ArrayField, GroupField, RichTextField, fieldAffectsData, NumberField, TextField, EmailField, TextareaField, CodeField, DateField, PointField, CheckboxField, BlockField, RowField, fieldIsPresentationalOnly } from '../../fields/config/types'; +import { Field, RadioField, RelationshipField, SelectField, UploadField, ArrayField, GroupField, RichTextField, fieldAffectsData, NumberField, TextField, EmailField, TextareaField, CodeField, DateField, PointField, CheckboxField, BlockField, RowField, fieldIsPresentationalOnly } from '../../fields/config/types'; import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; import withNullableType from './withNullableType'; @@ -50,16 +50,13 @@ function buildObjectType(name: string, fields: Field[], parentName: string, base type: withNullableType(field, GraphQLJSON), async resolve(parent, args, context) { if (args.depth > 0) { - const richTextRelationshipPromise = createRichTextRelationshipPromise({ + await createRichTextRelationshipPromise({ req: context.req, - data: parent, - payload: context.req.payload, + siblingDoc: parent, depth: args.depth, field, showHiddenFields: false, }); - - await richTextRelationshipPromise(); } return parent[field.name]; diff --git a/src/index.ts b/src/index.ts index 139e9cf60f..2b953925b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,6 @@ import bindResolvers, { GraphQLResolvers } from './graphql/bindResolvers'; import buildEmail from './email/build'; import identifyAPI from './express/middleware/identifyAPI'; import errorHandler, { ErrorHandler } from './express/middleware/errorHandler'; -import performFieldOperations from './fields/performFieldOperations'; import localOperations from './collections/operations/local'; import localGlobalOperations from './globals/operations/local'; import { encrypt, decrypt } from './auth/crypto'; @@ -110,8 +109,6 @@ export class Payload { authenticate: PayloadAuthenticate; - performFieldOperations: typeof performFieldOperations; - requestHandlers: RequestHandlers; /** @@ -148,8 +145,6 @@ export class Payload { bindRequestHandlers(this); bindResolvers(this); - this.performFieldOperations = performFieldOperations.bind(this); - // If not initializing locally, scaffold router if (!this.local) { this.router = express.Router(); diff --git a/src/versions/drafts/saveCollectionDraft.ts b/src/versions/drafts/saveCollectionDraft.ts index 21308a4494..3656d1ce10 100644 --- a/src/versions/drafts/saveCollectionDraft.ts +++ b/src/versions/drafts/saveCollectionDraft.ts @@ -18,7 +18,7 @@ export const saveCollectionDraft = async ({ id, data, autosave, -}: Args): Promise => { +}: Args): Promise> => { const VersionsModel = payload.versions[config.slug]; let existingAutosaveVersion; diff --git a/src/versions/ensurePublishedCollectionVersion.ts b/src/versions/ensurePublishedCollectionVersion.ts index c3ef36e2fd..b7700ecc80 100644 --- a/src/versions/ensurePublishedCollectionVersion.ts +++ b/src/versions/ensurePublishedCollectionVersion.ts @@ -2,6 +2,7 @@ import { Payload } from '..'; import { SanitizedCollectionConfig } from '../collections/config/types'; import { enforceMaxVersions } from './enforceMaxVersions'; import { PayloadRequest } from '../express/types'; +import { afterRead } from '../fields/hooks/afterRead'; type Args = { payload: Payload @@ -43,15 +44,12 @@ export const ensurePublishedCollectionVersion = async ({ }); if (moreRecentDrafts?.length === 0) { - const version = await payload.performFieldOperations(config, { - id, + const version = await afterRead({ depth: 0, + doc: docWithLocales, + entityConfig: config, req, - data: docWithLocales, - hook: 'afterRead', - operation: 'update', overrideAccess: true, - flattenLocales: false, showHiddenFields: true, }); diff --git a/src/versions/ensurePublishedGlobalVersion.ts b/src/versions/ensurePublishedGlobalVersion.ts index 570d051b65..9f5520c0e1 100644 --- a/src/versions/ensurePublishedGlobalVersion.ts +++ b/src/versions/ensurePublishedGlobalVersion.ts @@ -2,6 +2,7 @@ import { Payload } from '..'; import { enforceMaxVersions } from './enforceMaxVersions'; import { PayloadRequest } from '../express/types'; import { SanitizedGlobalConfig } from '../globals/config/types'; +import { afterRead } from '../fields/hooks/afterRead'; type Args = { payload: Payload @@ -38,14 +39,12 @@ export const ensurePublishedGlobalVersion = async ({ }); if (moreRecentDrafts?.length === 0) { - const version = await payload.performFieldOperations(config, { + const version = await afterRead({ depth: 0, + doc: docWithLocales, + entityConfig: config, req, - data: docWithLocales, - hook: 'afterRead', - operation: 'update', overrideAccess: true, - flattenLocales: false, showHiddenFields: true, }); diff --git a/src/versions/saveCollectionVersion.ts b/src/versions/saveCollectionVersion.ts index 85f0cd7983..55a596d65f 100644 --- a/src/versions/saveCollectionVersion.ts +++ b/src/versions/saveCollectionVersion.ts @@ -3,6 +3,7 @@ import { SanitizedCollectionConfig } from '../collections/config/types'; import { enforceMaxVersions } from './enforceMaxVersions'; import { PayloadRequest } from '../express/types'; import sanitizeInternalFields from '../utilities/sanitizeInternalFields'; +import { afterRead } from '../fields/hooks/afterRead'; type Args = { payload: Payload @@ -55,16 +56,14 @@ export const saveCollectionVersion = async ({ } } - version = await payload.performFieldOperations(config, { - id, + version = await afterRead({ depth: 0, + doc: version, + entityConfig: config, req, - data: version, - hook: 'afterRead', - operation: 'update', overrideAccess: true, - flattenLocales: false, showHiddenFields: true, + flattenLocales: false, }); if (version._id) delete version._id; diff --git a/src/versions/saveGlobalVersion.ts b/src/versions/saveGlobalVersion.ts index b7c2e48851..e4239c2dde 100644 --- a/src/versions/saveGlobalVersion.ts +++ b/src/versions/saveGlobalVersion.ts @@ -3,6 +3,7 @@ import { enforceMaxVersions } from './enforceMaxVersions'; import { PayloadRequest } from '../express/types'; import { SanitizedGlobalConfig } from '../globals/config/types'; import sanitizeInternalFields from '../utilities/sanitizeInternalFields'; +import { afterRead } from '../fields/hooks/afterRead'; type Args = { payload: Payload @@ -50,14 +51,13 @@ export const saveGlobalVersion = async ({ } } - version = await payload.performFieldOperations(config, { + version = await afterRead({ depth: 0, - req, - data: version, - hook: 'afterRead', - operation: 'update', - overrideAccess: true, + doc: version, + entityConfig: config, flattenLocales: false, + overrideAccess: true, + req, showHiddenFields: true, });