diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index 9bed6b8725..7c25daa6b9 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -116,7 +116,7 @@ export type BaseRichTextHookArgs< field: FieldAffectingData /** The global which the field belongs to. If the field belongs to a collection, this will be null. */ global: null | SanitizedGlobalConfig - + indexPath: number[] /** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */ originalDoc?: TData /** diff --git a/packages/payload/src/collections/operations/find.ts b/packages/payload/src/collections/operations/find.ts index 71387ca9c1..4e1c05969d 100644 --- a/packages/payload/src/collections/operations/find.ts +++ b/packages/payload/src/collections/operations/find.ts @@ -239,7 +239,7 @@ export const findOperation = async < doc._isLocked = !!lockedDoc doc._userEditing = lockedDoc ? lockedDoc?.user?.value : null } - } catch (error) { + } catch (_err) { for (const doc of result.docs) { doc._isLocked = false doc._userEditing = null diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index ea5d973b89..039c1ce2af 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -166,6 +166,7 @@ export type FieldHookArgs({ fields: collection?.fields || global?.fields, global, operation, - path: [], + parentIndexPath: '', + parentPath: '', + parentSchemaPath: '', previousDoc, previousSiblingDoc: previousDoc, req, - schemaPath: [], siblingData: data, siblingDoc: incomingDoc, }) diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index ac32a7c163..7c1dbe6e0f 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -7,7 +7,7 @@ import type { Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName } from '../../config/types.js' -import { getFieldPaths } from '../../getFieldPaths.js' +import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js' import { traverseFields } from './traverseFields.js' type Args = { @@ -19,14 +19,9 @@ type Args = { fieldIndex: number global: null | SanitizedGlobalConfig operation: 'create' | 'update' - /** - * The parent's path - */ - parentPath: (number | string)[] - /** - * The parent's schemaPath (path without indexes). - */ - parentSchemaPath: string[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string previousDoc: JsonObject previousSiblingDoc: JsonObject req: PayloadRequest @@ -46,6 +41,7 @@ export const promise = async ({ fieldIndex, global, operation, + parentIndexPath, parentPath, parentSchemaPath, previousDoc, @@ -54,15 +50,17 @@ export const promise = async ({ siblingData, siblingDoc, }: Args): Promise => { - const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({ + const { indexPath, path, schemaPath } = getFieldPaths({ field, index: fieldIndex, - parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data - parentPath: parentPath.join('.'), - parentSchemaPath: parentSchemaPath.join('.'), + parentIndexPath, + parentPath, + parentSchemaPath, }) - const fieldPath = _fieldPath ? _fieldPath.split('.') : [] - const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : [] + + const pathSegments = path ? path.split('.') : [] + const schemaPathSegments = schemaPath ? schemaPath.split('.') : [] + const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : [] if (fieldAffectsData(field)) { // Execute hooks @@ -76,14 +74,15 @@ export const promise = async ({ data, field, global, + indexPath: indexPathSegments, operation, originalDoc: doc, - path: fieldPath, + path: pathSegments, previousDoc, previousSiblingDoc, previousValue: previousDoc[field.name], req, - schemaPath: fieldSchemaPath, + schemaPath: schemaPathSegments, siblingData, value: siblingDoc[field.name], }) @@ -102,7 +101,7 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + rows.forEach((row, rowIndex) => { promises.push( traverseFields({ collection, @@ -112,18 +111,20 @@ export const promise = async ({ fields: field.fields, global, operation, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath, previousDoc, - previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject), + previousSiblingDoc: previousDoc?.[field.name]?.[rowIndex] || ({} as JsonObject), req, - schemaPath: fieldSchemaPath, - siblingData: siblingData?.[field.name]?.[i] || {}, + siblingData: siblingData?.[field.name]?.[rowIndex] || {}, siblingDoc: row ? { ...row } : {}, }), ) }) await Promise.all(promises) } + break } @@ -132,7 +133,8 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { const block = field.blocks.find( (blockType) => blockType.slug === (row as JsonObject).blockType, ) @@ -147,17 +149,19 @@ export const promise = async ({ fields: block.fields, global, operation, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath + '.' + block.slug, previousDoc, - previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as JsonObject), + previousSiblingDoc: previousDoc?.[field.name]?.[rowIndex] || ({} as JsonObject), req, - schemaPath: fieldSchemaPath, - siblingData: siblingData?.[field.name]?.[i] || {}, + siblingData: siblingData?.[field.name]?.[rowIndex] || {}, siblingDoc: row ? { ...row } : {}, }), ) } }) + await Promise.all(promises) } @@ -174,17 +178,19 @@ export const promise = async ({ fields: field.fields, global, operation, - path: fieldPath, + parentIndexPath: indexPath, + parentPath, + parentSchemaPath: schemaPath, previousDoc, previousSiblingDoc: { ...previousSiblingDoc }, req, - schemaPath: fieldSchemaPath, siblingData: siblingData || {}, siblingDoc: { ...siblingDoc }, }) break } + case 'group': { await traverseFields({ collection, @@ -194,11 +200,12 @@ export const promise = async ({ fields: field.fields, global, operation, - path: fieldPath, + parentIndexPath: '', + parentPath: path, + parentSchemaPath: schemaPath, previousDoc, previousSiblingDoc: previousDoc[field.name] as JsonObject, req, - schemaPath: fieldSchemaPath, siblingData: (siblingData?.[field.name] as JsonObject) || {}, siblingDoc: siblingDoc[field.name] as JsonObject, }) @@ -210,6 +217,7 @@ export const promise = async ({ if (!field?.editor) { throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor } + if (typeof field?.editor === 'function') { throw new Error('Attempted to access unsanitized rich text editor.') } @@ -226,14 +234,15 @@ export const promise = async ({ data, field, global, + indexPath: indexPathSegments, operation, originalDoc: doc, - path: fieldPath, + path: pathSegments, previousDoc, previousSiblingDoc, previousValue: previousDoc[field.name], req, - schemaPath: fieldSchemaPath, + schemaPath: schemaPathSegments, siblingData, value: siblingDoc[field.name], }) @@ -251,7 +260,9 @@ export const promise = async ({ let tabSiblingDoc = siblingDoc let tabPreviousSiblingDoc = siblingDoc - if (tabHasName(field)) { + const isNamedTab = tabHasName(field) + + if (isNamedTab) { tabSiblingData = (siblingData[field.name] as JsonObject) ?? {} tabSiblingDoc = (siblingDoc[field.name] as JsonObject) ?? {} tabPreviousSiblingDoc = (previousDoc[field.name] as JsonObject) ?? {} @@ -265,11 +276,12 @@ export const promise = async ({ fields: field.fields, global, operation, - path: fieldPath, + parentIndexPath: isNamedTab ? '' : indexPath, + parentPath: isNamedTab ? path : parentPath, + parentSchemaPath: schemaPath, previousDoc, previousSiblingDoc: tabPreviousSiblingDoc, req, - schemaPath: fieldSchemaPath, siblingData: tabSiblingData, siblingDoc: tabSiblingDoc, }) @@ -286,14 +298,16 @@ export const promise = async ({ fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })), global, operation, - path: fieldPath, + parentIndexPath: indexPath, + parentPath: path, + parentSchemaPath: schemaPath, previousDoc, previousSiblingDoc: { ...previousSiblingDoc }, req, - schemaPath: fieldSchemaPath, siblingData: siblingData || {}, siblingDoc: { ...siblingDoc }, }) + break } diff --git a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts index 223ba0ed33..273729e656 100644 --- a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts @@ -14,11 +14,12 @@ type Args = { fields: (Field | TabAsField)[] global: null | SanitizedGlobalConfig operation: 'create' | 'update' - path: (number | string)[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string previousDoc: JsonObject previousSiblingDoc: JsonObject req: PayloadRequest - schemaPath: string[] siblingData: JsonObject siblingDoc: JsonObject } @@ -31,11 +32,12 @@ export const traverseFields = async ({ fields, global, operation, - path, + parentIndexPath, + parentPath, + parentSchemaPath, previousDoc, previousSiblingDoc, req, - schemaPath, siblingData, siblingDoc, }: Args): Promise => { @@ -52,8 +54,9 @@ export const traverseFields = async ({ fieldIndex, global, operation, - parentPath: path, - parentSchemaPath: schemaPath, + parentIndexPath, + parentPath, + parentSchemaPath, previousDoc, previousSiblingDoc, req, diff --git a/packages/payload/src/fields/hooks/afterRead/index.ts b/packages/payload/src/fields/hooks/afterRead/index.ts index d345e1c83a..d9a659df69 100644 --- a/packages/payload/src/fields/hooks/afterRead/index.ts +++ b/packages/payload/src/fields/hooks/afterRead/index.ts @@ -83,11 +83,12 @@ export async function afterRead(args: Args): Promise global, locale, overrideAccess, - path: [], + parentIndexPath: '', + parentPath: '', + parentSchemaPath: '', populate, populationPromises, req, - schemaPath: [], select, selectMode: select ? getSelectMode(select) : undefined, showHiddenFields, diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 509160a4df..5a36fdcf91 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -14,7 +14,7 @@ import type { Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName } from '../../config/types.js' import { getDefaultValue } from '../../getDefaultValue.js' -import { getFieldPaths } from '../../getFieldPaths.js' +import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js' import { relationshipPopulationPromise } from './relationshipPopulationPromise.js' import { traverseFields } from './traverseFields.js' @@ -37,14 +37,9 @@ type Args = { global: null | SanitizedGlobalConfig locale: null | string overrideAccess: boolean - /** - * The parent's path. - */ - parentPath: (number | string)[] - /** - * The parent's schemaPath (path without indexes). - */ - parentSchemaPath: string[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string populate?: PopulateType populationPromises: Promise[] req: PayloadRequest @@ -80,6 +75,7 @@ export const promise = async ({ global, locale, overrideAccess, + parentIndexPath, parentPath, parentSchemaPath, populate, @@ -92,15 +88,17 @@ export const promise = async ({ triggerAccessControl = true, triggerHooks = true, }: Args): Promise => { - const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({ + const { indexPath, path, schemaPath } = getFieldPaths({ field, index: fieldIndex, - parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data - parentPath: parentPath.join('.'), - parentSchemaPath: parentSchemaPath.join('.'), + parentIndexPath, + parentPath, + parentSchemaPath, }) - const fieldPath = _fieldPath ? _fieldPath.split('.') : [] - const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : [] + + const pathSegments = path ? path.split('.') : [] + const schemaPathSegments = schemaPath ? schemaPath.split('.') : [] + const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : [] if ( fieldAffectsData(field) && @@ -111,6 +109,7 @@ export const promise = async ({ delete siblingDoc[field.name] } + // Strip unselected fields if (fieldAffectsData(field) && select && selectMode) { if (selectMode === 'include') { if (!select[field.name]) { @@ -246,12 +245,13 @@ export const promise = async ({ field, findMany, global, + indexPath: indexPathSegments, operation: 'read', originalDoc: doc, overrideAccess, - path: fieldPath, + path: pathSegments, req, - schemaPath: fieldSchemaPath, + schemaPath: schemaPathSegments, showHiddenFields, siblingData: siblingDoc, value, @@ -275,12 +275,13 @@ export const promise = async ({ field, findMany, global, + indexPath: indexPathSegments, operation: 'read', originalDoc: doc, overrideAccess, - path: fieldPath, + path: pathSegments, req, - schemaPath: fieldSchemaPath, + schemaPath: schemaPathSegments, showHiddenFields, siblingData: siblingDoc, value: siblingDoc[field.name], @@ -361,7 +362,7 @@ export const promise = async ({ } if (Array.isArray(rows)) { - rows.forEach((row, i) => { + rows.forEach((row, rowIndex) => { traverseFields({ collection, context, @@ -377,11 +378,12 @@ export const promise = async ({ global, locale, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath, populate, populationPromises, req, - schemaPath: fieldSchemaPath, select: typeof arraySelect === 'object' ? arraySelect : undefined, selectMode, showHiddenFields, @@ -393,7 +395,7 @@ export const promise = async ({ } else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) { Object.values(rows).forEach((localeRows) => { if (Array.isArray(localeRows)) { - localeRows.forEach((row, i) => { + localeRows.forEach((row, rowIndex) => { traverseFields({ collection, context, @@ -409,11 +411,12 @@ export const promise = async ({ global, locale, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath, populate, populationPromises, req, - schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc: (row as JsonObject) || {}, triggerAccessControl, @@ -434,7 +437,7 @@ export const promise = async ({ let blocksSelect = select?.[field.name] if (Array.isArray(rows)) { - rows.forEach((row, i) => { + rows.forEach((row, rowIndex) => { const block = field.blocks.find( (blockType) => blockType.slug === (row as JsonObject).blockType, ) @@ -487,11 +490,12 @@ export const promise = async ({ global, locale, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath + '.' + block.slug, populate, populationPromises, req, - schemaPath: fieldSchemaPath, select: typeof blockSelect === 'object' ? blockSelect : undefined, selectMode: blockSelectMode, showHiddenFields, @@ -504,7 +508,7 @@ export const promise = async ({ } else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) { Object.values(rows).forEach((localeRows) => { if (Array.isArray(localeRows)) { - localeRows.forEach((row, i) => { + localeRows.forEach((row, rowIndex) => { const block = field.blocks.find( (blockType) => blockType.slug === (row as JsonObject).blockType, ) @@ -525,11 +529,12 @@ export const promise = async ({ global, locale, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath + '.' + block.slug, populate, populationPromises, req, - schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc: (row as JsonObject) || {}, triggerAccessControl, @@ -563,11 +568,12 @@ export const promise = async ({ global, locale, overrideAccess, - path: fieldPath, + parentIndexPath: indexPath, + parentPath, + parentSchemaPath: schemaPath, populate, populationPromises, req, - schemaPath: fieldSchemaPath, select, selectMode, showHiddenFields, @@ -578,8 +584,10 @@ export const promise = async ({ break } + case 'group': { let groupDoc = siblingDoc[field.name] as JsonObject + if (typeof siblingDoc[field.name] !== 'object') { groupDoc = {} } @@ -601,11 +609,12 @@ export const promise = async ({ global, locale, overrideAccess, - path: fieldPath, + parentIndexPath: '', + parentPath: path, + parentSchemaPath: schemaPath, populate, populationPromises, req, - schemaPath: fieldSchemaPath, select: typeof groupSelect === 'object' ? groupSelect : undefined, selectMode, showHiddenFields, @@ -621,6 +630,7 @@ export const promise = async ({ if (!field?.editor) { throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor } + if (typeof field?.editor === 'function') { throw new Error('Attempted to access unsanitized rich text editor.') } @@ -652,15 +662,16 @@ export const promise = async ({ findMany, flattenLocales, global, + indexPath: indexPathSegments, locale, operation: 'read', originalDoc: doc, overrideAccess, - path: fieldPath, + path: pathSegments, populate, populationPromises, req, - schemaPath: fieldSchemaPath, + schemaPath: schemaPathSegments, showHiddenFields, siblingData: siblingDoc, triggerAccessControl, @@ -689,15 +700,16 @@ export const promise = async ({ findMany, flattenLocales, global, + indexPath: indexPathSegments, locale, operation: 'read', originalDoc: doc, overrideAccess, - path: fieldPath, + path: pathSegments, populate, populationPromises, req, - schemaPath: fieldSchemaPath, + schemaPath: schemaPathSegments, showHiddenFields, siblingData: siblingDoc, triggerAccessControl, @@ -717,8 +729,12 @@ export const promise = async ({ case 'tab': { let tabDoc = siblingDoc let tabSelect: SelectType | undefined - if (tabHasName(field)) { + + const isNamedTab = tabHasName(field) + + if (isNamedTab) { tabDoc = siblingDoc[field.name] as JsonObject + if (typeof siblingDoc[field.name] !== 'object') { tabDoc = {} } @@ -745,11 +761,12 @@ export const promise = async ({ global, locale, overrideAccess, - path: fieldPath, + parentIndexPath: isNamedTab ? '' : indexPath, + parentPath: isNamedTab ? path : parentPath, + parentSchemaPath: schemaPath, populate, populationPromises, req, - schemaPath: fieldSchemaPath, select: tabSelect, selectMode, showHiddenFields, @@ -777,11 +794,12 @@ export const promise = async ({ global, locale, overrideAccess, - path: fieldPath, + parentIndexPath: indexPath, + parentPath: path, + parentSchemaPath: schemaPath, populate, populationPromises, req, - schemaPath: fieldSchemaPath, select, selectMode, showHiddenFields, @@ -789,9 +807,9 @@ export const promise = async ({ triggerAccessControl, triggerHooks, }) + break } - default: { break } diff --git a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts index 81894caf8b..7f5028a19c 100644 --- a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts +++ b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts @@ -30,11 +30,12 @@ type Args = { global: null | SanitizedGlobalConfig locale: null | string overrideAccess: boolean - path: (number | string)[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string populate?: PopulateType populationPromises: Promise[] req: PayloadRequest - schemaPath: string[] select?: SelectType selectMode?: SelectMode showHiddenFields: boolean @@ -58,11 +59,12 @@ export const traverseFields = ({ global, locale, overrideAccess, - path, + parentIndexPath, + parentPath, + parentSchemaPath, populate, populationPromises, req, - schemaPath, select, selectMode, showHiddenFields, @@ -88,8 +90,9 @@ export const traverseFields = ({ global, locale, overrideAccess, - parentPath: path, - parentSchemaPath: schemaPath, + parentIndexPath, + parentPath, + parentSchemaPath, populate, populationPromises, req, diff --git a/packages/payload/src/fields/hooks/beforeChange/index.ts b/packages/payload/src/fields/hooks/beforeChange/index.ts index 7c33ec5152..9359b5c37b 100644 --- a/packages/payload/src/fields/hooks/beforeChange/index.ts +++ b/packages/payload/src/fields/hooks/beforeChange/index.ts @@ -28,6 +28,7 @@ export type Args = { * - Transform data for storage * - Unflatten locales. The input `data` is the normal document for one locale. The output result will become the document with locales. */ + export const beforeChange = async ({ id, collection, @@ -56,9 +57,10 @@ export const beforeChange = async ({ global, mergeLocaleActions, operation, - path: [], + parentIndexPath: '', + parentPath: '', + parentSchemaPath: '', req, - schemaPath: [], siblingData: data, siblingDoc: doc, siblingDocWithLocales: docWithLocales, diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index 2f0641d64e..1e827684fd 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -4,14 +4,14 @@ import type { ValidationFieldError } from '../../../errors/index.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { RequestContext } from '../../../index.js' import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js' -import type { BaseValidateOptions, Field, TabAsField } from '../../config/types.js' +import type { Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js' -import { getFormattedLabel } from '../../../utilities/getFormattedLabel.js' +import { getLabelFromPath } from '../../../utilities/getLabelFromPath.js' import { getTranslatedLabel } from '../../../utilities/getTranslatedLabel.js' import { fieldAffectsData, tabHasName } from '../../config/types.js' -import { getFieldPaths } from '../../getFieldPaths.js' +import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js' import { getExistingRowDoc } from './getExistingRowDoc.js' import { traverseFields } from './traverseFields.js' @@ -23,23 +23,14 @@ type Args = { docWithLocales: JsonObject errors: ValidationFieldError[] field: Field | TabAsField - /** - * The index of the field as it appears in the parent's fields array. This is used to construct the field path / schemaPath - * for unnamed fields like rows and collapsibles. - */ fieldIndex: number global: null | SanitizedGlobalConfig id?: number | string mergeLocaleActions: (() => Promise)[] operation: Operation - /** - * The parent's path. - */ - parentPath: (number | string)[] - /** - * The parent's schemaPath (path without indexes). - */ - parentSchemaPath: string[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string req: PayloadRequest siblingData: JsonObject siblingDoc: JsonObject @@ -68,6 +59,7 @@ export const promise = async ({ global, mergeLocaleActions, operation, + parentIndexPath, parentPath, parentSchemaPath, req, @@ -76,6 +68,14 @@ export const promise = async ({ siblingDocWithLocales, skipValidation, }: Args): Promise => { + const { indexPath, path, schemaPath } = getFieldPaths({ + field, + index: fieldIndex, + parentIndexPath, + parentPath, + parentSchemaPath, + }) + const passesCondition = field.admin?.condition ? Boolean(field.admin.condition(data, siblingData, { user: req.user })) : true @@ -84,15 +84,9 @@ export const promise = async ({ const defaultLocale = localization ? localization?.defaultLocale : 'en' const operationLocale = req.locale || defaultLocale - const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({ - field, - index: fieldIndex, - parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data - parentPath: parentPath.join('.'), - parentSchemaPath: parentSchemaPath.join('.'), - }) - const fieldPath = _fieldPath ? _fieldPath.split('.') : [] - const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : [] + const pathSegments = path ? path.split('.') : [] + const schemaPathSegments = schemaPath ? schemaPath.split('.') : [] + const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : [] if (fieldAffectsData(field)) { // skip validation if the field is localized and the incoming data is null @@ -113,13 +107,14 @@ export const promise = async ({ data, field, global, + indexPath: indexPathSegments, operation, originalDoc: doc, - path: fieldPath, + path: pathSegments, previousSiblingDoc: siblingDoc, previousValue: siblingDoc[field.name], req, - schemaPath: parentSchemaPath, + schemaPath: schemaPathSegments, siblingData, siblingDocWithLocales, value: siblingData[field.name], @@ -163,16 +158,17 @@ export const promise = async ({ if (typeof validationResult === 'string') { const label = getTranslatedLabel(field?.label || field?.name, req.i18n) + const parentPathSegments = parentPath ? parentPath.split('.') : [] const fieldLabel = - Array.isArray(parentPath) && parentPath.length > 0 - ? getFormattedLabel([...parentPath, label]) + Array.isArray(parentPathSegments) && parentPathSegments.length > 0 + ? getLabelFromPath(parentPathSegments.concat(label)) : label errors.push({ label: fieldLabel, message: validationResult, - path: fieldPath.join('.'), + path, }) } } @@ -216,7 +212,8 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { promises.push( traverseFields({ id, @@ -230,9 +227,10 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData: row as JsonObject, siblingDoc: getExistingRowDoc(row as JsonObject, siblingDoc[field.name]), siblingDocWithLocales: getExistingRowDoc( @@ -254,8 +252,10 @@ export const promise = async ({ const rows = siblingData[field.name] if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name]) + const rowSiblingDocWithLocales = getExistingRowDoc( row as JsonObject, siblingDocWithLocales ? siblingDocWithLocales[field.name] : {}, @@ -278,9 +278,10 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath + '.' + block.slug, req, - schemaPath: fieldSchemaPath, siblingData: row as JsonObject, siblingDoc: rowSiblingDoc, siblingDocWithLocales: rowSiblingDocWithLocales, @@ -310,9 +311,10 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: fieldPath, + parentIndexPath: indexPath, + parentPath, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData, siblingDoc, siblingDocWithLocales, @@ -326,9 +328,11 @@ export const promise = async ({ if (typeof siblingData[field.name] !== 'object') { siblingData[field.name] = {} } + if (typeof siblingDoc[field.name] !== 'object') { siblingDoc[field.name] = {} } + if (typeof siblingDocWithLocales[field.name] !== 'object') { siblingDocWithLocales[field.name] = {} } @@ -345,9 +349,10 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: fieldPath, + parentIndexPath: '', + parentPath: path, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData: siblingData[field.name] as JsonObject, siblingDoc: siblingDoc[field.name] as JsonObject, siblingDocWithLocales: siblingDocWithLocales[field.name] as JsonObject, @@ -356,6 +361,7 @@ export const promise = async ({ break } + case 'point': { // Transform point data for storage if ( @@ -379,6 +385,7 @@ export const promise = async ({ if (!field?.editor) { throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor } + if (typeof field?.editor === 'function') { throw new Error('Attempted to access unsanitized rich text editor.') } @@ -397,14 +404,15 @@ export const promise = async ({ errors, field, global, + indexPath: indexPathSegments, mergeLocaleActions, operation, originalDoc: doc, - path: fieldPath, + path: pathSegments, previousSiblingDoc: siblingDoc, previousValue: siblingDoc[field.name], req, - schemaPath: parentSchemaPath, + schemaPath: schemaPathSegments, siblingData, siblingDocWithLocales, skipValidation, @@ -425,13 +433,17 @@ export const promise = async ({ let tabSiblingDoc = siblingDoc let tabSiblingDocWithLocales = siblingDocWithLocales - if (tabHasName(field)) { + const isNamedTab = tabHasName(field) + + if (isNamedTab) { if (typeof siblingData[field.name] !== 'object') { siblingData[field.name] = {} } + if (typeof siblingDoc[field.name] !== 'object') { siblingDoc[field.name] = {} } + if (typeof siblingDocWithLocales[field.name] !== 'object') { siblingDocWithLocales[field.name] = {} } @@ -453,9 +465,10 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: fieldPath, + parentIndexPath: isNamedTab ? '' : indexPath, + parentPath: isNamedTab ? path : parentPath, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData: tabSiblingData, siblingDoc: tabSiblingDoc, siblingDocWithLocales: tabSiblingDocWithLocales, @@ -478,9 +491,10 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: fieldPath, + parentIndexPath: indexPath, + parentPath: path, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData, siblingDoc, siblingDocWithLocales, diff --git a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts index 626c2a5474..c75b3e865a 100644 --- a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts @@ -25,9 +25,10 @@ type Args = { id?: number | string mergeLocaleActions: (() => Promise)[] operation: Operation - path: (number | string)[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string req: PayloadRequest - schemaPath: string[] siblingData: JsonObject /** * The original siblingData (not modified by any hooks) @@ -60,9 +61,10 @@ export const traverseFields = async ({ global, mergeLocaleActions, operation, - path, + parentIndexPath, + parentPath, + parentSchemaPath, req, - schemaPath, siblingData, siblingDoc, siblingDocWithLocales, @@ -85,8 +87,9 @@ export const traverseFields = async ({ global, mergeLocaleActions, operation, - parentPath: path, - parentSchemaPath: schemaPath, + parentIndexPath, + parentPath, + parentSchemaPath, req, siblingData, siblingDoc, diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/index.ts b/packages/payload/src/fields/hooks/beforeDuplicate/index.ts index 5679b18015..7eea0c3af3 100644 --- a/packages/payload/src/fields/hooks/beforeDuplicate/index.ts +++ b/packages/payload/src/fields/hooks/beforeDuplicate/index.ts @@ -2,7 +2,6 @@ import type { SanitizedCollectionConfig } from '../../../collections/config/type import type { RequestContext } from '../../../index.js' import type { JsonObject, PayloadRequest } from '../../../types/index.js' -import { deepCopyObjectSimple } from '../../../utilities/deepCopyObject.js' import { traverseFields } from './traverseFields.js' type Args = { @@ -35,9 +34,10 @@ export const beforeDuplicate = async ({ doc, fields: collection?.fields, overrideAccess, - path: [], + parentIndexPath: '', + parentPath: '', + parentSchemaPath: '', req, - schemaPath: [], siblingDoc: doc, }) diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts index 9c3d43e092..cec3cac67d 100644 --- a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts @@ -3,8 +3,8 @@ import type { RequestContext } from '../../../index.js' import type { JsonObject, PayloadRequest } from '../../../types/index.js' import type { Field, FieldHookArgs, TabAsField } from '../../config/types.js' -import { fieldAffectsData, tabHasName } from '../../config/types.js' -import { getFieldPaths } from '../../getFieldPaths.js' +import { fieldAffectsData } from '../../config/types.js' +import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js' import { runBeforeDuplicateHooks } from './runHook.js' import { traverseFields } from './traverseFields.js' @@ -16,8 +16,9 @@ type Args = { fieldIndex: number id?: number | string overrideAccess: boolean - parentPath: (number | string)[] - parentSchemaPath: string[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string req: PayloadRequest siblingDoc: JsonObject } @@ -30,40 +31,25 @@ export const promise = async ({ field, fieldIndex, overrideAccess, + parentIndexPath, parentPath, parentSchemaPath, req, siblingDoc, }: Args): Promise => { - const { localization } = req.payload.config - - const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({ + const { indexPath, path, schemaPath } = getFieldPaths({ field, index: fieldIndex, - parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data - parentPath: parentPath.join('.'), - parentSchemaPath: parentSchemaPath.join('.'), + parentIndexPath, + parentPath, + parentSchemaPath, }) - const fieldPath = _fieldPath ? _fieldPath.split('.') : [] - const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : [] - // Handle unnamed tabs - if (field.type === 'tab' && !tabHasName(field)) { - await traverseFields({ - id, - collection, - context, - doc, - fields: field.fields, - overrideAccess, - path: fieldPath, - req, - schemaPath: fieldSchemaPath, - siblingDoc, - }) + const { localization } = req.payload.config - return - } + const pathSegments = path ? path.split('.') : [] + const schemaPathSegments = schemaPath ? schemaPath.split('.') : [] + const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : [] if (fieldAffectsData(field)) { let fieldData = siblingDoc?.[field.name] @@ -82,11 +68,12 @@ export const promise = async ({ data: doc, field, global: undefined, - path: fieldPath, + indexPath: indexPathSegments, + path: pathSegments, previousSiblingDoc: siblingDoc, previousValue: siblingDoc[field.name]?.[locale], req, - schemaPath: parentSchemaPath, + schemaPath: schemaPathSegments, siblingData: siblingDoc, siblingDocWithLocales: siblingDoc, value: siblingDoc[field.name]?.[locale], @@ -114,11 +101,12 @@ export const promise = async ({ data: doc, field, global: undefined, - path: fieldPath, + indexPath: indexPathSegments, + path: pathSegments, previousSiblingDoc: siblingDoc, previousValue: siblingDoc[field.name], req, - schemaPath: parentSchemaPath, + schemaPath: schemaPathSegments, siblingData: siblingDoc, siblingDocWithLocales: siblingDoc, value: siblingDoc[field.name], @@ -150,7 +138,8 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { promises.push( traverseFields({ id, @@ -159,22 +148,26 @@ export const promise = async ({ doc, fields: field.fields, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingDoc: row, }), ) }) } + break } + case 'blocks': { const rows = fieldData[locale] if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { const blockTypeToMatch = row.blockType const block = field.blocks.find( @@ -189,9 +182,10 @@ export const promise = async ({ doc, fields: block.fields, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath + '.' + block.slug, req, - schemaPath: fieldSchemaPath, siblingDoc: row, }), ) @@ -201,7 +195,6 @@ export const promise = async ({ } case 'group': - case 'tab': { promises.push( traverseFields({ @@ -211,9 +204,10 @@ export const promise = async ({ doc, fields: field.fields, overrideAccess, - path: fieldSchemaPath, + parentIndexPath: '', + parentPath: path, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingDoc: fieldData[locale], }), ) @@ -235,7 +229,8 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { promises.push( traverseFields({ id, @@ -244,23 +239,28 @@ export const promise = async ({ doc, fields: field.fields, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingDoc: row, }), ) }) + await Promise.all(promises) } + break } + case 'blocks': { const rows = siblingDoc[field.name] if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { const blockTypeToMatch = row.blockType const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch) @@ -275,28 +275,28 @@ export const promise = async ({ doc, fields: block.fields, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath + '.' + block.slug, req, - schemaPath: fieldSchemaPath, siblingDoc: row, }), ) } }) + await Promise.all(promises) } break } - case 'group': - - case 'tab': { + case 'group': { if (typeof siblingDoc[field.name] !== 'object') { siblingDoc[field.name] = {} } - const groupDoc = siblingDoc[field.name] as Record + const groupDoc = siblingDoc[field.name] as JsonObject await traverseFields({ id, @@ -305,10 +305,35 @@ export const promise = async ({ doc, fields: field.fields, overrideAccess, - path: fieldPath, + parentIndexPath: '', + parentPath: path, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, - siblingDoc: groupDoc as JsonObject, + siblingDoc: groupDoc, + }) + + break + } + + case 'tab': { + if (typeof siblingDoc[field.name] !== 'object') { + siblingDoc[field.name] = {} + } + + const tabDoc = siblingDoc[field.name] as JsonObject + + await traverseFields({ + id, + collection, + context, + doc, + fields: field.fields, + overrideAccess, + parentIndexPath: '', + parentPath: path, + parentSchemaPath: schemaPath, + req, + siblingDoc: tabDoc, }) break @@ -327,9 +352,31 @@ export const promise = async ({ doc, fields: field.fields, overrideAccess, - path: fieldPath, + parentIndexPath: indexPath, + parentPath, + parentSchemaPath: schemaPath, + req, + siblingDoc, + }) + + break + } + + // Unnamed Tab + // @ts-expect-error `fieldAffectsData` inferred return type doesn't account for TabAsField + case 'tab': { + await traverseFields({ + id, + collection, + context, + doc, + // @ts-expect-error `fieldAffectsData` inferred return type doesn't account for TabAsField + fields: field.fields, + overrideAccess, + parentIndexPath: indexPath, + parentPath, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingDoc, }) @@ -344,9 +391,10 @@ export const promise = async ({ doc, fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })), overrideAccess, - path: fieldPath, + parentIndexPath: indexPath, + parentPath: path, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingDoc, }) diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeDuplicate/traverseFields.ts index 6e9b6b04c6..b94047870f 100644 --- a/packages/payload/src/fields/hooks/beforeDuplicate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeDuplicate/traverseFields.ts @@ -12,9 +12,10 @@ type Args = { fields: (Field | TabAsField)[] id?: number | string overrideAccess: boolean - path: (number | string)[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string req: PayloadRequest - schemaPath: string[] siblingDoc: JsonObject } @@ -25,12 +26,14 @@ export const traverseFields = async ({ doc, fields, overrideAccess, - path, + parentIndexPath, + parentPath, + parentSchemaPath, req, - schemaPath, siblingDoc, }: Args): Promise => { const promises = [] + fields.forEach((field, fieldIndex) => { promises.push( promise({ @@ -41,8 +44,9 @@ export const traverseFields = async ({ field, fieldIndex, overrideAccess, - parentPath: path, - parentSchemaPath: schemaPath, + parentIndexPath, + parentPath, + parentSchemaPath, req, siblingDoc, }), diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.ts b/packages/payload/src/fields/hooks/beforeValidate/index.ts index 590417316a..b390d7a1c5 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.ts @@ -47,9 +47,10 @@ export const beforeValidate = async ({ global, operation, overrideAccess, - path: [], + parentIndexPath: '', + parentPath: '', + parentSchemaPath: '', req, - schemaPath: [], siblingData: incomingData, siblingDoc: doc, }) diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index e109b20b2d..fede4caed9 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -8,7 +8,7 @@ import type { Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js' import { getDefaultValue } from '../../getDefaultValue.js' -import { getFieldPaths } from '../../getFieldPaths.js' +import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js' import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js' import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js' import { traverseFields } from './traverseFields.js' @@ -27,8 +27,9 @@ type Args = { id?: number | string operation: 'create' | 'update' overrideAccess: boolean - parentPath: (number | string)[] - parentSchemaPath: string[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string req: PayloadRequest siblingData: JsonObject /** @@ -55,21 +56,24 @@ export const promise = async ({ global, operation, overrideAccess, + parentIndexPath, parentPath, parentSchemaPath, req, siblingData, siblingDoc, }: Args): Promise => { - const { path: _fieldPath, schemaPath: _fieldSchemaPath } = getFieldPaths({ + const { indexPath, path, schemaPath } = getFieldPaths({ field, index: fieldIndex, - parentIndexPath: '', // Doesn't matter, as unnamed fields do not affect data, and hooks are only run on fields that affect data - parentPath: parentPath.join('.'), - parentSchemaPath: parentSchemaPath.join('.'), + parentIndexPath, + parentPath, + parentSchemaPath, }) - const fieldPath = _fieldPath ? _fieldPath.split('.') : [] - const fieldSchemaPath = _fieldSchemaPath ? _fieldSchemaPath.split('.') : [] + + const pathSegments = path ? path.split('.') : [] + const schemaPathSegments = schemaPath ? schemaPath.split('.') : [] + const indexPathSegments = indexPath ? indexPath.split('-').filter(Boolean)?.map(Number) : [] if (fieldAffectsData(field)) { if (field.name === 'id') { @@ -271,14 +275,15 @@ export const promise = async ({ data, field, global, + indexPath: indexPathSegments, operation, originalDoc: doc, overrideAccess, - path: fieldPath, + path: pathSegments, previousSiblingDoc: siblingDoc, previousValue: siblingDoc[field.name], req, - schemaPath: fieldSchemaPath, + schemaPath: schemaPathSegments, siblingData, value: siblingData[field.name], }) @@ -325,7 +330,8 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { promises.push( traverseFields({ id, @@ -337,14 +343,16 @@ export const promise = async ({ global, operation, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData: row as JsonObject, siblingDoc: getExistingRowDoc(row as JsonObject, siblingDoc[field.name]), }), ) }) + await Promise.all(promises) } break @@ -355,7 +363,8 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row, i) => { + + rows.forEach((row, rowIndex) => { const rowSiblingDoc = getExistingRowDoc(row as JsonObject, siblingDoc[field.name]) const blockTypeToMatch = (row as JsonObject).blockType || rowSiblingDoc.blockType const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch) @@ -374,15 +383,17 @@ export const promise = async ({ global, operation, overrideAccess, - path: [...fieldPath, i], + parentIndexPath: '', + parentPath: path + '.' + rowIndex, + parentSchemaPath: schemaPath + '.' + block.slug, req, - schemaPath: fieldSchemaPath, siblingData: row as JsonObject, siblingDoc: rowSiblingDoc, }), ) } }) + await Promise.all(promises) } @@ -401,19 +412,22 @@ export const promise = async ({ global, operation, overrideAccess, - path: fieldPath, + parentIndexPath: indexPath, + parentPath, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData, siblingDoc, }) break } + case 'group': { if (typeof siblingData[field.name] !== 'object') { siblingData[field.name] = {} } + if (typeof siblingDoc[field.name] !== 'object') { siblingDoc[field.name] = {} } @@ -431,9 +445,10 @@ export const promise = async ({ global, operation, overrideAccess, - path: fieldPath, + parentIndexPath: '', + parentPath: path, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData: groupData as JsonObject, siblingDoc: groupDoc as JsonObject, }) @@ -445,6 +460,7 @@ export const promise = async ({ if (!field?.editor) { throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor } + if (typeof field?.editor === 'function') { throw new Error('Attempted to access unsanitized rich text editor.') } @@ -461,14 +477,15 @@ export const promise = async ({ data, field, global, + indexPath: indexPathSegments, operation, originalDoc: doc, overrideAccess, - path: fieldPath, + path: pathSegments, previousSiblingDoc: siblingDoc, previousValue: siblingData[field.name], req, - schemaPath: fieldSchemaPath, + schemaPath: schemaPathSegments, siblingData, value: siblingData[field.name], }) @@ -484,10 +501,14 @@ export const promise = async ({ case 'tab': { let tabSiblingData let tabSiblingDoc - if (tabHasName(field)) { + + const isNamedTab = tabHasName(field) + + if (isNamedTab) { if (typeof siblingData[field.name] !== 'object') { siblingData[field.name] = {} } + if (typeof siblingDoc[field.name] !== 'object') { siblingDoc[field.name] = {} } @@ -509,9 +530,10 @@ export const promise = async ({ global, operation, overrideAccess, - path: fieldPath, + parentIndexPath: isNamedTab ? '' : indexPath, + parentPath: isNamedTab ? path : parentPath, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData: tabSiblingData, siblingDoc: tabSiblingDoc, }) @@ -530,9 +552,10 @@ export const promise = async ({ global, operation, overrideAccess, - path: fieldPath, + parentIndexPath: indexPath, + parentPath: path, + parentSchemaPath: schemaPath, req, - schemaPath: fieldSchemaPath, siblingData, siblingDoc, }) diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts index cc8fa9a25a..8f1a29f5e0 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts @@ -19,9 +19,10 @@ type Args = { id?: number | string operation: 'create' | 'update' overrideAccess: boolean - path: (number | string)[] + parentIndexPath: string + parentPath: string + parentSchemaPath: string req: PayloadRequest - schemaPath: string[] siblingData: JsonObject /** * The original siblingData (not modified by any hooks) @@ -39,13 +40,15 @@ export const traverseFields = async ({ global, operation, overrideAccess, - path, + parentIndexPath, + parentPath, + parentSchemaPath, req, - schemaPath, siblingData, siblingDoc, }: Args): Promise => { const promises = [] + fields.forEach((field, fieldIndex) => { promises.push( promise({ @@ -59,13 +62,15 @@ export const traverseFields = async ({ global, operation, overrideAccess, - parentPath: path, - parentSchemaPath: schemaPath, + parentIndexPath, + parentPath, + parentSchemaPath, req, siblingData, siblingDoc, }), ) }) + await Promise.all(promises) } diff --git a/packages/payload/src/fields/utilities/getFieldPaths.ts b/packages/payload/src/fields/utilities/getFieldPaths.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index c1916b34c5..43229471c4 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1152,6 +1152,7 @@ export { type ServerOnlyFieldProperties, } from './fields/config/client.js' export { sanitizeFields } from './fields/config/sanitize.js' + export type { AdminClient, ArrayField, @@ -1428,7 +1429,6 @@ export { deleteCollectionVersions } from './versions/deleteCollectionVersions.js export { enforceMaxVersions } from './versions/enforceMaxVersions.js' export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js' export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js' - export { saveVersion } from './versions/saveVersion.js' export type { SchedulePublishTaskInput } from './versions/schedule/types.js' export type { TypeWithVersion } from './versions/types.js' diff --git a/packages/payload/src/utilities/getFormattedLabel.ts b/packages/payload/src/utilities/getLabelFromPath.ts similarity index 88% rename from packages/payload/src/utilities/getFormattedLabel.ts rename to packages/payload/src/utilities/getLabelFromPath.ts index 7e1241e16d..fbf4bec5fc 100644 --- a/packages/payload/src/utilities/getFormattedLabel.ts +++ b/packages/payload/src/utilities/getLabelFromPath.ts @@ -1,4 +1,4 @@ -export const getFormattedLabel = (path: (number | string)[]): string => { +export const getLabelFromPath = (path: (number | string)[]): string => { return path .filter((pathSegment) => !(typeof pathSegment === 'string' && pathSegment.includes('_index'))) .reduce((acc, part) => { diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 170e5e3977..1c4940d044 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -190,6 +190,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte context: _context, data, global, + indexPath, operation, originalDoc, path, @@ -198,6 +199,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte req, schemaPath, } = args + let { value } = args if (finalSanitizedEditorConfig?.features?.hooks?.afterChange?.length) { for (const hook of finalSanitizedEditorConfig.features.hooks.afterChange) { @@ -292,11 +294,12 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte fields: subFields, global, operation, - path, + parentIndexPath: indexPath.join('-'), + parentPath: path.join('.'), + parentSchemaPath: schemaPath.join('.'), previousDoc, previousSiblingDoc: { ...nodePreviousSiblingDoc }, req, - schemaPath, siblingData: nodeSiblingData || {}, siblingDoc: { ...nodeSiblingDoc }, }) @@ -322,6 +325,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte findMany, flattenLocales, global, + indexPath, locale, originalDoc, overrideAccess, @@ -334,6 +338,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte triggerAccessControl, triggerHooks, } = args + let { value } = args if (finalSanitizedEditorConfig?.features?.hooks?.afterRead?.length) { @@ -408,11 +413,12 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte global, locale: locale!, overrideAccess: overrideAccess!, - path, + parentIndexPath: indexPath.join('-'), + parentPath: path.join('.'), + parentSchemaPath: schemaPath.join('.'), populate, populationPromises: populationPromises!, req, - schemaPath, showHiddenFields: showHiddenFields!, siblingDoc: nodeSliblingData, triggerAccessControl, @@ -435,6 +441,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte errors, field, global, + indexPath, mergeLocaleActions, operation, originalDoc, @@ -446,6 +453,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte siblingDocWithLocales, skipValidation, } = args + let { value } = args if (finalSanitizedEditorConfig?.features?.hooks?.beforeChange?.length) { @@ -566,9 +574,10 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte global, mergeLocaleActions: mergeLocaleActions!, operation: operation!, - path, + parentIndexPath: indexPath.join('-'), + parentPath: path.join('.'), + parentSchemaPath: schemaPath.join('.'), req, - schemaPath, siblingData: nodeSiblingData, siblingDoc: nodePreviousSiblingDoc, siblingDocWithLocales: nodeSiblingDocWithLocales ?? {}, @@ -620,6 +629,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte context, data, global, + indexPath, operation, originalDoc, overrideAccess, @@ -628,6 +638,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte req, schemaPath, } = args + let { value } = args if (finalSanitizedEditorConfig?.features?.hooks?.beforeValidate?.length) { for (const hook of finalSanitizedEditorConfig.features.hooks.beforeValidate) { @@ -755,9 +766,10 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte global, operation, overrideAccess: overrideAccess!, - path, + parentIndexPath: indexPath.join('-'), + parentPath: path.join('.'), + parentSchemaPath: schemaPath.join('.'), req, - schemaPath, siblingData: nodeSiblingData, siblingDoc: nodeSiblingDoc, }) diff --git a/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts b/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts index 44bcdf0d1a..d7c212937e 100644 --- a/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts +++ b/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts @@ -59,10 +59,11 @@ export const recursivelyPopulateFieldsForGraphQL = ({ global: null, // Pass from core? This is only needed for hooks, so we can leave this null for now locale: req.locale!, overrideAccess, - path: [], + parentIndexPath: '', + parentPath: '', + parentSchemaPath: '', populationPromises, // This is not the same as populationPromises passed into this recurseNestedFields. These are just promises resolved at the very end. req, - schemaPath: [], showHiddenFields, siblingDoc, triggerHooks: false, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index b1642e52a9..d84ffe1f2a 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -365,7 +365,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom } const parentPath = path + '.' + i - const rowSchemaPath = schemaPath + '.' + block.slug if (block) { row.id = row?.id || new ObjectId().toHexString() @@ -435,7 +434,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom parentIndexPath: '', parentPassesCondition: passesCondition, parentPath, - parentSchemaPath: rowSchemaPath, + parentSchemaPath: schemaPath + '.' + block.slug, permissions: fieldPermissions === true ? fieldPermissions @@ -741,10 +740,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom includeSchema, omitParents, operation, - parentIndexPath: tabHasName(tab) ? '' : tabIndexPath, + parentIndexPath: isNamedTab ? '' : tabIndexPath, parentPassesCondition: passesCondition, - parentPath: tabHasName(tab) ? tabPath : parentPath, - parentSchemaPath: tabHasName(tab) ? tabSchemaPath : parentSchemaPath, + parentPath: isNamedTab ? tabPath : parentPath, + parentSchemaPath: isNamedTab ? tabSchemaPath : parentSchemaPath, permissions: childPermissions, preferences, previousFormState, diff --git a/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts b/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts index 42dfac268d..a238e1113e 100644 --- a/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts +++ b/packages/ui/src/utilities/buildClientFieldSchemaMap/traverseFields.ts @@ -131,8 +131,11 @@ export const traverseFields = ({ } break } + case 'tabs': field.tabs.map((tab, tabIndex) => { + const isNamedTab = tabHasName(tab) + const { indexPath: tabIndexPath, schemaPath: tabSchemaPath } = getFieldPaths({ field: { ...tab, @@ -151,8 +154,8 @@ export const traverseFields = ({ config, fields: tab.fields, i18n, - parentIndexPath: tabHasName(tab) ? '' : tabIndexPath, - parentSchemaPath: tabHasName(tab) ? tabSchemaPath : parentSchemaPath, + parentIndexPath: isNamedTab ? '' : tabIndexPath, + parentSchemaPath: isNamedTab ? tabSchemaPath : parentSchemaPath, payload, schemaMap, }) diff --git a/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts b/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts index 37df3098ee..cc620332d5 100644 --- a/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts +++ b/packages/ui/src/utilities/buildFieldSchemaMap/traverseFields.ts @@ -98,6 +98,8 @@ export const traverseFields = ({ case 'tabs': field.tabs.map((tab, tabIndex) => { + const isNamedTab = tabHasName(tab) + const { indexPath: tabIndexPath, schemaPath: tabSchemaPath } = getFieldPaths({ field: { ...tab, @@ -115,8 +117,8 @@ export const traverseFields = ({ config, fields: tab.fields, i18n, - parentIndexPath: tabHasName(tab) ? '' : tabIndexPath, - parentSchemaPath: tabHasName(tab) ? tabSchemaPath : parentSchemaPath, + parentIndexPath: isNamedTab ? '' : tabIndexPath, + parentSchemaPath: isNamedTab ? tabSchemaPath : parentSchemaPath, schemaMap, }) }) diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 353f19cace..22f8b49173 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -70,7 +70,7 @@ export interface UserAuthOperations { export interface Post { id: string; title?: string | null; - richText?: { + content?: { root: { type: string; children: { @@ -217,7 +217,7 @@ export interface PayloadMigration { */ export interface PostsSelect { title?: T; - richText?: T; + content?: T; updatedAt?: T; createdAt?: T; _status?: T; @@ -340,23 +340,6 @@ export interface MenuSelect { createdAt?: T; globalType?: T; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "ContactBlock". - */ -export interface ContactBlock { - /** - * ... - */ - first: string; - /** - * ... - */ - two: string; - id?: string | null; - blockName?: string | null; - blockType: 'contact'; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "auth". diff --git a/test/database/config.ts b/test/database/config.ts index 83389c7014..eb5fd9df9e 100644 --- a/test/database/config.ts +++ b/test/database/config.ts @@ -8,7 +8,7 @@ import { v4 as uuid } from 'uuid' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { devUser } from '../credentials.js' -import { postsSlug } from './shared.js' +import { errorOnUnnamedFieldsSlug, postsSlug } from './shared.js' const defaultValueField: TextField = { name: 'defaultValue', @@ -156,6 +156,32 @@ export default buildConfigWithDefaults({ ], }, }, + { + slug: errorOnUnnamedFieldsSlug, + fields: [ + { + type: 'tabs', + tabs: [ + { + label: 'UnnamedTab', + fields: [ + { + name: 'groupWithinUnnamedTab', + type: 'group', + fields: [ + { + name: 'text', + type: 'text', + required: true, + }, + ], + }, + ], + }, + ], + }, + ], + }, { slug: 'default-values', fields: [ diff --git a/test/database/int.spec.ts b/test/database/int.spec.ts index c7daa70f0b..888624d1e2 100644 --- a/test/database/int.spec.ts +++ b/test/database/int.spec.ts @@ -26,7 +26,7 @@ import { devUser } from '../credentials.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { isMongoose } from '../helpers/isMongoose.js' import removeFiles from '../helpers/removeFiles.js' -import { postsSlug } from './shared.js' +import { errorOnUnnamedFieldsSlug, postsSlug } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -895,7 +895,7 @@ describe('database', () => { await expect(errorMessage).toBe('The following field is invalid: Title') }) - it('should return proper deeply nested field validation errors', async () => { + it('should return validation errors in response', async () => { try { await payload.create({ collection: postsSlug, @@ -921,6 +921,22 @@ describe('database', () => { ) } }) + + it('should return validation errors with proper field paths for unnamed fields', async () => { + try { + await payload.create({ + collection: errorOnUnnamedFieldsSlug, + data: { + groupWithinUnnamedTab: { + // @ts-expect-error + text: undefined, + }, + }, + }) + } catch (e: any) { + expect(e.data?.errors?.[0]?.path).toBe('groupWithinUnnamedTab.text') + } + }) }) describe('defaultValue', () => { diff --git a/test/database/payload-types.ts b/test/database/payload-types.ts index b9dc6d96a0..62a306a5e3 100644 --- a/test/database/payload-types.ts +++ b/test/database/payload-types.ts @@ -12,6 +12,7 @@ export interface Config { }; collections: { posts: Post; + 'error-on-unnamed-fields': ErrorOnUnnamedField; 'default-values': DefaultValue; 'relation-a': RelationA; 'relation-b': RelationB; @@ -30,6 +31,7 @@ export interface Config { collectionsJoins: {}; collectionsSelect: { posts: PostsSelect | PostsSelect; + 'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect | ErrorOnUnnamedFieldsSelect; 'default-values': DefaultValuesSelect | DefaultValuesSelect; 'relation-a': RelationASelect | RelationASelect; 'relation-b': RelationBSelect | RelationBSelect; @@ -114,6 +116,18 @@ export interface Post { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "error-on-unnamed-fields". + */ +export interface ErrorOnUnnamedField { + id: string; + groupWithinUnnamedTab: { + text: string; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "default-values". @@ -355,6 +369,10 @@ export interface PayloadLockedDocument { relationTo: 'posts'; value: string | Post; } | null) + | ({ + relationTo: 'error-on-unnamed-fields'; + value: string | ErrorOnUnnamedField; + } | null) | ({ relationTo: 'default-values'; value: string | DefaultValue; @@ -482,6 +500,19 @@ export interface PostsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "error-on-unnamed-fields_select". + */ +export interface ErrorOnUnnamedFieldsSelect { + groupWithinUnnamedTab?: + | T + | { + text?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "default-values_select". diff --git a/test/database/shared.ts b/test/database/shared.ts index f4dad31482..0c2fe99672 100644 --- a/test/database/shared.ts +++ b/test/database/shared.ts @@ -1 +1,2 @@ export const postsSlug = 'posts' +export const errorOnUnnamedFieldsSlug = 'error-on-unnamed-fields' diff --git a/test/fields/collections/Tabs/index.ts b/test/fields/collections/Tabs/index.ts index 8a7121c8bb..f0647dad7a 100644 --- a/test/fields/collections/Tabs/index.ts +++ b/test/fields/collections/Tabs/index.ts @@ -47,6 +47,8 @@ const TabsFields: CollectionConfig = { type: 'array', required: true, fields: [ + // path: 'array.n.text' + // schemaPath: '_index-1-0.array.text' { name: 'text', type: 'text', diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index ef90dd0afe..3d8b6a1a9a 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -30,6 +30,7 @@ import { clearAndSeedEverything } from './seed.js' import { arrayFieldsSlug, blockFieldsSlug, + collapsibleFieldsSlug, groupFieldsSlug, relationshipFieldsSlug, tabsFieldsSlug, @@ -487,7 +488,7 @@ describe('Fields', () => { }) describe('rows', () => { - it('show proper validation error message on text field within row field', async () => { + it('should show proper validation error message on text field within row field', async () => { await expect(async () => payload.create({ collection: 'row-fields', @@ -1677,7 +1678,7 @@ describe('Fields', () => { expect(res.id).toBe(doc.id) }) - it('show proper validation error on text field in nested array', async () => { + it('should show proper validation error on text field in nested array', async () => { await expect(async () => payload.create({ collection, @@ -1697,7 +1698,7 @@ describe('Fields', () => { ).rejects.toThrow('The following field is invalid: Items 1 > SubArray 1 > Second text field') }) - it('show proper validation error on text field in row field in nested array', async () => { + it('should show proper validation error on text field in row field in nested array', async () => { await expect(async () => payload.create({ collection, @@ -2506,10 +2507,10 @@ describe('Fields', () => { }) describe('collapsible', () => { - it('show proper validation error message for fields nested in collapsible', async () => { + it('should show proper validation error message for fields nested in collapsible', async () => { await expect(async () => payload.create({ - collection: 'collapsible-fields', + collection: collapsibleFieldsSlug, data: { text: 'required', group: { diff --git a/test/hooks/collections/BeforeValidate/index.ts b/test/hooks/collections/BeforeValidate/index.ts index a7967cff3e..17348d6c2c 100644 --- a/test/hooks/collections/BeforeValidate/index.ts +++ b/test/hooks/collections/BeforeValidate/index.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload' -import { beforeValidateSlug } from '../../collectionSlugs.js' +import { beforeValidateSlug } from '../../shared.js' export const BeforeValidateCollection: CollectionConfig = { slug: beforeValidateSlug, diff --git a/test/hooks/collections/Data/index.ts b/test/hooks/collections/Data/index.ts index 7aac1a9122..c7e526851d 100644 --- a/test/hooks/collections/Data/index.ts +++ b/test/hooks/collections/Data/index.ts @@ -18,7 +18,6 @@ export const DataHooks: CollectionConfig = { return args }, ], - beforeChange: [ ({ context, data, collection }) => { context['collection_beforeChange_collection'] = JSON.stringify(collection) @@ -69,7 +68,6 @@ export const DataHooks: CollectionConfig = { return value }, ], - afterRead: [ ({ collection, field, context }) => { return ( diff --git a/test/hooks/collections/FieldPaths/index.ts b/test/hooks/collections/FieldPaths/index.ts new file mode 100644 index 0000000000..e395b9335e --- /dev/null +++ b/test/hooks/collections/FieldPaths/index.ts @@ -0,0 +1,214 @@ +import type { CollectionConfig, Field, FieldHook, FieldHookArgs } from 'payload' + +import { fieldPathsSlug } from '../../shared.js' + +const attachPathsToDoc = ( + label: string, + { value, path, schemaPath, indexPath, data }: FieldHookArgs, +): any => { + if (!data) { + data = {} + } + + // attach values to data for `beforeRead` and `beforeChange` hooks + data[`${label}_FieldPaths`] = { + path, + schemaPath, + indexPath, + } + + return value +} + +const attachHooks = ( + fieldIdentifier: string, +): { + afterRead: FieldHook[] + beforeChange: FieldHook[] + beforeDuplicate: FieldHook[] + beforeValidate: FieldHook[] +} => ({ + beforeValidate: [(args) => attachPathsToDoc(`${fieldIdentifier}_beforeValidate`, args)], + beforeChange: [(args) => attachPathsToDoc(`${fieldIdentifier}_beforeChange`, args)], + afterRead: [(args) => attachPathsToDoc(`${fieldIdentifier}_afterRead`, args)], + beforeDuplicate: [(args) => attachPathsToDoc(`${fieldIdentifier}_beforeDuplicate`, args)], +}) + +const createFields = (fieldIdentifiers: string[]): Field[] => + fieldIdentifiers.reduce((acc, fieldIdentifier) => { + return [ + ...acc, + { + name: `${fieldIdentifier}_beforeValidate_FieldPaths`, + type: 'json', + }, + { + name: `${fieldIdentifier}_beforeChange_FieldPaths`, + type: 'json', + }, + { + name: `${fieldIdentifier}_afterRead_FieldPaths`, + type: 'json', + }, + { + name: `${fieldIdentifier}_beforeDuplicate_FieldPaths`, + type: 'json', + }, + ] + }, [] as Field[]) + +export const FieldPaths: CollectionConfig = { + slug: fieldPathsSlug, + fields: [ + { + // path: 'topLevelNamedField' + // schemaPath: 'topLevelNamedField' + // indexPath: '' + name: 'topLevelNamedField', + type: 'text', + hooks: attachHooks('topLevelNamedField'), + }, + { + // path: 'array' + // schemaPath: 'array' + // indexPath: '' + name: 'array', + type: 'array', + fields: [ + { + // path: 'array.[n].fieldWithinArray' + // schemaPath: 'array.fieldWithinArray' + // indexPath: '' + name: 'fieldWithinArray', + type: 'text', + hooks: attachHooks('fieldWithinArray'), + }, + { + // path: 'array.[n].nestedArray' + // schemaPath: 'array.nestedArray' + // indexPath: '' + name: 'nestedArray', + type: 'array', + fields: [ + { + // path: 'array.[n].nestedArray.[n].fieldWithinNestedArray' + // schemaPath: 'array.nestedArray.fieldWithinNestedArray' + // indexPath: '' + name: 'fieldWithinNestedArray', + type: 'text', + hooks: attachHooks('fieldWithinNestedArray'), + }, + ], + }, + { + // path: 'array.[n]._index-2' + // schemaPath: 'array._index-2' + // indexPath: '' + type: 'row', + fields: [ + { + // path: 'array.[n].fieldWithinRowWithinArray' + // schemaPath: 'array._index-2.fieldWithinRowWithinArray' + // indexPath: '' + name: 'fieldWithinRowWithinArray', + type: 'text', + hooks: attachHooks('fieldWithinRowWithinArray'), + }, + ], + }, + ], + }, + { + // path: '_index-2' + // schemaPath: '_index-2' + // indexPath: '2' + type: 'row', + fields: [ + { + // path: 'fieldWithinRow' + // schemaPath: '_index-2.fieldWithinRow' + // indexPath: '' + name: 'fieldWithinRow', + type: 'text', + hooks: attachHooks('fieldWithinRow'), + }, + ], + }, + { + // path: '_index-3' + // schemaPath: '_index-3' + // indexPath: '3' + type: 'tabs', + tabs: [ + { + // path: '_index-3-0' + // schemaPath: '_index-3-0' + // indexPath: '3-0' + label: 'Unnamed Tab', + fields: [ + { + // path: 'fieldWithinUnnamedTab' + // schemaPath: '_index-3-0.fieldWithinUnnamedTab' + // indexPath: '' + name: 'fieldWithinUnnamedTab', + type: 'text', + hooks: attachHooks('fieldWithinUnnamedTab'), + }, + { + // path: '_index-3-0-1' + // schemaPath: '_index-3-0-1' + // indexPath: '3-0-1' + type: 'tabs', + tabs: [ + { + // path: '_index-3-0-1-0' + // schemaPath: '_index-3-0-1-0' + // indexPath: '3-0-1-0' + label: 'Nested Unnamed Tab', + fields: [ + { + // path: 'fieldWithinNestedUnnamedTab' + // schemaPath: '_index-3-0-1-0.fieldWithinNestedUnnamedTab' + // indexPath: '' + name: 'fieldWithinNestedUnnamedTab', + type: 'text', + hooks: attachHooks('fieldWithinNestedUnnamedTab'), + }, + ], + }, + ], + }, + ], + }, + { + // path: 'namedTab' + // schemaPath: '_index-3.namedTab' + // indexPath: '' + label: 'Named Tab', + name: 'namedTab', + fields: [ + { + // path: 'namedTab.fieldWithinNamedTab' + // schemaPath: '_index-3.namedTab.fieldWithinNamedTab' + // indexPath: '' + name: 'fieldWithinNamedTab', + type: 'text', + hooks: attachHooks('fieldWithinNamedTab'), + }, + ], + }, + ], + }, + // create fields for the hooks to save data to + ...createFields([ + 'topLevelNamedField', + 'fieldWithinArray', + 'fieldWithinNestedArray', + 'fieldWithinRowWithinArray', + 'fieldWithinRow', + 'fieldWithinUnnamedTab', + 'fieldWithinNestedUnnamedTab', + 'fieldWithinNamedTab', + ]), + ], +} diff --git a/test/hooks/config.ts b/test/hooks/config.ts index d6699cd884..a3247abcae 100644 --- a/test/hooks/config.ts +++ b/test/hooks/config.ts @@ -13,12 +13,14 @@ import { BeforeValidateCollection } from './collections/BeforeValidate/index.js' import ChainingHooks from './collections/ChainingHooks/index.js' import ContextHooks from './collections/ContextHooks/index.js' import { DataHooks } from './collections/Data/index.js' +import { FieldPaths } from './collections/FieldPaths/index.js' import Hooks, { hooksSlug } from './collections/Hook/index.js' import NestedAfterReadHooks from './collections/NestedAfterReadHooks/index.js' import Relations from './collections/Relations/index.js' import TransformHooks from './collections/Transform/index.js' import Users, { seedHooksUsers } from './collections/Users/index.js' import { DataHooksGlobal } from './globals/Data/index.js' + export const HooksConfig: Promise = buildConfigWithDefaults({ admin: { importMap: { @@ -37,6 +39,7 @@ export const HooksConfig: Promise = buildConfigWithDefaults({ Relations, Users, DataHooks, + FieldPaths, ], globals: [DataHooksGlobal], endpoints: [ diff --git a/test/hooks/e2e.spec.ts b/test/hooks/e2e.spec.ts index ef9a68c56f..759c78eded 100644 --- a/test/hooks/e2e.spec.ts +++ b/test/hooks/e2e.spec.ts @@ -12,7 +12,7 @@ import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { TEST_TIMEOUT_LONG } from '../playwright.config.js' -import { beforeValidateSlug } from './collectionSlugs.js' +import { beforeValidateSlug } from './shared.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index a4ce5ba694..8d21609870 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -21,7 +21,7 @@ import { import { relationsSlug } from './collections/Relations/index.js' import { transformSlug } from './collections/Transform/index.js' import { hooksUsersSlug } from './collections/Users/index.js' -import { beforeValidateSlug } from './collectionSlugs.js' +import { beforeValidateSlug, fieldPathsSlug } from './shared.js' import { HooksConfig } from './config.js' import { dataHooksGlobalSlug } from './globals/Data/index.js' @@ -517,6 +517,101 @@ describe('Hooks', () => { expect(doc.field_globalAndField).toStrictEqual(globalAndFieldString + globalAndFieldString) }) + + it('should pass correct field paths through field hooks', async () => { + const formatExpectedFieldPaths = ( + fieldIdentifier: string, + { + path, + schemaPath, + }: { + path: string[] + schemaPath: string[] + }, + ) => ({ + [`${fieldIdentifier}_beforeValidate_FieldPaths`]: { + path, + schemaPath, + }, + [`${fieldIdentifier}_beforeChange_FieldPaths`]: { + path, + schemaPath, + }, + [`${fieldIdentifier}_afterRead_FieldPaths`]: { + path, + schemaPath, + }, + [`${fieldIdentifier}_beforeDuplicate_FieldPaths`]: { + path, + schemaPath, + }, + }) + + const originalDoc = await payload.create({ + collection: fieldPathsSlug, + data: { + topLevelNamedField: 'Test', + array: [ + { + fieldWithinArray: 'Test', + nestedArray: [ + { + fieldWithinNestedArray: 'Test', + fieldWithinNestedRow: 'Test', + }, + ], + }, + ], + fieldWithinRow: 'Test', + fieldWithinUnnamedTab: 'Test', + namedTab: { + fieldWithinNamedTab: 'Test', + }, + fieldWithinNestedUnnamedTab: 'Test', + }, + }) + + // duplicate the doc to ensure that the beforeDuplicate hook is run + const doc = await payload.duplicate({ + id: originalDoc.id, + collection: fieldPathsSlug, + }) + + expect(doc).toMatchObject({ + ...formatExpectedFieldPaths('topLevelNamedField', { + path: ['topLevelNamedField'], + schemaPath: ['topLevelNamedField'], + }), + ...formatExpectedFieldPaths('fieldWithinArray', { + path: ['array', '0', 'fieldWithinArray'], + schemaPath: ['array', 'fieldWithinArray'], + }), + ...formatExpectedFieldPaths('fieldWithinNestedArray', { + path: ['array', '0', 'nestedArray', '0', 'fieldWithinNestedArray'], + schemaPath: ['array', 'nestedArray', 'fieldWithinNestedArray'], + }), + ...formatExpectedFieldPaths('fieldWithinRowWithinArray', { + path: ['array', '0', 'fieldWithinRowWithinArray'], + schemaPath: ['array', '_index-2', 'fieldWithinRowWithinArray'], + }), + ...formatExpectedFieldPaths('fieldWithinRow', { + path: ['fieldWithinRow'], + schemaPath: ['_index-2', 'fieldWithinRow'], + }), + ...formatExpectedFieldPaths('fieldWithinUnnamedTab', { + path: ['fieldWithinUnnamedTab'], + schemaPath: ['_index-3-0', 'fieldWithinUnnamedTab'], + }), + ...formatExpectedFieldPaths('fieldWithinNestedUnnamedTab', { + path: ['fieldWithinNestedUnnamedTab'], + schemaPath: ['_index-3-0-1-0', 'fieldWithinNestedUnnamedTab'], + }), + ...formatExpectedFieldPaths('fieldWithinNamedTab', { + path: ['namedTab', 'fieldWithinNamedTab'], + schemaPath: ['_index-3', 'namedTab', 'fieldWithinNamedTab'], + }), + }) + }) }) describe('config level after error hook', () => { diff --git a/test/hooks/payload-types.ts b/test/hooks/payload-types.ts index 7427e2bb24..73c4857ba2 100644 --- a/test/hooks/payload-types.ts +++ b/test/hooks/payload-types.ts @@ -22,6 +22,7 @@ export interface Config { relations: Relation; 'hooks-users': HooksUser; 'data-hooks': DataHook; + 'field-paths': FieldPath; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -39,6 +40,7 @@ export interface Config { relations: RelationsSelect | RelationsSelect; 'hooks-users': HooksUsersSelect | HooksUsersSelect; 'data-hooks': DataHooksSelect | DataHooksSelect; + 'field-paths': FieldPathsSelect | FieldPathsSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -236,6 +238,286 @@ export interface DataHook { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "field-paths". + */ +export interface FieldPath { + id: string; + topLevelNamedField?: string | null; + array?: + | { + fieldWithinArray?: string | null; + nestedArray?: + | { + fieldWithinNestedArray?: string | null; + id?: string | null; + }[] + | null; + id?: string | null; + }[] + | null; + fieldWithinRow?: string | null; + fieldWithinUnnamedTab?: string | null; + fieldWithinNestedUnnamedTab?: string | null; + namedTab?: { + fieldWithinNamedTab?: string | null; + }; + topLevelNamedField_beforeValidate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + topLevelNamedField_beforeChange_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + topLevelNamedField_afterRead_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + topLevelNamedField_beforeDuplicate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinArray_beforeValidate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinArray_beforeChange_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinArray_afterRead_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinArray_beforeDuplicate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNestedArray_beforeValidate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNestedArray_beforeChange_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNestedArray_afterRead_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNestedArray_beforeDuplicate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinRow_beforeValidate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinRow_beforeChange_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinRow_afterRead_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinRow_beforeDuplicate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinUnnamedTab_beforeValidate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinUnnamedTab_beforeChange_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinUnnamedTab_afterRead_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinUnnamedTab_beforeDuplicate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNestedUnnamedTab_beforeValidate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNestedUnnamedTab_beforeChange_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNestedUnnamedTab_afterRead_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNestedUnnamedTab_beforeDuplicate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNamedTab_beforeValidate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNamedTab_beforeChange_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNamedTab_afterRead_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + fieldWithinNamedTab_beforeDuplicate_FieldPaths?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -286,6 +568,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'data-hooks'; value: string | DataHook; + } | null) + | ({ + relationTo: 'field-paths'; + value: string | FieldPath; } | null); globalSlug?: string | null; user: { @@ -470,6 +756,63 @@ export interface DataHooksSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "field-paths_select". + */ +export interface FieldPathsSelect { + topLevelNamedField?: T; + array?: + | T + | { + fieldWithinArray?: T; + nestedArray?: + | T + | { + fieldWithinNestedArray?: T; + id?: T; + }; + id?: T; + }; + fieldWithinRow?: T; + fieldWithinUnnamedTab?: T; + fieldWithinNestedUnnamedTab?: T; + namedTab?: + | T + | { + fieldWithinNamedTab?: T; + }; + topLevelNamedField_beforeValidate_FieldPaths?: T; + topLevelNamedField_beforeChange_FieldPaths?: T; + topLevelNamedField_afterRead_FieldPaths?: T; + topLevelNamedField_beforeDuplicate_FieldPaths?: T; + fieldWithinArray_beforeValidate_FieldPaths?: T; + fieldWithinArray_beforeChange_FieldPaths?: T; + fieldWithinArray_afterRead_FieldPaths?: T; + fieldWithinArray_beforeDuplicate_FieldPaths?: T; + fieldWithinNestedArray_beforeValidate_FieldPaths?: T; + fieldWithinNestedArray_beforeChange_FieldPaths?: T; + fieldWithinNestedArray_afterRead_FieldPaths?: T; + fieldWithinNestedArray_beforeDuplicate_FieldPaths?: T; + fieldWithinRow_beforeValidate_FieldPaths?: T; + fieldWithinRow_beforeChange_FieldPaths?: T; + fieldWithinRow_afterRead_FieldPaths?: T; + fieldWithinRow_beforeDuplicate_FieldPaths?: T; + fieldWithinUnnamedTab_beforeValidate_FieldPaths?: T; + fieldWithinUnnamedTab_beforeChange_FieldPaths?: T; + fieldWithinUnnamedTab_afterRead_FieldPaths?: T; + fieldWithinUnnamedTab_beforeDuplicate_FieldPaths?: T; + fieldWithinNestedUnnamedTab_beforeValidate_FieldPaths?: T; + fieldWithinNestedUnnamedTab_beforeChange_FieldPaths?: T; + fieldWithinNestedUnnamedTab_afterRead_FieldPaths?: T; + fieldWithinNestedUnnamedTab_beforeDuplicate_FieldPaths?: T; + fieldWithinNamedTab_beforeValidate_FieldPaths?: T; + fieldWithinNamedTab_beforeChange_FieldPaths?: T; + fieldWithinNamedTab_afterRead_FieldPaths?: T; + fieldWithinNamedTab_beforeDuplicate_FieldPaths?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". diff --git a/test/hooks/collectionSlugs.ts b/test/hooks/shared.ts similarity index 54% rename from test/hooks/collectionSlugs.ts rename to test/hooks/shared.ts index 6a4647bffa..141f1aca63 100644 --- a/test/hooks/collectionSlugs.ts +++ b/test/hooks/shared.ts @@ -1 +1,2 @@ export const beforeValidateSlug = 'before-validate' +export const fieldPathsSlug = 'field-paths' diff --git a/tsconfig.base.json b/tsconfig.base.json index c461af5dcb..06a27a30e1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/admin/config.ts"], + "@payload-config": ["./test/hooks/config.ts"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],