fix(ui): relationship filter renders stale values when changing fields (#11080)

Fixes #9873. The relationship filter in the "where" builder renders
stale values when switching between fields or adding additional "and"
conditions. This was because the `RelationshipFilter` component was not
responding to changes in the `relationTo` prop and failing to reset
internal state when these events took place.

While it sounds like a simple fix, it was actually quite extensive. The
`RelationshipFilter` component was previously relying on a `useEffect`
that had a callback in its dependencies. This was causing the effect to
run uncontrollably using old references. To avoid this, we use the new
`useEffectEvent` approach which allows the underlying effect to run much
more precisely. Same with the `Condition` component that wraps it. We
now run callbacks directly within event handlers as much as possible,
and rely on `useEffectEvent` _only_ for debounced value changes.

This component was also unnecessarily complex...and still is to some
degree. Previously, it was maintaining two separate refs, one to track
the relationships that have yet to fully load, and another to track the
next pages of each relationship that need to load on the next run. These
have been combined into a single ref that tracks both simultaneously, as
this data is interrelated.

This change also does some much needed housekeeping to the
`WhereBuilder` by improving types, defaulting the operator field, etc.

Related: #11023 and #11032

Unrelated: finds a few more instances where the new `addListFilter`
helper from #11026 could be used. Also removes a few duplicative tests.
This commit is contained in:
Jacob Fletcher
2025-02-11 09:45:41 -05:00
committed by GitHub
parent 1f3ccb82d9
commit da6511eba9
23 changed files with 414 additions and 436 deletions

View File

@@ -213,7 +213,6 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
collectionPluralLabel={collectionConfig?.labels?.plural}
collectionSlug={collectionConfig.slug}
fields={collectionConfig?.fields}
key={String(hasWhereParam.current && !query?.where)}
renderedFilters={renderedFilters}
/>
</AnimateHeight>

View File

@@ -1,7 +0,0 @@
@import '../../scss/styles.scss';
@layer payload-default {
.view-description {
margin-block-end: calc(var(--base));
}
}

View File

@@ -5,7 +5,6 @@ import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
export type ViewDescriptionComponent = React.ComponentType<any>
@@ -24,7 +23,7 @@ export const ViewDescription: React.FC<ViewDescriptionProps> = (props) => {
const { description } = props
if (description) {
return <div className="view-description">{getTranslation(description, i18n)}</div>
return <div className="custom-view-description">{getTranslation(description, i18n)}</div>
}
return null

View File

@@ -2,7 +2,7 @@ import type { Operator, Option, SelectFieldClient, TextFieldClient } from 'paylo
import React from 'react'
import type { FieldCondition } from '../../types.js'
import type { ReducedField } from '../../types.js'
import { DateFilter } from '../Date/index.js'
import { NumberFilter } from '../Number/index.js'
@@ -13,7 +13,7 @@ import { Text } from '../Text/index.js'
type Props = {
booleanSelect: boolean
disabled: boolean
internalField: FieldCondition
internalField: ReducedField
onChange: React.Dispatch<React.SetStateAction<string>>
operator: Operator
options: Option[]
@@ -34,6 +34,7 @@ export const DefaultFilter: React.FC<Props> = ({
<Select
disabled={disabled}
field={internalField.field as SelectFieldClient}
isClearable={!booleanSelect}
onChange={onChange}
operator={operator}
options={options}

View File

@@ -8,11 +8,12 @@ import type { Option } from '../../../ReactSelect/types.js'
import type { Props, ValueWithRelation } from './types.js'
import { useDebounce } from '../../../../hooks/useDebounce.js'
import { useEffectEvent } from '../../../../hooks/useEffectEvent.js'
import { useConfig } from '../../../../providers/Config/index.js'
import { useTranslation } from '../../../../providers/Translation/index.js'
import { ReactSelect } from '../../../ReactSelect/index.js'
import './index.scss'
import optionsReducer from './optionsReducer.js'
import './index.scss'
const baseClass = 'condition-value-relationship'
@@ -37,22 +38,32 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
const hasMultipleRelations = Array.isArray(relationTo)
const [options, dispatchOptions] = useReducer(optionsReducer, [])
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
const [errorLoading, setErrorLoading] = useState('')
const [hasLoadedFirstOptions, setHasLoadedFirstOptions] = useState(false)
const debouncedSearch = useDebounce(search, 300)
const { i18n, t } = useTranslation()
const relationSlugs = hasMultipleRelations ? relationTo : [relationTo]
const initialRelationMap = () => {
const map: Map<string, number> = new Map()
relationSlugs.forEach((relation) => {
map.set(relation, 1)
})
return map
}
const nextPageByRelationshipRef = React.useRef<Map<string, number>>(initialRelationMap())
const partiallyLoadedRelationshipSlugs = React.useRef<string[]>(relationSlugs)
const loadedRelationships = React.useRef<
Map<
string,
{
hasLoadedAll: boolean
nextPage: number
}
>
>(
new Map(
relationSlugs.map((relation) => [
relation,
{
hasLoadedAll: false,
nextPage: 1,
},
]),
),
)
const addOptions = useCallback(
(data, relation) => {
@@ -62,7 +73,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
[hasMultipleRelations, i18n, getEntityConfig],
)
const loadRelationOptions = React.useCallback(
const loadOptions = useEffectEvent(
async ({
abortController,
relationSlug,
@@ -70,20 +81,23 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
abortController: AbortController
relationSlug: string
}) => {
if (relationSlug && partiallyLoadedRelationshipSlugs.current.includes(relationSlug)) {
const loadedRelationship = loadedRelationships.current.get(relationSlug)
if (relationSlug && !loadedRelationship.hasLoadedAll) {
const collection = getEntityConfig({
collectionSlug: relationSlug,
})
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
const pageIndex = nextPageByRelationshipRef.current.get(relationSlug)
const where: Where = {
and: [],
}
const query = {
depth: 0,
limit: maxResultsPerRequest,
page: pageIndex,
page: loadedRelationship.nextPage,
select: {
[fieldToSearch]: true,
},
@@ -116,12 +130,15 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
addOptions(data, relationSlug)
if (data.nextPage) {
nextPageByRelationshipRef.current.set(relationSlug, data.nextPage)
loadedRelationships.current.set(relationSlug, {
hasLoadedAll: false,
nextPage: data.nextPage,
})
} else {
partiallyLoadedRelationshipSlugs.current =
partiallyLoadedRelationshipSlugs.current.filter(
(partiallyLoadedRelation) => partiallyLoadedRelation !== relationSlug,
)
loadedRelationships.current.set(relationSlug, {
hasLoadedAll: true,
nextPage: null,
})
}
}
} else {
@@ -129,25 +146,27 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
}
} catch (e) {
if (!abortController.signal.aborted) {
console.error(e)
console.error(e) // eslint-disable-line no-console
}
}
}
setHasLoadedFirstOptions(true)
},
[addOptions, api, debouncedSearch, getEntityConfig, i18n.language, serverURL, t],
)
const loadMoreOptions = React.useCallback(() => {
if (partiallyLoadedRelationshipSlugs.current.length > 0) {
const handleScrollToBottom = React.useCallback(() => {
const relationshipToLoad = loadedRelationships.current.entries().next().value
if (relationshipToLoad[0] && !relationshipToLoad[1].hasLoadedAll) {
const abortController = new AbortController()
void loadRelationOptions({
void loadOptions({
abortController,
relationSlug: partiallyLoadedRelationshipSlugs.current[0],
relationSlug: relationshipToLoad[0],
})
}
}, [loadRelationOptions])
}, [])
const findOptionsByValue = useCallback((): Option | Option[] => {
if (value) {
@@ -206,15 +225,28 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
return undefined
}, [hasMany, hasMultipleRelations, value, options])
const handleInputChange = (input: string) => {
if (input !== search) {
dispatchOptions({ type: 'CLEAR', i18n, required: false })
const relationSlug = partiallyLoadedRelationshipSlugs.current[0]
partiallyLoadedRelationshipSlugs.current = relationSlugs
nextPageByRelationshipRef.current.set(relationSlug, 1)
setSearch(input)
}
}
const handleInputChange = useCallback(
(input: string) => {
if (input !== search) {
dispatchOptions({ type: 'CLEAR', i18n, required: false })
const relationSlugs = Array.isArray(relationTo) ? relationTo : [relationTo]
loadedRelationships.current = new Map(
relationSlugs.map((relation) => [
relation,
{
hasLoadedAll: false,
nextPage: 1,
},
]),
)
setSearch(input)
}
},
[i18n, relationTo, search],
)
const addOptionByID = useCallback(
async (id, relation) => {
@@ -239,19 +271,37 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
)
/**
* 1. Trigger initial relationship options fetch
* 2. When search changes, loadRelationOptions will
* fire off again
* When `relationTo` changes externally, reset the options and reload them from scratch
* The `loadOptions` dependency is a useEffectEvent which has no dependencies of its own
* This means we can safely depend on it without it triggering this effect to run
* This is useful because this effect should _only_ run when `relationTo` changes
*/
useEffect(() => {
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
loadedRelationships.current = new Map(
relations.map((relation) => [
relation,
{
hasLoadedAll: false,
nextPage: 1,
},
]),
)
dispatchOptions({ type: 'CLEAR', i18n, required: false })
setHasLoadedFirstOptions(false)
const abortControllers: AbortController[] = []
relations.forEach((relation) => {
const abortController = new AbortController()
void loadRelationOptions({
void loadOptions({
abortController,
relationSlug: relation,
})
abortControllers.push(abortController)
})
@@ -266,11 +316,10 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
}
})
}
}, [i18n, loadRelationOptions, relationTo])
}, [i18n, relationTo, debouncedSearch])
/**
* Load any options that were not returned
* in the first 10 of each relation fetch
* Load any other options that might exist in the value that were not loaded already
*/
useEffect(() => {
if (value && hasLoadedFirstOptions) {
@@ -327,6 +376,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
onChange(null)
return
}
if (hasMany && Array.isArray(selected)) {
onChange(
selected
@@ -352,7 +402,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
}
}}
onInputChange={handleInputChange}
onMenuScrollToBottom={loadMoreOptions}
onMenuScrollToBottom={handleScrollToBottom}
options={options}
placeholder={t('general:selectValue')}
value={valueToRender}

View File

@@ -11,6 +11,7 @@ import { formatOptions } from './formatOptions.js'
export const Select: React.FC<Props> = ({
disabled,
isClearable,
onChange,
operator,
options: optionsFromProps,
@@ -41,6 +42,7 @@ export const Select: React.FC<Props> = ({
const onSelect = React.useCallback(
(selectedOption) => {
let newValue
if (!selectedOption) {
newValue = null
} else if (isMulti) {
@@ -71,6 +73,7 @@ export const Select: React.FC<Props> = ({
return (
<ReactSelect
disabled={disabled}
isClearable={isClearable}
isMulti={isMulti}
onChange={onSelect}
options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))}

View File

@@ -4,6 +4,7 @@ import type { DefaultFilterProps } from '../types.js'
export type Props = {
readonly field: SelectFieldClient
readonly isClearable?: boolean
readonly onChange: (val: string) => void
readonly options: Option[]
readonly value: string

View File

@@ -1,48 +1,27 @@
'use client'
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import type { FieldCondition } from '../types.js'
import type { AddCondition, ReducedField, UpdateCondition } from '../types.js'
export type Props = {
readonly addCondition: ({
andIndex,
fieldName,
orIndex,
relation,
}: {
andIndex: number
fieldName: string
orIndex: number
relation: 'and' | 'or'
}) => void
readonly addCondition: AddCondition
readonly andIndex: number
readonly fieldName: string
readonly initialValue: string
readonly operator: Operator
readonly options: FieldCondition[]
readonly orIndex: number
readonly reducedFields: ReducedField[]
readonly removeCondition: ({ andIndex, orIndex }: { andIndex: number; orIndex: number }) => void
readonly RenderedFilter: React.ReactNode
readonly updateCondition: ({
andIndex,
fieldName,
operator,
orIndex,
value,
}: {
andIndex: number
fieldName: string
operator: string
orIndex: number
value: string
}) => void
readonly updateCondition: UpdateCondition
readonly value: string
}
import type { Operator } from 'payload'
import type { Operator, Option as PayloadOption } from 'payload'
import type { Option } from '../../ReactSelect/index.js'
import { useDebounce } from '../../../hooks/useDebounce.js'
import { useEffectEvent } from '../../../hooks/useEffectEvent.js'
import { useTranslation } from '../../../providers/Translation/index.js'
import { Button } from '../../Button/index.js'
import { ReactSelect } from '../../ReactSelect/index.js'
@@ -56,76 +35,82 @@ export const Condition: React.FC<Props> = (props) => {
addCondition,
andIndex,
fieldName,
initialValue,
operator,
options,
orIndex,
reducedFields,
removeCondition,
RenderedFilter,
updateCondition,
value,
} = props
const [fieldOption, setFieldOption] = useState<FieldCondition>(() =>
options.find((field) => fieldName === field.value),
)
const { t } = useTranslation()
const [internalOperatorOption, setInternalOperatorOption] = useState<Operator>(operator)
const [internalQueryValue, setInternalQueryValue] = useState<string>(initialValue)
const debouncedValue = useDebounce(internalQueryValue, 300)
const reducedField = reducedFields.find((field) => field.value === fieldName)
useEffect(() => {
if (debouncedValue === undefined) {
return
}
const [internalValue, setInternalValue] = useState<string>(value)
if (debouncedValue === null) {
removeCondition({
andIndex,
orIndex,
})
const debouncedValue = useDebounce(internalValue, 300)
return
}
const booleanSelect = ['exists'].includes(operator) || reducedField?.field?.type === 'checkbox'
if ((fieldOption?.value || typeof fieldOption?.value === 'number') && internalOperatorOption) {
updateCondition({
andIndex,
fieldName: fieldOption.value,
operator: internalOperatorOption,
orIndex,
value: debouncedValue,
})
}
}, [
debouncedValue,
andIndex,
fieldOption?.value,
internalOperatorOption,
orIndex,
updateCondition,
operator,
removeCondition,
])
const booleanSelect =
['exists'].includes(internalOperatorOption) || fieldOption?.field?.type === 'checkbox'
let valueOptions
let valueOptions: PayloadOption[] = []
if (booleanSelect) {
valueOptions = [
{ label: t('general:true'), value: 'true' },
{ label: t('general:false'), value: 'false' },
]
} else if (fieldOption?.field && 'options' in fieldOption.field) {
valueOptions = fieldOption.field.options
} else if (reducedField?.field && 'options' in reducedField.field) {
valueOptions = reducedField.field.options
}
const updateValue = useEffectEvent((debouncedValue) => {
if (operator) {
updateCondition({
andIndex,
field: reducedField,
operator,
orIndex,
value: debouncedValue === null ? '' : debouncedValue,
})
}
})
useEffect(() => {
updateValue(debouncedValue)
}, [debouncedValue])
const disabled =
(!fieldOption?.value && typeof fieldOption?.value !== 'number') ||
fieldOption?.field?.admin?.disableListFilter
(!reducedField?.value && typeof reducedField?.value !== 'number') ||
reducedField?.field?.admin?.disableListFilter
const handleFieldChange = useCallback(
(field: Option<string>) => {
setInternalValue(undefined)
updateCondition({
andIndex,
field: reducedFields.find((option) => option.value === field.value),
operator,
orIndex,
value: undefined,
})
},
[andIndex, operator, orIndex, reducedFields, updateCondition],
)
const handleOperatorChange = useCallback(
(operator: Option<Operator>) => {
updateCondition({
andIndex,
field: reducedField,
operator: operator.value,
orIndex,
value,
})
},
[andIndex, reducedField, orIndex, updateCondition, value],
)
return (
<div className={baseClass}>
@@ -135,15 +120,11 @@ export const Condition: React.FC<Props> = (props) => {
<ReactSelect
disabled={disabled}
isClearable={false}
onChange={(field: Option) => {
setFieldOption(options.find((f) => f.value === field.value))
setInternalOperatorOption(undefined)
setInternalQueryValue(undefined)
}}
options={options.filter((field) => !field.field.admin.disableListFilter)}
onChange={handleFieldChange}
options={reducedFields.filter((field) => !field.field.admin.disableListFilter)}
value={
options.find((field) => fieldOption?.value === field.value) || {
value: fieldOption?.value,
reducedField || {
value: reducedField?.value,
}
}
/>
@@ -152,15 +133,9 @@ export const Condition: React.FC<Props> = (props) => {
<ReactSelect
disabled={disabled}
isClearable={false}
onChange={(operator: Option<Operator>) => {
setInternalOperatorOption(operator.value)
}}
options={fieldOption?.operators}
value={
fieldOption?.operators.find(
(operator) => internalOperatorOption === operator.value,
) || null
}
onChange={handleOperatorChange}
options={reducedField?.operators}
value={reducedField?.operators.find((o) => operator === o.value) || null}
/>
</div>
<div className={`${baseClass}__value`}>
@@ -168,15 +143,13 @@ export const Condition: React.FC<Props> = (props) => {
<DefaultFilter
booleanSelect={booleanSelect}
disabled={
!internalOperatorOption ||
!fieldOption ||
fieldOption?.field?.admin?.disableListFilter
!operator || !reducedField || reducedField?.field?.admin?.disableListFilter
}
internalField={fieldOption}
onChange={setInternalQueryValue}
operator={internalOperatorOption}
internalField={reducedField}
onChange={setInternalValue}
operator={operator}
options={valueOptions}
value={internalQueryValue ?? ''}
value={internalValue ?? ''}
/>
)}
</div>
@@ -203,7 +176,7 @@ export const Condition: React.FC<Props> = (props) => {
onClick={() =>
addCondition({
andIndex: andIndex + 1,
fieldName: options.find((field) => !field.field.admin?.disableListFilter).value,
field: reducedFields.find((field) => !field.field.admin?.disableListFilter),
orIndex,
relation: 'and',
})

View File

@@ -1,11 +1,11 @@
import type { Operator, Where } from 'payload'
import type { Action, FieldCondition } from '../types.js'
import type { Action, ReducedField } from '../types.js'
export type Props = {
andIndex: number
dispatch: (action: Action) => void
fields: FieldCondition[]
fields: ReducedField[]
orIndex: number
value: Where
}

View File

@@ -78,7 +78,12 @@ const contains = {
value: 'contains',
}
const fieldTypeConditions = {
const fieldTypeConditions: {
[key: string]: {
component: string
operators: { label: string; value: string }[]
}
} = {
checkbox: {
component: 'Text',
operators: boolean,

View File

@@ -1,16 +1,17 @@
'use client'
import type { Operator } from 'payload'
import type { Operator, Where } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { useEffect, useState } from 'react'
import React, { useMemo } from 'react'
import type { WhereBuilderProps } from './types.js'
import type { AddCondition, UpdateCondition, WhereBuilderProps } from './types.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { Condition } from './Condition/index.js'
import { reduceClientFields } from './reduceClientFields.js'
import fieldTypes from './field-types.js'
import { reduceFields } from './reduceFields.js'
import { transformWhereQuery } from './transformWhereQuery.js'
import validateWhereQuery from './validateWhereQuery.js'
import './index.scss'
@@ -27,50 +28,19 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
const { collectionPluralLabel, fields, renderedFilters } = props
const { i18n, t } = useTranslation()
const [options, setOptions] = useState(() => reduceClientFields({ fields, i18n }))
useEffect(() => {
setOptions(reduceClientFields({ fields, i18n }))
}, [fields, i18n])
const reducedFields = useMemo(() => reduceFields({ fields, i18n }), [fields, i18n])
const { handleWhereChange, query } = useListQuery()
const [shouldUpdateQuery, setShouldUpdateQuery] = React.useState(false)
// This handles initializing the where conditions from the search query (URL). That way, if you pass in
// query params to the URL, the where conditions will be initialized from those and displayed in the UI.
// Example: /admin/collections/posts?where[or][0][and][0][text][equals]=example%20post
/*
stored conditions look like this:
[
_or_ & _and_ queries have the same shape:
{
and: [{
category: {
equals: 'category-a'
}
}]
},
{
and:[{
category: {
equals: 'category-b'
},
text: {
not_equals: 'oranges'
},
}]
}
]
*/
const [conditions, setConditions] = React.useState(() => {
const [conditions, setConditions] = React.useState<Where[]>(() => {
const whereFromSearch = query.where
if (whereFromSearch) {
if (validateWhereQuery(whereFromSearch)) {
return whereFromSearch.or
}
// Transform the where query to be in the right format. This will transform something simple like [text][equals]=example%20post to the right format
const transformedWhere = transformWhereQuery(whereFromSearch)
@@ -84,43 +54,54 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
return []
})
const addCondition = React.useCallback(
({ andIndex, fieldName, orIndex, relation }) => {
const addCondition: AddCondition = React.useCallback(
({ andIndex, field, orIndex, relation }) => {
const newConditions = [...conditions]
const defaultOperator = fieldTypes[field.field.type].operators[0].value
if (relation === 'and') {
newConditions[orIndex].and.splice(andIndex, 0, { [fieldName]: {} })
newConditions[orIndex].and.splice(andIndex, 0, {
[field.value]: {
[defaultOperator]: undefined,
},
})
} else {
newConditions.push({
and: [
{
[fieldName]: {},
[field.value]: {
[defaultOperator]: undefined,
},
},
],
})
}
setConditions(newConditions)
},
[conditions],
)
const updateCondition = React.useCallback(
({ andIndex, fieldName, operator, orIndex, value: valueArg }) => {
const updateCondition: UpdateCondition = React.useCallback(
({ andIndex, field, operator: incomingOperator, orIndex, value: valueArg }) => {
const existingRowCondition = conditions[orIndex].and[andIndex]
if (typeof existingRowCondition === 'object' && fieldName && operator) {
const value = valueArg ?? (operator ? existingRowCondition[operator] : '')
const defaults = fieldTypes[field.field.type]
const operator = incomingOperator || defaults.operators[0].value
if (typeof existingRowCondition === 'object' && field.value) {
const value = valueArg ?? existingRowCondition?.[operator]
const newRowCondition = {
[fieldName]: operator ? { [operator]: value } : {},
[field.value]: { [operator]: value },
}
if (JSON.stringify(existingRowCondition) !== JSON.stringify(newRowCondition)) {
const newConditions = [...conditions]
newConditions[orIndex].and[andIndex] = newRowCondition
setConditions(newConditions)
if (![null, undefined].includes(value)) {
// only update query when field/operator/value are filled out
setShouldUpdateQuery(true)
}
}
const newConditions = [...conditions]
newConditions[orIndex].and[andIndex] = newRowCondition
setConditions(newConditions)
setShouldUpdateQuery(true)
}
},
[conditions],
@@ -130,9 +111,11 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
({ andIndex, orIndex }) => {
const newConditions = [...conditions]
newConditions[orIndex].and.splice(andIndex, 1)
if (newConditions[orIndex].and.length === 0) {
newConditions.splice(orIndex, 1)
}
setConditions(newConditions)
setShouldUpdateQuery(true)
},
@@ -166,15 +149,13 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
<ul className={`${baseClass}__and-filters`}>
{Array.isArray(or?.and) &&
or.and.map((_, andIndex) => {
const initialFieldName = Object.keys(conditions[orIndex].and[andIndex])[0]
const initialOperator =
(Object.keys(
conditions[orIndex].and[andIndex]?.[initialFieldName] || {},
)?.[0] as Operator) || undefined
const initialValue =
conditions[orIndex].and[andIndex]?.[initialFieldName]?.[
initialOperator
] || undefined
const condition = conditions[orIndex].and[andIndex]
const fieldName = Object.keys(condition)[0]
const operator =
(Object.keys(condition?.[fieldName] || {})?.[0] as Operator) || undefined
const value = condition?.[fieldName]?.[operator] || undefined
return (
<li key={andIndex}>
@@ -184,14 +165,14 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
<Condition
addCondition={addCondition}
andIndex={andIndex}
fieldName={initialFieldName}
initialValue={initialValue}
operator={initialOperator}
options={options}
fieldName={fieldName}
operator={operator}
orIndex={orIndex}
reducedFields={reducedFields}
removeCondition={removeCondition}
RenderedFilter={renderedFilters?.get(initialFieldName)}
RenderedFilter={renderedFilters?.get(fieldName)}
updateCondition={updateCondition}
value={value}
/>
</li>
)
@@ -210,7 +191,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
onClick={() => {
addCondition({
andIndex: 0,
fieldName: options[0].value,
field: reducedFields[0],
orIndex: conditions.length,
relation: 'or',
})
@@ -230,10 +211,10 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
iconPosition="left"
iconStyle="with-border"
onClick={() => {
if (options.length > 0) {
if (reducedFields.length > 0) {
addCondition({
andIndex: 0,
fieldName: options.find((field) => !field.field.admin?.disableListFilter).value,
field: reducedFields.find((field) => !field.field.admin?.disableListFilter),
orIndex: conditions.length,
relation: 'or',
})

View File

@@ -5,13 +5,13 @@ import type { ClientField } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared'
import type { FieldCondition } from './types.js'
import type { ReducedField } from './types.js'
import { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.js'
import { combineLabel } from '../FieldSelect/index.js'
import fieldTypes from './field-types.js'
export type ReduceClientFieldsArgs = {
type ReduceFieldOptionsArgs = {
fields: ClientField[]
i18n: I18nClient
labelPrefix?: string
@@ -22,12 +22,12 @@ export type ReduceClientFieldsArgs = {
* Reduces a field map to a flat array of fields with labels and values.
* Used in the WhereBuilder component to render the fields in the dropdown.
*/
export const reduceClientFields = ({
export const reduceFields = ({
fields,
i18n,
labelPrefix,
pathPrefix,
}: ReduceClientFieldsArgs): FieldCondition[] => {
}: ReduceFieldOptionsArgs): ReducedField[] => {
return fields.reduce((reduced, field) => {
// Do not filter out `field.admin.disableListFilter` fields here, as these should still render as disabled if they appear in the URL query
if (fieldIsHiddenOrDisabled(field) && !fieldIsID(field)) {
@@ -55,7 +55,7 @@ export const reduceClientFields = ({
if (typeof localizedTabLabel === 'string') {
reduced.push(
...reduceClientFields({
...reduceFields({
fields: tab.fields,
i18n,
labelPrefix: labelWithPrefix,
@@ -71,7 +71,7 @@ export const reduceClientFields = ({
// Rows cant have labels, so we need to handle them differently
if (field.type === 'row' && 'fields' in field) {
reduced.push(
...reduceClientFields({
...reduceFields({
fields: field.fields,
i18n,
labelPrefix,
@@ -89,7 +89,7 @@ export const reduceClientFields = ({
: localizedTabLabel
reduced.push(
...reduceClientFields({
...reduceFields({
fields: field.fields,
i18n,
labelPrefix: labelWithPrefix,
@@ -116,18 +116,20 @@ export const reduceClientFields = ({
: pathPrefix
reduced.push(
...reduceClientFields({
...reduceFields({
fields: field.fields,
i18n,
labelPrefix: labelWithPrefix,
pathPrefix: pathWithPrefix,
}),
)
return reduced
}
if (typeof fieldTypes[field.type] === 'object') {
const operatorKeys = new Set()
const operators = fieldTypes[field.type].operators.reduce((acc, operator) => {
if (!operatorKeys.has(operator.value)) {
operatorKeys.add(operator.value)
@@ -137,6 +139,7 @@ export const reduceClientFields = ({
label: i18n.t(operatorKey),
})
}
return acc
}, [])
@@ -149,13 +152,11 @@ export const reduceClientFields = ({
})
: localizedLabel
const formattedValue = pathPrefix
? createNestedClientFieldPath(pathPrefix, field)
: field.name
const fieldPath = pathPrefix ? createNestedClientFieldPath(pathPrefix, field) : field.name
const formattedField: FieldCondition = {
const formattedField: ReducedField = {
label: formattedLabel,
value: formattedValue,
value: fieldPath,
...fieldTypes[field.type],
field,
operators,

View File

@@ -7,9 +7,9 @@ export type WhereBuilderProps = {
readonly renderedFilters?: Map<string, React.ReactNode>
}
export type FieldCondition = {
export type ReducedField = {
field: ClientField
label: string
label: React.ReactNode
operators: {
label: string
value: Operator
@@ -47,3 +47,29 @@ export type Action = ADD | REMOVE | UPDATE
export type State = {
or: Where[]
}
export type AddCondition = ({
andIndex,
field,
orIndex,
relation,
}: {
andIndex: number
field: ReducedField
orIndex: number
relation: 'and' | 'or'
}) => void
export type UpdateCondition = ({
andIndex,
field,
operator,
orIndex,
value,
}: {
andIndex: number
field: ReducedField
operator: string
orIndex: number
value: string
}) => void

View File

@@ -18,7 +18,9 @@ const validateWhereQuery = (whereQuery): whereQuery is Where => {
if (typeof andQuery !== 'object') {
return false
}
const andKeys = Object.keys(andQuery)
// If there are no keys, it's not a valid WhereField.
if (andKeys.length === 0) {
return false

View File

@@ -183,6 +183,14 @@ export const Posts: CollectionConfig = {
},
relationTo: 'posts',
},
{
name: 'users',
type: 'relationship',
admin: {
position: 'sidebar',
},
relationTo: 'users',
},
{
name: 'customCell',
type: 'text',

View File

@@ -16,6 +16,7 @@ import { CollectionGroup2B } from './collections/Group2B.js'
import { CollectionHidden } from './collections/Hidden.js'
import { CollectionNoApiView } from './collections/NoApiView.js'
import { CollectionNotInView } from './collections/NotInView.js'
import { Orders } from './collections/Orders.js'
import { Posts } from './collections/Posts.js'
import { UploadCollection } from './collections/Upload.js'
import { Users } from './collections/Users.js'

View File

@@ -30,6 +30,7 @@ const description = 'Description'
let payload: PayloadTestSDK<Config>
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js'
import { openListColumns } from 'helpers/e2e/openListColumns.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
@@ -43,7 +44,6 @@ import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
import { reorderColumns } from '../../../helpers/e2e/reorderColumns.js'
import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -112,7 +112,7 @@ describe('List View', () => {
test('should render static collection descriptions', async () => {
await page.goto(postsUrl.list)
await expect(
page.locator('.view-description', {
page.locator('.custom-view-description', {
hasText: exactText('This is a custom collection description.'),
}),
).toBeVisible()
@@ -121,7 +121,7 @@ describe('List View', () => {
test('should render dynamic collection description components', async () => {
await page.goto(customViewsUrl.list)
await expect(
page.locator('.view-description', {
page.locator('.custom-view-description', {
hasText: exactText('This is a custom view description component.'),
}),
).toBeVisible()
@@ -136,7 +136,7 @@ describe('List View', () => {
await expect(linkCell).toHaveAttribute(
'href',
`${adminRoutes.routes.admin}/collections/posts/${id}`,
`${adminRoutes.routes?.admin}/collections/posts/${id}`,
)
await page.locator('.list-controls__toggle-columns').click()
@@ -153,7 +153,7 @@ describe('List View', () => {
await expect(linkCell).toHaveAttribute(
'href',
`${adminRoutes.routes.admin}/collections/posts/${id}`,
`${adminRoutes.routes?.admin}/collections/posts/${id}`,
)
})
})
@@ -285,21 +285,22 @@ describe('List View', () => {
describe('filters', () => {
test('should not close where builder when clearing final condition', async () => {
await page.goto(postsUrl.list)
await openListFilters(page, {})
await page.locator('.where-builder__add-first-filter').click()
await page.locator('.condition__field').click()
await page.locator('.rs__option', { hasText: exactText('Relationship') }).click()
await page.locator('.condition__operator').click()
await page.locator('.rs__option', { hasText: exactText('equals') }).click()
const valueInput = await page.locator('.condition__value')
await valueInput.click()
await valueInput.locator('.rs__option').first().click()
await page.waitForURL(/&where/)
await addListFilter({
page,
fieldLabel: 'Relationship',
operatorLabel: 'equals',
value: 'post1',
})
const encodedQueryString =
'&' + encodeURIComponent('where[or][0][and][0][relationship][equals]') + '='
await page.waitForURL(new RegExp(encodedQueryString + '[^&]*'))
await page.locator('.condition__actions .btn.condition__actions-remove').click()
await page.waitForURL(/^(?!.*&where)/)
await page.waitForURL(new RegExp(encodedQueryString))
const whereBuilder = page.locator('.list-controls__where.rah-static.rah-static--height-auto')
await expect(whereBuilder).toBeVisible()
@@ -348,12 +349,11 @@ describe('List View', () => {
const firstId = page.locator(tableRowLocator).first().locator('.cell-id')
await expect(firstId).toHaveText(`ID: ${id}`)
// Remove filter
await page.locator('.condition__actions-remove').click()
await expect(page.locator(tableRowLocator)).toHaveCount(2)
})
test('should reset filter value and operator on field update', async () => {
test('should reset filter value when a different field is selected', async () => {
const id = (await page.locator('.cell-id').first().innerText()).replace('ID: ', '')
await addListFilter({
@@ -373,9 +373,6 @@ describe('List View', () => {
await dropdownFieldOption.click()
await expect(filterField).toContainText('Status')
// expect operator & value field to reset (be empty)
const operatorField = page.locator('.condition__operator')
await expect(operatorField.locator('.rs__placeholder')).toContainText('Select a value')
await expect(page.locator('.condition__value input')).toHaveValue('')
})
@@ -389,11 +386,55 @@ describe('List View', () => {
value: 'post1',
})
await page.waitForURL(/&where/)
const encodedQueryString =
'&' + encodeURIComponent('where[or][0][and][0][relationship][equals]') + '='
page.locator('.condition__value').locator('.clear-indicator').click()
await page.waitForURL(new RegExp(encodedQueryString + '[^&]*'))
await page.waitForURL(/^(?!.*&where)/)
await page.locator('.condition__value .clear-indicator').click()
await page.waitForURL(new RegExp(encodedQueryString))
})
test.skip('should remove condition from URL when a different field is selected', async () => {
// TODO: fix this bug and write this test
})
test('should refresh relationship values when a different field is selected', async () => {
await page.goto(postsUrl.list)
await addListFilter({
page,
fieldLabel: 'Relationship',
operatorLabel: 'equals',
value: 'post1',
})
const whereBuilder = page.locator('.where-builder')
const conditionField = whereBuilder.locator('.condition__field')
await conditionField.click()
await conditionField
.locator('.rs__option', {
hasText: exactText('Users'),
})
?.click()
await expect(whereBuilder.locator('.condition__field')).toContainText('Users')
const operatorInput = whereBuilder.locator('.condition__operator')
await operatorInput.click()
const operatorOptions = operatorInput.locator('.rs__option')
await operatorOptions.locator(`text=equals`).click()
await whereBuilder.locator('.condition__value').click()
const valueOptions = await whereBuilder
.locator('.condition__value .rs__option')
.evaluateAll((options) => options.map((option) => option.textContent))
expect(valueOptions).not.toContain('post1')
})
test('should accept where query from valid URL where parameter', async () => {
@@ -521,27 +562,16 @@ describe('List View', () => {
await expect(page.locator('.collection-list__page-info')).toHaveText('1-3 of 3')
})
test('should reset filter values for every additional filters', async () => {
test('should reset filter values for every additional filter', async () => {
await page.goto(postsUrl.list)
await openListFilters(page, {})
await page.locator('.where-builder__add-first-filter').click()
const firstConditionField = page.locator('.condition__field')
const firstOperatorField = page.locator('.condition__operator')
const firstValueField = page.locator('.condition__value >> input')
await addListFilter({
page,
fieldLabel: 'Tab 1 > Title',
operatorLabel: 'equals',
value: 'Test',
})
await firstConditionField.click()
await firstConditionField
.locator('.rs__option', {
hasText: exactText('Tab 1 > Title'),
})
.click()
await expect(firstConditionField.locator('.rs__single-value')).toContainText('Tab 1 > Title')
await firstOperatorField.click()
await firstOperatorField.locator('.rs__option').locator('text=equals').click()
await firstValueField.fill('Test')
await expect(firstValueField).toHaveValue('Test')
await page.locator('.condition__actions-add').click()
const secondLi = page.locator('.where-builder__and-filters li:nth-child(2)')
await expect(secondLi).toBeVisible()
@@ -564,7 +594,8 @@ describe('List View', () => {
skipValueInput: true,
})
const valueInput = page.locator('.condition__value >> input')
const whereBuilder = page.locator('.where-builder')
const valueInput = whereBuilder.locator('.condition__value >> input')
// Type into the input field instead of filling it
await valueInput.click()
@@ -672,30 +703,44 @@ describe('List View', () => {
})
test('should properly paginate many documents', async () => {
await page.goto(
`${with300DocumentsUrl.list}?limit=10&page=1&where%5Bor%5D%5B0%5D%5Band%5D%5B0%5D%5BselfRelation%5D%5Bequals%5D=null`,
)
const valueField = page.locator('.condition__value')
await page.goto(with300DocumentsUrl.list)
await addListFilter({
page,
fieldLabel: 'Self Relation',
operatorLabel: 'equals',
skipValueInput: true,
})
const whereBuilder = page.locator('.where-builder')
const valueField = whereBuilder.locator('.condition__value')
await valueField.click()
await page.keyboard.type('4')
const options = page.getByRole('option')
expect(options).toHaveCount(10)
const options = whereBuilder.locator('.condition__value .rs__option')
await expect(options).toHaveCount(10)
for (const option of await options.all()) {
expect(option).toHaveText('4')
expect(await option.innerText()).toContain('4')
}
await page.keyboard.press('Backspace')
await page.keyboard.type('5')
expect(options).toHaveCount(10)
await expect(options).toHaveCount(10)
for (const option of await options.all()) {
expect(option).toHaveText('5')
expect(await option.innerText()).toContain('5')
}
// await options.last().scrollIntoViewIfNeeded()
await options.first().hover()
// three times because react-select is not very reliable
await page.mouse.wheel(0, 50)
await page.mouse.wheel(0, 50)
await page.mouse.wheel(0, 50)
expect(options).toHaveCount(20)
await expect(options).toHaveCount(20)
})
})
@@ -991,7 +1036,7 @@ describe('List View', () => {
await page.goto(postsUrl.list)
await page.locator('.per-page .popup-button').click()
await page.locator('.per-page .popup-button').click()
const options = await page.locator('.per-page button.per-page__button')
const options = page.locator('.per-page button.per-page__button')
await expect(options).toHaveCount(3)
await expect(options.nth(0)).toContainText('5')
await expect(options.nth(1)).toContainText('10')

View File

@@ -178,6 +178,7 @@ export interface Post {
| null;
defaultValueField?: string | null;
relationship?: (string | null) | Post;
users?: (string | null) | User;
customCell?: string | null;
upload?: (string | null) | Upload;
hiddenField?: string | null;
@@ -276,9 +277,6 @@ export interface CustomField {
* Static field description.
*/
descriptionAsString?: string | null;
/**
* Function description
*/
descriptionAsFunction?: string | null;
descriptionAsComponent?: string | null;
customSelectField?: string | null;
@@ -580,6 +578,7 @@ export interface PostsSelect<T extends boolean = true> {
};
defaultValueField?: T;
relationship?: T;
users?: T;
customCell?: T;
upload?: T;
hiddenField?: T;

View File

@@ -1,8 +1,8 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -43,7 +43,6 @@ import {
slug,
versionedRelationshipFieldSlug,
} from './slugs.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -491,25 +490,13 @@ describe('Relationship Field', () => {
await page.goto(versionedRelationshipFieldURL.list)
await page.locator('.list-controls__toggle-columns').click()
await openListFilters(page, {})
await page.locator('.where-builder__add-first-filter').click()
const conditionField = page.locator('.condition__field')
await conditionField.click()
const dropdownFieldOptions = conditionField.locator('.rs__option')
await dropdownFieldOptions.locator('text=Relationship Field').nth(0).click()
const operatorField = page.locator('.condition__operator')
await operatorField.click()
const dropdownOperatorOptions = operatorField.locator('.rs__option')
await dropdownOperatorOptions.locator('text=exists').click()
const valueField = page.locator('.condition__value')
await valueField.click()
const dropdownValueOptions = valueField.locator('.rs__option')
await dropdownValueOptions.locator('text=True').click()
await addListFilter({
page,
fieldLabel: 'Relationship Field',
operatorLabel: 'exists',
value: 'True',
})
await expect(page.locator(tableRowLocator)).toHaveCount(1)
})

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
@@ -12,8 +13,6 @@ import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { checkboxFieldsSlug } from '../../slugs.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -68,8 +67,6 @@ describe('Checkboxes', () => {
value: 'True',
})
await wait(1000)
await expect(page.locator('table > tbody > tr')).toHaveCount(1)
})
})

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import path from 'path'
import { wait } from 'payload/shared'
@@ -17,7 +18,6 @@ import { RESTClient } from '../../../helpers/rest.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { emailFieldsSlug } from '../../slugs.js'
import { emailDoc } from './shared.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
@@ -126,99 +126,4 @@ describe('Email', () => {
const nextSiblingText = await page.evaluate((el) => el.textContent, nextSibling)
expect(nextSiblingText).toEqual('#after-input')
})
test('should reset filter conditions when adding additional filters', async () => {
await page.goto(url.list)
await addListFilter({
page,
fieldLabel: 'Text en',
operatorLabel: 'equals',
value: 'hello',
})
// open the second filter options
await page.locator('.condition__actions-add').click()
const secondLi = page.locator('.where-builder__and-filters li:nth-child(2)')
await expect(secondLi).toBeVisible()
const secondInitialField = secondLi.locator('.condition__field')
const secondOperatorField = secondLi.locator('.condition__operator >> input')
const secondValueField = secondLi.locator('.condition__value >> input')
await expect(secondInitialField.locator('.rs__single-value')).toContainText('Email')
await expect(secondOperatorField).toHaveValue('')
await expect(secondValueField).toHaveValue('')
})
test('should not re-render page upon typing in a value in the filter value field', async () => {
await page.goto(url.list)
await addListFilter({
page,
fieldLabel: 'Text en',
operatorLabel: 'equals',
skipValueInput: true,
})
// Type into the input field instead of filling it
const firstValueField = page.locator('.condition__value >> input')
await firstValueField.click()
await firstValueField.type('hello', { delay: 100 }) // Add a delay to simulate typing speed
// Wait for a short period to see if the input loses focus
await page.waitForTimeout(500)
// Check if the input still has the correct value
await expect(firstValueField).toHaveValue('hello')
})
test('should still show second filter if two filters exist and first filter is removed', async () => {
await page.goto(url.list)
await addListFilter({
page,
fieldLabel: 'Text en',
operatorLabel: 'equals',
value: 'hello',
})
// open the second filter options
await page.locator('.condition__actions-add').click()
const secondLi = page.locator('.where-builder__and-filters li:nth-child(2)')
await expect(secondLi).toBeVisible()
const secondInitialField = secondLi.locator('.condition__field')
const secondOperatorField = secondLi.locator('.condition__operator')
const secondValueField = secondLi.locator('.condition__value >> input')
await secondInitialField.click()
const secondInitialFieldOptions = secondInitialField.locator('.rs__option')
await secondInitialFieldOptions.locator('text=text').first().click()
await expect(secondInitialField.locator('.rs__single-value')).toContainText('Text')
await secondOperatorField.click()
await secondOperatorField.locator('.rs__option').locator('text=equals').click()
await secondValueField.fill('world')
await expect(secondValueField).toHaveValue('world')
await wait(500)
const firstLi = page.locator('.where-builder__and-filters li:nth-child(1)')
const removeButton = firstLi.locator('.condition__actions-remove')
// remove first filter
await removeButton.click()
const filterListItems = page.locator('.where-builder__and-filters li')
await expect(filterListItems).toHaveCount(1)
const firstValueField = page.locator('.condition__value >> input')
await expect(firstValueField).toHaveValue('world')
})
})

View File

@@ -1,6 +1,7 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
@@ -26,7 +27,6 @@ import { reInitializeDB } from '../../../helpers/reInitializeDB.js'
import { RESTClient } from '../../../helpers/rest.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { relationshipFieldsSlug, textFieldsSlug } from '../../slugs.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')

View File

@@ -2,7 +2,6 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from 'helpers.js'
import { wait } from 'payload/shared'
import { openListFilters } from './openListFilters.js'
@@ -16,6 +15,7 @@ export const addListFilter = async ({
fieldLabel: string
operatorLabel: string
page: Page
replaceExisting?: boolean
skipValueInput?: boolean
value?: string
}) => {
@@ -28,24 +28,26 @@ export const addListFilter = async ({
const conditionField = whereBuilder.locator('.condition__field')
await conditionField.click()
const conditionOptions = conditionField.locator('.rs__option', {
hasText: exactText(fieldLabel),
})
await conditionField
.locator('.rs__option', {
hasText: exactText(fieldLabel),
})
?.click()
await conditionOptions.click()
await expect(whereBuilder.locator('.condition__field')).toContainText(fieldLabel)
const operatorInput = whereBuilder.locator('.condition__operator')
await operatorInput.click()
const operatorOptions = operatorInput.locator('.rs__option')
await operatorOptions.locator(`text=${operatorLabel}`).click()
if (!skipValueInput) {
const valueInput = whereBuilder.locator('.condition__value >> input')
await valueInput.fill(value)
await wait(100)
await expect(valueInput).toHaveValue(value)
const valueOptions = whereBuilder.locator('.condition__value').locator('.rs__option')
const valueOptions = whereBuilder.locator('.condition__value .rs__option')
if ((await whereBuilder.locator('.condition__value >> input.rs__input').count()) > 0) {
await valueOptions.locator(`text=${value}`).click()
}