fix: field paths within hooks (#10638)

Field paths within hooks are not correct.

For example, an unnamed tab containing a group field and nested text
field should have the path:
- `myGroupField.myTextField`

However, within hooks that path is formatted as:
- `_index-1.myGroupField.myTextField`

The leading index shown above should not exist, as this field is
considered top-level since it is located within an unnamed tab.

This discrepancy is only evident through the APIs themselves, such as
when creating a request with invalid data and reading the validation
errors in the response. Form state contains proper field paths, which is
ultimately why this issue was never caught. This is because within the
admin panel we merge the API response with the current form state,
obscuring the underlying issue. This becomes especially obvious in
#10580, where we no longer initialize validation errors within form
state until the form has been submitted, and instead rely solely on the
API response for the initial error state.

Here's comprehensive example of how field paths _should_ be formatted:

```
{
  // ...
  fields: [
    {
      // path: 'topLevelNamedField'
      // schemaPath: 'topLevelNamedField'
      // indexPath: ''
      name: 'topLevelNamedField',
      type: 'text',
    },
    {
      // path: 'array'
      // schemaPath: 'array'
      // indexPath: ''
      name: 'array',
      type: 'array',
      fields: [
        {
          // path: 'array.[n].fieldWithinArray'
          // schemaPath: 'array.fieldWithinArray'
          // indexPath: ''
          name: 'fieldWithinArray',
          type: 'text',
        },
        {
          // 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',
            },
          ],
        },
        {
          // path: 'array.[n]._index-2'
          // schemaPath: 'array._index-2'
          // indexPath: '2'
          type: 'row',
          fields: [
            {
              // path: 'array.[n].fieldWithinRowWithinArray'
              // schemaPath: 'array._index-2.fieldWithinRowWithinArray'
              // indexPath: ''
              name: 'fieldWithinRowWithinArray',
              type: 'text',
            },
          ],
        },
      ],
    },
    {
      // path: '_index-2'
      // schemaPath: '_index-2'
      // indexPath: '2'
      type: 'row',
      fields: [
        {
          // path: 'fieldWithinRow'
          // schemaPath: '_index-2.fieldWithinRow'
          // indexPath: ''
          name: 'fieldWithinRow',
          type: 'text',
        },
      ],
    },
    {
      // 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',
            },
            {
              // 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',
                    },
                  ],
                },
              ],
            },
          ],
        },
        {
          // 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',
            },
          ],
        },
      ],
    },
  ]
}
```
This commit is contained in:
Jacob Fletcher
2025-01-27 14:41:35 -05:00
committed by GitHub
parent 9f9919d2c6
commit 0acaf8a7f7
43 changed files with 1224 additions and 318 deletions

View File

@@ -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
/**

View File

@@ -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

View File

@@ -166,6 +166,7 @@ export type FieldHookArgs<TData extends TypeWithID = any, TValue = any, TSibling
findMany?: boolean
/** The global which the field belongs to. If the field belongs to a collection, this will be null. */
global: null | SanitizedGlobalConfig
indexPath: number[]
/** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */
operation?: 'create' | 'delete' | 'read' | 'update'
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */

View File

@@ -1,14 +1,14 @@
import type { ClientField, Field, TabAsField, TabAsFieldClient } from './config/types.js'
import type { ClientField, Field, Tab, TabAsFieldClient } from './config/types.js'
type Args = {
field: ClientField | Field | TabAsField | TabAsFieldClient
field: ClientField | Field | Tab | TabAsFieldClient
index: number
parentIndexPath: string
parentPath: string
parentSchemaPath: string
}
type Result = {
type FieldPaths = {
/**
* A string of '-' separated indexes representing where
* to find this field in a given field schema array.
@@ -16,11 +16,11 @@ type Result = {
*/
indexPath: string
/**
* Path for this field specifically.
* Path for this field relative to its position in the data.
*/
path: string
/**
* Schema path for this field specifically.
* Path for this field relative to its position in the schema.
*/
schemaPath: string
}
@@ -31,7 +31,7 @@ export function getFieldPaths({
parentIndexPath,
parentPath,
parentSchemaPath,
}: Args): Result {
}: Args): FieldPaths {
if ('name' in field) {
return {
indexPath: `${parentIndexPath ? parentIndexPath + '-' : ''}${index}`,
@@ -48,3 +48,37 @@ export function getFieldPaths({
schemaPath: `${parentSchemaPath ? parentSchemaPath + '.' : ''}${indexSuffix}`,
}
}
export function getFieldPathsModified({
field,
index,
parentIndexPath,
parentPath,
parentSchemaPath,
}: Args): FieldPaths {
const parentPathSegments = parentPath.split('.')
const parentIsUnnamed = parentPathSegments[parentPathSegments.length - 1].startsWith('_index-')
const parentWithoutIndex = parentIsUnnamed
? parentPathSegments.slice(0, -1).join('.')
: parentPath
const parentPathToUse = parentIsUnnamed ? parentWithoutIndex : parentPath
if ('name' in field) {
return {
indexPath: '',
path: `${parentPathToUse ? parentPathToUse + '.' : ''}${field.name}`,
schemaPath: `${parentSchemaPath ? parentSchemaPath + '.' : ''}${field.name}`,
}
}
const indexSuffix = `_index-${`${parentIndexPath ? parentIndexPath + '-' : ''}${index}`}`
return {
indexPath: `${parentIndexPath ? parentIndexPath + '-' : ''}${index}`,
path: `${parentPathToUse ? parentPathToUse + '.' : ''}${indexSuffix}`,
schemaPath: `${!parentIsUnnamed && parentSchemaPath ? parentSchemaPath + '.' : ''}${indexSuffix}`,
}
}

View File

@@ -44,11 +44,12 @@ export const afterChange = async <T extends JsonObject>({
fields: collection?.fields || global?.fields,
global,
operation,
path: [],
parentIndexPath: '',
parentPath: '',
parentSchemaPath: '',
previousDoc,
previousSiblingDoc: previousDoc,
req,
schemaPath: [],
siblingData: data,
siblingDoc: incomingDoc,
})

View File

@@ -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<void> => {
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
}

View File

@@ -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<void> => {
@@ -52,8 +54,9 @@ export const traverseFields = async ({
fieldIndex,
global,
operation,
parentPath: path,
parentSchemaPath: schemaPath,
parentIndexPath,
parentPath,
parentSchemaPath,
previousDoc,
previousSiblingDoc,
req,

View File

@@ -83,11 +83,12 @@ export async function afterRead<T extends JsonObject>(args: Args<T>): Promise<T>
global,
locale,
overrideAccess,
path: [],
parentIndexPath: '',
parentPath: '',
parentSchemaPath: '',
populate,
populationPromises,
req,
schemaPath: [],
select,
selectMode: select ? getSelectMode(select) : undefined,
showHiddenFields,

View File

@@ -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<void>[]
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<void> => {
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
}

View File

@@ -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<void>[]
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,

View File

@@ -28,6 +28,7 @@ export type Args<T extends JsonObject> = {
* - 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 <T extends JsonObject>({
id,
collection,
@@ -56,9 +57,10 @@ export const beforeChange = async <T extends JsonObject>({
global,
mergeLocaleActions,
operation,
path: [],
parentIndexPath: '',
parentPath: '',
parentSchemaPath: '',
req,
schemaPath: [],
siblingData: data,
siblingDoc: doc,
siblingDocWithLocales: docWithLocales,

View File

@@ -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<void>)[]
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<void> => {
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,

View File

@@ -25,9 +25,10 @@ type Args = {
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
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,

View File

@@ -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<T extends JsonObject> = {
@@ -35,9 +34,10 @@ export const beforeDuplicate = async <T extends JsonObject>({
doc,
fields: collection?.fields,
overrideAccess,
path: [],
parentIndexPath: '',
parentPath: '',
parentSchemaPath: '',
req,
schemaPath: [],
siblingDoc: doc,
})

View File

@@ -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<T> = {
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 <T>({
field,
fieldIndex,
overrideAccess,
parentIndexPath,
parentPath,
parentSchemaPath,
req,
siblingDoc,
}: Args<T>): Promise<void> => {
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('.'),
})
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,
parentIndexPath,
parentPath,
parentSchemaPath,
})
return
}
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]
@@ -82,11 +68,12 @@ export const promise = async <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
}
case 'group':
case 'tab': {
promises.push(
traverseFields({
@@ -211,9 +204,10 @@ export const promise = async <T>({
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 <T>({
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 <T>({
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 <T>({
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<string, unknown>
const groupDoc = siblingDoc[field.name] as JsonObject
await traverseFields({
id,
@@ -305,10 +305,35 @@ export const promise = async <T>({
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 <T>({
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 <T>({
doc,
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
overrideAccess,
path: fieldPath,
parentIndexPath: indexPath,
parentPath: path,
parentSchemaPath: schemaPath,
req,
schemaPath: fieldSchemaPath,
siblingDoc,
})

View File

@@ -12,9 +12,10 @@ type Args<T> = {
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 <T>({
doc,
fields,
overrideAccess,
path,
parentIndexPath,
parentPath,
parentSchemaPath,
req,
schemaPath,
siblingDoc,
}: Args<T>): Promise<void> => {
const promises = []
fields.forEach((field, fieldIndex) => {
promises.push(
promise({
@@ -41,8 +44,9 @@ export const traverseFields = async <T>({
field,
fieldIndex,
overrideAccess,
parentPath: path,
parentSchemaPath: schemaPath,
parentIndexPath,
parentPath,
parentSchemaPath,
req,
siblingDoc,
}),

View File

@@ -47,9 +47,10 @@ export const beforeValidate = async <T extends JsonObject>({
global,
operation,
overrideAccess,
path: [],
parentIndexPath: '',
parentPath: '',
parentSchemaPath: '',
req,
schemaPath: [],
siblingData: incomingData,
siblingDoc: doc,
})

View File

@@ -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<T> = {
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 <T>({
global,
operation,
overrideAccess,
parentIndexPath,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
}: Args<T>): Promise<void> => {
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
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 <T>({
global,
operation,
overrideAccess,
path: fieldPath,
parentIndexPath: indexPath,
parentPath: path,
parentSchemaPath: schemaPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})

View File

@@ -19,9 +19,10 @@ type Args<T> = {
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 <T>({
global,
operation,
overrideAccess,
path,
parentIndexPath,
parentPath,
parentSchemaPath,
req,
schemaPath,
siblingData,
siblingDoc,
}: Args<T>): Promise<void> => {
const promises = []
fields.forEach((field, fieldIndex) => {
promises.push(
promise({
@@ -59,13 +62,15 @@ export const traverseFields = async <T>({
global,
operation,
overrideAccess,
parentPath: path,
parentSchemaPath: schemaPath,
parentIndexPath,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
}),
)
})
await Promise.all(promises)
}

View File

@@ -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'

View File

@@ -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<string[]>((acc, part) => {

View File

@@ -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,
})

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
})

View File

@@ -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,
})
})

View File

@@ -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<T extends boolean = true> {
title?: T;
richText?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -340,23 +340,6 @@ export interface MenuSelect<T extends boolean = true> {
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".

View File

@@ -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: [

View File

@@ -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', () => {

View File

@@ -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<false> | PostsSelect<true>;
'error-on-unnamed-fields': ErrorOnUnnamedFieldsSelect<false> | ErrorOnUnnamedFieldsSelect<true>;
'default-values': DefaultValuesSelect<false> | DefaultValuesSelect<true>;
'relation-a': RelationASelect<false> | RelationASelect<true>;
'relation-b': RelationBSelect<false> | RelationBSelect<true>;
@@ -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<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "error-on-unnamed-fields_select".
*/
export interface ErrorOnUnnamedFieldsSelect<T extends boolean = true> {
groupWithinUnnamedTab?:
| T
| {
text?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "default-values_select".

View File

@@ -1 +1,2 @@
export const postsSlug = 'posts'
export const errorOnUnnamedFieldsSlug = 'error-on-unnamed-fields'

View File

@@ -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',

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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',
]),
],
}

View File

@@ -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<SanitizedConfig> = buildConfigWithDefaults({
admin: {
importMap: {
@@ -37,6 +39,7 @@ export const HooksConfig: Promise<SanitizedConfig> = buildConfigWithDefaults({
Relations,
Users,
DataHooks,
FieldPaths,
],
globals: [DataHooksGlobal],
endpoints: [

View File

@@ -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)

View File

@@ -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', () => {

View File

@@ -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<false> | RelationsSelect<true>;
'hooks-users': HooksUsersSelect<false> | HooksUsersSelect<true>;
'data-hooks': DataHooksSelect<false> | DataHooksSelect<true>;
'field-paths': FieldPathsSelect<false> | FieldPathsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -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<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "field-paths_select".
*/
export interface FieldPathsSelect<T extends boolean = true> {
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".

View File

@@ -1 +1,2 @@
export const beforeValidateSlug = 'before-validate'
export const fieldPathsSlug = 'field-paths'

View File

@@ -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"],