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 { generateSchema } from '../bin/generateSchema.js'
export { buildObjectType } from '../schema/buildObjectType.js'

View File

@@ -81,7 +81,7 @@ type Args = {
parentName: string parentName: string
} }
function buildObjectType({ export function buildObjectType({
name, name,
baseFields = {}, baseFields = {},
config, config,
@@ -492,13 +492,13 @@ function buildObjectType({
// is run here again, with the provided depth. // is run here again, with the provided depth.
// In the graphql find.ts resolver, the depth is then hard-coded to 0. // 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. // 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 fieldPromises = []
const populationPromises = [] const populationPromises = []
const populateDepth = const populateDepth =
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
editor?.populationPromises({ editor?.graphQLPopulationPromises({
context, context,
depth: populateDepth, depth: populateDepth,
draft: args.draft, draft: args.draft,
@@ -698,5 +698,3 @@ function buildObjectType({
return newlyCreatedBlockType 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 { updateResolver } from '../resolvers/collections/update.js'
import formatName from '../utilities/formatName.js' import formatName from '../utilities/formatName.js'
import { buildMutationInputType, getCollectionIDType } from './buildMutationInputType.js' import { buildMutationInputType, getCollectionIDType } from './buildMutationInputType.js'
import buildObjectType from './buildObjectType.js' import { buildObjectType } from './buildObjectType.js'
import buildPaginatedListType from './buildPaginatedListType.js' import buildPaginatedListType from './buildPaginatedListType.js'
import { buildPolicyType } from './buildPoliciesType.js' import { buildPolicyType } from './buildPoliciesType.js'
import buildWhereInputType from './buildWhereInputType.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 updateResolver from '../resolvers/globals/update.js'
import formatName from '../utilities/formatName.js' import formatName from '../utilities/formatName.js'
import { buildMutationInputType } from './buildMutationInputType.js' import { buildMutationInputType } from './buildMutationInputType.js'
import buildObjectType from './buildObjectType.js' import { buildObjectType } from './buildObjectType.js'
import buildPaginatedListType from './buildPaginatedListType.js' import buildPaginatedListType from './buildPaginatedListType.js'
import { buildPolicyType } from './buildPoliciesType.js' import { buildPolicyType } from './buildPoliciesType.js'
import buildWhereInputType from './buildWhereInputType.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 { JSONSchema4 } from 'json-schema'
import type React from 'react' import type React from 'react'
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { SanitizedConfig } from '../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 { PayloadRequestWithData, RequestContext } from '../types/index.js'
import type { WithServerSidePropsComponentProps } from './elements/WithServerSideProps.js' import type { WithServerSidePropsComponentProps } from './elements/WithServerSideProps.js'
@@ -15,6 +17,173 @@ export type RichTextFieldProps<
path?: string 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< type RichTextAdapterBase<
Value extends object = object, Value extends object = object,
AdapterProps = any, AdapterProps = any,
@@ -32,7 +201,28 @@ type RichTextAdapterBase<
schemaMap: Map<string, Field[]> schemaMap: Map<string, Field[]>
schemaPath: string schemaPath: string
}) => Map<string, Field[]> }) => 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> i18n?: Partial<GenericLanguages>
outputSchema?: ({ outputSchema?: ({
collectionIDFieldTypes, collectionIDFieldTypes,
@@ -50,27 +240,6 @@ type RichTextAdapterBase<
interfaceNameDefinitions: Map<string, JSONSchema4> interfaceNameDefinitions: Map<string, JSONSchema4>
isRequired: boolean isRequired: boolean
}) => JSONSchema4 }) => 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< validate: Validate<
Value, Value,
Value, Value,

View File

@@ -172,25 +172,6 @@ export const sanitizeFields = async ({
if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) { if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) {
config.i18n.translations = deepMerge(config.i18n.translations, field.editor.i18n) 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) { if (richTextSanitizationPromises) {
richTextSanitizationPromises.push(sanitizeRichText) richTextSanitizationPromises.push(sanitizeRichText)

View File

@@ -499,8 +499,8 @@ export const richText = baseField.keys({
CellComponent: componentSchema.optional(), CellComponent: componentSchema.optional(),
FieldComponent: componentSchema.optional(), FieldComponent: componentSchema.optional(),
afterReadPromise: joi.func().optional(), afterReadPromise: joi.func().optional(),
graphQLPopulationPromises: joi.func().optional(),
outputSchema: joi.func().optional(), outputSchema: joi.func().optional(),
populationPromise: joi.func().optional(),
validate: joi.func().required(), validate: joi.func().required(),
}) })
.unknown(), .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. */ /** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
originalDoc?: TData originalDoc?: TData
overrideAccess?: boolean 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. */ /** The document before changes were applied, only in `afterChange` hooks. */
previousDoc?: TData 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 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 previousValue?: TValue
/** The Express request object. It is mocked for Local API operations. */ /** The Express request object. It is mocked for Local API operations. */
req: PayloadRequestWithData 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. */ /** The sibling data passed to a field that the hook is running against. */
siblingData: Partial<TSiblingData> 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. */ /** The value of the field. */
value?: TValue 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> = { type Args<T> = {
collection: SanitizedCollectionConfig | null collection: SanitizedCollectionConfig | null
context: RequestContext context: RequestContext
/**
* The data before hooks
*/
data: Record<string, unknown> | T data: Record<string, unknown> | T
/**
* The data after hooks
*/
doc: Record<string, unknown> | T doc: Record<string, unknown> | T
global: SanitizedGlobalConfig | null global: SanitizedGlobalConfig | null
operation: 'create' | 'update' operation: 'create' | 'update'
@@ -24,7 +30,6 @@ export const afterChange = async <T extends Record<string, unknown>>({
collection, collection,
context, context,
data, data,
doc: incomingDoc, doc: incomingDoc,
global, global,
operation, operation,
@@ -41,9 +46,11 @@ export const afterChange = async <T extends Record<string, unknown>>({
fields: collection?.fields || global?.fields, fields: collection?.fields || global?.fields,
global, global,
operation, operation,
path: [],
previousDoc, previousDoc,
previousSiblingDoc: previousDoc, previousSiblingDoc: previousDoc,
req, req,
schemaPath: [],
siblingData: data, siblingData: data,
siblingDoc: doc, siblingDoc: doc,
}) })

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ type Args = {
/** /**
* This function is responsible for the following actions, in order: * This function is responsible for the following actions, in order:
* - Remove hidden fields from response * - 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.) * - Sanitize outgoing data (point field, etc.)
* - Execute field hooks * - Execute field hooks
* - Execute read access control * - Execute read access control
@@ -77,8 +77,10 @@ export async function afterRead<T = any>(args: Args): Promise<T> {
global, global,
locale, locale,
overrideAccess, overrideAccess,
path: [],
populationPromises, populationPromises,
req, req,
schemaPath: [],
showHiddenFields, showHiddenFields,
siblingDoc: doc, siblingDoc: doc,
}) })

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,8 +24,9 @@ type Args = {
id?: number | string id?: number | string
mergeLocaleActions: (() => Promise<void>)[] mergeLocaleActions: (() => Promise<void>)[]
operation: Operation operation: Operation
path: string path: (number | string)[]
req: PayloadRequestWithData req: PayloadRequestWithData
schemaPath: string[]
siblingData: Record<string, unknown> siblingData: Record<string, unknown>
/** /**
* The original siblingData (not modified by any hooks) * The original siblingData (not modified by any hooks)
@@ -44,7 +45,7 @@ type Args = {
* - Execute field hooks * - Execute field hooks
* - Validate data * - Validate data
* - Transform data for storage * - 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 ({ export const traverseFields = async ({
id, id,
@@ -61,6 +62,7 @@ export const traverseFields = async ({
operation, operation,
path, path,
req, req,
schemaPath,
siblingData, siblingData,
siblingDoc, siblingDoc,
siblingDocWithLocales, siblingDocWithLocales,
@@ -83,7 +85,8 @@ export const traverseFields = async ({
global, global,
mergeLocaleActions, mergeLocaleActions,
operation, operation,
path, parentPath: path,
parentSchemaPath: schemaPath,
req, req,
siblingData, siblingData,
siblingDoc, siblingDoc,

View File

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

View File

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

View File

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

View File

@@ -51,7 +51,6 @@ export const BlockContent: React.FC<Props> = (props) => {
formData, formData,
formSchema, formSchema,
nodeKey, nodeKey,
path,
reducedBlock: { labels }, reducedBlock: { labels },
schemaPath, schemaPath,
} = props } = props
@@ -111,17 +110,21 @@ export const BlockContent: React.FC<Props> = (props) => {
// does not have, even if it's undefined. // 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. // 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. // Inside of fields.data however, that sub-blocks blockName property does not exist at all.
function removeUndefinedAndNullRecursively(obj: object) { function removeUndefinedAndNullAndEmptyArraysRecursively(obj: object) {
Object.keys(obj).forEach((key) => { for (const key in obj) {
if (obj[key] && typeof obj[key] === 'object') { const value = obj[key]
removeUndefinedAndNullRecursively(obj[key]) if (Array.isArray(value) && !value?.length) {
} else if (obj[key] === undefined || obj[key] === null) { delete obj[key]
} else if (value && typeof value === 'object') {
removeUndefinedAndNullAndEmptyArraysRecursively(value)
} else if (value === undefined || value === null) {
delete obj[key] 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, // 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 // 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 { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import { BlockContent } from './BlockContent.js' import { BlockContent } from './BlockContent.js'
import './index.scss' import './index.scss'
import { removeEmptyArrayValues } from './removeEmptyArrayValues.js'
type Props = { type Props = {
blockFieldWrapperName: string
children?: React.ReactNode children?: React.ReactNode
formData: BlockFields formData: BlockFields
@@ -44,7 +42,7 @@ type Props = {
} }
export const BlockComponent: React.FC<Props> = (props) => { export const BlockComponent: React.FC<Props> = (props) => {
const { blockFieldWrapperName, formData, nodeKey } = props const { formData, nodeKey } = props
const config = useConfig() const config = useConfig()
const submitted = useFormSubmitted() const submitted = useFormSubmitted()
const { id } = useDocumentInfo() const { id } = useDocumentInfo()
@@ -81,7 +79,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
if (state) { if (state) {
setInitialState({ setInitialState({
...removeEmptyArrayValues({ fields: state }), ...state,
blockName: { blockName: {
initialValue: '', initialValue: '',
passesCondition: true, passesCondition: true,
@@ -175,6 +173,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
</Collapsible> </Collapsible>
) )
}, [ }, [
classNames,
fieldMap, fieldMap,
parentLexicalRichTextField, parentLexicalRichTextField,
nodeKey, nodeKey,
@@ -182,7 +181,6 @@ export const BlockComponent: React.FC<Props> = (props) => {
submitted, submitted,
initialState, initialState,
reducedBlock, reducedBlock,
blockFieldWrapperName,
onChange, onChange,
schemaFieldsPath, schemaFieldsPath,
path, path,

View File

@@ -10,9 +10,9 @@ import type { BlocksFeatureClientProps } from './feature.client.js'
import { createNode } from '../typeUtilities.js' import { createNode } from '../typeUtilities.js'
import { BlocksFeatureClientComponent } from './feature.client.js' import { BlocksFeatureClientComponent } from './feature.client.js'
import { blockPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js' import { i18n } from './i18n.js'
import { BlockNode } from './nodes/BlocksNode.js' import { BlockNode } from './nodes/BlocksNode.js'
import { blockPopulationPromiseHOC } from './populationPromise.js'
import { blockValidationHOC } from './validate.js' import { blockValidationHOC } from './validate.js'
export type BlocksFeatureProps = { export type BlocksFeatureProps = {
@@ -114,71 +114,17 @@ export const BlocksFeature: FeatureProviderProviderServer<
i18n, i18n,
nodes: [ nodes: [
createNode({ createNode({
/* // TODO: Implement these hooks once docWithLocales / originalSiblingDoc => node matching has been figured out getSubFields: ({ node, req }) => {
hooks: {
beforeChange: [
async ({ context, findMany, node, operation, overrideAccess, req }) => {
const blockType = node.fields.blockType const blockType = node.fields.blockType
const block = deepCopyObject( const block = props.blocks.find((block) => block.slug === blockType)
props.blocks.find((block) => block.slug === blockType), return block?.fields
)
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
}, },
], getSubFieldsData: ({ node }) => {
beforeValidate: [ return node?.fields
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
}, },
], graphQLPopulationPromises: [blockPopulationPromiseHOC(props)],
},*/
node: BlockNode, node: BlockNode,
populationPromises: [blockPopulationPromiseHOC(props)],
validations: [blockValidationHOC(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 { BlocksFeatureProps } from './feature.server.js'
import type { SerializedBlockNode } from './nodes/BlocksNode.js' import type { SerializedBlockNode } from './nodes/BlocksNode.js'
import { recurseNestedFields } from '../../../populate/recurseNestedFields.js' import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const blockPopulationPromiseHOC = ( export const blockPopulationPromiseHOC = (
props: BlocksFeatureProps, props: BlocksFeatureProps,
@@ -21,7 +21,6 @@ export const blockPopulationPromiseHOC = (
populationPromises, populationPromises,
req, req,
showHiddenFields, showHiddenFields,
siblingDoc,
}) => { }) => {
const blockFieldData = node.fields const blockFieldData = node.fields
@@ -31,22 +30,21 @@ export const blockPopulationPromiseHOC = (
return return
} }
recurseNestedFields({ recursivelyPopulateFieldsForGraphQL({
context, context,
currentDepth, currentDepth,
data: blockFieldData, data: blockFieldData,
depth, depth,
draft,
editorPopulationPromises, editorPopulationPromises,
fieldPromises, fieldPromises,
fields: block.fields, fields: block.fields,
findMany, 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, overrideAccess,
populationPromises, populationPromises,
req, req,
showHiddenFields, 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, siblingDoc: blockFieldData,
}) })
} }

View File

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

View File

@@ -1,5 +1,5 @@
import type { Config, SanitizedConfig } from 'payload/config' 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 { traverseFields } from '@payloadcms/ui/utilities/buildFieldSchemaMap/traverseFields'
import { sanitizeFields } from 'payload/config' 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 { convertLexicalNodesToHTML } from '../converters/html/converter/index.js'
import { createNode } from '../typeUtilities.js' import { createNode } from '../typeUtilities.js'
import { LinkFeatureClientComponent } from './feature.client.js' import { LinkFeatureClientComponent } from './feature.client.js'
import { linkPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js' import { i18n } from './i18n.js'
import { LinkMarkdownTransformer } from './markdownTransformer.js' import { LinkMarkdownTransformer } from './markdownTransformer.js'
import { AutoLinkNode } from './nodes/AutoLinkNode.js' import { AutoLinkNode } from './nodes/AutoLinkNode.js'
import { LinkNode } from './nodes/LinkNode.js' import { LinkNode } from './nodes/LinkNode.js'
import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js' import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js'
import { linkPopulationPromiseHOC } from './populationPromise.js'
import { linkValidation } from './validate.js' import { linkValidation } from './validate.js'
export type ExclusiveLinkCollectionsProps = 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 * A function or array defining additional fields for the link feature. These will be
* displayed in the link editor drawer. * 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. * 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. * This behaves exactly like the maxDepth properties of relationship and upload fields.
@@ -82,6 +87,13 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
}) })
props.fields = sanitizedFields 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 { return {
ClientComponent: LinkFeatureClientComponent, ClientComponent: LinkFeatureClientComponent,
clientFeatureProps: { clientFeatureProps: {
@@ -143,16 +155,9 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
nodeTypes: [AutoLinkNode.getType()], nodeTypes: [AutoLinkNode.getType()],
}, },
}, },
hooks: {
afterRead: [
({ node }) => {
return node
},
],
},
node: AutoLinkNode, node: AutoLinkNode,
populationPromises: [linkPopulationPromiseHOC(props)], // Since AutoLinkNodes are just internal links, they need no hooks or graphQL population promises
validations: [linkValidation(props)], validations: [linkValidation(props, sanitizedFieldsWithoutText)],
}), }),
createNode({ createNode({
converters: { converters: {
@@ -181,9 +186,15 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
nodeTypes: [LinkNode.getType()], nodeTypes: [LinkNode.getType()],
}, },
}, },
getSubFields: ({ node, req }) => {
return sanitizedFieldsWithoutText
},
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [linkPopulationPromiseHOC(props)],
node: LinkNode, node: LinkNode,
populationPromises: [linkPopulationPromiseHOC(props)], validations: [linkValidation(props, sanitizedFieldsWithoutText)],
validations: [linkValidation(props)],
}), }),
], ],
serverFeatureProps: props, serverFeatureProps: props,

View File

@@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js'
import type { LinkFeatureServerProps } from './feature.server.js' import type { LinkFeatureServerProps } from './feature.server.js'
import type { SerializedLinkNode } from './nodes/types.js' import type { SerializedLinkNode } from './nodes/types.js'
import { recurseNestedFields } from '../../../populate/recurseNestedFields.js' import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const linkPopulationPromiseHOC = ( export const linkPopulationPromiseHOC = (
props: LinkFeatureServerProps, 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 * Should populate all fields, including the doc field (for internal links), as it's treated like a normal field
*/ */
if (Array.isArray(props.fields)) { if (Array.isArray(props.fields)) {
recurseNestedFields({ recursivelyPopulateFieldsForGraphQL({
context, context,
currentDepth, currentDepth,
data: node.fields, data: node.fields,
@@ -40,7 +40,7 @@ export const linkPopulationPromiseHOC = (
fieldPromises, fieldPromises,
fields: props.fields, fields: props.fields,
findMany, 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, overrideAccess,
populationPromises, populationPromises,
req, req,

View File

@@ -11,7 +11,7 @@ import { LinkNode } from './LinkNode.js'
export class AutoLinkNode extends LinkNode { export class AutoLinkNode extends LinkNode {
static clone(node: AutoLinkNode): AutoLinkNode { 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 { static getType(): string {
@@ -61,7 +61,7 @@ export class AutoLinkNode extends LinkNode {
} }
export function $createAutoLinkNode({ fields }: { fields: LinkFields }): AutoLinkNode { 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 { export function $isAutoLinkNode(node: LexicalNode | null | undefined): node is AutoLinkNode {
return node instanceof AutoLinkNode return node instanceof AutoLinkNode

View File

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

View File

@@ -21,7 +21,8 @@ export type LinkFields = {
export type SerializedLinkNode = Spread< export type SerializedLinkNode = Spread<
{ {
fields: LinkFields fields: LinkFields
id?: string // optional if AutoLinkNode
}, },
SerializedElementNode 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 { 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 { closeModal, isModalOpen, toggleModal } = useModal()
const editDepth = useEditDepth() const editDepth = useEditDepth()
@@ -114,6 +114,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
newTab: undefined, newTab: undefined,
url: '', url: '',
...focusLinkParent.getFields(), ...focusLinkParent.getFields(),
id: focusLinkParent.getID(),
text: focusLinkParent.getTextContent(), text: focusLinkParent.getTextContent(),
} }

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import type { FeatureProviderProviderServer } from '../types.js' import type { FeatureProviderProviderServer } from '../types.js'
import { populate } from '../../../populateGraphQL/populate.js'
import { createNode } from '../typeUtilities.js' import { createNode } from '../typeUtilities.js'
import { RelationshipFeatureClientComponent } from './feature.client.js' import { RelationshipFeatureClientComponent } from './feature.client.js'
import { relationshipPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js' import { i18n } from './i18n.js'
import { RelationshipNode } from './nodes/RelationshipNode.js' import { RelationshipNode } from './nodes/RelationshipNode.js'
import { relationshipPopulationPromiseHOC } from './populationPromise.js'
export type ExclusiveRelationshipFeatureProps = export type ExclusiveRelationshipFeatureProps =
| { | {
@@ -50,8 +51,55 @@ export const RelationshipFeature: FeatureProviderProviderServer<
i18n, i18n,
nodes: [ nodes: [
createNode({ 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, node: RelationshipNode,
populationPromises: [relationshipPopulationPromiseHOC(props)],
}), }),
], ],
serverFeatureProps: props, serverFeatureProps: props,

View File

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

View File

@@ -1,5 +1,5 @@
import type { Transformer } from '@lexical/markdown' 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 { JSONSchema4 } from 'json-schema'
import type { Klass, LexicalEditor, LexicalNode, SerializedEditorState } from 'lexical' import type { Klass, LexicalEditor, LexicalNode, SerializedEditorState } from 'lexical'
import type { SerializedLexicalNode } from 'lexical' import type { SerializedLexicalNode } from 'lexical'
@@ -25,6 +25,7 @@ export type PopulationPromise<T extends SerializedLexicalNode = SerializedLexica
context, context,
currentDepth, currentDepth,
depth, depth,
draft,
editorPopulationPromises, editorPopulationPromises,
field, field,
fieldPromises, fieldPromises,
@@ -236,21 +237,103 @@ export type ClientComponentProps<ClientFeatureProps> = ClientFeatureProps extend
order: number order: number
} & ClientFeatureProps } & ClientFeatureProps
export type FieldNodeHookArgs<T extends SerializedLexicalNode> = { export type AfterReadNodeHookArgs<T extends SerializedLexicalNode> = {
context: RequestContext /**
* 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. */ /** Boolean to denote if this hook is running against finding one, or finding many within the afterRead hook. */
findMany?: boolean findMany: boolean
/** The value of the field. */ flattenLocales: boolean
node?: T /**
* 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. */ /** 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' operation: 'create' | 'delete' | 'read' | 'update'
overrideAccess?: boolean /** The value of the node before any changes. Not available in afterRead hooks */
/** The Express request object. It is mocked for Local API operations. */ 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 req: PayloadRequestWithData
} }
export type FieldNodeHook<T extends SerializedLexicalNode> = ( export type AfterReadNodeHook<T extends SerializedLexicalNode> = (
args: FieldNodeHookArgs<T>, 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 ) => Promise<T> | T
// Define the node with hooks that use the node's exportJSON return type // 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?: { converters?: {
html?: HTMLConverter<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>> 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']>>> getSubFields?: (args: {
beforeValidate?: Array<FieldNodeHook<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>> node: ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>
} req: PayloadRequestWithData
node: Klass<T> | LexicalNodeReplacement }) => Field[] | null
populationPromises?: Array< getSubFieldsData?: (args: {
node: ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>
req: PayloadRequestWithData
}) => Record<string, unknown>
graphQLPopulationPromises?: Array<
PopulationPromise<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>> 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']>>> validations?: Array<NodeValidation<ReturnType<ReplaceAny<T, LexicalNode>['exportJSON']>>>
} }
@@ -451,17 +544,21 @@ export type SanitizedServerFeatures = Required<
} }
/** The node types mapped to their hooks */ /** 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?: { hooks?: {
afterChange?: Map<string, Array<FieldNodeHook<SerializedLexicalNode>>> afterChange?: Map<string, Array<AfterChangeNodeHook<SerializedLexicalNode>>>
afterRead?: Map<string, Array<FieldNodeHook<SerializedLexicalNode>>> afterRead?: Map<string, Array<AfterReadNodeHook<SerializedLexicalNode>>>
beforeChange?: Map<string, Array<FieldNodeHook<SerializedLexicalNode>>> beforeChange?: Map<string, Array<BeforeChangeNodeHook<SerializedLexicalNode>>>
/** beforeValidate?: Map<string, Array<BeforeValidateNodeHook<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>>>
} /** The node types mapped to their populationPromises */ } /** The node types mapped to their populationPromises */
populationPromises: Map<string, Array<PopulationPromise>>
/** The node types mapped to their validations */ /** The node types mapped to their validations */
validations: Map<string, Array<NodeValidation>> 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_COMMAND } from '../plugin/index.js'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './commands.js' import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './commands.js'
const baseClass = 'lexical-upload-drawer'
const insertUpload = ({ const insertUpload = ({
editor, editor,
relationTo, relationTo,

View File

@@ -7,11 +7,12 @@ import { sanitizeFields } from 'payload/config'
import type { FeatureProviderProviderServer } from '../types.js' import type { FeatureProviderProviderServer } from '../types.js'
import type { UploadFeaturePropsClient } from './feature.client.js' import type { UploadFeaturePropsClient } from './feature.client.js'
import { populate } from '../../../populateGraphQL/populate.js'
import { createNode } from '../typeUtilities.js' import { createNode } from '../typeUtilities.js'
import { UploadFeatureClientComponent } from './feature.client.js' import { UploadFeatureClientComponent } from './feature.client.js'
import { uploadPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js' import { i18n } from './i18n.js'
import { UploadNode } from './nodes/UploadNode.js' import { UploadNode } from './nodes/UploadNode.js'
import { uploadPopulationPromiseHOC } from './populationPromise.js'
import { uploadValidation } from './validate.js' import { uploadValidation } from './validate.js'
export type UploadFeatureProps = { export type UploadFeatureProps = {
@@ -177,8 +178,73 @@ export const UploadFeature: FeatureProviderProviderServer<
nodeTypes: [UploadNode.getType()], 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, node: UploadNode,
populationPromises: [uploadPopulationPromiseHOC(props)],
validations: [uploadValidation(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 { UploadFeatureProps } from './feature.server.js'
import type { SerializedUploadNode } from './nodes/UploadNode.js' import type { SerializedUploadNode } from './nodes/UploadNode.js'
import { populate } from '../../../populate/populate.js' import { populate } from '../../../populateGraphQL/populate.js'
import { recurseNestedFields } from '../../../populate/recurseNestedFields.js' import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const uploadPopulationPromiseHOC = ( export const uploadPopulationPromiseHOC = (
props?: UploadFeatureProps, props?: UploadFeatureProps,
@@ -14,7 +14,6 @@ export const uploadPopulationPromiseHOC = (
depth, depth,
draft, draft,
editorPopulationPromises, editorPopulationPromises,
field,
fieldPromises, fieldPromises,
findMany, findMany,
flattenLocales, flattenLocales,
@@ -42,19 +41,20 @@ export const uploadPopulationPromiseHOC = (
data: node, data: node,
depth: populateDepth, depth: populateDepth,
draft, draft,
field,
key: 'value', key: 'value',
overrideAccess, overrideAccess,
req, req,
showHiddenFields, showHiddenFields,
}), }),
) )
}
if (Array.isArray(props?.collections?.[node?.relationTo]?.fields)) { const collectionFieldSchema = props?.collections?.[node?.relationTo]?.fields
if (!props?.collections?.[node?.relationTo]?.fields?.length) {
if (Array.isArray(collectionFieldSchema)) {
if (!collectionFieldSchema?.length) {
return return
} }
recurseNestedFields({ recursivelyPopulateFieldsForGraphQL({
context, context,
currentDepth, currentDepth,
data: node.fields || {}, data: node.fields || {},
@@ -62,9 +62,9 @@ export const uploadPopulationPromiseHOC = (
draft, draft,
editorPopulationPromises, editorPopulationPromises,
fieldPromises, fieldPromises,
fields: props?.collections?.[node?.relationTo]?.fields, fields: collectionFieldSchema,
findMany, 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, overrideAccess,
populationPromises, populationPromises,
req, req,
@@ -75,3 +75,4 @@ export const uploadPopulationPromiseHOC = (
} }
} }
} }
}

View File

@@ -10,6 +10,7 @@ import type {
import type { JSX } from 'react' import type { JSX } from 'react'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js' import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
import ObjectID from 'bson-objectid'
import { $applyNodeReplacement } from 'lexical' import { $applyNodeReplacement } from 'lexical'
import * as React from 'react' import * as React from 'react'
@@ -22,6 +23,7 @@ export type UploadData = {
// unknown, custom fields: // unknown, custom fields:
[key: string]: unknown [key: string]: unknown
} }
id: string
relationTo: string relationTo: string
value: number | 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) { if (serializedNode.version === 1 && (serializedNode?.value as unknown as { id: string })?.id) {
serializedNode.value = (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 = { const importedData: UploadData = {
id: serializedNode.id,
fields: serializedNode.fields, fields: serializedNode.fields,
relationTo: serializedNode.relationTo, relationTo: serializedNode.relationTo,
value: serializedNode.value, value: serializedNode.value,
@@ -141,7 +148,7 @@ export class UploadNode extends DecoratorBlockNode {
...super.exportJSON(), ...super.exportJSON(),
...this.getData(), ...this.getData(),
type: this.getType(), type: this.getType(),
version: 2, version: 3,
} }
} }
@@ -160,8 +167,15 @@ export class UploadNode extends DecoratorBlockNode {
} }
} }
export function $createUploadNode({ data }: { data: UploadData }): UploadNode { export function $createUploadNode({
return $applyNodeReplacement(new UploadNode({ data })) 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 { 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 { UploadDrawer } from '../drawer/index.js'
import { $createUploadNode, UploadNode } from '../nodes/UploadNode.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> = export const INSERT_UPLOAD_COMMAND: LexicalCommand<InsertUploadPayload> =
createCommand('INSERT_UPLOAD_COMMAND') createCommand('INSERT_UPLOAD_COMMAND')
@@ -47,6 +47,7 @@ export const UploadPlugin: PluginComponentWithAnchor<UploadFeaturePropsClient> =
if ($isRangeSelection(selection)) { if ($isRangeSelection(selection)) {
const uploadNode = $createUploadNode({ const uploadNode = $createUploadNode({
data: { data: {
id: payload.id,
fields: payload.fields, fields: payload.fields,
relationTo: payload.relationTo, relationTo: payload.relationTo,
value: payload.value, value: payload.value,

View File

@@ -1,4 +1,4 @@
import type { Config, SanitizedConfig } from 'payload/config' import type { SanitizedConfig } from 'payload/config'
import type { import type {
FeatureProviderServer, 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 { ResolvedServerFeatureMap, SanitizedServerFeatures } from '../../../features/types.js'
import type { SanitizedServerEditorConfig, ServerEditorConfig } from '../types.js' import type { SanitizedServerEditorConfig, ServerEditorConfig } from '../types.js'
@@ -16,18 +16,18 @@ export const sanitizeServerFeatures = (
generatedTypes: { generatedTypes: {
modifyOutputSchemas: [], modifyOutputSchemas: [],
}, },
getSubFields: new Map(),
getSubFieldsData: new Map(),
graphQLPopulationPromises: new Map(),
hooks: { hooks: {
afterChange: new Map(), afterChange: new Map(),
afterRead: new Map(), afterRead: new Map(),
beforeChange: new Map(), beforeChange: new Map(),
beforeDuplicate: new Map(),
beforeValidate: new Map(), beforeValidate: new Map(),
}, },
i18n: {}, i18n: {},
markdownTransformers: [], markdownTransformers: [],
nodes: [], nodes: [],
populationPromises: new Map(),
validations: new Map(), validations: new Map(),
} }
@@ -56,8 +56,8 @@ export const sanitizeServerFeatures = (
sanitized.nodes = sanitized.nodes.concat(feature.nodes) sanitized.nodes = sanitized.nodes.concat(feature.nodes)
feature.nodes.forEach((node) => { 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 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) { if (node?.graphQLPopulationPromises?.length) {
sanitized.populationPromises.set(nodeType, node.populationPromises) sanitized.graphQLPopulationPromises.set(nodeType, node.graphQLPopulationPromises)
} }
if (node?.validations?.length) { if (node?.validations?.length) {
sanitized.validations.set(nodeType, node.validations) sanitized.validations.set(nodeType, node.validations)
@@ -74,12 +74,15 @@ export const sanitizeServerFeatures = (
if (node?.hooks?.beforeChange) { if (node?.hooks?.beforeChange) {
sanitized.hooks.beforeChange.set(nodeType, 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) { if (node?.hooks?.beforeValidate) {
sanitized.hooks.beforeValidate.set(nodeType, 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 { 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 { 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 { FeatureProviderServer, ResolvedServerFeatureMap } from './field/features/types.js'
import type { SanitizedServerEditorConfig } from './field/lexical/config/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 { getGenerateComponentMap } from './generateComponentMap.js'
import { getGenerateSchemaMap } from './generateSchemaMap.js' import { getGenerateSchemaMap } from './generateSchemaMap.js'
import { i18n } from './i18n.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' import { richTextValidateHOC } from './validate/index.js'
let defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = null let defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = null
@@ -121,105 +132,560 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
generateSchemaMap: getGenerateSchemaMap({ generateSchemaMap: getGenerateSchemaMap({
resolvedFeatureMap, resolvedFeatureMap,
}), }),
i18n: featureI18n, graphQLPopulationPromises({
/* hooks: { context,
afterChange: finalSanitizedEditorConfig.features.hooks.afterChange, currentDepth,
afterRead: finalSanitizedEditorConfig.features.hooks.afterRead, depth,
beforeChange: finalSanitizedEditorConfig.features.hooks.beforeChange, draft,
beforeDuplicate: finalSanitizedEditorConfig.features.hooks.beforeDuplicate, field,
beforeValidate: finalSanitizedEditorConfig.features.hooks.beforeValidate, fieldPromises,
},*/ findMany,
/* // TODO: Figure out docWithLocales / originalSiblingDoc => node matching. Can't use indexes, as the order of nodes could technically change between hooks. flattenLocales,
hooks: { overrideAccess,
afterChange: [ populationPromises,
async ({ context, findMany, operation, overrideAccess, req, value }) => { req,
await recurseNodesAsync({ showHiddenFields,
callback: async (node) => { siblingDoc,
const afterChangeHooks = finalSanitizedEditorConfig.features.hooks.afterChange }) {
if (afterChangeHooks?.has(node.type)) { // check if there are any features with nodes which have populationPromises for this field
for (const hook of afterChangeHooks.get(node.type)) { if (finalSanitizedEditorConfig?.features?.graphQLPopulationPromises?.size) {
node = await hook({ context, findMany, node, operation, overrideAccess, req }) 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 ?? [], 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 return value
}, },
], ],
afterRead: [ afterRead: [
async ({ context, findMany, operation, overrideAccess, req, value }) => { /**
await recurseNodesAsync({ * afterRead hooks do not receive the originalNode. Thus, they can run on all nodes, not just nodes with an ID.
callback: async (node) => { */
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 const afterReadHooks = finalSanitizedEditorConfig.features.hooks.afterRead
if (afterReadHooks?.has(node.type)) { if (afterReadHooks?.has(node.type)) {
for (const hook of afterReadHooks.get(node.type)) { for (const hook of afterReadHooks.get(node.type)) {
node = await hook({ context, findMany, node, operation, overrideAccess, req }) node = await hook({
} context,
} currentDepth,
}, depth,
nodes: (value as SerializedEditorState)?.root?.children ?? [], 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 return value
}, },
], ],
beforeChange: [ beforeChange: [
async ({ context, findMany, operation, overrideAccess, req, value }) => { async ({
await recurseNodesAsync({ collection,
callback: async (node) => { context: _context,
const beforeChangeHooks = finalSanitizedEditorConfig.features.hooks.beforeChange duplicate,
if (beforeChangeHooks?.has(node.type)) { errors,
for (const hook of beforeChangeHooks.get(node.type)) { field,
node = await hook({ context, findMany, node, operation, overrideAccess, req }) 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 ?? [], nodes: (value as SerializedEditorState)?.root?.children ?? [],
}) })
return value if (siblingDocWithLocales?.[field.name]) {
}, recurseNodeTree({
], nodeIDMap: originalNodeWithLocalesIDMap,
beforeDuplicate: [ nodes:
async ({ context, findMany, operation, overrideAccess, req, value }) => { (siblingDocWithLocales[field.name] as SerializedEditorState)?.root?.children ??
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 ?? [],
}) })
}
// 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 return value
}, },
], ],
beforeValidate: [ beforeValidate: [
async ({ context, findMany, operation, overrideAccess, req, value }) => { async ({
await recurseNodesAsync({ collection,
callback: async (node) => { 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 const beforeValidateHooks = finalSanitizedEditorConfig.features.hooks.beforeValidate
if (beforeValidateHooks?.has(node.type)) { if (beforeValidateHooks?.has(node.type)) {
for (const hook of beforeValidateHooks.get(node.type)) { for (const hook of beforeValidateHooks.get(node.type)) {
/** if (!originalNodeIDMap[id]) {
* 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 console.warn(
*/ /* '(beforeValidate) No original node found for node with id',
node = await hook({ context, findMany, node, operation, overrideAccess, req }) id,
'node:',
node,
'path',
path.join('.'),
)
continue
} }
} node = await hook({
}, context,
nodes: (value as SerializedEditorState)?.root?.children ?? [], 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 return value
}, },
], ],
},*/ },
i18n: featureI18n,
outputSchema: ({ outputSchema: ({
collectionIDFieldTypes, collectionIDFieldTypes,
config, config,
@@ -297,41 +763,6 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
return outputSchema 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({ validate: richTextValidateHOC({
editorConfig: finalSanitizedEditorConfig, 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 type { ToolbarGroup, ToolbarGroupItem } from './field/features/toolbars/types.js'
export { createNode } from './field/features/typeUtilities.js' export { createNode } from './field/features/typeUtilities.js'
export type { export type {
AfterChangeNodeHook,
AfterChangeNodeHookArgs,
AfterReadNodeHook,
AfterReadNodeHookArgs,
BaseNodeHookArgs,
BeforeChangeNodeHook,
BeforeChangeNodeHookArgs,
BeforeValidateNodeHook,
BeforeValidateNodeHookArgs,
ClientComponentProps, ClientComponentProps,
ClientFeature, ClientFeature,
ClientFeatureProviderMap, ClientFeatureProviderMap,
@@ -459,8 +899,6 @@ export type {
FeatureProviderProviderClient, FeatureProviderProviderClient,
FeatureProviderProviderServer, FeatureProviderProviderServer,
FeatureProviderServer, FeatureProviderServer,
FieldNodeHook,
FieldNodeHookArgs,
NodeValidation, NodeValidation,
NodeWithHooks, NodeWithHooks,
PluginComponent, PluginComponent,
@@ -559,6 +997,6 @@ export {
addSwipeUpListener, addSwipeUpListener,
} from './field/lexical/utils/swipe.js' } from './field/lexical/utils/swipe.js'
export { sanitizeUrl, validateUrl } from './field/lexical/utils/url.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' 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 { PayloadRequestWithData } from 'payload/types'
import type { Collection, Field, RichTextField } from 'payload/types' import type { Collection } from 'payload/types'
import { createDataloaderCacheKey } from 'payload/utilities' import { createDataloaderCacheKey } from 'payload/utilities'
import type { AdapterProps } from '../types.js'
type Arguments = { type Arguments = {
currentDepth?: number currentDepth?: number
data: unknown data: unknown
depth: number depth: number
draft: boolean draft: boolean
field: RichTextField<SerializedEditorState, AdapterProps>
key: number | string key: number | string
overrideAccess?: boolean overrideAccess: boolean
req: PayloadRequestWithData req: PayloadRequestWithData
showHiddenFields: boolean showHiddenFields: boolean
} }
@@ -29,11 +25,16 @@ export const populate = async ({
overrideAccess, overrideAccess,
req, req,
showHiddenFields, showHiddenFields,
}: Omit<Arguments, 'field'> & { }: Arguments & {
collection: Collection collection: Collection
field: Field
id: number | string id: number | string
}): Promise<void> => { }): 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 dataRef = data as Record<string, unknown>
const doc = await req.payloadDataLoader.load( const doc = await req.payloadDataLoader.load(
@@ -45,7 +46,7 @@ export const populate = async ({
draft, draft,
fallbackLocale: req.fallbackLocale, fallbackLocale: req.fallbackLocale,
locale: req.locale, locale: req.locale,
overrideAccess: typeof overrideAccess === 'undefined' ? false : overrideAccess, overrideAccess,
showHiddenFields, showHiddenFields,
transactionID: req.transactionID, transactionID: req.transactionID,
}), }),

View File

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

View File

@@ -29,7 +29,7 @@ type NestedRichTextFieldsArgs = {
siblingDoc: Record<string, unknown> siblingDoc: Record<string, unknown>
} }
export const recurseNestedFields = ({ export const recursivelyPopulateFieldsForGraphQL = ({
context, context,
currentDepth = 0, currentDepth = 0,
data, 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 global: null, // Pass from core? This is only needed for hooks, so we can leave this null for now
locale: req.locale, locale: req.locale,
overrideAccess, overrideAccess,
path: [],
populationPromises, // This is not the same as populationPromises passed into this recurseNestedFields. These are just promises resolved at the very end. populationPromises, // This is not the same as populationPromises passed into this recurseNestedFields. These are just promises resolved at the very end.
req, req,
schemaPath: [],
showHiddenFields, showHiddenFields,
siblingDoc, siblingDoc,
//triggerAccessControl: false, // TODO: Enable this to support access control triggerHooks: false,
//triggerHooks: false, // TODO: Enable this to support hooks
}) })
} }

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 { populate } from './populate.js'
import { recurseNestedFields } from './recurseNestedFields.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 = { type RecurseRichTextArgs = {
children: unknown[] children: unknown[]

View File

@@ -52,15 +52,7 @@ export function slateEditor(
FieldComponent: RichTextField, FieldComponent: RichTextField,
generateComponentMap: getGenerateComponentMap(args), generateComponentMap: getGenerateComponentMap(args),
generateSchemaMap: getGenerateSchemaMap(args), generateSchemaMap: getGenerateSchemaMap(args),
outputSchema: ({ isRequired }) => { graphQLPopulationPromises({
return {
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'object',
},
}
},
populationPromises({
context, context,
currentDepth, currentDepth,
depth, 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, validate: richTextValidate,
} }
} }

View File

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

View File

@@ -28,6 +28,7 @@ export function generateLexicalRichText() {
format: '', format: '',
type: 'upload', type: 'upload',
version: 2, version: 2,
id: '665d105a91e1c337ba8308dd',
fields: { fields: {
caption: { caption: {
root: { 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, localized: true,
}, },
{ {
name: 'lexicalSimple', name: 'lexicalBlocksSubLocalized',
type: 'richText', type: 'richText',
localized: true, admin: {
description: 'Non-localized field with localized block subfields',
},
editor: lexicalEditor({ 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', direction: 'ltr',
format: '', format: '',
indent: 0, indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link', type: 'link',
version: 2, version: 2,
fields: { fields: {
@@ -86,6 +87,7 @@ export function generateLexicalRichText() {
direction: 'ltr', direction: 'ltr',
format: '', format: '',
indent: 0, indent: 0,
id: '665d10938106ab380c7f3730',
type: 'link', type: 'link',
version: 2, version: 2,
fields: { fields: {
@@ -230,6 +232,7 @@ export function generateLexicalRichText() {
format: '', format: '',
type: 'upload', type: 'upload',
version: 2, version: 2,
id: '665d10938106ab380c7f372f',
relationTo: 'uploads', relationTo: 'uploads',
value: '{{UPLOAD_DOC_ID}}', value: '{{UPLOAD_DOC_ID}}',
fields: { fields: {

View File

@@ -14,6 +14,8 @@ import { devUser } from '../credentials.js'
import { NextRESTClient } from '../helpers/NextRESTClient.js' import { NextRESTClient } from '../helpers/NextRESTClient.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js' import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { lexicalDocData } from './collections/Lexical/data.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 { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { richTextDocData } from './collections/RichText/data.js' import { richTextDocData } from './collections/RichText/data.js'
import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText.js' import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText.js'
@@ -569,4 +571,100 @@ describe('Lexical', () => {
expect(populatedDocEditorRelationshipNode.value.text).toStrictEqual(textDoc.text) 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 { export interface LexicalLocalizedField {
id: string; id: string;
title: string; title: string;
lexicalSimple?: { lexicalBlocksSubLocalized?: {
root: { root: {
type: string; type: string;
children: { children: {
@@ -237,21 +237,6 @@ export interface LexicalLocalizedField {
}; };
[k: string]: unknown; [k: string]: unknown;
} | null; } | 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; updatedAt: string;
createdAt: string; createdAt: string;
} }

View File

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

View File

@@ -7,6 +7,7 @@ import type { NestedAfterReadHook } from './payload-types.js'
import { devUser, regularUser } from '../credentials.js' import { devUser, regularUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js' import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { isMongoose } from '../helpers/isMongoose.js'
import { afterOperationSlug } from './collections/AfterOperation/index.js' import { afterOperationSlug } from './collections/AfterOperation/index.js'
import { chainingHooksSlug } from './collections/ChainingHooks/index.js' import { chainingHooksSlug } from './collections/ChainingHooks/index.js'
import { contextHooksSlug } from './collections/ContextHooks/index.js' import { contextHooksSlug } from './collections/ContextHooks/index.js'
@@ -35,7 +36,7 @@ describe('Hooks', () => {
await payload.db.destroy() await payload.db.destroy()
} }
}) })
if (isMongoose(payload)) {
describe('transform actions', () => { describe('transform actions', () => {
it('should create and not throw an error', async () => { 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 // 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() expect(doc.localizedTransform).toBeDefined()
}) })
}) })
}
describe('hook execution', () => { describe('hook execution', () => {
let doc let doc