fix(richtext-lexical): ensure sub-fields have access to full document data in form state (#9869)
Fixes https://github.com/payloadcms/payload/issues/10940 This PR does the following: - adds a `useDocumentForm` hook to access the document Form. Useful if you are within a sub-Form - ensure the `data` property passed to field conditions, read access control, validation and filterOptions is always the top-level document data. Previously, for fields within lexical blocks/links/upload, this incorrectly was the lexical block-level data. - adds a `blockData` property to hooks, field conditions, read/update/create field access control, validation and filterOptions for all fields. This allows you to access the data of the nearest parent block, which is especially useful for lexical sub-fields. Users that were previously depending on the incorrect behavior of the `data` property in order to access the data of the lexical block can now switch to the new `blockData` property
This commit is contained in:
@@ -654,6 +654,26 @@ const ExampleCollection = {
|
||||
]}
|
||||
/>
|
||||
|
||||
## useDocumentForm
|
||||
|
||||
The `useDocumentForm` hook works the same way as the [useForm](#useform) hook, but it always gives you access to the top-level `Form` of a document. This is useful if you need to access the document's `Form` context from within a child `Form`.
|
||||
|
||||
An example where this could happen would be custom components within lexical blocks, as lexical blocks initialize their own child `Form`.
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useDocumentForm } from '@payloadcms/ui'
|
||||
|
||||
const MyComponent: React.FC = () => {
|
||||
const { fields: parentDocumentFields } = useDocumentForm()
|
||||
|
||||
return (
|
||||
<p>The document's Form has ${Object.keys(parentDocumentFields).length} fields</p>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## useCollapsible
|
||||
|
||||
The `useCollapsible` hook allows you to control parent collapsibles:
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -34,6 +34,7 @@ export const generatePasswordSaltHash = async ({
|
||||
const validationResult = password(passwordToSet, {
|
||||
name: 'password',
|
||||
type: 'text',
|
||||
blockData: {},
|
||||
data: {},
|
||||
event: 'submit',
|
||||
preferences: { fields: {} },
|
||||
|
||||
@@ -133,7 +133,13 @@ import type {
|
||||
TextareaFieldValidation,
|
||||
} from '../../index.js'
|
||||
import type { DocumentPreferences } from '../../preferences/types.js'
|
||||
import type { DefaultValue, Operation, PayloadRequest, Where } from '../../types/index.js'
|
||||
import type {
|
||||
DefaultValue,
|
||||
JsonObject,
|
||||
Operation,
|
||||
PayloadRequest,
|
||||
Where,
|
||||
} from '../../types/index.js'
|
||||
import type {
|
||||
NumberFieldManyValidation,
|
||||
NumberFieldSingleValidation,
|
||||
@@ -148,6 +154,10 @@ import type {
|
||||
} from '../validations.js'
|
||||
|
||||
export type FieldHookArgs<TData extends TypeWithID = any, TValue = any, TSiblingData = any> = {
|
||||
/**
|
||||
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
|
||||
*/
|
||||
blockData: JsonObject | undefined
|
||||
/** The collection which the field belongs to. If the field belongs to a global, this will be null. */
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
@@ -212,7 +222,11 @@ export type FieldHook<TData extends TypeWithID = any, TValue = any, TSiblingData
|
||||
|
||||
export type FieldAccess<TData extends TypeWithID = any, TSiblingData = any> = (args: {
|
||||
/**
|
||||
* The incoming data used to `create` or `update` the document with. `data` is undefined during the `read` operation.
|
||||
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
|
||||
*/
|
||||
blockData?: JsonObject | undefined
|
||||
/**
|
||||
* The incoming, top-level document data used to `create` or `update` the document with.
|
||||
*/
|
||||
data?: Partial<TData>
|
||||
/**
|
||||
@@ -231,13 +245,33 @@ export type FieldAccess<TData extends TypeWithID = any, TSiblingData = any> = (a
|
||||
siblingData?: Partial<TSiblingData>
|
||||
}) => boolean | Promise<boolean>
|
||||
|
||||
//TODO: In 4.0, we should replace the three parameters of the condition function with a single, named parameter object
|
||||
export type Condition<TData extends TypeWithID = any, TSiblingData = any> = (
|
||||
/**
|
||||
* The top-level document data
|
||||
*/
|
||||
data: Partial<TData>,
|
||||
/**
|
||||
* Immediately adjacent data to this field. For example, if this is a `group` field, then `siblingData` will be the other fields within the group.
|
||||
*/
|
||||
siblingData: Partial<TSiblingData>,
|
||||
{ user }: { user: PayloadRequest['user'] },
|
||||
{
|
||||
blockData,
|
||||
user,
|
||||
}: {
|
||||
/**
|
||||
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
|
||||
*/
|
||||
blockData: Partial<TData>
|
||||
user: PayloadRequest['user']
|
||||
},
|
||||
) => boolean
|
||||
|
||||
export type FilterOptionsProps<TData = any> = {
|
||||
/**
|
||||
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
|
||||
*/
|
||||
blockData: TData
|
||||
/**
|
||||
* An object containing the full collection or global document currently being edited.
|
||||
*/
|
||||
@@ -348,6 +382,11 @@ export type LabelsClient = {
|
||||
}
|
||||
|
||||
export type BaseValidateOptions<TData, TSiblingData, TValue> = {
|
||||
/**
|
||||
/**
|
||||
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
|
||||
*/
|
||||
blockData: Partial<TData>
|
||||
collectionSlug?: string
|
||||
data: Partial<TData>
|
||||
event?: 'onChange' | 'submit'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ValidationFieldError } from '../../../errors/index.js'
|
||||
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
|
||||
import type { RequestContext } from '../../../index.js'
|
||||
import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js'
|
||||
import type { Field, TabAsField } from '../../config/types.js'
|
||||
import type { Field, TabAsField, Validate } from '../../config/types.js'
|
||||
|
||||
import { MissingEditorProp } from '../../../errors/index.js'
|
||||
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
|
||||
@@ -16,6 +16,10 @@ import { getExistingRowDoc } from './getExistingRowDoc.js'
|
||||
import { traverseFields } from './traverseFields.js'
|
||||
|
||||
type Args = {
|
||||
/**
|
||||
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
|
||||
*/
|
||||
blockData?: JsonObject
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
data: JsonObject
|
||||
@@ -48,6 +52,7 @@ type Args = {
|
||||
|
||||
export const promise = async ({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -77,7 +82,7 @@ export const promise = async ({
|
||||
})
|
||||
|
||||
const passesCondition = field.admin?.condition
|
||||
? Boolean(field.admin.condition(data, siblingData, { user: req.user }))
|
||||
? Boolean(field.admin.condition(data, siblingData, { blockData, user: req.user }))
|
||||
: true
|
||||
let skipValidationFromHere = skipValidation || !passesCondition
|
||||
const { localization } = req.payload.config
|
||||
@@ -102,6 +107,7 @@ export const promise = async ({
|
||||
await priorHook
|
||||
|
||||
const hookedValue = await currentHook({
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -139,22 +145,27 @@ export const promise = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const validationResult = await field.validate(
|
||||
valueToValidate as never,
|
||||
{
|
||||
...field,
|
||||
id,
|
||||
collectionSlug: collection?.slug,
|
||||
data: deepMergeWithSourceArrays(doc, data),
|
||||
event: 'submit',
|
||||
jsonError,
|
||||
operation,
|
||||
preferences: { fields: {} },
|
||||
previousValue: siblingDoc[field.name],
|
||||
req,
|
||||
siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData),
|
||||
} as any,
|
||||
)
|
||||
const validateFn: Validate<object, object, object, object> = field.validate as Validate<
|
||||
object,
|
||||
object,
|
||||
object,
|
||||
object
|
||||
>
|
||||
const validationResult = await validateFn(valueToValidate as never, {
|
||||
...field,
|
||||
id,
|
||||
blockData,
|
||||
collectionSlug: collection?.slug,
|
||||
data: deepMergeWithSourceArrays(doc, data),
|
||||
event: 'submit',
|
||||
// @ts-expect-error
|
||||
jsonError,
|
||||
operation,
|
||||
preferences: { fields: {} },
|
||||
previousValue: siblingDoc[field.name],
|
||||
req,
|
||||
siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData),
|
||||
})
|
||||
|
||||
if (typeof validationResult === 'string') {
|
||||
const label = getTranslatedLabel(field?.label || field?.name, req.i18n)
|
||||
@@ -217,6 +228,7 @@ export const promise = async ({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -268,6 +280,7 @@ export const promise = async ({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData: row,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -301,6 +314,7 @@ export const promise = async ({
|
||||
case 'row': {
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -339,6 +353,7 @@ export const promise = async ({
|
||||
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -455,6 +470,7 @@ export const promise = async ({
|
||||
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -481,6 +497,7 @@ export const promise = async ({
|
||||
case 'tabs': {
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,10 @@ import { runBeforeDuplicateHooks } from './runHook.js'
|
||||
import { traverseFields } from './traverseFields.js'
|
||||
|
||||
type Args<T> = {
|
||||
/**
|
||||
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
|
||||
*/
|
||||
blockData?: JsonObject
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
doc: T
|
||||
@@ -25,6 +29,7 @@ type Args<T> = {
|
||||
|
||||
export const promise = async <T>({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -63,6 +68,7 @@ export const promise = async <T>({
|
||||
const localizedValues = await localizedValuesPromise
|
||||
|
||||
const beforeDuplicateArgs: FieldHookArgs = {
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data: doc,
|
||||
@@ -96,6 +102,7 @@ export const promise = async <T>({
|
||||
siblingDoc[field.name] = localeData
|
||||
} else {
|
||||
const beforeDuplicateArgs: FieldHookArgs = {
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data: doc,
|
||||
@@ -143,6 +150,7 @@ export const promise = async <T>({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -177,6 +185,7 @@ export const promise = async <T>({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData: row,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -199,6 +208,7 @@ export const promise = async <T>({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -234,6 +244,7 @@ export const promise = async <T>({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -270,6 +281,7 @@ export const promise = async <T>({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData: row,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -300,6 +312,7 @@ export const promise = async <T>({
|
||||
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -324,6 +337,7 @@ export const promise = async <T>({
|
||||
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -347,6 +361,7 @@ export const promise = async <T>({
|
||||
case 'row': {
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -367,6 +382,7 @@ export const promise = async <T>({
|
||||
case 'tab': {
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -386,6 +402,7 @@ export const promise = async <T>({
|
||||
case 'tabs': {
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { Field, TabAsField } from '../../config/types.js'
|
||||
import { promise } from './promise.js'
|
||||
|
||||
type Args<T> = {
|
||||
/**
|
||||
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
|
||||
*/
|
||||
blockData?: JsonObject
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
doc: T
|
||||
@@ -21,6 +25,7 @@ type Args<T> = {
|
||||
|
||||
export const traverseFields = async <T>({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
@@ -38,6 +43,7 @@ export const traverseFields = async <T>({
|
||||
promises.push(
|
||||
promise({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
doc,
|
||||
|
||||
@@ -14,6 +14,10 @@ import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js'
|
||||
import { traverseFields } from './traverseFields.js'
|
||||
|
||||
type Args<T> = {
|
||||
/**
|
||||
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
|
||||
*/
|
||||
blockData?: JsonObject
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
data: T
|
||||
@@ -47,6 +51,7 @@ type Args<T> = {
|
||||
|
||||
export const promise = async <T>({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -270,6 +275,7 @@ export const promise = async <T>({
|
||||
await priorHook
|
||||
|
||||
const hookedValue = await currentHook({
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -298,7 +304,7 @@ export const promise = async <T>({
|
||||
if (field.access && field.access[operation]) {
|
||||
const result = overrideAccess
|
||||
? true
|
||||
: await field.access[operation]({ id, data, doc, req, siblingData })
|
||||
: await field.access[operation]({ id, blockData, data, doc, req, siblingData })
|
||||
|
||||
if (!result) {
|
||||
delete siblingData[field.name]
|
||||
@@ -335,6 +341,7 @@ export const promise = async <T>({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -375,6 +382,7 @@ export const promise = async <T>({
|
||||
promises.push(
|
||||
traverseFields({
|
||||
id,
|
||||
blockData: row,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -404,6 +412,7 @@ export const promise = async <T>({
|
||||
case 'row': {
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -437,6 +446,7 @@ export const promise = async <T>({
|
||||
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -522,6 +532,7 @@ export const promise = async <T>({
|
||||
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -544,6 +555,7 @@ export const promise = async <T>({
|
||||
case 'tabs': {
|
||||
await traverseFields({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
|
||||
@@ -7,6 +7,10 @@ import type { Field, TabAsField } from '../../config/types.js'
|
||||
import { promise } from './promise.js'
|
||||
|
||||
type Args<T> = {
|
||||
/**
|
||||
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
|
||||
*/
|
||||
blockData?: JsonObject
|
||||
collection: null | SanitizedCollectionConfig
|
||||
context: RequestContext
|
||||
data: T
|
||||
@@ -32,6 +36,7 @@ type Args<T> = {
|
||||
|
||||
export const traverseFields = async <T>({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
@@ -53,6 +58,7 @@ export const traverseFields = async <T>({
|
||||
promises.push(
|
||||
promise({
|
||||
id,
|
||||
blockData,
|
||||
collection,
|
||||
context,
|
||||
data,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Pill,
|
||||
RenderFields,
|
||||
SectionTitle,
|
||||
useDocumentForm,
|
||||
useDocumentInfo,
|
||||
useEditDepth,
|
||||
useFormSubmitted,
|
||||
@@ -23,6 +24,7 @@ import { deepCopyObjectSimpleWithoutReactComponents, reduceFieldsToValues } from
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
const baseClass = 'lexical-block'
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { $getNodeByKey } from 'lexical'
|
||||
@@ -33,9 +35,9 @@ import type { BlockFields } from '../../server/nodes/BlocksNode.js'
|
||||
|
||||
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
|
||||
import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
|
||||
import './index.scss'
|
||||
import { $isBlockNode } from '../nodes/BlocksNode.js'
|
||||
import { BlockContent } from './BlockContent.js'
|
||||
import './index.scss'
|
||||
import { removeEmptyArrayValues } from './removeEmptyArrayValues.js'
|
||||
|
||||
type Props = {
|
||||
@@ -64,6 +66,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
},
|
||||
uuid: uuidFromContext,
|
||||
} = useEditorConfigContext()
|
||||
|
||||
const { fields: parentDocumentFields } = useDocumentForm()
|
||||
const onChangeAbortControllerRef = useRef(new AbortController())
|
||||
const editDepth = useEditDepth()
|
||||
const [errorCount, setErrorCount] = React.useState(0)
|
||||
@@ -127,7 +131,9 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
data: formData,
|
||||
docPermissions: { fields: true },
|
||||
docPreferences: await getDocPreferences(),
|
||||
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
|
||||
globalSlug,
|
||||
initialBlockData: formData,
|
||||
operation: 'update',
|
||||
renderAllFields: true,
|
||||
schemaPath: schemaFieldsPath,
|
||||
@@ -164,6 +170,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
getDocPreferences,
|
||||
parentDocumentFields,
|
||||
])
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = React.useState<boolean>(
|
||||
@@ -196,8 +203,10 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
fields: true,
|
||||
},
|
||||
docPreferences: await getDocPreferences(),
|
||||
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
|
||||
formState: prevFormState,
|
||||
globalSlug,
|
||||
initialBlockFormState: prevFormState,
|
||||
operation: 'update',
|
||||
renderAllFields: submit ? true : false,
|
||||
schemaPath: schemaFieldsPath,
|
||||
@@ -254,6 +263,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
globalSlug,
|
||||
schemaFieldsPath,
|
||||
formData.blockType,
|
||||
parentDocumentFields,
|
||||
editor,
|
||||
nodeKey,
|
||||
],
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FormSubmit,
|
||||
RenderFields,
|
||||
ShimmerEffect,
|
||||
useDocumentForm,
|
||||
useDocumentInfo,
|
||||
useEditDepth,
|
||||
useServerFunctions,
|
||||
@@ -26,6 +27,7 @@ import { $getNodeByKey } from 'lexical'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import type { InlineBlockFields } from '../../server/nodes/InlineBlocksNode.js'
|
||||
@@ -77,6 +79,8 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
setCreatedInlineBlock,
|
||||
uuid: uuidFromContext,
|
||||
} = useEditorConfigContext()
|
||||
const { fields: parentDocumentFields } = useDocumentForm()
|
||||
|
||||
const { getFormState } = useServerFunctions()
|
||||
const editDepth = useEditDepth()
|
||||
const firstTimeDrawer = useRef(false)
|
||||
@@ -161,7 +165,10 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
data: formData,
|
||||
docPermissions: { fields: true },
|
||||
docPreferences: await getDocPreferences(),
|
||||
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
|
||||
globalSlug,
|
||||
initialBlockData: formData,
|
||||
initialBlockFormState: formData,
|
||||
operation: 'update',
|
||||
renderAllFields: true,
|
||||
schemaPath: schemaFieldsPath,
|
||||
@@ -191,6 +198,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
getDocPreferences,
|
||||
parentDocumentFields,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -210,8 +218,10 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
fields: true,
|
||||
},
|
||||
docPreferences: await getDocPreferences(),
|
||||
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
|
||||
formState: prevFormState,
|
||||
globalSlug,
|
||||
initialBlockFormState: prevFormState,
|
||||
operation: 'update',
|
||||
renderAllFields: submit ? true : false,
|
||||
schemaPath: schemaFieldsPath,
|
||||
@@ -229,7 +239,15 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
|
||||
|
||||
return state
|
||||
},
|
||||
[getFormState, id, collectionSlug, getDocPreferences, globalSlug, schemaFieldsPath],
|
||||
[
|
||||
getFormState,
|
||||
id,
|
||||
collectionSlug,
|
||||
getDocPreferences,
|
||||
parentDocumentFields,
|
||||
globalSlug,
|
||||
schemaFieldsPath,
|
||||
],
|
||||
)
|
||||
// cleanup effect
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Form,
|
||||
FormSubmit,
|
||||
RenderFields,
|
||||
useDocumentForm,
|
||||
useDocumentInfo,
|
||||
useServerFunctions,
|
||||
useTranslation,
|
||||
} from '@payloadcms/ui'
|
||||
import { abortAndIgnore } from '@payloadcms/ui/shared'
|
||||
import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
@@ -28,6 +30,7 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { id, collectionSlug, getDocPreferences, globalSlug } = useDocumentInfo()
|
||||
const { fields: parentDocumentFields } = useDocumentForm()
|
||||
|
||||
const onChangeAbortControllerRef = useRef(new AbortController())
|
||||
|
||||
@@ -57,7 +60,9 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
|
||||
fields: true,
|
||||
},
|
||||
docPreferences: await getDocPreferences(),
|
||||
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
|
||||
globalSlug,
|
||||
initialBlockData: data,
|
||||
operation: 'update',
|
||||
renderAllFields: true,
|
||||
schemaPath: schemaFieldsPath,
|
||||
@@ -72,7 +77,16 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
|
||||
return () => {
|
||||
abortAndIgnore(controller)
|
||||
}
|
||||
}, [schemaFieldsPath, id, data, getFormState, collectionSlug, globalSlug, getDocPreferences])
|
||||
}, [
|
||||
schemaFieldsPath,
|
||||
id,
|
||||
data,
|
||||
getFormState,
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
getDocPreferences,
|
||||
parentDocumentFields,
|
||||
])
|
||||
|
||||
const onChange = useCallback(
|
||||
async ({ formState: prevFormState }: { formState: FormState }) => {
|
||||
@@ -88,8 +102,10 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
|
||||
fields: true,
|
||||
},
|
||||
docPreferences: await getDocPreferences(),
|
||||
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
|
||||
formState: prevFormState,
|
||||
globalSlug,
|
||||
initialBlockFormState: prevFormState,
|
||||
operation: 'update',
|
||||
schemaPath: schemaFieldsPath,
|
||||
signal: controller.signal,
|
||||
@@ -101,7 +117,15 @@ export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'dra
|
||||
|
||||
return state
|
||||
},
|
||||
[getFormState, id, collectionSlug, getDocPreferences, globalSlug, schemaFieldsPath],
|
||||
[
|
||||
getFormState,
|
||||
id,
|
||||
collectionSlug,
|
||||
getDocPreferences,
|
||||
parentDocumentFields,
|
||||
globalSlug,
|
||||
schemaFieldsPath,
|
||||
],
|
||||
)
|
||||
|
||||
// cleanup effect
|
||||
|
||||
@@ -176,6 +176,7 @@ export { fieldBaseClass } from '../../fields/shared/index.js'
|
||||
|
||||
export {
|
||||
useAllFormFields,
|
||||
useDocumentForm,
|
||||
useForm,
|
||||
useFormFields,
|
||||
useFormInitializing,
|
||||
|
||||
@@ -49,6 +49,7 @@ const PasswordFieldComponent: React.FC<PasswordFieldProps> = (props) => {
|
||||
return password(value, {
|
||||
name: 'password',
|
||||
type: 'text',
|
||||
blockData: {},
|
||||
data: {},
|
||||
event: 'onChange',
|
||||
preferences: { fields: {} },
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { Context, FormFieldsContext as FormFieldsContextType } from './types.js'
|
||||
|
||||
const FormContext = createContext({} as Context)
|
||||
const DocumentFormContext = createContext({} as Context)
|
||||
const FormWatchContext = createContext({} as Context)
|
||||
const SubmittedContext = createContext(false)
|
||||
const ProcessingContext = createContext(false)
|
||||
@@ -26,6 +27,12 @@ export type RenderedFieldSlots = Map<string, RenderedField>
|
||||
* @see https://payloadcms.com/docs/admin/hooks#useform
|
||||
*/
|
||||
const useForm = (): Context => useContext(FormContext)
|
||||
/**
|
||||
* Get the state of the document-level form. This is useful if you need to access the document-level Form from within a child Form.
|
||||
* This is the case withing lexical Blocks, as each lexical blocks renders their own Form.
|
||||
*/
|
||||
const useDocumentForm = (): Context => useContext(DocumentFormContext)
|
||||
|
||||
const useWatchForm = (): Context => useContext(FormWatchContext)
|
||||
const useFormSubmitted = (): boolean => useContext(SubmittedContext)
|
||||
const useFormProcessing = (): boolean => useContext(ProcessingContext)
|
||||
@@ -49,6 +56,7 @@ const useFormFields = <Value = unknown>(
|
||||
const useAllFormFields = (): FormFieldsContextType => useFullContext(FormFieldsContext)
|
||||
|
||||
export {
|
||||
DocumentFormContext,
|
||||
FormContext,
|
||||
FormFieldsContext,
|
||||
FormWatchContext,
|
||||
@@ -57,6 +65,7 @@ export {
|
||||
ProcessingContext,
|
||||
SubmittedContext,
|
||||
useAllFormFields,
|
||||
useDocumentForm,
|
||||
useForm,
|
||||
useFormFields,
|
||||
useFormInitializing,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
reduceFieldsToValues,
|
||||
wait,
|
||||
} from 'payload/shared'
|
||||
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
@@ -34,6 +34,7 @@ import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js'
|
||||
import { requests } from '../../utilities/api.js'
|
||||
import {
|
||||
DocumentFormContext,
|
||||
FormContext,
|
||||
FormFieldsContext,
|
||||
FormWatchContext,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
ModifiedContext,
|
||||
ProcessingContext,
|
||||
SubmittedContext,
|
||||
useDocumentForm,
|
||||
} from './context.js'
|
||||
import { errorMessages } from './errorMessages.js'
|
||||
import { fieldReducer } from './fieldReducer.js'
|
||||
@@ -63,6 +65,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
// fields: fieldsFromProps = collection?.fields || global?.fields,
|
||||
handleResponse,
|
||||
initialState, // fully formed initial field state
|
||||
isDocumentForm,
|
||||
isInitializing: initializingFromProps,
|
||||
onChange,
|
||||
onSubmit,
|
||||
@@ -77,6 +80,8 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const documentForm = useDocumentForm()
|
||||
|
||||
const { code: locale } = useLocale()
|
||||
const { i18n, t } = useTranslation()
|
||||
const { refreshCookie, user } = useAuth()
|
||||
@@ -110,8 +115,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
const validatedFieldState = {}
|
||||
let isValid = true
|
||||
|
||||
const dataFromContext = contextRef.current.getData()
|
||||
const data = dataFromContext
|
||||
const data = contextRef.current.getData()
|
||||
|
||||
const validationPromises = Object.entries(contextRef.current.fields).map(
|
||||
async ([path, field]) => {
|
||||
@@ -131,7 +135,9 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
...field,
|
||||
id,
|
||||
collectionSlug,
|
||||
data,
|
||||
// If there is a parent document form, we can get the data from that form
|
||||
blockData: undefined, // Will be expensive to get - not worth to pass to client-side validation, as this can be obtained by the user using `useFormFields()`
|
||||
data: documentForm?.getData ? documentForm.getData() : data,
|
||||
event: 'submit',
|
||||
operation,
|
||||
preferences: {} as any,
|
||||
@@ -170,7 +176,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
}
|
||||
|
||||
return isValid
|
||||
}, [collectionSlug, config, dispatchFields, id, operation, t, user])
|
||||
}, [collectionSlug, config, dispatchFields, id, operation, t, user, documentForm])
|
||||
|
||||
const submit = useCallback(
|
||||
async (options: SubmitOptions = {}, e): Promise<void> => {
|
||||
@@ -719,6 +725,16 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
250,
|
||||
)
|
||||
|
||||
const DocumentFormContextComponent: React.FC<any> = isDocumentForm
|
||||
? DocumentFormContext.Provider
|
||||
: React.Fragment
|
||||
|
||||
const documentFormContextProps = isDocumentForm
|
||||
? {
|
||||
value: contextRef.current,
|
||||
}
|
||||
: {}
|
||||
|
||||
return (
|
||||
<form
|
||||
action={typeof action === 'function' ? void action : action}
|
||||
@@ -728,31 +744,34 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
onSubmit={(e) => void contextRef.current.submit({}, e)}
|
||||
ref={formRef}
|
||||
>
|
||||
<FormContext.Provider value={contextRef.current}>
|
||||
<FormWatchContext.Provider
|
||||
value={{
|
||||
fields,
|
||||
...contextRef.current,
|
||||
}}
|
||||
>
|
||||
<SubmittedContext.Provider value={submitted}>
|
||||
<InitializingContext.Provider value={!isMounted || (isMounted && initializing)}>
|
||||
<ProcessingContext.Provider value={processing}>
|
||||
<ModifiedContext.Provider value={modified}>
|
||||
<FormFieldsContext.Provider value={fieldsReducer}>
|
||||
{children}
|
||||
</FormFieldsContext.Provider>
|
||||
</ModifiedContext.Provider>
|
||||
</ProcessingContext.Provider>
|
||||
</InitializingContext.Provider>
|
||||
</SubmittedContext.Provider>
|
||||
</FormWatchContext.Provider>
|
||||
</FormContext.Provider>
|
||||
<DocumentFormContextComponent {...documentFormContextProps}>
|
||||
<FormContext.Provider value={contextRef.current}>
|
||||
<FormWatchContext.Provider
|
||||
value={{
|
||||
fields,
|
||||
...contextRef.current,
|
||||
}}
|
||||
>
|
||||
<SubmittedContext.Provider value={submitted}>
|
||||
<InitializingContext.Provider value={!isMounted || (isMounted && initializing)}>
|
||||
<ProcessingContext.Provider value={processing}>
|
||||
<ModifiedContext.Provider value={modified}>
|
||||
<FormFieldsContext.Provider value={fieldsReducer}>
|
||||
{children}
|
||||
</FormFieldsContext.Provider>
|
||||
</ModifiedContext.Provider>
|
||||
</ProcessingContext.Provider>
|
||||
</InitializingContext.Provider>
|
||||
</SubmittedContext.Provider>
|
||||
</FormWatchContext.Provider>
|
||||
</FormContext.Provider>
|
||||
</DocumentFormContextComponent>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DocumentFormContext,
|
||||
FormContext,
|
||||
FormFieldsContext,
|
||||
FormWatchContext,
|
||||
@@ -760,6 +779,7 @@ export {
|
||||
ProcessingContext,
|
||||
SubmittedContext,
|
||||
useAllFormFields,
|
||||
useDocumentForm,
|
||||
useForm,
|
||||
useFormFields,
|
||||
useFormModified,
|
||||
|
||||
@@ -37,6 +37,13 @@ export type FormProps = {
|
||||
errorToast: (value: string) => void,
|
||||
) => void
|
||||
initialState?: FormState
|
||||
/**
|
||||
* Determines if this Form is the main, top-level Form of a document. If set to true, the
|
||||
* Form's children will be wrapped in a DocumentFormContext, which lets you access this document
|
||||
* Form's data and fields from any child component - even if that child component is wrapped in a child
|
||||
* Form (e.g. a lexical block).
|
||||
*/
|
||||
isDocumentForm?: boolean
|
||||
isInitializing?: boolean
|
||||
log?: boolean
|
||||
onChange?: ((args: { formState: FormState; submitted?: boolean }) => Promise<FormState>)[]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
|
||||
import { useOperation } from '../../providers/Operation/index.js'
|
||||
import { useTranslation } from '../../providers/Translation/index.js'
|
||||
import {
|
||||
useDocumentForm,
|
||||
useForm,
|
||||
useFormFields,
|
||||
useFormInitializing,
|
||||
@@ -45,6 +46,7 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
|
||||
const { config } = useConfig()
|
||||
|
||||
const { getData, getDataByPath, getSiblingData, setModified } = useForm()
|
||||
const documentForm = useDocumentForm()
|
||||
const modified = useFormModified()
|
||||
|
||||
const filterOptions = field?.filterOptions
|
||||
@@ -142,12 +144,14 @@ export const useField = <TValue,>(options: Options): FieldType<TValue> => {
|
||||
let errorMessage: string | undefined = prevErrorMessage.current
|
||||
let valid: boolean | string = prevValid.current
|
||||
|
||||
const data = getData()
|
||||
const isValid =
|
||||
typeof validate === 'function'
|
||||
? await validate(valueToValidate, {
|
||||
id,
|
||||
blockData: undefined, // Will be expensive to get - not worth to pass to client-side validation, as this can be obtained by the user using `useFormFields()`
|
||||
collectionSlug,
|
||||
data: getData(),
|
||||
data: documentForm?.getData ? documentForm.getData() : data,
|
||||
event: 'onChange',
|
||||
operation,
|
||||
preferences: {} as any,
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -445,6 +445,7 @@ export const DefaultEditView: React.FC<ClientSideEditViewProps> = ({
|
||||
disabled={isReadOnlyForIncomingUser || isInitializing || !hasSavePermission}
|
||||
disableValidationOnSubmit={!validateBeforeSubmit}
|
||||
initialState={!isInitializing && initialState}
|
||||
isDocumentForm={true}
|
||||
isInitializing={isInitializing}
|
||||
method={id ? 'PATCH' : 'POST'}
|
||||
onChange={[onChange]}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BlockCollapsible } from '@payloadcms/richtext-lexical/client'
|
||||
import React from 'react'
|
||||
|
||||
export const BlockComponentRSC: BlocksFieldServerComponent = (props) => {
|
||||
const { data } = props
|
||||
const { siblingData } = props
|
||||
|
||||
return <BlockCollapsible>Data: {data?.key ?? ''}</BlockCollapsible>
|
||||
return <BlockCollapsible>Data: {siblingData?.key ?? ''}</BlockCollapsible>
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user