Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
21 changes: 17 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,7 @@ export type BlocksField = {
blockReferences?: (Block | BlockSlug)[]
blocks: Block[]
defaultValue?: DefaultValue
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
94 changes: 92 additions & 2 deletions packages/payload/src/fields/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,101 @@ 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 arrayLengthValidationResult = validateArrayLength(value, { maxRows, minRows, required, t })
if (!arrayLengthValidationResult) {
return arrayLengthValidationResult
}

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
6 changes: 6 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,12 @@ 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.
*/
newState[path].blocksFilterOptions = incomingField.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.

Without this, if blocksFilterOptions goes from a value to undefined, it would be ignored


// 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
24 changes: 15 additions & 9 deletions packages/ui/src/forms/useField/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FieldState, FilterOptionsResult, Option, Row, Validate } from 'payload'
import type { FieldState, Validate } from 'payload'

export type Options = {
disableFormData?: boolean
Expand Down Expand Up @@ -28,21 +28,27 @@ export type Options = {
}

export type FieldType<T> = {
customComponents?: FieldState['customComponents']
disabled: boolean
errorMessage?: string
errorPaths?: string[]
filterOptions?: FilterOptionsResult
formInitializing: boolean
formProcessing: boolean
formSubmitted: boolean
initialValue?: T
path: string
/**
* @deprecated - readOnly is no longer returned from useField. Remove this in 4.0.
*/
readOnly?: boolean
Copy link
Member Author

Choose a reason for hiding this comment

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

readOnly currently is always undefined. I think it's only there to prevent a breaking change (narrower type, project won't build)

rows?: Row[]
selectFilterOptions?: Option[]
setValue: (val: unknown, disableModifyingForm?: boolean) => void
showError: boolean
valid?: boolean
value: T
}
} & Pick<
Copy link
Member Author

Choose a reason for hiding this comment

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

Using Pick is preferred over re-defining types. That way, we also bring over JSDocs

FieldState,
| 'blocksFilterOptions'
| 'customComponents'
| 'errorMessage'
| 'errorPaths'
| 'filterOptions'
| 'rows'
| 'selectFilterOptions'
| 'valid'
>
2 changes: 1 addition & 1 deletion test/fields/baseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

import ArrayFields from './collections/Array/index.js'
import BlockFields from './collections/Blocks/index.js'
import { BlockFields } from './collections/Blocks/index.js'
import CheckboxFields from './collections/Checkbox/index.js'
import CodeFields from './collections/Code/index.js'
import CollapsibleFields from './collections/Collapsible/index.js'
Expand Down
Loading
Loading