Skip to content

Commit da6511e

Browse files
authored
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.
1 parent 1f3ccb8 commit da6511e

File tree

23 files changed

+414
-436
lines changed

23 files changed

+414
-436
lines changed

packages/ui/src/elements/ListControls/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,6 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
213213
collectionPluralLabel={collectionConfig?.labels?.plural}
214214
collectionSlug={collectionConfig.slug}
215215
fields={collectionConfig?.fields}
216-
key={String(hasWhereParam.current && !query?.where)}
217216
renderedFilters={renderedFilters}
218217
/>
219218
</AnimateHeight>

packages/ui/src/elements/ViewDescription/index.scss

Lines changed: 0 additions & 7 deletions
This file was deleted.

packages/ui/src/elements/ViewDescription/index.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { getTranslation } from '@payloadcms/translations'
55
import React from 'react'
66

77
import { useTranslation } from '../../providers/Translation/index.js'
8-
import './index.scss'
98

109
export type ViewDescriptionComponent = React.ComponentType<any>
1110

@@ -24,7 +23,7 @@ export const ViewDescription: React.FC<ViewDescriptionProps> = (props) => {
2423
const { description } = props
2524

2625
if (description) {
27-
return <div className="view-description">{getTranslation(description, i18n)}</div>
26+
return <div className="custom-view-description">{getTranslation(description, i18n)}</div>
2827
}
2928

3029
return null

packages/ui/src/elements/WhereBuilder/Condition/DefaultFilter/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Operator, Option, SelectFieldClient, TextFieldClient } from 'paylo
22

33
import React from 'react'
44

5-
import type { FieldCondition } from '../../types.js'
5+
import type { ReducedField } from '../../types.js'
66

77
import { DateFilter } from '../Date/index.js'
88
import { NumberFilter } from '../Number/index.js'
@@ -13,7 +13,7 @@ import { Text } from '../Text/index.js'
1313
type Props = {
1414
booleanSelect: boolean
1515
disabled: boolean
16-
internalField: FieldCondition
16+
internalField: ReducedField
1717
onChange: React.Dispatch<React.SetStateAction<string>>
1818
operator: Operator
1919
options: Option[]
@@ -34,6 +34,7 @@ export const DefaultFilter: React.FC<Props> = ({
3434
<Select
3535
disabled={disabled}
3636
field={internalField.field as SelectFieldClient}
37+
isClearable={!booleanSelect}
3738
onChange={onChange}
3839
operator={operator}
3940
options={options}

packages/ui/src/elements/WhereBuilder/Condition/Relationship/index.tsx

Lines changed: 95 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import type { Option } from '../../../ReactSelect/types.js'
88
import type { Props, ValueWithRelation } from './types.js'
99

1010
import { useDebounce } from '../../../../hooks/useDebounce.js'
11+
import { useEffectEvent } from '../../../../hooks/useEffectEvent.js'
1112
import { useConfig } from '../../../../providers/Config/index.js'
1213
import { useTranslation } from '../../../../providers/Translation/index.js'
1314
import { ReactSelect } from '../../../ReactSelect/index.js'
14-
import './index.scss'
1515
import optionsReducer from './optionsReducer.js'
16+
import './index.scss'
1617

1718
const baseClass = 'condition-value-relationship'
1819

@@ -37,22 +38,32 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
3738
const hasMultipleRelations = Array.isArray(relationTo)
3839
const [options, dispatchOptions] = useReducer(optionsReducer, [])
3940
const [search, setSearch] = useState('')
41+
const debouncedSearch = useDebounce(search, 300)
4042
const [errorLoading, setErrorLoading] = useState('')
4143
const [hasLoadedFirstOptions, setHasLoadedFirstOptions] = useState(false)
42-
const debouncedSearch = useDebounce(search, 300)
4344
const { i18n, t } = useTranslation()
44-
const relationSlugs = hasMultipleRelations ? relationTo : [relationTo]
4545

46-
const initialRelationMap = () => {
47-
const map: Map<string, number> = new Map()
48-
relationSlugs.forEach((relation) => {
49-
map.set(relation, 1)
50-
})
51-
return map
52-
}
46+
const relationSlugs = hasMultipleRelations ? relationTo : [relationTo]
5347

54-
const nextPageByRelationshipRef = React.useRef<Map<string, number>>(initialRelationMap())
55-
const partiallyLoadedRelationshipSlugs = React.useRef<string[]>(relationSlugs)
48+
const loadedRelationships = React.useRef<
49+
Map<
50+
string,
51+
{
52+
hasLoadedAll: boolean
53+
nextPage: number
54+
}
55+
>
56+
>(
57+
new Map(
58+
relationSlugs.map((relation) => [
59+
relation,
60+
{
61+
hasLoadedAll: false,
62+
nextPage: 1,
63+
},
64+
]),
65+
),
66+
)
5667

5768
const addOptions = useCallback(
5869
(data, relation) => {
@@ -62,28 +73,31 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
6273
[hasMultipleRelations, i18n, getEntityConfig],
6374
)
6475

65-
const loadRelationOptions = React.useCallback(
76+
const loadOptions = useEffectEvent(
6677
async ({
6778
abortController,
6879
relationSlug,
6980
}: {
7081
abortController: AbortController
7182
relationSlug: string
7283
}) => {
73-
if (relationSlug && partiallyLoadedRelationshipSlugs.current.includes(relationSlug)) {
84+
const loadedRelationship = loadedRelationships.current.get(relationSlug)
85+
86+
if (relationSlug && !loadedRelationship.hasLoadedAll) {
7487
const collection = getEntityConfig({
7588
collectionSlug: relationSlug,
7689
})
90+
7791
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
78-
const pageIndex = nextPageByRelationshipRef.current.get(relationSlug)
7992

8093
const where: Where = {
8194
and: [],
8295
}
96+
8397
const query = {
8498
depth: 0,
8599
limit: maxResultsPerRequest,
86-
page: pageIndex,
100+
page: loadedRelationship.nextPage,
87101
select: {
88102
[fieldToSearch]: true,
89103
},
@@ -116,38 +130,43 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
116130
addOptions(data, relationSlug)
117131

118132
if (data.nextPage) {
119-
nextPageByRelationshipRef.current.set(relationSlug, data.nextPage)
133+
loadedRelationships.current.set(relationSlug, {
134+
hasLoadedAll: false,
135+
nextPage: data.nextPage,
136+
})
120137
} else {
121-
partiallyLoadedRelationshipSlugs.current =
122-
partiallyLoadedRelationshipSlugs.current.filter(
123-
(partiallyLoadedRelation) => partiallyLoadedRelation !== relationSlug,
124-
)
138+
loadedRelationships.current.set(relationSlug, {
139+
hasLoadedAll: true,
140+
nextPage: null,
141+
})
125142
}
126143
}
127144
} else {
128145
setErrorLoading(t('error:unspecific'))
129146
}
130147
} catch (e) {
131148
if (!abortController.signal.aborted) {
132-
console.error(e)
149+
console.error(e) // eslint-disable-line no-console
133150
}
134151
}
135152
}
136153

137154
setHasLoadedFirstOptions(true)
138155
},
139-
[addOptions, api, debouncedSearch, getEntityConfig, i18n.language, serverURL, t],
140156
)
141157

142-
const loadMoreOptions = React.useCallback(() => {
143-
if (partiallyLoadedRelationshipSlugs.current.length > 0) {
158+
const handleScrollToBottom = React.useCallback(() => {
159+
const relationshipToLoad = loadedRelationships.current.entries().next().value
160+
161+
if (relationshipToLoad[0] && !relationshipToLoad[1].hasLoadedAll) {
144162
const abortController = new AbortController()
145-
void loadRelationOptions({
163+
164+
void loadOptions({
146165
abortController,
147-
relationSlug: partiallyLoadedRelationshipSlugs.current[0],
166+
relationSlug: relationshipToLoad[0],
148167
})
149168
}
150-
}, [loadRelationOptions])
169+
}, [])
151170

152171
const findOptionsByValue = useCallback((): Option | Option[] => {
153172
if (value) {
@@ -206,15 +225,28 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
206225
return undefined
207226
}, [hasMany, hasMultipleRelations, value, options])
208227

209-
const handleInputChange = (input: string) => {
210-
if (input !== search) {
211-
dispatchOptions({ type: 'CLEAR', i18n, required: false })
212-
const relationSlug = partiallyLoadedRelationshipSlugs.current[0]
213-
partiallyLoadedRelationshipSlugs.current = relationSlugs
214-
nextPageByRelationshipRef.current.set(relationSlug, 1)
215-
setSearch(input)
216-
}
217-
}
228+
const handleInputChange = useCallback(
229+
(input: string) => {
230+
if (input !== search) {
231+
dispatchOptions({ type: 'CLEAR', i18n, required: false })
232+
233+
const relationSlugs = Array.isArray(relationTo) ? relationTo : [relationTo]
234+
235+
loadedRelationships.current = new Map(
236+
relationSlugs.map((relation) => [
237+
relation,
238+
{
239+
hasLoadedAll: false,
240+
nextPage: 1,
241+
},
242+
]),
243+
)
244+
245+
setSearch(input)
246+
}
247+
},
248+
[i18n, relationTo, search],
249+
)
218250

219251
const addOptionByID = useCallback(
220252
async (id, relation) => {
@@ -239,19 +271,37 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
239271
)
240272

241273
/**
242-
* 1. Trigger initial relationship options fetch
243-
* 2. When search changes, loadRelationOptions will
244-
* fire off again
274+
* When `relationTo` changes externally, reset the options and reload them from scratch
275+
* The `loadOptions` dependency is a useEffectEvent which has no dependencies of its own
276+
* This means we can safely depend on it without it triggering this effect to run
277+
* This is useful because this effect should _only_ run when `relationTo` changes
245278
*/
246279
useEffect(() => {
247280
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
281+
282+
loadedRelationships.current = new Map(
283+
relations.map((relation) => [
284+
relation,
285+
{
286+
hasLoadedAll: false,
287+
nextPage: 1,
288+
},
289+
]),
290+
)
291+
292+
dispatchOptions({ type: 'CLEAR', i18n, required: false })
293+
setHasLoadedFirstOptions(false)
294+
248295
const abortControllers: AbortController[] = []
296+
249297
relations.forEach((relation) => {
250298
const abortController = new AbortController()
251-
void loadRelationOptions({
299+
300+
void loadOptions({
252301
abortController,
253302
relationSlug: relation,
254303
})
304+
255305
abortControllers.push(abortController)
256306
})
257307

@@ -266,11 +316,10 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
266316
}
267317
})
268318
}
269-
}, [i18n, loadRelationOptions, relationTo])
319+
}, [i18n, relationTo, debouncedSearch])
270320

271321
/**
272-
* Load any options that were not returned
273-
* in the first 10 of each relation fetch
322+
* Load any other options that might exist in the value that were not loaded already
274323
*/
275324
useEffect(() => {
276325
if (value && hasLoadedFirstOptions) {
@@ -327,6 +376,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
327376
onChange(null)
328377
return
329378
}
379+
330380
if (hasMany && Array.isArray(selected)) {
331381
onChange(
332382
selected
@@ -352,7 +402,7 @@ export const RelationshipFilter: React.FC<Props> = (props) => {
352402
}
353403
}}
354404
onInputChange={handleInputChange}
355-
onMenuScrollToBottom={loadMoreOptions}
405+
onMenuScrollToBottom={handleScrollToBottom}
356406
options={options}
357407
placeholder={t('general:selectValue')}
358408
value={valueToRender}

packages/ui/src/elements/WhereBuilder/Condition/Select/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { formatOptions } from './formatOptions.js'
1111

1212
export const Select: React.FC<Props> = ({
1313
disabled,
14+
isClearable,
1415
onChange,
1516
operator,
1617
options: optionsFromProps,
@@ -41,6 +42,7 @@ export const Select: React.FC<Props> = ({
4142
const onSelect = React.useCallback(
4243
(selectedOption) => {
4344
let newValue
45+
4446
if (!selectedOption) {
4547
newValue = null
4648
} else if (isMulti) {
@@ -71,6 +73,7 @@ export const Select: React.FC<Props> = ({
7173
return (
7274
<ReactSelect
7375
disabled={disabled}
76+
isClearable={isClearable}
7477
isMulti={isMulti}
7578
onChange={onSelect}
7679
options={options.map((option) => ({ ...option, label: getTranslation(option.label, i18n) }))}

packages/ui/src/elements/WhereBuilder/Condition/Select/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { DefaultFilterProps } from '../types.js'
44

55
export type Props = {
66
readonly field: SelectFieldClient
7+
readonly isClearable?: boolean
78
readonly onChange: (val: string) => void
89
readonly options: Option[]
910
readonly value: string

0 commit comments

Comments
 (0)