feat(richtext-lexical)!: sub-field hooks and localization support (#6591)

## BREAKING
- Our internal field hook methods now have new required `schemaPath` and
path `props`. This affects the following functions, if you are using
those: `afterChangeTraverseFields`, `afterReadTraverseFields`,
`beforeChangeTraverseFields`, `beforeValidateTraverseFields`,
`afterReadPromise`
- The afterChange field hook's `value` is now the value AFTER the
previous hooks were run. Previously, this was the original value, which
I believe is a bug
- Only relevant if you have built your own richText adapter: the
richText adapter `populationPromises` property has been renamed to
`graphQLPopulationPromises` and is now only run for graphQL. Previously,
it was run for graphQL AND the rest API. To migrate, use
`hooks.afterRead` to run population for the rest API
- Only relevant if you have built your own lexical features: The
`populationPromises` server feature property has been renamed to
`graphQLPopulationPromises` and is now only run for graphQL. Previously,
it was run for graphQL AND the rest API. To migrate, use
`hooks.afterRead` to run population for the rest API
- Serialized lexical link and upload nodes now have a new `id` property.
While not breaking, localization / hooks will not work for their fields
until you have migrated to that. Re-saving the old document on the new
version will automatically add the `id` property for you. You will also
get a bunch of console logs for every lexical node which is not migrated
This commit is contained in:
Alessio Gravili
2024-06-12 13:33:08 -04:00
committed by GitHub
parent 27510bb963
commit 4e127054ca
62 changed files with 1959 additions and 514 deletions

View File

@@ -1 +1,2 @@
export { generateSchema } from '../bin/generateSchema.js'
export { buildObjectType } from '../schema/buildObjectType.js'

View File

@@ -81,7 +81,7 @@ type Args = {
parentName: string
}
function buildObjectType({
export function buildObjectType({
name,
baseFields = {},
config,
@@ -492,13 +492,13 @@ function buildObjectType({
// is run here again, with the provided depth.
// In the graphql find.ts resolver, the depth is then hard-coded to 0.
// Effectively, this means that the populationPromise for GraphQL is only run here, and not in the find.ts resolver / normal population promise.
if (editor?.populationPromises) {
if (editor?.graphQLPopulationPromises) {
const fieldPromises = []
const populationPromises = []
const populateDepth =
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
editor?.populationPromises({
editor?.graphQLPopulationPromises({
context,
depth: populateDepth,
draft: args.draft,
@@ -698,5 +698,3 @@ function buildObjectType({
return newlyCreatedBlockType
}
export default buildObjectType

View File

@@ -37,7 +37,7 @@ import restoreVersionResolver from '../resolvers/collections/restoreVersion.js'
import { updateResolver } from '../resolvers/collections/update.js'
import formatName from '../utilities/formatName.js'
import { buildMutationInputType, getCollectionIDType } from './buildMutationInputType.js'
import buildObjectType from './buildObjectType.js'
import { buildObjectType } from './buildObjectType.js'
import buildPaginatedListType from './buildPaginatedListType.js'
import { buildPolicyType } from './buildPoliciesType.js'
import buildWhereInputType from './buildWhereInputType.js'

View File

@@ -17,7 +17,7 @@ import restoreVersionResolver from '../resolvers/globals/restoreVersion.js'
import updateResolver from '../resolvers/globals/update.js'
import formatName from '../utilities/formatName.js'
import { buildMutationInputType } from './buildMutationInputType.js'
import buildObjectType from './buildObjectType.js'
import { buildObjectType } from './buildObjectType.js'
import buildPaginatedListType from './buildPaginatedListType.js'
import { buildPolicyType } from './buildPoliciesType.js'
import buildWhereInputType from './buildWhereInputType.js'

View File

@@ -2,8 +2,10 @@ import type { GenericLanguages, I18n, I18nClient } from '@payloadcms/translation
import type { JSONSchema4 } from 'json-schema'
import type React from 'react'
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { SanitizedConfig } from '../config/types.js'
import type { Field, FieldBase, RichTextField, Validate } from '../fields/config/types.js'
import type { Field, FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../types/index.js'
import type { WithServerSidePropsComponentProps } from './elements/WithServerSideProps.js'
@@ -15,6 +17,173 @@ export type RichTextFieldProps<
path?: string
}
export type AfterReadRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
currentDepth?: number
depth?: number
draft?: boolean
fallbackLocale?: string
fieldPromises?: Promise<void>[]
/** Boolean to denote if this hook is running against finding one, or finding many within the afterRead hook. */
findMany?: boolean
flattenLocales?: boolean
locale?: string
/** A string relating to which operation the field type is currently executing within. */
operation?: 'create' | 'delete' | 'read' | 'update'
overrideAccess?: boolean
populationPromises?: Promise<void>[]
showHiddenFields?: boolean
triggerAccessControl?: boolean
triggerHooks?: boolean
}
export type AfterChangeRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
/** A string relating to which operation the field type is currently executing within. */
operation: 'create' | 'update'
/** The document before changes were applied. */
previousDoc?: TData
/** The sibling data of the document before changes being applied. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes */
previousValue?: TValue
}
export type BeforeValidateRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
/** A string relating to which operation the field type is currently executing within. */
operation: 'create' | 'update'
overrideAccess?: boolean
/** The sibling data of the document before changes being applied. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes */
previousValue?: TValue
}
export type BeforeChangeRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
/**
* The original data with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks.
*/
docWithLocales?: Record<string, unknown>
duplicate?: boolean
errors?: { field: string; message: string }[]
/** Only available in `beforeChange` field hooks */
mergeLocaleActions?: (() => Promise<void>)[]
/** A string relating to which operation the field type is currently executing within. */
operation?: 'create' | 'delete' | 'read' | 'update'
/** The sibling data of the document before changes being applied. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes */
previousValue?: TValue
/**
* The original siblingData with locales (not modified by any hooks).
*/
siblingDocWithLocales?: Record<string, unknown>
skipValidation?: boolean
}
export type BaseRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
/** The collection which the field belongs to. If the field belongs to a global, this will be null. */
collection: SanitizedCollectionConfig | null
context: RequestContext
/** The data passed to update the document within create and update operations, and the full document itself in the afterRead hook. */
data?: Partial<TData>
/** The field which the hook is running against. */
field: FieldAffectingData
/** The global which the field belongs to. If the field belongs to a collection, this will be null. */
global: SanitizedGlobalConfig | null
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
originalDoc?: TData
/**
* The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas.
*/
path: (number | string)[]
/** The Express request object. It is mocked for Local API operations. */
req: PayloadRequestWithData
/**
* The schemaPath of the field, e.g. ["group", "myArray", "textField"]. The schemaPath is the path but without indexes and would be used in the context of field schemas, not field data.
*/
schemaPath: string[]
/** The sibling data passed to a field that the hook is running against. */
siblingData: Partial<TSiblingData>
/** The value of the field. */
value?: TValue
}
export type AfterReadRichTextHook<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = (
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
AfterReadRichTextHookArgs<TData, TValue, TSiblingData>,
) => Promise<TValue> | TValue
export type AfterChangeRichTextHook<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = (
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
AfterChangeRichTextHookArgs<TData, TValue, TSiblingData>,
) => Promise<TValue> | TValue
export type BeforeChangeRichTextHook<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = (
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
BeforeChangeRichTextHookArgs<TData, TValue, TSiblingData>,
) => Promise<TValue> | TValue
export type BeforeValidateRichTextHook<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = (
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
BeforeValidateRichTextHookArgs<TData, TValue, TSiblingData>,
) => Promise<TValue> | TValue
export type RichTextHooks = {
afterChange?: AfterChangeRichTextHook[]
afterRead?: AfterReadRichTextHook[]
beforeChange?: BeforeChangeRichTextHook[]
beforeValidate?: BeforeValidateRichTextHook[]
}
type RichTextAdapterBase<
Value extends object = object,
AdapterProps = any,
@@ -32,7 +201,28 @@ type RichTextAdapterBase<
schemaMap: Map<string, Field[]>
schemaPath: string
}) => Map<string, Field[]>
hooks?: FieldBase['hooks']
/**
* Like an afterRead hook, but runs only for the GraphQL resolver. For populating data, this should be used, as afterRead hooks do not have a depth in graphQL.
*
* To populate stuff / resolve field hooks, mutate the incoming populationPromises or fieldPromises array. They will then be awaited in the correct order within payload itself.
* @param data
*/
graphQLPopulationPromises?: (data: {
context: RequestContext
currentDepth?: number
depth: number
draft: boolean
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
fieldPromises: Promise<void>[]
findMany: boolean
flattenLocales: boolean
overrideAccess?: boolean
populationPromises: Promise<void>[]
req: PayloadRequestWithData
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
}) => void
hooks?: RichTextHooks
i18n?: Partial<GenericLanguages>
outputSchema?: ({
collectionIDFieldTypes,
@@ -50,27 +240,6 @@ type RichTextAdapterBase<
interfaceNameDefinitions: Map<string, JSONSchema4>
isRequired: boolean
}) => JSONSchema4
/**
* Like an afterRead hook, but runs for both afterRead AND in the GraphQL resolver. For populating data, this should be used.
*
* To populate stuff / resolve field hooks, mutate the incoming populationPromises or fieldPromises array. They will then be awaited in the correct order within payload itself.
* @param data
*/
populationPromises?: (data: {
context: RequestContext
currentDepth?: number
depth: number
draft: boolean
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
fieldPromises: Promise<void>[]
findMany: boolean
flattenLocales: boolean
overrideAccess?: boolean
populationPromises: Promise<void>[]
req: PayloadRequestWithData
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
}) => void
validate: Validate<
Value,
Value,

View File

@@ -172,25 +172,6 @@ export const sanitizeFields = async ({
if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) {
config.i18n.translations = deepMerge(config.i18n.translations, field.editor.i18n)
}
// Add editor adapter hooks to field hooks
if (!field.hooks) field.hooks = {}
const mergeHooks = (hookName: keyof typeof field.editor.hooks) => {
if (typeof field.editor === 'function') return
if (field.editor?.hooks?.[hookName]?.length) {
field.hooks[hookName] = field.hooks[hookName]
? field.hooks[hookName].concat(field.editor.hooks[hookName])
: [...field.editor.hooks[hookName]]
}
}
mergeHooks('afterRead')
mergeHooks('afterChange')
mergeHooks('beforeChange')
mergeHooks('beforeValidate')
mergeHooks('beforeDuplicate')
}
if (richTextSanitizationPromises) {
richTextSanitizationPromises.push(sanitizeRichText)

View File

@@ -499,8 +499,8 @@ export const richText = baseField.keys({
CellComponent: componentSchema.optional(),
FieldComponent: componentSchema.optional(),
afterReadPromise: joi.func().optional(),
graphQLPopulationPromises: joi.func().optional(),
outputSchema: joi.func().optional(),
populationPromise: joi.func().optional(),
validate: joi.func().required(),
})
.unknown(),

View File

@@ -41,16 +41,28 @@ export type FieldHookArgs<TData extends TypeWithID = any, TValue = any, TSibling
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
originalDoc?: TData
overrideAccess?: boolean
/**
* The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas.
*/
path: (number | string)[]
/** The document before changes were applied, only in `afterChange` hooks. */
previousDoc?: TData
/** The sibling data of the document before changes being applied, only in `beforeChange` and `afterChange` hook. */
/** The sibling data of the document before changes being applied, only in `beforeChange`, `beforeValidate`, `beforeDuplicate` and `afterChange` field hooks. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes, only in `beforeChange`, `afterChange` and `beforeValidate` hooks. */
/** The previous value of the field, before changes, only in `beforeChange`, `afterChange`, `beforeDuplicate` and `beforeValidate` field hooks. */
previousValue?: TValue
/** The Express request object. It is mocked for Local API operations. */
req: PayloadRequestWithData
/**
* The schemaPath of the field, e.g. ["group", "myArray", "textField"]. The schemaPath is the path but without indexes and would be used in the context of field schemas, not field data.
*/
schemaPath: string[]
/** The sibling data passed to a field that the hook is running against. */
siblingData: Partial<TSiblingData>
/**
* The original siblingData with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks.
*/
siblingDocWithLocales?: Record<string, unknown>
/** The value of the field. */
value?: TValue
}

View File

@@ -0,0 +1,39 @@
import type { Field, TabAsField } from './config/types.js'
import { tabHasName } from './config/types.js'
export function getFieldPaths({
field,
parentPath,
parentSchemaPath,
}: {
field: Field | TabAsField
parentPath: (number | string)[]
parentSchemaPath: string[]
}): {
path: (number | string)[]
schemaPath: string[]
} {
if (field.type === 'tabs' || field.type === 'row' || field.type === 'collapsible') {
return {
path: parentPath,
schemaPath: parentSchemaPath,
}
} else if (field.type === 'tab') {
if (tabHasName(field)) {
return {
path: [...parentPath, field.name],
schemaPath: [...parentSchemaPath, field.name],
}
} else {
return {
path: parentPath,
schemaPath: parentSchemaPath,
}
}
}
const path = parentPath?.length ? [...parentPath, field.name] : [field.name]
const schemaPath = parentSchemaPath?.length ? [...parentSchemaPath, field.name] : [field.name]
return { path, schemaPath }
}

View File

@@ -8,7 +8,13 @@ import { traverseFields } from './traverseFields.js'
type Args<T> = {
collection: SanitizedCollectionConfig | null
context: RequestContext
/**
* The data before hooks
*/
data: Record<string, unknown> | T
/**
* The data after hooks
*/
doc: Record<string, unknown> | T
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
@@ -24,7 +30,6 @@ export const afterChange = async <T extends Record<string, unknown>>({
collection,
context,
data,
doc: incomingDoc,
global,
operation,
@@ -41,9 +46,11 @@ export const afterChange = async <T extends Record<string, unknown>>({
fields: collection?.fields || global?.fields,
global,
operation,
path: [],
previousDoc,
previousSiblingDoc: previousDoc,
req,
schemaPath: [],
siblingData: data,
siblingDoc: doc,
})

View File

@@ -1,10 +1,13 @@
/* eslint-disable no-param-reassign */
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js'
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 { traverseFields } from './traverseFields.js'
type Args = {
@@ -15,6 +18,14 @@ type Args = {
field: Field | TabAsField
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
/**
* The parent's path
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
previousDoc: Record<string, unknown>
previousSiblingDoc: Record<string, unknown>
req: PayloadRequestWithData
@@ -33,12 +44,20 @@ export const promise = async ({
field,
global,
operation,
parentPath,
parentSchemaPath,
previousDoc,
previousSiblingDoc,
req,
siblingData,
siblingDoc,
}: Args): Promise<void> => {
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
field,
parentPath,
parentSchemaPath,
})
if (fieldAffectsData(field)) {
// Execute hooks
if (field.hooks?.afterChange) {
@@ -53,12 +72,14 @@ export const promise = async ({
global,
operation,
originalDoc: doc,
path: fieldPath,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
@@ -79,9 +100,11 @@ export const promise = async ({
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: previousDoc[field.name] as Record<string, unknown>,
req,
schemaPath: fieldSchemaPath,
siblingData: (siblingData?.[field.name] as Record<string, unknown>) || {},
siblingDoc: siblingDoc[field.name] as Record<string, unknown>,
})
@@ -104,9 +127,11 @@ export const promise = async ({
fields: field.fields,
global,
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as Record<string, unknown>),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: { ...row } || {},
}),
@@ -135,10 +160,12 @@ export const promise = async ({
fields: block.fields,
global,
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc:
previousDoc?.[field.name]?.[i] || ({} as Record<string, unknown>),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: { ...row } || {},
}),
@@ -161,9 +188,11 @@ export const promise = async ({
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
@@ -190,9 +219,11 @@ export const promise = async ({
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: tabPreviousSiblingDoc,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
})
@@ -209,15 +240,57 @@ export const promise = async ({
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
break
}
case 'richText': {
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.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterChange?.length) {
await editor.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
path: fieldPath,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
default: {
break
}

View File

@@ -13,9 +13,11 @@ type Args = {
fields: (Field | TabAsField)[]
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
path: (number | string)[]
previousDoc: Record<string, unknown>
previousSiblingDoc: Record<string, unknown>
req: PayloadRequestWithData
schemaPath: string[]
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
}
@@ -28,9 +30,11 @@ export const traverseFields = async ({
fields,
global,
operation,
path,
previousDoc,
previousSiblingDoc,
req,
schemaPath,
siblingData,
siblingDoc,
}: Args): Promise<void> => {
@@ -46,6 +50,8 @@ export const traverseFields = async ({
field,
global,
operation,
parentPath: path,
parentSchemaPath: schemaPath,
previousDoc,
previousSiblingDoc,
req,

View File

@@ -25,7 +25,7 @@ type Args = {
/**
* This function is responsible for the following actions, in order:
* - Remove hidden fields from response
* - Flatten locales into requested locale
* - Flatten locales into requested locale. If the input doc contains all locales, the output doc after this function will only contain the requested locale.
* - Sanitize outgoing data (point field, etc.)
* - Execute field hooks
* - Execute read access control
@@ -77,8 +77,10 @@ export async function afterRead<T = any>(args: Args): Promise<T> {
global,
locale,
overrideAccess,
path: [],
populationPromises,
req,
schemaPath: [],
showHiddenFields,
siblingDoc: doc,
})

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-param-reassign */
import type { RichTextAdapter } from '../../../admin/types.js'
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js'
@@ -8,6 +8,7 @@ import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import getValueWithDefault from '../../getDefaultValue.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { relationshipPopulationPromise } from './relationshipPopulationPromise.js'
import { traverseFields } from './traverseFields.js'
@@ -29,6 +30,14 @@ type Args = {
global: SanitizedGlobalConfig | null
locale: null | string
overrideAccess: boolean
/**
* The parent's path.
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
populationPromises: Promise<void>[]
req: PayloadRequestWithData
showHiddenFields: boolean
@@ -60,6 +69,8 @@ export const promise = async ({
global,
locale,
overrideAccess,
parentPath,
parentSchemaPath,
populationPromises,
req,
showHiddenFields,
@@ -67,6 +78,12 @@ export const promise = async ({
triggerAccessControl = true,
triggerHooks = true,
}: Args): Promise<void> => {
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
field,
parentPath,
parentSchemaPath,
})
if (
fieldAffectsData(field) &&
field.hidden &&
@@ -151,29 +168,7 @@ export const promise = async ({
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
// This is run here AND in the GraphQL Resolver
if (editor?.populationPromises) {
const populateDepth =
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
editor.populationPromises({
context,
currentDepth,
depth: populateDepth,
draft,
field,
fieldPromises,
findMany,
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
})
}
// Rich Text fields should use afterRead hooks to do population. The previous editor.populationPromises have been renamed to editor.graphQLPopulationPromises
break
}
@@ -212,10 +207,14 @@ export const promise = async ({
context,
data: doc,
field,
findMany,
global,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: siblingDoc,
value,
})
@@ -238,7 +237,9 @@ export const promise = async ({
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: siblingDoc,
value: siblingDoc[field.name],
})
@@ -322,8 +323,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: groupDoc,
triggerAccessControl,
@@ -337,7 +340,7 @@ export const promise = async ({
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
rows.forEach((row) => {
rows.forEach((row, i) => {
traverseFields({
collection,
context,
@@ -353,8 +356,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
@@ -364,7 +369,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) => {
localeRows.forEach((row, i) => {
traverseFields({
collection,
context,
@@ -380,8 +385,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
@@ -400,7 +407,7 @@ export const promise = async ({
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
rows.forEach((row) => {
rows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType)
if (block) {
@@ -419,8 +426,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
@@ -431,7 +440,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) => {
localeRows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType)
if (block) {
@@ -450,8 +459,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
@@ -485,8 +496,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc,
triggerAccessControl,
@@ -518,8 +531,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: tabDoc,
triggerAccessControl,
@@ -545,8 +560,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc,
triggerAccessControl,
@@ -555,6 +572,101 @@ export const promise = async ({
break
}
case 'richText': {
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.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterRead?.length) {
await editor.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook
const shouldRunHookOnAllLocales =
field.localized &&
(locale === 'all' || !flattenLocales) &&
typeof siblingDoc[field.name] === 'object'
if (shouldRunHookOnAllLocales) {
const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) =>
(async () => {
const hookedValue = await currentHook({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
fallbackLocale,
field,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
triggerAccessControl,
triggerHooks,
value,
})
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue
}
})(),
)
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
fallbackLocale,
field,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
triggerAccessControl,
triggerHooks,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}
}, Promise.resolve())
}
break
}
default: {
break
}

View File

@@ -23,8 +23,10 @@ type Args = {
global: SanitizedGlobalConfig | null
locale: null | string
overrideAccess: boolean
path: (number | string)[]
populationPromises: Promise<void>[]
req: PayloadRequestWithData
schemaPath: string[]
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
triggerAccessControl?: boolean
@@ -46,8 +48,10 @@ export const traverseFields = ({
global,
locale,
overrideAccess,
path,
populationPromises,
req,
schemaPath,
showHiddenFields,
siblingDoc,
triggerAccessControl = true,
@@ -70,6 +74,8 @@ export const traverseFields = ({
global,
locale,
overrideAccess,
parentPath: path,
parentSchemaPath: schemaPath,
populationPromises,
req,
showHiddenFields,

View File

@@ -27,7 +27,7 @@ type Args<T> = {
* - Validate data
* - Transform data for storage
* - beforeDuplicate hooks (if duplicate)
* - Unflatten locales
* - 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 Record<string, unknown>>({
id,
@@ -59,8 +59,9 @@ export const beforeChange = async <T extends Record<string, unknown>>({
global,
mergeLocaleActions,
operation,
path: '',
path: [],
req,
schemaPath: [],
siblingData: data,
siblingDoc: doc,
siblingDocWithLocales: docWithLocales,

View File

@@ -1,11 +1,14 @@
import merge from 'deepmerge'
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { Operation, PayloadRequestWithData, RequestContext } from '../../../types/index.js'
import type { Field, FieldHookArgs, TabAsField, ValidateOptions } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { beforeDuplicate } from './beforeDuplicate.js'
import { getExistingRowDoc } from './getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
@@ -23,7 +26,14 @@ type Args = {
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
operation: Operation
path: string
/**
* The parent's path.
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
req: PayloadRequestWithData
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
@@ -52,7 +62,8 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
@@ -67,6 +78,12 @@ export const promise = async ({
const defaultLocale = localization ? localization?.defaultLocale : 'en'
const operationLocale = req.locale || defaultLocale
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
field,
parentPath,
parentSchemaPath,
})
if (fieldAffectsData(field)) {
// skip validation if the field is localized and the incoming data is null
if (field.localized && operationLocale !== defaultLocale) {
@@ -88,10 +105,13 @@ export const promise = async ({
global,
operation,
originalDoc: doc,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
value: siblingData[field.name],
})
@@ -127,7 +147,7 @@ export const promise = async ({
if (typeof validationResult === 'string') {
errors.push({
field: `${path}${field.name}`,
field: fieldPath.join('.'),
message: validationResult,
})
}
@@ -139,8 +159,13 @@ export const promise = async ({
data,
field,
global: undefined,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
value: siblingData[field.name],
}
@@ -225,8 +250,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path: `${path}${field.name}.`,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData[field.name] as Record<string, unknown>,
siblingDoc: siblingDoc[field.name] as Record<string, unknown>,
siblingDocWithLocales: siblingDocWithLocales[field.name] as Record<string, unknown>,
@@ -256,8 +282,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path: `${path}${field.name}.${i}.`,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row,
siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]),
siblingDocWithLocales: getExistingRowDoc(row, siblingDocWithLocales[field.name]),
@@ -299,8 +326,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path: `${path}${field.name}.${i}.`,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row,
siblingDoc: rowSiblingDoc,
siblingDocWithLocales: rowSiblingDocWithLocales,
@@ -331,8 +359,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
@@ -343,13 +372,11 @@ export const promise = async ({
}
case 'tab': {
let tabPath = path
let tabSiblingData = siblingData
let tabSiblingDoc = siblingDoc
let tabSiblingDocWithLocales = siblingDocWithLocales
if (tabHasName(field)) {
tabPath = `${path}${field.name}.`
if (typeof siblingData[field.name] !== 'object') siblingData[field.name] = {}
if (typeof siblingDoc[field.name] !== 'object') siblingDoc[field.name] = {}
if (typeof siblingDocWithLocales[field.name] !== 'object')
@@ -373,8 +400,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path: tabPath,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
siblingDocWithLocales: tabSiblingDocWithLocales,
@@ -398,8 +426,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
@@ -409,6 +438,52 @@ export const promise = async ({
break
}
case 'richText': {
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.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeChange?.length) {
await editor.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
docWithLocales,
duplicate,
errors,
field,
global,
mergeLocaleActions,
operation,
originalDoc: doc,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
skipValidation,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
default: {
break
}

View File

@@ -24,8 +24,9 @@ type Args = {
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
operation: Operation
path: string
path: (number | string)[]
req: PayloadRequestWithData
schemaPath: string[]
siblingData: Record<string, unknown>
/**
* The original siblingData (not modified by any hooks)
@@ -44,7 +45,7 @@ type Args = {
* - Execute field hooks
* - Validate data
* - Transform data for storage
* - Unflatten locales
* - Unflatten locales. The input `data` is the normal document for one locale. The output result will become the document with locales.
*/
export const traverseFields = async ({
id,
@@ -61,6 +62,7 @@ export const traverseFields = async ({
operation,
path,
req,
schemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
@@ -83,7 +85,8 @@ export const traverseFields = async ({
global,
mergeLocaleActions,
operation,
path,
parentPath: path,
parentSchemaPath: schemaPath,
req,
siblingData,
siblingDoc,

View File

@@ -49,7 +49,9 @@ export const beforeValidate = async <T extends Record<string, unknown>>({
global,
operation,
overrideAccess,
path: [],
req,
schemaPath: [],
siblingData: data,
siblingDoc: doc,
})

View File

@@ -1,11 +1,14 @@
/* eslint-disable no-param-reassign */
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js'
import getValueWithDefault from '../../getDefaultValue.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js'
import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
@@ -23,6 +26,8 @@ type Args<T> = {
id?: number | string
operation: 'create' | 'update'
overrideAccess: boolean
parentPath: (number | string)[]
parentSchemaPath: string[]
req: PayloadRequestWithData
siblingData: Record<string, unknown>
/**
@@ -48,10 +53,18 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
}: Args<T>): Promise<void> => {
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
field,
parentPath,
parentSchemaPath,
})
if (fieldAffectsData(field)) {
if (field.name === 'id') {
if (field.type === 'number' && typeof siblingData[field.name] === 'string') {
@@ -229,8 +242,11 @@ export const promise = async <T>({
operation,
originalDoc: doc,
overrideAccess,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingData[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
})
@@ -288,7 +304,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: groupData,
siblingDoc: groupDoc,
})
@@ -301,7 +319,7 @@ export const promise = async <T>({
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row) => {
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
@@ -313,7 +331,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row,
siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]),
}),
@@ -329,7 +349,7 @@ export const promise = async <T>({
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row) => {
rows.forEach((row, i) => {
const rowSiblingDoc = getExistingRowDoc(row, siblingDoc[field.name])
const blockTypeToMatch = row.blockType || rowSiblingDoc.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
@@ -348,7 +368,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row,
siblingDoc: rowSiblingDoc,
}),
@@ -373,7 +395,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})
@@ -405,7 +429,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
})
@@ -424,7 +450,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})
@@ -432,6 +460,46 @@ export const promise = async <T>({
break
}
case 'richText': {
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.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeValidate?.length) {
await editor.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
overrideAccess,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingData[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
default: {
break
}

View File

@@ -18,7 +18,9 @@ type Args<T> = {
id?: number | string
operation: 'create' | 'update'
overrideAccess: boolean
path: (number | string)[]
req: PayloadRequestWithData
schemaPath: string[]
siblingData: Record<string, unknown>
/**
* The original siblingData (not modified by any hooks)
@@ -36,7 +38,9 @@ export const traverseFields = async <T>({
global,
operation,
overrideAccess,
path,
req,
schemaPath,
siblingData,
siblingDoc,
}: Args<T>): Promise<void> => {
@@ -53,6 +57,8 @@ export const traverseFields = async <T>({
global,
operation,
overrideAccess,
parentPath: path,
parentSchemaPath: schemaPath,
req,
siblingData,
siblingDoc,

View File

@@ -51,7 +51,6 @@ export const BlockContent: React.FC<Props> = (props) => {
formData,
formSchema,
nodeKey,
path,
reducedBlock: { labels },
schemaPath,
} = props
@@ -111,17 +110,21 @@ export const BlockContent: React.FC<Props> = (props) => {
// does not have, even if it's undefined.
// Currently, this happens if a block has another sub-blocks field. Inside formData, that sub-blocks field has an undefined blockName property.
// Inside of fields.data however, that sub-blocks blockName property does not exist at all.
function removeUndefinedAndNullRecursively(obj: object) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
removeUndefinedAndNullRecursively(obj[key])
} else if (obj[key] === undefined || obj[key] === null) {
function removeUndefinedAndNullAndEmptyArraysRecursively(obj: object) {
for (const key in obj) {
const value = obj[key]
if (Array.isArray(value) && !value?.length) {
delete obj[key]
} else if (value && typeof value === 'object') {
removeUndefinedAndNullAndEmptyArraysRecursively(value)
} else if (value === undefined || value === null) {
delete obj[key]
}
})
}
removeUndefinedAndNullRecursively(newFormData)
removeUndefinedAndNullRecursively(formData)
}
removeUndefinedAndNullAndEmptyArraysRecursively(newFormData)
removeUndefinedAndNullAndEmptyArraysRecursively(formData)
// Only update if the data has actually changed. Otherwise, we may be triggering an unnecessary value change,
// which would trigger the "Leave without saving" dialog unnecessarily

View File

@@ -29,10 +29,8 @@ import type { BlocksFeatureClientProps } from '../feature.client.js'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import { BlockContent } from './BlockContent.js'
import './index.scss'
import { removeEmptyArrayValues } from './removeEmptyArrayValues.js'
type Props = {
blockFieldWrapperName: string
children?: React.ReactNode
formData: BlockFields
@@ -44,7 +42,7 @@ type Props = {
}
export const BlockComponent: React.FC<Props> = (props) => {
const { blockFieldWrapperName, formData, nodeKey } = props
const { formData, nodeKey } = props
const config = useConfig()
const submitted = useFormSubmitted()
const { id } = useDocumentInfo()
@@ -81,7 +79,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
if (state) {
setInitialState({
...removeEmptyArrayValues({ fields: state }),
...state,
blockName: {
initialValue: '',
passesCondition: true,
@@ -175,6 +173,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
</Collapsible>
)
}, [
classNames,
fieldMap,
parentLexicalRichTextField,
nodeKey,
@@ -182,7 +181,6 @@ export const BlockComponent: React.FC<Props> = (props) => {
submitted,
initialState,
reducedBlock,
blockFieldWrapperName,
onChange,
schemaFieldsPath,
path,

View File

@@ -10,9 +10,9 @@ import type { BlocksFeatureClientProps } from './feature.client.js'
import { createNode } from '../typeUtilities.js'
import { BlocksFeatureClientComponent } from './feature.client.js'
import { blockPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { BlockNode } from './nodes/BlocksNode.js'
import { blockPopulationPromiseHOC } from './populationPromise.js'
import { blockValidationHOC } from './validate.js'
export type BlocksFeatureProps = {
@@ -114,71 +114,17 @@ export const BlocksFeature: FeatureProviderProviderServer<
i18n,
nodes: [
createNode({
/* // TODO: Implement these hooks once docWithLocales / originalSiblingDoc => node matching has been figured out
hooks: {
beforeChange: [
async ({ context, findMany, node, operation, overrideAccess, req }) => {
getSubFields: ({ node, req }) => {
const blockType = node.fields.blockType
const block = deepCopyObject(
props.blocks.find((block) => block.slug === blockType),
)
await beforeChangeTraverseFields({
id: null,
collection: null,
context,
data: node.fields,
doc: node.fields,
fields: sanitizedBlock.fields,
global: null,
mergeLocaleActions: [],
operation:
operation === 'create' || operation === 'update' ? operation : 'update',
overrideAccess,
path: '',
req,
siblingData: node.fields,
siblingDoc: node.fields,
})
return node
const block = props.blocks.find((block) => block.slug === blockType)
return block?.fields
},
],
beforeValidate: [
async ({ context, findMany, node, operation, overrideAccess, req }) => {
const blockType = node.fields.blockType
const block = deepCopyObject(
props.blocks.find((block) => block.slug === blockType),
)
await beforeValidateTraverseFields({
id: null,
collection: null,
context,
data: node.fields,
doc: node.fields,
fields: sanitizedBlock.fields,
global: null,
operation:
operation === 'create' || operation === 'update' ? operation : 'update',
overrideAccess,
req,
siblingData: node.fields,
siblingDoc: node.fields,
})
return node
getSubFieldsData: ({ node }) => {
return node?.fields
},
],
},*/
graphQLPopulationPromises: [blockPopulationPromiseHOC(props)],
node: BlockNode,
populationPromises: [blockPopulationPromiseHOC(props)],
validations: [blockValidationHOC(props)],
}),
],

View File

@@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js'
import type { BlocksFeatureProps } from './feature.server.js'
import type { SerializedBlockNode } from './nodes/BlocksNode.js'
import { recurseNestedFields } from '../../../populate/recurseNestedFields.js'
import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const blockPopulationPromiseHOC = (
props: BlocksFeatureProps,
@@ -21,7 +21,6 @@ export const blockPopulationPromiseHOC = (
populationPromises,
req,
showHiddenFields,
siblingDoc,
}) => {
const blockFieldData = node.fields
@@ -31,22 +30,21 @@ export const blockPopulationPromiseHOC = (
return
}
recurseNestedFields({
recursivelyPopulateFieldsForGraphQL({
context,
currentDepth,
data: blockFieldData,
depth,
draft,
editorPopulationPromises,
fieldPromises,
fields: block.fields,
findMany,
flattenLocales: false, // Disable localization handling which does not work properly yet. Once we fully support hooks, this can be enabled (pass through flattenLocales again)
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
// The afterReadPromise gets its data from looking for field.name inside the siblingDoc. Thus, here we cannot pass the whole document's siblingDoc, but only the siblingDoc (sibling fields) of the current field.
draft,
siblingDoc: blockFieldData,
})
}

View File

@@ -1,6 +1,6 @@
import type { User } from 'payload/auth'
import type { SanitizedConfig } from 'payload/config'
import type { Field, RadioField, TextField } from 'payload/types'
import type { FieldAffectingData, RadioField, TextField } from 'payload/types'
import { validateUrl } from '../../../lexical/utils/url.js'
@@ -9,7 +9,7 @@ export const getBaseFields = (
enabledCollections: false | string[],
disabledCollections: false | string[],
maxDepth?: number,
): Field[] => {
): FieldAffectingData[] => {
let enabledRelations: string[]
/**
@@ -33,7 +33,7 @@ export const getBaseFields = (
.map(({ slug }) => slug)
}
const baseFields: Field[] = [
const baseFields: FieldAffectingData[] = [
{
name: 'text',
type: 'text',

View File

@@ -1,5 +1,5 @@
import type { Config, SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types'
import type { Field, FieldAffectingData } from 'payload/types'
import { traverseFields } from '@payloadcms/ui/utilities/buildFieldSchemaMap/traverseFields'
import { sanitizeFields } from 'payload/config'
@@ -11,12 +11,12 @@ import type { ClientProps } from './feature.client.js'
import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js'
import { createNode } from '../typeUtilities.js'
import { LinkFeatureClientComponent } from './feature.client.js'
import { linkPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { LinkMarkdownTransformer } from './markdownTransformer.js'
import { AutoLinkNode } from './nodes/AutoLinkNode.js'
import { LinkNode } from './nodes/LinkNode.js'
import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js'
import { linkPopulationPromiseHOC } from './populationPromise.js'
import { linkValidation } from './validate.js'
export type ExclusiveLinkCollectionsProps =
@@ -46,7 +46,12 @@ export type LinkFeatureServerProps = ExclusiveLinkCollectionsProps & {
* A function or array defining additional fields for the link feature. These will be
* displayed in the link editor drawer.
*/
fields?: ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[]) | Field[]
fields?:
| ((args: {
config: SanitizedConfig
defaultFields: FieldAffectingData[]
}) => (Field | FieldAffectingData)[])
| Field[]
/**
* Sets a maximum population depth for the internal doc default field of link, regardless of the remaining depth when the field is reached.
* This behaves exactly like the maxDepth properties of relationship and upload fields.
@@ -82,6 +87,13 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
})
props.fields = sanitizedFields
// the text field is not included in the node data.
// Thus, for tasks like validation, we do not want to pass it a text field in the schema which will never have data.
// Otherwise, it will cause a validation error (field is required).
const sanitizedFieldsWithoutText = deepCopyObject(sanitizedFields).filter(
(field) => field.name !== 'text',
)
return {
ClientComponent: LinkFeatureClientComponent,
clientFeatureProps: {
@@ -143,16 +155,9 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
nodeTypes: [AutoLinkNode.getType()],
},
},
hooks: {
afterRead: [
({ node }) => {
return node
},
],
},
node: AutoLinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
validations: [linkValidation(props)],
// Since AutoLinkNodes are just internal links, they need no hooks or graphQL population promises
validations: [linkValidation(props, sanitizedFieldsWithoutText)],
}),
createNode({
converters: {
@@ -181,9 +186,15 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
nodeTypes: [LinkNode.getType()],
},
},
getSubFields: ({ node, req }) => {
return sanitizedFieldsWithoutText
},
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [linkPopulationPromiseHOC(props)],
node: LinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
validations: [linkValidation(props)],
validations: [linkValidation(props, sanitizedFieldsWithoutText)],
}),
],
serverFeatureProps: props,

View File

@@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js'
import type { LinkFeatureServerProps } from './feature.server.js'
import type { SerializedLinkNode } from './nodes/types.js'
import { recurseNestedFields } from '../../../populate/recurseNestedFields.js'
import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const linkPopulationPromiseHOC = (
props: LinkFeatureServerProps,
@@ -30,7 +30,7 @@ export const linkPopulationPromiseHOC = (
* Should populate all fields, including the doc field (for internal links), as it's treated like a normal field
*/
if (Array.isArray(props.fields)) {
recurseNestedFields({
recursivelyPopulateFieldsForGraphQL({
context,
currentDepth,
data: node.fields,
@@ -40,7 +40,7 @@ export const linkPopulationPromiseHOC = (
fieldPromises,
fields: props.fields,
findMany,
flattenLocales: false, // Disable localization handling which does not work properly yet. Once we fully support hooks, this can be enabled (pass through flattenLocales again)
flattenLocales,
overrideAccess,
populationPromises,
req,

View File

@@ -11,7 +11,7 @@ import { LinkNode } from './LinkNode.js'
export class AutoLinkNode extends LinkNode {
static clone(node: AutoLinkNode): AutoLinkNode {
return new AutoLinkNode({ fields: node.__fields, key: node.__key })
return new AutoLinkNode({ id: undefined, fields: node.__fields, key: node.__key })
}
static getType(): string {
@@ -61,7 +61,7 @@ export class AutoLinkNode extends LinkNode {
}
export function $createAutoLinkNode({ fields }: { fields: LinkFields }): AutoLinkNode {
return $applyNodeReplacement(new AutoLinkNode({ fields }))
return $applyNodeReplacement(new AutoLinkNode({ id: undefined, fields }))
}
export function $isAutoLinkNode(node: LexicalNode | null | undefined): node is AutoLinkNode {
return node instanceof AutoLinkNode

View File

@@ -11,6 +11,7 @@ import type {
} from 'lexical'
import { addClassNamesToElement, isHTMLAnchorElement } from '@lexical/utils'
import ObjectID from 'bson-objectid'
import {
$applyNodeReplacement,
$createTextNode,
@@ -29,8 +30,10 @@ const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', '
/** @noInheritDoc */
export class LinkNode extends ElementNode {
__fields: LinkFields
__id: string
constructor({
id,
fields = {
doc: null,
linkType: 'custom',
@@ -40,14 +43,17 @@ export class LinkNode extends ElementNode {
key,
}: {
fields: LinkFields
id: string
key?: NodeKey
}) {
super(key)
this.__fields = fields
this.__id = id
}
static clone(node: LinkNode): LinkNode {
return new LinkNode({
id: node.__id,
fields: node.__fields,
key: node.__key,
})
@@ -76,7 +82,13 @@ export class LinkNode extends ElementNode {
serializedNode.version = 2
}
if (serializedNode.version === 2 && !serializedNode.id) {
serializedNode.id = new ObjectID.default().toHexString()
serializedNode.version = 3
}
const node = $createLinkNode({
id: serializedNode.id,
fields: serializedNode.fields,
})
node.setFormat(serializedNode.format)
@@ -115,12 +127,17 @@ export class LinkNode extends ElementNode {
}
exportJSON(): SerializedLinkNode {
return {
const returnObject: SerializedLinkNode = {
...super.exportJSON(),
type: this.getType(),
fields: this.getFields(),
version: 2,
version: 3,
}
const id = this.getID()
if (id) {
returnObject.id = id
}
return returnObject
}
extractWithChild(
@@ -146,6 +163,10 @@ export class LinkNode extends ElementNode {
return this.getLatest().__fields
}
getID(): string {
return this.getLatest().__id
}
insertNewAfter(selection: RangeSelection, restoreSelection = true): ElementNodeType | null {
const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection)
if ($isElementNode(element)) {
@@ -216,6 +237,7 @@ function $convertAnchorElement(domNode: Node): DOMConversionOutput {
const content = domNode.textContent
if (content !== null && content !== '') {
node = $createLinkNode({
id: new ObjectID.default().toHexString(),
fields: {
doc: null,
linkType: 'custom',
@@ -228,8 +250,13 @@ function $convertAnchorElement(domNode: Node): DOMConversionOutput {
return { node }
}
export function $createLinkNode({ fields }: { fields: LinkFields }): LinkNode {
return $applyNodeReplacement(new LinkNode({ fields }))
export function $createLinkNode({ id, fields }: { fields: LinkFields; id?: string }): LinkNode {
return $applyNodeReplacement(
new LinkNode({
id: id ?? new ObjectID.default().toHexString(),
fields,
}),
)
}
export function $isLinkNode(node: LexicalNode | null | undefined): node is LinkNode {
@@ -349,8 +376,6 @@ export function $toggleLink(payload: LinkPayload): void {
})
}
}
/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
export const toggleLink = $toggleLink
function $getLinkAncestor(node: LexicalNode): LinkNode | null {
return $getAncestor(node, (ancestor) => $isLinkNode(ancestor)) as LinkNode

View File

@@ -21,7 +21,8 @@ export type LinkFields = {
export type SerializedLinkNode = Spread<
{
fields: LinkFields
id?: string // optional if AutoLinkNode
},
SerializedElementNode
>
export type SerializedAutoLinkNode = SerializedLinkNode
export type SerializedAutoLinkNode = Omit<SerializedLinkNode, 'id'>

View File

@@ -48,7 +48,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
const { i18n, t } = useTranslation()
const [stateData, setStateData] = useState<{} | (LinkFields & { text: string })>({})
const [stateData, setStateData] = useState<{} | (LinkFields & { id?: string; text: string })>({})
const { closeModal, isModalOpen, toggleModal } = useModal()
const editDepth = useEditDepth()
@@ -114,6 +114,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
newTab: undefined,
url: '',
...focusLinkParent.getFields(),
id: focusLinkParent.getID(),
text: focusLinkParent.getTextContent(),
}

View File

@@ -1,5 +1,5 @@
import type { SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types'
import type { Field, FieldAffectingData } from 'payload/types'
import { getBaseFields } from '../../drawer/baseFields.js'
@@ -8,14 +8,14 @@ import { getBaseFields } from '../../drawer/baseFields.js'
*/
export function transformExtraFields(
customFieldSchema:
| ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[])
| ((args: { config: SanitizedConfig; defaultFields: FieldAffectingData[] }) => Field[])
| Field[],
config: SanitizedConfig,
enabledCollections?: false | string[],
disabledCollections?: false | string[],
maxDepth?: number,
): Field[] {
const baseFields: Field[] = getBaseFields(
const baseFields: FieldAffectingData[] = getBaseFields(
config,
enabledCollections,
disabledCollections,
@@ -29,7 +29,7 @@ export function transformExtraFields(
} else if (Array.isArray(customFieldSchema)) {
fields = customFieldSchema
} else {
fields = baseFields
fields = baseFields as Field[]
}
return fields

View File

@@ -16,7 +16,7 @@ import type { LinkFields } from '../../nodes/types.js'
import type { LinkPayload } from '../floatingLinkEditor/types.js'
import { validateUrl } from '../../../../lexical/utils/url.js'
import { LinkNode, TOGGLE_LINK_COMMAND, toggleLink } from '../../nodes/LinkNode.js'
import { $toggleLink, LinkNode, TOGGLE_LINK_COMMAND } from '../../nodes/LinkNode.js'
export const LinkPlugin: PluginComponent<ClientProps> = () => {
const [editor] = useLexicalComposerContext()
@@ -29,7 +29,7 @@ export const LinkPlugin: PluginComponent<ClientProps> = () => {
editor.registerCommand(
TOGGLE_LINK_COMMAND,
(payload: LinkPayload) => {
toggleLink(payload)
$toggleLink(payload)
return true
},
COMMAND_PRIORITY_LOW,

View File

@@ -8,6 +8,7 @@ import type { SerializedAutoLinkNode, SerializedLinkNode } from './nodes/types.j
export const linkValidation = (
props: LinkFeatureServerProps,
sanitizedFieldsWithoutText: Field[],
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
): NodeValidation<SerializedAutoLinkNode | SerializedLinkNode> => {
return async ({
@@ -20,19 +21,14 @@ export const linkValidation = (
* Run buildStateFromSchema as that properly validates link fields and link sub-fields
*/
const data = {
...node.fields,
text: 'ignored',
}
const result = await buildStateFromSchema({
id,
data,
fieldSchema: props.fields as Field[], // Sanitized in feature.server.ts
data: node.fields,
fieldSchema: sanitizedFieldsWithoutText, // Sanitized in feature.server.ts
operation: operation === 'create' || operation === 'update' ? operation : 'update',
preferences,
req,
siblingData: data,
siblingData: node.fields,
})
let errorPaths = []

View File

@@ -1,10 +1,11 @@
import type { FeatureProviderProviderServer } from '../types.js'
import { populate } from '../../../populateGraphQL/populate.js'
import { createNode } from '../typeUtilities.js'
import { RelationshipFeatureClientComponent } from './feature.client.js'
import { relationshipPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { RelationshipNode } from './nodes/RelationshipNode.js'
import { relationshipPopulationPromiseHOC } from './populationPromise.js'
export type ExclusiveRelationshipFeatureProps =
| {
@@ -50,8 +51,55 @@ export const RelationshipFeature: FeatureProviderProviderServer<
i18n,
nodes: [
createNode({
graphQLPopulationPromises: [relationshipPopulationPromiseHOC(props)],
hooks: {
afterRead: [
({
currentDepth,
depth,
draft,
node,
overrideAccess,
populationPromises,
req,
showHiddenFields,
}) => {
if (!node?.value) {
return node
}
const collection = req.payload.collections[node?.relationTo]
if (!collection) {
return node
}
// @ts-expect-error
const id = node?.value?.id || node?.value // for backwards-compatibility
const populateDepth =
props?.maxDepth !== undefined && props?.maxDepth < depth
? props?.maxDepth
: depth
populationPromises.push(
populate({
id,
collection,
currentDepth,
data: node,
depth: populateDepth,
draft,
key: 'value',
overrideAccess,
req,
showHiddenFields,
}),
)
return node
},
],
},
node: RelationshipNode,
populationPromises: [relationshipPopulationPromiseHOC(props)],
}),
],
serverFeatureProps: props,

View File

@@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js'
import type { RelationshipFeatureProps } from './feature.server.js'
import type { SerializedRelationshipNode } from './nodes/RelationshipNode.js'
import { populate } from '../../../populate/populate.js'
import { populate } from '../../../populateGraphQL/populate.js'
export const relationshipPopulationPromiseHOC = (
props: RelationshipFeatureProps,
@@ -11,7 +11,6 @@ export const relationshipPopulationPromiseHOC = (
currentDepth,
depth,
draft,
field,
node,
overrideAccess,
populationPromises,
@@ -36,7 +35,6 @@ export const relationshipPopulationPromiseHOC = (
data: node,
depth: populateDepth,
draft,
field,
key: 'value',
overrideAccess,
req,

View File

@@ -1,5 +1,5 @@
import type { Transformer } from '@lexical/markdown'
import type { GenericLanguages, I18n, I18nClient } from '@payloadcms/translations'
import type { GenericLanguages, I18nClient } from '@payloadcms/translations'
import type { JSONSchema4 } from 'json-schema'
import type { Klass, LexicalEditor, LexicalNode, SerializedEditorState } from 'lexical'
import type { SerializedLexicalNode } from 'lexical'
@@ -25,6 +25,7 @@ export type PopulationPromise<T extends SerializedLexicalNode = SerializedLexica
context,
currentDepth,
depth,
draft,
editorPopulationPromises,
field,
fieldPromises,
@@ -236,21 +237,103 @@ export type ClientComponentProps<ClientFeatureProps> = ClientFeatureProps extend
order: number
} & ClientFeatureProps
export type FieldNodeHookArgs<T extends SerializedLexicalNode> = {
context: RequestContext
export type AfterReadNodeHookArgs<T extends SerializedLexicalNode> = {
/**
* Only available in `afterRead` hooks.
*/
currentDepth: number
/**
* Only available in `afterRead` hooks.
*/
depth: number
draft: boolean
fallbackLocale: string
/**
* Only available in `afterRead` field hooks.
*/
fieldPromises: Promise<void>[]
/** Boolean to denote if this hook is running against finding one, or finding many within the afterRead hook. */
findMany?: boolean
/** The value of the field. */
node?: T
findMany: boolean
flattenLocales: boolean
/**
* The requested locale.
*/
locale: string
overrideAccess: boolean
/**
* Only available in `afterRead` field hooks.
*/
populationPromises: Promise<void>[]
/**
* Only available in `afterRead` hooks.
*/
showHiddenFields: boolean
/**
* Only available in `afterRead` hooks.
*/
triggerAccessControl: boolean
/**
* Only available in `afterRead` hooks.
*/
triggerHooks: boolean
}
export type AfterChangeNodeHookArgs<T extends SerializedLexicalNode> = {
/** 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'
overrideAccess?: boolean
/** The Express request object. It is mocked for Local API operations. */
operation: 'create' | 'delete' | 'read' | 'update'
/** The value of the node before any changes. Not available in afterRead hooks */
originalNode: T
}
export type BeforeValidateNodeHookArgs<T extends SerializedLexicalNode> = {
/** 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 value of the node before any changes. Not available in afterRead hooks */
originalNode: T
overrideAccess: boolean
}
export type BeforeChangeNodeHookArgs<T extends SerializedLexicalNode> = {
duplicate: boolean
/**
* Only available in `beforeChange` hooks.
*/
errors: { field: string; message: string }[]
mergeLocaleActions: (() => Promise<void>)[]
/** 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 value of the node before any changes. Not available in afterRead hooks */
originalNode: T
/**
* The original node with locales (not modified by any hooks).
*/
originalNodeWithLocales?: T
skipValidation: boolean
}
export type BaseNodeHookArgs<T extends SerializedLexicalNode> = {
context: RequestContext
/** The value of the node. */
node: T
parentRichTextFieldPath: (number | string)[]
parentRichTextFieldSchemaPath: string[]
/** The payload request object. It is mocked for Local API operations. */
req: PayloadRequestWithData
}
export type FieldNodeHook<T extends SerializedLexicalNode> = (
args: FieldNodeHookArgs<T>,
export type AfterReadNodeHook<T extends SerializedLexicalNode> = (
args: AfterReadNodeHookArgs<T> & BaseNodeHookArgs<T>,
) => Promise<T> | T
export type AfterChangeNodeHook<T extends SerializedLexicalNode> = (
args: AfterChangeNodeHookArgs<T> & BaseNodeHookArgs<T>,
) => Promise<T> | T
export type BeforeChangeNodeHook<T extends SerializedLexicalNode> = (
args: BeforeChangeNodeHookArgs<T> & BaseNodeHookArgs<T>,
) => Promise<T> | T
export type BeforeValidateNodeHook<T extends SerializedLexicalNode> = (
args: BeforeValidateNodeHookArgs<T> & BaseNodeHookArgs<T>,
) => Promise<T> | T
// Define the node with hooks that use the node's exportJSON return type
@@ -258,20 +341,30 @@ export type NodeWithHooks<T extends LexicalNode = any> = {
converters?: {
html?: HTMLConverter<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
}
hooks?: {
afterChange?: Array<FieldNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
afterRead?: Array<FieldNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
beforeChange?: Array<FieldNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
/**
* Runs before a document is duplicated to prevent errors in unique fields or return null to use defaultValue.
* If a node includes sub-fields (e.g. block and link nodes), passing those subFields here will make payload
* automatically populate & run hooks for them
*/
beforeDuplicate?: Array<FieldNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
beforeValidate?: Array<FieldNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
}
node: Klass<T> | LexicalNodeReplacement
populationPromises?: Array<
getSubFields?: (args: {
node: ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>
req: PayloadRequestWithData
}) => Field[] | null
getSubFieldsData?: (args: {
node: ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>
req: PayloadRequestWithData
}) => Record<string, unknown>
graphQLPopulationPromises?: Array<
PopulationPromise<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
>
hooks?: {
afterChange?: Array<AfterChangeNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
afterRead?: Array<AfterReadNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
beforeChange?: Array<BeforeChangeNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
beforeValidate?: Array<
BeforeValidateNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>
>
}
node: Klass<T> | LexicalNodeReplacement
validations?: Array<NodeValidation<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
}
@@ -451,17 +544,21 @@ export type SanitizedServerFeatures = Required<
}
/** The node types mapped to their hooks */
getSubFields?: Map<
string,
(args: { node: SerializedLexicalNode; req: PayloadRequestWithData }) => Field[] | null
>
getSubFieldsData?: Map<
string,
(args: { node: SerializedLexicalNode; req: PayloadRequestWithData }) => Record<string, unknown>
>
graphQLPopulationPromises: Map<string, Array<PopulationPromise>>
hooks?: {
afterChange?: Map<string, Array<FieldNodeHook<SerializedLexicalNode>>>
afterRead?: Map<string, Array<FieldNodeHook<SerializedLexicalNode>>>
beforeChange?: Map<string, Array<FieldNodeHook<SerializedLexicalNode>>>
/**
* Runs before a document is duplicated to prevent errors in unique fields or return null to use defaultValue.
*/
beforeDuplicate?: Map<string, Array<FieldNodeHook<SerializedLexicalNode>>>
beforeValidate?: Map<string, Array<FieldNodeHook<SerializedLexicalNode>>>
afterChange?: Map<string, Array<AfterChangeNodeHook<SerializedLexicalNode>>>
afterRead?: Map<string, Array<AfterReadNodeHook<SerializedLexicalNode>>>
beforeChange?: Map<string, Array<BeforeChangeNodeHook<SerializedLexicalNode>>>
beforeValidate?: Map<string, Array<BeforeValidateNodeHook<SerializedLexicalNode>>>
} /** The node types mapped to their populationPromises */
populationPromises: Map<string, Array<PopulationPromise>>
/** The node types mapped to their validations */
validations: Map<string, Array<NodeValidation>>
}

View File

@@ -11,8 +11,6 @@ import { $createUploadNode } from '../nodes/UploadNode.js'
import { INSERT_UPLOAD_COMMAND } from '../plugin/index.js'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './commands.js'
const baseClass = 'lexical-upload-drawer'
const insertUpload = ({
editor,
relationTo,

View File

@@ -7,11 +7,12 @@ import { sanitizeFields } from 'payload/config'
import type { FeatureProviderProviderServer } from '../types.js'
import type { UploadFeaturePropsClient } from './feature.client.js'
import { populate } from '../../../populateGraphQL/populate.js'
import { createNode } from '../typeUtilities.js'
import { UploadFeatureClientComponent } from './feature.client.js'
import { uploadPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { UploadNode } from './nodes/UploadNode.js'
import { uploadPopulationPromiseHOC } from './populationPromise.js'
import { uploadValidation } from './validate.js'
export type UploadFeatureProps = {
@@ -177,8 +178,73 @@ export const UploadFeature: FeatureProviderProviderServer<
nodeTypes: [UploadNode.getType()],
},
},
getSubFields: ({ node, req }) => {
const collection = req.payload.collections[node?.relationTo]
if (collection) {
const collectionFieldSchema = props?.collections?.[node?.relationTo]?.fields
if (Array.isArray(collectionFieldSchema)) {
if (!collectionFieldSchema?.length) {
return null
}
return collectionFieldSchema
}
}
return null
},
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [uploadPopulationPromiseHOC(props)],
hooks: {
afterRead: [
({
currentDepth,
depth,
draft,
node,
overrideAccess,
populationPromises,
req,
showHiddenFields,
}) => {
if (!node?.value) {
return node
}
const collection = req.payload.collections[node?.relationTo]
if (!collection) {
return node
}
// @ts-expect-error
const id = node?.value?.id || node?.value // for backwards-compatibility
const populateDepth =
props?.maxDepth !== undefined && props?.maxDepth < depth
? props?.maxDepth
: depth
populationPromises.push(
populate({
id,
collection,
currentDepth,
data: node,
depth: populateDepth,
draft,
key: 'value',
overrideAccess,
req,
showHiddenFields,
}),
)
return node
},
],
},
node: UploadNode,
populationPromises: [uploadPopulationPromiseHOC(props)],
validations: [uploadValidation(props)],
}),
],

View File

@@ -2,8 +2,8 @@ import type { PopulationPromise } from '../types.js'
import type { UploadFeatureProps } from './feature.server.js'
import type { SerializedUploadNode } from './nodes/UploadNode.js'
import { populate } from '../../../populate/populate.js'
import { recurseNestedFields } from '../../../populate/recurseNestedFields.js'
import { populate } from '../../../populateGraphQL/populate.js'
import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const uploadPopulationPromiseHOC = (
props?: UploadFeatureProps,
@@ -14,7 +14,6 @@ export const uploadPopulationPromiseHOC = (
depth,
draft,
editorPopulationPromises,
field,
fieldPromises,
findMany,
flattenLocales,
@@ -42,19 +41,20 @@ export const uploadPopulationPromiseHOC = (
data: node,
depth: populateDepth,
draft,
field,
key: 'value',
overrideAccess,
req,
showHiddenFields,
}),
)
}
if (Array.isArray(props?.collections?.[node?.relationTo]?.fields)) {
if (!props?.collections?.[node?.relationTo]?.fields?.length) {
const collectionFieldSchema = props?.collections?.[node?.relationTo]?.fields
if (Array.isArray(collectionFieldSchema)) {
if (!collectionFieldSchema?.length) {
return
}
recurseNestedFields({
recursivelyPopulateFieldsForGraphQL({
context,
currentDepth,
data: node.fields || {},
@@ -62,9 +62,9 @@ export const uploadPopulationPromiseHOC = (
draft,
editorPopulationPromises,
fieldPromises,
fields: props?.collections?.[node?.relationTo]?.fields,
fields: collectionFieldSchema,
findMany,
flattenLocales: false, // Disable localization handling which does not work properly yet. Once we fully support hooks, this can be enabled (pass through flattenLocales again)
flattenLocales,
overrideAccess,
populationPromises,
req,
@@ -75,3 +75,4 @@ export const uploadPopulationPromiseHOC = (
}
}
}
}

View File

@@ -10,6 +10,7 @@ import type {
import type { JSX } from 'react'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
import ObjectID from 'bson-objectid'
import { $applyNodeReplacement } from 'lexical'
import * as React from 'react'
@@ -22,6 +23,7 @@ export type UploadData = {
// unknown, custom fields:
[key: string]: unknown
}
id: string
relationTo: string
value: number | string
}
@@ -106,8 +108,13 @@ export class UploadNode extends DecoratorBlockNode {
if (serializedNode.version === 1 && (serializedNode?.value as unknown as { id: string })?.id) {
serializedNode.value = (serializedNode.value as unknown as { id: string }).id
}
if (serializedNode.version === 2 && !serializedNode?.id) {
serializedNode.id = new ObjectID.default().toHexString()
serializedNode.version = 3
}
const importedData: UploadData = {
id: serializedNode.id,
fields: serializedNode.fields,
relationTo: serializedNode.relationTo,
value: serializedNode.value,
@@ -141,7 +148,7 @@ export class UploadNode extends DecoratorBlockNode {
...super.exportJSON(),
...this.getData(),
type: this.getType(),
version: 2,
version: 3,
}
}
@@ -160,8 +167,15 @@ export class UploadNode extends DecoratorBlockNode {
}
}
export function $createUploadNode({ data }: { data: UploadData }): UploadNode {
return $applyNodeReplacement(new UploadNode({ data }))
export function $createUploadNode({
data,
}: {
data: Omit<UploadData, 'id'> & Partial<Pick<UploadData, 'id'>>
}): UploadNode {
if (!data?.id) {
data.id = new ObjectID.default().toHexString()
}
return $applyNodeReplacement(new UploadNode({ data: data as UploadData }))
}
export function $isUploadNode(node: LexicalNode | null | undefined): node is UploadNode {

View File

@@ -21,7 +21,7 @@ import type { UploadData } from '../nodes/UploadNode.js'
import { UploadDrawer } from '../drawer/index.js'
import { $createUploadNode, UploadNode } from '../nodes/UploadNode.js'
export type InsertUploadPayload = Readonly<UploadData>
export type InsertUploadPayload = Readonly<Omit<UploadData, 'id'> & Partial<Pick<UploadData, 'id'>>>
export const INSERT_UPLOAD_COMMAND: LexicalCommand<InsertUploadPayload> =
createCommand('INSERT_UPLOAD_COMMAND')
@@ -47,6 +47,7 @@ export const UploadPlugin: PluginComponentWithAnchor<UploadFeaturePropsClient> =
if ($isRangeSelection(selection)) {
const uploadNode = $createUploadNode({
data: {
id: payload.id,
fields: payload.fields,
relationTo: payload.relationTo,
value: payload.value,

View File

@@ -1,4 +1,4 @@
import type { Config, SanitizedConfig } from 'payload/config'
import type { SanitizedConfig } from 'payload/config'
import type {
FeatureProviderServer,

View File

@@ -1,4 +1,4 @@
import type { Config, SanitizedConfig } from 'payload/config'
import type { SanitizedConfig } from 'payload/config'
import type { ResolvedServerFeatureMap, SanitizedServerFeatures } from '../../../features/types.js'
import type { SanitizedServerEditorConfig, ServerEditorConfig } from '../types.js'
@@ -16,18 +16,18 @@ export const sanitizeServerFeatures = (
generatedTypes: {
modifyOutputSchemas: [],
},
getSubFields: new Map(),
getSubFieldsData: new Map(),
graphQLPopulationPromises: new Map(),
hooks: {
afterChange: new Map(),
afterRead: new Map(),
beforeChange: new Map(),
beforeDuplicate: new Map(),
beforeValidate: new Map(),
},
i18n: {},
markdownTransformers: [],
nodes: [],
populationPromises: new Map(),
validations: new Map(),
}
@@ -56,8 +56,8 @@ export const sanitizeServerFeatures = (
sanitized.nodes = sanitized.nodes.concat(feature.nodes)
feature.nodes.forEach((node) => {
const nodeType = 'with' in node.node ? node.node.replace.getType() : node.node.getType() // TODO: Idk if this works for node replacements
if (node?.populationPromises?.length) {
sanitized.populationPromises.set(nodeType, node.populationPromises)
if (node?.graphQLPopulationPromises?.length) {
sanitized.graphQLPopulationPromises.set(nodeType, node.graphQLPopulationPromises)
}
if (node?.validations?.length) {
sanitized.validations.set(nodeType, node.validations)
@@ -74,12 +74,15 @@ export const sanitizeServerFeatures = (
if (node?.hooks?.beforeChange) {
sanitized.hooks.beforeChange.set(nodeType, node.hooks.beforeChange)
}
if (node?.hooks?.beforeDuplicate) {
sanitized.hooks.beforeDuplicate.set(nodeType, node.hooks.beforeDuplicate)
}
if (node?.hooks?.beforeValidate) {
sanitized.hooks.beforeValidate.set(nodeType, node.hooks.beforeValidate)
}
if (node?.getSubFields) {
sanitized.getSubFields.set(nodeType, node.getSubFields)
}
if (node?.getSubFieldsData) {
sanitized.getSubFieldsData.set(nodeType, node.getSubFieldsData)
}
})
}

View File

@@ -1,8 +1,18 @@
import type { JSONSchema4 } from 'json-schema'
import type { EditorConfig as LexicalEditorConfig } from 'lexical'
import type {
EditorConfig as LexicalEditorConfig,
SerializedEditorState,
SerializedLexicalNode,
} from 'lexical'
import { withMergedProps } from '@payloadcms/ui/elements/withMergedProps'
import { withNullableJSONSchemaType } from 'payload/utilities'
import {
afterChangeTraverseFields,
afterReadTraverseFields,
beforeChangeTraverseFields,
beforeValidateTraverseFields,
withNullableJSONSchemaType,
} from 'payload/utilities'
import type { FeatureProviderServer, ResolvedServerFeatureMap } from './field/features/types.js'
import type { SanitizedServerEditorConfig } from './field/lexical/config/types.js'
@@ -28,7 +38,8 @@ import { cloneDeep } from './field/lexical/utils/cloneDeep.js'
import { getGenerateComponentMap } from './generateComponentMap.js'
import { getGenerateSchemaMap } from './generateSchemaMap.js'
import { i18n } from './i18n.js'
import { populateLexicalPopulationPromises } from './populate/populateLexicalPopulationPromises.js'
import { populateLexicalPopulationPromises } from './populateGraphQL/populateLexicalPopulationPromises.js'
import { recurseNodeTree } from './recurseNodeTree.js'
import { richTextValidateHOC } from './validate/index.js'
let defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = null
@@ -121,105 +132,560 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
generateSchemaMap: getGenerateSchemaMap({
resolvedFeatureMap,
}),
i18n: featureI18n,
/* hooks: {
afterChange: finalSanitizedEditorConfig.features.hooks.afterChange,
afterRead: finalSanitizedEditorConfig.features.hooks.afterRead,
beforeChange: finalSanitizedEditorConfig.features.hooks.beforeChange,
beforeDuplicate: finalSanitizedEditorConfig.features.hooks.beforeDuplicate,
beforeValidate: finalSanitizedEditorConfig.features.hooks.beforeValidate,
},*/
/* // TODO: Figure out docWithLocales / originalSiblingDoc => node matching. Can't use indexes, as the order of nodes could technically change between hooks.
hooks: {
afterChange: [
async ({ context, findMany, operation, overrideAccess, req, value }) => {
await recurseNodesAsync({
callback: async (node) => {
const afterChangeHooks = finalSanitizedEditorConfig.features.hooks.afterChange
if (afterChangeHooks?.has(node.type)) {
for (const hook of afterChangeHooks.get(node.type)) {
node = await hook({ context, findMany, node, operation, overrideAccess, req })
}
graphQLPopulationPromises({
context,
currentDepth,
depth,
draft,
field,
fieldPromises,
findMany,
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
}) {
// check if there are any features with nodes which have populationPromises for this field
if (finalSanitizedEditorConfig?.features?.graphQLPopulationPromises?.size) {
populateLexicalPopulationPromises({
context,
currentDepth: currentDepth ?? 0,
depth,
draft,
editorPopulationPromises: finalSanitizedEditorConfig.features.graphQLPopulationPromises,
field,
fieldPromises,
findMany,
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
})
}
},
hooks: {
afterChange: [
async ({
collection,
context: _context,
global,
operation,
path,
req,
schemaPath,
value,
}) => {
if (
!finalSanitizedEditorConfig.features.hooks.afterChange.size &&
!finalSanitizedEditorConfig.features.getSubFields.size
) {
return value
}
const context: any = _context
const nodeIDMap: {
[key: string]: SerializedLexicalNode
} = {}
/**
* Get the originalNodeIDMap from the beforeValidate hook, which is always run before this hook.
*/
const originalNodeIDMap: {
[key: string]: SerializedLexicalNode
} = context?.internal?.richText?.[path.join('.')]?.originalNodeIDMap
if (!originalNodeIDMap || !Object.keys(originalNodeIDMap).length || !value) {
return value
}
recurseNodeTree({
nodeIDMap,
nodes: (value as SerializedEditorState)?.root?.children ?? [],
})
// eslint-disable-next-line prefer-const
for (let [id, node] of Object.entries(nodeIDMap)) {
const afterChangeHooks = finalSanitizedEditorConfig.features.hooks.afterChange
if (afterChangeHooks?.has(node.type)) {
for (const hook of afterChangeHooks.get(node.type)) {
if (!originalNodeIDMap[id]) {
console.warn(
'(afterChange) No original node found for node with id',
id,
'node:',
node,
'path',
path.join('.'),
)
continue
}
node = await hook({
context,
node,
operation,
originalNode: originalNodeIDMap[id],
parentRichTextFieldPath: path,
parentRichTextFieldSchemaPath: schemaPath,
req,
})
}
}
const subFieldFn = finalSanitizedEditorConfig.features.getSubFields.get(node.type)
const subFieldDataFn = finalSanitizedEditorConfig.features.getSubFieldsData.get(
node.type,
)
if (subFieldFn) {
const subFields = subFieldFn({ node, req })
const data = subFieldDataFn({ node, req })
const originalData = subFieldDataFn({ node: originalNodeIDMap[id], req })
if (subFields?.length) {
await afterChangeTraverseFields({
collection,
context,
data: originalData,
doc: data,
fields: subFields,
global,
operation,
path,
previousDoc: data,
previousSiblingDoc: { ...data },
req,
schemaPath,
siblingData: originalData || {},
siblingDoc: { ...data },
})
}
}
}
return value
},
],
afterRead: [
async ({ context, findMany, operation, overrideAccess, req, value }) => {
await recurseNodesAsync({
callback: async (node) => {
/**
* afterRead hooks do not receive the originalNode. Thus, they can run on all nodes, not just nodes with an ID.
*/
async ({
collection,
context: context,
currentDepth,
depth,
draft,
fallbackLocale,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path,
populationPromises,
req,
schemaPath,
showHiddenFields,
triggerAccessControl,
triggerHooks,
value,
}) => {
if (
!finalSanitizedEditorConfig.features.hooks.afterRead.size &&
!finalSanitizedEditorConfig.features.getSubFields.size
) {
return value
}
const flattenedNodes: SerializedLexicalNode[] = []
recurseNodeTree({
flattenedNodes,
nodes: (value as SerializedEditorState)?.root?.children ?? [],
})
for (let node of flattenedNodes) {
const afterReadHooks = finalSanitizedEditorConfig.features.hooks.afterRead
if (afterReadHooks?.has(node.type)) {
for (const hook of afterReadHooks.get(node.type)) {
node = await hook({ context, findMany, node, operation, overrideAccess, req })
}
}
},
nodes: (value as SerializedEditorState)?.root?.children ?? [],
node = await hook({
context,
currentDepth,
depth,
draft,
fallbackLocale,
fieldPromises,
findMany,
flattenLocales,
locale,
node,
overrideAccess,
parentRichTextFieldPath: path,
parentRichTextFieldSchemaPath: schemaPath,
populationPromises,
req,
showHiddenFields,
triggerAccessControl,
triggerHooks,
})
}
}
const subFieldFn = finalSanitizedEditorConfig.features.getSubFields.get(node.type)
const subFieldDataFn = finalSanitizedEditorConfig.features.getSubFieldsData.get(
node.type,
)
if (subFieldFn) {
const subFields = subFieldFn({ node, req })
const data = subFieldDataFn({ node, req })
if (subFields?.length) {
afterReadTraverseFields({
collection,
context,
currentDepth,
depth,
doc: data,
draft,
fallbackLocale,
fieldPromises,
fields: subFields,
findMany,
flattenLocales,
global,
locale,
overrideAccess,
path,
populationPromises,
req,
schemaPath,
showHiddenFields,
siblingDoc: data,
triggerAccessControl,
triggerHooks,
})
}
}
}
return value
},
],
beforeChange: [
async ({ context, findMany, operation, overrideAccess, req, value }) => {
await recurseNodesAsync({
callback: async (node) => {
const beforeChangeHooks = finalSanitizedEditorConfig.features.hooks.beforeChange
if (beforeChangeHooks?.has(node.type)) {
for (const hook of beforeChangeHooks.get(node.type)) {
node = await hook({ context, findMany, node, operation, overrideAccess, req })
async ({
collection,
context: _context,
duplicate,
errors,
field,
global,
mergeLocaleActions,
operation,
path,
req,
schemaPath,
siblingData,
siblingDocWithLocales,
skipValidation,
value,
}) => {
if (
!finalSanitizedEditorConfig.features.hooks.beforeChange.size &&
!finalSanitizedEditorConfig.features.getSubFields.size
) {
return value
}
const context: any = _context
const nodeIDMap: {
[key: string]: SerializedLexicalNode
} = {}
/**
* Get the originalNodeIDMap from the beforeValidate hook, which is always run before this hook.
*/
const originalNodeIDMap: {
[key: string]: SerializedLexicalNode
} = context?.internal?.richText?.[path.join('.')]?.originalNodeIDMap
if (!originalNodeIDMap || !Object.keys(originalNodeIDMap).length || !value) {
return value
}
},
const originalNodeWithLocalesIDMap: {
[key: string]: SerializedLexicalNode
} = {}
recurseNodeTree({
nodeIDMap,
nodes: (value as SerializedEditorState)?.root?.children ?? [],
})
return value
},
],
beforeDuplicate: [
async ({ context, findMany, operation, overrideAccess, req, value }) => {
await recurseNodesAsync({
callback: async (node) => {
const beforeDuplicateHooks = finalSanitizedEditorConfig.features.hooks.beforeDuplicate
if (beforeDuplicateHooks?.has(node.type)) {
for (const hook of beforeDuplicateHooks.get(node.type)) {
node = await hook({ context, findMany, node, operation, overrideAccess, req })
}
}
},
nodes: (value as SerializedEditorState)?.root?.children ?? [],
if (siblingDocWithLocales?.[field.name]) {
recurseNodeTree({
nodeIDMap: originalNodeWithLocalesIDMap,
nodes:
(siblingDocWithLocales[field.name] as SerializedEditorState)?.root?.children ??
[],
})
}
// eslint-disable-next-line prefer-const
for (let [id, node] of Object.entries(nodeIDMap)) {
const beforeChangeHooks = finalSanitizedEditorConfig.features.hooks.beforeChange
if (beforeChangeHooks?.has(node.type)) {
for (const hook of beforeChangeHooks.get(node.type)) {
if (!originalNodeIDMap[id]) {
console.warn(
'(beforeChange) No original node found for node with id',
id,
'node:',
node,
'path',
path.join('.'),
)
continue
}
node = await hook({
context,
duplicate,
errors,
mergeLocaleActions,
node,
operation,
originalNode: originalNodeIDMap[id],
originalNodeWithLocales: originalNodeWithLocalesIDMap[id],
parentRichTextFieldPath: path,
parentRichTextFieldSchemaPath: schemaPath,
req,
skipValidation,
})
}
}
const subFieldFn = finalSanitizedEditorConfig.features.getSubFields.get(node.type)
const subFieldDataFn = finalSanitizedEditorConfig.features.getSubFieldsData.get(
node.type,
)
if (subFieldFn) {
const subFields = subFieldFn({ node, req })
const data = subFieldDataFn({ node, req })
const originalData = subFieldDataFn({ node: originalNodeIDMap[id], req })
const originalDataWithLocales = subFieldDataFn({
node: originalNodeWithLocalesIDMap[id],
req,
})
if (subFields?.length) {
await beforeChangeTraverseFields({
id,
collection,
context,
data,
doc: originalData,
docWithLocales: originalDataWithLocales ?? {},
duplicate,
errors,
fields: subFields,
global,
mergeLocaleActions,
operation,
path,
req,
schemaPath,
siblingData: data,
siblingDoc: originalData,
siblingDocWithLocales: originalDataWithLocales ?? {},
skipValidation,
})
}
}
}
/**
* within the beforeChange hook, id's may be re-generated.
* Example:
* 1. Seed data contains IDs for block feature blocks.
* 2. Those are used in beforeValidate
* 3. in beforeChange, those IDs are regenerated, because you cannot provide IDs during document creation. See baseIDField beforeChange hook for reasoning
* 4. Thus, in order for all post-beforeChange hooks to receive the correct ID, we need to update the originalNodeIDMap with the new ID's, by regenerating the nodeIDMap.
* The reason this is not generated for every hook, is to save on performance. We know we only really have to generate it in beforeValidate, which is the first hook,
* and in beforeChange, which is where modifications to the provided IDs can occur.
*/
const newOriginalNodeIDMap: {
[key: string]: SerializedLexicalNode
} = {}
const previousValue = siblingData[field.name]
recurseNodeTree({
nodeIDMap: newOriginalNodeIDMap,
nodes: (previousValue as SerializedEditorState)?.root?.children ?? [],
})
if (!context.internal) {
// Add to context, for other hooks to use
context.internal = {}
}
if (!context.internal.richText) {
context.internal.richText = {}
}
context.internal.richText[path.join('.')] = {
originalNodeIDMap: newOriginalNodeIDMap,
}
return value
},
],
beforeValidate: [
async ({ context, findMany, operation, overrideAccess, req, value }) => {
await recurseNodesAsync({
callback: async (node) => {
async ({
collection,
context,
global,
operation,
overrideAccess,
path,
previousValue,
req,
schemaPath,
value,
}) => {
// return value if there are NO hooks
if (
!finalSanitizedEditorConfig.features.hooks.beforeValidate.size &&
!finalSanitizedEditorConfig.features.hooks.afterChange.size &&
!finalSanitizedEditorConfig.features.hooks.beforeChange.size &&
!finalSanitizedEditorConfig.features.getSubFields.size
) {
return value
}
/**
* beforeValidate is the first field hook which runs. This is where we can create the node map, which can then be used in the other hooks.
*
*/
/**
* flattenedNodes contains all nodes in the editor, in the order they appear in the editor. They will be used for the following hooks:
* - afterRead
*
* The other hooks require nodes to have IDs, which is why those are ran only from the nodeIDMap. They require IDs because they have both doc/siblingDoc and data/siblingData, and
* thus require a reliable way to match new node data to old node data. Given that node positions can change in between hooks, this is only reliably possible for nodes which are saved with
* an ID.
*/
//const flattenedNodes: SerializedLexicalNode[] = []
/**
* Only nodes with id's (so, nodes with hooks added to them) will be added to the nodeIDMap. They will be used for the following hooks:
* - afterChange
* - beforeChange
* - beforeValidate
* - beforeDuplicate
*
* Other hooks are handled by the flattenedNodes. All nodes in the nodeIDMap are part of flattenedNodes.
*/
const originalNodeIDMap: {
[key: string]: SerializedLexicalNode
} = {}
recurseNodeTree({
nodeIDMap: originalNodeIDMap,
nodes: (previousValue as SerializedEditorState)?.root?.children ?? [],
})
if (!context.internal) {
// Add to context, for other hooks to use
context.internal = {}
}
if (!(context as any).internal.richText) {
;(context as any).internal.richText = {}
}
;(context as any).internal.richText[path.join('.')] = {
originalNodeIDMap,
}
/**
* Now that the maps for all hooks are set up, we can run the validate hook
*/
if (!finalSanitizedEditorConfig.features.hooks.beforeValidate.size) {
return value
}
const nodeIDMap: {
[key: string]: SerializedLexicalNode
} = {}
recurseNodeTree({
//flattenedNodes,
nodeIDMap,
nodes: (value as SerializedEditorState)?.root?.children ?? [],
})
// eslint-disable-next-line prefer-const
for (let [id, node] of Object.entries(nodeIDMap)) {
const beforeValidateHooks = finalSanitizedEditorConfig.features.hooks.beforeValidate
if (beforeValidateHooks?.has(node.type)) {
for (const hook of beforeValidateHooks.get(node.type)) {
/**
* We cannot pass the originalNode here, as there is no way to map one node to a previous one, as a previous originalNode might be in a different position
*/ /*
node = await hook({ context, findMany, node, operation, overrideAccess, req })
if (!originalNodeIDMap[id]) {
console.warn(
'(beforeValidate) No original node found for node with id',
id,
'node:',
node,
'path',
path.join('.'),
)
continue
}
}
},
nodes: (value as SerializedEditorState)?.root?.children ?? [],
node = await hook({
context,
node,
operation,
originalNode: originalNodeIDMap[id],
overrideAccess,
parentRichTextFieldPath: path,
parentRichTextFieldSchemaPath: schemaPath,
req,
})
}
}
const subFieldFn = finalSanitizedEditorConfig.features.getSubFields.get(node.type)
const subFieldDataFn = finalSanitizedEditorConfig.features.getSubFieldsData.get(
node.type,
)
if (subFieldFn) {
const subFields = subFieldFn({ node, req })
const data = subFieldDataFn({ node, req })
const originalData = subFieldDataFn({ node: originalNodeIDMap[id], req })
if (subFields?.length) {
await beforeValidateTraverseFields({
id,
collection,
context,
data,
doc: originalData,
fields: subFields,
global,
operation,
overrideAccess,
path,
req,
schemaPath,
siblingData: data,
siblingDoc: originalData,
})
}
}
}
return value
},
],
},*/
},
i18n: featureI18n,
outputSchema: ({
collectionIDFieldTypes,
config,
@@ -297,41 +763,6 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
return outputSchema
},
populationPromises({
context,
currentDepth,
depth,
draft,
field,
fieldPromises,
findMany,
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
}) {
// check if there are any features with nodes which have populationPromises for this field
if (finalSanitizedEditorConfig?.features?.populationPromises?.size) {
populateLexicalPopulationPromises({
context,
currentDepth,
depth,
draft,
editorPopulationPromises: finalSanitizedEditorConfig.features.populationPromises,
field,
fieldPromises,
findMany,
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
})
}
},
validate: richTextValidateHOC({
editorConfig: finalSanitizedEditorConfig,
}),
@@ -452,6 +883,15 @@ export { InlineToolbarFeature } from './field/features/toolbars/inline/feature.s
export type { ToolbarGroup, ToolbarGroupItem } from './field/features/toolbars/types.js'
export { createNode } from './field/features/typeUtilities.js'
export type {
AfterChangeNodeHook,
AfterChangeNodeHookArgs,
AfterReadNodeHook,
AfterReadNodeHookArgs,
BaseNodeHookArgs,
BeforeChangeNodeHook,
BeforeChangeNodeHookArgs,
BeforeValidateNodeHook,
BeforeValidateNodeHookArgs,
ClientComponentProps,
ClientFeature,
ClientFeatureProviderMap,
@@ -459,8 +899,6 @@ export type {
FeatureProviderProviderClient,
FeatureProviderProviderServer,
FeatureProviderServer,
FieldNodeHook,
FieldNodeHookArgs,
NodeValidation,
NodeWithHooks,
PluginComponent,
@@ -559,6 +997,6 @@ export {
addSwipeUpListener,
} from './field/lexical/utils/swipe.js'
export { sanitizeUrl, validateUrl } from './field/lexical/utils/url.js'
export { defaultRichTextValue } from './populate/defaultValue.js'
export { defaultRichTextValue } from './populateGraphQL/defaultValue.js'
export type { LexicalEditorProps, LexicalRichTextAdapter } from './types.js'

View File

@@ -1,19 +1,15 @@
import type { SerializedEditorState } from 'lexical'
import type { PayloadRequestWithData } from 'payload/types'
import type { Collection, Field, RichTextField } from 'payload/types'
import type { Collection } from 'payload/types'
import { createDataloaderCacheKey } from 'payload/utilities'
import type { AdapterProps } from '../types.js'
type Arguments = {
currentDepth?: number
data: unknown
depth: number
draft: boolean
field: RichTextField<SerializedEditorState, AdapterProps>
key: number | string
overrideAccess?: boolean
overrideAccess: boolean
req: PayloadRequestWithData
showHiddenFields: boolean
}
@@ -29,11 +25,16 @@ export const populate = async ({
overrideAccess,
req,
showHiddenFields,
}: Omit<Arguments, 'field'> & {
}: Arguments & {
collection: Collection
field: Field
id: number | string
}): Promise<void> => {
const shouldPopulate = depth && currentDepth <= depth
// usually depth is checked within recursivelyPopulateFieldsForGraphQL. But since this populate function can be called outside of that (in rest afterRead node hooks) we need to check here too
if (!shouldPopulate) {
return
}
const dataRef = data as Record<string, unknown>
const doc = await req.payloadDataLoader.load(
@@ -45,7 +46,7 @@ export const populate = async ({
draft,
fallbackLocale: req.fallbackLocale,
locale: req.locale,
overrideAccess: typeof overrideAccess === 'undefined' ? false : overrideAccess,
overrideAccess,
showHiddenFields,
transactionID: req.transactionID,
}),

View File

@@ -7,7 +7,7 @@ import type { AdapterProps } from '../types.js'
import { recurseNodes } from '../forEachNodeRecursively.js'
export type Args = Parameters<
RichTextAdapter<SerializedEditorState, AdapterProps>['populationPromises']
RichTextAdapter<SerializedEditorState, AdapterProps>['graphQLPopulationPromises']
>[0] & {
editorPopulationPromises: Map<string, Array<PopulationPromise>>
}
@@ -31,7 +31,9 @@ export const populateLexicalPopulationPromises = ({
showHiddenFields,
siblingDoc,
}: Args) => {
if (depth <= 0 || currentDepth > depth) {
const shouldPopulate = depth && currentDepth <= depth
if (!shouldPopulate) {
return
}

View File

@@ -29,7 +29,7 @@ type NestedRichTextFieldsArgs = {
siblingDoc: Record<string, unknown>
}
export const recurseNestedFields = ({
export const recursivelyPopulateFieldsForGraphQL = ({
context,
currentDepth = 0,
data,
@@ -60,11 +60,12 @@ export const recurseNestedFields = ({
global: null, // Pass from core? This is only needed for hooks, so we can leave this null for now
locale: req.locale,
overrideAccess,
path: [],
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,
//triggerAccessControl: false, // TODO: Enable this to support access control
//triggerHooks: false, // TODO: Enable this to support hooks
triggerHooks: false,
})
}

View File

@@ -0,0 +1,45 @@
import type { SerializedLexicalNode } from 'lexical'
// Initialize both flattenedNodes and nodeIDMap
export const recurseNodeTree = ({
flattenedNodes,
nodeIDMap,
nodes,
}: {
flattenedNodes?: SerializedLexicalNode[]
nodeIDMap?: {
[key: string]: SerializedLexicalNode
}
nodes: SerializedLexicalNode[]
}): void => {
if (!nodes?.length) {
return
}
for (const node of nodes) {
if (flattenedNodes) {
flattenedNodes.push(node)
}
if (nodeIDMap) {
if (node && 'id' in node && node.id) {
nodeIDMap[node.id as string] = node
} else if (
'fields' in node &&
typeof node.fields === 'object' &&
node.fields &&
'id' in node.fields &&
node?.fields?.id
) {
nodeIDMap[node.fields.id as string] = node
}
}
if ('children' in node && Array.isArray(node?.children) && node?.children?.length) {
recurseNodeTree({
flattenedNodes,
nodeIDMap,
nodes: node.children as SerializedLexicalNode[],
})
}
}
}

View File

@@ -5,7 +5,9 @@ import type { AdapterArguments } from '../types.js'
import { populate } from './populate.js'
import { recurseNestedFields } from './recurseNestedFields.js'
export type Args = Parameters<RichTextAdapter<any[], AdapterArguments>['populationPromises']>[0]
export type Args = Parameters<
RichTextAdapter<any[], AdapterArguments>['graphQLPopulationPromises']
>[0]
type RecurseRichTextArgs = {
children: unknown[]

View File

@@ -52,15 +52,7 @@ export function slateEditor(
FieldComponent: RichTextField,
generateComponentMap: getGenerateComponentMap(args),
generateSchemaMap: getGenerateSchemaMap(args),
outputSchema: ({ isRequired }) => {
return {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'object',
},
}
},
populationPromises({
graphQLPopulationPromises({
context,
currentDepth,
depth,
@@ -98,6 +90,58 @@ export function slateEditor(
})
}
},
hooks: {
afterRead: [
({
context: _context,
currentDepth,
depth,
draft,
field: _field,
fieldPromises,
findMany,
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingData,
}) => {
const context: any = _context
const field = _field as any
if (
field.admin?.elements?.includes('relationship') ||
field.admin?.elements?.includes('upload') ||
field.admin?.elements?.includes('link') ||
!field?.admin?.elements
) {
richTextRelationshipPromise({
context,
currentDepth,
depth,
draft,
field,
fieldPromises,
findMany,
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc: siblingData,
})
}
},
],
},
outputSchema: ({ isRequired }) => {
return {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'object',
},
}
},
validate: richTextValidate,
}
}

View File

@@ -920,10 +920,9 @@ describe('lexicalBlocks', () => {
await wait(300)
await page.click('#action-save', { delay: 100 })
await wait(300)
await expect(page.locator('.payload-toast-container')).toContainText(
'The following field is invalid',
'The following fields are invalid',
)
await wait(300)

View File

@@ -28,6 +28,7 @@ export function generateLexicalRichText() {
format: '',
type: 'upload',
version: 2,
id: '665d105a91e1c337ba8308dd',
fields: {
caption: {
root: {

View File

@@ -0,0 +1,44 @@
export function generateLexicalLocalizedRichText(text1: string, text2: string, blockID?: string) {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: text1,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: blockID ?? '66685716795f191f08367b1a',
blockName: '',
textLocalized: text2,
counter: 1,
blockType: 'block',
},
},
],
direction: 'ltr',
},
}
}

View File

@@ -21,11 +21,50 @@ export const LexicalLocalizedFields: CollectionConfig = {
localized: true,
},
{
name: 'lexicalSimple',
name: 'lexicalBlocksSubLocalized',
type: 'richText',
localized: true,
admin: {
description: 'Non-localized field with localized block subfields',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
{
slug: 'block',
fields: [
{
name: 'textLocalized',
type: 'text',
localized: true,
},
{
name: 'counter',
type: 'number',
hooks: {
beforeChange: [
({ value }) => {
return value ? value + 1 : 1
},
],
afterRead: [
({ value }) => {
return value ? value * 10 : 10
},
],
},
},
{
name: 'rel',
type: 'relationship',
relationTo: lexicalLocalizedFieldsSlug,
},
],
},
],
}),
],
}),
},
{
@@ -60,36 +99,5 @@ export const LexicalLocalizedFields: CollectionConfig = {
],
}),
},
{
name: 'lexicalBlocksSubLocalized',
type: 'richText',
admin: {
description: 'Non-localized field with localized block subfields',
},
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({
blocks: [
{
slug: 'block',
fields: [
{
name: 'textLocalized',
type: 'text',
localized: true,
},
{
name: 'rel',
type: 'relationship',
relationTo: lexicalLocalizedFieldsSlug,
},
],
},
],
}),
],
}),
},
],
}

View File

@@ -54,6 +54,7 @@ export function generateLexicalRichText() {
direction: 'ltr',
format: '',
indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link',
version: 2,
fields: {
@@ -86,6 +87,7 @@ export function generateLexicalRichText() {
direction: 'ltr',
format: '',
indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link',
version: 2,
fields: {
@@ -230,6 +232,7 @@ export function generateLexicalRichText() {
format: '',
type: 'upload',
version: 2,
id: '665d10938106ab380c7f372f',
relationTo: 'uploads',
value: '{{UPLOAD_DOC_ID}}',
fields: {

View File

@@ -14,6 +14,8 @@ import { devUser } from '../credentials.js'
import { NextRESTClient } from '../helpers/NextRESTClient.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { lexicalDocData } from './collections/Lexical/data.js'
import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { richTextDocData } from './collections/RichText/data.js'
import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText.js'
@@ -569,4 +571,100 @@ describe('Lexical', () => {
expect(populatedDocEditorRelationshipNode.value.text).toStrictEqual(textDoc.text)
})
})
describe('Localization', () => {
it('ensure localized lexical field is different across locales', async () => {
const lexicalDocEN = await payload.find({
collection: 'lexical-localized-fields',
locale: 'en',
where: {
title: {
equals: 'Localized Lexical en',
},
},
})
expect(lexicalDocEN.docs[0].lexicalBlocksLocalized.root.children[0].children[0].text).toEqual(
'English text',
)
const lexicalDocES = await payload.findByID({
collection: 'lexical-localized-fields',
locale: 'es',
id: lexicalDocEN.docs[0].id,
})
expect(lexicalDocES.lexicalBlocksLocalized.root.children[0].children[0].text).toEqual(
'Spanish text',
)
})
it('ensure localized text field within blocks field within unlocalized lexical field is different across locales', async () => {
const lexicalDocEN = await payload.find({
collection: 'lexical-localized-fields',
locale: 'en',
where: {
title: {
equals: 'Localized Lexical en',
},
},
})
expect(
lexicalDocEN.docs[0].lexicalBlocksSubLocalized.root.children[0].children[0].text,
).toEqual('Shared text')
expect(
(lexicalDocEN.docs[0].lexicalBlocksSubLocalized.root.children[1].fields as any)
.textLocalized,
).toEqual('English text in block')
const lexicalDocES = await payload.findByID({
collection: 'lexical-localized-fields',
locale: 'es',
id: lexicalDocEN.docs[0].id,
})
expect(lexicalDocES.lexicalBlocksSubLocalized.root.children[0].children[0].text).toEqual(
'Shared text',
)
expect(
(lexicalDocES.lexicalBlocksSubLocalized.root.children[1].fields as any).textLocalized,
).toEqual('Spanish text in block')
})
})
describe('Hooks', () => {
it('ensure hook within number field within lexical block runs', async () => {
const lexicalDocEN = await payload.create({
collection: 'lexical-localized-fields',
locale: 'en',
data: {
title: 'Localized Lexical hooks',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }) as any,
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',
) as any,
},
})
expect(
(lexicalDocEN.lexicalBlocksSubLocalized.root.children[1].fields as any).counter,
).toEqual(20) // Initial: 1. BeforeChange: +1 (2). AfterRead: *10 (20)
// update document with same data
const lexicalDocENUpdated = await payload.update({
collection: 'lexical-localized-fields',
locale: 'en',
id: lexicalDocEN.id,
data: lexicalDocEN,
})
expect(
(lexicalDocENUpdated.lexicalBlocksSubLocalized.root.children[1].fields as any).counter,
).toEqual(210) // Initial: 20. BeforeChange: +1 (21). AfterRead: *10 (210)
})
})
})

View File

@@ -207,7 +207,7 @@ export interface LexicalMigrateField {
export interface LexicalLocalizedField {
id: string;
title: string;
lexicalSimple?: {
lexicalBlocksSubLocalized?: {
root: {
type: string;
children: {
@@ -237,21 +237,6 @@ export interface LexicalLocalizedField {
};
[k: string]: unknown;
} | null;
lexicalBlocksSubLocalized?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}

View File

@@ -15,6 +15,7 @@ import { dateDoc } from './collections/Date/shared.js'
import { groupDoc } from './collections/Group/shared.js'
import { jsonDoc } from './collections/JSON/shared.js'
import { lexicalDocData } from './collections/Lexical/data.js'
import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { numberDoc } from './collections/Number/shared.js'
@@ -281,9 +282,11 @@ export const seed = async (_payload: Payload) => {
collection: lexicalLocalizedFieldsSlug,
data: {
title: 'Localized Lexical en',
lexicalSimple: textToLexicalJSON({ text: 'English text' }),
lexicalBlocksLocalized: textToLexicalJSON({ text: 'English text' }),
lexicalBlocksSubLocalized: textToLexicalJSON({ text: 'English text' }),
lexicalBlocksLocalized: textToLexicalJSON({ text: 'English text' }) as any,
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',
) as any,
},
locale: 'en',
depth: 0,
@@ -295,9 +298,12 @@ export const seed = async (_payload: Payload) => {
id: lexicalLocalizedDoc1.id,
data: {
title: 'Localized Lexical es',
lexicalSimple: textToLexicalJSON({ text: 'Spanish text' }),
lexicalBlocksLocalized: textToLexicalJSON({ text: 'Spanish text' }),
lexicalBlocksSubLocalized: textToLexicalJSON({ text: 'Spanish text' }),
lexicalBlocksLocalized: textToLexicalJSON({ text: 'Spanish text' }) as any,
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'Spanish text in block',
(lexicalLocalizedDoc1.lexicalBlocksSubLocalized.root.children[1].fields as any).id,
) as any,
},
locale: 'es',
depth: 0,

View File

@@ -7,6 +7,7 @@ import type { NestedAfterReadHook } from './payload-types.js'
import { devUser, regularUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { isMongoose } from '../helpers/isMongoose.js'
import { afterOperationSlug } from './collections/AfterOperation/index.js'
import { chainingHooksSlug } from './collections/ChainingHooks/index.js'
import { contextHooksSlug } from './collections/ContextHooks/index.js'
@@ -35,7 +36,7 @@ describe('Hooks', () => {
await payload.db.destroy()
}
})
if (isMongoose(payload)) {
describe('transform actions', () => {
it('should create and not throw an error', async () => {
// the collection has hooks that will cause an error if transform actions is not handled properly
@@ -51,6 +52,7 @@ describe('Hooks', () => {
expect(doc.localizedTransform).toBeDefined()
})
})
}
describe('hook execution', () => {
let doc