Skip to content

Commit 4e12705

Browse files
authored
feat(richtext-lexical)!: sub-field hooks and localization support (#6591)
## BREAKING - Our internal field hook methods now have new required `schemaPath` and path `props`. This affects the following functions, if you are using those: `afterChangeTraverseFields`, `afterReadTraverseFields`, `beforeChangeTraverseFields`, `beforeValidateTraverseFields`, `afterReadPromise` - The afterChange field hook's `value` is now the value AFTER the previous hooks were run. Previously, this was the original value, which I believe is a bug - Only relevant if you have built your own richText adapter: the richText adapter `populationPromises` property has been renamed to `graphQLPopulationPromises` and is now only run for graphQL. Previously, it was run for graphQL AND the rest API. To migrate, use `hooks.afterRead` to run population for the rest API - Only relevant if you have built your own lexical features: The `populationPromises` server feature property has been renamed to `graphQLPopulationPromises` and is now only run for graphQL. Previously, it was run for graphQL AND the rest API. To migrate, use `hooks.afterRead` to run population for the rest API - Serialized lexical link and upload nodes now have a new `id` property. While not breaking, localization / hooks will not work for their fields until you have migrated to that. Re-saving the old document on the new version will automatically add the `id` property for you. You will also get a bunch of console logs for every lexical node which is not migrated
1 parent 27510bb commit 4e12705

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1937
-492
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { generateSchema } from '../bin/generateSchema.js'
2+
export { buildObjectType } from '../schema/buildObjectType.js'

packages/graphql/src/schema/buildObjectType.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ type Args = {
8181
parentName: string
8282
}
8383

84-
function buildObjectType({
84+
export function buildObjectType({
8585
name,
8686
baseFields = {},
8787
config,
@@ -492,13 +492,13 @@ function buildObjectType({
492492
// is run here again, with the provided depth.
493493
// In the graphql find.ts resolver, the depth is then hard-coded to 0.
494494
// Effectively, this means that the populationPromise for GraphQL is only run here, and not in the find.ts resolver / normal population promise.
495-
if (editor?.populationPromises) {
495+
if (editor?.graphQLPopulationPromises) {
496496
const fieldPromises = []
497497
const populationPromises = []
498498
const populateDepth =
499499
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
500500

501-
editor?.populationPromises({
501+
editor?.graphQLPopulationPromises({
502502
context,
503503
depth: populateDepth,
504504
draft: args.draft,
@@ -698,5 +698,3 @@ function buildObjectType({
698698

699699
return newlyCreatedBlockType
700700
}
701-
702-
export default buildObjectType

packages/graphql/src/schema/initCollections.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import restoreVersionResolver from '../resolvers/collections/restoreVersion.js'
3737
import { updateResolver } from '../resolvers/collections/update.js'
3838
import formatName from '../utilities/formatName.js'
3939
import { buildMutationInputType, getCollectionIDType } from './buildMutationInputType.js'
40-
import buildObjectType from './buildObjectType.js'
40+
import { buildObjectType } from './buildObjectType.js'
4141
import buildPaginatedListType from './buildPaginatedListType.js'
4242
import { buildPolicyType } from './buildPoliciesType.js'
4343
import buildWhereInputType from './buildWhereInputType.js'

packages/graphql/src/schema/initGlobals.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import restoreVersionResolver from '../resolvers/globals/restoreVersion.js'
1717
import updateResolver from '../resolvers/globals/update.js'
1818
import formatName from '../utilities/formatName.js'
1919
import { buildMutationInputType } from './buildMutationInputType.js'
20-
import buildObjectType from './buildObjectType.js'
20+
import { buildObjectType } from './buildObjectType.js'
2121
import buildPaginatedListType from './buildPaginatedListType.js'
2222
import { buildPolicyType } from './buildPoliciesType.js'
2323
import buildWhereInputType from './buildWhereInputType.js'

packages/payload/src/admin/RichText.ts

Lines changed: 190 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { GenericLanguages, I18n, I18nClient } from '@payloadcms/translation
22
import type { JSONSchema4 } from 'json-schema'
33
import type React from 'react'
44

5+
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
56
import type { SanitizedConfig } from '../config/types.js'
6-
import type { Field, FieldBase, RichTextField, Validate } from '../fields/config/types.js'
7+
import type { Field, FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js'
8+
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
79
import type { PayloadRequestWithData, RequestContext } from '../types/index.js'
810
import type { WithServerSidePropsComponentProps } from './elements/WithServerSideProps.js'
911

@@ -15,6 +17,173 @@ export type RichTextFieldProps<
1517
path?: string
1618
}
1719

20+
export type AfterReadRichTextHookArgs<
21+
TData extends TypeWithID = any,
22+
TValue = any,
23+
TSiblingData = any,
24+
> = {
25+
currentDepth?: number
26+
27+
depth?: number
28+
29+
draft?: boolean
30+
31+
fallbackLocale?: string
32+
33+
fieldPromises?: Promise<void>[]
34+
35+
/** Boolean to denote if this hook is running against finding one, or finding many within the afterRead hook. */
36+
findMany?: boolean
37+
38+
flattenLocales?: boolean
39+
40+
locale?: string
41+
42+
/** A string relating to which operation the field type is currently executing within. */
43+
operation?: 'create' | 'delete' | 'read' | 'update'
44+
45+
overrideAccess?: boolean
46+
47+
populationPromises?: Promise<void>[]
48+
showHiddenFields?: boolean
49+
triggerAccessControl?: boolean
50+
triggerHooks?: boolean
51+
}
52+
53+
export type AfterChangeRichTextHookArgs<
54+
TData extends TypeWithID = any,
55+
TValue = any,
56+
TSiblingData = any,
57+
> = {
58+
/** A string relating to which operation the field type is currently executing within. */
59+
operation: 'create' | 'update'
60+
/** The document before changes were applied. */
61+
previousDoc?: TData
62+
/** The sibling data of the document before changes being applied. */
63+
previousSiblingDoc?: TData
64+
/** The previous value of the field, before changes */
65+
previousValue?: TValue
66+
}
67+
export type BeforeValidateRichTextHookArgs<
68+
TData extends TypeWithID = any,
69+
TValue = any,
70+
TSiblingData = any,
71+
> = {
72+
/** A string relating to which operation the field type is currently executing within. */
73+
operation: 'create' | 'update'
74+
overrideAccess?: boolean
75+
/** The sibling data of the document before changes being applied. */
76+
previousSiblingDoc?: TData
77+
/** The previous value of the field, before changes */
78+
previousValue?: TValue
79+
}
80+
81+
export type BeforeChangeRichTextHookArgs<
82+
TData extends TypeWithID = any,
83+
TValue = any,
84+
TSiblingData = any,
85+
> = {
86+
/**
87+
* The original data with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks.
88+
*/
89+
docWithLocales?: Record<string, unknown>
90+
91+
duplicate?: boolean
92+
93+
errors?: { field: string; message: string }[]
94+
/** Only available in `beforeChange` field hooks */
95+
mergeLocaleActions?: (() => Promise<void>)[]
96+
/** A string relating to which operation the field type is currently executing within. */
97+
operation?: 'create' | 'delete' | 'read' | 'update'
98+
/** The sibling data of the document before changes being applied. */
99+
previousSiblingDoc?: TData
100+
/** The previous value of the field, before changes */
101+
previousValue?: TValue
102+
/**
103+
* The original siblingData with locales (not modified by any hooks).
104+
*/
105+
siblingDocWithLocales?: Record<string, unknown>
106+
107+
skipValidation?: boolean
108+
}
109+
110+
export type BaseRichTextHookArgs<
111+
TData extends TypeWithID = any,
112+
TValue = any,
113+
TSiblingData = any,
114+
> = {
115+
/** The collection which the field belongs to. If the field belongs to a global, this will be null. */
116+
collection: SanitizedCollectionConfig | null
117+
context: RequestContext
118+
/** The data passed to update the document within create and update operations, and the full document itself in the afterRead hook. */
119+
data?: Partial<TData>
120+
/** The field which the hook is running against. */
121+
field: FieldAffectingData
122+
/** The global which the field belongs to. If the field belongs to a collection, this will be null. */
123+
global: SanitizedGlobalConfig | null
124+
125+
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
126+
originalDoc?: TData
127+
/**
128+
* 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.
129+
*/
130+
path: (number | string)[]
131+
132+
/** The Express request object. It is mocked for Local API operations. */
133+
req: PayloadRequestWithData
134+
/**
135+
* 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.
136+
*/
137+
schemaPath: string[]
138+
/** The sibling data passed to a field that the hook is running against. */
139+
siblingData: Partial<TSiblingData>
140+
/** The value of the field. */
141+
value?: TValue
142+
}
143+
144+
export type AfterReadRichTextHook<
145+
TData extends TypeWithID = any,
146+
TValue = any,
147+
TSiblingData = any,
148+
> = (
149+
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
150+
AfterReadRichTextHookArgs<TData, TValue, TSiblingData>,
151+
) => Promise<TValue> | TValue
152+
153+
export type AfterChangeRichTextHook<
154+
TData extends TypeWithID = any,
155+
TValue = any,
156+
TSiblingData = any,
157+
> = (
158+
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
159+
AfterChangeRichTextHookArgs<TData, TValue, TSiblingData>,
160+
) => Promise<TValue> | TValue
161+
162+
export type BeforeChangeRichTextHook<
163+
TData extends TypeWithID = any,
164+
TValue = any,
165+
TSiblingData = any,
166+
> = (
167+
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
168+
BeforeChangeRichTextHookArgs<TData, TValue, TSiblingData>,
169+
) => Promise<TValue> | TValue
170+
171+
export type BeforeValidateRichTextHook<
172+
TData extends TypeWithID = any,
173+
TValue = any,
174+
TSiblingData = any,
175+
> = (
176+
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
177+
BeforeValidateRichTextHookArgs<TData, TValue, TSiblingData>,
178+
) => Promise<TValue> | TValue
179+
180+
export type RichTextHooks = {
181+
afterChange?: AfterChangeRichTextHook[]
182+
afterRead?: AfterReadRichTextHook[]
183+
beforeChange?: BeforeChangeRichTextHook[]
184+
beforeValidate?: BeforeValidateRichTextHook[]
185+
}
186+
18187
type RichTextAdapterBase<
19188
Value extends object = object,
20189
AdapterProps = any,
@@ -32,31 +201,13 @@ type RichTextAdapterBase<
32201
schemaMap: Map<string, Field[]>
33202
schemaPath: string
34203
}) => Map<string, Field[]>
35-
hooks?: FieldBase['hooks']
36-
i18n?: Partial<GenericLanguages>
37-
outputSchema?: ({
38-
collectionIDFieldTypes,
39-
config,
40-
field,
41-
interfaceNameDefinitions,
42-
isRequired,
43-
}: {
44-
collectionIDFieldTypes: { [key: string]: 'number' | 'string' }
45-
config?: SanitizedConfig
46-
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
47-
/**
48-
* Allows you to define new top-level interfaces that can be re-used in the output schema.
49-
*/
50-
interfaceNameDefinitions: Map<string, JSONSchema4>
51-
isRequired: boolean
52-
}) => JSONSchema4
53204
/**
54-
* Like an afterRead hook, but runs for both afterRead AND in the GraphQL resolver. For populating data, this should be used.
205+
* 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.
55206
*
56207
* 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.
57208
* @param data
58209
*/
59-
populationPromises?: (data: {
210+
graphQLPopulationPromises?: (data: {
60211
context: RequestContext
61212
currentDepth?: number
62213
depth: number
@@ -71,6 +222,24 @@ type RichTextAdapterBase<
71222
showHiddenFields: boolean
72223
siblingDoc: Record<string, unknown>
73224
}) => void
225+
hooks?: RichTextHooks
226+
i18n?: Partial<GenericLanguages>
227+
outputSchema?: ({
228+
collectionIDFieldTypes,
229+
config,
230+
field,
231+
interfaceNameDefinitions,
232+
isRequired,
233+
}: {
234+
collectionIDFieldTypes: { [key: string]: 'number' | 'string' }
235+
config?: SanitizedConfig
236+
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
237+
/**
238+
* Allows you to define new top-level interfaces that can be re-used in the output schema.
239+
*/
240+
interfaceNameDefinitions: Map<string, JSONSchema4>
241+
isRequired: boolean
242+
}) => JSONSchema4
74243
validate: Validate<
75244
Value,
76245
Value,

packages/payload/src/fields/config/sanitize.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -172,25 +172,6 @@ export const sanitizeFields = async ({
172172
if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) {
173173
config.i18n.translations = deepMerge(config.i18n.translations, field.editor.i18n)
174174
}
175-
176-
// Add editor adapter hooks to field hooks
177-
if (!field.hooks) field.hooks = {}
178-
179-
const mergeHooks = (hookName: keyof typeof field.editor.hooks) => {
180-
if (typeof field.editor === 'function') return
181-
182-
if (field.editor?.hooks?.[hookName]?.length) {
183-
field.hooks[hookName] = field.hooks[hookName]
184-
? field.hooks[hookName].concat(field.editor.hooks[hookName])
185-
: [...field.editor.hooks[hookName]]
186-
}
187-
}
188-
189-
mergeHooks('afterRead')
190-
mergeHooks('afterChange')
191-
mergeHooks('beforeChange')
192-
mergeHooks('beforeValidate')
193-
mergeHooks('beforeDuplicate')
194175
}
195176
if (richTextSanitizationPromises) {
196177
richTextSanitizationPromises.push(sanitizeRichText)

packages/payload/src/fields/config/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,8 @@ export const richText = baseField.keys({
499499
CellComponent: componentSchema.optional(),
500500
FieldComponent: componentSchema.optional(),
501501
afterReadPromise: joi.func().optional(),
502+
graphQLPopulationPromises: joi.func().optional(),
502503
outputSchema: joi.func().optional(),
503-
populationPromise: joi.func().optional(),
504504
validate: joi.func().required(),
505505
})
506506
.unknown(),

packages/payload/src/fields/config/types.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,28 @@ export type FieldHookArgs<TData extends TypeWithID = any, TValue = any, TSibling
4141
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
4242
originalDoc?: TData
4343
overrideAccess?: boolean
44+
/**
45+
* 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.
46+
*/
47+
path: (number | string)[]
4448
/** The document before changes were applied, only in `afterChange` hooks. */
4549
previousDoc?: TData
46-
/** The sibling data of the document before changes being applied, only in `beforeChange` and `afterChange` hook. */
50+
/** The sibling data of the document before changes being applied, only in `beforeChange`, `beforeValidate`, `beforeDuplicate` and `afterChange` field hooks. */
4751
previousSiblingDoc?: TData
48-
/** The previous value of the field, before changes, only in `beforeChange`, `afterChange` and `beforeValidate` hooks. */
52+
/** The previous value of the field, before changes, only in `beforeChange`, `afterChange`, `beforeDuplicate` and `beforeValidate` field hooks. */
4953
previousValue?: TValue
5054
/** The Express request object. It is mocked for Local API operations. */
5155
req: PayloadRequestWithData
56+
/**
57+
* 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.
58+
*/
59+
schemaPath: string[]
5260
/** The sibling data passed to a field that the hook is running against. */
5361
siblingData: Partial<TSiblingData>
62+
/**
63+
* The original siblingData with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks.
64+
*/
65+
siblingDocWithLocales?: Record<string, unknown>
5466
/** The value of the field. */
5567
value?: TValue
5668
}

0 commit comments

Comments
 (0)