diff --git a/packages/graphql/src/exports/utilities.ts b/packages/graphql/src/exports/utilities.ts index 83fa7acd0..35da58930 100644 --- a/packages/graphql/src/exports/utilities.ts +++ b/packages/graphql/src/exports/utilities.ts @@ -1 +1,2 @@ export { generateSchema } from '../bin/generateSchema.js' +export { buildObjectType } from '../schema/buildObjectType.js' diff --git a/packages/graphql/src/schema/buildObjectType.ts b/packages/graphql/src/schema/buildObjectType.ts index 9e89e2524..8464a0352 100644 --- a/packages/graphql/src/schema/buildObjectType.ts +++ b/packages/graphql/src/schema/buildObjectType.ts @@ -81,7 +81,7 @@ type Args = { parentName: string } -function buildObjectType({ +export function buildObjectType({ name, baseFields = {}, config, @@ -492,13 +492,13 @@ function buildObjectType({ // is run here again, with the provided depth. // In the graphql find.ts resolver, the depth is then hard-coded to 0. // Effectively, this means that the populationPromise for GraphQL is only run here, and not in the find.ts resolver / normal population promise. - if (editor?.populationPromises) { + if (editor?.graphQLPopulationPromises) { const fieldPromises = [] const populationPromises = [] const populateDepth = field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth - editor?.populationPromises({ + editor?.graphQLPopulationPromises({ context, depth: populateDepth, draft: args.draft, @@ -698,5 +698,3 @@ function buildObjectType({ return newlyCreatedBlockType } - -export default buildObjectType diff --git a/packages/graphql/src/schema/initCollections.ts b/packages/graphql/src/schema/initCollections.ts index 1e638c1ed..33e057d8c 100644 --- a/packages/graphql/src/schema/initCollections.ts +++ b/packages/graphql/src/schema/initCollections.ts @@ -37,7 +37,7 @@ import restoreVersionResolver from '../resolvers/collections/restoreVersion.js' import { updateResolver } from '../resolvers/collections/update.js' import formatName from '../utilities/formatName.js' import { buildMutationInputType, getCollectionIDType } from './buildMutationInputType.js' -import buildObjectType from './buildObjectType.js' +import { buildObjectType } from './buildObjectType.js' import buildPaginatedListType from './buildPaginatedListType.js' import { buildPolicyType } from './buildPoliciesType.js' import buildWhereInputType from './buildWhereInputType.js' diff --git a/packages/graphql/src/schema/initGlobals.ts b/packages/graphql/src/schema/initGlobals.ts index 760f50935..6f8875ef9 100644 --- a/packages/graphql/src/schema/initGlobals.ts +++ b/packages/graphql/src/schema/initGlobals.ts @@ -17,7 +17,7 @@ import restoreVersionResolver from '../resolvers/globals/restoreVersion.js' import updateResolver from '../resolvers/globals/update.js' import formatName from '../utilities/formatName.js' import { buildMutationInputType } from './buildMutationInputType.js' -import buildObjectType from './buildObjectType.js' +import { buildObjectType } from './buildObjectType.js' import buildPaginatedListType from './buildPaginatedListType.js' import { buildPolicyType } from './buildPoliciesType.js' import buildWhereInputType from './buildWhereInputType.js' diff --git a/packages/payload/src/admin/RichText.ts b/packages/payload/src/admin/RichText.ts index 4fb503752..2c36f0eb0 100644 --- a/packages/payload/src/admin/RichText.ts +++ b/packages/payload/src/admin/RichText.ts @@ -2,8 +2,10 @@ import type { GenericLanguages, I18n, I18nClient } from '@payloadcms/translation import type { JSONSchema4 } from 'json-schema' import type React from 'react' +import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js' import type { SanitizedConfig } from '../config/types.js' -import type { Field, FieldBase, RichTextField, Validate } from '../fields/config/types.js' +import type { Field, FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js' +import type { SanitizedGlobalConfig } from '../globals/config/types.js' import type { PayloadRequestWithData, RequestContext } from '../types/index.js' import type { WithServerSidePropsComponentProps } from './elements/WithServerSideProps.js' @@ -15,6 +17,173 @@ export type RichTextFieldProps< path?: string } +export type AfterReadRichTextHookArgs< + TData extends TypeWithID = any, + TValue = any, + TSiblingData = any, +> = { + currentDepth?: number + + depth?: number + + draft?: boolean + + fallbackLocale?: string + + fieldPromises?: Promise[] + + /** 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[] + 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 + + duplicate?: boolean + + errors?: { field: string; message: string }[] + /** Only available in `beforeChange` field hooks */ + mergeLocaleActions?: (() => Promise)[] + /** 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 + + 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 + /** 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 + /** The value of the field. */ + value?: TValue +} + +export type AfterReadRichTextHook< + TData extends TypeWithID = any, + TValue = any, + TSiblingData = any, +> = ( + args: BaseRichTextHookArgs & + AfterReadRichTextHookArgs, +) => Promise | TValue + +export type AfterChangeRichTextHook< + TData extends TypeWithID = any, + TValue = any, + TSiblingData = any, +> = ( + args: BaseRichTextHookArgs & + AfterChangeRichTextHookArgs, +) => Promise | TValue + +export type BeforeChangeRichTextHook< + TData extends TypeWithID = any, + TValue = any, + TSiblingData = any, +> = ( + args: BaseRichTextHookArgs & + BeforeChangeRichTextHookArgs, +) => Promise | TValue + +export type BeforeValidateRichTextHook< + TData extends TypeWithID = any, + TValue = any, + TSiblingData = any, +> = ( + args: BaseRichTextHookArgs & + BeforeValidateRichTextHookArgs, +) => Promise | TValue + +export type RichTextHooks = { + afterChange?: AfterChangeRichTextHook[] + afterRead?: AfterReadRichTextHook[] + beforeChange?: BeforeChangeRichTextHook[] + beforeValidate?: BeforeValidateRichTextHook[] +} + type RichTextAdapterBase< Value extends object = object, AdapterProps = any, @@ -32,7 +201,28 @@ type RichTextAdapterBase< schemaMap: Map schemaPath: string }) => Map - 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 + fieldPromises: Promise[] + findMany: boolean + flattenLocales: boolean + overrideAccess?: boolean + populationPromises: Promise[] + req: PayloadRequestWithData + showHiddenFields: boolean + siblingDoc: Record + }) => void + hooks?: RichTextHooks i18n?: Partial outputSchema?: ({ collectionIDFieldTypes, @@ -50,27 +240,6 @@ type RichTextAdapterBase< interfaceNameDefinitions: Map isRequired: boolean }) => JSONSchema4 - /** - * Like an afterRead hook, but runs for both afterRead AND in the GraphQL resolver. For populating data, this should be used. - * - * To populate stuff / resolve field hooks, mutate the incoming populationPromises or fieldPromises array. They will then be awaited in the correct order within payload itself. - * @param data - */ - populationPromises?: (data: { - context: RequestContext - currentDepth?: number - depth: number - draft: boolean - field: RichTextField - fieldPromises: Promise[] - findMany: boolean - flattenLocales: boolean - overrideAccess?: boolean - populationPromises: Promise[] - req: PayloadRequestWithData - showHiddenFields: boolean - siblingDoc: Record - }) => void validate: Validate< Value, Value, diff --git a/packages/payload/src/fields/config/sanitize.ts b/packages/payload/src/fields/config/sanitize.ts index e7b203e30..3276bb9f2 100644 --- a/packages/payload/src/fields/config/sanitize.ts +++ b/packages/payload/src/fields/config/sanitize.ts @@ -172,25 +172,6 @@ export const sanitizeFields = async ({ if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) { config.i18n.translations = deepMerge(config.i18n.translations, field.editor.i18n) } - - // Add editor adapter hooks to field hooks - if (!field.hooks) field.hooks = {} - - const mergeHooks = (hookName: keyof typeof field.editor.hooks) => { - if (typeof field.editor === 'function') return - - if (field.editor?.hooks?.[hookName]?.length) { - field.hooks[hookName] = field.hooks[hookName] - ? field.hooks[hookName].concat(field.editor.hooks[hookName]) - : [...field.editor.hooks[hookName]] - } - } - - mergeHooks('afterRead') - mergeHooks('afterChange') - mergeHooks('beforeChange') - mergeHooks('beforeValidate') - mergeHooks('beforeDuplicate') } if (richTextSanitizationPromises) { richTextSanitizationPromises.push(sanitizeRichText) diff --git a/packages/payload/src/fields/config/schema.ts b/packages/payload/src/fields/config/schema.ts index 82c5e0fc9..d9200e095 100644 --- a/packages/payload/src/fields/config/schema.ts +++ b/packages/payload/src/fields/config/schema.ts @@ -499,8 +499,8 @@ export const richText = baseField.keys({ CellComponent: componentSchema.optional(), FieldComponent: componentSchema.optional(), afterReadPromise: joi.func().optional(), + graphQLPopulationPromises: joi.func().optional(), outputSchema: joi.func().optional(), - populationPromise: joi.func().optional(), validate: joi.func().required(), }) .unknown(), diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 44df929ba..d1204a86e 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -41,16 +41,28 @@ export type FieldHookArgs + /** + * The original siblingData with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks. + */ + siblingDocWithLocales?: Record /** The value of the field. */ value?: TValue } diff --git a/packages/payload/src/fields/getFieldPaths.ts b/packages/payload/src/fields/getFieldPaths.ts new file mode 100644 index 000000000..2db80bdec --- /dev/null +++ b/packages/payload/src/fields/getFieldPaths.ts @@ -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 } +} diff --git a/packages/payload/src/fields/hooks/afterChange/index.ts b/packages/payload/src/fields/hooks/afterChange/index.ts index 4e49f5ee0..835632a76 100644 --- a/packages/payload/src/fields/hooks/afterChange/index.ts +++ b/packages/payload/src/fields/hooks/afterChange/index.ts @@ -8,7 +8,13 @@ import { traverseFields } from './traverseFields.js' type Args = { collection: SanitizedCollectionConfig | null context: RequestContext + /** + * The data before hooks + */ data: Record | T + /** + * The data after hooks + */ doc: Record | T global: SanitizedGlobalConfig | null operation: 'create' | 'update' @@ -24,7 +30,6 @@ export const afterChange = async >({ collection, context, data, - doc: incomingDoc, global, operation, @@ -41,9 +46,11 @@ export const afterChange = async >({ fields: collection?.fields || global?.fields, global, operation, + path: [], previousDoc, previousSiblingDoc: previousDoc, req, + schemaPath: [], siblingData: data, siblingDoc: doc, }) diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts index a32984244..814247125 100644 --- a/packages/payload/src/fields/hooks/afterChange/promise.ts +++ b/packages/payload/src/fields/hooks/afterChange/promise.ts @@ -1,10 +1,13 @@ /* eslint-disable no-param-reassign */ +import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' +import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName } from '../../config/types.js' +import { getFieldPaths } from '../../getFieldPaths.js' import { traverseFields } from './traverseFields.js' type Args = { @@ -15,6 +18,14 @@ type Args = { field: Field | TabAsField global: SanitizedGlobalConfig | null operation: 'create' | 'update' + /** + * The parent's path + */ + parentPath: (number | string)[] + /** + * The parent's schemaPath (path without indexes). + */ + parentSchemaPath: string[] previousDoc: Record previousSiblingDoc: Record req: PayloadRequestWithData @@ -33,12 +44,20 @@ export const promise = async ({ field, global, operation, + parentPath, + parentSchemaPath, previousDoc, previousSiblingDoc, req, siblingData, siblingDoc, }: Args): Promise => { + const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({ + field, + parentPath, + parentSchemaPath, + }) + if (fieldAffectsData(field)) { // Execute hooks if (field.hooks?.afterChange) { @@ -53,12 +72,14 @@ export const promise = async ({ global, operation, originalDoc: doc, + path: fieldPath, previousDoc, previousSiblingDoc, previousValue: previousDoc[field.name], req, + schemaPath: fieldSchemaPath, siblingData, - value: siblingData[field.name], + value: siblingDoc[field.name], }) if (hookedValue !== undefined) { @@ -79,9 +100,11 @@ export const promise = async ({ fields: field.fields, global, operation, + path: fieldPath, previousDoc, previousSiblingDoc: previousDoc[field.name] as Record, req, + schemaPath: fieldSchemaPath, siblingData: (siblingData?.[field.name] as Record) || {}, siblingDoc: siblingDoc[field.name] as Record, }) @@ -104,9 +127,11 @@ export const promise = async ({ fields: field.fields, global, operation, + path: [...fieldPath, i], previousDoc, previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as Record), req, + schemaPath: fieldSchemaPath, siblingData: siblingData?.[field.name]?.[i] || {}, siblingDoc: { ...row } || {}, }), @@ -135,10 +160,12 @@ export const promise = async ({ fields: block.fields, global, operation, + path: [...fieldPath, i], previousDoc, previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as Record), req, + schemaPath: fieldSchemaPath, siblingData: siblingData?.[field.name]?.[i] || {}, siblingDoc: { ...row } || {}, }), @@ -161,9 +188,11 @@ export const promise = async ({ fields: field.fields, global, operation, + path: fieldPath, previousDoc, previousSiblingDoc: { ...previousSiblingDoc }, req, + schemaPath: fieldSchemaPath, siblingData: siblingData || {}, siblingDoc: { ...siblingDoc }, }) @@ -190,9 +219,11 @@ export const promise = async ({ fields: field.fields, global, operation, + path: fieldPath, previousDoc, previousSiblingDoc: tabPreviousSiblingDoc, req, + schemaPath: fieldSchemaPath, siblingData: tabSiblingData, siblingDoc: tabSiblingDoc, }) @@ -209,15 +240,57 @@ export const promise = async ({ fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })), global, operation, + path: fieldPath, previousDoc, previousSiblingDoc: { ...previousSiblingDoc }, req, + schemaPath: fieldSchemaPath, siblingData: siblingData || {}, siblingDoc: { ...siblingDoc }, }) break } + case 'richText': { + if (!field?.editor) { + throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor + } + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + + const editor: RichTextAdapter = field?.editor + + if (editor?.hooks?.afterChange?.length) { + await editor.hooks.afterChange.reduce(async (priorHook, currentHook) => { + await priorHook + + const hookedValue = await currentHook({ + collection, + context, + data, + field, + global, + operation, + originalDoc: doc, + path: fieldPath, + previousDoc, + previousSiblingDoc, + previousValue: previousDoc[field.name], + req, + schemaPath: fieldSchemaPath, + siblingData, + value: siblingDoc[field.name], + }) + + if (hookedValue !== undefined) { + siblingDoc[field.name] = hookedValue + } + }, Promise.resolve()) + } + break + } + default: { break } diff --git a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts index 30b5c9402..09c90fdb9 100644 --- a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts @@ -13,9 +13,11 @@ type Args = { fields: (Field | TabAsField)[] global: SanitizedGlobalConfig | null operation: 'create' | 'update' + path: (number | string)[] previousDoc: Record previousSiblingDoc: Record req: PayloadRequestWithData + schemaPath: string[] siblingData: Record siblingDoc: Record } @@ -28,9 +30,11 @@ export const traverseFields = async ({ fields, global, operation, + path, previousDoc, previousSiblingDoc, req, + schemaPath, siblingData, siblingDoc, }: Args): Promise => { @@ -46,6 +50,8 @@ export const traverseFields = async ({ field, global, operation, + parentPath: path, + parentSchemaPath: schemaPath, previousDoc, previousSiblingDoc, req, diff --git a/packages/payload/src/fields/hooks/afterRead/index.ts b/packages/payload/src/fields/hooks/afterRead/index.ts index 70a29c5ee..670952272 100644 --- a/packages/payload/src/fields/hooks/afterRead/index.ts +++ b/packages/payload/src/fields/hooks/afterRead/index.ts @@ -25,7 +25,7 @@ type Args = { /** * This function is responsible for the following actions, in order: * - Remove hidden fields from response - * - Flatten locales into requested locale + * - Flatten locales into requested locale. If the input doc contains all locales, the output doc after this function will only contain the requested locale. * - Sanitize outgoing data (point field, etc.) * - Execute field hooks * - Execute read access control @@ -77,8 +77,10 @@ export async function afterRead(args: Args): Promise { global, locale, overrideAccess, + path: [], populationPromises, req, + schemaPath: [], showHiddenFields, siblingDoc: doc, }) diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index f988480b5..aa29c1ade 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -1,5 +1,5 @@ /* eslint-disable no-param-reassign */ -import type { RichTextAdapter } from '../../../admin/types.js' +import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js' @@ -8,6 +8,7 @@ import type { Field, TabAsField } from '../../config/types.js' import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName } from '../../config/types.js' import getValueWithDefault from '../../getDefaultValue.js' +import { getFieldPaths } from '../../getFieldPaths.js' import { relationshipPopulationPromise } from './relationshipPopulationPromise.js' import { traverseFields } from './traverseFields.js' @@ -29,6 +30,14 @@ type Args = { global: SanitizedGlobalConfig | null locale: null | string overrideAccess: boolean + /** + * The parent's path. + */ + parentPath: (number | string)[] + /** + * The parent's schemaPath (path without indexes). + */ + parentSchemaPath: string[] populationPromises: Promise[] req: PayloadRequestWithData showHiddenFields: boolean @@ -60,6 +69,8 @@ export const promise = async ({ global, locale, overrideAccess, + parentPath, + parentSchemaPath, populationPromises, req, showHiddenFields, @@ -67,6 +78,12 @@ export const promise = async ({ triggerAccessControl = true, triggerHooks = true, }: Args): Promise => { + const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({ + field, + parentPath, + parentSchemaPath, + }) + if ( fieldAffectsData(field) && field.hidden && @@ -151,29 +168,7 @@ export const promise = async ({ throw new Error('Attempted to access unsanitized rich text editor.') } - const editor: RichTextAdapter = field?.editor - // This is run here AND in the GraphQL Resolver - if (editor?.populationPromises) { - const populateDepth = - field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth - - editor.populationPromises({ - context, - currentDepth, - depth: populateDepth, - draft, - field, - fieldPromises, - findMany, - flattenLocales, - overrideAccess, - populationPromises, - req, - showHiddenFields, - siblingDoc, - }) - } - + // Rich Text fields should use afterRead hooks to do population. The previous editor.populationPromises have been renamed to editor.graphQLPopulationPromises break } @@ -212,10 +207,14 @@ export const promise = async ({ context, data: doc, field, + findMany, global, operation: 'read', originalDoc: doc, + overrideAccess, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData: siblingDoc, value, }) @@ -238,7 +237,9 @@ export const promise = async ({ operation: 'read', originalDoc: doc, overrideAccess, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData: siblingDoc, value: siblingDoc[field.name], }) @@ -322,8 +323,10 @@ export const promise = async ({ global, locale, overrideAccess, + path: fieldPath, populationPromises, req, + schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc: groupDoc, triggerAccessControl, @@ -337,7 +340,7 @@ export const promise = async ({ const rows = siblingDoc[field.name] if (Array.isArray(rows)) { - rows.forEach((row) => { + rows.forEach((row, i) => { traverseFields({ collection, context, @@ -353,8 +356,10 @@ export const promise = async ({ global, locale, overrideAccess, + path: [...fieldPath, i], populationPromises, req, + schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc: row || {}, triggerAccessControl, @@ -364,7 +369,7 @@ export const promise = async ({ } else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) { Object.values(rows).forEach((localeRows) => { if (Array.isArray(localeRows)) { - localeRows.forEach((row) => { + localeRows.forEach((row, i) => { traverseFields({ collection, context, @@ -380,8 +385,10 @@ export const promise = async ({ global, locale, overrideAccess, + path: [...fieldPath, i], populationPromises, req, + schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc: row || {}, triggerAccessControl, @@ -400,7 +407,7 @@ export const promise = async ({ const rows = siblingDoc[field.name] if (Array.isArray(rows)) { - rows.forEach((row) => { + rows.forEach((row, i) => { const block = field.blocks.find((blockType) => blockType.slug === row.blockType) if (block) { @@ -419,8 +426,10 @@ export const promise = async ({ global, locale, overrideAccess, + path: [...fieldPath, i], populationPromises, req, + schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc: row || {}, triggerAccessControl, @@ -431,7 +440,7 @@ export const promise = async ({ } else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) { Object.values(rows).forEach((localeRows) => { if (Array.isArray(localeRows)) { - localeRows.forEach((row) => { + localeRows.forEach((row, i) => { const block = field.blocks.find((blockType) => blockType.slug === row.blockType) if (block) { @@ -450,8 +459,10 @@ export const promise = async ({ global, locale, overrideAccess, + path: [...fieldPath, i], populationPromises, req, + schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc: row || {}, triggerAccessControl, @@ -485,8 +496,10 @@ export const promise = async ({ global, locale, overrideAccess, + path: fieldPath, populationPromises, req, + schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc, triggerAccessControl, @@ -518,8 +531,10 @@ export const promise = async ({ global, locale, overrideAccess, + path: fieldPath, populationPromises, req, + schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc: tabDoc, triggerAccessControl, @@ -545,8 +560,10 @@ export const promise = async ({ global, locale, overrideAccess, + path: fieldPath, populationPromises, req, + schemaPath: fieldSchemaPath, showHiddenFields, siblingDoc, triggerAccessControl, @@ -555,6 +572,101 @@ export const promise = async ({ break } + case 'richText': { + if (!field?.editor) { + throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor + } + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + + const editor: RichTextAdapter = field?.editor + + if (editor?.hooks?.afterRead?.length) { + await editor.hooks.afterRead.reduce(async (priorHook, currentHook) => { + await priorHook + + const shouldRunHookOnAllLocales = + field.localized && + (locale === 'all' || !flattenLocales) && + typeof siblingDoc[field.name] === 'object' + + if (shouldRunHookOnAllLocales) { + const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) => + (async () => { + const hookedValue = await currentHook({ + collection, + context, + currentDepth, + data: doc, + depth, + draft, + fallbackLocale, + field, + fieldPromises, + findMany, + flattenLocales, + global, + locale, + operation: 'read', + originalDoc: doc, + overrideAccess, + path: fieldPath, + populationPromises, + req, + schemaPath: fieldSchemaPath, + showHiddenFields, + siblingData: siblingDoc, + triggerAccessControl, + triggerHooks, + value, + }) + + if (hookedValue !== undefined) { + siblingDoc[field.name][locale] = hookedValue + } + })(), + ) + + await Promise.all(hookPromises) + } else { + const hookedValue = await currentHook({ + collection, + context, + currentDepth, + data: doc, + depth, + draft, + fallbackLocale, + field, + fieldPromises, + findMany, + flattenLocales, + global, + locale, + operation: 'read', + originalDoc: doc, + overrideAccess, + path: fieldPath, + populationPromises, + req, + schemaPath: fieldSchemaPath, + showHiddenFields, + siblingData: siblingDoc, + triggerAccessControl, + triggerHooks, + value: siblingDoc[field.name], + }) + + if (hookedValue !== undefined) { + siblingDoc[field.name] = hookedValue + } + } + }, Promise.resolve()) + } + break + } + default: { break } diff --git a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts index cc09728e5..36c9ec106 100644 --- a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts +++ b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts @@ -23,8 +23,10 @@ type Args = { global: SanitizedGlobalConfig | null locale: null | string overrideAccess: boolean + path: (number | string)[] populationPromises: Promise[] req: PayloadRequestWithData + schemaPath: string[] showHiddenFields: boolean siblingDoc: Record triggerAccessControl?: boolean @@ -46,8 +48,10 @@ export const traverseFields = ({ global, locale, overrideAccess, + path, populationPromises, req, + schemaPath, showHiddenFields, siblingDoc, triggerAccessControl = true, @@ -70,6 +74,8 @@ export const traverseFields = ({ global, locale, overrideAccess, + parentPath: path, + parentSchemaPath: schemaPath, populationPromises, req, showHiddenFields, diff --git a/packages/payload/src/fields/hooks/beforeChange/index.ts b/packages/payload/src/fields/hooks/beforeChange/index.ts index 8975b2f32..435e1c5ff 100644 --- a/packages/payload/src/fields/hooks/beforeChange/index.ts +++ b/packages/payload/src/fields/hooks/beforeChange/index.ts @@ -27,7 +27,7 @@ type Args = { * - Validate data * - Transform data for storage * - beforeDuplicate hooks (if duplicate) - * - Unflatten locales + * - Unflatten locales. The input `data` is the normal document for one locale. The output result will become the document with locales. */ export const beforeChange = async >({ id, @@ -59,8 +59,9 @@ export const beforeChange = async >({ global, mergeLocaleActions, operation, - path: '', + path: [], req, + schemaPath: [], siblingData: data, siblingDoc: doc, siblingDocWithLocales: docWithLocales, diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts index ea7479988..18c8a36e9 100644 --- a/packages/payload/src/fields/hooks/beforeChange/promise.ts +++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts @@ -1,11 +1,14 @@ import merge from 'deepmerge' +import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { Operation, PayloadRequestWithData, RequestContext } from '../../../types/index.js' import type { Field, FieldHookArgs, TabAsField, ValidateOptions } from '../../config/types.js' +import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName } from '../../config/types.js' +import { getFieldPaths } from '../../getFieldPaths.js' import { beforeDuplicate } from './beforeDuplicate.js' import { getExistingRowDoc } from './getExistingRowDoc.js' import { traverseFields } from './traverseFields.js' @@ -23,7 +26,14 @@ type Args = { id?: number | string mergeLocaleActions: (() => Promise)[] operation: Operation - path: string + /** + * The parent's path. + */ + parentPath: (number | string)[] + /** + * The parent's schemaPath (path without indexes). + */ + parentSchemaPath: string[] req: PayloadRequestWithData siblingData: Record siblingDoc: Record @@ -52,7 +62,8 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path, + parentPath, + parentSchemaPath, req, siblingData, siblingDoc, @@ -67,6 +78,12 @@ export const promise = async ({ const defaultLocale = localization ? localization?.defaultLocale : 'en' const operationLocale = req.locale || defaultLocale + const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({ + field, + parentPath, + parentSchemaPath, + }) + if (fieldAffectsData(field)) { // skip validation if the field is localized and the incoming data is null if (field.localized && operationLocale !== defaultLocale) { @@ -88,10 +105,13 @@ export const promise = async ({ global, operation, originalDoc: doc, + path: fieldPath, previousSiblingDoc: siblingDoc, previousValue: siblingDoc[field.name], req, + schemaPath: parentSchemaPath, siblingData, + siblingDocWithLocales, value: siblingData[field.name], }) @@ -127,7 +147,7 @@ export const promise = async ({ if (typeof validationResult === 'string') { errors.push({ - field: `${path}${field.name}`, + field: fieldPath.join('.'), message: validationResult, }) } @@ -139,8 +159,13 @@ export const promise = async ({ data, field, global: undefined, + path: fieldPath, + previousSiblingDoc: siblingDoc, + previousValue: siblingDoc[field.name], req, + schemaPath: parentSchemaPath, siblingData, + siblingDocWithLocales, value: siblingData[field.name], } @@ -225,8 +250,9 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: `${path}${field.name}.`, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData: siblingData[field.name] as Record, siblingDoc: siblingDoc[field.name] as Record, siblingDocWithLocales: siblingDocWithLocales[field.name] as Record, @@ -256,8 +282,9 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: `${path}${field.name}.${i}.`, + path: [...fieldPath, i], req, + schemaPath: fieldSchemaPath, siblingData: row, siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]), siblingDocWithLocales: getExistingRowDoc(row, siblingDocWithLocales[field.name]), @@ -299,8 +326,9 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: `${path}${field.name}.${i}.`, + path: [...fieldPath, i], req, + schemaPath: fieldSchemaPath, siblingData: row, siblingDoc: rowSiblingDoc, siblingDocWithLocales: rowSiblingDocWithLocales, @@ -331,8 +359,9 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData, siblingDoc, siblingDocWithLocales, @@ -343,13 +372,11 @@ export const promise = async ({ } case 'tab': { - let tabPath = path let tabSiblingData = siblingData let tabSiblingDoc = siblingDoc let tabSiblingDocWithLocales = siblingDocWithLocales if (tabHasName(field)) { - tabPath = `${path}${field.name}.` if (typeof siblingData[field.name] !== 'object') siblingData[field.name] = {} if (typeof siblingDoc[field.name] !== 'object') siblingDoc[field.name] = {} if (typeof siblingDocWithLocales[field.name] !== 'object') @@ -373,8 +400,9 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path: tabPath, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData: tabSiblingData, siblingDoc: tabSiblingDoc, siblingDocWithLocales: tabSiblingDocWithLocales, @@ -398,8 +426,9 @@ export const promise = async ({ global, mergeLocaleActions, operation, - path, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData, siblingDoc, siblingDocWithLocales, @@ -409,6 +438,52 @@ export const promise = async ({ break } + case 'richText': { + if (!field?.editor) { + throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor + } + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + + const editor: RichTextAdapter = field?.editor + + if (editor?.hooks?.beforeChange?.length) { + await editor.hooks.beforeChange.reduce(async (priorHook, currentHook) => { + await priorHook + + const hookedValue = await currentHook({ + collection, + context, + data, + docWithLocales, + duplicate, + errors, + field, + global, + mergeLocaleActions, + operation, + originalDoc: doc, + path: fieldPath, + previousSiblingDoc: siblingDoc, + previousValue: siblingDoc[field.name], + req, + schemaPath: parentSchemaPath, + siblingData, + siblingDocWithLocales, + skipValidation, + value: siblingData[field.name], + }) + + if (hookedValue !== undefined) { + siblingData[field.name] = hookedValue + } + }, Promise.resolve()) + } + + break + } + default: { break } diff --git a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts index 41944b59f..5c9bbac79 100644 --- a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts @@ -24,8 +24,9 @@ type Args = { id?: number | string mergeLocaleActions: (() => Promise)[] operation: Operation - path: string + path: (number | string)[] req: PayloadRequestWithData + schemaPath: string[] siblingData: Record /** * The original siblingData (not modified by any hooks) @@ -44,7 +45,7 @@ type Args = { * - Execute field hooks * - Validate data * - Transform data for storage - * - Unflatten locales + * - Unflatten locales. The input `data` is the normal document for one locale. The output result will become the document with locales. */ export const traverseFields = async ({ id, @@ -61,6 +62,7 @@ export const traverseFields = async ({ operation, path, req, + schemaPath, siblingData, siblingDoc, siblingDocWithLocales, @@ -83,7 +85,8 @@ export const traverseFields = async ({ global, mergeLocaleActions, operation, - path, + parentPath: path, + parentSchemaPath: schemaPath, req, siblingData, siblingDoc, diff --git a/packages/payload/src/fields/hooks/beforeValidate/index.ts b/packages/payload/src/fields/hooks/beforeValidate/index.ts index df9c0be6b..59f5cca89 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/index.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/index.ts @@ -49,7 +49,9 @@ export const beforeValidate = async >({ global, operation, overrideAccess, + path: [], req, + schemaPath: [], siblingData: data, siblingDoc: doc, }) diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts index d8a8433f9..0e7a6c1ed 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts @@ -1,11 +1,14 @@ /* eslint-disable no-param-reassign */ +import type { RichTextAdapter } from '../../../admin/RichText.js' import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' import type { SanitizedGlobalConfig } from '../../../globals/config/types.js' import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js' import type { Field, TabAsField } from '../../config/types.js' +import { MissingEditorProp } from '../../../errors/index.js' import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js' import getValueWithDefault from '../../getDefaultValue.js' +import { getFieldPaths } from '../../getFieldPaths.js' import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js' import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js' import { traverseFields } from './traverseFields.js' @@ -23,6 +26,8 @@ type Args = { id?: number | string operation: 'create' | 'update' overrideAccess: boolean + parentPath: (number | string)[] + parentSchemaPath: string[] req: PayloadRequestWithData siblingData: Record /** @@ -48,10 +53,18 @@ export const promise = async ({ global, operation, overrideAccess, + parentPath, + parentSchemaPath, req, siblingData, siblingDoc, }: Args): Promise => { + const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({ + field, + parentPath, + parentSchemaPath, + }) + if (fieldAffectsData(field)) { if (field.name === 'id') { if (field.type === 'number' && typeof siblingData[field.name] === 'string') { @@ -229,8 +242,11 @@ export const promise = async ({ operation, originalDoc: doc, overrideAccess, + path: fieldPath, previousSiblingDoc: siblingDoc, + previousValue: siblingData[field.name], req, + schemaPath: fieldSchemaPath, siblingData, value: siblingData[field.name], }) @@ -288,7 +304,9 @@ export const promise = async ({ global, operation, overrideAccess, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData: groupData, siblingDoc: groupDoc, }) @@ -301,7 +319,7 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row) => { + rows.forEach((row, i) => { promises.push( traverseFields({ id, @@ -313,7 +331,9 @@ export const promise = async ({ global, operation, overrideAccess, + path: [...fieldPath, i], req, + schemaPath: fieldSchemaPath, siblingData: row, siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]), }), @@ -329,7 +349,7 @@ export const promise = async ({ if (Array.isArray(rows)) { const promises = [] - rows.forEach((row) => { + rows.forEach((row, i) => { const rowSiblingDoc = getExistingRowDoc(row, siblingDoc[field.name]) const blockTypeToMatch = row.blockType || rowSiblingDoc.blockType const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch) @@ -348,7 +368,9 @@ export const promise = async ({ global, operation, overrideAccess, + path: [...fieldPath, i], req, + schemaPath: fieldSchemaPath, siblingData: row, siblingDoc: rowSiblingDoc, }), @@ -373,7 +395,9 @@ export const promise = async ({ global, operation, overrideAccess, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData, siblingDoc, }) @@ -405,7 +429,9 @@ export const promise = async ({ global, operation, overrideAccess, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData: tabSiblingData, siblingDoc: tabSiblingDoc, }) @@ -424,7 +450,9 @@ export const promise = async ({ global, operation, overrideAccess, + path: fieldPath, req, + schemaPath: fieldSchemaPath, siblingData, siblingDoc, }) @@ -432,6 +460,46 @@ export const promise = async ({ break } + case 'richText': { + if (!field?.editor) { + throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor + } + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + + const editor: RichTextAdapter = field?.editor + + if (editor?.hooks?.beforeValidate?.length) { + await editor.hooks.beforeValidate.reduce(async (priorHook, currentHook) => { + await priorHook + + const hookedValue = await currentHook({ + collection, + context, + data, + field, + global, + operation, + originalDoc: doc, + overrideAccess, + path: fieldPath, + previousSiblingDoc: siblingDoc, + previousValue: siblingData[field.name], + req, + schemaPath: fieldSchemaPath, + siblingData, + value: siblingData[field.name], + }) + + if (hookedValue !== undefined) { + siblingData[field.name] = hookedValue + } + }, Promise.resolve()) + } + break + } + default: { break } diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts index a25e4c35f..e0ef4ccf3 100644 --- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts +++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts @@ -18,7 +18,9 @@ type Args = { id?: number | string operation: 'create' | 'update' overrideAccess: boolean + path: (number | string)[] req: PayloadRequestWithData + schemaPath: string[] siblingData: Record /** * The original siblingData (not modified by any hooks) @@ -36,7 +38,9 @@ export const traverseFields = async ({ global, operation, overrideAccess, + path, req, + schemaPath, siblingData, siblingDoc, }: Args): Promise => { @@ -53,6 +57,8 @@ export const traverseFields = async ({ global, operation, overrideAccess, + parentPath: path, + parentSchemaPath: schemaPath, req, siblingData, siblingDoc, diff --git a/packages/richtext-lexical/src/field/features/blocks/component/BlockContent.tsx b/packages/richtext-lexical/src/field/features/blocks/component/BlockContent.tsx index 3822faa4c..7859aee47 100644 --- a/packages/richtext-lexical/src/field/features/blocks/component/BlockContent.tsx +++ b/packages/richtext-lexical/src/field/features/blocks/component/BlockContent.tsx @@ -51,7 +51,6 @@ export const BlockContent: React.FC = (props) => { formData, formSchema, nodeKey, - path, reducedBlock: { labels }, schemaPath, } = props @@ -111,17 +110,21 @@ export const BlockContent: React.FC = (props) => { // does not have, even if it's undefined. // Currently, this happens if a block has another sub-blocks field. Inside formData, that sub-blocks field has an undefined blockName property. // Inside of fields.data however, that sub-blocks blockName property does not exist at all. - function removeUndefinedAndNullRecursively(obj: object) { - Object.keys(obj).forEach((key) => { - if (obj[key] && typeof obj[key] === 'object') { - removeUndefinedAndNullRecursively(obj[key]) - } else if (obj[key] === undefined || obj[key] === null) { + function removeUndefinedAndNullAndEmptyArraysRecursively(obj: object) { + for (const key in obj) { + const value = obj[key] + if (Array.isArray(value) && !value?.length) { + delete obj[key] + } else if (value && typeof value === 'object') { + removeUndefinedAndNullAndEmptyArraysRecursively(value) + } else if (value === undefined || value === null) { delete obj[key] } - }) + } } - removeUndefinedAndNullRecursively(newFormData) - removeUndefinedAndNullRecursively(formData) + removeUndefinedAndNullAndEmptyArraysRecursively(newFormData) + + removeUndefinedAndNullAndEmptyArraysRecursively(formData) // Only update if the data has actually changed. Otherwise, we may be triggering an unnecessary value change, // which would trigger the "Leave without saving" dialog unnecessarily diff --git a/packages/richtext-lexical/src/field/features/blocks/component/index.tsx b/packages/richtext-lexical/src/field/features/blocks/component/index.tsx index 74099a9ec..298982404 100644 --- a/packages/richtext-lexical/src/field/features/blocks/component/index.tsx +++ b/packages/richtext-lexical/src/field/features/blocks/component/index.tsx @@ -29,10 +29,8 @@ import type { BlocksFeatureClientProps } from '../feature.client.js' import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js' import { BlockContent } from './BlockContent.js' import './index.scss' -import { removeEmptyArrayValues } from './removeEmptyArrayValues.js' type Props = { - blockFieldWrapperName: string children?: React.ReactNode formData: BlockFields @@ -44,7 +42,7 @@ type Props = { } export const BlockComponent: React.FC = (props) => { - const { blockFieldWrapperName, formData, nodeKey } = props + const { formData, nodeKey } = props const config = useConfig() const submitted = useFormSubmitted() const { id } = useDocumentInfo() @@ -81,7 +79,7 @@ export const BlockComponent: React.FC = (props) => { if (state) { setInitialState({ - ...removeEmptyArrayValues({ fields: state }), + ...state, blockName: { initialValue: '', passesCondition: true, @@ -175,6 +173,7 @@ export const BlockComponent: React.FC = (props) => { ) }, [ + classNames, fieldMap, parentLexicalRichTextField, nodeKey, @@ -182,7 +181,6 @@ export const BlockComponent: React.FC = (props) => { submitted, initialState, reducedBlock, - blockFieldWrapperName, onChange, schemaFieldsPath, path, diff --git a/packages/richtext-lexical/src/field/features/blocks/feature.server.ts b/packages/richtext-lexical/src/field/features/blocks/feature.server.ts index 14e8df6dd..95e841290 100644 --- a/packages/richtext-lexical/src/field/features/blocks/feature.server.ts +++ b/packages/richtext-lexical/src/field/features/blocks/feature.server.ts @@ -10,9 +10,9 @@ import type { BlocksFeatureClientProps } from './feature.client.js' import { createNode } from '../typeUtilities.js' import { BlocksFeatureClientComponent } from './feature.client.js' +import { blockPopulationPromiseHOC } from './graphQLPopulationPromise.js' import { i18n } from './i18n.js' import { BlockNode } from './nodes/BlocksNode.js' -import { blockPopulationPromiseHOC } from './populationPromise.js' import { blockValidationHOC } from './validate.js' export type BlocksFeatureProps = { @@ -114,71 +114,17 @@ export const BlocksFeature: FeatureProviderProviderServer< i18n, nodes: [ createNode({ - /* // TODO: Implement these hooks once docWithLocales / originalSiblingDoc => node matching has been figured out - hooks: { - beforeChange: [ - async ({ context, findMany, node, operation, overrideAccess, req }) => { - const blockType = node.fields.blockType + getSubFields: ({ node, req }) => { + const blockType = node.fields.blockType - const block = deepCopyObject( - props.blocks.find((block) => block.slug === blockType), - ) - - - await beforeChangeTraverseFields({ - id: null, - collection: null, - context, - data: node.fields, - doc: node.fields, - fields: sanitizedBlock.fields, - global: null, - mergeLocaleActions: [], - operation: - operation === 'create' || operation === 'update' ? operation : 'update', - overrideAccess, - path: '', - req, - siblingData: node.fields, - siblingDoc: node.fields, - }) - - - return node - }, - ], - beforeValidate: [ - async ({ context, findMany, node, operation, overrideAccess, req }) => { - const blockType = node.fields.blockType - - const block = deepCopyObject( - props.blocks.find((block) => block.slug === blockType), - ) - - - - await beforeValidateTraverseFields({ - id: null, - collection: null, - context, - data: node.fields, - doc: node.fields, - fields: sanitizedBlock.fields, - global: null, - operation: - operation === 'create' || operation === 'update' ? operation : 'update', - overrideAccess, - req, - siblingData: node.fields, - siblingDoc: node.fields, - }) - - return node - }, - ], - },*/ + const block = props.blocks.find((block) => block.slug === blockType) + return block?.fields + }, + getSubFieldsData: ({ node }) => { + return node?.fields + }, + graphQLPopulationPromises: [blockPopulationPromiseHOC(props)], node: BlockNode, - populationPromises: [blockPopulationPromiseHOC(props)], validations: [blockValidationHOC(props)], }), ], diff --git a/packages/richtext-lexical/src/field/features/blocks/populationPromise.ts b/packages/richtext-lexical/src/field/features/blocks/graphQLPopulationPromise.ts similarity index 69% rename from packages/richtext-lexical/src/field/features/blocks/populationPromise.ts rename to packages/richtext-lexical/src/field/features/blocks/graphQLPopulationPromise.ts index 987f221ee..28c787b21 100644 --- a/packages/richtext-lexical/src/field/features/blocks/populationPromise.ts +++ b/packages/richtext-lexical/src/field/features/blocks/graphQLPopulationPromise.ts @@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js' import type { BlocksFeatureProps } from './feature.server.js' import type { SerializedBlockNode } from './nodes/BlocksNode.js' -import { recurseNestedFields } from '../../../populate/recurseNestedFields.js' +import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js' export const blockPopulationPromiseHOC = ( props: BlocksFeatureProps, @@ -21,7 +21,6 @@ export const blockPopulationPromiseHOC = ( populationPromises, req, showHiddenFields, - siblingDoc, }) => { const blockFieldData = node.fields @@ -31,22 +30,21 @@ export const blockPopulationPromiseHOC = ( return } - recurseNestedFields({ + recursivelyPopulateFieldsForGraphQL({ context, currentDepth, data: blockFieldData, depth, + draft, editorPopulationPromises, fieldPromises, fields: block.fields, findMany, - flattenLocales: false, // Disable localization handling which does not work properly yet. Once we fully support hooks, this can be enabled (pass through flattenLocales again) + flattenLocales, overrideAccess, populationPromises, req, showHiddenFields, - // The afterReadPromise gets its data from looking for field.name inside the siblingDoc. Thus, here we cannot pass the whole document's siblingDoc, but only the siblingDoc (sibling fields) of the current field. - draft, siblingDoc: blockFieldData, }) } diff --git a/packages/richtext-lexical/src/field/features/link/drawer/baseFields.ts b/packages/richtext-lexical/src/field/features/link/drawer/baseFields.ts index 9992e89f4..be58cc065 100644 --- a/packages/richtext-lexical/src/field/features/link/drawer/baseFields.ts +++ b/packages/richtext-lexical/src/field/features/link/drawer/baseFields.ts @@ -1,6 +1,6 @@ import type { User } from 'payload/auth' import type { SanitizedConfig } from 'payload/config' -import type { Field, RadioField, TextField } from 'payload/types' +import type { FieldAffectingData, RadioField, TextField } from 'payload/types' import { validateUrl } from '../../../lexical/utils/url.js' @@ -9,7 +9,7 @@ export const getBaseFields = ( enabledCollections: false | string[], disabledCollections: false | string[], maxDepth?: number, -): Field[] => { +): FieldAffectingData[] => { let enabledRelations: string[] /** @@ -33,7 +33,7 @@ export const getBaseFields = ( .map(({ slug }) => slug) } - const baseFields: Field[] = [ + const baseFields: FieldAffectingData[] = [ { name: 'text', type: 'text', diff --git a/packages/richtext-lexical/src/field/features/link/feature.server.ts b/packages/richtext-lexical/src/field/features/link/feature.server.ts index 86e704749..61ee87e97 100644 --- a/packages/richtext-lexical/src/field/features/link/feature.server.ts +++ b/packages/richtext-lexical/src/field/features/link/feature.server.ts @@ -1,5 +1,5 @@ import type { Config, SanitizedConfig } from 'payload/config' -import type { Field } from 'payload/types' +import type { Field, FieldAffectingData } from 'payload/types' import { traverseFields } from '@payloadcms/ui/utilities/buildFieldSchemaMap/traverseFields' import { sanitizeFields } from 'payload/config' @@ -11,12 +11,12 @@ import type { ClientProps } from './feature.client.js' import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js' import { createNode } from '../typeUtilities.js' import { LinkFeatureClientComponent } from './feature.client.js' +import { linkPopulationPromiseHOC } from './graphQLPopulationPromise.js' import { i18n } from './i18n.js' import { LinkMarkdownTransformer } from './markdownTransformer.js' import { AutoLinkNode } from './nodes/AutoLinkNode.js' import { LinkNode } from './nodes/LinkNode.js' import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js' -import { linkPopulationPromiseHOC } from './populationPromise.js' import { linkValidation } from './validate.js' export type ExclusiveLinkCollectionsProps = @@ -46,7 +46,12 @@ export type LinkFeatureServerProps = ExclusiveLinkCollectionsProps & { * A function or array defining additional fields for the link feature. These will be * displayed in the link editor drawer. */ - fields?: ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[]) | Field[] + fields?: + | ((args: { + config: SanitizedConfig + defaultFields: FieldAffectingData[] + }) => (Field | FieldAffectingData)[]) + | Field[] /** * Sets a maximum population depth for the internal doc default field of link, regardless of the remaining depth when the field is reached. * This behaves exactly like the maxDepth properties of relationship and upload fields. @@ -82,6 +87,13 @@ export const LinkFeature: FeatureProviderProviderServer field.name !== 'text', + ) + return { ClientComponent: LinkFeatureClientComponent, clientFeatureProps: { @@ -143,16 +155,9 @@ export const LinkFeature: FeatureProviderProviderServer { - return node - }, - ], - }, node: AutoLinkNode, - populationPromises: [linkPopulationPromiseHOC(props)], - validations: [linkValidation(props)], + // Since AutoLinkNodes are just internal links, they need no hooks or graphQL population promises + validations: [linkValidation(props, sanitizedFieldsWithoutText)], }), createNode({ converters: { @@ -181,9 +186,15 @@ export const LinkFeature: FeatureProviderProviderServer { + return sanitizedFieldsWithoutText + }, + getSubFieldsData: ({ node }) => { + return node?.fields + }, + graphQLPopulationPromises: [linkPopulationPromiseHOC(props)], node: LinkNode, - populationPromises: [linkPopulationPromiseHOC(props)], - validations: [linkValidation(props)], + validations: [linkValidation(props, sanitizedFieldsWithoutText)], }), ], serverFeatureProps: props, diff --git a/packages/richtext-lexical/src/field/features/link/populationPromise.ts b/packages/richtext-lexical/src/field/features/link/graphQLPopulationPromise.ts similarity index 79% rename from packages/richtext-lexical/src/field/features/link/populationPromise.ts rename to packages/richtext-lexical/src/field/features/link/graphQLPopulationPromise.ts index 8a52e9e7f..385b978d1 100644 --- a/packages/richtext-lexical/src/field/features/link/populationPromise.ts +++ b/packages/richtext-lexical/src/field/features/link/graphQLPopulationPromise.ts @@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js' import type { LinkFeatureServerProps } from './feature.server.js' import type { SerializedLinkNode } from './nodes/types.js' -import { recurseNestedFields } from '../../../populate/recurseNestedFields.js' +import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js' export const linkPopulationPromiseHOC = ( props: LinkFeatureServerProps, @@ -30,7 +30,7 @@ export const linkPopulationPromiseHOC = ( * Should populate all fields, including the doc field (for internal links), as it's treated like a normal field */ if (Array.isArray(props.fields)) { - recurseNestedFields({ + recursivelyPopulateFieldsForGraphQL({ context, currentDepth, data: node.fields, @@ -40,7 +40,7 @@ export const linkPopulationPromiseHOC = ( fieldPromises, fields: props.fields, findMany, - flattenLocales: false, // Disable localization handling which does not work properly yet. Once we fully support hooks, this can be enabled (pass through flattenLocales again) + flattenLocales, overrideAccess, populationPromises, req, diff --git a/packages/richtext-lexical/src/field/features/link/nodes/AutoLinkNode.ts b/packages/richtext-lexical/src/field/features/link/nodes/AutoLinkNode.ts index 007e41244..45dfab93b 100644 --- a/packages/richtext-lexical/src/field/features/link/nodes/AutoLinkNode.ts +++ b/packages/richtext-lexical/src/field/features/link/nodes/AutoLinkNode.ts @@ -11,7 +11,7 @@ import { LinkNode } from './LinkNode.js' export class AutoLinkNode extends LinkNode { static clone(node: AutoLinkNode): AutoLinkNode { - return new AutoLinkNode({ fields: node.__fields, key: node.__key }) + return new AutoLinkNode({ id: undefined, fields: node.__fields, key: node.__key }) } static getType(): string { @@ -61,7 +61,7 @@ export class AutoLinkNode extends LinkNode { } export function $createAutoLinkNode({ fields }: { fields: LinkFields }): AutoLinkNode { - return $applyNodeReplacement(new AutoLinkNode({ fields })) + return $applyNodeReplacement(new AutoLinkNode({ id: undefined, fields })) } export function $isAutoLinkNode(node: LexicalNode | null | undefined): node is AutoLinkNode { return node instanceof AutoLinkNode diff --git a/packages/richtext-lexical/src/field/features/link/nodes/LinkNode.ts b/packages/richtext-lexical/src/field/features/link/nodes/LinkNode.ts index bb21892fc..40fc39fe8 100644 --- a/packages/richtext-lexical/src/field/features/link/nodes/LinkNode.ts +++ b/packages/richtext-lexical/src/field/features/link/nodes/LinkNode.ts @@ -11,6 +11,7 @@ import type { } from 'lexical' import { addClassNamesToElement, isHTMLAnchorElement } from '@lexical/utils' +import ObjectID from 'bson-objectid' import { $applyNodeReplacement, $createTextNode, @@ -29,8 +30,10 @@ const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', ' /** @noInheritDoc */ export class LinkNode extends ElementNode { __fields: LinkFields + __id: string constructor({ + id, fields = { doc: null, linkType: 'custom', @@ -40,14 +43,17 @@ export class LinkNode extends ElementNode { key, }: { fields: LinkFields + id: string key?: NodeKey }) { super(key) this.__fields = fields + this.__id = id } static clone(node: LinkNode): LinkNode { return new LinkNode({ + id: node.__id, fields: node.__fields, key: node.__key, }) @@ -76,7 +82,13 @@ export class LinkNode extends ElementNode { serializedNode.version = 2 } + if (serializedNode.version === 2 && !serializedNode.id) { + serializedNode.id = new ObjectID.default().toHexString() + serializedNode.version = 3 + } + const node = $createLinkNode({ + id: serializedNode.id, fields: serializedNode.fields, }) node.setFormat(serializedNode.format) @@ -115,12 +127,17 @@ export class LinkNode extends ElementNode { } exportJSON(): SerializedLinkNode { - return { + const returnObject: SerializedLinkNode = { ...super.exportJSON(), type: this.getType(), fields: this.getFields(), - version: 2, + version: 3, } + const id = this.getID() + if (id) { + returnObject.id = id + } + return returnObject } extractWithChild( @@ -146,6 +163,10 @@ export class LinkNode extends ElementNode { return this.getLatest().__fields } + getID(): string { + return this.getLatest().__id + } + insertNewAfter(selection: RangeSelection, restoreSelection = true): ElementNodeType | null { const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection) if ($isElementNode(element)) { @@ -216,6 +237,7 @@ function $convertAnchorElement(domNode: Node): DOMConversionOutput { const content = domNode.textContent if (content !== null && content !== '') { node = $createLinkNode({ + id: new ObjectID.default().toHexString(), fields: { doc: null, linkType: 'custom', @@ -228,8 +250,13 @@ function $convertAnchorElement(domNode: Node): DOMConversionOutput { return { node } } -export function $createLinkNode({ fields }: { fields: LinkFields }): LinkNode { - return $applyNodeReplacement(new LinkNode({ fields })) +export function $createLinkNode({ id, fields }: { fields: LinkFields; id?: string }): LinkNode { + return $applyNodeReplacement( + new LinkNode({ + id: id ?? new ObjectID.default().toHexString(), + fields, + }), + ) } export function $isLinkNode(node: LexicalNode | null | undefined): node is LinkNode { @@ -349,8 +376,6 @@ export function $toggleLink(payload: LinkPayload): void { }) } } -/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */ -export const toggleLink = $toggleLink function $getLinkAncestor(node: LexicalNode): LinkNode | null { return $getAncestor(node, (ancestor) => $isLinkNode(ancestor)) as LinkNode diff --git a/packages/richtext-lexical/src/field/features/link/nodes/types.ts b/packages/richtext-lexical/src/field/features/link/nodes/types.ts index b5b51ee19..c6488b838 100644 --- a/packages/richtext-lexical/src/field/features/link/nodes/types.ts +++ b/packages/richtext-lexical/src/field/features/link/nodes/types.ts @@ -21,7 +21,8 @@ export type LinkFields = { export type SerializedLinkNode = Spread< { fields: LinkFields + id?: string // optional if AutoLinkNode }, SerializedElementNode > -export type SerializedAutoLinkNode = SerializedLinkNode +export type SerializedAutoLinkNode = Omit diff --git a/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/LinkEditor/index.tsx b/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/LinkEditor/index.tsx index c67bdb2bf..ebb07d298 100644 --- a/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/LinkEditor/index.tsx +++ b/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/LinkEditor/index.tsx @@ -48,7 +48,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R const { i18n, t } = useTranslation() - const [stateData, setStateData] = useState<{} | (LinkFields & { text: string })>({}) + const [stateData, setStateData] = useState<{} | (LinkFields & { id?: string; text: string })>({}) const { closeModal, isModalOpen, toggleModal } = useModal() const editDepth = useEditDepth() @@ -114,6 +114,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R newTab: undefined, url: '', ...focusLinkParent.getFields(), + id: focusLinkParent.getID(), text: focusLinkParent.getTextContent(), } diff --git a/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/utilities.ts b/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/utilities.ts index 38de17909..40efad636 100644 --- a/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/utilities.ts +++ b/packages/richtext-lexical/src/field/features/link/plugins/floatingLinkEditor/utilities.ts @@ -1,5 +1,5 @@ import type { SanitizedConfig } from 'payload/config' -import type { Field } from 'payload/types' +import type { Field, FieldAffectingData } from 'payload/types' import { getBaseFields } from '../../drawer/baseFields.js' @@ -8,14 +8,14 @@ import { getBaseFields } from '../../drawer/baseFields.js' */ export function transformExtraFields( customFieldSchema: - | ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[]) + | ((args: { config: SanitizedConfig; defaultFields: FieldAffectingData[] }) => Field[]) | Field[], config: SanitizedConfig, enabledCollections?: false | string[], disabledCollections?: false | string[], maxDepth?: number, ): Field[] { - const baseFields: Field[] = getBaseFields( + const baseFields: FieldAffectingData[] = getBaseFields( config, enabledCollections, disabledCollections, @@ -29,7 +29,7 @@ export function transformExtraFields( } else if (Array.isArray(customFieldSchema)) { fields = customFieldSchema } else { - fields = baseFields + fields = baseFields as Field[] } return fields diff --git a/packages/richtext-lexical/src/field/features/link/plugins/link/index.tsx b/packages/richtext-lexical/src/field/features/link/plugins/link/index.tsx index 3c8baf696..68d69718a 100644 --- a/packages/richtext-lexical/src/field/features/link/plugins/link/index.tsx +++ b/packages/richtext-lexical/src/field/features/link/plugins/link/index.tsx @@ -16,7 +16,7 @@ import type { LinkFields } from '../../nodes/types.js' import type { LinkPayload } from '../floatingLinkEditor/types.js' import { validateUrl } from '../../../../lexical/utils/url.js' -import { LinkNode, TOGGLE_LINK_COMMAND, toggleLink } from '../../nodes/LinkNode.js' +import { $toggleLink, LinkNode, TOGGLE_LINK_COMMAND } from '../../nodes/LinkNode.js' export const LinkPlugin: PluginComponent = () => { const [editor] = useLexicalComposerContext() @@ -29,7 +29,7 @@ export const LinkPlugin: PluginComponent = () => { editor.registerCommand( TOGGLE_LINK_COMMAND, (payload: LinkPayload) => { - toggleLink(payload) + $toggleLink(payload) return true }, COMMAND_PRIORITY_LOW, diff --git a/packages/richtext-lexical/src/field/features/link/validate.ts b/packages/richtext-lexical/src/field/features/link/validate.ts index 09c37b80a..86250254c 100644 --- a/packages/richtext-lexical/src/field/features/link/validate.ts +++ b/packages/richtext-lexical/src/field/features/link/validate.ts @@ -8,6 +8,7 @@ import type { SerializedAutoLinkNode, SerializedLinkNode } from './nodes/types.j export const linkValidation = ( props: LinkFeatureServerProps, + sanitizedFieldsWithoutText: Field[], // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents ): NodeValidation => { return async ({ @@ -20,19 +21,14 @@ export const linkValidation = ( * Run buildStateFromSchema as that properly validates link fields and link sub-fields */ - const data = { - ...node.fields, - text: 'ignored', - } - const result = await buildStateFromSchema({ id, - data, - fieldSchema: props.fields as Field[], // Sanitized in feature.server.ts + data: node.fields, + fieldSchema: sanitizedFieldsWithoutText, // Sanitized in feature.server.ts operation: operation === 'create' || operation === 'update' ? operation : 'update', preferences, req, - siblingData: data, + siblingData: node.fields, }) let errorPaths = [] diff --git a/packages/richtext-lexical/src/field/features/relationship/feature.server.ts b/packages/richtext-lexical/src/field/features/relationship/feature.server.ts index 4804ba647..b0733342a 100644 --- a/packages/richtext-lexical/src/field/features/relationship/feature.server.ts +++ b/packages/richtext-lexical/src/field/features/relationship/feature.server.ts @@ -1,10 +1,11 @@ import type { FeatureProviderProviderServer } from '../types.js' +import { populate } from '../../../populateGraphQL/populate.js' import { createNode } from '../typeUtilities.js' import { RelationshipFeatureClientComponent } from './feature.client.js' +import { relationshipPopulationPromiseHOC } from './graphQLPopulationPromise.js' import { i18n } from './i18n.js' import { RelationshipNode } from './nodes/RelationshipNode.js' -import { relationshipPopulationPromiseHOC } from './populationPromise.js' export type ExclusiveRelationshipFeatureProps = | { @@ -50,8 +51,55 @@ export const RelationshipFeature: FeatureProviderProviderServer< i18n, nodes: [ createNode({ + graphQLPopulationPromises: [relationshipPopulationPromiseHOC(props)], + hooks: { + afterRead: [ + ({ + currentDepth, + depth, + draft, + node, + overrideAccess, + populationPromises, + req, + showHiddenFields, + }) => { + if (!node?.value) { + return node + } + const collection = req.payload.collections[node?.relationTo] + + if (!collection) { + return node + } + // @ts-expect-error + const id = node?.value?.id || node?.value // for backwards-compatibility + + const populateDepth = + props?.maxDepth !== undefined && props?.maxDepth < depth + ? props?.maxDepth + : depth + + populationPromises.push( + populate({ + id, + collection, + currentDepth, + data: node, + depth: populateDepth, + draft, + key: 'value', + overrideAccess, + req, + showHiddenFields, + }), + ) + + return node + }, + ], + }, node: RelationshipNode, - populationPromises: [relationshipPopulationPromiseHOC(props)], }), ], serverFeatureProps: props, diff --git a/packages/richtext-lexical/src/field/features/relationship/populationPromise.ts b/packages/richtext-lexical/src/field/features/relationship/graphQLPopulationPromise.ts similarity index 93% rename from packages/richtext-lexical/src/field/features/relationship/populationPromise.ts rename to packages/richtext-lexical/src/field/features/relationship/graphQLPopulationPromise.ts index 3547e978f..ce19e827d 100644 --- a/packages/richtext-lexical/src/field/features/relationship/populationPromise.ts +++ b/packages/richtext-lexical/src/field/features/relationship/graphQLPopulationPromise.ts @@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js' import type { RelationshipFeatureProps } from './feature.server.js' import type { SerializedRelationshipNode } from './nodes/RelationshipNode.js' -import { populate } from '../../../populate/populate.js' +import { populate } from '../../../populateGraphQL/populate.js' export const relationshipPopulationPromiseHOC = ( props: RelationshipFeatureProps, @@ -11,7 +11,6 @@ export const relationshipPopulationPromiseHOC = ( currentDepth, depth, draft, - field, node, overrideAccess, populationPromises, @@ -36,7 +35,6 @@ export const relationshipPopulationPromiseHOC = ( data: node, depth: populateDepth, draft, - field, key: 'value', overrideAccess, req, diff --git a/packages/richtext-lexical/src/field/features/types.ts b/packages/richtext-lexical/src/field/features/types.ts index e99855782..21a3e25d9 100644 --- a/packages/richtext-lexical/src/field/features/types.ts +++ b/packages/richtext-lexical/src/field/features/types.ts @@ -1,5 +1,5 @@ import type { Transformer } from '@lexical/markdown' -import type { GenericLanguages, I18n, I18nClient } from '@payloadcms/translations' +import type { GenericLanguages, I18nClient } from '@payloadcms/translations' import type { JSONSchema4 } from 'json-schema' import type { Klass, LexicalEditor, LexicalNode, SerializedEditorState } from 'lexical' import type { SerializedLexicalNode } from 'lexical' @@ -25,6 +25,7 @@ export type PopulationPromise = ClientFeatureProps extend order: number } & ClientFeatureProps -export type FieldNodeHookArgs = { - context: RequestContext +export type AfterReadNodeHookArgs = { + /** + * 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[] /** Boolean to denote if this hook is running against finding one, or finding many within the afterRead hook. */ - findMany?: boolean - /** The value of the field. */ - node?: T + findMany: boolean + flattenLocales: boolean + /** + * The requested locale. + */ + locale: string + overrideAccess: boolean + /** + * Only available in `afterRead` field hooks. + */ + populationPromises: Promise[] + /** + * Only available in `afterRead` hooks. + */ + showHiddenFields: boolean + /** + * Only available in `afterRead` hooks. + */ + triggerAccessControl: boolean + /** + * Only available in `afterRead` hooks. + */ + triggerHooks: boolean +} + +export type AfterChangeNodeHookArgs = { /** A string relating to which operation the field type is currently executing within. Useful within beforeValidate, beforeChange, and afterChange hooks to differentiate between create and update operations. */ - operation?: 'create' | 'delete' | 'read' | 'update' - overrideAccess?: boolean - /** The Express request object. It is mocked for Local API operations. */ + operation: 'create' | 'delete' | 'read' | 'update' + /** The value of the node before any changes. Not available in afterRead hooks */ + originalNode: T +} +export type BeforeValidateNodeHookArgs = { + /** 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 = { + duplicate: boolean + /** + * Only available in `beforeChange` hooks. + */ + errors: { field: string; message: string }[] + mergeLocaleActions: (() => Promise)[] + /** 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 = { + context: RequestContext + /** The value of the node. */ + node: T + parentRichTextFieldPath: (number | string)[] + parentRichTextFieldSchemaPath: string[] + /** The payload request object. It is mocked for Local API operations. */ req: PayloadRequestWithData } -export type FieldNodeHook = ( - args: FieldNodeHookArgs, +export type AfterReadNodeHook = ( + args: AfterReadNodeHookArgs & BaseNodeHookArgs, +) => Promise | T + +export type AfterChangeNodeHook = ( + args: AfterChangeNodeHookArgs & BaseNodeHookArgs, +) => Promise | T + +export type BeforeChangeNodeHook = ( + args: BeforeChangeNodeHookArgs & BaseNodeHookArgs, +) => Promise | T + +export type BeforeValidateNodeHook = ( + args: BeforeValidateNodeHookArgs & BaseNodeHookArgs, ) => Promise | T // Define the node with hooks that use the node's exportJSON return type @@ -258,20 +341,30 @@ export type NodeWithHooks = { converters?: { html?: HTMLConverter['exportJSON']>> } - hooks?: { - afterChange?: Array['exportJSON']>>> - afterRead?: Array['exportJSON']>>> - beforeChange?: Array['exportJSON']>>> - /** - * Runs before a document is duplicated to prevent errors in unique fields or return null to use defaultValue. - */ - beforeDuplicate?: Array['exportJSON']>>> - beforeValidate?: Array['exportJSON']>>> - } - node: Klass | LexicalNodeReplacement - populationPromises?: Array< + /** + * 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 + */ + getSubFields?: (args: { + node: ReturnType['exportJSON']> + req: PayloadRequestWithData + }) => Field[] | null + getSubFieldsData?: (args: { + node: ReturnType['exportJSON']> + req: PayloadRequestWithData + }) => Record + graphQLPopulationPromises?: Array< PopulationPromise['exportJSON']>> > + hooks?: { + afterChange?: Array['exportJSON']>>> + afterRead?: Array['exportJSON']>>> + beforeChange?: Array['exportJSON']>>> + beforeValidate?: Array< + BeforeValidateNodeHook['exportJSON']>> + > + } + node: Klass | LexicalNodeReplacement validations?: Array['exportJSON']>>> } @@ -451,17 +544,21 @@ export type SanitizedServerFeatures = Required< } /** The node types mapped to their hooks */ + getSubFields?: Map< + string, + (args: { node: SerializedLexicalNode; req: PayloadRequestWithData }) => Field[] | null + > + getSubFieldsData?: Map< + string, + (args: { node: SerializedLexicalNode; req: PayloadRequestWithData }) => Record + > + graphQLPopulationPromises: Map> hooks?: { - afterChange?: Map>> - afterRead?: Map>> - beforeChange?: Map>> - /** - * Runs before a document is duplicated to prevent errors in unique fields or return null to use defaultValue. - */ - beforeDuplicate?: Map>> - beforeValidate?: Map>> + afterChange?: Map>> + afterRead?: Map>> + beforeChange?: Map>> + beforeValidate?: Map>> } /** The node types mapped to their populationPromises */ - populationPromises: Map> /** The node types mapped to their validations */ validations: Map> } diff --git a/packages/richtext-lexical/src/field/features/upload/drawer/index.tsx b/packages/richtext-lexical/src/field/features/upload/drawer/index.tsx index 2e8b28fcf..e49387f06 100644 --- a/packages/richtext-lexical/src/field/features/upload/drawer/index.tsx +++ b/packages/richtext-lexical/src/field/features/upload/drawer/index.tsx @@ -11,8 +11,6 @@ import { $createUploadNode } from '../nodes/UploadNode.js' import { INSERT_UPLOAD_COMMAND } from '../plugin/index.js' import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './commands.js' -const baseClass = 'lexical-upload-drawer' - const insertUpload = ({ editor, relationTo, diff --git a/packages/richtext-lexical/src/field/features/upload/feature.server.ts b/packages/richtext-lexical/src/field/features/upload/feature.server.ts index a744647ff..1e70a33b5 100644 --- a/packages/richtext-lexical/src/field/features/upload/feature.server.ts +++ b/packages/richtext-lexical/src/field/features/upload/feature.server.ts @@ -7,11 +7,12 @@ import { sanitizeFields } from 'payload/config' import type { FeatureProviderProviderServer } from '../types.js' import type { UploadFeaturePropsClient } from './feature.client.js' +import { populate } from '../../../populateGraphQL/populate.js' import { createNode } from '../typeUtilities.js' import { UploadFeatureClientComponent } from './feature.client.js' +import { uploadPopulationPromiseHOC } from './graphQLPopulationPromise.js' import { i18n } from './i18n.js' import { UploadNode } from './nodes/UploadNode.js' -import { uploadPopulationPromiseHOC } from './populationPromise.js' import { uploadValidation } from './validate.js' export type UploadFeatureProps = { @@ -177,8 +178,73 @@ export const UploadFeature: FeatureProviderProviderServer< nodeTypes: [UploadNode.getType()], }, }, + getSubFields: ({ node, req }) => { + const collection = req.payload.collections[node?.relationTo] + + if (collection) { + const collectionFieldSchema = props?.collections?.[node?.relationTo]?.fields + + if (Array.isArray(collectionFieldSchema)) { + if (!collectionFieldSchema?.length) { + return null + } + return collectionFieldSchema + } + } + return null + }, + getSubFieldsData: ({ node }) => { + return node?.fields + }, + graphQLPopulationPromises: [uploadPopulationPromiseHOC(props)], + hooks: { + afterRead: [ + ({ + currentDepth, + depth, + draft, + node, + overrideAccess, + populationPromises, + req, + showHiddenFields, + }) => { + if (!node?.value) { + return node + } + const collection = req.payload.collections[node?.relationTo] + + if (!collection) { + return node + } + // @ts-expect-error + const id = node?.value?.id || node?.value // for backwards-compatibility + + const populateDepth = + props?.maxDepth !== undefined && props?.maxDepth < depth + ? props?.maxDepth + : depth + + populationPromises.push( + populate({ + id, + collection, + currentDepth, + data: node, + depth: populateDepth, + draft, + key: 'value', + overrideAccess, + req, + showHiddenFields, + }), + ) + + return node + }, + ], + }, node: UploadNode, - populationPromises: [uploadPopulationPromiseHOC(props)], validations: [uploadValidation(props)], }), ], diff --git a/packages/richtext-lexical/src/field/features/upload/populationPromise.ts b/packages/richtext-lexical/src/field/features/upload/graphQLPopulationPromise.ts similarity index 56% rename from packages/richtext-lexical/src/field/features/upload/populationPromise.ts rename to packages/richtext-lexical/src/field/features/upload/graphQLPopulationPromise.ts index c675a2707..0594376c3 100644 --- a/packages/richtext-lexical/src/field/features/upload/populationPromise.ts +++ b/packages/richtext-lexical/src/field/features/upload/graphQLPopulationPromise.ts @@ -2,8 +2,8 @@ import type { PopulationPromise } from '../types.js' import type { UploadFeatureProps } from './feature.server.js' import type { SerializedUploadNode } from './nodes/UploadNode.js' -import { populate } from '../../../populate/populate.js' -import { recurseNestedFields } from '../../../populate/recurseNestedFields.js' +import { populate } from '../../../populateGraphQL/populate.js' +import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js' export const uploadPopulationPromiseHOC = ( props?: UploadFeatureProps, @@ -14,7 +14,6 @@ export const uploadPopulationPromiseHOC = ( depth, draft, editorPopulationPromises, - field, fieldPromises, findMany, flattenLocales, @@ -42,35 +41,37 @@ export const uploadPopulationPromiseHOC = ( data: node, depth: populateDepth, draft, - field, key: 'value', overrideAccess, req, showHiddenFields, }), ) - } - if (Array.isArray(props?.collections?.[node?.relationTo]?.fields)) { - if (!props?.collections?.[node?.relationTo]?.fields?.length) { - return + + const collectionFieldSchema = props?.collections?.[node?.relationTo]?.fields + + if (Array.isArray(collectionFieldSchema)) { + if (!collectionFieldSchema?.length) { + return + } + recursivelyPopulateFieldsForGraphQL({ + context, + currentDepth, + data: node.fields || {}, + depth, + draft, + editorPopulationPromises, + fieldPromises, + fields: collectionFieldSchema, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingDoc: node.fields || {}, + }) } - recurseNestedFields({ - context, - currentDepth, - data: node.fields || {}, - depth, - draft, - editorPopulationPromises, - fieldPromises, - fields: props?.collections?.[node?.relationTo]?.fields, - findMany, - flattenLocales: false, // Disable localization handling which does not work properly yet. Once we fully support hooks, this can be enabled (pass through flattenLocales again) - overrideAccess, - populationPromises, - req, - showHiddenFields, - siblingDoc: node.fields || {}, - }) } } } diff --git a/packages/richtext-lexical/src/field/features/upload/nodes/UploadNode.tsx b/packages/richtext-lexical/src/field/features/upload/nodes/UploadNode.tsx index 8878fd83f..feac83a26 100644 --- a/packages/richtext-lexical/src/field/features/upload/nodes/UploadNode.tsx +++ b/packages/richtext-lexical/src/field/features/upload/nodes/UploadNode.tsx @@ -10,6 +10,7 @@ import type { import type { JSX } from 'react' import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js' +import ObjectID from 'bson-objectid' import { $applyNodeReplacement } from 'lexical' import * as React from 'react' @@ -22,6 +23,7 @@ export type UploadData = { // unknown, custom fields: [key: string]: unknown } + id: string relationTo: string value: number | string } @@ -106,8 +108,13 @@ export class UploadNode extends DecoratorBlockNode { if (serializedNode.version === 1 && (serializedNode?.value as unknown as { id: string })?.id) { serializedNode.value = (serializedNode.value as unknown as { id: string }).id } + if (serializedNode.version === 2 && !serializedNode?.id) { + serializedNode.id = new ObjectID.default().toHexString() + serializedNode.version = 3 + } const importedData: UploadData = { + id: serializedNode.id, fields: serializedNode.fields, relationTo: serializedNode.relationTo, value: serializedNode.value, @@ -141,7 +148,7 @@ export class UploadNode extends DecoratorBlockNode { ...super.exportJSON(), ...this.getData(), type: this.getType(), - version: 2, + version: 3, } } @@ -160,8 +167,15 @@ export class UploadNode extends DecoratorBlockNode { } } -export function $createUploadNode({ data }: { data: UploadData }): UploadNode { - return $applyNodeReplacement(new UploadNode({ data })) +export function $createUploadNode({ + data, +}: { + data: Omit & Partial> +}): 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 { diff --git a/packages/richtext-lexical/src/field/features/upload/plugin/index.tsx b/packages/richtext-lexical/src/field/features/upload/plugin/index.tsx index 0cc657a94..c75716274 100644 --- a/packages/richtext-lexical/src/field/features/upload/plugin/index.tsx +++ b/packages/richtext-lexical/src/field/features/upload/plugin/index.tsx @@ -21,7 +21,7 @@ import type { UploadData } from '../nodes/UploadNode.js' import { UploadDrawer } from '../drawer/index.js' import { $createUploadNode, UploadNode } from '../nodes/UploadNode.js' -export type InsertUploadPayload = Readonly +export type InsertUploadPayload = Readonly & Partial>> export const INSERT_UPLOAD_COMMAND: LexicalCommand = createCommand('INSERT_UPLOAD_COMMAND') @@ -47,6 +47,7 @@ export const UploadPlugin: PluginComponentWithAnchor = if ($isRangeSelection(selection)) { const uploadNode = $createUploadNode({ data: { + id: payload.id, fields: payload.fields, relationTo: payload.relationTo, value: payload.value, diff --git a/packages/richtext-lexical/src/field/lexical/config/server/loader.ts b/packages/richtext-lexical/src/field/lexical/config/server/loader.ts index ddb21aed3..3ca4a159c 100644 --- a/packages/richtext-lexical/src/field/lexical/config/server/loader.ts +++ b/packages/richtext-lexical/src/field/lexical/config/server/loader.ts @@ -1,4 +1,4 @@ -import type { Config, SanitizedConfig } from 'payload/config' +import type { SanitizedConfig } from 'payload/config' import type { FeatureProviderServer, diff --git a/packages/richtext-lexical/src/field/lexical/config/server/sanitize.ts b/packages/richtext-lexical/src/field/lexical/config/server/sanitize.ts index 77cef1dcd..84a27f4ff 100644 --- a/packages/richtext-lexical/src/field/lexical/config/server/sanitize.ts +++ b/packages/richtext-lexical/src/field/lexical/config/server/sanitize.ts @@ -1,4 +1,4 @@ -import type { Config, SanitizedConfig } from 'payload/config' +import type { SanitizedConfig } from 'payload/config' import type { ResolvedServerFeatureMap, SanitizedServerFeatures } from '../../../features/types.js' import type { SanitizedServerEditorConfig, ServerEditorConfig } from '../types.js' @@ -16,18 +16,18 @@ export const sanitizeServerFeatures = ( generatedTypes: { modifyOutputSchemas: [], }, - + getSubFields: new Map(), + getSubFieldsData: new Map(), + graphQLPopulationPromises: new Map(), hooks: { afterChange: new Map(), afterRead: new Map(), beforeChange: new Map(), - beforeDuplicate: new Map(), beforeValidate: new Map(), }, i18n: {}, markdownTransformers: [], nodes: [], - populationPromises: new Map(), validations: new Map(), } @@ -56,8 +56,8 @@ export const sanitizeServerFeatures = ( sanitized.nodes = sanitized.nodes.concat(feature.nodes) feature.nodes.forEach((node) => { const nodeType = 'with' in node.node ? node.node.replace.getType() : node.node.getType() // TODO: Idk if this works for node replacements - if (node?.populationPromises?.length) { - sanitized.populationPromises.set(nodeType, node.populationPromises) + if (node?.graphQLPopulationPromises?.length) { + sanitized.graphQLPopulationPromises.set(nodeType, node.graphQLPopulationPromises) } if (node?.validations?.length) { sanitized.validations.set(nodeType, node.validations) @@ -74,12 +74,15 @@ export const sanitizeServerFeatures = ( if (node?.hooks?.beforeChange) { sanitized.hooks.beforeChange.set(nodeType, node.hooks.beforeChange) } - if (node?.hooks?.beforeDuplicate) { - sanitized.hooks.beforeDuplicate.set(nodeType, node.hooks.beforeDuplicate) - } if (node?.hooks?.beforeValidate) { sanitized.hooks.beforeValidate.set(nodeType, node.hooks.beforeValidate) } + if (node?.getSubFields) { + sanitized.getSubFields.set(nodeType, node.getSubFields) + } + if (node?.getSubFieldsData) { + sanitized.getSubFieldsData.set(nodeType, node.getSubFieldsData) + } }) } diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 943350d0e..86009666f 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -1,8 +1,18 @@ import type { JSONSchema4 } from 'json-schema' -import type { EditorConfig as LexicalEditorConfig } from 'lexical' +import type { + EditorConfig as LexicalEditorConfig, + SerializedEditorState, + SerializedLexicalNode, +} from 'lexical' import { withMergedProps } from '@payloadcms/ui/elements/withMergedProps' -import { withNullableJSONSchemaType } from 'payload/utilities' +import { + afterChangeTraverseFields, + afterReadTraverseFields, + beforeChangeTraverseFields, + beforeValidateTraverseFields, + withNullableJSONSchemaType, +} from 'payload/utilities' import type { FeatureProviderServer, ResolvedServerFeatureMap } from './field/features/types.js' import type { SanitizedServerEditorConfig } from './field/lexical/config/types.js' @@ -28,7 +38,8 @@ import { cloneDeep } from './field/lexical/utils/cloneDeep.js' import { getGenerateComponentMap } from './generateComponentMap.js' import { getGenerateSchemaMap } from './generateSchemaMap.js' import { i18n } from './i18n.js' -import { populateLexicalPopulationPromises } from './populate/populateLexicalPopulationPromises.js' +import { populateLexicalPopulationPromises } from './populateGraphQL/populateLexicalPopulationPromises.js' +import { recurseNodeTree } from './recurseNodeTree.js' import { richTextValidateHOC } from './validate/index.js' let defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig = null @@ -121,105 +132,560 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte generateSchemaMap: getGenerateSchemaMap({ resolvedFeatureMap, }), - i18n: featureI18n, - /* hooks: { - afterChange: finalSanitizedEditorConfig.features.hooks.afterChange, - afterRead: finalSanitizedEditorConfig.features.hooks.afterRead, - beforeChange: finalSanitizedEditorConfig.features.hooks.beforeChange, - beforeDuplicate: finalSanitizedEditorConfig.features.hooks.beforeDuplicate, - beforeValidate: finalSanitizedEditorConfig.features.hooks.beforeValidate, - },*/ - /* // TODO: Figure out docWithLocales / originalSiblingDoc => node matching. Can't use indexes, as the order of nodes could technically change between hooks. + graphQLPopulationPromises({ + context, + currentDepth, + depth, + draft, + field, + fieldPromises, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingDoc, + }) { + // check if there are any features with nodes which have populationPromises for this field + if (finalSanitizedEditorConfig?.features?.graphQLPopulationPromises?.size) { + populateLexicalPopulationPromises({ + context, + currentDepth: currentDepth ?? 0, + depth, + draft, + editorPopulationPromises: finalSanitizedEditorConfig.features.graphQLPopulationPromises, + field, + fieldPromises, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingDoc, + }) + } + }, hooks: { afterChange: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const afterChangeHooks = finalSanitizedEditorConfig.features.hooks.afterChange - if (afterChangeHooks?.has(node.type)) { - for (const hook of afterChangeHooks.get(node.type)) { - node = await hook({ context, findMany, node, operation, overrideAccess, req }) - } - } - }, + async ({ + collection, + context: _context, + global, + operation, + path, + req, + schemaPath, + value, + }) => { + if ( + !finalSanitizedEditorConfig.features.hooks.afterChange.size && + !finalSanitizedEditorConfig.features.getSubFields.size + ) { + return value + } + const context: any = _context + const nodeIDMap: { + [key: string]: SerializedLexicalNode + } = {} + + /** + * Get the originalNodeIDMap from the beforeValidate hook, which is always run before this hook. + */ + const originalNodeIDMap: { + [key: string]: SerializedLexicalNode + } = context?.internal?.richText?.[path.join('.')]?.originalNodeIDMap + + if (!originalNodeIDMap || !Object.keys(originalNodeIDMap).length || !value) { + return value + } + + recurseNodeTree({ + nodeIDMap, nodes: (value as SerializedEditorState)?.root?.children ?? [], }) + // eslint-disable-next-line prefer-const + for (let [id, node] of Object.entries(nodeIDMap)) { + const afterChangeHooks = finalSanitizedEditorConfig.features.hooks.afterChange + if (afterChangeHooks?.has(node.type)) { + for (const hook of afterChangeHooks.get(node.type)) { + if (!originalNodeIDMap[id]) { + console.warn( + '(afterChange) No original node found for node with id', + id, + 'node:', + node, + 'path', + path.join('.'), + ) + continue + } + node = await hook({ + context, + node, + operation, + originalNode: originalNodeIDMap[id], + parentRichTextFieldPath: path, + parentRichTextFieldSchemaPath: schemaPath, + req, + }) + } + } + const subFieldFn = finalSanitizedEditorConfig.features.getSubFields.get(node.type) + const subFieldDataFn = finalSanitizedEditorConfig.features.getSubFieldsData.get( + node.type, + ) + + if (subFieldFn) { + const subFields = subFieldFn({ node, req }) + const data = subFieldDataFn({ node, req }) + const originalData = subFieldDataFn({ node: originalNodeIDMap[id], req }) + + if (subFields?.length) { + await afterChangeTraverseFields({ + collection, + context, + data: originalData, + doc: data, + fields: subFields, + global, + operation, + path, + previousDoc: data, + previousSiblingDoc: { ...data }, + req, + schemaPath, + siblingData: originalData || {}, + siblingDoc: { ...data }, + }) + } + } + } return value }, ], afterRead: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const afterReadHooks = finalSanitizedEditorConfig.features.hooks.afterRead - if (afterReadHooks?.has(node.type)) { - for (const hook of afterReadHooks.get(node.type)) { - node = await hook({ context, findMany, node, operation, overrideAccess, req }) - } - } - }, + /** + * afterRead hooks do not receive the originalNode. Thus, they can run on all nodes, not just nodes with an ID. + */ + async ({ + collection, + context: context, + currentDepth, + depth, + draft, + fallbackLocale, + fieldPromises, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + path, + populationPromises, + req, + schemaPath, + showHiddenFields, + triggerAccessControl, + triggerHooks, + value, + }) => { + if ( + !finalSanitizedEditorConfig.features.hooks.afterRead.size && + !finalSanitizedEditorConfig.features.getSubFields.size + ) { + return value + } + const flattenedNodes: SerializedLexicalNode[] = [] + + recurseNodeTree({ + flattenedNodes, nodes: (value as SerializedEditorState)?.root?.children ?? [], }) + for (let node of flattenedNodes) { + const afterReadHooks = finalSanitizedEditorConfig.features.hooks.afterRead + if (afterReadHooks?.has(node.type)) { + for (const hook of afterReadHooks.get(node.type)) { + node = await hook({ + context, + currentDepth, + depth, + draft, + fallbackLocale, + fieldPromises, + findMany, + flattenLocales, + locale, + node, + overrideAccess, + parentRichTextFieldPath: path, + parentRichTextFieldSchemaPath: schemaPath, + populationPromises, + req, + showHiddenFields, + triggerAccessControl, + triggerHooks, + }) + } + } + const subFieldFn = finalSanitizedEditorConfig.features.getSubFields.get(node.type) + const subFieldDataFn = finalSanitizedEditorConfig.features.getSubFieldsData.get( + node.type, + ) + + if (subFieldFn) { + const subFields = subFieldFn({ node, req }) + const data = subFieldDataFn({ node, req }) + + if (subFields?.length) { + afterReadTraverseFields({ + collection, + context, + currentDepth, + depth, + doc: data, + draft, + fallbackLocale, + fieldPromises, + fields: subFields, + findMany, + flattenLocales, + global, + locale, + overrideAccess, + path, + populationPromises, + req, + schemaPath, + showHiddenFields, + siblingDoc: data, + triggerAccessControl, + triggerHooks, + }) + } + } + } + return value }, ], beforeChange: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const beforeChangeHooks = finalSanitizedEditorConfig.features.hooks.beforeChange - if (beforeChangeHooks?.has(node.type)) { - for (const hook of beforeChangeHooks.get(node.type)) { - node = await hook({ context, findMany, node, operation, overrideAccess, req }) - } - } - }, + async ({ + collection, + context: _context, + duplicate, + errors, + field, + global, + mergeLocaleActions, + operation, + path, + req, + schemaPath, + siblingData, + siblingDocWithLocales, + skipValidation, + value, + }) => { + if ( + !finalSanitizedEditorConfig.features.hooks.beforeChange.size && + !finalSanitizedEditorConfig.features.getSubFields.size + ) { + return value + } + + const context: any = _context + const nodeIDMap: { + [key: string]: SerializedLexicalNode + } = {} + + /** + * Get the originalNodeIDMap from the beforeValidate hook, which is always run before this hook. + */ + const originalNodeIDMap: { + [key: string]: SerializedLexicalNode + } = context?.internal?.richText?.[path.join('.')]?.originalNodeIDMap + + if (!originalNodeIDMap || !Object.keys(originalNodeIDMap).length || !value) { + return value + } + + const originalNodeWithLocalesIDMap: { + [key: string]: SerializedLexicalNode + } = {} + + recurseNodeTree({ + nodeIDMap, nodes: (value as SerializedEditorState)?.root?.children ?? [], }) - return value - }, - ], - beforeDuplicate: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const beforeDuplicateHooks = finalSanitizedEditorConfig.features.hooks.beforeDuplicate - if (beforeDuplicateHooks?.has(node.type)) { - for (const hook of beforeDuplicateHooks.get(node.type)) { - node = await hook({ context, findMany, node, operation, overrideAccess, req }) + if (siblingDocWithLocales?.[field.name]) { + recurseNodeTree({ + nodeIDMap: originalNodeWithLocalesIDMap, + nodes: + (siblingDocWithLocales[field.name] as SerializedEditorState)?.root?.children ?? + [], + }) + } + + // eslint-disable-next-line prefer-const + for (let [id, node] of Object.entries(nodeIDMap)) { + const beforeChangeHooks = finalSanitizedEditorConfig.features.hooks.beforeChange + if (beforeChangeHooks?.has(node.type)) { + for (const hook of beforeChangeHooks.get(node.type)) { + if (!originalNodeIDMap[id]) { + console.warn( + '(beforeChange) No original node found for node with id', + id, + 'node:', + node, + 'path', + path.join('.'), + ) + continue } + node = await hook({ + context, + duplicate, + errors, + mergeLocaleActions, + node, + operation, + originalNode: originalNodeIDMap[id], + originalNodeWithLocales: originalNodeWithLocalesIDMap[id], + parentRichTextFieldPath: path, + parentRichTextFieldSchemaPath: schemaPath, + req, + skipValidation, + }) } - }, - nodes: (value as SerializedEditorState)?.root?.children ?? [], + } + + const subFieldFn = finalSanitizedEditorConfig.features.getSubFields.get(node.type) + const subFieldDataFn = finalSanitizedEditorConfig.features.getSubFieldsData.get( + node.type, + ) + + if (subFieldFn) { + const subFields = subFieldFn({ node, req }) + const data = subFieldDataFn({ node, req }) + const originalData = subFieldDataFn({ node: originalNodeIDMap[id], req }) + const originalDataWithLocales = subFieldDataFn({ + node: originalNodeWithLocalesIDMap[id], + req, + }) + + if (subFields?.length) { + await beforeChangeTraverseFields({ + id, + collection, + context, + data, + doc: originalData, + docWithLocales: originalDataWithLocales ?? {}, + duplicate, + errors, + fields: subFields, + global, + mergeLocaleActions, + operation, + path, + req, + schemaPath, + siblingData: data, + siblingDoc: originalData, + siblingDocWithLocales: originalDataWithLocales ?? {}, + skipValidation, + }) + } + } + } + + /** + * within the beforeChange hook, id's may be re-generated. + * Example: + * 1. Seed data contains IDs for block feature blocks. + * 2. Those are used in beforeValidate + * 3. in beforeChange, those IDs are regenerated, because you cannot provide IDs during document creation. See baseIDField beforeChange hook for reasoning + * 4. Thus, in order for all post-beforeChange hooks to receive the correct ID, we need to update the originalNodeIDMap with the new ID's, by regenerating the nodeIDMap. + * The reason this is not generated for every hook, is to save on performance. We know we only really have to generate it in beforeValidate, which is the first hook, + * and in beforeChange, which is where modifications to the provided IDs can occur. + */ + const newOriginalNodeIDMap: { + [key: string]: SerializedLexicalNode + } = {} + + const previousValue = siblingData[field.name] + + recurseNodeTree({ + nodeIDMap: newOriginalNodeIDMap, + nodes: (previousValue as SerializedEditorState)?.root?.children ?? [], }) + if (!context.internal) { + // Add to context, for other hooks to use + context.internal = {} + } + if (!context.internal.richText) { + context.internal.richText = {} + } + context.internal.richText[path.join('.')] = { + originalNodeIDMap: newOriginalNodeIDMap, + } + return value }, ], beforeValidate: [ - async ({ context, findMany, operation, overrideAccess, req, value }) => { - await recurseNodesAsync({ - callback: async (node) => { - const beforeValidateHooks = finalSanitizedEditorConfig.features.hooks.beforeValidate - if (beforeValidateHooks?.has(node.type)) { - for (const hook of beforeValidateHooks.get(node.type)) { - /** - * We cannot pass the originalNode here, as there is no way to map one node to a previous one, as a previous originalNode might be in a different position - */ /* - node = await hook({ context, findMany, node, operation, overrideAccess, req }) + async ({ + collection, + context, + global, + operation, + overrideAccess, + path, + previousValue, + req, + schemaPath, + value, + }) => { + // return value if there are NO hooks + if ( + !finalSanitizedEditorConfig.features.hooks.beforeValidate.size && + !finalSanitizedEditorConfig.features.hooks.afterChange.size && + !finalSanitizedEditorConfig.features.hooks.beforeChange.size && + !finalSanitizedEditorConfig.features.getSubFields.size + ) { + return value + } + + /** + * beforeValidate is the first field hook which runs. This is where we can create the node map, which can then be used in the other hooks. + * + */ + + /** + * flattenedNodes contains all nodes in the editor, in the order they appear in the editor. They will be used for the following hooks: + * - afterRead + * + * The other hooks require nodes to have IDs, which is why those are ran only from the nodeIDMap. They require IDs because they have both doc/siblingDoc and data/siblingData, and + * thus require a reliable way to match new node data to old node data. Given that node positions can change in between hooks, this is only reliably possible for nodes which are saved with + * an ID. + */ + //const flattenedNodes: SerializedLexicalNode[] = [] + + /** + * Only nodes with id's (so, nodes with hooks added to them) will be added to the nodeIDMap. They will be used for the following hooks: + * - afterChange + * - beforeChange + * - beforeValidate + * - beforeDuplicate + * + * Other hooks are handled by the flattenedNodes. All nodes in the nodeIDMap are part of flattenedNodes. + */ + + const originalNodeIDMap: { + [key: string]: SerializedLexicalNode + } = {} + + recurseNodeTree({ + nodeIDMap: originalNodeIDMap, + nodes: (previousValue as SerializedEditorState)?.root?.children ?? [], + }) + + if (!context.internal) { + // Add to context, for other hooks to use + context.internal = {} + } + if (!(context as any).internal.richText) { + ;(context as any).internal.richText = {} + } + ;(context as any).internal.richText[path.join('.')] = { + originalNodeIDMap, + } + + /** + * Now that the maps for all hooks are set up, we can run the validate hook + */ + if (!finalSanitizedEditorConfig.features.hooks.beforeValidate.size) { + return value + } + const nodeIDMap: { + [key: string]: SerializedLexicalNode + } = {} + recurseNodeTree({ + //flattenedNodes, + nodeIDMap, + nodes: (value as SerializedEditorState)?.root?.children ?? [], + }) + + // eslint-disable-next-line prefer-const + for (let [id, node] of Object.entries(nodeIDMap)) { + const beforeValidateHooks = finalSanitizedEditorConfig.features.hooks.beforeValidate + if (beforeValidateHooks?.has(node.type)) { + for (const hook of beforeValidateHooks.get(node.type)) { + if (!originalNodeIDMap[id]) { + console.warn( + '(beforeValidate) No original node found for node with id', + id, + 'node:', + node, + 'path', + path.join('.'), + ) + continue + } + node = await hook({ + context, + node, + operation, + originalNode: originalNodeIDMap[id], + overrideAccess, + parentRichTextFieldPath: path, + parentRichTextFieldSchemaPath: schemaPath, + req, + }) } } - }, - nodes: (value as SerializedEditorState)?.root?.children ?? [], - }) + const subFieldFn = finalSanitizedEditorConfig.features.getSubFields.get(node.type) + const subFieldDataFn = finalSanitizedEditorConfig.features.getSubFieldsData.get( + node.type, + ) - return value - }, - ], - },*/ + if (subFieldFn) { + const subFields = subFieldFn({ node, req }) + const data = subFieldDataFn({ node, req }) + const originalData = subFieldDataFn({ node: originalNodeIDMap[id], req }) + + if (subFields?.length) { + await beforeValidateTraverseFields({ + id, + collection, + context, + data, + doc: originalData, + fields: subFields, + global, + operation, + overrideAccess, + path, + req, + schemaPath, + siblingData: data, + siblingDoc: originalData, + }) + } + } + } + + return value + }, + ], + }, + i18n: featureI18n, outputSchema: ({ collectionIDFieldTypes, config, @@ -297,41 +763,6 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte return outputSchema }, - populationPromises({ - context, - currentDepth, - depth, - draft, - field, - fieldPromises, - findMany, - flattenLocales, - overrideAccess, - populationPromises, - req, - showHiddenFields, - siblingDoc, - }) { - // check if there are any features with nodes which have populationPromises for this field - if (finalSanitizedEditorConfig?.features?.populationPromises?.size) { - populateLexicalPopulationPromises({ - context, - currentDepth, - depth, - draft, - editorPopulationPromises: finalSanitizedEditorConfig.features.populationPromises, - field, - fieldPromises, - findMany, - flattenLocales, - overrideAccess, - populationPromises, - req, - showHiddenFields, - siblingDoc, - }) - } - }, validate: richTextValidateHOC({ editorConfig: finalSanitizedEditorConfig, }), @@ -452,6 +883,15 @@ export { InlineToolbarFeature } from './field/features/toolbars/inline/feature.s export type { ToolbarGroup, ToolbarGroupItem } from './field/features/toolbars/types.js' export { createNode } from './field/features/typeUtilities.js' export type { + AfterChangeNodeHook, + AfterChangeNodeHookArgs, + AfterReadNodeHook, + AfterReadNodeHookArgs, + BaseNodeHookArgs, + BeforeChangeNodeHook, + BeforeChangeNodeHookArgs, + BeforeValidateNodeHook, + BeforeValidateNodeHookArgs, ClientComponentProps, ClientFeature, ClientFeatureProviderMap, @@ -459,8 +899,6 @@ export type { FeatureProviderProviderClient, FeatureProviderProviderServer, FeatureProviderServer, - FieldNodeHook, - FieldNodeHookArgs, NodeValidation, NodeWithHooks, PluginComponent, @@ -559,6 +997,6 @@ export { addSwipeUpListener, } from './field/lexical/utils/swipe.js' export { sanitizeUrl, validateUrl } from './field/lexical/utils/url.js' -export { defaultRichTextValue } from './populate/defaultValue.js' +export { defaultRichTextValue } from './populateGraphQL/defaultValue.js' export type { LexicalEditorProps, LexicalRichTextAdapter } from './types.js' diff --git a/packages/richtext-lexical/src/populate/defaultValue.ts b/packages/richtext-lexical/src/populateGraphQL/defaultValue.ts similarity index 100% rename from packages/richtext-lexical/src/populate/defaultValue.ts rename to packages/richtext-lexical/src/populateGraphQL/defaultValue.ts diff --git a/packages/richtext-lexical/src/populate/populate.ts b/packages/richtext-lexical/src/populateGraphQL/populate.ts similarity index 71% rename from packages/richtext-lexical/src/populate/populate.ts rename to packages/richtext-lexical/src/populateGraphQL/populate.ts index 1644c92d6..c71060474 100644 --- a/packages/richtext-lexical/src/populate/populate.ts +++ b/packages/richtext-lexical/src/populateGraphQL/populate.ts @@ -1,19 +1,15 @@ -import type { SerializedEditorState } from 'lexical' import type { PayloadRequestWithData } from 'payload/types' -import type { Collection, Field, RichTextField } from 'payload/types' +import type { Collection } from 'payload/types' import { createDataloaderCacheKey } from 'payload/utilities' -import type { AdapterProps } from '../types.js' - type Arguments = { currentDepth?: number data: unknown depth: number draft: boolean - field: RichTextField key: number | string - overrideAccess?: boolean + overrideAccess: boolean req: PayloadRequestWithData showHiddenFields: boolean } @@ -29,11 +25,16 @@ export const populate = async ({ overrideAccess, req, showHiddenFields, -}: Omit & { +}: Arguments & { collection: Collection - field: Field id: number | string }): Promise => { + 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 const doc = await req.payloadDataLoader.load( @@ -45,7 +46,7 @@ export const populate = async ({ draft, fallbackLocale: req.fallbackLocale, locale: req.locale, - overrideAccess: typeof overrideAccess === 'undefined' ? false : overrideAccess, + overrideAccess, showHiddenFields, transactionID: req.transactionID, }), diff --git a/packages/richtext-lexical/src/populate/populateLexicalPopulationPromises.ts b/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts similarity index 89% rename from packages/richtext-lexical/src/populate/populateLexicalPopulationPromises.ts rename to packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts index 38b7fbca8..4a67ac07d 100644 --- a/packages/richtext-lexical/src/populate/populateLexicalPopulationPromises.ts +++ b/packages/richtext-lexical/src/populateGraphQL/populateLexicalPopulationPromises.ts @@ -7,7 +7,7 @@ import type { AdapterProps } from '../types.js' import { recurseNodes } from '../forEachNodeRecursively.js' export type Args = Parameters< - RichTextAdapter['populationPromises'] + RichTextAdapter['graphQLPopulationPromises'] >[0] & { editorPopulationPromises: Map> } @@ -31,7 +31,9 @@ export const populateLexicalPopulationPromises = ({ showHiddenFields, siblingDoc, }: Args) => { - if (depth <= 0 || currentDepth > depth) { + const shouldPopulate = depth && currentDepth <= depth + + if (!shouldPopulate) { return } diff --git a/packages/richtext-lexical/src/populate/recurseNestedFields.ts b/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts similarity index 91% rename from packages/richtext-lexical/src/populate/recurseNestedFields.ts rename to packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts index fc73266ed..25f37ad69 100644 --- a/packages/richtext-lexical/src/populate/recurseNestedFields.ts +++ b/packages/richtext-lexical/src/populateGraphQL/recursivelyPopulateFieldsForGraphQL.ts @@ -29,7 +29,7 @@ type NestedRichTextFieldsArgs = { siblingDoc: Record } -export const recurseNestedFields = ({ +export const recursivelyPopulateFieldsForGraphQL = ({ context, currentDepth = 0, data, @@ -60,11 +60,12 @@ export const recurseNestedFields = ({ global: null, // Pass from core? This is only needed for hooks, so we can leave this null for now locale: req.locale, overrideAccess, + path: [], populationPromises, // This is not the same as populationPromises passed into this recurseNestedFields. These are just promises resolved at the very end. req, + schemaPath: [], showHiddenFields, siblingDoc, - //triggerAccessControl: false, // TODO: Enable this to support access control - //triggerHooks: false, // TODO: Enable this to support hooks + triggerHooks: false, }) } diff --git a/packages/richtext-lexical/src/recurseNodeTree.ts b/packages/richtext-lexical/src/recurseNodeTree.ts new file mode 100644 index 000000000..1c72a79c0 --- /dev/null +++ b/packages/richtext-lexical/src/recurseNodeTree.ts @@ -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[], + }) + } + } +} diff --git a/packages/richtext-slate/src/data/richTextRelationshipPromise.ts b/packages/richtext-slate/src/data/richTextRelationshipPromise.ts index 8d2dec7b0..7f301ab6d 100644 --- a/packages/richtext-slate/src/data/richTextRelationshipPromise.ts +++ b/packages/richtext-slate/src/data/richTextRelationshipPromise.ts @@ -5,7 +5,9 @@ import type { AdapterArguments } from '../types.js' import { populate } from './populate.js' import { recurseNestedFields } from './recurseNestedFields.js' -export type Args = Parameters['populationPromises']>[0] +export type Args = Parameters< + RichTextAdapter['graphQLPopulationPromises'] +>[0] type RecurseRichTextArgs = { children: unknown[] diff --git a/packages/richtext-slate/src/index.tsx b/packages/richtext-slate/src/index.tsx index 98705e342..8b9940e21 100644 --- a/packages/richtext-slate/src/index.tsx +++ b/packages/richtext-slate/src/index.tsx @@ -52,15 +52,7 @@ export function slateEditor( FieldComponent: RichTextField, generateComponentMap: getGenerateComponentMap(args), generateSchemaMap: getGenerateSchemaMap(args), - outputSchema: ({ isRequired }) => { - return { - type: withNullableJSONSchemaType('array', isRequired), - items: { - type: 'object', - }, - } - }, - populationPromises({ + graphQLPopulationPromises({ context, currentDepth, depth, @@ -98,6 +90,58 @@ export function slateEditor( }) } }, + hooks: { + afterRead: [ + ({ + context: _context, + currentDepth, + depth, + draft, + field: _field, + fieldPromises, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingData, + }) => { + const context: any = _context + const field = _field as any + if ( + field.admin?.elements?.includes('relationship') || + field.admin?.elements?.includes('upload') || + field.admin?.elements?.includes('link') || + !field?.admin?.elements + ) { + richTextRelationshipPromise({ + context, + currentDepth, + depth, + draft, + field, + fieldPromises, + findMany, + flattenLocales, + overrideAccess, + populationPromises, + req, + showHiddenFields, + siblingDoc: siblingData, + }) + } + }, + ], + }, + outputSchema: ({ isRequired }) => { + return { + type: withNullableJSONSchemaType('array', isRequired), + items: { + type: 'object', + }, + } + }, validate: richTextValidate, } } diff --git a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts index cfcb221b7..aed801316 100644 --- a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts @@ -920,10 +920,9 @@ describe('lexicalBlocks', () => { await wait(300) await page.click('#action-save', { delay: 100 }) - await wait(300) await expect(page.locator('.payload-toast-container')).toContainText( - 'The following field is invalid', + 'The following fields are invalid', ) await wait(300) diff --git a/test/fields/collections/Lexical/generateLexicalRichText.ts b/test/fields/collections/Lexical/generateLexicalRichText.ts index 70f864e5e..2bbd10a04 100644 --- a/test/fields/collections/Lexical/generateLexicalRichText.ts +++ b/test/fields/collections/Lexical/generateLexicalRichText.ts @@ -28,6 +28,7 @@ export function generateLexicalRichText() { format: '', type: 'upload', version: 2, + id: '665d105a91e1c337ba8308dd', fields: { caption: { root: { diff --git a/test/fields/collections/LexicalLocalized/generateLexicalRichText.ts b/test/fields/collections/LexicalLocalized/generateLexicalRichText.ts new file mode 100644 index 000000000..8d0fae35a --- /dev/null +++ b/test/fields/collections/LexicalLocalized/generateLexicalRichText.ts @@ -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', + }, + } +} diff --git a/test/fields/collections/LexicalLocalized/index.ts b/test/fields/collections/LexicalLocalized/index.ts index af1530381..5532e9173 100644 --- a/test/fields/collections/LexicalLocalized/index.ts +++ b/test/fields/collections/LexicalLocalized/index.ts @@ -21,11 +21,50 @@ export const LexicalLocalizedFields: CollectionConfig = { localized: true, }, { - name: 'lexicalSimple', + name: 'lexicalBlocksSubLocalized', type: 'richText', - localized: true, + admin: { + description: 'Non-localized field with localized block subfields', + }, editor: lexicalEditor({ - features: ({ defaultFeatures }) => [...defaultFeatures], + features: ({ defaultFeatures }) => [ + ...defaultFeatures, + BlocksFeature({ + blocks: [ + { + slug: 'block', + fields: [ + { + name: 'textLocalized', + type: 'text', + localized: true, + }, + { + name: 'counter', + type: 'number', + hooks: { + beforeChange: [ + ({ value }) => { + return value ? value + 1 : 1 + }, + ], + afterRead: [ + ({ value }) => { + return value ? value * 10 : 10 + }, + ], + }, + }, + { + name: 'rel', + type: 'relationship', + relationTo: lexicalLocalizedFieldsSlug, + }, + ], + }, + ], + }), + ], }), }, { @@ -60,36 +99,5 @@ export const LexicalLocalizedFields: CollectionConfig = { ], }), }, - { - name: 'lexicalBlocksSubLocalized', - type: 'richText', - admin: { - description: 'Non-localized field with localized block subfields', - }, - editor: lexicalEditor({ - features: ({ defaultFeatures }) => [ - ...defaultFeatures, - BlocksFeature({ - blocks: [ - { - slug: 'block', - fields: [ - { - name: 'textLocalized', - type: 'text', - localized: true, - }, - { - name: 'rel', - type: 'relationship', - relationTo: lexicalLocalizedFieldsSlug, - }, - ], - }, - ], - }), - ], - }), - }, ], } diff --git a/test/fields/collections/RichText/generateLexicalRichText.ts b/test/fields/collections/RichText/generateLexicalRichText.ts index 0d3f3c4bb..141e3e2dd 100644 --- a/test/fields/collections/RichText/generateLexicalRichText.ts +++ b/test/fields/collections/RichText/generateLexicalRichText.ts @@ -54,6 +54,7 @@ export function generateLexicalRichText() { direction: 'ltr', format: '', indent: 0, + id: '665d10938106ab380c7f3730', type: 'link', version: 2, fields: { @@ -86,6 +87,7 @@ export function generateLexicalRichText() { direction: 'ltr', format: '', indent: 0, + id: '665d10938106ab380c7f3730', type: 'link', version: 2, fields: { @@ -230,6 +232,7 @@ export function generateLexicalRichText() { format: '', type: 'upload', version: 2, + id: '665d10938106ab380c7f372f', relationTo: 'uploads', value: '{{UPLOAD_DOC_ID}}', fields: { diff --git a/test/fields/lexical.int.spec.ts b/test/fields/lexical.int.spec.ts index 4725e7ce3..2c47da1b0 100644 --- a/test/fields/lexical.int.spec.ts +++ b/test/fields/lexical.int.spec.ts @@ -14,6 +14,8 @@ import { devUser } from '../credentials.js' import { NextRESTClient } from '../helpers/NextRESTClient.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' import { lexicalDocData } from './collections/Lexical/data.js' +import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js' +import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js' import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js' import { richTextDocData } from './collections/RichText/data.js' import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText.js' @@ -569,4 +571,100 @@ describe('Lexical', () => { expect(populatedDocEditorRelationshipNode.value.text).toStrictEqual(textDoc.text) }) }) + + describe('Localization', () => { + it('ensure localized lexical field is different across locales', async () => { + const lexicalDocEN = await payload.find({ + collection: 'lexical-localized-fields', + locale: 'en', + where: { + title: { + equals: 'Localized Lexical en', + }, + }, + }) + + expect(lexicalDocEN.docs[0].lexicalBlocksLocalized.root.children[0].children[0].text).toEqual( + 'English text', + ) + + const lexicalDocES = await payload.findByID({ + collection: 'lexical-localized-fields', + locale: 'es', + id: lexicalDocEN.docs[0].id, + }) + + expect(lexicalDocES.lexicalBlocksLocalized.root.children[0].children[0].text).toEqual( + 'Spanish text', + ) + }) + + it('ensure localized text field within blocks field within unlocalized lexical field is different across locales', async () => { + const lexicalDocEN = await payload.find({ + collection: 'lexical-localized-fields', + locale: 'en', + where: { + title: { + equals: 'Localized Lexical en', + }, + }, + }) + + expect( + lexicalDocEN.docs[0].lexicalBlocksSubLocalized.root.children[0].children[0].text, + ).toEqual('Shared text') + + expect( + (lexicalDocEN.docs[0].lexicalBlocksSubLocalized.root.children[1].fields as any) + .textLocalized, + ).toEqual('English text in block') + + const lexicalDocES = await payload.findByID({ + collection: 'lexical-localized-fields', + locale: 'es', + id: lexicalDocEN.docs[0].id, + }) + + expect(lexicalDocES.lexicalBlocksSubLocalized.root.children[0].children[0].text).toEqual( + 'Shared text', + ) + + expect( + (lexicalDocES.lexicalBlocksSubLocalized.root.children[1].fields as any).textLocalized, + ).toEqual('Spanish text in block') + }) + }) + + describe('Hooks', () => { + it('ensure hook within number field within lexical block runs', async () => { + const lexicalDocEN = await payload.create({ + collection: 'lexical-localized-fields', + locale: 'en', + data: { + title: 'Localized Lexical hooks', + lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }) as any, + lexicalBlocksSubLocalized: generateLexicalLocalizedRichText( + 'Shared text', + 'English text in block', + ) as any, + }, + }) + + expect( + (lexicalDocEN.lexicalBlocksSubLocalized.root.children[1].fields as any).counter, + ).toEqual(20) // Initial: 1. BeforeChange: +1 (2). AfterRead: *10 (20) + + // update document with same data + const lexicalDocENUpdated = await payload.update({ + collection: 'lexical-localized-fields', + locale: 'en', + id: lexicalDocEN.id, + data: lexicalDocEN, + }) + + expect( + (lexicalDocENUpdated.lexicalBlocksSubLocalized.root.children[1].fields as any).counter, + ).toEqual(210) // Initial: 20. BeforeChange: +1 (21). AfterRead: *10 (210) + }) + }) }) diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index b88f8a8a4..599c40bfd 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -207,7 +207,7 @@ export interface LexicalMigrateField { export interface LexicalLocalizedField { id: string; title: string; - lexicalSimple?: { + lexicalBlocksSubLocalized?: { root: { type: string; children: { @@ -237,21 +237,6 @@ export interface LexicalLocalizedField { }; [k: string]: unknown; } | null; - lexicalBlocksSubLocalized?: { - root: { - type: string; - children: { - type: string; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; updatedAt: string; createdAt: string; } diff --git a/test/fields/seed.ts b/test/fields/seed.ts index 91f8080cc..aa3631e5f 100644 --- a/test/fields/seed.ts +++ b/test/fields/seed.ts @@ -15,6 +15,7 @@ import { dateDoc } from './collections/Date/shared.js' import { groupDoc } from './collections/Group/shared.js' import { jsonDoc } from './collections/JSON/shared.js' import { lexicalDocData } from './collections/Lexical/data.js' +import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js' import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js' import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js' import { numberDoc } from './collections/Number/shared.js' @@ -281,9 +282,11 @@ export const seed = async (_payload: Payload) => { collection: lexicalLocalizedFieldsSlug, data: { title: 'Localized Lexical en', - lexicalSimple: textToLexicalJSON({ text: 'English text' }), - lexicalBlocksLocalized: textToLexicalJSON({ text: 'English text' }), - lexicalBlocksSubLocalized: textToLexicalJSON({ text: 'English text' }), + lexicalBlocksLocalized: textToLexicalJSON({ text: 'English text' }) as any, + lexicalBlocksSubLocalized: generateLexicalLocalizedRichText( + 'Shared text', + 'English text in block', + ) as any, }, locale: 'en', depth: 0, @@ -295,9 +298,12 @@ export const seed = async (_payload: Payload) => { id: lexicalLocalizedDoc1.id, data: { title: 'Localized Lexical es', - lexicalSimple: textToLexicalJSON({ text: 'Spanish text' }), - lexicalBlocksLocalized: textToLexicalJSON({ text: 'Spanish text' }), - lexicalBlocksSubLocalized: textToLexicalJSON({ text: 'Spanish text' }), + lexicalBlocksLocalized: textToLexicalJSON({ text: 'Spanish text' }) as any, + lexicalBlocksSubLocalized: generateLexicalLocalizedRichText( + 'Shared text', + 'Spanish text in block', + (lexicalLocalizedDoc1.lexicalBlocksSubLocalized.root.children[1].fields as any).id, + ) as any, }, locale: 'es', depth: 0, diff --git a/test/hooks/int.spec.ts b/test/hooks/int.spec.ts index ec42772c0..454a7ccef 100644 --- a/test/hooks/int.spec.ts +++ b/test/hooks/int.spec.ts @@ -7,6 +7,7 @@ import type { NestedAfterReadHook } from './payload-types.js' import { devUser, regularUser } from '../credentials.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' +import { isMongoose } from '../helpers/isMongoose.js' import { afterOperationSlug } from './collections/AfterOperation/index.js' import { chainingHooksSlug } from './collections/ChainingHooks/index.js' import { contextHooksSlug } from './collections/ContextHooks/index.js' @@ -35,22 +36,23 @@ describe('Hooks', () => { await payload.db.destroy() } }) + if (isMongoose(payload)) { + describe('transform actions', () => { + it('should create and not throw an error', async () => { + // the collection has hooks that will cause an error if transform actions is not handled properly + const doc = await payload.create({ + collection: transformSlug, + data: { + localizedTransform: [2, 8], + transform: [2, 8], + }, + }) - describe('transform actions', () => { - it('should create and not throw an error', async () => { - // the collection has hooks that will cause an error if transform actions is not handled properly - const doc = await payload.create({ - collection: transformSlug, - data: { - localizedTransform: [2, 8], - transform: [2, 8], - }, + expect(doc.transform).toBeDefined() + expect(doc.localizedTransform).toBeDefined() }) - - expect(doc.transform).toBeDefined() - expect(doc.localizedTransform).toBeDefined() }) - }) + } describe('hook execution', () => { let doc