diff --git a/docs/admin/hooks.mdx b/docs/admin/hooks.mdx
index 4c2c151bc..1974a45dd 100644
--- a/docs/admin/hooks.mdx
+++ b/docs/admin/hooks.mdx
@@ -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 (
+
The document's Form has ${Object.keys(parentDocumentFields).length} fields
+ )
+}
+```
+
## useCollapsible
The `useCollapsible` hook allows you to control parent collapsibles:
diff --git a/packages/next/src/views/ForgotPassword/ForgotPasswordForm/index.tsx b/packages/next/src/views/ForgotPassword/ForgotPasswordForm/index.tsx
index 50625e283..3672ddc6e 100644
--- a/packages/next/src/views/ForgotPassword/ForgotPasswordForm/index.tsx
+++ b/packages/next/src/views/ForgotPassword/ForgotPasswordForm/index.tsx
@@ -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: {} },
diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts
index 263e97214..7ed86c105 100644
--- a/packages/payload/src/admin/forms/Form.ts
+++ b/packages/payload/src/admin/forms/Form.ts
@@ -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
*/
diff --git a/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts b/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts
index 8106b0d42..a0b65c0ff 100644
--- a/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts
+++ b/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts
@@ -34,6 +34,7 @@ export const generatePasswordSaltHash = async ({
const validationResult = password(passwordToSet, {
name: 'password',
type: 'text',
+ blockData: {},
data: {},
event: 'submit',
preferences: { fields: {} },
diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts
index c41b4c56f..561f8580e 100644
--- a/packages/payload/src/fields/config/types.ts
+++ b/packages/payload/src/fields/config/types.ts
@@ -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 = {
+ /**
+ * 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 = (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
/**
@@ -231,13 +245,33 @@ export type FieldAccess = (a
siblingData?: Partial
}) => boolean | Promise
+//TODO: In 4.0, we should replace the three parameters of the condition function with a single, named parameter object
export type Condition = (
+ /**
+ * The top-level document data
+ */
data: Partial,
+ /**
+ * 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,
- { 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
+ user: PayloadRequest['user']
+ },
) => boolean
export type FilterOptionsProps = {
+ /**
+ * 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 = {
+ /**
+ /**
+ * The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
+ */
+ blockData: Partial
collectionSlug?: string
data: Partial
event?: 'onChange' | 'submit'
diff --git a/packages/payload/src/fields/hooks/afterChange/promise.ts b/packages/payload/src/fields/hooks/afterChange/promise.ts
index 7c1dbe6e0..53fd12f05 100644
--- a/packages/payload/src/fields/hooks/afterChange/promise.ts
+++ b/packages/payload/src/fields/hooks/afterChange/promise.ts
@@ -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,
diff --git a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts
index 273729e65..09ce619d7 100644
--- a/packages/payload/src/fields/hooks/afterChange/traverseFields.ts
+++ b/packages/payload/src/fields/hooks/afterChange/traverseFields.ts
@@ -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,
diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts
index 5a36fdcf9..f572880b7 100644
--- a/packages/payload/src/fields/hooks/afterRead/promise.ts
+++ b/packages/payload/src/fields/hooks/afterRead/promise.ts
@@ -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,
diff --git a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts
index 7f5028a19..7d9b5c64e 100644
--- a/packages/payload/src/fields/hooks/afterRead/traverseFields.ts
+++ b/packages/payload/src/fields/hooks/afterRead/traverseFields.ts
@@ -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,
diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts
index 1e827684f..e38bc8664 100644
--- a/packages/payload/src/fields/hooks/beforeChange/promise.ts
+++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts
@@ -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 = 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,
diff --git a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts
index c75b3e865..a341bd98c 100644
--- a/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts
+++ b/packages/payload/src/fields/hooks/beforeChange/traverseFields.ts
@@ -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,
diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
index cec3cac67..da20308f3 100644
--- a/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
+++ b/packages/payload/src/fields/hooks/beforeDuplicate/promise.ts
@@ -9,6 +9,10 @@ import { runBeforeDuplicateHooks } from './runHook.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
doc: T
@@ -25,6 +29,7 @@ type Args = {
export const promise = async ({
id,
+ blockData,
collection,
context,
doc,
@@ -63,6 +68,7 @@ export const promise = async ({
const localizedValues = await localizedValuesPromise
const beforeDuplicateArgs: FieldHookArgs = {
+ blockData,
collection,
context,
data: doc,
@@ -96,6 +102,7 @@ export const promise = async ({
siblingDoc[field.name] = localeData
} else {
const beforeDuplicateArgs: FieldHookArgs = {
+ blockData,
collection,
context,
data: doc,
@@ -143,6 +150,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
+ blockData,
collection,
context,
doc,
@@ -177,6 +185,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
+ blockData: row,
collection,
context,
doc,
@@ -199,6 +208,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
+ blockData,
collection,
context,
doc,
@@ -234,6 +244,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
+ blockData,
collection,
context,
doc,
@@ -270,6 +281,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
+ blockData: row,
collection,
context,
doc,
@@ -300,6 +312,7 @@ export const promise = async ({
await traverseFields({
id,
+ blockData,
collection,
context,
doc,
@@ -324,6 +337,7 @@ export const promise = async ({
await traverseFields({
id,
+ blockData,
collection,
context,
doc,
@@ -347,6 +361,7 @@ export const promise = async ({
case 'row': {
await traverseFields({
id,
+ blockData,
collection,
context,
doc,
@@ -367,6 +382,7 @@ export const promise = async ({
case 'tab': {
await traverseFields({
id,
+ blockData,
collection,
context,
doc,
@@ -386,6 +402,7 @@ export const promise = async ({
case 'tabs': {
await traverseFields({
id,
+ blockData,
collection,
context,
doc,
diff --git a/packages/payload/src/fields/hooks/beforeDuplicate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeDuplicate/traverseFields.ts
index b94047870..91a1ac5bc 100644
--- a/packages/payload/src/fields/hooks/beforeDuplicate/traverseFields.ts
+++ b/packages/payload/src/fields/hooks/beforeDuplicate/traverseFields.ts
@@ -6,6 +6,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
doc: T
@@ -21,6 +25,7 @@ type Args = {
export const traverseFields = async ({
id,
+ blockData,
collection,
context,
doc,
@@ -38,6 +43,7 @@ export const traverseFields = async ({
promises.push(
promise({
id,
+ blockData,
collection,
context,
doc,
diff --git a/packages/payload/src/fields/hooks/beforeValidate/promise.ts b/packages/payload/src/fields/hooks/beforeValidate/promise.ts
index fede4caed..ecd732d59 100644
--- a/packages/payload/src/fields/hooks/beforeValidate/promise.ts
+++ b/packages/payload/src/fields/hooks/beforeValidate/promise.ts
@@ -14,6 +14,10 @@ import { getExistingRowDoc } from '../beforeChange/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: T
@@ -47,6 +51,7 @@ type Args = {
export const promise = async ({
id,
+ blockData,
collection,
context,
data,
@@ -270,6 +275,7 @@ export const promise = async ({
await priorHook
const hookedValue = await currentHook({
+ blockData,
collection,
context,
data,
@@ -298,7 +304,7 @@ export const promise = async ({
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 ({
promises.push(
traverseFields({
id,
+ blockData,
collection,
context,
data,
@@ -375,6 +382,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
+ blockData: row,
collection,
context,
data,
@@ -404,6 +412,7 @@ export const promise = async ({
case 'row': {
await traverseFields({
id,
+ blockData,
collection,
context,
data,
@@ -437,6 +446,7 @@ export const promise = async ({
await traverseFields({
id,
+ blockData,
collection,
context,
data,
@@ -522,6 +532,7 @@ export const promise = async ({
await traverseFields({
id,
+ blockData,
collection,
context,
data,
@@ -544,6 +555,7 @@ export const promise = async ({
case 'tabs': {
await traverseFields({
id,
+ blockData,
collection,
context,
data,
diff --git a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts
index 8f1a29f5e..a982401c8 100644
--- a/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts
+++ b/packages/payload/src/fields/hooks/beforeValidate/traverseFields.ts
@@ -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: T
@@ -32,6 +36,7 @@ type Args = {
export const traverseFields = async ({
id,
+ blockData,
collection,
context,
data,
@@ -53,6 +58,7 @@ export const traverseFields = async ({
promises.push(
promise({
id,
+ blockData,
collection,
context,
data,
diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts
index 94250728f..270e1d9fa 100644
--- a/packages/payload/src/fields/validations.ts
+++ b/packages/payload/src/fields/validations.ts
@@ -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,
diff --git a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
index 75f5061cc..15c2dd545 100644
--- a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
+++ b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
@@ -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) => {
},
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) => {
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) => {
collectionSlug,
globalSlug,
getDocPreferences,
+ parentDocumentFields,
])
const [isCollapsed, setIsCollapsed] = React.useState(
@@ -196,8 +203,10 @@ export const BlockComponent: React.FC = (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) => {
globalSlug,
schemaFieldsPath,
formData.blockType,
+ parentDocumentFields,
editor,
nodeKey,
],
diff --git a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx
index 6f0065e27..918ba19ba 100644
--- a/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx
+++ b/packages/richtext-lexical/src/features/blocks/client/componentInline/index.tsx
@@ -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) => {
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) => {
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) => {
collectionSlug,
globalSlug,
getDocPreferences,
+ parentDocumentFields,
])
/**
@@ -210,8 +218,10 @@ export const InlineBlockComponent: React.FC = (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) => {
return state
},
- [getFormState, id, collectionSlug, getDocPreferences, globalSlug, schemaFieldsPath],
+ [
+ getFormState,
+ id,
+ collectionSlug,
+ getDocPreferences,
+ parentDocumentFields,
+ globalSlug,
+ schemaFieldsPath,
+ ],
)
// cleanup effect
useEffect(() => {
diff --git a/packages/richtext-lexical/src/features/blocks/server/validate.ts b/packages/richtext-lexical/src/features/blocks/server/validate.ts
index c58f15c1e..b64b3fc9b 100644
--- a/packages/richtext-lexical/src/features/blocks/server/validate.ts
+++ b/packages/richtext-lexical/src/features/blocks/server/validate.ts
@@ -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,
diff --git a/packages/richtext-lexical/src/features/link/server/validate.ts b/packages/richtext-lexical/src/features/link/server/validate.ts
index 68cd925fa..d617290f1 100644
--- a/packages/richtext-lexical/src/features/link/server/validate.ts
+++ b/packages/richtext-lexical/src/features/link/server/validate.ts
@@ -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,
diff --git a/packages/richtext-lexical/src/features/upload/server/validate.ts b/packages/richtext-lexical/src/features/upload/server/validate.ts
index 8d8357ccf..98557ef58 100644
--- a/packages/richtext-lexical/src/features/upload/server/validate.ts
+++ b/packages/richtext-lexical/src/features/upload/server/validate.ts
@@ -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,
diff --git a/packages/richtext-lexical/src/field/rscEntry.tsx b/packages/richtext-lexical/src/field/rscEntry.tsx
index a5ad4faaa..acac61da7 100644
--- a/packages/richtext-lexical/src/field/rscEntry.tsx
+++ b/packages/richtext-lexical/src/field/rscEntry.tsx
@@ -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,
diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts
index 1c4940d04..54bbb8a00 100644
--- a/packages/richtext-lexical/src/index.ts
+++ b/packages/richtext-lexical/src/index.ts
@@ -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,
diff --git a/packages/richtext-lexical/src/utilities/buildInitialState.ts b/packages/richtext-lexical/src/utilities/buildInitialState.ts
index d6614d2d8..80a914bf6 100644
--- a/packages/richtext-lexical/src/utilities/buildInitialState.ts
+++ b/packages/richtext-lexical/src/utilities/buildInitialState.ts
@@ -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,
diff --git a/packages/richtext-lexical/src/utilities/fieldsDrawer/DrawerContent.tsx b/packages/richtext-lexical/src/utilities/fieldsDrawer/DrawerContent.tsx
index 44ece625e..0377ce112 100644
--- a/packages/richtext-lexical/src/utilities/fieldsDrawer/DrawerContent.tsx
+++ b/packages/richtext-lexical/src/utilities/fieldsDrawer/DrawerContent.tsx
@@ -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 {
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 {
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 = (props) => {
return password(value, {
name: 'password',
type: 'text',
+ blockData: {},
data: {},
event: 'onChange',
preferences: { fields: {} },
diff --git a/packages/ui/src/forms/Form/context.ts b/packages/ui/src/forms/Form/context.ts
index ca86677e9..b630fb388 100644
--- a/packages/ui/src/forms/Form/context.ts
+++ b/packages/ui/src/forms/Form/context.ts
@@ -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
* @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 = (
const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext)
export {
+ DocumentFormContext,
FormContext,
FormFieldsContext,
FormWatchContext,
@@ -57,6 +65,7 @@ export {
ProcessingContext,
SubmittedContext,
useAllFormFields,
+ useDocumentForm,
useForm,
useFormFields,
useFormInitializing,
diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx
index bba3d4e12..6671a74c1 100644
--- a/packages/ui/src/forms/Form/index.tsx
+++ b/packages/ui/src/forms/Form/index.tsx
@@ -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 = (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 = (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 = (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 = (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 = (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 => {
@@ -719,6 +725,16 @@ export const Form: React.FC = (props) => {
250,
)
+ const DocumentFormContextComponent: React.FC = isDocumentForm
+ ? DocumentFormContext.Provider
+ : React.Fragment
+
+ const documentFormContextProps = isDocumentForm
+ ? {
+ value: contextRef.current,
+ }
+ : {}
+
return (
)
}
export {
+ DocumentFormContext,
FormContext,
FormFieldsContext,
FormWatchContext,
@@ -760,6 +779,7 @@ export {
ProcessingContext,
SubmittedContext,
useAllFormFields,
+ useDocumentForm,
useForm,
useFormFields,
useFormModified,
diff --git a/packages/ui/src/forms/Form/types.ts b/packages/ui/src/forms/Form/types.ts
index 88627648d..cc40f0b6a 100644
--- a/packages/ui/src/forms/Form/types.ts
+++ b/packages/ui/src/forms/Form/types.ts
@@ -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)[]
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
index cb73740a8..97566381d 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
+++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts
@@ -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,
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx
index 4eabe26cc..0ce6efaa9 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx
+++ b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx
@@ -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,
diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
index c26b4909f..c649fdfca 100644
--- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
+++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts
@@ -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,
diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx
index 9b8223285..17ae9fd55 100644
--- a/packages/ui/src/forms/useField/index.tsx
+++ b/packages/ui/src/forms/useField/index.tsx
@@ -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 = (options: Options): FieldType => {
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 = (options: Options): FieldType => {
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,
diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts
index 0b573b03d..fdb7dd646 100644
--- a/packages/ui/src/utilities/buildFormState.ts
+++ b/packages/ui/src/utilities/buildFormState.ts
@@ -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: {} },
diff --git a/packages/ui/src/views/Edit/Auth/APIKey.tsx b/packages/ui/src/views/Edit/Auth/APIKey.tsx
index f6dee201b..d35ba5c80 100644
--- a/packages/ui/src/views/Edit/Auth/APIKey.tsx
+++ b/packages/ui/src/views/Edit/Auth/APIKey.tsx
@@ -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,
diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx
index 7065b98da..4bcfc730c 100644
--- a/packages/ui/src/views/Edit/index.tsx
+++ b/packages/ui/src/views/Edit/index.tsx
@@ -445,6 +445,7 @@ export const DefaultEditView: React.FC = ({
disabled={isReadOnlyForIncomingUser || isInitializing || !hasSavePermission}
disableValidationOnSubmit={!validateBeforeSubmit}
initialState={!isInitializing && initialState}
+ isDocumentForm={true}
isInitializing={isInitializing}
method={id ? 'PATCH' : 'POST'}
onChange={[onChange]}
diff --git a/test/fields/collections/Lexical/blockComponents/BlockComponentRSC.tsx b/test/fields/collections/Lexical/blockComponents/BlockComponentRSC.tsx
index 5fe32cdc0..d139a031e 100644
--- a/test/fields/collections/Lexical/blockComponents/BlockComponentRSC.tsx
+++ b/test/fields/collections/Lexical/blockComponents/BlockComponentRSC.tsx
@@ -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 Data: {data?.key ?? ''}
+ return Data: {siblingData?.key ?? ''}
}
diff --git a/test/fields/collections/Lexical/blocks.ts b/test/fields/collections/Lexical/blocks.ts
index bc8bbf386..706aa6bef 100644
--- a/test/fields/collections/Lexical/blocks.ts
+++ b/test/fields/collections/Lexical/blocks.ts
@@ -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: [
diff --git a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts
index 0ad368f26..74fc231bd 100644
--- a/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts
+++ b/test/fields/collections/Lexical/e2e/blocks/e2e.spec.ts
@@ -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()
diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts
index bff47de35..e8fe5ef50 100644
--- a/test/fields/collections/Lexical/index.ts
+++ b/test/fields/collections/Lexical/index.ts
@@ -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,
diff --git a/test/playwright.config.ts b/test/playwright.config.ts
index ff2cdd619..0abed39d5 100644
--- a/test/playwright.config.ts
+++ b/test/playwright.config.ts
@@ -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