refactor(ui): improve relationship field option loading reliability using queues (#12653)
This PR uses the new `useQueue` hook for relationship react-select field for loading options. This will reduce flakiness in our CI and ensure the following: - most recently triggered options loading request will not have its result overwritten by a previous, delayed request - reduce unnecessary, parallel requests - outdated requests are discarded from the queue if a newer request exist
This commit is contained in:
@@ -9,7 +9,7 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState }
|
||||
import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js'
|
||||
import type { ListDrawerProps } from '../../elements/ListDrawer/types.js'
|
||||
import type { ReactSelectAdapterProps } from '../../elements/ReactSelect/types.js'
|
||||
import type { GetResults, HasManyValueUnion, Option, RelationshipInputProps } from './types.js'
|
||||
import type { HasManyValueUnion, Option, RelationshipInputProps, UpdateResults } from './types.js'
|
||||
|
||||
import { AddNewRelation } from '../../elements/AddNewRelation/index.js'
|
||||
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
|
||||
@@ -21,6 +21,7 @@ import { FieldError } from '../../fields/FieldError/index.js'
|
||||
import { FieldLabel } from '../../fields/FieldLabel/index.js'
|
||||
import { useDebouncedCallback } from '../../hooks/useDebouncedCallback.js'
|
||||
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
|
||||
import { useQueues } from '../../hooks/useQueues.js'
|
||||
import { useAuth } from '../../providers/Auth/index.js'
|
||||
import { useConfig } from '../../providers/Config/index.js'
|
||||
import { useLocale } from '../../providers/Locale/index.js'
|
||||
@@ -31,8 +32,8 @@ import { createRelationMap } from './createRelationMap.js'
|
||||
import { findOptionsByValue } from './findOptionsByValue.js'
|
||||
import { optionsReducer } from './optionsReducer.js'
|
||||
import { MultiValueLabel } from './select-components/MultiValueLabel/index.js'
|
||||
import { SingleValue } from './select-components/SingleValue/index.js'
|
||||
import './index.scss'
|
||||
import { SingleValue } from './select-components/SingleValue/index.js'
|
||||
|
||||
const baseClass = 'relationship'
|
||||
|
||||
@@ -94,6 +95,7 @@ export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
|
||||
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false)
|
||||
const [menuIsOpen, setMenuIsOpen] = useState(false)
|
||||
const hasLoadedFirstPageRef = useRef(false)
|
||||
const { queueTask } = useQueues()
|
||||
|
||||
const [options, dispatchOptions] = useReducer(optionsReducer, [])
|
||||
|
||||
@@ -173,8 +175,8 @@ export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
|
||||
|
||||
const openDrawerWhenRelationChanges = useRef(false)
|
||||
|
||||
const getResults: GetResults = useCallback(
|
||||
async ({
|
||||
const updateResults: UpdateResults = useCallback(
|
||||
({
|
||||
filterOptions,
|
||||
hasMany: hasManyArg,
|
||||
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
|
||||
@@ -187,155 +189,158 @@ export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
|
||||
if (!permissions) {
|
||||
return
|
||||
}
|
||||
const lastFullyLoadedRelationToUse =
|
||||
typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1
|
||||
queueTask(async () => {
|
||||
const lastFullyLoadedRelationToUse =
|
||||
typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1
|
||||
|
||||
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
|
||||
const relationsToFetch =
|
||||
lastFullyLoadedRelationToUse === -1
|
||||
? relations
|
||||
: relations.slice(lastFullyLoadedRelationToUse + 1)
|
||||
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
|
||||
const relationsToFetch =
|
||||
lastFullyLoadedRelationToUse === -1
|
||||
? relations
|
||||
: relations.slice(lastFullyLoadedRelationToUse + 1)
|
||||
|
||||
let resultsFetched = 0
|
||||
const relationMap = createRelationMap(
|
||||
hasManyArg === true
|
||||
? {
|
||||
hasMany: true,
|
||||
relationTo,
|
||||
value: valueArg,
|
||||
}
|
||||
: {
|
||||
hasMany: false,
|
||||
relationTo,
|
||||
value: valueArg,
|
||||
},
|
||||
)
|
||||
|
||||
if (!errorLoading) {
|
||||
await relationsToFetch.reduce(async (priorRelation, relation) => {
|
||||
const relationFilterOption = filterOptions?.[relation]
|
||||
|
||||
let lastLoadedPageToUse
|
||||
if (search !== searchArg) {
|
||||
lastLoadedPageToUse = 1
|
||||
} else {
|
||||
lastLoadedPageToUse = lastLoadedPageArg[relation] + 1
|
||||
}
|
||||
await priorRelation
|
||||
|
||||
if (relationFilterOption === false) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (resultsFetched < 10) {
|
||||
const collection = getEntityConfig({ collectionSlug: relation })
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
|
||||
let fieldToSort = collection?.defaultSort || 'id'
|
||||
if (typeof sortOptions === 'string') {
|
||||
fieldToSort = sortOptions
|
||||
} else if (sortOptions?.[relation]) {
|
||||
fieldToSort = sortOptions[relation]
|
||||
}
|
||||
|
||||
const query: {
|
||||
[key: string]: unknown
|
||||
where: Where
|
||||
} = {
|
||||
depth: 0,
|
||||
draft: true,
|
||||
limit: maxResultsPerRequest,
|
||||
locale,
|
||||
page: lastLoadedPageToUse,
|
||||
select: {
|
||||
[fieldToSearch]: true,
|
||||
},
|
||||
sort: fieldToSort,
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
id: {
|
||||
not_in: relationMap[relation],
|
||||
},
|
||||
},
|
||||
],
|
||||
let resultsFetched = 0
|
||||
const relationMap = createRelationMap(
|
||||
hasManyArg === true
|
||||
? {
|
||||
hasMany: true,
|
||||
relationTo,
|
||||
value: valueArg,
|
||||
}
|
||||
: {
|
||||
hasMany: false,
|
||||
relationTo,
|
||||
value: valueArg,
|
||||
},
|
||||
)
|
||||
|
||||
if (!errorLoading) {
|
||||
await relationsToFetch.reduce(async (priorRelation, relation) => {
|
||||
const relationFilterOption = filterOptions?.[relation]
|
||||
|
||||
let lastLoadedPageToUse
|
||||
if (search !== searchArg) {
|
||||
lastLoadedPageToUse = 1
|
||||
} else {
|
||||
lastLoadedPageToUse = lastLoadedPageArg[relation] + 1
|
||||
}
|
||||
await priorRelation
|
||||
|
||||
if (relationFilterOption === false) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (searchArg) {
|
||||
query.where.and.push({
|
||||
[fieldToSearch]: {
|
||||
like: searchArg,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (relationFilterOption && typeof relationFilterOption !== 'boolean') {
|
||||
query.where.and.push(relationFilterOption)
|
||||
}
|
||||
|
||||
sanitizeFilterOptionsQuery(query.where)
|
||||
|
||||
const response = await fetch(`${serverURL}${api}/${relation}`, {
|
||||
body: qs.stringify(query),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Payload-HTTP-Method-Override': 'GET',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: PaginatedDocs<unknown> = await response.json()
|
||||
setLastLoadedPage((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
[relation]: lastLoadedPageToUse,
|
||||
}
|
||||
})
|
||||
|
||||
if (!data.nextPage) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
||||
if (resultsFetched < 10) {
|
||||
const collection = getEntityConfig({ collectionSlug: relation })
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
|
||||
let fieldToSort = collection?.defaultSort || 'id'
|
||||
if (typeof sortOptions === 'string') {
|
||||
fieldToSort = sortOptions
|
||||
} else if (sortOptions?.[relation]) {
|
||||
fieldToSort = sortOptions[relation]
|
||||
}
|
||||
|
||||
if (data.docs.length > 0) {
|
||||
resultsFetched += data.docs.length
|
||||
const query: {
|
||||
[key: string]: unknown
|
||||
where: Where
|
||||
} = {
|
||||
depth: 0,
|
||||
draft: true,
|
||||
limit: maxResultsPerRequest,
|
||||
locale,
|
||||
page: lastLoadedPageToUse,
|
||||
select: {
|
||||
[fieldToSearch]: true,
|
||||
},
|
||||
sort: fieldToSort,
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
id: {
|
||||
not_in: relationMap[relation],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
if (searchArg) {
|
||||
query.where.and.push({
|
||||
[fieldToSearch]: {
|
||||
like: searchArg,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (relationFilterOption && typeof relationFilterOption !== 'boolean') {
|
||||
query.where.and.push(relationFilterOption)
|
||||
}
|
||||
|
||||
sanitizeFilterOptionsQuery(query.where)
|
||||
|
||||
const response = await fetch(`${serverURL}${api}/${relation}`, {
|
||||
body: qs.stringify(query),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Payload-HTTP-Method-Override': 'GET',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: PaginatedDocs<unknown> = await response.json()
|
||||
setLastLoadedPage((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
[relation]: lastLoadedPageToUse,
|
||||
}
|
||||
})
|
||||
|
||||
if (!data.nextPage) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
||||
}
|
||||
|
||||
if (data.docs.length > 0) {
|
||||
resultsFetched += data.docs.length
|
||||
|
||||
dispatchOptions({
|
||||
type: 'ADD',
|
||||
collection,
|
||||
config,
|
||||
docs: data.docs,
|
||||
i18n,
|
||||
sort,
|
||||
})
|
||||
}
|
||||
} else if (response.status === 403) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
||||
dispatchOptions({
|
||||
type: 'ADD',
|
||||
collection,
|
||||
config,
|
||||
docs: data.docs,
|
||||
docs: [],
|
||||
i18n,
|
||||
ids: relationMap[relation],
|
||||
sort,
|
||||
})
|
||||
} else {
|
||||
setErrorLoading(t('error:unspecific'))
|
||||
}
|
||||
} else if (response.status === 403) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
||||
dispatchOptions({
|
||||
type: 'ADD',
|
||||
collection,
|
||||
config,
|
||||
docs: [],
|
||||
i18n,
|
||||
ids: relationMap[relation],
|
||||
sort,
|
||||
})
|
||||
} else {
|
||||
setErrorLoading(t('error:unspecific'))
|
||||
}
|
||||
}
|
||||
}, Promise.resolve())
|
||||
}, Promise.resolve())
|
||||
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess()
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[
|
||||
permissions,
|
||||
queueTask,
|
||||
relationTo,
|
||||
errorLoading,
|
||||
search,
|
||||
@@ -353,7 +358,7 @@ export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
|
||||
|
||||
const updateSearch = useDebouncedCallback<{ search: string } & HasManyValueUnion>(
|
||||
({ hasMany: hasManyArg, search: searchArg, value }) => {
|
||||
void getResults({
|
||||
updateResultsEffectEvent({
|
||||
filterOptions,
|
||||
lastLoadedPage: {},
|
||||
search: searchArg,
|
||||
@@ -593,8 +598,8 @@ export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
|
||||
[config.routes.admin],
|
||||
)
|
||||
|
||||
const getResultsEffectEvent: GetResults = useEffectEvent(async (args) => {
|
||||
return await getResults(args)
|
||||
const updateResultsEffectEvent: UpdateResults = useEffectEvent((args) => {
|
||||
return updateResults(args)
|
||||
})
|
||||
|
||||
// When (`relationTo` || `filterOptions` || `locale`) changes, reset component
|
||||
@@ -605,7 +610,7 @@ export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
|
||||
// re-fetch options
|
||||
if (hasLoadedFirstPageRef.current && menuIsOpen) {
|
||||
setIsLoading(true)
|
||||
void getResultsEffectEvent({
|
||||
void updateResultsEffectEvent({
|
||||
filterOptions,
|
||||
lastLoadedPage: {},
|
||||
onSuccess: () => {
|
||||
@@ -777,7 +782,7 @@ export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
|
||||
setMenuIsOpen(true)
|
||||
if (!hasLoadedFirstPageRef.current) {
|
||||
setIsLoading(true)
|
||||
void getResults({
|
||||
updateResultsEffectEvent({
|
||||
filterOptions,
|
||||
lastLoadedPage: {},
|
||||
onSuccess: () => {
|
||||
@@ -798,7 +803,7 @@ export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
|
||||
}
|
||||
}}
|
||||
onMenuScrollToBottom={() => {
|
||||
void getResults({
|
||||
updateResultsEffectEvent({
|
||||
filterOptions,
|
||||
lastFullyLoadedRelation,
|
||||
lastLoadedPage,
|
||||
|
||||
@@ -78,7 +78,7 @@ export type HasManyValueUnion =
|
||||
value?: PolymorphicRelationValue[]
|
||||
}
|
||||
|
||||
export type GetResults = (
|
||||
export type UpdateResults = (
|
||||
args: {
|
||||
filterOptions?: FilterOptionsResult
|
||||
lastFullyLoadedRelation?: number
|
||||
@@ -87,7 +87,7 @@ export type GetResults = (
|
||||
search?: string
|
||||
sort?: boolean
|
||||
} & HasManyValueUnion,
|
||||
) => Promise<void>
|
||||
) => void
|
||||
|
||||
export type RelationshipInputProps = {
|
||||
readonly AfterInput?: React.ReactNode
|
||||
|
||||
Reference in New Issue
Block a user