Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/fields/blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,34 @@ As you build your own Block configs, you might want to store them in separate fi
```ts
import type { Block } from 'payload'
```

## Conditional Blocks

Blocks can be conditionally enabled using the `filterOptions` property on the blocks field. It allows you to provide a function that returns which block slugs should be available based on the given context.

### Behavior

- `filterOptions` is re-evaluated as part of the form state request, whenever the document data changes.
- If a block is present in the field but no longer allowed by `filterOptions`, a validation error will occur when saving.

### Example

```ts
{
name: 'blocksWithDynamicFilterOptions',
type: 'blocks',
filterOptions: ({ siblingData }) => {
return siblingData?.enabledBlocks?.length
? [siblingData.enabledBlocks] // allow only the matching block
: true // allow all blocks if no value is set
},
blocks: [
{ slug: 'block1', fields: [{ type: 'text', name: 'block1Text' }] },
{ slug: 'block2', fields: [{ type: 'text', name: 'block2Text' }] },
{ slug: 'block3', fields: [{ type: 'text', name: 'block3Text' }] },
// ...
],
}
```

In this example, the list of available blocks is determined by the enabledBlocks sibling field. If no value is set, all blocks remain available.
6 changes: 6 additions & 0 deletions packages/payload/src/admin/forms/Form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export type FieldState = {
* See `mergeServerFormState` for more details.
*/
addedByServer?: boolean
/**
* If the field is a `blocks` field, this will contain the slugs of blocks that are allowed, based on the result of `field.filterOptions`.
* If this is undefined, all blocks are allowed.
* If this is an empty array, no blocks are allowed.
*/
blocksFilterOptions?: string[]
customComponents?: {
/**
* This is used by UI fields, as they can have arbitrary components defined if used
Expand Down
50 changes: 46 additions & 4 deletions packages/payload/src/fields/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,22 @@ export type FilterOptionsFunc<TData = any> = (
options: FilterOptionsProps<TData>,
) => boolean | Promise<boolean | Where> | Where

export type FilterOptions<TData = any> =
| ((options: FilterOptionsProps<TData>) => boolean | Promise<boolean | Where> | Where)
| null
| Where
export type FilterOptions<TData = any> = FilterOptionsFunc<TData> | null | Where

type BlockSlugOrString = (({} & string) | BlockSlug)[]

export type BlocksFilterOptionsProps<TData = any> = {
/**
* The `id` of the current document being edited. Will be undefined during the `create` operation.
*/
id: number | string
} & Pick<FilterOptionsProps<TData>, 'data' | 'req' | 'siblingData' | 'user'>

export type BlocksFilterOptions<TData = any> =
| ((
options: BlocksFilterOptionsProps<TData>,
) => BlockSlugOrString | Promise<BlockSlugOrString | true> | true)
| BlockSlugOrString

type Admin = {
className?: string
Expand Down Expand Up @@ -1515,6 +1527,36 @@ export type BlocksField = {
blockReferences?: (Block | BlockSlug)[]
blocks: Block[]
defaultValue?: DefaultValue
/**
* Blocks can be conditionally enabled using the `filterOptions` property on the blocks field.
* It allows you to provide a function that returns which block slugs should be available based on the given context.
*
* @behavior
*
* - `filterOptions` is re-evaluated as part of the form state request, whenever the document data changes.
* - If a block is present in the field but no longer allowed by `filterOptions`, a validation error will occur when saving.
*
* @example
*
* ```ts
* {
* name: 'blocksWithDynamicFilterOptions',
* type: 'blocks',
* filterOptions: ({ siblingData }) => {
* return siblingData?.enabledBlocks?.length
* ? [siblingData.enabledBlocks] // allow only the matching block
* : true // allow all blocks if no value is set
* },
* blocks: [
* { slug: 'block1', fields: [{ type: 'text', name: 'block1Text' }] },
* { slug: 'block2', fields: [{ type: 'text', name: 'block2Text' }] },
* { slug: 'block3', fields: [{ type: 'text', name: 'block3Text' }] },
* ],
* }
* ```
* In this example, the list of available blocks is determined by the enabledBlocks sibling field. If no value is set, all blocks remain available.
*/
filterOptions?: BlocksFilterOptions
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reason for keeping name filterOptions despite different type is in PR description

labels?: Labels
maxRows?: number
minRows?: number
Expand Down
91 changes: 73 additions & 18 deletions packages/payload/src/fields/hooks/beforeChange/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
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 { Block, Field, TabAsField, Validate } from '../../config/types.js'

import { MissingEditorProp } from '../../../errors/index.js'
import { type RequestContext, validateBlocksFilterOptions } from '../../../index.js'
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
import { getTranslatedLabel } from '../../../utilities/getTranslatedLabel.js'
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js'
Expand Down Expand Up @@ -200,16 +200,68 @@ export const promise = async ({
})

if (typeof validationResult === 'string') {
const fieldLabel = buildFieldLabel(
fieldLabelPath,
getTranslatedLabel(field?.label || field?.name, req.i18n),
)

errors.push({
label: fieldLabel,
message: validationResult,
path,
})
let filterOptionsError = false

if (field.type === 'blocks' && field.filterOptions) {
// Re-run filteroptions. If the validation error is due to filteroptions, we need to add error paths to all the blocks
// that are no longer valid
const validationResult = validateBlocksFilterOptions({
id,
data,
filterOptions: field.filterOptions,
req,
siblingData,
value: siblingData[field.name],
})
if (validationResult?.invalidBlockSlugs?.length) {
filterOptionsError = true
let rowIndex = -1
for (const block of siblingData[field.name] as JsonObject[]) {
rowIndex++
if (validationResult.invalidBlockSlugs.includes(block.blockType as string)) {
const blockConfigOrSlug = (field.blockReferences ?? field.blocks).find(
(blockFromField) =>
typeof blockFromField === 'string'
? blockFromField === block.blockType
: blockFromField.slug === block.blockType,
) as Block | undefined
const blockConfig =
typeof blockConfigOrSlug !== 'string'
? blockConfigOrSlug
: req.payload.config?.blocks?.[blockConfigOrSlug]

const blockLabelPath =
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} > ${req.t('fields:block')} ${rowIndex + 1} (${getTranslatedLabel(blockConfig?.labels?.singular || block.blockType, req.i18n)})`,
)

errors.push({
label: blockLabelPath,
message: req.t('validation:invalidBlock', { block: block.blockType }),
path: `${path}.${rowIndex}.id`,
})
}
}
}
}

if (!filterOptionsError) {
// If the error is due to block filterOptions, we want to push the errors for each individual block, not the blocks
// field itself => only push the error if the field is not a block field with validation failure due to filterOptions
const fieldLabel = buildFieldLabel(
fieldLabelPath,
getTranslatedLabel(field?.label || field?.name, req.i18n),
)

errors.push({
label: fieldLabel,
message: validationResult,
path,
})
}
}
}

Expand Down Expand Up @@ -311,6 +363,14 @@ export const promise = async ({
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined)

const blockLabelPath =
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} > ${req.t('fields:block')} ${rowIndex + 1} (${getTranslatedLabel(block?.labels?.singular || blockTypeToMatch, req.i18n)})`,
)

if (block) {
promises.push(
traverseFields({
Expand All @@ -322,13 +382,8 @@ export const promise = async ({
doc,
docWithLocales,
errors,
fieldLabelPath:
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} ${rowIndex + 1}`,
),
fieldLabelPath: blockLabelPath,

fields: block.fields,
global,
mergeLocaleActions,
Expand Down
Loading
Loading