Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion packages/payload/src/admin/views/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -101,7 +102,7 @@ export type ListViewClientProps = {
queryPreset?: QueryPreset
queryPresetPermissions?: SanitizedCollectionPermission
renderedFilters?: Map<string, React.ReactNode>
resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
resolvedFilterOptions?: Map<string, Option[] | ResolvedFilterOptions>
viewType: ViewTypes
} & ListViewSlots

Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/elements/ListControls/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ClientCollectionConfig,
Option,
QueryPreset,
ResolvedFilterOptions,
SanitizedCollectionPermission,
Expand Down Expand Up @@ -35,5 +36,5 @@ export type ListControlsProps = {
readonly queryPreset?: QueryPreset
readonly queryPresetPermissions?: SanitizedCollectionPermission
readonly renderedFilters?: Map<string, React.ReactNode>
readonly resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
readonly resolvedFilterOptions?: Map<string, Option[] | ResolvedFilterOptions>
}
4 changes: 2 additions & 2 deletions packages/ui/src/elements/ListWhereBuilder/index.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -12,7 +12,7 @@ export type ListWhereBuilderProps = {
readonly fields: ClientField[]
readonly onEmptyRemove?: () => void
readonly renderedFilters?: Map<string, React.ReactNode>
readonly resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
readonly resolvedFilterOptions?: Map<string, Option[] | ResolvedFilterOptions>
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.SetStateAction<string>>
operator: Operator
Expand Down Expand Up @@ -51,6 +51,8 @@ export const DefaultFilter: React.FC<Props> = ({
)
}

const whereFilterOptions = Array.isArray(filterOptions) ? {} : (filterOptions ?? {})

switch (internalField?.field?.type) {
case 'date': {
return (
Expand Down Expand Up @@ -81,7 +83,7 @@ export const DefaultFilter: React.FC<Props> = ({
<RelationshipFilter
disabled={disabled}
field={internalField.field}
filterOptions={filterOptions ?? {}}
filterOptions={whereFilterOptions}
onChange={onChange}
operator={operator}
value={value}
Expand All @@ -94,7 +96,7 @@ export const DefaultFilter: React.FC<Props> = ({
<RelationshipFilter
disabled={disabled}
field={internalField.field}
filterOptions={filterOptions ?? {}}
filterOptions={whereFilterOptions}
onChange={onChange}
operator={operator}
value={value}
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/elements/WhereBuilder/Condition/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type Props = {
readonly andIndex: number
readonly disableRemoveButton: boolean
readonly fieldPath: string
readonly filterOptions?: ResolvedFilterOptions
readonly filterOptions?: PayloadOption[] | ResolvedFilterOptions
readonly isFirstCondition: boolean
readonly join: 'and' | 'or'
readonly operator: Operator
Expand Down Expand Up @@ -86,7 +86,7 @@ export const Condition: React.FC<Props> = (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) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/elements/WhereBuilder/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
ClientField,
Operator,
Option,
ResolvedFilterOptions,
SanitizedCollectionConfig,
Where,
Expand All @@ -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<string, React.ReactNode>
readonly resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
readonly resolvedFilterOptions?: Map<string, Option[] | ResolvedFilterOptions>
/** The current `where` value to render conditions from. */
readonly value?: Where
}
Expand Down
117 changes: 117 additions & 0 deletions packages/ui/src/views/List/resolveAllFilterOptions.spec.ts
Original file line number Diff line number Diff line change
@@ -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' }])
})
})
21 changes: 17 additions & 4 deletions packages/ui/src/views/List/resolveAllFilterOptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Field, PayloadRequest, ResolvedFilterOptions } from 'payload'
import type { Field, Option, PayloadRequest, ResolvedFilterOptions } from 'payload'

import {
fieldAffectsData,
Expand All @@ -18,9 +18,11 @@ export const resolveAllFilterOptions = async ({
fields: Field[]
pathPrefix?: string
req: PayloadRequest
result?: Map<string, ResolvedFilterOptions>
}): Promise<Map<string, ResolvedFilterOptions>> => {
const resolvedFilterOptions = !result ? new Map<string, ResolvedFilterOptions>() : result
result?: Map<string, Option[] | ResolvedFilterOptions>
}): Promise<Map<string, Option[] | ResolvedFilterOptions>> => {
const resolvedFilterOptions = !result
? new Map<string, Option[] | ResolvedFilterOptions>()
: result

await Promise.all(
fields.map(async (field) => {
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions test/fields/collections/Select/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ 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,
saveDocAndAssert,
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'
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion test/fields/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,7 @@ export interface CheckboxField {
id: string;
checkbox: boolean;
checkboxNotRequired?: boolean | null;
checkboxRequiresTrue?: boolean | null;
updatedAt: string;
createdAt: string;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2871,6 +2872,7 @@ export interface LocalizedTabsBlockSelect<T extends boolean = true> {
export interface CheckboxFieldsSelect<T extends boolean = true> {
checkbox?: T;
checkboxNotRequired?: T;
checkboxRequiresTrue?: T;
updatedAt?: T;
createdAt?: T;
}
Expand Down
Loading