From ae32c555acf6df2e189776f2dfa2b38550e4364c Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Thu, 6 Feb 2025 11:49:17 -0700 Subject: [PATCH] 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 --- docs/admin/hooks.mdx | 20 ++ .../ForgotPasswordForm/index.tsx | 2 + packages/payload/src/admin/forms/Form.ts | 7 + .../local/generatePasswordSaltHash.ts | 1 + packages/payload/src/fields/config/types.ts | 45 ++- .../src/fields/hooks/afterChange/promise.ts | 12 + .../hooks/afterChange/traverseFields.ts | 6 + .../src/fields/hooks/afterRead/promise.ts | 16 + .../fields/hooks/afterRead/traverseFields.ts | 6 + .../src/fields/hooks/beforeChange/promise.ts | 53 ++- .../hooks/beforeChange/traverseFields.ts | 6 + .../fields/hooks/beforeDuplicate/promise.ts | 17 + .../hooks/beforeDuplicate/traverseFields.ts | 6 + .../fields/hooks/beforeValidate/promise.ts | 14 +- .../hooks/beforeValidate/traverseFields.ts | 6 + packages/payload/src/fields/validations.ts | 3 +- .../blocks/client/component/index.tsx | 12 +- .../blocks/client/componentInline/index.tsx | 20 +- .../src/features/blocks/server/validate.ts | 4 +- .../src/features/link/server/validate.ts | 4 +- .../src/features/upload/server/validate.ts | 4 + .../richtext-lexical/src/field/rscEntry.tsx | 1 + packages/richtext-lexical/src/index.ts | 8 +- .../src/utilities/buildInitialState.ts | 3 + .../utilities/fieldsDrawer/DrawerContent.tsx | 28 +- packages/ui/src/exports/client/index.ts | 1 + packages/ui/src/fields/Password/index.tsx | 1 + packages/ui/src/forms/Form/context.ts | 9 + packages/ui/src/forms/Form/index.tsx | 70 ++-- packages/ui/src/forms/Form/types.ts | 7 + .../addFieldStatePromise.ts | 20 +- .../forms/fieldSchemasToFormState/index.tsx | 35 +- .../fieldSchemasToFormState/iterateFields.ts | 10 +- packages/ui/src/forms/useField/index.tsx | 6 +- packages/ui/src/utilities/buildFormState.ts | 15 + packages/ui/src/views/Edit/Auth/APIKey.tsx | 1 + packages/ui/src/views/Edit/index.tsx | 1 + .../blockComponents/BlockComponentRSC.tsx | 4 +- test/fields/collections/Lexical/blocks.ts | 118 ++++++- .../Lexical/e2e/blocks/e2e.spec.ts | 327 +++++++++++++++++- test/fields/collections/Lexical/index.ts | 4 + test/playwright.config.ts | 6 +- 42 files changed, 869 insertions(+), 70 deletions(-) diff --git a/docs/admin/hooks.mdx b/docs/admin/hooks.mdx index 4c2c151bc2..1974a45ddd 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 50625e283e..3672ddc6e2 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 263e97214e..7ed86c1053 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 8106b0d429..a0b65c0ff5 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 c41b4c56f5..561f8580e2 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 7c1dbe6e0f..53fd12f052 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 273729e656..09ce619d70 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 5a36fdcf91..f572880b7a 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 7f5028a19c..7d9b5c64e8 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 1e827684fd..e38bc86647 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 c75b3e865a..a341bd98cd 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 cec3cac67d..da20308f35 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 b94047870f..91a1ac5bc2 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 fede4caed9..ecd732d595 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 8f1a29f5e0..a982401c83 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 94250728f9..270e1d9fa8 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 75f5061ccf..15c2dd5450 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 6f0065e272..918ba19ba0 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 c58f15c1e6..b64b3fc9b7 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 68cd925fa7..d617290f11 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 8d8357ccf9..98557ef58e 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 a5ad4faaa0..acac61da77 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 1c4940d044..54bbb8a006 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 d6614d2d8a..80a914bf61 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 44ece625e2..0377ce1120 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 ca86677e98..b630fb3887 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 bba3d4e121..6671a74c16 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 (
= (props) => { onSubmit={(e) => void contextRef.current.submit({}, e)} ref={formRef} > - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + +
) } 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 88627648dc..cc40f0b6ab 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 cb73740a83..97566381dc 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 4eabe26cc8..0ce6efaa9b 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 c26b4909f6..c649fdfca8 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 9b8223285f..17ae9fd55c 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 0b573b03d1..fdb7dd646a 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 f6dee201b4..d35ba5c806 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 7065b98daa..4bcfc730cd 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 5fe32cdc04..d139a031e2 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 bc8bbf3869..706aa6bef6 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 0ad368f260..74fc231bd1 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 bff47de359..e8fe5ef500 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 ff2cdd619a..0abed39d55 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