fix(richtext-lexical): ensure sub-fields have access to full document data in form state (#9869)

Fixes https://github.com/payloadcms/payload/issues/10940

This PR does the following:
- adds a `useDocumentForm` hook to access the document Form. Useful if
you are within a sub-Form
- ensure the `data` property passed to field conditions, read access
control, validation and filterOptions is always the top-level document
data. Previously, for fields within lexical blocks/links/upload, this
incorrectly was the lexical block-level data.
- adds a `blockData` property to hooks, field conditions,
read/update/create field access control, validation and filterOptions
for all fields. This allows you to access the data of the nearest parent
block, which is especially useful for lexical sub-fields. Users that
were previously depending on the incorrect behavior of the `data`
property in order to access the data of the lexical block can now switch
to the new `blockData` property
This commit is contained in:
Alessio Gravili
2025-02-06 11:49:17 -07:00
committed by GitHub
parent 8ed410456c
commit ae32c555ac
42 changed files with 869 additions and 70 deletions

View File

@@ -654,6 +654,26 @@ const ExampleCollection = {
]}
/>
## useDocumentForm
The `useDocumentForm` hook works the same way as the [useForm](#useform) hook, but it always gives you access to the top-level `Form` of a document. This is useful if you need to access the document's `Form` context from within a child `Form`.
An example where this could happen would be custom components within lexical blocks, as lexical blocks initialize their own child `Form`.
```tsx
'use client'
import { useDocumentForm } from '@payloadcms/ui'
const MyComponent: React.FC = () => {
const { fields: parentDocumentFields } = useDocumentForm()
return (
<p>The document's Form has ${Object.keys(parentDocumentFields).length} fields</p>
)
}
```
## useCollapsible
The `useCollapsible` hook allows you to control parent collapsibles:

View File

@@ -91,6 +91,7 @@ export const ForgotPasswordForm: React.FC = () => {
text(value, {
name: 'username',
type: 'text',
blockData: {},
data: {},
event: 'onChange',
preferences: { fields: {} },
@@ -120,6 +121,7 @@ export const ForgotPasswordForm: React.FC = () => {
email(value, {
name: 'email',
type: 'email',
blockData: {},
data: {},
event: 'onChange',
preferences: { fields: {} },

View File

@@ -68,9 +68,16 @@ export type BuildFormStateArgs = {
data?: Data
docPermissions: SanitizedDocumentPermissions | undefined
docPreferences: DocumentPreferences
/**
* In case `formState` is not the top-level, document form state, this can be passed to
* provide the top-level form state.
*/
documentFormState?: FormState
fallbackLocale?: false | TypedLocale
formState?: FormState
id?: number | string
initialBlockData?: Data
initialBlockFormState?: FormState
/*
If not i18n was passed, the language can be passed to init i18n
*/

View File

@@ -34,6 +34,7 @@ export const generatePasswordSaltHash = async ({
const validationResult = password(passwordToSet, {
name: 'password',
type: 'text',
blockData: {},
data: {},
event: 'submit',
preferences: { fields: {} },

View File

@@ -133,7 +133,13 @@ import type {
TextareaFieldValidation,
} from '../../index.js'
import type { DocumentPreferences } from '../../preferences/types.js'
import type { DefaultValue, Operation, PayloadRequest, Where } from '../../types/index.js'
import type {
DefaultValue,
JsonObject,
Operation,
PayloadRequest,
Where,
} from '../../types/index.js'
import type {
NumberFieldManyValidation,
NumberFieldSingleValidation,
@@ -148,6 +154,10 @@ import type {
} from '../validations.js'
export type FieldHookArgs<TData extends TypeWithID = any, TValue = any, TSiblingData = any> = {
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: JsonObject | undefined
/** The collection which the field belongs to. If the field belongs to a global, this will be null. */
collection: null | SanitizedCollectionConfig
context: RequestContext
@@ -212,7 +222,11 @@ export type FieldHook<TData extends TypeWithID = any, TValue = any, TSiblingData
export type FieldAccess<TData extends TypeWithID = any, TSiblingData = any> = (args: {
/**
* The incoming data used to `create` or `update` the document with. `data` is undefined during the `read` operation.
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData?: JsonObject | undefined
/**
* The incoming, top-level document data used to `create` or `update` the document with.
*/
data?: Partial<TData>
/**
@@ -231,13 +245,33 @@ export type FieldAccess<TData extends TypeWithID = any, TSiblingData = any> = (a
siblingData?: Partial<TSiblingData>
}) => boolean | Promise<boolean>
//TODO: In 4.0, we should replace the three parameters of the condition function with a single, named parameter object
export type Condition<TData extends TypeWithID = any, TSiblingData = any> = (
/**
* The top-level document data
*/
data: Partial<TData>,
/**
* Immediately adjacent data to this field. For example, if this is a `group` field, then `siblingData` will be the other fields within the group.
*/
siblingData: Partial<TSiblingData>,
{ user }: { user: PayloadRequest['user'] },
{
blockData,
user,
}: {
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: Partial<TData>
user: PayloadRequest['user']
},
) => boolean
export type FilterOptionsProps<TData = any> = {
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: TData
/**
* An object containing the full collection or global document currently being edited.
*/
@@ -348,6 +382,11 @@ export type LabelsClient = {
}
export type BaseValidateOptions<TData, TSiblingData, TValue> = {
/**
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: Partial<TData>
collectionSlug?: string
data: Partial<TData>
event?: 'onChange' | 'submit'

View File

@@ -11,6 +11,10 @@ import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
import { traverseFields } from './traverseFields.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
@@ -33,6 +37,7 @@ type Args = {
// - Execute field hooks
export const promise = async ({
blockData,
collection,
context,
data,
@@ -69,6 +74,7 @@ export const promise = async ({
await priorHook
const hookedValue = await currentHook({
blockData,
collection,
context,
data,
@@ -104,6 +110,7 @@ export const promise = async ({
rows.forEach((row, rowIndex) => {
promises.push(
traverseFields({
blockData,
collection,
context,
data,
@@ -142,6 +149,7 @@ export const promise = async ({
if (block) {
promises.push(
traverseFields({
blockData: siblingData?.[field.name]?.[rowIndex],
collection,
context,
data,
@@ -171,6 +179,7 @@ export const promise = async ({
case 'collapsible':
case 'row': {
await traverseFields({
blockData,
collection,
context,
data,
@@ -193,6 +202,7 @@ export const promise = async ({
case 'group': {
await traverseFields({
blockData,
collection,
context,
data,
@@ -269,6 +279,7 @@ export const promise = async ({
}
await traverseFields({
blockData,
collection,
context,
data,
@@ -291,6 +302,7 @@ export const promise = async ({
case 'tabs': {
await traverseFields({
blockData,
collection,
context,
data,

View File

@@ -7,6 +7,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
@@ -25,6 +29,7 @@ type Args = {
}
export const traverseFields = async ({
blockData,
collection,
context,
data,
@@ -46,6 +51,7 @@ export const traverseFields = async ({
fields.forEach((field, fieldIndex) => {
promises.push(
promise({
blockData,
collection,
context,
data,

View File

@@ -19,6 +19,10 @@ import { relationshipPopulationPromise } from './relationshipPopulationPromise.j
import { traverseFields } from './traverseFields.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
currentDepth: number
@@ -60,6 +64,7 @@ type Args = {
// - Populate relationships
export const promise = async ({
blockData,
collection,
context,
currentDepth,
@@ -236,6 +241,7 @@ export const promise = async ({
const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) =>
(async () => {
const hookedValue = await currentHook({
blockData,
collection,
context,
currentDepth,
@@ -266,6 +272,7 @@ export const promise = async ({
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
blockData,
collection,
context,
currentDepth,
@@ -301,6 +308,7 @@ export const promise = async ({
? true
: await field.access.read({
id: doc.id as number | string,
blockData,
data: doc,
doc,
req,
@@ -364,6 +372,7 @@ export const promise = async ({
if (Array.isArray(rows)) {
rows.forEach((row, rowIndex) => {
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -397,6 +406,7 @@ export const promise = async ({
if (Array.isArray(localeRows)) {
localeRows.forEach((row, rowIndex) => {
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -476,6 +486,7 @@ export const promise = async ({
if (block) {
traverseFields({
blockData: row,
collection,
context,
currentDepth,
@@ -515,6 +526,7 @@ export const promise = async ({
if (block) {
traverseFields({
blockData: row,
collection,
context,
currentDepth,
@@ -554,6 +566,7 @@ export const promise = async ({
case 'collapsible':
case 'row': {
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -595,6 +608,7 @@ export const promise = async ({
const groupSelect = select?.[field.name]
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -747,6 +761,7 @@ export const promise = async ({
}
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -780,6 +795,7 @@ export const promise = async ({
case 'tabs': {
traverseFields({
blockData,
collection,
context,
currentDepth,

View File

@@ -13,6 +13,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
currentDepth: number
@@ -45,6 +49,7 @@ type Args = {
}
export const traverseFields = ({
blockData,
collection,
context,
currentDepth,
@@ -75,6 +80,7 @@ export const traverseFields = ({
fields.forEach((field, fieldIndex) => {
fieldPromises.push(
promise({
blockData,
collection,
context,
currentDepth,

View File

@@ -4,7 +4,7 @@ import type { ValidationFieldError } from '../../../errors/index.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import type { Field, TabAsField, Validate } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
@@ -16,6 +16,10 @@ import { getExistingRowDoc } from './getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
@@ -48,6 +52,7 @@ type Args = {
export const promise = async ({
id,
blockData,
collection,
context,
data,
@@ -77,7 +82,7 @@ export const promise = async ({
})
const passesCondition = field.admin?.condition
? Boolean(field.admin.condition(data, siblingData, { user: req.user }))
? Boolean(field.admin.condition(data, siblingData, { blockData, user: req.user }))
: true
let skipValidationFromHere = skipValidation || !passesCondition
const { localization } = req.payload.config
@@ -102,6 +107,7 @@ export const promise = async ({
await priorHook
const hookedValue = await currentHook({
blockData,
collection,
context,
data,
@@ -139,22 +145,27 @@ export const promise = async ({
}
}
const validationResult = await field.validate(
valueToValidate as never,
{
...field,
id,
collectionSlug: collection?.slug,
data: deepMergeWithSourceArrays(doc, data),
event: 'submit',
jsonError,
operation,
preferences: { fields: {} },
previousValue: siblingDoc[field.name],
req,
siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData),
} as any,
)
const validateFn: Validate<object, object, object, object> = field.validate as Validate<
object,
object,
object,
object
>
const validationResult = await validateFn(valueToValidate as never, {
...field,
id,
blockData,
collectionSlug: collection?.slug,
data: deepMergeWithSourceArrays(doc, data),
event: 'submit',
// @ts-expect-error
jsonError,
operation,
preferences: { fields: {} },
previousValue: siblingDoc[field.name],
req,
siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData),
})
if (typeof validationResult === 'string') {
const label = getTranslatedLabel(field?.label || field?.name, req.i18n)
@@ -217,6 +228,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
data,
@@ -268,6 +280,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
data,
@@ -301,6 +314,7 @@ export const promise = async ({
case 'row': {
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -339,6 +353,7 @@ export const promise = async ({
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -455,6 +470,7 @@ export const promise = async ({
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -481,6 +497,7 @@ export const promise = async ({
case 'tabs': {
await traverseFields({
id,
blockData,
collection,
context,
data,

View File

@@ -8,6 +8,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
@@ -51,6 +55,7 @@ type Args = {
*/
export const traverseFields = async ({
id,
blockData,
collection,
context,
data,
@@ -76,6 +81,7 @@ export const traverseFields = async ({
promises.push(
promise({
id,
blockData,
collection,
context,
data,

View File

@@ -9,6 +9,10 @@ import { runBeforeDuplicateHooks } from './runHook.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
doc: T
@@ -25,6 +29,7 @@ type Args<T> = {
export const promise = async <T>({
id,
blockData,
collection,
context,
doc,
@@ -63,6 +68,7 @@ export const promise = async <T>({
const localizedValues = await localizedValuesPromise
const beforeDuplicateArgs: FieldHookArgs = {
blockData,
collection,
context,
data: doc,
@@ -96,6 +102,7 @@ export const promise = async <T>({
siblingDoc[field.name] = localeData
} else {
const beforeDuplicateArgs: FieldHookArgs = {
blockData,
collection,
context,
data: doc,
@@ -143,6 +150,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -177,6 +185,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
doc,
@@ -199,6 +208,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -234,6 +244,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -270,6 +281,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
doc,
@@ -300,6 +312,7 @@ export const promise = async <T>({
await traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -324,6 +337,7 @@ export const promise = async <T>({
await traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -347,6 +361,7 @@ export const promise = async <T>({
case 'row': {
await traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -367,6 +382,7 @@ export const promise = async <T>({
case 'tab': {
await traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -386,6 +402,7 @@ export const promise = async <T>({
case 'tabs': {
await traverseFields({
id,
blockData,
collection,
context,
doc,

View File

@@ -6,6 +6,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
doc: T
@@ -21,6 +25,7 @@ type Args<T> = {
export const traverseFields = async <T>({
id,
blockData,
collection,
context,
doc,
@@ -38,6 +43,7 @@ export const traverseFields = async <T>({
promises.push(
promise({
id,
blockData,
collection,
context,
doc,

View File

@@ -14,6 +14,10 @@ import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: T
@@ -47,6 +51,7 @@ type Args<T> = {
export const promise = async <T>({
id,
blockData,
collection,
context,
data,
@@ -270,6 +275,7 @@ export const promise = async <T>({
await priorHook
const hookedValue = await currentHook({
blockData,
collection,
context,
data,
@@ -298,7 +304,7 @@ export const promise = async <T>({
if (field.access && field.access[operation]) {
const result = overrideAccess
? true
: await field.access[operation]({ id, data, doc, req, siblingData })
: await field.access[operation]({ id, blockData, data, doc, req, siblingData })
if (!result) {
delete siblingData[field.name]
@@ -335,6 +341,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
data,
@@ -375,6 +382,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
data,
@@ -404,6 +412,7 @@ export const promise = async <T>({
case 'row': {
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -437,6 +446,7 @@ export const promise = async <T>({
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -522,6 +532,7 @@ export const promise = async <T>({
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -544,6 +555,7 @@ export const promise = async <T>({
case 'tabs': {
await traverseFields({
id,
blockData,
collection,
context,
data,

View File

@@ -7,6 +7,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: T
@@ -32,6 +36,7 @@ type Args<T> = {
export const traverseFields = async <T>({
id,
blockData,
collection,
context,
data,
@@ -53,6 +58,7 @@ export const traverseFields = async <T>({
promises.push(
promise({
id,
blockData,
collection,
context,
data,

View File

@@ -510,7 +510,7 @@ const validateFilterOptions: Validate<
RelationshipField | UploadField
> = async (
value,
{ id, data, filterOptions, relationTo, req, req: { payload, t, user }, siblingData },
{ id, blockData, data, filterOptions, relationTo, req, req: { payload, t, user }, siblingData },
) => {
if (typeof filterOptions !== 'undefined' && value) {
const options: {
@@ -527,6 +527,7 @@ const validateFilterOptions: Validate<
typeof filterOptions === 'function'
? await filterOptions({
id,
blockData,
data,
relationTo: collection,
req,

View File

@@ -12,6 +12,7 @@ import {
Pill,
RenderFields,
SectionTitle,
useDocumentForm,
useDocumentInfo,
useEditDepth,
useFormSubmitted,
@@ -23,6 +24,7 @@ import { deepCopyObjectSimpleWithoutReactComponents, reduceFieldsToValues } from
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
const baseClass = 'lexical-block'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { getTranslation } from '@payloadcms/translations'
import { $getNodeByKey } from 'lexical'
@@ -33,9 +35,9 @@ import type { BlockFields } from '../../server/nodes/BlocksNode.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
import './index.scss'
import { $isBlockNode } from '../nodes/BlocksNode.js'
import { BlockContent } from './BlockContent.js'
import './index.scss'
import { removeEmptyArrayValues } from './removeEmptyArrayValues.js'
type Props = {
@@ -64,6 +66,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
},
uuid: uuidFromContext,
} = useEditorConfigContext()
const { fields: parentDocumentFields } = useDocumentForm()
const onChangeAbortControllerRef = useRef(new AbortController())
const editDepth = useEditDepth()
const [errorCount, setErrorCount] = React.useState(0)
@@ -127,7 +131,9 @@ export const BlockComponent: React.FC<Props> = (props) => {
data: formData,
docPermissions: { fields: true },
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
globalSlug,
initialBlockData: formData,
operation: 'update',
renderAllFields: true,
schemaPath: schemaFieldsPath,
@@ -164,6 +170,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
collectionSlug,
globalSlug,
getDocPreferences,
parentDocumentFields,
])
const [isCollapsed, setIsCollapsed] = React.useState<boolean>(
@@ -196,8 +203,10 @@ export const BlockComponent: React.FC<Props> = (props) => {
fields: true,
},
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
formState: prevFormState,
globalSlug,
initialBlockFormState: prevFormState,
operation: 'update',
renderAllFields: submit ? true : false,
schemaPath: schemaFieldsPath,
@@ -254,6 +263,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
globalSlug,
schemaFieldsPath,
formData.blockType,
parentDocumentFields,
editor,
nodeKey,
],

View File

@@ -16,6 +16,7 @@ import {
FormSubmit,
RenderFields,
ShimmerEffect,
useDocumentForm,
useDocumentInfo,
useEditDepth,
useServerFunctions,
@@ -26,6 +27,7 @@ import { $getNodeByKey } from 'lexical'
import './index.scss'
import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared'
import { v4 as uuid } from 'uuid'
import type { InlineBlockFields } from '../../server/nodes/InlineBlocksNode.js'
@@ -77,6 +79,8 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
setCreatedInlineBlock,
uuid: uuidFromContext,
} = useEditorConfigContext()
const { fields: parentDocumentFields } = useDocumentForm()
const { getFormState } = useServerFunctions()
const editDepth = useEditDepth()
const firstTimeDrawer = useRef(false)
@@ -161,7 +165,10 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
data: formData,
docPermissions: { fields: true },
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
globalSlug,
initialBlockData: formData,
initialBlockFormState: formData,
operation: 'update',
renderAllFields: true,
schemaPath: schemaFieldsPath,
@@ -191,6 +198,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
collectionSlug,
globalSlug,
getDocPreferences,
parentDocumentFields,
])
/**
@@ -210,8 +218,10 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
fields: true,
},
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
formState: prevFormState,
globalSlug,
initialBlockFormState: prevFormState,
operation: 'update',
renderAllFields: submit ? true : false,
schemaPath: schemaFieldsPath,
@@ -229,7 +239,15 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
return state
},
[getFormState, id, collectionSlug, getDocPreferences, globalSlug, schemaFieldsPath],
[
getFormState,
id,
collectionSlug,
getDocPreferences,
parentDocumentFields,
globalSlug,
schemaFieldsPath,
],
)
// cleanup effect
useEffect(() => {

View File

@@ -13,7 +13,7 @@ export const blockValidationHOC = (
const blockFieldData = node.fields ?? ({} as BlockFields)
const {
options: { id, collectionSlug, operation, preferences, req },
options: { id, collectionSlug, data, operation, preferences, req },
} = validation
// find block
@@ -32,8 +32,10 @@ export const blockValidationHOC = (
id,
collectionSlug,
data: blockFieldData,
documentData: data,
fields: block.fields,
fieldSchemaMap: undefined,
initialBlockData: blockFieldData,
operation: operation === 'create' || operation === 'update' ? operation : 'update',
permissions: {},
preferences,

View File

@@ -13,7 +13,7 @@ export const linkValidation = (
return async ({
node,
validation: {
options: { id, collectionSlug, operation, preferences, req },
options: { id, collectionSlug, data, operation, preferences, req },
},
}) => {
/**
@@ -24,8 +24,10 @@ export const linkValidation = (
id,
collectionSlug,
data: node.fields,
documentData: data,
fields: sanitizedFieldsWithoutText, // Sanitized in feature.server.ts
fieldSchemaMap: undefined,
initialBlockData: node.fields,
operation: operation === 'create' || operation === 'update' ? operation : 'update',
permissions: {},
preferences,

View File

@@ -13,6 +13,7 @@ export const uploadValidation = (
validation: {
options: {
id,
data,
operation,
preferences,
req,
@@ -45,9 +46,12 @@ export const uploadValidation = (
const result = await fieldSchemasToFormState({
id,
collectionSlug: node.relationTo,
data: node?.fields ?? {},
documentData: data,
fields: collection.fields,
fieldSchemaMap: undefined,
initialBlockData: node?.fields ?? {},
operation: operation === 'create' || operation === 'update' ? operation : 'update',
permissions: {},
preferences,

View File

@@ -56,6 +56,7 @@ export const RscEntryLexicalField: React.FC<
id: args.id,
clientFieldSchemaMap: args.clientFieldSchemaMap,
collectionSlug: args.collectionSlug,
documentData: args.data,
field,
fieldSchemaMap: args.fieldSchemaMap,
lexicalFieldSchemaPath: schemaPath,

View File

@@ -287,6 +287,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
if (subFields?.length) {
await afterChangeTraverseFields({
blockData: nodeSiblingData,
collection,
context,
data: data ?? {},
@@ -395,10 +396,11 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
if (subFieldFn && subFieldDataFn) {
const subFields = subFieldFn({ node, req })
const nodeSliblingData = subFieldDataFn({ node, req }) ?? {}
const nodeSiblingData = subFieldDataFn({ node, req }) ?? {}
if (subFields?.length) {
afterReadTraverseFields({
blockData: nodeSiblingData,
collection,
context,
currentDepth: currentDepth!,
@@ -420,7 +422,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
populationPromises: populationPromises!,
req,
showHiddenFields: showHiddenFields!,
siblingDoc: nodeSliblingData,
siblingDoc: nodeSiblingData,
triggerAccessControl,
triggerHooks,
})
@@ -564,6 +566,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
if (subFields?.length) {
await beforeChangeTraverseFields({
id,
blockData: nodeSiblingData,
collection,
context,
data: data ?? {},
@@ -758,6 +761,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
if (subFields?.length) {
await beforeValidateTraverseFields({
id,
blockData: nodeSiblingData,
collection,
context,
data,

View File

@@ -25,6 +25,7 @@ type Props = {
context: {
clientFieldSchemaMap: ClientFieldSchemaMap
collectionSlug: string
documentData?: any
field: RichTextField
fieldSchemaMap: FieldSchemaMap
id?: number | string
@@ -73,8 +74,10 @@ export async function buildInitialState({
clientFieldSchemaMap: context.clientFieldSchemaMap,
collectionSlug: context.collectionSlug,
data: blockNode.fields,
documentData: context.documentData,
fields: (context.fieldSchemaMap.get(schemaFieldsPath) as any)?.fields,
fieldSchemaMap: context.fieldSchemaMap,
initialBlockData: blockNode.fields,
operation: context.operation as any, // TODO: Type
permissions: true,
preferences: context.preferences,

View File

@@ -5,11 +5,13 @@ import {
Form,
FormSubmit,
RenderFields,
useDocumentForm,
useDocumentInfo,
useServerFunctions,
useTranslation,
} from '@payloadcms/ui'
import { abortAndIgnore } from '@payloadcms/ui/shared'
import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { v4 as uuid } from 'uuid'
@@ -28,6 +30,7 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
}) => {
const { t } = useTranslation()
const { id, collectionSlug, getDocPreferences, globalSlug } = useDocumentInfo()
const { fields: parentDocumentFields } = useDocumentForm()
const onChangeAbortControllerRef = useRef(new AbortController())
@@ -57,7 +60,9 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
fields: true,
},
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
globalSlug,
initialBlockData: data,
operation: 'update',
renderAllFields: true,
schemaPath: schemaFieldsPath,
@@ -72,7 +77,16 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
return () => {
abortAndIgnore(controller)
}
}, [schemaFieldsPath, id, data, getFormState, collectionSlug, globalSlug, getDocPreferences])
}, [
schemaFieldsPath,
id,
data,
getFormState,
collectionSlug,
globalSlug,
getDocPreferences,
parentDocumentFields,
])
const onChange = useCallback(
async ({ formState: prevFormState }: { formState: FormState }) => {
@@ -88,8 +102,10 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
fields: true,
},
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
formState: prevFormState,
globalSlug,
initialBlockFormState: prevFormState,
operation: 'update',
schemaPath: schemaFieldsPath,
signal: controller.signal,
@@ -101,7 +117,15 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
return state
},
[getFormState, id, collectionSlug, getDocPreferences, globalSlug, schemaFieldsPath],
[
getFormState,
id,
collectionSlug,
getDocPreferences,
parentDocumentFields,
globalSlug,
schemaFieldsPath,
],
)
// cleanup effect

View File

@@ -176,6 +176,7 @@ export { fieldBaseClass } from '../../fields/shared/index.js'
export {
useAllFormFields,
useDocumentForm,
useForm,
useFormFields,
useFormInitializing,

View File

@@ -49,6 +49,7 @@ const PasswordFieldComponent: React.FC<PasswordFieldProps> = (props) => {
return password(value, {
name: 'password',
type: 'text',
blockData: {},
data: {},
event: 'onChange',
preferences: { fields: {} },

View File

@@ -11,6 +11,7 @@ import {
import type { Context, FormFieldsContext as FormFieldsContextType } from './types.js'
const FormContext = createContext({} as Context)
const DocumentFormContext = createContext({} as Context)
const FormWatchContext = createContext({} as Context)
const SubmittedContext = createContext(false)
const ProcessingContext = createContext(false)
@@ -26,6 +27,12 @@ export type RenderedFieldSlots = Map<string, RenderedField>
* @see https://payloadcms.com/docs/admin/hooks#useform
*/
const useForm = (): Context => useContext(FormContext)
/**
* Get the state of the document-level form. This is useful if you need to access the document-level Form from within a child Form.
* This is the case withing lexical Blocks, as each lexical blocks renders their own Form.
*/
const useDocumentForm = (): Context => useContext(DocumentFormContext)
const useWatchForm = (): Context => useContext(FormWatchContext)
const useFormSubmitted = (): boolean => useContext(SubmittedContext)
const useFormProcessing = (): boolean => useContext(ProcessingContext)
@@ -49,6 +56,7 @@ const useFormFields = <Value = unknown>(
const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext)
export {
DocumentFormContext,
FormContext,
FormFieldsContext,
FormWatchContext,
@@ -57,6 +65,7 @@ export {
ProcessingContext,
SubmittedContext,
useAllFormFields,
useDocumentForm,
useForm,
useFormFields,
useFormInitializing,

View File

@@ -10,7 +10,7 @@ import {
reduceFieldsToValues,
wait,
} from 'payload/shared'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { toast } from 'sonner'
import type {
@@ -34,6 +34,7 @@ import { useTranslation } from '../../providers/Translation/index.js'
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
import { requests } from '../../utilities/api.js'
import {
DocumentFormContext,
FormContext,
FormFieldsContext,
FormWatchContext,
@@ -41,6 +42,7 @@ import {
ModifiedContext,
ProcessingContext,
SubmittedContext,
useDocumentForm,
} from './context.js'
import { errorMessages } from './errorMessages.js'
import { fieldReducer } from './fieldReducer.js'
@@ -63,6 +65,7 @@ export const Form: React.FC<FormProps> = (props) => {
// fields: fieldsFromProps = collection?.fields || global?.fields,
handleResponse,
initialState, // fully formed initial field state
isDocumentForm,
isInitializing: initializingFromProps,
onChange,
onSubmit,
@@ -77,6 +80,8 @@ export const Form: React.FC<FormProps> = (props) => {
const router = useRouter()
const documentForm = useDocumentForm()
const { code: locale } = useLocale()
const { i18n, t } = useTranslation()
const { refreshCookie, user } = useAuth()
@@ -110,8 +115,7 @@ export const Form: React.FC<FormProps> = (props) => {
const validatedFieldState = {}
let isValid = true
const dataFromContext = contextRef.current.getData()
const data = dataFromContext
const data = contextRef.current.getData()
const validationPromises = Object.entries(contextRef.current.fields).map(
async ([path, field]) => {
@@ -131,7 +135,9 @@ export const Form: React.FC<FormProps> = (props) => {
...field,
id,
collectionSlug,
data,
// If there is a parent document form, we can get the data from that form
blockData: undefined, // Will be expensive to get - not worth to pass to client-side validation, as this can be obtained by the user using `useFormFields()`
data: documentForm?.getData ? documentForm.getData() : data,
event: 'submit',
operation,
preferences: {} as any,
@@ -170,7 +176,7 @@ export const Form: React.FC<FormProps> = (props) => {
}
return isValid
}, [collectionSlug, config, dispatchFields, id, operation, t, user])
}, [collectionSlug, config, dispatchFields, id, operation, t, user, documentForm])
const submit = useCallback(
async (options: SubmitOptions = {}, e): Promise<void> => {
@@ -719,6 +725,16 @@ export const Form: React.FC<FormProps> = (props) => {
250,
)
const DocumentFormContextComponent: React.FC<any> = isDocumentForm
? DocumentFormContext.Provider
: React.Fragment
const documentFormContextProps = isDocumentForm
? {
value: contextRef.current,
}
: {}
return (
<form
action={typeof action === 'function' ? void action : action}
@@ -728,31 +744,34 @@ export const Form: React.FC<FormProps> = (props) => {
onSubmit={(e) => void contextRef.current.submit({}, e)}
ref={formRef}
>
<FormContext.Provider value={contextRef.current}>
<FormWatchContext.Provider
value={{
fields,
...contextRef.current,
}}
>
<SubmittedContext.Provider value={submitted}>
<InitializingContext.Provider value={!isMounted || (isMounted && initializing)}>
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
<FormFieldsContext.Provider value={fieldsReducer}>
{children}
</FormFieldsContext.Provider>
</ModifiedContext.Provider>
</ProcessingContext.Provider>
</InitializingContext.Provider>
</SubmittedContext.Provider>
</FormWatchContext.Provider>
</FormContext.Provider>
<DocumentFormContextComponent {...documentFormContextProps}>
<FormContext.Provider value={contextRef.current}>
<FormWatchContext.Provider
value={{
fields,
...contextRef.current,
}}
>
<SubmittedContext.Provider value={submitted}>
<InitializingContext.Provider value={!isMounted || (isMounted && initializing)}>
<ProcessingContext.Provider value={processing}>
<ModifiedContext.Provider value={modified}>
<FormFieldsContext.Provider value={fieldsReducer}>
{children}
</FormFieldsContext.Provider>
</ModifiedContext.Provider>
</ProcessingContext.Provider>
</InitializingContext.Provider>
</SubmittedContext.Provider>
</FormWatchContext.Provider>
</FormContext.Provider>
</DocumentFormContextComponent>
</form>
)
}
export {
DocumentFormContext,
FormContext,
FormFieldsContext,
FormWatchContext,
@@ -760,6 +779,7 @@ export {
ProcessingContext,
SubmittedContext,
useAllFormFields,
useDocumentForm,
useForm,
useFormFields,
useFormModified,

View File

@@ -37,6 +37,13 @@ export type FormProps = {
errorToast: (value: string) => void,
) => void
initialState?: FormState
/**
* Determines if this Form is the main, top-level Form of a document. If set to true, the
* Form's children will be wrapped in a DocumentFormContext, which lets you access this document
* Form's data and fields from any child component - even if that child component is wrapped in a child
* Form (e.g. a lexical block).
*/
isDocumentForm?: boolean
isInitializing?: boolean
log?: boolean
onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise<FormState>)[]

View File

@@ -39,6 +39,10 @@ export type AddFieldStatePromiseArgs = {
* if all parents are localized, then the field is localized
*/
anyParentLocalized?: boolean
/**
* Data of the nearest parent block, or undefined
*/
blockData: Data | undefined
clientFieldSchemaMap?: ClientFieldSchemaMap
collectionSlug?: string
data: Data
@@ -101,6 +105,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized = false,
blockData,
clientFieldSchemaMap,
collectionSlug,
data,
@@ -159,7 +164,13 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldPermissions === true || deepCopyObjectSimple(fieldPermissions?.read)
if (typeof field?.access?.read === 'function') {
hasPermission = await field.access.read({ id, data: fullData, req, siblingData: data })
hasPermission = await field.access.read({
id,
blockData,
data: fullData,
req,
siblingData: data,
})
} else {
hasPermission = true
}
@@ -187,6 +198,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
validationResult = await validate(data?.[field.name], {
...field,
id,
blockData,
collectionSlug,
data: fullData,
event: 'onChange',
@@ -257,6 +269,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id,
addErrorPathToParent,
anyParentLocalized: field.localized || anyParentLocalized,
blockData,
clientFieldSchemaMap,
collectionSlug,
data: row,
@@ -421,6 +434,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id,
addErrorPathToParent,
anyParentLocalized: field.localized || anyParentLocalized,
blockData: row,
clientFieldSchemaMap,
collectionSlug,
data: row,
@@ -517,6 +531,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id,
addErrorPathToParent,
anyParentLocalized: field.localized || anyParentLocalized,
blockData,
clientFieldSchemaMap,
collectionSlug,
data: data?.[field.name] || {},
@@ -565,6 +580,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
if (typeof field.filterOptions === 'function') {
const query = await getFilterOptionsQuery(field.filterOptions, {
id,
blockData,
data: fullData,
relationTo: field.relationTo,
req,
@@ -665,6 +681,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
// passthrough parent functionality
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized: fieldIsLocalized(field) || anyParentLocalized,
blockData,
clientFieldSchemaMap,
collectionSlug,
data,
@@ -730,6 +747,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized: tab.localized || anyParentLocalized,
blockData,
clientFieldSchemaMap,
collectionSlug,
data: isNamedTab ? data?.[tab.name] || {} : data,

View File

@@ -24,6 +24,23 @@ type Args = {
clientFieldSchemaMap?: ClientFieldSchemaMap
collectionSlug?: string
data?: Data
/**
* If this is undefined, the `data` passed to this function will serve as `fullData` and `data` when iterating over
* the top-level-fields to generate form state.
* For sub fields, the `data` will be narrowed down to the sub fields, while `fullData` remains the same.
*
* Usually, the `data` passed to this function will be the document data. This means that running validation, read access control
* or executing filterOptions here will have access to the full document through the passed `fullData` parameter, and that `fullData` and `data` will be identical.
*
* In some cases however, this function is used to generate form state solely for sub fields - independent from the parent form state.
* This means that `data` will be the form state of the sub fields - the document data won't be available here.
*
* In these cases, you can pass `documentData` which will be used as `fullData` instead of `data`.
*
* This is useful for lexical blocks, as lexical block fields there are not part of the parent form state, yet we still want
* document data to be available for validation and filterOptions, under the `data` key.
*/
documentData?: Data
fields: Field[] | undefined
/**
* The field schema map is required for field rendering.
@@ -32,6 +49,11 @@ type Args = {
*/
fieldSchemaMap: FieldSchemaMap | undefined
id?: number | string
/**
* Validation, filterOptions and read access control will receive the `blockData`, which is the data of the nearest parent block. You can pass in
* the initial block data here, which will be used as `blockData` for the top-level fields, until the first block is encountered.
*/
initialBlockData?: Data
operation?: 'create' | 'update'
permissions: SanitizedFieldsPermissions
preferences: DocumentPreferences
@@ -56,8 +78,10 @@ export const fieldSchemasToFormState = async ({
clientFieldSchemaMap,
collectionSlug,
data = {},
documentData,
fields,
fieldSchemaMap,
initialBlockData,
operation,
permissions,
preferences,
@@ -89,15 +113,24 @@ export const fieldSchemasToFormState = async ({
user: req.user,
})
let fullData = dataWithDefaultValues
if (documentData) {
// By the time this function is used to get form state for nested forms, their default values should have already been calculated
// => no need to run calculateDefaultValues here
fullData = documentData
}
await iterateFields({
id,
addErrorPathToParent: null,
blockData: initialBlockData,
clientFieldSchemaMap,
collectionSlug,
data: dataWithDefaultValues,
fields,
fieldSchemaMap,
fullData: dataWithDefaultValues,
fullData,
operation,
parentIndexPath: '',
parentPassesCondition: true,

View File

@@ -23,6 +23,10 @@ type Args = {
* if any parents is localized, then the field is localized. @default false
*/
anyParentLocalized?: boolean
/**
* Data of the nearest parent block, or undefined
*/
blockData: Data | undefined
clientFieldSchemaMap?: ClientFieldSchemaMap
collectionSlug?: string
data: Data
@@ -75,6 +79,7 @@ export const iterateFields = async ({
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized = false,
blockData,
clientFieldSchemaMap,
collectionSlug,
data,
@@ -117,7 +122,9 @@ export const iterateFields = async ({
try {
passesCondition = Boolean(
(field?.admin?.condition
? Boolean(field.admin.condition(fullData || {}, data || {}, { user: req.user }))
? Boolean(
field.admin.condition(fullData || {}, data || {}, { blockData, user: req.user }),
)
: true) && parentPassesCondition,
)
} catch (err) {
@@ -135,6 +142,7 @@ export const iterateFields = async ({
id,
addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized,
blockData,
clientFieldSchemaMap,
collectionSlug,
data,

View File

@@ -15,6 +15,7 @@ import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
import { useOperation } from '../../providers/Operation/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import {
useDocumentForm,
useForm,
useFormFields,
useFormInitializing,
@@ -45,6 +46,7 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
const { config } = useConfig()
const { getData, getDataByPath, getSiblingData, setModified } = useForm()
const documentForm = useDocumentForm()
const modified = useFormModified()
const filterOptions = field?.filterOptions
@@ -142,12 +144,14 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
let errorMessage: string | undefined = prevErrorMessage.current
let valid: boolean | string = prevValid.current
const data = getData()
const isValid =
typeof validate === 'function'
? await validate(valueToValidate, {
id,
blockData: undefined, // Will be expensive to get - not worth to pass to client-side validation, as this can be obtained by the user using `useFormFields()`
collectionSlug,
data: getData(),
data: documentForm?.getData ? documentForm.getData() : data,
event: 'onChange',
operation,
preferences: {} as any,

View File

@@ -102,8 +102,11 @@ export const buildFormState = async (
data: incomingData,
docPermissions,
docPreferences,
documentFormState,
formState,
globalSlug,
initialBlockData,
initialBlockFormState,
operation,
renderAllFields,
req,
@@ -165,6 +168,16 @@ export const buildFormState = async (
data = reduceFieldsToValues(formState, true)
}
let documentData = undefined
if (documentFormState) {
documentData = reduceFieldsToValues(documentFormState, true)
}
let blockData = initialBlockData
if (initialBlockFormState) {
blockData = reduceFieldsToValues(initialBlockFormState, true)
}
/**
* When building state for sub schemas we need to adjust:
* - `fields`
@@ -185,8 +198,10 @@ export const buildFormState = async (
clientFieldSchemaMap: clientSchemaMap,
collectionSlug,
data,
documentData,
fields,
fieldSchemaMap: schemaMap,
initialBlockData: blockData,
operation,
permissions: docPermissions?.fields || {},
preferences: docPreferences || { fields: {} },

View File

@@ -38,6 +38,7 @@ export const APIKey: React.FC<{ readonly enabled: boolean; readonly readOnly?: b
text(val, {
name: 'apiKey',
type: 'text',
blockData: {},
data: {},
event: 'onChange',
maxLength: 48,

View File

@@ -445,6 +445,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
disabled={isReadOnlyForIncomingUser || isInitializing || !hasSavePermission}
disableValidationOnSubmit={!validateBeforeSubmit}
initialState={!isInitializing && initialState}
isDocumentForm={true}
isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}

View File

@@ -4,7 +4,7 @@ import { BlockCollapsible } from '@payloadcms/richtext-lexical/client'
import React from 'react'
export const BlockComponentRSC: BlocksFieldServerComponent = (props) => {
const { data } = props
const { siblingData } = props
return <BlockCollapsible>Data: {data?.key ?? ''}</BlockCollapsible>
return <BlockCollapsible>Data: {siblingData?.key ?? ''}</BlockCollapsible>
}

View File

@@ -1,4 +1,4 @@
import type { ArrayField, Block } from 'payload'
import type { ArrayField, Block, TextFieldSingleValidation } from 'payload'
import { BlocksFeature, FixedToolbarFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
@@ -11,6 +11,122 @@ async function asyncFunction(param: string) {
}, 1000)
})
}
export const FilterOptionsBlock: Block = {
slug: 'filterOptionsBlock',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'groupText',
type: 'text',
},
{
name: 'dependsOnDocData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ data }) => {
if (!data.title) {
return true
}
return {
text: {
equals: data.title,
},
}
},
},
{
name: 'dependsOnSiblingData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ siblingData }) => {
console.log('SD', siblingData)
// @ts-expect-error
if (!siblingData?.groupText) {
return true
}
return {
text: {
equals: (siblingData as any)?.groupText,
},
}
},
},
{
name: 'dependsOnBlockData',
type: 'relationship',
relationTo: 'text-fields',
filterOptions: ({ blockData }) => {
if (!blockData?.text) {
return true
}
return {
text: {
equals: blockData?.text,
},
}
},
},
],
},
],
}
export const ValidationBlock: Block = {
slug: 'validationBlock',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'groupText',
type: 'text',
},
{
name: 'textDependsOnDocData',
type: 'text',
validate: ((value, { data }) => {
if ((data as any)?.title === 'invalid') {
return 'doc title cannot be invalid'
}
return true
}) as TextFieldSingleValidation,
},
{
name: 'textDependsOnSiblingData',
type: 'text',
validate: ((value, { siblingData }) => {
if ((siblingData as any)?.groupText === 'invalid') {
return 'textDependsOnSiblingData sibling field cannot be invalid'
}
}) as TextFieldSingleValidation,
},
{
name: 'textDependsOnBlockData',
type: 'text',
validate: ((value, { blockData }) => {
if ((blockData as any)?.text === 'invalid') {
return 'textDependsOnBlockData sibling field cannot be invalid'
}
}) as TextFieldSingleValidation,
},
],
},
],
}
export const AsyncHooksBlock: Block = {
slug: 'asyncHooksBlock',
fields: [

View File

@@ -30,6 +30,7 @@ import { RESTClient } from '../../../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../../../playwright.config.js'
import { lexicalFieldsSlug } from '../../../../slugs.js'
import { lexicalDocData } from '../../data.js'
import { trackNetworkRequests } from '../../../../../helpers/e2e/trackNetworkRequests.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -301,17 +302,335 @@ describe('lexicalBlocks', () => {
fn: async ({ lexicalWithBlocks }) => {
const rscBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[14] as SerializedBlockNode
const paragraphBlock: SerializedBlockNode = lexicalWithBlocks.root
.children[12] as SerializedBlockNode
const paragraphNode: SerializedParagraphNode = lexicalWithBlocks.root
.children[12] as SerializedParagraphNode
expect(rscBlock.fields.blockType).toBe('BlockRSC')
expect(rscBlock.fields.key).toBe('value2')
expect((paragraphBlock.children[0] as SerializedTextNode).text).toBe('123')
expect((paragraphBlock.children[0] as SerializedTextNode).format).toBe(1)
expect((paragraphNode.children[0] as SerializedTextNode).text).toBe('123')
expect((paragraphNode.children[0] as SerializedTextNode).format).toBe(1)
},
})
})
describe('block filterOptions', () => {
async function setupFilterOptionsTests() {
const { richTextField } = await navigateToLexicalFields()
await payload.create({
collection: 'text-fields',
data: {
text: 'invalid',
},
depth: 0,
})
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
await expect(lastParagraph).toBeVisible()
await lastParagraph.click()
await page.keyboard.press('Enter')
await page.keyboard.press('/')
await page.keyboard.type('filter')
// CreateBlock
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
const blockSelectButton = slashMenuPopover.locator('button').first()
await expect(blockSelectButton).toBeVisible()
await expect(blockSelectButton).toContainText('Filter Options Block')
await blockSelectButton.click()
await expect(slashMenuPopover).toBeHidden()
const newBlock = richTextField
.locator('.lexical-block:not(.lexical-block .lexical-block)')
.nth(8) // The :not(.lexical-block .lexical-block) makes sure this does not select sub-blocks
await newBlock.scrollIntoViewIfNeeded()
await saveDocAndAssert(page)
const topLevelDocTextField = page.locator('#field-title').first()
const blockTextField = newBlock.locator('#field-text').first()
const blockGroupTextField = newBlock.locator('#field-group__groupText').first()
const dependsOnDocData = newBlock.locator('#field-group__dependsOnDocData').first()
const dependsOnSiblingData = newBlock.locator('#field-group__dependsOnSiblingData').first()
const dependsOnBlockData = newBlock.locator('#field-group__dependsOnBlockData').first()
return {
topLevelDocTextField,
blockTextField,
blockGroupTextField,
dependsOnDocData,
dependsOnSiblingData,
dependsOnBlockData,
newBlock,
}
}
test('ensure block fields with filter options have access to document-level data', async () => {
const {
blockGroupTextField,
blockTextField,
dependsOnBlockData,
dependsOnDocData,
dependsOnSiblingData,
newBlock,
topLevelDocTextField,
} = await setupFilterOptionsTests()
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('No options')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnBlockData.locator('.rs__control').click()
// Fill and wait for form state to come back
await trackNetworkRequests(page, '/admin/collections/lexical-fields', async () => {
await topLevelDocTextField.fill('invalid')
})
// Ensure block form state is updated and comes back (=> filter options are updated)
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockTextField.fill('.')
await blockTextField.fill('')
},
{ allowedNumberOfRequests: 2 },
)
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnBlockData.locator('.rs__control').click()
await saveDocAndAssert(page)
})
test('ensure block fields with filter options have access to sibling data', async () => {
const {
blockGroupTextField,
blockTextField,
dependsOnBlockData,
dependsOnDocData,
dependsOnSiblingData,
newBlock,
topLevelDocTextField,
} = await setupFilterOptionsTests()
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockGroupTextField.fill('invalid')
},
{ allowedNumberOfRequests: 2 },
)
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('No options')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnBlockData.locator('.rs__control').click()
await saveDocAndAssert(page)
})
test('ensure block fields with filter options have access to block-level data', async () => {
const {
blockGroupTextField,
blockTextField,
dependsOnBlockData,
dependsOnDocData,
dependsOnSiblingData,
newBlock,
topLevelDocTextField,
} = await setupFilterOptionsTests()
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockTextField.fill('invalid')
},
{ allowedNumberOfRequests: 2 },
)
await dependsOnDocData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('No options')
await dependsOnDocData.locator('.rs__control').click()
await dependsOnSiblingData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toContainText('Seeded text document')
await expect(newBlock.locator('.rs__menu')).toContainText('Another text document')
await dependsOnSiblingData.locator('.rs__control').click()
await dependsOnBlockData.locator('.rs__control').click()
await expect(newBlock.locator('.rs__menu')).toHaveText('Text Fieldsinvalid')
await dependsOnBlockData.locator('.rs__control').click()
await saveDocAndAssert(page)
})
})
describe('block validation data', () => {
async function setupValidationTests() {
const { richTextField } = await navigateToLexicalFields()
await payload.create({
collection: 'text-fields',
data: {
text: 'invalid',
},
depth: 0,
})
const lastParagraph = richTextField.locator('p').last()
await lastParagraph.scrollIntoViewIfNeeded()
await expect(lastParagraph).toBeVisible()
await lastParagraph.click()
await page.keyboard.press('Enter')
await page.keyboard.press('/')
await page.keyboard.type('validation')
// CreateBlock
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
const blockSelectButton = slashMenuPopover.locator('button').first()
await expect(blockSelectButton).toBeVisible()
await expect(blockSelectButton).toContainText('Validation Block')
await blockSelectButton.click()
await expect(slashMenuPopover).toBeHidden()
const newBlock = richTextField
.locator('.lexical-block:not(.lexical-block .lexical-block)')
.nth(8) // The :not(.lexical-block .lexical-block) makes sure this does not select sub-blocks
await newBlock.scrollIntoViewIfNeeded()
await saveDocAndAssert(page)
const topLevelDocTextField = page.locator('#field-title').first()
const blockTextField = newBlock.locator('#field-text').first()
const blockGroupTextField = newBlock.locator('#field-group__groupText').first()
const dependsOnDocData = newBlock.locator('#field-group__textDependsOnDocData').first()
const dependsOnSiblingData = newBlock
.locator('#field-group__textDependsOnSiblingData')
.first()
const dependsOnBlockData = newBlock.locator('#field-group__textDependsOnBlockData').first()
return {
topLevelDocTextField,
blockTextField,
blockGroupTextField,
dependsOnDocData,
dependsOnSiblingData,
dependsOnBlockData,
newBlock,
}
}
test('ensure block fields with validations have access to document-level data', async () => {
const { topLevelDocTextField } = await setupValidationTests()
await topLevelDocTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Doc Data',
)
await expect(page.locator('.payload-toast-container')).not.toBeVisible()
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await topLevelDocTextField.fill('Rich Text') // Default value
},
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
})
test('ensure block fields with validations have access to sibling data', async () => {
const { blockGroupTextField } = await setupValidationTests()
await blockGroupTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Sibling Data',
)
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockGroupTextField.fill('')
},
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
})
test('ensure block fields with validations have access to block-level data', async () => {
const { blockTextField } = await setupValidationTests()
await blockTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Block Data',
)
await expect(page.locator('.payload-toast-container')).not.toBeVisible()
await trackNetworkRequests(
page,
'/admin/collections/lexical-fields',
async () => {
await blockTextField.fill('')
},
{ allowedNumberOfRequests: 2 },
)
await saveDocAndAssert(page)
})
})
test('ensure async hooks are awaited properly', async () => {
const { richTextField } = await navigateToLexicalFields()

View File

@@ -23,6 +23,7 @@ import {
AsyncHooksBlock,
CodeBlock,
ConditionalLayoutBlock,
FilterOptionsBlock,
RadioButtonsBlock,
RelationshipBlock,
RelationshipHasManyBlock,
@@ -32,6 +33,7 @@ import {
TabBlock,
TextBlock,
UploadAndRichTextBlock,
ValidationBlock,
} from './blocks.js'
import { ModifyInlineBlockFeature } from './ModifyInlineBlockFeature/feature.server.js'
@@ -74,6 +76,8 @@ const editorConfig: ServerEditorConfig = {
ModifyInlineBlockFeature(),
BlocksFeature({
blocks: [
ValidationBlock,
FilterOptionsBlock,
AsyncHooksBlock,
RichTextBlock,
TextBlock,

View File

@@ -8,11 +8,11 @@ const dirname = path.dirname(filename)
dotenv.config({ path: path.resolve(dirname, 'test.env') })
let multiplier = process.env.CI ? 3 : 0.75
let smallMultiplier = process.env.CI ? 2 : 0.75
let multiplier = process.env.CI ? 3 : 1
let smallMultiplier = process.env.CI ? 2 : 1
export const TEST_TIMEOUT_LONG = 640000 * multiplier // 8*3 minutes - used as timeOut for the beforeAll
export const TEST_TIMEOUT = 30000 * multiplier
export const TEST_TIMEOUT = 40000 * multiplier
export const EXPECT_TIMEOUT = 6000 * smallMultiplier
export const POLL_TOPASS_TIMEOUT = EXPECT_TIMEOUT * 4 // That way expect.poll() or expect().toPass can retry 4 times. 4x higher than default expect timeout => can retry 4 times if retryable expects are used inside