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:
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
import type { Props } from './types.js'
|
||||
import type { DateFilterProps as Props } from './types.js'
|
||||
|
||||
import { DatePickerField } from '../../../DatePicker/index.js'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DateFieldClient } from 'payload'
|
||||
|
||||
import type { DefaultFilterProps } from '../types.js'
|
||||
|
||||
export type Props = {
|
||||
export type DateFilterProps = {
|
||||
readonly field: DateFieldClient
|
||||
readonly value: Date | string
|
||||
} & DefaultFilterProps
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import type { ReducedField } from '../../types.js'
|
||||
import type { ReducedField, Value } from '../../types.js'
|
||||
|
||||
import { DateFilter } from '../Date/index.js'
|
||||
import { NumberFilter } from '../Number/index.js'
|
||||
@@ -24,7 +24,7 @@ type Props = {
|
||||
onChange: React.Dispatch<React.SetStateAction<string>>
|
||||
operator: Operator
|
||||
options: Option[]
|
||||
value: string
|
||||
value: Value
|
||||
}
|
||||
|
||||
export const DefaultFilter: React.FC<Props> = ({
|
||||
@@ -46,7 +46,7 @@ export const DefaultFilter: React.FC<Props> = ({
|
||||
onChange={onChange}
|
||||
operator={operator}
|
||||
options={options}
|
||||
value={value}
|
||||
value={value as string}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export const DefaultFilter: React.FC<Props> = ({
|
||||
field={internalField.field}
|
||||
onChange={onChange}
|
||||
operator={operator}
|
||||
value={value}
|
||||
value={value as Date | string}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const DefaultFilter: React.FC<Props> = ({
|
||||
field={internalField.field}
|
||||
onChange={onChange}
|
||||
operator={operator}
|
||||
value={value}
|
||||
value={value as number | number[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export const DefaultFilter: React.FC<Props> = ({
|
||||
field={internalField?.field as TextFieldClient}
|
||||
onChange={onChange}
|
||||
operator={operator}
|
||||
value={value}
|
||||
value={value as string}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,89 @@
|
||||
'use client'
|
||||
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 { ReactSelect } from '../../../ReactSelect/index.js'
|
||||
import './index.scss'
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
className={baseClass}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t('general:enterAValue')}
|
||||
type="number"
|
||||
value={value}
|
||||
value={value as number}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { NumberFieldClient } from 'payload'
|
||||
|
||||
import type { DefaultFilterProps } from '../types.js'
|
||||
|
||||
export type Props = {
|
||||
export type NumberFilterProps = {
|
||||
readonly field: NumberFieldClient
|
||||
readonly onChange: (e: string) => void
|
||||
readonly value: string
|
||||
readonly value: number | number[]
|
||||
} & DefaultFilterProps
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as qs from 'qs-esm'
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react'
|
||||
|
||||
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 { useEffectEvent } from '../../../../hooks/useEffectEvent.js'
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
|
||||
import type { DefaultFilterProps } from '../types.js'
|
||||
|
||||
export type Props = {
|
||||
export type RelationshipFilterProps = {
|
||||
readonly field: RelationshipFieldClient
|
||||
readonly filterOptions: ResolvedFilterOptions
|
||||
} & DefaultFilterProps
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
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 { ReactSelect } from '../../../ReactSelect/index.js'
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Option, SelectFieldClient } from 'payload'
|
||||
|
||||
import type { DefaultFilterProps } from '../types.js'
|
||||
|
||||
export type Props = {
|
||||
export type SelectFilterProps = {
|
||||
readonly field: SelectFieldClient
|
||||
readonly isClearable?: boolean
|
||||
readonly onChange: (val: string) => void
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
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 './index.scss'
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { TextFieldClient } from 'payload'
|
||||
|
||||
import type { DefaultFilterProps } from '../types.js'
|
||||
|
||||
export type Props = {
|
||||
export type TextFilterProps = {
|
||||
readonly field: TextFieldClient
|
||||
readonly onChange: (val: string) => void
|
||||
readonly value: string
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client'
|
||||
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 = {
|
||||
readonly addCondition: AddCondition
|
||||
@@ -14,7 +20,7 @@ export type Props = {
|
||||
readonly removeCondition: RemoveCondition
|
||||
readonly RenderedFilter: React.ReactNode
|
||||
readonly updateCondition: UpdateCondition
|
||||
readonly value: string
|
||||
readonly value: Value
|
||||
}
|
||||
|
||||
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 [internalValue, setInternalValue] = useState<string>(value)
|
||||
const [internalValue, setInternalValue] = useState<Value>(value)
|
||||
|
||||
const debouncedValue = useDebounce(internalValue, 300)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
||||
|
||||
if (relation === 'and') {
|
||||
newConditions[orIndex].and.splice(andIndex, 0, {
|
||||
[field.value]: {
|
||||
[String(field.value)]: {
|
||||
[defaultOperator]: undefined,
|
||||
},
|
||||
})
|
||||
@@ -68,7 +68,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
||||
newConditions.push({
|
||||
and: [
|
||||
{
|
||||
[field.value]: {
|
||||
[String(field.value)]: {
|
||||
[defaultOperator]: undefined,
|
||||
},
|
||||
},
|
||||
@@ -91,14 +91,14 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
|
||||
if (typeof existingCondition === 'object' && field.value) {
|
||||
const value = valueArg ?? existingCondition?.[operator]
|
||||
|
||||
const valueChanged = value !== existingCondition?.[field.value]?.[operator]
|
||||
const valueChanged = value !== existingCondition?.[String(field.value)]?.[String(operator)]
|
||||
|
||||
const operatorChanged =
|
||||
operator !== Object.keys(existingCondition?.[field.value] || {})?.[0]
|
||||
operator !== Object.keys(existingCondition?.[String(field.value)] || {})?.[0]
|
||||
|
||||
if (valueChanged || operatorChanged) {
|
||||
const newRowCondition = {
|
||||
[field.value]: { [operator]: value },
|
||||
[String(field.value)]: { [operator]: value },
|
||||
}
|
||||
|
||||
const newConditions = [...conditions]
|
||||
|
||||
@@ -14,6 +14,8 @@ export type WhereBuilderProps = {
|
||||
readonly resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
|
||||
}
|
||||
|
||||
export type Value = Date | number | number[] | string | string[]
|
||||
|
||||
export type ReducedField = {
|
||||
field: ClientField
|
||||
label: React.ReactNode
|
||||
@@ -21,7 +23,7 @@ export type ReducedField = {
|
||||
label: string
|
||||
value: Operator
|
||||
}[]
|
||||
value: string
|
||||
value: Value
|
||||
}
|
||||
|
||||
export type Relation = 'and' | 'or'
|
||||
@@ -78,7 +80,7 @@ export type UpdateCondition = ({
|
||||
field: ReducedField
|
||||
operator: string
|
||||
orIndex: number
|
||||
value: string
|
||||
value: Value
|
||||
}) => Promise<void> | void
|
||||
|
||||
export type RemoveCondition = ({
|
||||
|
||||
@@ -88,6 +88,66 @@ describe('Number', () => {
|
||||
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 () => {
|
||||
const input = 5
|
||||
await page.goto(url.create)
|
||||
|
||||
Reference in New Issue
Block a user