Skip to content

Commit d8dace6

Browse files
authored
feat: conditional blocks (#13801)
This PR introduces support for conditionally setting allowable block types via a new `field.filterOptions` property on the blocks field. Closes the following feature requests: #5348, #4668 (partly) ## Example ```ts fields: [ { name: 'enabledBlocks', type: 'text', admin: { description: "Change the value of this field to change the enabled blocks of the blocksWithDynamicFilterOptions field. If it's empty, all blocks are enabled.", }, }, { name: 'blocksWithFilterOptions', type: 'blocks', filterOptions: ['block1', 'block2'], blocks: [ { slug: 'block1', fields: [ { type: 'text', name: 'block1Text', }, ], }, { slug: 'block2', fields: [ { type: 'text', name: 'block2Text', }, ], }, { slug: 'block3', fields: [ { type: 'text', name: 'block3Text', }, ], }, ], }, { name: 'blocksWithDynamicFilterOptions', type: 'blocks', filterOptions: ({ siblingData: _siblingData, data }) => { const siblingData = _siblingData as { enabledBlocks: string } if (siblingData?.enabledBlocks !== data?.enabledBlocks) { // Just an extra assurance that the field is working as intended throw new Error('enabledBlocks and siblingData.enabledBlocks must be identical') } return siblingData?.enabledBlocks?.length ? [siblingData.enabledBlocks] : true }, blocks: [ { slug: 'block1', fields: [ { type: 'text', name: 'block1Text', }, ], }, { slug: 'block2', fields: [ { type: 'text', name: 'block2Text', }, ], }, { slug: 'block3', fields: [ { type: 'text', name: 'block3Text', }, ], }, ], }, ] ``` https://github.com/user-attachments/assets/e38a804f-22fa-4fd2-a6af-ba9b0a5a04d2 # Rationale ## Why not `block.condition`? - Individual blocks are often reused in multiple contexts, where the logic for when they should be available may differ. It’s more appropriate for the blocks field (typically tied to a single collection) to determine availability. - Hiding existing blocks when they no longer satisfy a condition would cause issues - for example, reordering blocks would break or cause block data to disappear. Instead, this implementation ensures consistency by throwing a validation error if a block is no longer allowed. This aligns with the behavior of `filterOptions` in relationship fields, rather than `condition`. ## Why not call it `blocksFilterOptions`? Although the type differs from relationship fields, this property is named `filterOptions` (and not `blocksFilterOptions`) for consistency across field types. For example, the Select field also uses `filterOptions` despite its type being unique. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211334752795631
1 parent e99e054 commit d8dace6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+879
-73
lines changed

docs/fields/blocks.mdx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,3 +389,34 @@ As you build your own Block configs, you might want to store them in separate fi
389389
```ts
390390
import type { Block } from 'payload'
391391
```
392+
393+
## Conditional Blocks
394+
395+
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.
396+
397+
### Behavior
398+
399+
- `filterOptions` is re-evaluated as part of the form state request, whenever the document data changes.
400+
- If a block is present in the field but no longer allowed by `filterOptions`, a validation error will occur when saving.
401+
402+
### Example
403+
404+
```ts
405+
{
406+
name: 'blocksWithDynamicFilterOptions',
407+
type: 'blocks',
408+
filterOptions: ({ siblingData }) => {
409+
return siblingData?.enabledBlocks?.length
410+
? [siblingData.enabledBlocks] // allow only the matching block
411+
: true // allow all blocks if no value is set
412+
},
413+
blocks: [
414+
{ slug: 'block1', fields: [{ type: 'text', name: 'block1Text' }] },
415+
{ slug: 'block2', fields: [{ type: 'text', name: 'block2Text' }] },
416+
{ slug: 'block3', fields: [{ type: 'text', name: 'block3Text' }] },
417+
// ...
418+
],
419+
}
420+
```
421+
422+
In this example, the list of available blocks is determined by the enabledBlocks sibling field. If no value is set, all blocks remain available.

packages/payload/src/admin/forms/Form.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export type FieldState = {
3434
* See `mergeServerFormState` for more details.
3535
*/
3636
addedByServer?: boolean
37+
/**
38+
* If the field is a `blocks` field, this will contain the slugs of blocks that are allowed, based on the result of `field.filterOptions`.
39+
* If this is undefined, all blocks are allowed.
40+
* If this is an empty array, no blocks are allowed.
41+
*/
42+
blocksFilterOptions?: string[]
3743
customComponents?: {
3844
/**
3945
* This is used by UI fields, as they can have arbitrary components defined if used

packages/payload/src/fields/config/types.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,22 @@ export type FilterOptionsFunc<TData = any> = (
323323
options: FilterOptionsProps<TData>,
324324
) => boolean | Promise<boolean | Where> | Where
325325

326-
export type FilterOptions<TData = any> =
327-
| ((options: FilterOptionsProps<TData>) => boolean | Promise<boolean | Where> | Where)
328-
| null
329-
| Where
326+
export type FilterOptions<TData = any> = FilterOptionsFunc<TData> | null | Where
327+
328+
type BlockSlugOrString = (({} & string) | BlockSlug)[]
329+
330+
export type BlocksFilterOptionsProps<TData = any> = {
331+
/**
332+
* The `id` of the current document being edited. Will be undefined during the `create` operation.
333+
*/
334+
id: number | string
335+
} & Pick<FilterOptionsProps<TData>, 'data' | 'req' | 'siblingData' | 'user'>
336+
337+
export type BlocksFilterOptions<TData = any> =
338+
| ((
339+
options: BlocksFilterOptionsProps<TData>,
340+
) => BlockSlugOrString | Promise<BlockSlugOrString | true> | true)
341+
| BlockSlugOrString
330342

331343
type Admin = {
332344
className?: string
@@ -1515,6 +1527,36 @@ export type BlocksField = {
15151527
blockReferences?: (Block | BlockSlug)[]
15161528
blocks: Block[]
15171529
defaultValue?: DefaultValue
1530+
/**
1531+
* Blocks can be conditionally enabled using the `filterOptions` property on the blocks field.
1532+
* It allows you to provide a function that returns which block slugs should be available based on the given context.
1533+
*
1534+
* @behavior
1535+
*
1536+
* - `filterOptions` is re-evaluated as part of the form state request, whenever the document data changes.
1537+
* - If a block is present in the field but no longer allowed by `filterOptions`, a validation error will occur when saving.
1538+
*
1539+
* @example
1540+
*
1541+
* ```ts
1542+
* {
1543+
* name: 'blocksWithDynamicFilterOptions',
1544+
* type: 'blocks',
1545+
* filterOptions: ({ siblingData }) => {
1546+
* return siblingData?.enabledBlocks?.length
1547+
* ? [siblingData.enabledBlocks] // allow only the matching block
1548+
* : true // allow all blocks if no value is set
1549+
* },
1550+
* blocks: [
1551+
* { slug: 'block1', fields: [{ type: 'text', name: 'block1Text' }] },
1552+
* { slug: 'block2', fields: [{ type: 'text', name: 'block2Text' }] },
1553+
* { slug: 'block3', fields: [{ type: 'text', name: 'block3Text' }] },
1554+
* ],
1555+
* }
1556+
* ```
1557+
* In this example, the list of available blocks is determined by the enabledBlocks sibling field. If no value is set, all blocks remain available.
1558+
*/
1559+
filterOptions?: BlocksFilterOptions
15181560
labels?: Labels
15191561
maxRows?: number
15201562
minRows?: number

packages/payload/src/fields/hooks/beforeChange/promise.ts

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import type { RichTextAdapter } from '../../../admin/RichText.js'
22
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
33
import type { ValidationFieldError } from '../../../errors/index.js'
44
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
5-
import type { RequestContext } from '../../../index.js'
65
import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js'
76
import type { Block, Field, TabAsField, Validate } from '../../config/types.js'
87

98
import { MissingEditorProp } from '../../../errors/index.js'
9+
import { type RequestContext, validateBlocksFilterOptions } from '../../../index.js'
1010
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
1111
import { getTranslatedLabel } from '../../../utilities/getTranslatedLabel.js'
1212
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js'
@@ -200,16 +200,68 @@ export const promise = async ({
200200
})
201201

202202
if (typeof validationResult === 'string') {
203-
const fieldLabel = buildFieldLabel(
204-
fieldLabelPath,
205-
getTranslatedLabel(field?.label || field?.name, req.i18n),
206-
)
207-
208-
errors.push({
209-
label: fieldLabel,
210-
message: validationResult,
211-
path,
212-
})
203+
let filterOptionsError = false
204+
205+
if (field.type === 'blocks' && field.filterOptions) {
206+
// Re-run filteroptions. If the validation error is due to filteroptions, we need to add error paths to all the blocks
207+
// that are no longer valid
208+
const validationResult = validateBlocksFilterOptions({
209+
id,
210+
data,
211+
filterOptions: field.filterOptions,
212+
req,
213+
siblingData,
214+
value: siblingData[field.name],
215+
})
216+
if (validationResult?.invalidBlockSlugs?.length) {
217+
filterOptionsError = true
218+
let rowIndex = -1
219+
for (const block of siblingData[field.name] as JsonObject[]) {
220+
rowIndex++
221+
if (validationResult.invalidBlockSlugs.includes(block.blockType as string)) {
222+
const blockConfigOrSlug = (field.blockReferences ?? field.blocks).find(
223+
(blockFromField) =>
224+
typeof blockFromField === 'string'
225+
? blockFromField === block.blockType
226+
: blockFromField.slug === block.blockType,
227+
) as Block | undefined
228+
const blockConfig =
229+
typeof blockConfigOrSlug !== 'string'
230+
? blockConfigOrSlug
231+
: req.payload.config?.blocks?.[blockConfigOrSlug]
232+
233+
const blockLabelPath =
234+
field?.label === false
235+
? fieldLabelPath
236+
: buildFieldLabel(
237+
fieldLabelPath,
238+
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} > ${req.t('fields:block')} ${rowIndex + 1} (${getTranslatedLabel(blockConfig?.labels?.singular || block.blockType, req.i18n)})`,
239+
)
240+
241+
errors.push({
242+
label: blockLabelPath,
243+
message: req.t('validation:invalidBlock', { block: block.blockType }),
244+
path: `${path}.${rowIndex}.id`,
245+
})
246+
}
247+
}
248+
}
249+
}
250+
251+
if (!filterOptionsError) {
252+
// If the error is due to block filterOptions, we want to push the errors for each individual block, not the blocks
253+
// field itself => only push the error if the field is not a block field with validation failure due to filterOptions
254+
const fieldLabel = buildFieldLabel(
255+
fieldLabelPath,
256+
getTranslatedLabel(field?.label || field?.name, req.i18n),
257+
)
258+
259+
errors.push({
260+
label: fieldLabel,
261+
message: validationResult,
262+
path,
263+
})
264+
}
213265
}
214266
}
215267

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

366+
const blockLabelPath =
367+
field?.label === false
368+
? fieldLabelPath
369+
: buildFieldLabel(
370+
fieldLabelPath,
371+
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} > ${req.t('fields:block')} ${rowIndex + 1} (${getTranslatedLabel(block?.labels?.singular || blockTypeToMatch, req.i18n)})`,
372+
)
373+
314374
if (block) {
315375
promises.push(
316376
traverseFields({
@@ -322,13 +382,8 @@ export const promise = async ({
322382
doc,
323383
docWithLocales,
324384
errors,
325-
fieldLabelPath:
326-
field?.label === false
327-
? fieldLabelPath
328-
: buildFieldLabel(
329-
fieldLabelPath,
330-
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} ${rowIndex + 1}`,
331-
),
385+
fieldLabelPath: blockLabelPath,
386+
332387
fields: block.fields,
333388
global,
334389
mergeLocaleActions,

0 commit comments

Comments
 (0)