diff --git a/packages/payload/src/admin/views/list.ts b/packages/payload/src/admin/views/list.ts index 9dc96a90963..2d4b13f087d 100644 --- a/packages/payload/src/admin/views/list.ts +++ b/packages/payload/src/admin/views/list.ts @@ -6,6 +6,7 @@ import type { } from '../../collections/config/types.js' import type { ServerProps } from '../../config/types.js' import type { PaginatedDocs } from '../../database/types.js' +import type { Option } from '../../fields/config/types.js' import type { CollectionPreferences } from '../../preferences/types.js' import type { QueryPreset } from '../../query-presets/types.js' import type { ResolvedFilterOptions, Where } from '../../types/index.js' @@ -101,7 +102,7 @@ export type ListViewClientProps = { queryPreset?: QueryPreset queryPresetPermissions?: SanitizedCollectionPermission renderedFilters?: Map - resolvedFilterOptions?: Map + resolvedFilterOptions?: Map viewType: ViewTypes } & ListViewSlots diff --git a/packages/ui/src/elements/ListControls/types.ts b/packages/ui/src/elements/ListControls/types.ts index 7c93dd5f30c..d26dfe8ffa5 100644 --- a/packages/ui/src/elements/ListControls/types.ts +++ b/packages/ui/src/elements/ListControls/types.ts @@ -1,5 +1,6 @@ import type { ClientCollectionConfig, + Option, QueryPreset, ResolvedFilterOptions, SanitizedCollectionPermission, @@ -35,5 +36,5 @@ export type ListControlsProps = { readonly queryPreset?: QueryPreset readonly queryPresetPermissions?: SanitizedCollectionPermission readonly renderedFilters?: Map - readonly resolvedFilterOptions?: Map + readonly resolvedFilterOptions?: Map } diff --git a/packages/ui/src/elements/ListWhereBuilder/index.tsx b/packages/ui/src/elements/ListWhereBuilder/index.tsx index a04515f3d94..30a1a77dc7e 100644 --- a/packages/ui/src/elements/ListWhereBuilder/index.tsx +++ b/packages/ui/src/elements/ListWhereBuilder/index.tsx @@ -1,5 +1,5 @@ 'use client' -import type { ClientField, ResolvedFilterOptions, SanitizedCollectionConfig } from 'payload' +import type { ClientField, Option, ResolvedFilterOptions, SanitizedCollectionConfig } from 'payload' import React, { useCallback } from 'react' @@ -12,7 +12,7 @@ export type ListWhereBuilderProps = { readonly fields: ClientField[] readonly onEmptyRemove?: () => void readonly renderedFilters?: Map - readonly resolvedFilterOptions?: Map + readonly resolvedFilterOptions?: Map } /** diff --git a/packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx index 3c5dca315d0..0377d5de35c 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx @@ -19,7 +19,7 @@ import { Text } from '../Text/index.js' type Props = { booleanSelect: boolean disabled: boolean - filterOptions?: ResolvedFilterOptions + filterOptions?: Option[] | ResolvedFilterOptions internalField: ReducedField onChange: React.Dispatch> operator: Operator @@ -51,6 +51,8 @@ export const DefaultFilter: React.FC = ({ ) } + const whereFilterOptions = Array.isArray(filterOptions) ? {} : (filterOptions ?? {}) + switch (internalField?.field?.type) { case 'date': { return ( @@ -81,7 +83,7 @@ export const DefaultFilter: React.FC = ({ = ({ = (props) => { { label: t('general:false'), value: 'false' }, ] } else if (reducedField?.field && 'options' in reducedField.field) { - valueOptions = reducedField.field.options + valueOptions = Array.isArray(filterOptions) ? filterOptions : reducedField.field.options } const updateValue = useEffectEvent(async (debouncedValue: Value) => { diff --git a/packages/ui/src/elements/WhereBuilder/types.ts b/packages/ui/src/elements/WhereBuilder/types.ts index 251ae6b439f..76cfb2be6cb 100644 --- a/packages/ui/src/elements/WhereBuilder/types.ts +++ b/packages/ui/src/elements/WhereBuilder/types.ts @@ -1,6 +1,7 @@ import type { ClientField, Operator, + Option, ResolvedFilterOptions, SanitizedCollectionConfig, Where, @@ -15,7 +16,7 @@ export type WhereBuilderProps = { /** Called when the last condition is removed, so the parent has empty state control. */ readonly onEmptyRemove?: () => void readonly renderedFilters?: Map - readonly resolvedFilterOptions?: Map + readonly resolvedFilterOptions?: Map /** The current `where` value to render conditions from. */ readonly value?: Where } diff --git a/packages/ui/src/views/List/resolveAllFilterOptions.spec.ts b/packages/ui/src/views/List/resolveAllFilterOptions.spec.ts new file mode 100644 index 00000000000..607ee53e6a7 --- /dev/null +++ b/packages/ui/src/views/List/resolveAllFilterOptions.spec.ts @@ -0,0 +1,117 @@ +import type { Field, PayloadRequest } from 'payload' + +import { describe, expect, it } from 'vitest' + +import { resolveAllFilterOptions } from './resolveAllFilterOptions.js' + +const mockReq = {} as PayloadRequest + +describe('resolveAllFilterOptions', () => { + it('resolves relationship filterOptions into a Where query keyed by relationTo collection', async () => { + const fields = [ + { + name: 'relationshipField', + type: 'relationship', + relationTo: 'posts', + filterOptions: () => ({ status: { equals: 'published' } }), + }, + ] as Field[] + + const result = await resolveAllFilterOptions({ fields, req: mockReq }) + + expect(result.get('relationshipField')).toEqual({ + posts: { status: { equals: 'published' } }, + }) + }) + + it('resolves select filterOptions into a filtered Option array', async () => { + const fields = [ + { + name: 'selectField', + type: 'select', + options: [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + { label: 'Three', value: 'three' }, + ], + filterOptions: ({ options }) => + options.filter( + (option) => (typeof option === 'string' ? option : option.value) !== 'three', + ), + }, + ] as Field[] + + const result = await resolveAllFilterOptions({ fields, req: mockReq }) + + expect(result.get('selectField')).toEqual([ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + ]) + }) + + it('awaits an async select filterOptions function and resolves it to a plain Option array', async () => { + const fields = [ + { + name: 'selectField', + type: 'select', + options: [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + { label: 'Three', value: 'three' }, + ], + filterOptions: async ({ options }) => + options.filter( + (option) => (typeof option === 'string' ? option : option.value) !== 'three', + ), + }, + ] as Field[] + + const result = await resolveAllFilterOptions({ fields, req: mockReq }) + + expect(result.get('selectField')).toEqual([ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + ]) + }) + + it('does not add a map entry for select fields without a filterOptions function', async () => { + const fields = [ + { + name: 'selectField', + type: 'select', + options: [{ label: 'One', value: 'one' }], + }, + ] as Field[] + + const result = await resolveAllFilterOptions({ fields, req: mockReq }) + + expect(result.has('selectField')).toBe(false) + }) + + it('resolves select filterOptions nested under a group field using the dot-notation path', async () => { + const fields = [ + { + name: 'group', + type: 'group', + fields: [ + { + name: 'selectField', + type: 'select', + options: [ + { label: 'One', value: 'one' }, + { label: 'Two', value: 'two' }, + ], + filterOptions: ({ options }) => + options.filter( + (option) => (typeof option === 'string' ? option : option.value) !== 'two', + ), + }, + ], + }, + ] as Field[] + + const result = await resolveAllFilterOptions({ fields, req: mockReq }) + + expect(result.get('group.selectField')).toEqual([{ label: 'One', value: 'one' }]) + }) +}) diff --git a/packages/ui/src/views/List/resolveAllFilterOptions.ts b/packages/ui/src/views/List/resolveAllFilterOptions.ts index a0331874fbc..d809f9653cc 100644 --- a/packages/ui/src/views/List/resolveAllFilterOptions.ts +++ b/packages/ui/src/views/List/resolveAllFilterOptions.ts @@ -1,4 +1,4 @@ -import type { Field, PayloadRequest, ResolvedFilterOptions } from 'payload' +import type { Field, Option, PayloadRequest, ResolvedFilterOptions } from 'payload' import { fieldAffectsData, @@ -18,9 +18,11 @@ export const resolveAllFilterOptions = async ({ fields: Field[] pathPrefix?: string req: PayloadRequest - result?: Map -}): Promise> => { - const resolvedFilterOptions = !result ? new Map() : result + result?: Map +}): Promise> => { + const resolvedFilterOptions = !result + ? new Map() + : result await Promise.all( fields.map(async (field) => { @@ -52,6 +54,17 @@ export const resolveAllFilterOptions = async ({ resolvedFilterOptions.set(fieldPath, options) } + if (field.type === 'select' && typeof field.filterOptions === 'function') { + const options = await field.filterOptions({ + data: {}, // use empty object to prevent breaking queries when accessing properties of `data` + options: field.options, + req, + siblingData: {}, // use empty object to prevent breaking queries when accessing properties of `siblingData` + }) + + resolvedFilterOptions.set(fieldPath, options) + } + if (fieldHasSubFields(field)) { await resolveAllFilterOptions({ fields: field.fields, diff --git a/test/fields/collections/Select/e2e.spec.ts b/test/fields/collections/Select/e2e.spec.ts index 36f0f9f8f56..465840b8559 100644 --- a/test/fields/collections/Select/e2e.spec.ts +++ b/test/fields/collections/Select/e2e.spec.ts @@ -10,6 +10,7 @@ import type { Config } from '../../payload-types.js' import { checkFocusIndicators } from '../../../__helpers/e2e/checkFocusIndicators.js' import { clickColumnSelectorItem, openListColumns } from '../../../__helpers/e2e/columns/index.js' +import { addListFilter } from '../../../__helpers/e2e/filters/addListFilter.js' import { ensureCompilationIsDone, initPageConsoleErrorCatch, @@ -17,6 +18,7 @@ import { waitForFormReady, } from '../../../__helpers/e2e/helpers.js' import { runAxeScan } from '../../../__helpers/e2e/runAxeScan.js' +import { getSelectMenu } from '../../../__helpers/e2e/selectInput.js' import { AdminUrlUtil } from '../../../__helpers/shared/adminUrlUtil.js' import { reInitializeDB } from '../../../__helpers/shared/clearAndSeed/reInitializeDB.js' import { initPayloadE2ENoConfig } from '../../../__helpers/shared/initPayloadE2ENoConfig.js' @@ -138,6 +140,26 @@ describe('Select', () => { await expect(options.locator('text=Value Two')).toBeVisible() }) + test('should resolve an async, DB-backed `filterOptions` for the list view filter value options', async () => { + await page.goto(url.list) + + const { condition } = await addListFilter({ + fieldLabel: 'Select with async filtered options', + operatorLabel: 'equals', + page, + }) + + await condition.locator('.condition__value input').click() + + // react-select portals its menu to the document body, so options must be queried page-wide + const valueOptions = getSelectMenu({ page }).locator('.rs__option') + + await expect(valueOptions).toHaveCount(3) + await expect(valueOptions.locator('text=Value One')).toBeVisible() + await expect(valueOptions.locator('text=Value Two')).toBeVisible() + await expect(valueOptions.locator('text=Value Three')).toBeVisible() + }) + test('should retain search when reducing options', async () => { await page.goto(url.create) await waitForFormReady(page) diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index dea7f469500..8c415218f25 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -993,6 +993,7 @@ export interface CheckboxField { id: string; checkbox: boolean; checkboxNotRequired?: boolean | null; + checkboxRequiresTrue?: boolean | null; updatedAt: string; createdAt: string; } @@ -1274,7 +1275,7 @@ export interface EmailField { */ export interface RadioField { id: string; - radio?: ('one' | 'two' | 'three') | null; + radio: 'one' | 'two' | 'three'; radioWithJsxLabelOption?: ('one' | 'two' | 'three') | null; updatedAt: string; createdAt: string; @@ -2871,6 +2872,7 @@ export interface LocalizedTabsBlockSelect { export interface CheckboxFieldsSelect { checkbox?: T; checkboxNotRequired?: T; + checkboxRequiresTrue?: T; updatedAt?: T; createdAt?: T; }