feat: select field filter options (#12487)

It is a common pattern to dynamically show and validate a select field's
options based on various criteria such as the current user or underlying
document.

Some examples of this might include:
- Restricting options based on a user's role, e.g. admin-only options
- Displaying different options based on the value of another field, e.g.
a city/state selector
 
While this is already possible to do with a custom `validate` function,
the user can still view and select the forbidden option...unless you
_also_ wired up a custom component.

Now, you can define `filterOptions` on select fields.

This behaves similarly to the existing `filterOptions` property on
relationship and upload fields, except the return value of this function
is simply an array of options, not a query constraint. The result of
this function will determine what is shown to the user and what is
validated on the server.

Here's an example:

```ts
{
  name: 'select',
  type: 'select',
  options: [
    {
      label: 'One',
      value: 'one',
    },
    {
      label: 'Two',
      value: 'two',
    },
    {
      label: 'Three',
      value: 'three',
    },
  ],
  filterOptions: ({ options, data }) =>
    data.disallowOption1
      ? options.filter(
          (option) => (typeof option === 'string' ? options : option.value) !== 'one',
        )
      : options,
}
```
This commit is contained in:
Jacob Fletcher
2025-05-22 15:54:12 -04:00
committed by GitHub
parent 45f4c5c22c
commit f75d62c79b
30 changed files with 228 additions and 25 deletions

View File

@@ -80,7 +80,7 @@ export const MyArrayField: Field = {
}
```
The Array Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Array Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ------------------------- | ----------------------------------------------------------------------------------- |

View File

@@ -78,7 +78,7 @@ export const MyBlocksField: Field = {
}
```
The Blocks Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Blocks Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ---------------------- | -------------------------------------------------------------------------- |

View File

@@ -68,7 +68,7 @@ export const MyCodeField: Field = {
}
```
The Code Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Code Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -58,7 +58,7 @@ export const MyCollapsibleField: Field = {
}
```
The Collapsible Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Collapsible Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ------------------- | ------------------------------- |

View File

@@ -65,7 +65,7 @@ export const MyDateField: Field = {
}
```
The Date Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Date Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Property | Description |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -65,7 +65,7 @@ export const MyEmailField: Field = {
}
```
The Email Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Email Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Property | Description |
| ------------------ | ------------------------------------------------------------------------- |

View File

@@ -69,7 +69,7 @@ export const MyGroupField: Field = {
}
```
The Group Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Group Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |

View File

@@ -67,7 +67,7 @@ export const MyJSONField: Field = {
}
```
The JSON Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The JSON Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -70,7 +70,7 @@ export const MyNumberField: Field = {
}
```
The Number Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Number Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Property | Description |
| ------------------ | --------------------------------------------------------------------------------- |

View File

@@ -82,7 +82,7 @@ export const MyRadioField: Field = {
}
```
The Radio Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Radio Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Property | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -86,7 +86,7 @@ export const MyRelationshipField: Field = {
}
```
The Relationship Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Relationship Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Property | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -54,6 +54,7 @@ export const MySelectField: Field = {
| **`enumName`** | Custom enum name for this field when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from name if not defined. |
| **`dbName`** | Custom table name (if `hasMany` set to `true`) for this field when using SQL Database Adapter ([Postgres](/docs/database/postgres)). Auto-generated from name if not defined. |
| **`interfaceName`** | Create a top level, reusable [Typescript interface](/docs/typescript/generating-types#custom-field-interfaces) & [GraphQL type](/docs/graphql/graphql-schema#custom-field-schemas). |
| **`filterOptions`** | Dynamically filter which options are available based on the user, data, etc. [More details](#filterOptions) |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
| **`virtual`** | Provide `true` to disable field in the database, or provide a string path to [link the field with a relationship](/docs/fields/relationship#linking-virtual-fields-with-relationships). See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) |
@@ -67,6 +68,61 @@ _\* An asterisk denotes that a property is required._
used as a GraphQL enum.
</Banner>
### filterOptions
Used to dynamically filter which options are available based on the user, data, etc.
Some examples of this might include:
- Restricting options based on a user's role, e.g. admin-only options
- Displaying different options based on the value of another field, e.g. a city/state selector
The result of `filterOptions` will determine:
- Which options are displayed in the Admin Panel
- Which options can be saved to the database
To do this, use the `filterOptions` property in your [Field Config](./overview):
```ts
import type { Field } from 'payload'
export const MySelectField: Field = {
// ...
// highlight-start
type: 'select',
options: [
{
label: 'One',
value: 'one',
},
{
label: 'Two',
value: 'two',
},
{
label: 'Three',
value: 'three',
},
],
filterOptions: ({ options, data }) =>
data.disallowOption1
? options.filter(
(option) =>
(typeof option === 'string' ? options : option.value) !== 'one',
)
: options,
// highlight-end
}
```
<Banner type="warning">
**Note:** This property is similar to `filterOptions` in
[Relationship](./relationship) or [Upload](./upload) fields, except that the
return value of this function is simply an array of options, not a query
constraint.
</Banner>
## Admin Options
To customize the appearance and behavior of the Select Field in the [Admin Panel](../admin/overview), you can use the `admin` option:
@@ -83,7 +139,7 @@ export const MySelectField: Field = {
}
```
The Select Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Select Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Property | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -70,7 +70,7 @@ export const MyTextField: Field = {
}
```
The Text Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Text Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -67,7 +67,7 @@ export const MyTextareaField: Field = {
}
```
The Textarea Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
The Textarea Field inherits all of the default admin options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
| Option | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -20,7 +20,11 @@ export const resolveAllFilterOptions = async ({
return
}
if ('name' in field && 'filterOptions' in field && field.filterOptions) {
if (
(field.type === 'relationship' || field.type === 'upload') &&
'filterOptions' in field &&
field.filterOptions
) {
const options = await resolveFilterOptions(field.filterOptions, {
id: undefined,
blockData: undefined,

View File

@@ -1,7 +1,8 @@
import type { MarkOptional } from 'ts-essentials'
import type { SelectField, SelectFieldClient } from '../../fields/config/types.js'
import type { Option, SelectField, SelectFieldClient } from '../../fields/config/types.js'
import type { SelectFieldValidation } from '../../fields/validations.js'
import type { PayloadRequest } from '../../types/index.js'
import type { FieldErrorClientComponent, FieldErrorServerComponent } from '../forms/Error.js'
import type {
ClientFieldBase,

View File

@@ -1,7 +1,7 @@
import { type SupportedLanguages } from '@payloadcms/translations'
import type { SanitizedDocumentPermissions } from '../../auth/types.js'
import type { Field, Validate } from '../../fields/config/types.js'
import type { Field, Option, Validate } from '../../fields/config/types.js'
import type { TypedLocale } from '../../index.js'
import type { DocumentPreferences } from '../../preferences/types.js'
import type { PayloadRequest, SelectType, Where } from '../../types/index.js'
@@ -67,6 +67,10 @@ export type FieldState = {
lastRenderedPath?: string
passesCondition?: boolean
rows?: Row[]
/**
* The result of running `field.filterOptions` on select fields.
*/
selectFilterOptions?: Option[]
valid?: boolean
validate?: Validate
value?: unknown

View File

@@ -36,7 +36,7 @@ export type ServerOnlyFieldProperties =
| 'dbName' // can be a function
| 'editor' // This is a `richText` only property
| 'enumName' // can be a function
| 'filterOptions' // This is a `relationship` and `upload` only property
| 'filterOptions' // This is a `relationship`, `upload`, and `select` only property
| 'graphQL'
| 'label'
| 'typescriptSchema'
@@ -53,7 +53,7 @@ const serverOnlyFieldProperties: Partial<ServerOnlyFieldProperties>[] = [
'access',
'validate',
'defaultValue',
'filterOptions', // This is a `relationship` and `upload` only property
'filterOptions', // This is a `relationship`, `upload`, and `select` only property
'editor', // This is a `richText` only property
'custom',
'typescriptSchema',
@@ -67,10 +67,12 @@ const serverOnlyFieldProperties: Partial<ServerOnlyFieldProperties>[] = [
// `tabs`
// `admin`
]
const serverOnlyFieldAdminProperties: Partial<ServerOnlyFieldAdminProperties>[] = [
'condition',
'components',
]
type FieldWithDescription = {
admin: AdminClient
} & ClientField

View File

@@ -42,6 +42,7 @@ import type {
CollapsibleFieldLabelClientComponent,
CollapsibleFieldLabelServerComponent,
ConditionalDateProps,
Data,
DateFieldClientProps,
DateFieldErrorClientComponent,
DateFieldErrorServerComponent,
@@ -1103,8 +1104,19 @@ export type SelectField = {
* Customize the DB enum name
*/
enumName?: DBIdentifierName
/**
* Reduce the available options based on the current user, value of another field, etc.
* Similar to the `filterOptions` property on `relationship` and `upload` fields, except with a different return type.
*/
filterOptions?: (args: {
data: Data
options: Option[]
req: PayloadRequest
siblingData: Data
}) => Option[]
hasMany?: boolean
/** Customize generated GraphQL and Typescript schema names.
/**
* Customize generated GraphQL and Typescript schema names.
* By default, it is bound to the collection.
*
* This is useful if you would like to generate a top level type to share amongst collections/fields.

View File

@@ -327,7 +327,7 @@ describe('Field Validations', () => {
},
],
}
it('should allow valid input', () => {
it('should allow valid input', async () => {
const val = 'one'
const result = select(val, selectOptions)
expect(result).toStrictEqual(true)

View File

@@ -865,13 +865,23 @@ export type SelectFieldSingleValidation = Validate<string, unknown, unknown, Sel
export const select: SelectFieldValidation = (
value,
{ hasMany, options, req: { t }, required },
{ data, filterOptions, hasMany, options, req, req: { t }, required, siblingData },
) => {
const filteredOptions =
typeof filterOptions === 'function'
? filterOptions({
data,
options,
req,
siblingData,
})
: options
if (
Array.isArray(value) &&
value.some(
(input) =>
!options.some(
!filteredOptions.some(
(option) => option === input || (typeof option !== 'string' && option?.value === input),
),
)
@@ -881,7 +891,7 @@ export const select: SelectFieldValidation = (
if (
typeof value === 'string' &&
!options.some(
!filteredOptions.some(
(option) => option === value || (typeof option !== 'string' && option.value === value),
)
) {

View File

@@ -22,6 +22,7 @@ export type SelectInputProps = {
readonly Description?: React.ReactNode
readonly description?: StaticDescription
readonly Error?: React.ReactNode
readonly filterOption?: ReactSelectAdapterProps['filterOption']
readonly hasMany?: boolean
readonly isClearable?: boolean
readonly isSortable?: boolean
@@ -49,6 +50,7 @@ export const SelectInput: React.FC<SelectInputProps> = (props) => {
Description,
description,
Error,
filterOption,
hasMany = false,
isClearable = true,
isSortable = true,
@@ -118,6 +120,7 @@ export const SelectInput: React.FC<SelectInputProps> = (props) => {
{BeforeInput}
<ReactSelect
disabled={readOnly}
filterOption={filterOption}
isClearable={isClearable}
isMulti={hasMany}
isSortable={isSortable}

View File

@@ -67,6 +67,7 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
path,
selectFilterOptions,
setValue,
showError,
value,
@@ -109,6 +110,14 @@ const SelectFieldComponent: SelectFieldClientComponent = (props) => {
Description={Description}
description={description}
Error={Error}
filterOption={
selectFilterOptions
? ({ value }) =>
selectFilterOptions?.some(
(option) => (typeof option === 'string' ? option : option.value) === value,
)
: undefined
}
hasMany={hasMany}
isClearable={isClearable}
isSortable={isSortable}

View File

@@ -631,6 +631,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
break
}
case 'relationship':
case 'upload': {
if (field.filterOptions) {
@@ -719,6 +720,28 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
break
}
case 'select': {
if (typeof field.filterOptions === 'function') {
fieldState.selectFilterOptions = field.filterOptions({
data: fullData,
options: field.options,
req,
siblingData: data,
})
}
if (data[field.name] !== undefined) {
fieldState.value = data[field.name]
fieldState.initialValue = data[field.name]
}
if (!filter || filter(args)) {
state[path] = fieldState
}
break
}
default: {
if (data[field.name] !== undefined) {
fieldState.value = data[field.name]

View File

@@ -128,6 +128,7 @@ export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
initialValue,
path,
rows: field?.rows,
selectFilterOptions: field?.selectFilterOptions,
setValue,
showError,
valid: field?.valid,

View File

@@ -1,4 +1,4 @@
import type { FieldState, FilterOptionsResult, Row, Validate } from 'payload'
import type { FieldState, FilterOptionsResult, Option, Row, Validate } from 'payload'
export type Options = {
disableFormData?: boolean
@@ -40,6 +40,7 @@ export type FieldType<T> = {
path: string
readOnly?: boolean
rows?: Row[]
selectFilterOptions?: Option[]
setValue: (val: unknown, disableModifyingForm?: boolean) => void
showError: boolean
valid?: boolean

View File

@@ -95,4 +95,19 @@ describe('Select', () => {
await expect(page.locator('.cell-selectWithJsxLabelOption svg#payload-logo')).toBeVisible()
})
test('should reduce options', async () => {
await page.goto(url.create)
const field = page.locator('#field-selectWithFilteredOptions')
await field.click({ delay: 100 })
const options = page.locator('.rs__option')
await expect(options.locator('text=One')).toBeVisible()
// click the field again to close the options
await field.click({ delay: 100 })
await page.locator('#field-disallowOption1').click()
await field.click({ delay: 100 })
await expect(options.locator('text=One')).toBeHidden()
})
})

View File

@@ -243,6 +243,36 @@ const SelectFields: CollectionConfig = {
},
],
},
{
name: 'disallowOption1',
type: 'checkbox',
},
{
name: 'selectWithFilteredOptions',
label: 'Select with filtered options',
type: 'select',
defaultValue: 'one',
options: [
{
label: 'Value One',
value: 'one',
},
{
label: 'Value Two',
value: 'two',
},
{
label: 'Value Three',
value: 'three',
},
],
filterOptions: ({ options, data }) =>
data.disallowOption1
? options.filter(
(option) => (typeof option === 'string' ? options : option.value) !== 'one',
)
: options,
},
],
}

View File

@@ -848,6 +848,34 @@ describe('Fields', () => {
})
expect(data.hasMany).toStrictEqual(['a'])
})
it('should prevent against saving a value excluded by `filterOptions`', async () => {
try {
const result = await payload.create({
collection: 'select-fields',
data: {
disallowOption1: true,
selectWithFilteredOptions: 'one',
},
})
expect(result).toBeFalsy()
} catch (error) {
expect((error as Error).message).toBe(
'The following field is invalid: Select with filtered options',
)
}
const result = await payload.create({
collection: 'select-fields',
data: {
disallowOption1: true,
selectWithFilteredOptions: 'two',
},
})
expect(result).toBeTruthy()
})
})
describe('number', () => {

View File

@@ -1380,6 +1380,8 @@ export interface SelectField {
category?: ('a' | 'b')[] | null;
};
selectWithJsxLabelOption?: ('one' | 'two' | 'three') | null;
disallowOption1?: boolean | null;
selectWithFilteredOptions?: ('one' | 'two' | 'three') | null;
updatedAt: string;
createdAt: string;
}
@@ -2877,6 +2879,8 @@ export interface SelectFieldsSelect<T extends boolean = true> {
category?: T;
};
selectWithJsxLabelOption?: T;
disallowOption1?: T;
selectWithFilteredOptions?: T;
updatedAt?: T;
createdAt?: T;
}