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'
|
'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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 = ({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user