fix(ui): adds multi select inputs for number fields in where builder (#12053)

### What?

The `in` & `not_in` operators were not properly working for `number`
fields as this operator requires an array of values for it's input.

### How?

Conditionally renders a multi select input for `number` fields when
filtering by `in` & `not_in` operators.
This commit is contained in:
Patrik
2025-04-09 13:26:18 -04:00
committed by GitHub
parent a90ae9d42b
commit 09916ad18e
15 changed files with 163 additions and 30 deletions

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import React from 'react' import React from 'react'
import type { Props } from './types.js' import type { DateFilterProps as Props } from './types.js'
import { DatePickerField } from '../../../DatePicker/index.js' import { DatePickerField } from '../../../DatePicker/index.js'

View File

@@ -2,7 +2,7 @@ import type { DateFieldClient } from 'payload'
import type { DefaultFilterProps } from '../types.js' import type { DefaultFilterProps } from '../types.js'
export type Props = { export type DateFilterProps = {
readonly field: DateFieldClient readonly field: DateFieldClient
readonly value: Date | string readonly value: Date | string
} & DefaultFilterProps } & DefaultFilterProps

View File

@@ -8,7 +8,7 @@ import type {
import React from 'react' import React from 'react'
import type { ReducedField } from '../../types.js' import type { ReducedField, Value } from '../../types.js'
import { DateFilter } from '../Date/index.js' import { DateFilter } from '../Date/index.js'
import { NumberFilter } from '../Number/index.js' import { NumberFilter } from '../Number/index.js'
@@ -24,7 +24,7 @@ type Props = {
onChange: React.Dispatch<React.SetStateAction<string>> onChange: React.Dispatch<React.SetStateAction<string>>
operator: Operator operator: Operator
options: Option[] options: Option[]
value: string value: Value
} }
export const DefaultFilter: React.FC<Props> = ({ export const DefaultFilter: React.FC<Props> = ({
@@ -46,7 +46,7 @@ export const DefaultFilter: React.FC<Props> = ({
onChange={onChange} onChange={onChange}
operator={operator} operator={operator}
options={options} options={options}
value={value} value={value as string}
/> />
) )
} }
@@ -59,7 +59,7 @@ export const DefaultFilter: React.FC<Props> = ({
field={internalField.field} field={internalField.field}
onChange={onChange} onChange={onChange}
operator={operator} operator={operator}
value={value} value={value as Date | string}
/> />
) )
} }
@@ -71,7 +71,7 @@ export const DefaultFilter: React.FC<Props> = ({
field={internalField.field} field={internalField.field}
onChange={onChange} onChange={onChange}
operator={operator} operator={operator}
value={value} value={value as number | number[]}
/> />
) )
} }
@@ -96,7 +96,7 @@ export const DefaultFilter: React.FC<Props> = ({
field={internalField?.field as TextFieldClient} field={internalField?.field as TextFieldClient}
onChange={onChange} onChange={onChange}
operator={operator} operator={operator}
value={value} value={value as string}
/> />
) )
} }

View File

@@ -1,24 +1,89 @@
'use client' 'use client'
import React from 'react' import React from 'react'
import type { Props } from './types.js' import type { NumberFilterProps as Props } from './types.js'
import { useTranslation } from '../../../../providers/Translation/index.js' import { useTranslation } from '../../../../providers/Translation/index.js'
import { ReactSelect } from '../../../ReactSelect/index.js'
import './index.scss' import './index.scss'
const baseClass = 'condition-value-number' const baseClass = 'condition-value-number'
export const NumberFilter: React.FC<Props> = ({ disabled, onChange, value }) => { export const NumberFilter: React.FC<Props> = (props) => {
const {
disabled,
field: { hasMany },
onChange,
operator,
value,
} = props
const { t } = useTranslation() const { t } = useTranslation()
return ( const isMulti = ['in', 'not_in'].includes(operator) || hasMany
const [valueToRender, setValueToRender] = React.useState<
{ id: string; label: string; value: { value: number } }[]
>([])
const onSelect = React.useCallback(
(selectedOption) => {
let newValue
if (!selectedOption) {
newValue = []
} else if (isMulti) {
if (Array.isArray(selectedOption)) {
newValue = selectedOption.map((option) => Number(option.value?.value || option.value))
} else {
newValue = [Number(selectedOption.value?.value || selectedOption.value)]
}
}
onChange(newValue)
},
[isMulti, onChange],
)
React.useEffect(() => {
if (Array.isArray(value)) {
setValueToRender(
value.map((val, index) => {
return {
id: `${val}${index}`, // append index to avoid duplicate keys but allow duplicate numbers
label: `${val}`,
value: {
toString: () => `${val}${index}`,
value: (val as any)?.value || val,
},
}
}),
)
} else {
setValueToRender([])
}
}, [value])
return isMulti ? (
<ReactSelect
disabled={disabled}
isClearable
isCreatable
isMulti={isMulti}
isSortable
numberOnly
onChange={onSelect}
options={[]}
placeholder={t('general:enterAValue')}
value={valueToRender || []}
/>
) : (
<input <input
className={baseClass} className={baseClass}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={t('general:enterAValue')} placeholder={t('general:enterAValue')}
type="number" type="number"
value={value} value={value as number}
/> />
) )
} }

View File

@@ -2,8 +2,8 @@ import type { NumberFieldClient } from 'payload'
import type { DefaultFilterProps } from '../types.js' import type { DefaultFilterProps } from '../types.js'
export type Props = { export type NumberFilterProps = {
readonly field: NumberFieldClient readonly field: NumberFieldClient
readonly onChange: (e: string) => void readonly onChange: (e: string) => void
readonly value: string readonly value: number | number[]
} & DefaultFilterProps } & DefaultFilterProps

View File

@@ -5,7 +5,7 @@ import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useReducer, useState } from 'react' import React, { useCallback, useEffect, useReducer, useState } from 'react'
import type { Option } from '../../../ReactSelect/types.js' import type { Option } from '../../../ReactSelect/types.js'
import type { Props, ValueWithRelation } from './types.js' import type { RelationshipFilterProps as Props, ValueWithRelation } from './types.js'
import { useDebounce } from '../../../../hooks/useDebounce.js' import { useDebounce } from '../../../../hooks/useDebounce.js'
import { useEffectEvent } from '../../../../hooks/useEffectEvent.js' import { useEffectEvent } from '../../../../hooks/useEffectEvent.js'

View File

@@ -8,7 +8,7 @@ import type {
import type { DefaultFilterProps } from '../types.js' import type { DefaultFilterProps } from '../types.js'
export type Props = { export type RelationshipFilterProps = {
readonly field: RelationshipFieldClient readonly field: RelationshipFieldClient
readonly filterOptions: ResolvedFilterOptions readonly filterOptions: ResolvedFilterOptions
} & DefaultFilterProps } & DefaultFilterProps

View File

@@ -3,7 +3,7 @@
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import React from 'react' import React from 'react'
import type { Props } from './types.js' import type { SelectFilterProps as Props } from './types.js'
import { useTranslation } from '../../../../providers/Translation/index.js' import { useTranslation } from '../../../../providers/Translation/index.js'
import { ReactSelect } from '../../../ReactSelect/index.js' import { ReactSelect } from '../../../ReactSelect/index.js'

View File

@@ -2,7 +2,7 @@ import type { Option, SelectFieldClient } from 'payload'
import type { DefaultFilterProps } from '../types.js' import type { DefaultFilterProps } from '../types.js'
export type Props = { export type SelectFilterProps = {
readonly field: SelectFieldClient readonly field: SelectFieldClient
readonly isClearable?: boolean readonly isClearable?: boolean
readonly onChange: (val: string) => void readonly onChange: (val: string) => void

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import React from 'react' import React from 'react'
import type { Props } from './types.js' import type { TextFilterProps as Props } from './types.js'
import { useTranslation } from '../../../../providers/Translation/index.js' import { useTranslation } from '../../../../providers/Translation/index.js'
import './index.scss' import './index.scss'

View File

@@ -2,7 +2,7 @@ import type { TextFieldClient } from 'payload'
import type { DefaultFilterProps } from '../types.js' import type { DefaultFilterProps } from '../types.js'
export type Props = { export type TextFilterProps = {
readonly field: TextFieldClient readonly field: TextFieldClient
readonly onChange: (val: string) => void readonly onChange: (val: string) => void
readonly value: string readonly value: string

View File

@@ -1,7 +1,13 @@
'use client' 'use client'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import type { AddCondition, ReducedField, RemoveCondition, UpdateCondition } from '../types.js' import type {
AddCondition,
ReducedField,
RemoveCondition,
UpdateCondition,
Value,
} from '../types.js'
export type Props = { export type Props = {
readonly addCondition: AddCondition readonly addCondition: AddCondition
@@ -14,7 +20,7 @@ export type Props = {
readonly removeCondition: RemoveCondition readonly removeCondition: RemoveCondition
readonly RenderedFilter: React.ReactNode readonly RenderedFilter: React.ReactNode
readonly updateCondition: UpdateCondition readonly updateCondition: UpdateCondition
readonly value: string readonly value: Value
} }
import type { Operator, Option as PayloadOption, ResolvedFilterOptions } from 'payload' import type { Operator, Option as PayloadOption, ResolvedFilterOptions } from 'payload'
@@ -51,7 +57,7 @@ export const Condition: React.FC<Props> = (props) => {
const reducedField = reducedFields.find((field) => field.value === fieldName) const reducedField = reducedFields.find((field) => field.value === fieldName)
const [internalValue, setInternalValue] = useState<string>(value) const [internalValue, setInternalValue] = useState<Value>(value)
const debouncedValue = useDebounce(internalValue, 300) const debouncedValue = useDebounce(internalValue, 300)

View File

@@ -60,7 +60,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
if (relation === 'and') { if (relation === 'and') {
newConditions[orIndex].and.splice(andIndex, 0, { newConditions[orIndex].and.splice(andIndex, 0, {
[field.value]: { [String(field.value)]: {
[defaultOperator]: undefined, [defaultOperator]: undefined,
}, },
}) })
@@ -68,7 +68,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
newConditions.push({ newConditions.push({
and: [ and: [
{ {
[field.value]: { [String(field.value)]: {
[defaultOperator]: undefined, [defaultOperator]: undefined,
}, },
}, },
@@ -91,14 +91,14 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
if (typeof existingCondition === 'object' && field.value) { if (typeof existingCondition === 'object' && field.value) {
const value = valueArg ?? existingCondition?.[operator] const value = valueArg ?? existingCondition?.[operator]
const valueChanged = value !== existingCondition?.[field.value]?.[operator] const valueChanged = value !== existingCondition?.[String(field.value)]?.[String(operator)]
const operatorChanged = const operatorChanged =
operator !== Object.keys(existingCondition?.[field.value] || {})?.[0] operator !== Object.keys(existingCondition?.[String(field.value)] || {})?.[0]
if (valueChanged || operatorChanged) { if (valueChanged || operatorChanged) {
const newRowCondition = { const newRowCondition = {
[field.value]: { [operator]: value }, [String(field.value)]: { [operator]: value },
} }
const newConditions = [...conditions] const newConditions = [...conditions]

View File

@@ -14,6 +14,8 @@ export type WhereBuilderProps = {
readonly resolvedFilterOptions?: Map<string, ResolvedFilterOptions> readonly resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
} }
export type Value = Date | number | number[] | string | string[]
export type ReducedField = { export type ReducedField = {
field: ClientField field: ClientField
label: React.ReactNode label: React.ReactNode
@@ -21,7 +23,7 @@ export type ReducedField = {
label: string label: string
value: Operator value: Operator
}[] }[]
value: string value: Value
} }
export type Relation = 'and' | 'or' export type Relation = 'and' | 'or'
@@ -78,7 +80,7 @@ export type UpdateCondition = ({
field: ReducedField field: ReducedField
operator: string operator: string
orIndex: number orIndex: number
value: string value: Value
}) => Promise<void> | void }) => Promise<void> | void
export type RemoveCondition = ({ export type RemoveCondition = ({

View File

@@ -88,6 +88,66 @@ describe('Number', () => {
await expect(page.locator('table >> tbody >> tr')).toHaveCount(2) await expect(page.locator('table >> tbody >> tr')).toHaveCount(2)
}) })
test('should filter Number field hasMany: false in the collection view - in', async () => {
await page.goto(url.list)
await expect(page.locator('table >> tbody >> tr')).toHaveCount(3)
await addListFilter({
page,
fieldLabel: 'Number',
operatorLabel: 'is in',
value: '2',
})
await wait(300)
await expect(page.locator('table >> tbody >> tr')).toHaveCount(1)
})
test('should filter Number field hasMany: false in the collection view - is not in', async () => {
await page.goto(url.list)
await expect(page.locator('table >> tbody >> tr')).toHaveCount(3)
await addListFilter({
page,
fieldLabel: 'Number',
operatorLabel: 'is not in',
value: '2',
})
await wait(300)
await expect(page.locator('table >> tbody >> tr')).toHaveCount(2)
})
test('should filter Number field hasMany: true in the collection view - in', async () => {
await page.goto(url.list)
await expect(page.locator('table >> tbody >> tr')).toHaveCount(3)
await addListFilter({
page,
fieldLabel: 'Has Many',
operatorLabel: 'is in',
value: '5',
})
await wait(300)
await expect(page.locator('table >> tbody >> tr')).toHaveCount(1)
})
test('should filter Number field hasMany: true in the collection view - is not in', async () => {
await page.goto(url.list)
await expect(page.locator('table >> tbody >> tr')).toHaveCount(3)
await addListFilter({
page,
fieldLabel: 'Has Many',
operatorLabel: 'is not in',
value: '6',
})
await wait(300)
await expect(page.locator('table >> tbody >> tr')).toHaveCount(3)
})
test('should create', async () => { test('should create', async () => {
const input = 5 const input = 5
await page.goto(url.create) await page.goto(url.create)