diff --git a/docs/fields/array.mdx b/docs/fields/array.mdx index a62a6a5285..77396b9164 100644 --- a/docs/fields/array.mdx +++ b/docs/fields/array.mdx @@ -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 | | ------------------------- | ----------------------------------------------------------------------------------- | diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index 34410bb1da..9fbe6c9755 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -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 | | ---------------------- | -------------------------------------------------------------------------- | diff --git a/docs/fields/code.mdx b/docs/fields/code.mdx index e7fe52045d..0b16ee4838 100644 --- a/docs/fields/code.mdx +++ b/docs/fields/code.mdx @@ -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 | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/fields/collapsible.mdx b/docs/fields/collapsible.mdx index ad746b0907..0d06e91a6f 100644 --- a/docs/fields/collapsible.mdx +++ b/docs/fields/collapsible.mdx @@ -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 | | ------------------- | ------------------------------- | diff --git a/docs/fields/date.mdx b/docs/fields/date.mdx index e2a9d2ba02..aec8650a2b 100644 --- a/docs/fields/date.mdx +++ b/docs/fields/date.mdx @@ -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 | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/fields/email.mdx b/docs/fields/email.mdx index a409386176..55812c4a00 100644 --- a/docs/fields/email.mdx +++ b/docs/fields/email.mdx @@ -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 | | ------------------ | ------------------------------------------------------------------------- | diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index 7865e0fc75..356cdd6acf 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -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 | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/docs/fields/json.mdx b/docs/fields/json.mdx index 5d18735c29..db9db40c8f 100644 --- a/docs/fields/json.mdx +++ b/docs/fields/json.mdx @@ -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 | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/fields/number.mdx b/docs/fields/number.mdx index a69d78fb8e..8b8813f4a7 100644 --- a/docs/fields/number.mdx +++ b/docs/fields/number.mdx @@ -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 | | ------------------ | --------------------------------------------------------------------------------- | diff --git a/docs/fields/radio.mdx b/docs/fields/radio.mdx index 4ca69d7abc..6160cc164f 100644 --- a/docs/fields/radio.mdx +++ b/docs/fields/radio.mdx @@ -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 | | ------------ | ---------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 12e95b2896..2b277d6776 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -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 | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index cf5ed27b54..8b61bfa3bb 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -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. +### 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 +} +``` + + + **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. + + ## 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 | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index e19df883bd..3d2042822b 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -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 | | ------------------ | --------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/fields/textarea.mdx b/docs/fields/textarea.mdx index 264749607a..c8913c800d 100644 --- a/docs/fields/textarea.mdx +++ b/docs/fields/textarea.mdx @@ -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 | | ------------------ | --------------------------------------------------------------------------------------------------------------------------- | diff --git a/packages/next/src/views/List/resolveAllFilterOptions.ts b/packages/next/src/views/List/resolveAllFilterOptions.ts index 64c9fb9c0f..d5fb33d8cc 100644 --- a/packages/next/src/views/List/resolveAllFilterOptions.ts +++ b/packages/next/src/views/List/resolveAllFilterOptions.ts @@ -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, diff --git a/packages/payload/src/admin/fields/Select.ts b/packages/payload/src/admin/fields/Select.ts index 8ebe8453c4..c78008717b 100644 --- a/packages/payload/src/admin/fields/Select.ts +++ b/packages/payload/src/admin/fields/Select.ts @@ -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, diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index 29d28181c1..374d28e068 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -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 diff --git a/packages/payload/src/fields/config/client.ts b/packages/payload/src/fields/config/client.ts index 408bfedff9..27f22f0b88 100644 --- a/packages/payload/src/fields/config/client.ts +++ b/packages/payload/src/fields/config/client.ts @@ -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[] = [ '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[] = [ // `tabs` // `admin` ] + const serverOnlyFieldAdminProperties: Partial[] = [ 'condition', 'components', ] + type FieldWithDescription = { admin: AdminClient } & ClientField diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 5f2d393a5c..800bb71375 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -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. diff --git a/packages/payload/src/fields/validations.spec.ts b/packages/payload/src/fields/validations.spec.ts index bda65e215c..b3b822771d 100644 --- a/packages/payload/src/fields/validations.spec.ts +++ b/packages/payload/src/fields/validations.spec.ts @@ -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) diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index 5018948c51..ff8b307cdc 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -865,13 +865,23 @@ export type SelectFieldSingleValidation = Validate { + 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), ) ) { diff --git a/packages/ui/src/fields/Select/Input.tsx b/packages/ui/src/fields/Select/Input.tsx index da6d007a9a..54e32c57a7 100644 --- a/packages/ui/src/fields/Select/Input.tsx +++ b/packages/ui/src/fields/Select/Input.tsx @@ -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 = (props) => { Description, description, Error, + filterOption, hasMany = false, isClearable = true, isSortable = true, @@ -118,6 +120,7 @@ export const SelectInput: React.FC = (props) => { {BeforeInput} { 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} diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 7032e2869c..d37c7ea672 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -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] diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index 987b819957..f867be97d1 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -128,6 +128,7 @@ export const useField = (options?: Options): FieldType => { initialValue, path, rows: field?.rows, + selectFilterOptions: field?.selectFilterOptions, setValue, showError, valid: field?.valid, diff --git a/packages/ui/src/forms/useField/types.ts b/packages/ui/src/forms/useField/types.ts index e91e60e8bd..bf181f7dbb 100644 --- a/packages/ui/src/forms/useField/types.ts +++ b/packages/ui/src/forms/useField/types.ts @@ -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 = { path: string readOnly?: boolean rows?: Row[] + selectFilterOptions?: Option[] setValue: (val: unknown, disableModifyingForm?: boolean) => void showError: boolean valid?: boolean diff --git a/test/fields/collections/Select/e2e.spec.ts b/test/fields/collections/Select/e2e.spec.ts index 56ff3b66fc..71823aa1f4 100644 --- a/test/fields/collections/Select/e2e.spec.ts +++ b/test/fields/collections/Select/e2e.spec.ts @@ -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() + }) }) diff --git a/test/fields/collections/Select/index.ts b/test/fields/collections/Select/index.ts index 6a57154c20..cab7cb46b1 100644 --- a/test/fields/collections/Select/index.ts +++ b/test/fields/collections/Select/index.ts @@ -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, + }, ], } diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 157ee33ec5..890c88e229 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -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', () => { diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index dd8b11bc81..f42ac45392 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -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 { category?: T; }; selectWithJsxLabelOption?: T; + disallowOption1?: T; + selectWithFilteredOptions?: T; updatedAt?: T; createdAt?: T; }