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:
@@ -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>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
@import '../../scss/styles.scss';
|
||||
|
||||
@layer payload-default {
|
||||
.view-description {
|
||||
margin-block-end: calc(var(--base));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 loadedRelationships = React.useRef<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
hasLoadedAll: boolean
|
||||
nextPage: number
|
||||
}
|
||||
|
||||
const nextPageByRelationshipRef = React.useRef<Map<string, number>>(initialRelationMap())
|
||||
const partiallyLoadedRelationshipSlugs = React.useRef<string[]>(relationSlugs)
|
||||
>
|
||||
>(
|
||||
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) => {
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => {
|
||||
if (input !== search) {
|
||||
dispatchOptions({ type: 'CLEAR', i18n, required: false })
|
||||
const relationSlug = partiallyLoadedRelationshipSlugs.current[0]
|
||||
partiallyLoadedRelationshipSlugs.current = relationSlugs
|
||||
nextPageByRelationshipRef.current.set(relationSlug, 1)
|
||||
|
||||
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}
|
||||
|
||||
@@ -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) }))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,44 +54,55 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[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',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -183,6 +183,14 @@ export const Posts: CollectionConfig = {
|
||||
},
|
||||
relationTo: 'posts',
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
relationTo: 'users',
|
||||
},
|
||||
{
|
||||
name: 'customCell',
|
||||
type: 'text',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 firstConditionField.click()
|
||||
await firstConditionField
|
||||
.locator('.rs__option', {
|
||||
hasText: exactText('Tab 1 > Title'),
|
||||
await addListFilter({
|
||||
page,
|
||||
fieldLabel: 'Tab 1 > Title',
|
||||
operatorLabel: 'equals',
|
||||
value: 'Test',
|
||||
})
|
||||
.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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, '../../')
|
||||
|
||||
@@ -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', {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user