fix: duplicate options appearing in relationship where builder (#6557)

- enables reactStrictMode by default
- enables reactCompiler by default
- fixes cases where ID's set to 0 broke UI
This commit is contained in:
Jarrod Flesch
2024-05-30 00:35:59 -04:00
committed by GitHub
parent aa02801c3d
commit 347464250e
9 changed files with 175 additions and 113 deletions

View File

@@ -9,16 +9,12 @@ const withBundleAnalyzer = bundleAnalyzer({
// eslint-disable-next-line no-restricted-exports
export default withBundleAnalyzer(
withPayload({
reactStrictMode: false,
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
experimental: {
reactCompiler: false,
},
async redirects() {
return [
{

View File

@@ -518,7 +518,10 @@ export const relationship: Validate<
required,
} = options
if ((!value || (Array.isArray(value) && value.length === 0)) && required) {
if (
((!value && typeof value !== 'number') || (Array.isArray(value) && value.length === 0)) &&
required
) {
return t('validation:required')
}
@@ -551,7 +554,7 @@ export const relationship: Validate<
collectionSlug = relationTo
// custom id
if (val) {
if (val || typeof val === 'number') {
requestedID = val
}
}

View File

@@ -0,0 +1,23 @@
'use client'
import type { ControlProps } from 'react-select'
import React from 'react'
import { components as SelectComponents } from 'react-select'
import type { Option } from '../types.js'
export const Input: React.FC<ControlProps<Option, any>> = (props) => {
return (
<React.Fragment>
{/* @ts-expect-error // TODO Fix this - Broke with React 19 types */}
<SelectComponents.Input
{...props}
/**
* Adding `aria-activedescendant` fixes hydration error
* source: https://github.com/JedWatson/react-select/issues/5459#issuecomment-1878037196
*/
aria-activedescendant={undefined}
/>
</React.Fragment>
)
}

View File

@@ -17,6 +17,7 @@ import { ShimmerEffect } from '../ShimmerEffect/index.js'
import { ClearIndicator } from './ClearIndicator/index.js'
import { Control } from './Control/index.js'
import { DropdownIndicator } from './DropdownIndicator/index.js'
import { Input } from './Input/index.js'
import { MultiValue, generateMultiValueDraggableID } from './MultiValue/index.js'
import { MultiValueLabel } from './MultiValueLabel/index.js'
import { MultiValueRemove } from './MultiValueRemove/index.js'
@@ -85,6 +86,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
ClearIndicator,
Control,
DropdownIndicator,
Input,
MultiValue,
MultiValueLabel,
MultiValueRemove,
@@ -157,6 +159,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
ClearIndicator,
Control,
DropdownIndicator,
Input,
MultiValue,
MultiValueLabel,
MultiValueRemove,

View File

@@ -37,7 +37,7 @@ export const RelationshipCell: React.FC<RelationshipCellProps> = ({
const isAboveViewport = canUseDOM ? entry?.boundingClientRect?.top < window.innerHeight : false
useEffect(() => {
if (cellData && isAboveViewport && !hasRequested) {
if ((cellData || typeof cellData === 'number') && isAboveViewport && !hasRequested) {
const formattedValues: Value[] = []
const arrayCellData = Array.isArray(cellData) ? cellData : [cellData]
arrayCellData

View File

@@ -1,11 +1,13 @@
'use client'
/* eslint-disable @typescript-eslint/no-floating-promises */
import type { PaginatedDocs } from 'payload/database'
import type { Where } from 'payload/types'
import QueryString from 'qs'
import React, { useCallback, useEffect, useReducer, useState } from 'react'
import type { Option } from '../../../ReactSelect/types.js'
import type { GetResults, Props, ValueWithRelation } from './types.js'
import type { Props, ValueWithRelation } from './types.js'
import { useDebounce } from '../../../../hooks/useDebounce.js'
import { useConfig } from '../../../../providers/Config/index.js'
@@ -29,13 +31,21 @@ export const RelationshipField: React.FC<Props> = (props) => {
const hasMultipleRelations = Array.isArray(relationTo)
const [options, dispatchOptions] = useReducer(optionsReducer, [])
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1)
const [lastLoadedPage, setLastLoadedPage] = useState(1)
const [search, setSearch] = useState('')
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 addOptions = useCallback(
(data, relation) => {
@@ -45,71 +55,94 @@ export const RelationshipField: React.FC<Props> = (props) => {
[collections, hasMultipleRelations, i18n],
)
const getResults = useCallback<GetResults>(
const loadRelationOptions = React.useCallback(
async ({
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
search: searchArg,
// eslint-disable-next-line @typescript-eslint/require-await
abortController,
relationSlug,
}: {
abortController: AbortController
relationSlug: string
}) => {
let lastLoadedPageToUse = typeof lastLoadedPageArg !== 'undefined' ? lastLoadedPageArg : 1
const lastFullyLoadedRelationToUse =
typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
const relationsToFetch =
lastFullyLoadedRelationToUse === -1
? relations
: relations.slice(lastFullyLoadedRelationToUse + 1)
let resultsFetched = 0
if (!errorLoading) {
relationsToFetch.reduce(async (priorRelation, relation) => {
await priorRelation
if (resultsFetched < 10) {
const collection = collections.find((coll) => coll.slug === relation)
const collection = collections.find((coll) => coll.slug === relationSlug)
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
const searchParam = searchArg ? `&where[${fieldToSearch}][like]=${searchArg}` : ''
const pageIndex = nextPageByRelationshipRef.current.get(relationSlug)
if (partiallyLoadedRelationshipSlugs.current.includes(relationSlug)) {
const query: {
depth?: number
limit?: number
page?: number
where: Where
} = {
depth: 0,
limit: maxResultsPerRequest,
page: pageIndex,
where: {
and: [],
},
}
if (debouncedSearch) {
query.where.and.push({
[fieldToSearch]: {
like: debouncedSearch,
},
})
}
try {
const response = await fetch(
`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`,
`${serverURL}${api}/${relationSlug}${QueryString.stringify(query, { addQueryPrefix: true })}`,
{
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
},
signal: abortController.signal,
},
)
if (response.ok) {
const data: PaginatedDocs = await response.json()
if (data.docs.length > 0) {
resultsFetched += data.docs.length
addOptions(data, relation)
setLastLoadedPage(data.page)
addOptions(data, relationSlug)
if (!data.nextPage) {
setLastFullyLoadedRelation(relations.indexOf(relation))
// If there are more relations to search, need to reset lastLoadedPage to 1
// both locally within function and state
if (relations.indexOf(relation) + 1 < relations.length) {
lastLoadedPageToUse = 1
if (!debouncedSearch) {
if (data.nextPage) {
nextPageByRelationshipRef.current.set(relationSlug, data.nextPage)
} else {
partiallyLoadedRelationshipSlugs.current =
partiallyLoadedRelationshipSlugs.current.filter(
(partiallyLoadedRelation) => partiallyLoadedRelation !== relationSlug,
)
}
}
}
} else {
setErrorLoading(t('error:unspecific'))
}
} catch (e) {
if (!abortController.signal.aborted) {
console.error(e)
}
}, Promise.resolve())
}
}
setHasLoadedFirstOptions(true)
},
[i18n, relationTo, errorLoading, collections, serverURL, api, addOptions, t],
[addOptions, api, collections, debouncedSearch, i18n.language, serverURL, t],
)
const loadMoreOptions = React.useCallback(() => {
if (partiallyLoadedRelationshipSlugs.current.length > 0) {
const abortController = new AbortController()
loadRelationOptions({
abortController,
relationSlug: partiallyLoadedRelationshipSlugs.current[0],
})
}
}, [loadRelationOptions])
const findOptionsByValue = useCallback((): Option | Option[] => {
if (value) {
if (hasMany) {
@@ -198,27 +231,34 @@ export const RelationshipField: React.FC<Props> = (props) => {
[i18n, addOptions, api, errorLoading, serverURL, t],
)
// ///////////////////////////
// Get results when search input changes
// ///////////////////////////
/**
* 1. Trigger initial relationship options fetch
* 2. When search changes, loadRelationOptions will
* fire off again
*/
useEffect(() => {
dispatchOptions({
type: 'CLEAR',
i18n,
required: true,
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
const abortControllers: AbortController[] = []
relations.forEach((relation) => {
const abortController = new AbortController()
loadRelationOptions({
abortController,
relationSlug: relation,
})
abortControllers.push(abortController)
})
setHasLoadedFirstOptions(true)
setLastLoadedPage(1)
setLastFullyLoadedRelation(-1)
getResults({ search: debouncedSearch })
}, [getResults, debouncedSearch, relationTo, i18n])
// ///////////////////////////
// Format options once first options have been retrieved
// ///////////////////////////
return () => {
abortControllers.forEach((controller) => {
if (controller.signal) controller.abort()
})
}
}, [i18n, loadRelationOptions, relationTo])
/**
* Load any options that were not returned
* in the first 10 of each relation fetch
*/
useEffect(() => {
if (value && hasLoadedFirstOptions) {
if (hasMany) {
@@ -299,9 +339,7 @@ export const RelationshipField: React.FC<Props> = (props) => {
}
}}
onInputChange={handleInputChange}
onMenuScrollToBottom={() => {
getResults({ lastFullyLoadedRelation, lastLoadedPage: lastLoadedPage + 1 })
}}
onMenuScrollToBottom={loadMoreOptions}
options={options}
placeholder={t('general:selectValue')}
value={valueToRender}

View File

@@ -82,7 +82,7 @@ export const Condition: React.FC<Props> = (props) => {
useEffect(() => {
// This is to trigger changes when the debounced value changes
if (
internalField.value &&
(internalField?.value || typeof internalField?.value === 'number') &&
internalOperatorOption &&
![null, undefined].includes(debouncedValue)
) {
@@ -130,12 +130,12 @@ export const Condition: React.FC<Props> = (props) => {
setInternalQueryValue(undefined)
}}
options={fields}
value={fields.find((field) => internalField.value === field.value) || fields[0]}
value={fields.find((field) => internalField?.value === field.value) || fields[0]}
/>
</div>
<div className={`${baseClass}__operator`}>
<ReactSelect
disabled={!internalField.value}
disabled={!internalField?.value && typeof internalField?.value !== 'number'}
isClearable={false}
onChange={(operator) => {
setInternalOperatorOption(operator.value)

View File

@@ -86,9 +86,9 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
return []
})
const addCondition = React.useCallback(({ andIndex, fieldName, orIndex, relation }) => {
setConditions((prevConditions) => {
const newConditions = [...prevConditions]
const addCondition = React.useCallback(
({ andIndex, fieldName, orIndex, relation }) => {
const newConditions = [...conditions]
if (relation === 'and') {
newConditions[orIndex].and.splice(andIndex, 0, { [fieldName]: {} })
} else {
@@ -100,46 +100,45 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
],
})
}
return newConditions
})
}, [])
setConditions(newConditions)
},
[conditions],
)
const updateCondition = React.useCallback(
({ andIndex, fieldName: fieldNameArg, operator: operatorArg, orIndex, value: valueArg }) => {
setConditions((prevConditions) => {
const newConditions = [...prevConditions]
if (typeof newConditions[orIndex].and[andIndex] === 'object') {
const fieldName = fieldNameArg
const operator = operatorArg
const value = valueArg ?? (operator ? newConditions[orIndex].and[andIndex][operator] : '')
if (fieldName && operator && ![null, undefined].includes(value)) {
newConditions[orIndex].and[andIndex] = {
({ andIndex, fieldName, operator, orIndex, value: valueArg }) => {
const existingRowCondition = conditions[orIndex].and[andIndex]
if (typeof existingRowCondition === 'object' && fieldName && operator) {
const value = valueArg ?? (operator ? existingRowCondition[operator] : '')
const newRowCondition = {
[fieldName]: operator ? { [operator]: value } : {},
}
if (JSON.stringify(existingRowCondition) !== JSON.stringify(newRowCondition)) {
conditions[orIndex].and[andIndex] = newRowCondition
setConditions(conditions)
if (![null, undefined].includes(value)) {
// only update query when field/operator/value are filled out
setShouldUpdateQuery(true)
}
}
return newConditions
})
}
},
[],
[conditions],
)
const removeCondition = React.useCallback(({ andIndex, orIndex }) => {
setConditions((prevConditions) => {
const newConditions = [...prevConditions]
const removeCondition = React.useCallback(
({ andIndex, orIndex }) => {
const newConditions = [...conditions]
newConditions[orIndex].and.splice(andIndex, 1)
if (newConditions[orIndex].and.length === 0) {
newConditions.splice(orIndex, 1)
}
return newConditions
})
setConditions(newConditions)
setShouldUpdateQuery(true)
}, [])
},
[conditions],
)
React.useEffect(() => {
if (shouldUpdateQuery) {

View File

@@ -7,7 +7,7 @@ type Args = {
}
export const findOptionsByValue = ({ options, value }: Args): Option | Option[] => {
if (value) {
if (value || typeof value === 'number') {
if (Array.isArray(value)) {
return value.map((val) => {
let matchedOption: Option