Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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 @@ -1520,6 +1532,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
93 changes: 91 additions & 2 deletions packages/payload/src/fields/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,100 @@ export const array: ArrayFieldValidation = (value, { maxRows, minRows, req: { t

export type BlocksFieldValidation = Validate<unknown, unknown, unknown, BlocksField>

/**
* This function validates the blocks in a blocks field against the provided filterOptions.
* It will return a list of all block slugs found in the value, the allowed block slugs (if any),
* and a list of invalid block slugs that are used despite being disallowed.
*
* @internal - this may break or be removed at any time
*/
export function validateBlocksFilterOptions({
id,
data,
filterOptions,
req,
siblingData,
value,
}: { value: Parameters<BlocksFieldValidation>[0] } & Pick<
Parameters<BlocksFieldValidation>[1],
'data' | 'filterOptions' | 'id' | 'req' | 'siblingData'
>): {
/**
* All block slugs found in the value of the blocks field
*/
allBlockSlugs: string[]
/**
* All block slugs that are allowed. If undefined, all blocks are allowed.
*/
allowedBlockSlugs: string[] | undefined
/**
* A list of block slugs that are used despite being disallowed. If undefined, field passed validation.
*/
invalidBlockSlugs: string | string[] | undefined
} {
const allBlockSlugs = Array.isArray(value)
? (value as Array<{ blockType?: string }>)
.map((b) => b.blockType)
.filter((s): s is string => Boolean(s))
: []

// if undefined => all blocks allowed
let allowedBlockSlugs: string[] | undefined = undefined

if (typeof filterOptions === 'function') {
const result = filterOptions({
id: id!, // original code asserted presence
data,
req,
siblingData,
user: req.user,
})
if (result !== true && Array.isArray(result)) {
allowedBlockSlugs = result
}
} else if (Array.isArray(filterOptions)) {
allowedBlockSlugs = filterOptions
}

const invalidBlockSlugs: string[] = []
if (allowedBlockSlugs) {
for (const blockSlug of allBlockSlugs) {
if (!allowedBlockSlugs.includes(blockSlug)) {
invalidBlockSlugs.push(blockSlug)
}
}
}

return {
allBlockSlugs,
allowedBlockSlugs,
invalidBlockSlugs,
}
}
export const blocks: BlocksFieldValidation = (
value,
{ maxRows, minRows, req: { t }, required },
{ id, data, filterOptions, maxRows, minRows, req: { t }, req, required, siblingData },
) => {
return validateArrayLength(value, { maxRows, minRows, required, t })
const lengthValidationResult = validateArrayLength(value, { maxRows, minRows, required, t })
if (typeof lengthValidationResult === 'string') {
return lengthValidationResult
}

if (filterOptions) {
const { invalidBlockSlugs } = validateBlocksFilterOptions({
id,
data,
filterOptions,
req,
siblingData,
value,
})
if (invalidBlockSlugs?.length) {
return t('validation:invalidSelections')
}
}

return true
}

const validateFilterOptions: Validate<
Expand Down
2 changes: 1 addition & 1 deletion packages/payload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1528,7 +1528,7 @@ export { traverseFields as beforeChangeTraverseFields } from './fields/hooks/bef
export { traverseFields as beforeValidateTraverseFields } from './fields/hooks/beforeValidate/traverseFields.js'

export { sortableFieldTypes } from './fields/sortableFieldTypes.js'
export { validations } from './fields/validations.js'
export { validateBlocksFilterOptions, validations } from './fields/validations.js'

export type {
ArrayFieldValidation,
Expand Down
62 changes: 38 additions & 24 deletions packages/ui/src/fields/Blocks/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,24 +104,6 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
return true
})()

const clientBlocks = useMemo(() => {
if (!blockReferences) {
return blocks
}

const resolvedBlocks: ClientBlock[] = []

for (const blockReference of blockReferences) {
const block =
typeof blockReference === 'string' ? config.blocksMap[blockReference] : blockReference
if (block) {
resolvedBlocks.push(block)
}
}

return resolvedBlocks
}, [blockReferences, blocks, config.blocksMap])

const memoizedValidate = useCallback(
(value, options) => {
// alternative locales can be null
Expand All @@ -136,6 +118,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
)

const {
blocksFilterOptions,
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
errorPaths,
Expand All @@ -150,6 +133,38 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
validate: memoizedValidate,
})

const { clientBlocks, clientBlocksAfterFilter } = useMemo(() => {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the same as the previous clientBlocks = useMemo(), but it also filters blocks now according to blocksFilterOptions returned from the form state.

We need both the filtered and unfiltered client blocks. The filtered ones are only used for the drawer. We still need the ability to display existing blocks that no longer match the filter and fail validation.

let resolvedBlocks: ClientBlock[] = []

if (!blockReferences) {
resolvedBlocks = blocks
} else {
for (const blockReference of blockReferences) {
const block =
typeof blockReference === 'string' ? config.blocksMap[blockReference] : blockReference
if (block) {
resolvedBlocks.push(block)
}
}
}

if (Array.isArray(blocksFilterOptions)) {
const clientBlocksAfterFilter = resolvedBlocks.filter((block) => {
const blockSlug = typeof block === 'string' ? block : block.slug
return blocksFilterOptions.includes(blockSlug)
})

return {
clientBlocks: resolvedBlocks,
clientBlocksAfterFilter,
}
}
return {
clientBlocks: resolvedBlocks,
clientBlocksAfterFilter: resolvedBlocks,
}
}, [blockReferences, blocks, blocksFilterOptions, config.blocksMap])

const addRow = useCallback(
(rowIndex: number, blockType: string) => {
addFieldRow({
Expand Down Expand Up @@ -404,10 +419,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
const { blockType, isLoading } = row

const blockConfig: ClientBlock =
config.blocksMap[blockType] ??
((blockReferences ?? blocks).find(
(block) => typeof block !== 'string' && block.slug === blockType,
) as ClientBlock)
config.blocksMap[blockType] ?? clientBlocks.find((block) => block.slug === blockType)

if (blockConfig) {
const rowPath = `${path}.${i}`
Expand All @@ -427,7 +439,8 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
{...draggableSortableItemProps}
addRow={addRow}
block={blockConfig}
blocks={blockReferences ?? blocks}
// Pass all blocks, not just clientBlocksAfterFilter, as existing blocks should still be displayed even if they don't match the new filter
blocks={clientBlocks}
copyRow={copyRow}
duplicateRow={duplicateRow}
errorCount={rowErrorCount}
Expand Down Expand Up @@ -499,7 +512,8 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
<BlocksDrawer
addRow={addRow}
addRowIndex={rows?.length || 0}
blocks={blockReferences ?? blocks}
// Only allow choosing filtered blocks
blocks={clientBlocksAfterFilter}
drawerSlug={drawerSlug}
labels={labels}
/>
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/forms/Form/mergeServerFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ export const mergeServerFormState = ({
newState[path].passesCondition = true
}

/**
* Undefined values for blocksFilterOptions coming back should be treated as "all blocks allowed" and
* should always be merged in.
* Without this, an undefined value coming back will incorrectly be ignored, and the previous filter will remain.
*/
if (!incomingField.blocksFilterOptions) {
delete newState[path].blocksFilterOptions
}

// Strip away the `addedByServer` property from the client
// This will prevent it from being passed back to the server
delete newState[path].addedByServer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
} from 'payload'

import ObjectIdImport from 'bson-objectid'
import { getBlockSelect, stripUnselectedFields } from 'payload'
import { getBlockSelect, stripUnselectedFields, validateBlocksFilterOptions } from 'payload'
import {
deepCopyObjectSimple,
fieldAffectsData,
Expand Down Expand Up @@ -580,6 +580,20 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom

fieldState.rows = rowMetadata

// Handle blocks filterOptions
if (field.filterOptions) {
const validationResult = validateBlocksFilterOptions({
id,
data: fullData,
filterOptions: field.filterOptions,
req,
siblingData: data,
value: data[field.name],
})

fieldState.blocksFilterOptions = validationResult.allowedBlockSlugs
}

// Add field to state
if (!omitParents && (!filter || filter(args))) {
state[path] = fieldState
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/forms/useField/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
// to prevent unnecessary rerenders
const result: FieldType<TValue> = useMemo(
() => ({
blocksFilterOptions: field?.blocksFilterOptions,
customComponents: field?.customComponents,
disabled: processing || initializing,
errorMessage: field?.errorMessage,
Expand Down
Loading
Loading