Files
payloadcms/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
Dallas Opelt 285fc8cf7e fix: presentational-field types incorrectly exposing hooks (#11514)
### What?

Presentational Fields such as
[Row](https://payloadcms.com/docs/fields/row) are described as only
effecting the admin panel. If they do not impact data, their types
should not include hooks in the fields config.

### Why?

Developers can currently assign hooks to these fields, expecting them to
work, when in reality they are not called.

### How?

Omit `hooks` from `FieldBase`

Fixes #11507

---------

Co-authored-by: German Jablonski <43938777+GermanJablo@users.noreply.github.com>
2025-09-10 18:05:59 +00:00

455 lines
13 KiB
TypeScript

import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, PayloadRequest } from '../../../types/index.js'
import type { Block, Field, FieldHookArgs, TabAsField } from '../../config/types.js'
import { fieldAffectsData, fieldShouldBeLocalized } from '../../config/types.js'
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
doc: T
field: Field | TabAsField
fieldIndex: number
id?: number | string
overrideAccess: boolean
parentIndexPath: string
parentIsLocalized: boolean
parentPath: string
parentSchemaPath: string
req: PayloadRequest
siblingDoc: JsonObject
siblingFields?: (Field | TabAsField)[]
}
export const promise = async <T>({
id,
blockData,
collection,
context,
doc,
field,
fieldIndex,
overrideAccess,
parentIndexPath,
parentIsLocalized,
parentPath,
parentSchemaPath,
req,
siblingDoc,
siblingFields,
}: Args<T>): Promise<void> => {
const { indexPath, path, schemaPath } = getFieldPaths({
field,
index: fieldIndex,
parentIndexPath,
parentPath,
parentSchemaPath,
})
const { localization } = req.payload.config
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!]
const fieldIsLocalized = localization && fieldShouldBeLocalized({ field, parentIsLocalized })
// Run field beforeDuplicate hooks.
// These hooks are responsible for resetting the `id` field values of array and block rows. See `baseIDField`.
if (Array.isArray('hooks' in field && field.hooks?.beforeDuplicate)) {
if (fieldIsLocalized) {
const localeData: JsonObject = {}
for (const locale of localization.localeCodes) {
const beforeDuplicateArgs: FieldHookArgs = {
blockData,
collection,
context,
data: doc as Partial<T>,
field,
global: undefined!,
indexPath: indexPathSegments,
path: pathSegments,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name!]?.[locale],
req,
schemaPath: schemaPathSegments,
siblingData: siblingDoc,
siblingDocWithLocales: siblingDoc,
siblingFields: siblingFields!,
value: siblingDoc[field.name!]?.[locale],
}
let hookResult
if ('hooks' in field && field.hooks?.beforeDuplicate) {
for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs)
}
}
if (typeof hookResult !== 'undefined') {
localeData[locale] = hookResult
}
}
siblingDoc[field.name!] = localeData
} else {
const beforeDuplicateArgs: FieldHookArgs = {
blockData,
collection,
context,
data: doc as Partial<T>,
field,
global: undefined!,
indexPath: indexPathSegments,
path: pathSegments,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name!]!,
req,
schemaPath: schemaPathSegments,
siblingData: siblingDoc,
siblingDocWithLocales: siblingDoc,
siblingFields: siblingFields!,
value: siblingDoc[field.name!]!,
}
let hookResult
if ('hooks' in field && field.hooks?.beforeDuplicate) {
for (const hook of field.hooks.beforeDuplicate) {
hookResult = await hook(beforeDuplicateArgs)
}
}
if (typeof hookResult !== 'undefined') {
siblingDoc[field.name!] = hookResult
}
}
}
// First, for any localized fields, we will loop over locales
// and if locale data is present, traverse the sub fields.
// There are only a few different fields where this is possible.
if (fieldIsLocalized) {
if (typeof fieldData !== 'object' || fieldData === null) {
siblingDoc[field.name!] = {}
fieldData = siblingDoc[field.name!]
}
const promises: Promise<void>[] = []
localization.localeCodes.forEach((locale) => {
if (fieldData[locale]) {
switch (field.type) {
case 'array': {
const rows = fieldData[locale]
if (Array.isArray(rows)) {
const promises: Promise<void>[] = []
rows.forEach((row, rowIndex) => {
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized!,
parentPath: path + '.' + rowIndex,
parentSchemaPath: schemaPath,
req,
siblingDoc: row,
}),
)
})
}
break
}
case 'blocks': {
const rows = fieldData[locale]
if (Array.isArray(rows)) {
const promises: Promise<void>[] = []
rows.forEach((row, rowIndex) => {
const blockTypeToMatch = row.blockType
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) =>
typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
doc,
fields: block!.fields,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized!,
parentPath: path + '.' + rowIndex,
parentSchemaPath: schemaPath + '.' + block!.slug,
req,
siblingDoc: row,
}),
)
})
}
break
}
case 'group':
case 'tab': {
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized!,
parentPath: path,
parentSchemaPath: schemaPath,
req,
siblingDoc: fieldData[locale],
}),
)
break
}
}
}
})
await Promise.all(promises)
} else {
// If the field is not localized, but it affects data,
// we need to further traverse its children
// so the child fields can run beforeDuplicate hooks
switch (field.type) {
case 'array': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises: Promise<void>[] = []
rows.forEach((row, rowIndex) => {
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized!,
parentPath: path + '.' + rowIndex,
parentSchemaPath: schemaPath,
req,
siblingDoc: row,
}),
)
})
await Promise.all(promises)
}
break
}
case 'blocks': {
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
const promises: Promise<void>[] = []
rows.forEach((row, rowIndex) => {
const blockTypeToMatch = row.blockType
const block: Block | undefined =
req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find(
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)
if (block) {
;(row as JsonObject).blockType = blockTypeToMatch
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
doc,
fields: block.fields,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized!,
parentPath: path + '.' + rowIndex,
parentSchemaPath: schemaPath + '.' + block.slug,
req,
siblingDoc: row,
}),
)
}
})
await Promise.all(promises)
}
break
}
case 'group': {
if (typeof siblingDoc[field.name] !== 'object') {
siblingDoc[field.name] = {}
}
const groupDoc = siblingDoc[field.name] as JsonObject
await traverseFields({
id,
blockData,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized!,
parentPath: path,
parentSchemaPath: schemaPath,
req,
siblingDoc: groupDoc,
})
break
}
case 'tab': {
if (typeof siblingDoc[field.name!] !== 'object') {
siblingDoc[field.name!] = {}
}
const tabDoc = siblingDoc[field.name!] as JsonObject
await traverseFields({
id,
blockData,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
parentIndexPath: '',
parentIsLocalized: parentIsLocalized || field.localized!,
parentPath: path,
parentSchemaPath: schemaPath,
req,
siblingDoc: tabDoc,
})
break
}
}
}
} else {
// Finally, we traverse fields which do not affect data here - collapsibles, rows, unnamed groups
switch (field.type) {
case 'collapsible':
case 'group':
case 'row': {
await traverseFields({
id,
blockData,
collection,
context,
doc,
fields: field.fields,
overrideAccess,
parentIndexPath: indexPath,
parentIsLocalized,
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,
blockData,
collection,
context,
doc,
// @ts-expect-error `fieldAffectsData` inferred return type doesn't account for TabAsField
fields: field.fields,
overrideAccess,
parentIndexPath: indexPath,
parentIsLocalized,
parentPath,
parentSchemaPath: schemaPath,
req,
siblingDoc,
})
break
}
case 'tabs': {
await traverseFields({
id,
blockData,
collection,
context,
doc,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
overrideAccess,
parentIndexPath: indexPath,
parentIsLocalized,
parentPath: path,
parentSchemaPath: schemaPath,
req,
siblingDoc,
})
break
}
default: {
break
}
}
}
}