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:
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
23
packages/ui/src/elements/ReactSelect/Input/index.tsx
Normal file
23
packages/ui/src/elements/ReactSelect/Input/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 collection = collections.find((coll) => coll.slug === relationSlug)
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
|
||||
const pageIndex = nextPageByRelationshipRef.current.get(relationSlug)
|
||||
|
||||
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
|
||||
const relationsToFetch =
|
||||
lastFullyLoadedRelationToUse === -1
|
||||
? relations
|
||||
: relations.slice(lastFullyLoadedRelationToUse + 1)
|
||||
if (partiallyLoadedRelationshipSlugs.current.includes(relationSlug)) {
|
||||
const query: {
|
||||
depth?: number
|
||||
limit?: number
|
||||
page?: number
|
||||
where: Where
|
||||
} = {
|
||||
depth: 0,
|
||||
limit: maxResultsPerRequest,
|
||||
page: pageIndex,
|
||||
where: {
|
||||
and: [],
|
||||
},
|
||||
}
|
||||
|
||||
let resultsFetched = 0
|
||||
if (debouncedSearch) {
|
||||
query.where.and.push({
|
||||
[fieldToSearch]: {
|
||||
like: debouncedSearch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!errorLoading) {
|
||||
relationsToFetch.reduce(async (priorRelation, relation) => {
|
||||
await priorRelation
|
||||
|
||||
if (resultsFetched < 10) {
|
||||
const collection = collections.find((coll) => coll.slug === relation)
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
|
||||
const searchParam = searchArg ? `&where[${fieldToSearch}][like]=${searchArg}` : ''
|
||||
|
||||
const response = await fetch(
|
||||
`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${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)
|
||||
if (response.ok) {
|
||||
const data: PaginatedDocs = await response.json()
|
||||
if (data.docs.length > 0) {
|
||||
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'))
|
||||
}
|
||||
} else {
|
||||
setErrorLoading(t('error:unspecific'))
|
||||
}
|
||||
}, Promise.resolve())
|
||||
} catch (e) {
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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] : '')
|
||||
({ 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 (fieldName && operator && ![null, undefined].includes(value)) {
|
||||
newConditions[orIndex].and[andIndex] = {
|
||||
[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
|
||||
})
|
||||
setShouldUpdateQuery(true)
|
||||
}, [])
|
||||
setConditions(newConditions)
|
||||
setShouldUpdateQuery(true)
|
||||
},
|
||||
[conditions],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldUpdateQuery) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user