feat(ui): extracts relationship input for external use (#12339)

This commit is contained in:
Jarrod Flesch
2025-05-15 14:54:26 -04:00
committed by GitHub
parent bd6ee317c1
commit 88769c8244
15 changed files with 1203 additions and 855 deletions

View File

@@ -22,7 +22,9 @@ const updateEnvExampleVariables = (
const [key] = line.split('=')
if (!key) {return}
if (!key) {
return
}
if (key === 'DATABASE_URI' || key === 'POSTGRES_URL' || key === 'MONGODB_URI') {
const dbChoice = databaseType ? dbChoiceRecord[databaseType] : null

View File

@@ -1208,7 +1208,7 @@ export type PolymorphicRelationshipField = {
export type PolymorphicRelationshipFieldClient = {
admin?: {
sortOptions?: Pick<PolymorphicRelationshipField['admin'], 'sortOptions'>
sortOptions?: PolymorphicRelationshipField['admin']['sortOptions']
} & RelationshipAdminClient
} & Pick<PolymorphicRelationshipField, 'relationTo'> &
SharedRelationshipPropertiesClient

View File

@@ -4,7 +4,6 @@ import type { ClientCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useCallback, useEffect, useState } from 'react'
import type { Value } from '../../fields/Relationship/types.js'
import type { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js'
import type { Props } from './types.js'
@@ -24,9 +23,9 @@ const baseClass = 'relationship-add-new'
export const AddNewRelation: React.FC<Props> = ({
Button: ButtonFromProps,
hasMany,
onChange,
path,
relationTo,
setValue,
unstyled,
value,
}) => {
@@ -54,18 +53,15 @@ export const AddNewRelation: React.FC<Props> = ({
const onSave: DocumentDrawerContextType['onSave'] = useCallback(
({ doc, operation }) => {
if (operation === 'create') {
const newValue: Value = Array.isArray(relationTo)
? {
relationTo: collectionConfig?.slug,
value: doc.id,
}
: doc.id
// ensure the value is not already in the array
const isNewValue =
Array.isArray(relationTo) && Array.isArray(value)
? !value.some((v) => v && typeof v === 'object' && v.value === doc.id)
: value !== doc.id
let isNewValue = false
if (!value) {
isNewValue = true
} else {
isNewValue = Array.isArray(value)
? !value.some((v) => v && v.value === doc.id)
: value.value !== doc.id
}
if (isNewValue) {
// dispatchOptions({
@@ -79,17 +75,26 @@ export const AddNewRelation: React.FC<Props> = ({
// sort: true,
// })
if (hasMany) {
setValue([...(Array.isArray(value) ? value : []), newValue])
if (hasMany === true) {
onChange([
...(Array.isArray(value) ? value : []),
{
relationTo: collectionConfig?.slug,
value: doc.id,
},
])
} else {
setValue(newValue)
onChange({
relationTo: relatedCollections[0].slug,
value: doc.id,
})
}
}
setSelectedCollection(undefined)
}
},
[relationTo, collectionConfig, hasMany, setValue, value],
[collectionConfig, hasMany, onChange, value, relatedCollections],
)
const onPopupToggle = useCallback((state) => {

View File

@@ -1,11 +1,20 @@
import type { Value } from '../../fields/Relationship/types.js'
import type { ValueWithRelation } from 'payload'
export type Props = {
readonly Button?: React.ReactNode
readonly hasMany: boolean
readonly path: string
readonly relationTo: string | string[]
readonly setValue: (value: unknown) => void
readonly unstyled?: boolean
readonly value: Value | Value[]
}
} & SharedRelationshipInputProps
type SharedRelationshipInputProps =
| {
readonly hasMany: false
readonly onChange: (value: ValueWithRelation, modifyForm?: boolean) => void
readonly value?: null | ValueWithRelation
}
| {
readonly hasMany: true
readonly onChange: (value: ValueWithRelation[]) => void
readonly value?: null | ValueWithRelation[]
}

View File

@@ -176,7 +176,7 @@ export { NumberField } from '../../fields/Number/index.js'
export { PasswordField } from '../../fields/Password/index.js'
export { PointField } from '../../fields/Point/index.js'
export { RadioGroupField } from '../../fields/RadioGroup/index.js'
export { RelationshipField } from '../../fields/Relationship/index.js'
export { RelationshipField, RelationshipInput } from '../../fields/Relationship/index.js'
export { RichTextField } from '../../fields/RichText/index.js'
export { RowField } from '../../fields/Row/index.js'
export { SelectField, SelectInput } from '../../fields/Select/index.js'

View File

@@ -0,0 +1,852 @@
'use client'
import type { FilterOptionsResult, PaginatedDocs, ValueWithRelation, Where } from 'payload'
import { dequal } from 'dequal/lite'
import { formatAdminURL, wordBoundariesRegex } from 'payload/shared'
import * as qs from 'qs-esm'
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
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 { AddNewRelation } from '../../elements/AddNewRelation/index.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
import { useListDrawer } from '../../elements/ListDrawer/index.js'
import { ReactSelect } from '../../elements/ReactSelect/index.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { FieldDescription } from '../../fields/FieldDescription/index.js'
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 { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { sanitizeFilterOptionsQuery } from '../../utilities/sanitizeFilterOptionsQuery.js'
import { fieldBaseClass } from '../shared/index.js'
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'
const baseClass = 'relationship'
export const RelationshipInput: React.FC<RelationshipInputProps> = (props) => {
const {
AfterInput,
allowCreate = true,
allowEdit = true,
appearance = 'select',
BeforeInput,
className,
description,
Description,
Error,
filterOptions,
hasMany,
initialValue,
isSortable = true,
label,
Label,
localized,
maxResultsPerRequest = 10,
onChange,
path,
placeholder,
readOnly,
relationTo,
required,
showError,
sortOptions,
style,
value,
} = props
const { config, getEntityConfig } = useConfig()
const {
routes: { api },
serverURL,
} = config
const { i18n, t } = useTranslation()
const { permissions } = useAuth()
const { code: locale } = useLocale()
const [currentlyOpenRelationship, setCurrentlyOpenRelationship] = useState<
Parameters<ReactSelectAdapterProps['customProps']['onDocumentOpen']>[0]
>({
id: undefined,
collectionSlug: undefined,
hasReadPermission: false,
})
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1)
const [lastLoadedPage, setLastLoadedPage] = useState<Record<string, number>>({})
const [errorLoading, setErrorLoading] = useState('')
const [search, setSearch] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false)
const [menuIsOpen, setMenuIsOpen] = useState(false)
const hasLoadedFirstPageRef = useRef(false)
const [options, dispatchOptions] = useReducer(optionsReducer, [])
const valueRef = useRef(value)
// the line below seems odd
valueRef.current = value
const [DocumentDrawer, , { isDrawerOpen, openDrawer }] = useDocumentDrawer({
id: currentlyOpenRelationship.id,
collectionSlug: currentlyOpenRelationship.collectionSlug,
})
// Filter selected values from displaying in the list drawer
const listDrawerFilterOptions = useMemo<FilterOptionsResult>(() => {
let newFilterOptions = filterOptions
if (value) {
const valuesByRelation = (hasMany === false ? [value] : value).reduce((acc, val) => {
if (!acc[val.relationTo]) {
acc[val.relationTo] = []
}
acc[val.relationTo].push(val.value)
return acc
}, {})
;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relation) => {
newFilterOptions = {
...(newFilterOptions || {}),
[relation]: {
...(typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {}),
...(valuesByRelation[relation]
? {
id: {
not_in: valuesByRelation[relation],
},
}
: {}),
},
}
})
}
return newFilterOptions
}, [filterOptions, value, hasMany, relationTo])
const [
ListDrawer,
,
{ closeDrawer: closeListDrawer, isDrawerOpen: isListDrawerOpen, openDrawer: openListDrawer },
] = useListDrawer({
collectionSlugs: relationTo,
filterOptions: listDrawerFilterOptions,
})
const onListSelect = useCallback<NonNullable<ListDrawerProps['onSelect']>>(
({ collectionSlug, doc }) => {
if (hasMany) {
onChange([
...(Array.isArray(value) ? value : []),
{
relationTo: collectionSlug,
value: doc.id,
},
])
} else if (hasMany === false) {
onChange({
relationTo: collectionSlug,
value: doc.id,
})
}
closeListDrawer()
},
[hasMany, onChange, closeListDrawer, value],
)
const openDrawerWhenRelationChanges = useRef(false)
const getResults: GetResults = useCallback(
async ({
filterOptions,
hasMany: hasManyArg,
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
onSuccess,
search: searchArg,
sort,
value: valueArg,
}) => {
if (!permissions) {
return
}
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
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],
},
},
],
},
}
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-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: [],
i18n,
ids: relationMap[relation],
sort,
})
} else {
setErrorLoading(t('error:unspecific'))
}
}
}, Promise.resolve())
if (typeof onSuccess === 'function') {
onSuccess()
}
}
},
[
permissions,
relationTo,
errorLoading,
search,
getEntityConfig,
sortOptions,
maxResultsPerRequest,
locale,
serverURL,
api,
i18n,
config,
t,
],
)
const updateSearch = useDebouncedCallback<{ search: string } & HasManyValueUnion>(
({ hasMany: hasManyArg, search: searchArg, value }) => {
void getResults({
filterOptions,
lastLoadedPage: {},
search: searchArg,
sort: true,
...(hasManyArg === true
? {
hasMany: hasManyArg,
value,
}
: {
hasMany: hasManyArg,
value,
}),
})
setSearch(searchArg)
},
300,
)
const handleInputChange = useCallback(
(options: { search: string } & HasManyValueUnion) => {
if (search !== options.search) {
setLastLoadedPage({})
updateSearch(options)
}
},
[search, updateSearch],
)
const handleValueChange = useEffectEvent(({ hasMany: hasManyArg, value }: HasManyValueUnion) => {
const relationMap = createRelationMap(
hasManyArg === true
? {
hasMany: hasManyArg,
relationTo,
value,
}
: {
hasMany: hasManyArg,
relationTo,
value,
},
)
void Object.entries(relationMap).reduce(async (priorRelation, [relation, ids]) => {
await priorRelation
const idsToLoad = ids.filter((id) => {
return !options.find((optionGroup) =>
optionGroup?.options?.find(
(option) => option.value === id && option.relationTo === relation,
),
)
})
if (idsToLoad.length > 0) {
const query = {
depth: 0,
draft: true,
limit: idsToLoad.length,
locale,
where: {
id: {
in: idsToLoad,
},
},
}
if (!errorLoading) {
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-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
const collection = getEntityConfig({ collectionSlug: relation })
let docs = []
if (response.ok) {
const data = await response.json()
docs = data.docs
}
dispatchOptions({
type: 'ADD',
collection,
config,
docs,
i18n,
ids: idsToLoad,
sort: true,
})
}
}
}, Promise.resolve())
})
const onSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
dispatchOptions({
type: 'UPDATE',
collection: args.collectionConfig,
config,
doc: args.doc,
i18n,
})
const docID = args.doc.id
if (hasMany) {
const currentValue = valueRef.current
? Array.isArray(valueRef.current)
? valueRef.current
: [valueRef.current]
: []
const valuesToSet = currentValue.map((option: ValueWithRelation) => {
return {
relationTo: option.value === docID ? args.collectionConfig.slug : option.relationTo,
value: option.value,
}
})
onChange(valuesToSet)
} else if (hasMany === false) {
onChange({ relationTo: args.collectionConfig.slug, value: docID })
}
},
[i18n, config, hasMany, onChange],
)
const onDuplicate = useCallback<DocumentDrawerProps['onDuplicate']>(
(args) => {
dispatchOptions({
type: 'ADD',
collection: args.collectionConfig,
config,
docs: [args.doc],
i18n,
sort: true,
})
if (hasMany) {
onChange(
valueRef.current
? (valueRef.current as ValueWithRelation[]).concat({
relationTo: args.collectionConfig.slug,
value: args.doc.id,
})
: null,
)
} else if (hasMany === false) {
onChange({
relationTo: args.collectionConfig.slug,
value: args.doc.id,
})
}
},
[i18n, config, hasMany, onChange],
)
const onDelete = useCallback<DocumentDrawerProps['onDelete']>(
(args) => {
dispatchOptions({
id: args.id,
type: 'REMOVE',
collection: args.collectionConfig,
config,
i18n,
})
if (hasMany) {
onChange(
valueRef.current
? (valueRef.current as ValueWithRelation[]).filter((option) => {
return option.value !== args.id
})
: null,
)
} else {
onChange(null)
}
return
},
[i18n, config, hasMany, onChange],
)
const filterOption = useCallback((item: Option, searchFilter: string) => {
if (!searchFilter) {
return true
}
const r = wordBoundariesRegex(searchFilter || '')
// breaking the labels to search into smaller parts increases performance
const breakApartThreshold = 250
let labelString = String(item.label)
// strings less than breakApartThreshold length won't be chunked
while (labelString.length > breakApartThreshold) {
// slicing by the next space after the length of the search input prevents slicing the string up by partial words
const indexOfSpace = labelString.indexOf(' ', searchFilter.length)
if (
r.test(labelString.slice(0, indexOfSpace === -1 ? searchFilter.length : indexOfSpace + 1))
) {
return true
}
labelString = labelString.slice(indexOfSpace === -1 ? searchFilter.length : indexOfSpace + 1)
}
return r.test(labelString.slice(-breakApartThreshold))
}, [])
const onDocumentOpen = useCallback<ReactSelectAdapterProps['customProps']['onDocumentOpen']>(
({ id, collectionSlug, hasReadPermission, openInNewTab }) => {
if (openInNewTab) {
if (hasReadPermission && id && collectionSlug) {
const docUrl = formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${collectionSlug}/${id}`,
})
window.open(docUrl, '_blank')
}
} else {
openDrawerWhenRelationChanges.current = true
setCurrentlyOpenRelationship({
id,
collectionSlug,
hasReadPermission,
})
}
},
[config.routes.admin],
)
const getResultsEffectEvent: GetResults = useEffectEvent(async (args) => {
return await getResults(args)
})
// When (`relationTo` || `filterOptions` || `locale`) changes, reset component
// Note - effect should not run on first run
useEffect(() => {
// If the menu is open while filterOptions changes
// due to latency of form state and fast clicking into this field,
// re-fetch options
if (hasLoadedFirstPageRef.current && menuIsOpen) {
setIsLoading(true)
void getResultsEffectEvent({
filterOptions,
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
setIsLoading(false)
},
...(hasMany === true
? {
hasMany,
value: valueRef.current as ValueWithRelation[],
}
: {
hasMany,
value: valueRef.current as ValueWithRelation,
}),
})
}
// If the menu is not open, still reset the field state
// because we need to get new options next time the menu opens
dispatchOptions({
type: 'CLEAR',
exemptValues: valueRef.current,
})
setLastFullyLoadedRelation(-1)
setLastLoadedPage({})
}, [relationTo, filterOptions, locale, path, menuIsOpen, hasMany])
const prevValue = useRef(value)
const isFirstRenderRef = useRef(true)
// ///////////////////////////////////
// Ensure we have an option for each value
// ///////////////////////////////////
useEffect(() => {
if (isFirstRenderRef.current || !dequal(value, prevValue.current)) {
handleValueChange(hasMany === true ? { hasMany, value } : { hasMany, value })
}
isFirstRenderRef.current = false
prevValue.current = value
}, [value, hasMany])
// Determine if we should switch to word boundary search
useEffect(() => {
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
const isIdOnly = relations.reduce((idOnly, relation) => {
const collection = getEntityConfig({ collectionSlug: relation })
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
return fieldToSearch === 'id' && idOnly
}, true)
setEnableWordBoundarySearch(!isIdOnly)
}, [relationTo, getEntityConfig])
useEffect(() => {
if (openDrawerWhenRelationChanges.current) {
openDrawer()
openDrawerWhenRelationChanges.current = false
}
}, [openDrawer, currentlyOpenRelationship])
const valueToRender = findOptionsByValue({ allowEdit, options, value })
if (!Array.isArray(valueToRender) && valueToRender?.value === 'null') {
valueToRender.value = null
}
return (
<div
className={[
fieldBaseClass,
baseClass,
className,
showError && 'error',
errorLoading && 'error-loading',
readOnly && `${baseClass}--read-only`,
!readOnly && allowCreate && `${baseClass}--allow-create`,
]
.filter(Boolean)
.join(' ')}
id={`field-${path.replace(/\./g, '__')}`}
style={style}
>
<RenderCustomComponent
CustomComponent={Label}
Fallback={
<FieldLabel label={label} localized={localized} path={path} required={required} />
}
/>
<div className={`${fieldBaseClass}__wrap`}>
<RenderCustomComponent
CustomComponent={Error}
Fallback={<FieldError path={path} showError={showError} />}
/>
{BeforeInput}
{!errorLoading && (
<div className={`${baseClass}__wrap`}>
<ReactSelect
backspaceRemovesValue={!(isDrawerOpen || isListDrawerOpen)}
components={{
MultiValueLabel,
SingleValue,
...(appearance !== 'select' && { DropdownIndicator: null }),
}}
customProps={{
disableKeyDown: isDrawerOpen || isListDrawerOpen,
disableMouseDown: isDrawerOpen || isListDrawerOpen,
onDocumentOpen,
onSave,
}}
disabled={readOnly || isDrawerOpen || isListDrawerOpen}
filterOption={enableWordBoundarySearch ? filterOption : undefined}
getOptionValue={(option: ValueWithRelation) => {
if (!option) {
return undefined
}
return hasMany && Array.isArray(relationTo)
? `${option.relationTo}_${option.value}`
: (option.value as string)
}}
isLoading={appearance === 'select' && isLoading}
isMulti={hasMany}
isSearchable={appearance === 'select'}
isSortable={isSortable}
menuIsOpen={appearance === 'select' ? menuIsOpen : false}
onChange={
!readOnly
? (selected) => {
if (hasMany) {
if (selected === null) {
onChange([])
} else {
onChange(selected as ValueWithRelation[])
}
} else if (hasMany === false) {
if (selected === null) {
onChange(null)
} else {
onChange(selected as ValueWithRelation)
}
}
}
: undefined
}
onInputChange={(newSearch) =>
handleInputChange({
search: newSearch,
...(hasMany === true
? {
hasMany,
value,
}
: {
hasMany,
value,
}),
})
}
onMenuClose={() => {
setMenuIsOpen(false)
}}
onMenuOpen={() => {
if (appearance === 'drawer') {
openListDrawer()
} else if (appearance === 'select') {
setMenuIsOpen(true)
if (!hasLoadedFirstPageRef.current) {
setIsLoading(true)
void getResults({
filterOptions,
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
setIsLoading(false)
},
...(hasMany === true
? {
hasMany,
value,
}
: {
hasMany,
value,
}),
})
}
}
}}
onMenuScrollToBottom={() => {
void getResults({
filterOptions,
lastFullyLoadedRelation,
lastLoadedPage,
search,
sort: false,
...(hasMany === true
? {
hasMany,
value: initialValue,
}
: {
hasMany,
value: initialValue,
}),
})
}}
options={options}
placeholder={placeholder}
showError={showError}
value={valueToRender ?? null}
/>
{!readOnly && allowCreate && (
<AddNewRelation
path={path}
relationTo={relationTo}
{...(hasMany === true
? {
hasMany,
onChange,
value,
}
: {
hasMany,
onChange,
value,
})}
/>
)}
</div>
)}
{errorLoading && <div className={`${baseClass}__error-loading`}>{errorLoading}</div>}
{AfterInput}
<RenderCustomComponent
CustomComponent={Description}
Fallback={<FieldDescription description={description} path={path} />}
/>
</div>
{currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && (
<DocumentDrawer onDelete={onDelete} onDuplicate={onDuplicate} onSave={onSave} />
)}
{appearance === 'drawer' && !readOnly && (
<ListDrawer allowCreate={allowCreate} enableRowSelections={false} onSelect={onListSelect} />
)}
</div>
)
}

View File

@@ -1,32 +1,27 @@
'use client'
import type { Value } from './types.js'
import type { HasManyValueUnion } from './types.js'
type RelationMap = {
[relation: string]: (number | string)[]
}
type CreateRelationMap = (args: {
hasMany: boolean
relationTo: string | string[]
value: null | Value | Value[] // really needs to be `ValueWithRelation`
}) => RelationMap
type CreateRelationMap = (
args: {
relationTo: string[]
} & HasManyValueUnion,
) => RelationMap
export const createRelationMap: CreateRelationMap = ({ hasMany, relationTo, value }) => {
const hasMultipleRelations = Array.isArray(relationTo)
let relationMap: RelationMap
if (Array.isArray(relationTo)) {
relationMap = relationTo.reduce((map, current) => {
const relationMap: RelationMap = relationTo.reduce((map, current) => {
return { ...map, [current]: [] }
}, {})
} else {
relationMap = { [relationTo]: [] }
}
if (value === null) {
return relationMap
}
const add = (relation: string, id: unknown) => {
if (value) {
const add = (relation: string, id: number | string) => {
if ((typeof id === 'string' || typeof id === 'number') && typeof relation === 'string') {
if (relationMap[relation]) {
relationMap[relation].push(id)
@@ -35,28 +30,15 @@ export const createRelationMap: CreateRelationMap = ({ hasMany, relationTo, valu
}
}
}
if (hasMany && Array.isArray(value)) {
if (hasMany === true) {
value.forEach((val) => {
if (
hasMultipleRelations &&
typeof val === 'object' &&
'relationTo' in val &&
'value' in val
) {
if (val) {
add(val.relationTo, val.value)
}
if (!hasMultipleRelations && typeof relationTo === 'string') {
add(relationTo, val)
}
})
} else if (hasMultipleRelations && Array.isArray(relationTo)) {
if (typeof value === 'object' && 'relationTo' in value && 'value' in value) {
} else {
add(value.relationTo, value.value)
}
} else {
add(relationTo, value)
}
return relationMap

View File

@@ -1,11 +1,13 @@
'use client'
import type { ValueWithRelation } from 'payload'
import type { Option } from '../../elements/ReactSelect/types.js'
import type { OptionGroup, Value } from './types.js'
import type { OptionGroup } from './types.js'
type Args = {
allowEdit: boolean
options: OptionGroup[]
value: Value | Value[]
value: ValueWithRelation | ValueWithRelation[]
}
export const findOptionsByValue = ({ allowEdit, options, value }: Args): Option | Option[] => {
@@ -17,11 +19,7 @@ export const findOptionsByValue = ({ allowEdit, options, value }: Args): Option
options.forEach((optGroup) => {
if (!matchedOption) {
matchedOption = optGroup.options.find((option) => {
if (typeof val === 'object') {
return option.value === val.value && option.relationTo === val.relationTo
}
return val === option.value
})
}
})
@@ -35,10 +33,7 @@ export const findOptionsByValue = ({ allowEdit, options, value }: Args): Option
options.forEach((optGroup) => {
if (!matchedOption) {
matchedOption = optGroup.options.find((option) => {
if (typeof value === 'object') {
return option.value === value.value && option.relationTo === value.relationTo
}
return value === option.value
})
}
})

View File

@@ -1,50 +1,17 @@
'use client'
import type {
FilterOptionsResult,
PaginatedDocs,
RelationshipFieldClientComponent,
Where,
} from 'payload'
import type { RelationshipFieldClientComponent, ValueWithRelation } from 'payload'
import { dequal } from 'dequal/lite'
import { formatAdminURL, wordBoundariesRegex } from 'payload/shared'
import * as qs from 'qs-esm'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import React, { useCallback, useMemo } from 'react'
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, Option, Value } from './types.js'
import type { PolymorphicRelationValue, Value } from './types.js'
import { AddNewRelation } from '../../elements/AddNewRelation/index.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
import { useListDrawer } from '../../elements/ListDrawer/index.js'
import { ReactSelect } from '../../elements/ReactSelect/index.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { FieldDescription } from '../../fields/FieldDescription/index.js'
import { FieldError } from '../../fields/FieldError/index.js'
import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js'
import { useDebouncedCallback } from '../../hooks/useDebouncedCallback.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useLocale } from '../../providers/Locale/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
import { sanitizeFilterOptionsQuery } from '../../utilities/sanitizeFilterOptionsQuery.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js'
import { fieldBaseClass } from '../shared/index.js'
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 { RelationshipInput } from './Input.js'
import './index.scss'
const maxResultsPerRequest = 10
const baseClass = 'relationship'
export { RelationshipInput }
const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => {
const {
@@ -63,7 +30,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
hasMany,
label,
localized,
relationTo,
relationTo: relationToProp,
required,
},
path: pathFromProps,
@@ -71,35 +38,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
validate,
} = props
const { config, getEntityConfig } = useConfig()
const {
routes: { api },
serverURL,
} = config
const { i18n, t } = useTranslation()
const { permissions } = useAuth()
const { code: locale } = useLocale()
const hasMultipleRelations = Array.isArray(relationTo)
const [currentlyOpenRelationship, setCurrentlyOpenRelationship] = useState<
Parameters<ReactSelectAdapterProps['customProps']['onDocumentOpen']>[0]
>({
id: undefined,
collectionSlug: undefined,
hasReadPermission: false,
})
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1)
const [lastLoadedPage, setLastLoadedPage] = useState<Record<string, number>>({})
const [errorLoading, setErrorLoading] = useState('')
const [search, setSearch] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false)
const [menuIsOpen, setMenuIsOpen] = useState(false)
const hasLoadedFirstPageRef = useRef(false)
const memoizedValidate = useCallback(
(value, validationOptions) => {
if (typeof validate === 'function') {
@@ -118,713 +56,175 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
setValue,
showError,
value,
} = useField<Value | Value[]>({
} = useField<Value>({
potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
const [options, dispatchOptions] = useReducer(optionsReducer, [])
const valueRef = useRef(value)
valueRef.current = value
const [DocumentDrawer, , { isDrawerOpen, openDrawer }] = useDocumentDrawer({
id: currentlyOpenRelationship.id,
collectionSlug: currentlyOpenRelationship.collectionSlug,
})
// Filter selected values from displaying in the list drawer
const listDrawerFilterOptions = useMemo<FilterOptionsResult>(() => {
let newFilterOptions = filterOptions
if (value) {
const valuesByRelation = (Array.isArray(value) ? value : [value]).reduce((acc, val) => {
if (typeof val === 'object' && val.relationTo) {
if (!acc[val.relationTo]) {
acc[val.relationTo] = []
}
acc[val.relationTo].push(val.value)
} else if (val) {
const relation = Array.isArray(relationTo) ? undefined : relationTo
if (relation) {
if (!acc[relation]) {
acc[relation] = []
}
acc[relation].push(val)
}
}
return acc
}, {})
;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relation) => {
newFilterOptions = {
...(newFilterOptions || {}),
[relation]: {
...(typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {}),
...(valuesByRelation[relation]
? {
id: {
not_in: valuesByRelation[relation],
},
}
: {}),
},
}
})
}
return newFilterOptions
}, [filterOptions, value, relationTo])
const [
ListDrawer,
,
{ closeDrawer: closeListDrawer, isDrawerOpen: isListDrawerOpen, openDrawer: openListDrawer },
] = useListDrawer({
collectionSlugs: hasMultipleRelations ? relationTo : [relationTo],
filterOptions: listDrawerFilterOptions,
})
const onListSelect = useCallback<NonNullable<ListDrawerProps['onSelect']>>(
({ collectionSlug, doc }) => {
const formattedSelection = hasMultipleRelations
? {
relationTo: collectionSlug,
value: doc.id,
}
: doc.id
if (hasMany) {
const withSelection = Array.isArray(value) ? value : []
setValue([...withSelection, formattedSelection])
} else {
setValue(formattedSelection)
}
closeListDrawer()
},
[hasMany, hasMultipleRelations, setValue, closeListDrawer, value],
)
const openDrawerWhenRelationChanges = useRef(false)
const getResults: GetResults = useCallback(
async ({
filterOptions,
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
lastLoadedPage: lastLoadedPageArg,
onSuccess,
search: searchArg,
sort,
value: valueArg,
}) => {
if (!permissions) {
return
}
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
const relationMap = createRelationMap({
hasMany,
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],
},
},
],
},
}
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-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: [],
i18n,
ids: relationMap[relation],
sort,
})
} else {
setErrorLoading(t('error:unspecific'))
}
}
}, Promise.resolve())
if (typeof onSuccess === 'function') {
onSuccess()
}
}
},
[
permissions,
relationTo,
hasMany,
errorLoading,
search,
getEntityConfig,
locale,
serverURL,
sortOptions,
api,
i18n,
config,
t,
],
)
const updateSearch = useDebouncedCallback((searchArg: string, valueArg: Value | Value[]) => {
void getResults({
filterOptions,
lastLoadedPage: {},
search: searchArg,
sort: true,
value: valueArg,
})
setSearch(searchArg)
}, 300)
const handleInputChange = useCallback(
(searchArg: string, valueArg: Value | Value[]) => {
if (search !== searchArg) {
setLastLoadedPage({})
updateSearch(searchArg, valueArg, searchArg !== '')
}
},
[search, updateSearch],
)
const handleValueChange = useEffectEvent((value: Value | Value[]) => {
const relationMap = createRelationMap({
hasMany,
relationTo,
value,
})
void Object.entries(relationMap).reduce(async (priorRelation, [relation, ids]) => {
await priorRelation
const idsToLoad = ids.filter((id) => {
return !options.find((optionGroup) =>
optionGroup?.options?.find(
(option) => option.value === id && option.relationTo === relation,
),
)
})
if (idsToLoad.length > 0) {
const query = {
depth: 0,
draft: true,
limit: idsToLoad.length,
locale,
where: {
id: {
in: idsToLoad,
},
},
}
if (!errorLoading) {
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-HTTP-Method-Override': 'GET',
},
method: 'POST',
})
const collection = getEntityConfig({ collectionSlug: relation })
let docs = []
if (response.ok) {
const data = await response.json()
docs = data.docs
}
dispatchOptions({
type: 'ADD',
collection,
config,
docs,
i18n,
ids: idsToLoad,
sort: true,
})
}
}
}, Promise.resolve())
})
const prevValue = useRef(value)
const isFirstRenderRef = useRef(true)
// ///////////////////////////////////
// Ensure we have an option for each value
// ///////////////////////////////////
useEffect(() => {
if (isFirstRenderRef.current || !dequal(value, prevValue.current)) {
handleValueChange(value)
}
isFirstRenderRef.current = false
prevValue.current = value
}, [value])
// Determine if we should switch to word boundary search
useEffect(() => {
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
const isIdOnly = relations.reduce((idOnly, relation) => {
const collection = getEntityConfig({ collectionSlug: relation })
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
return fieldToSearch === 'id' && idOnly
}, true)
setEnableWordBoundarySearch(!isIdOnly)
}, [relationTo, getEntityConfig])
const getResultsEffectEvent: GetResults = useEffectEvent(async (args) => {
return await getResults(args)
})
// When (`relationTo` || `filterOptions` || `locale`) changes, reset component
// Note - effect should not run on first run
useEffect(() => {
// If the menu is open while filterOptions changes
// due to latency of form state and fast clicking into this field,
// re-fetch options
if (hasLoadedFirstPageRef.current && menuIsOpen) {
setIsLoading(true)
void getResultsEffectEvent({
filterOptions,
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
setIsLoading(false)
},
value: valueRef.current,
})
}
// If the menu is not open, still reset the field state
// because we need to get new options next time the menu opens
dispatchOptions({
type: 'CLEAR',
exemptValues: valueRef.current,
})
setLastFullyLoadedRelation(-1)
setLastLoadedPage({})
}, [relationTo, filterOptions, locale, path, menuIsOpen])
const onSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
dispatchOptions({
type: 'UPDATE',
collection: args.collectionConfig,
config,
doc: args.doc,
i18n,
})
const currentValue = valueRef.current
const docID = args.doc.id
if (hasMany) {
const unchanged = (currentValue as Option[]).some((option) =>
typeof option === 'string' ? option === docID : option.value === docID,
)
const valuesToSet = (currentValue as Option[]).map((option) =>
option.value === docID
? { relationTo: args.collectionConfig.slug, value: docID }
: option,
)
setValue(valuesToSet, unchanged)
} else {
const unchanged = currentValue === docID
setValue({ relationTo: args.collectionConfig.slug, value: docID }, unchanged)
}
},
[i18n, config, hasMany, setValue],
)
const onDuplicate = useCallback<DocumentDrawerProps['onDuplicate']>(
(args) => {
dispatchOptions({
type: 'ADD',
collection: args.collectionConfig,
config,
docs: [args.doc],
i18n,
sort: true,
})
if (hasMany) {
setValue(
valueRef.current
? (valueRef.current as Option[]).concat({
relationTo: args.collectionConfig.slug,
value: args.doc.id,
} as Option)
: null,
)
} else {
setValue({
relationTo: args.collectionConfig.slug,
value: args.doc.id,
})
}
},
[i18n, config, hasMany, setValue],
)
const onDelete = useCallback<DocumentDrawerProps['onDelete']>(
(args) => {
dispatchOptions({
id: args.id,
type: 'REMOVE',
collection: args.collectionConfig,
config,
i18n,
})
if (hasMany) {
setValue(
valueRef.current
? (valueRef.current as Option[]).filter((option) => {
return option.value !== args.id
})
: null,
)
} else {
setValue(null)
}
return
},
[i18n, config, hasMany, setValue],
)
const filterOption = useCallback((item: Option, searchFilter: string) => {
if (!searchFilter) {
return true
}
const r = wordBoundariesRegex(searchFilter || '')
// breaking the labels to search into smaller parts increases performance
const breakApartThreshold = 250
let labelString = String(item.label)
// strings less than breakApartThreshold length won't be chunked
while (labelString.length > breakApartThreshold) {
// slicing by the next space after the length of the search input prevents slicing the string up by partial words
const indexOfSpace = labelString.indexOf(' ', searchFilter.length)
if (
r.test(labelString.slice(0, indexOfSpace === -1 ? searchFilter.length : indexOfSpace + 1))
) {
return true
}
labelString = labelString.slice(indexOfSpace === -1 ? searchFilter.length : indexOfSpace + 1)
}
return r.test(labelString.slice(-breakApartThreshold))
}, [])
const onDocumentOpen = useCallback<ReactSelectAdapterProps['customProps']['onDocumentOpen']>(
({ id, collectionSlug, hasReadPermission, openInNewTab }) => {
if (openInNewTab) {
if (hasReadPermission && id && collectionSlug) {
const docUrl = formatAdminURL({
adminRoute: config.routes.admin,
path: `/collections/${collectionSlug}/${id}`,
})
window.open(docUrl, '_blank')
}
} else {
openDrawerWhenRelationChanges.current = true
setCurrentlyOpenRelationship({
id,
collectionSlug,
hasReadPermission,
})
}
},
[setCurrentlyOpenRelationship, config.routes.admin],
)
useEffect(() => {
if (openDrawerWhenRelationChanges.current) {
openDrawer()
openDrawerWhenRelationChanges.current = false
}
}, [openDrawer, currentlyOpenRelationship])
const valueToRender = findOptionsByValue({ allowEdit, options, value })
if (!Array.isArray(valueToRender) && valueToRender?.value === 'null') {
valueToRender.value = null
}
const styles = useMemo(() => mergeFieldStyles(field), [field])
const isPolymorphic = Array.isArray(relationToProp)
const [relationTo] = React.useState(() =>
Array.isArray(relationToProp) ? relationToProp : [relationToProp],
)
const handleChangeHasMulti = useCallback(
(newValue: ValueWithRelation[]) => {
if (!newValue) {
setValue(null, newValue === value)
return
}
let disableFormModication = false
if (isPolymorphic) {
disableFormModication =
Array.isArray(value) &&
Array.isArray(newValue) &&
value.length === newValue.length &&
(value as PolymorphicRelationValue[]).every((val, idx) => {
const newVal = newValue[idx]
return val.value === newVal.value && val.relationTo === newVal.relationTo
})
} else {
disableFormModication =
Array.isArray(value) &&
Array.isArray(newValue) &&
value.length === newValue.length &&
value.every((val, idx) => val === newValue[idx].value)
}
const dataToSet = newValue.map((val) => {
if (isPolymorphic) {
return val
} else {
return val.value
}
})
setValue(dataToSet, disableFormModication)
},
[isPolymorphic, setValue, value],
)
const handleChangeSingle = useCallback(
(newValue: ValueWithRelation) => {
if (!newValue) {
setValue(null, newValue === value)
return
}
let disableFormModication = false
if (isPolymorphic) {
disableFormModication =
value &&
newValue &&
(value as PolymorphicRelationValue).value === newValue.value &&
(value as PolymorphicRelationValue).relationTo === newValue.relationTo
} else {
disableFormModication = value && newValue && value === newValue.value
}
const dataToSet = isPolymorphic ? newValue : newValue.value
setValue(dataToSet, disableFormModication)
},
[isPolymorphic, setValue, value],
)
const memoizedValue: ValueWithRelation | ValueWithRelation[] = React.useMemo(() => {
if (hasMany === true) {
return (
Array.isArray(value)
? value.map((val) => {
return isPolymorphic
? val
: {
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
value: val,
}
})
: value
) as ValueWithRelation[]
} else {
return (
value
? isPolymorphic
? value
: {
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
value,
}
: value
) as ValueWithRelation
}
}, [hasMany, value, isPolymorphic, relationTo])
const memoizedInitialValue: ValueWithRelation | ValueWithRelation[] = React.useMemo(() => {
if (hasMany === true) {
return (
Array.isArray(initialValue)
? initialValue.map((val) => {
return isPolymorphic
? val
: {
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
value: val,
}
})
: initialValue
) as ValueWithRelation[]
} else {
return (
initialValue
? isPolymorphic
? initialValue
: {
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
value: initialValue,
}
: initialValue
) as ValueWithRelation
}
}, [initialValue, isPolymorphic, relationTo, hasMany])
return (
<div
className={[
fieldBaseClass,
baseClass,
className,
showError && 'error',
errorLoading && 'error-loading',
(readOnly || disabled) && `${baseClass}--read-only`,
!(readOnly || disabled) && allowCreate && `${baseClass}--allow-create`,
]
.filter(Boolean)
.join(' ')}
id={`field-${path.replace(/\./g, '__')}`}
style={styles}
>
<RenderCustomComponent
CustomComponent={Label}
Fallback={
<FieldLabel label={label} localized={localized} path={path} required={required} />
}
/>
<div className={`${fieldBaseClass}__wrap`}>
<RenderCustomComponent
CustomComponent={Error}
Fallback={<FieldError path={path} showError={showError} />}
/>
{BeforeInput}
{!errorLoading && (
<div className={`${baseClass}__wrap`}>
<ReactSelect
backspaceRemovesValue={!(isDrawerOpen || isListDrawerOpen)}
components={{
MultiValueLabel,
SingleValue,
...(appearance !== 'select' && { DropdownIndicator: null }),
}}
customProps={{
disableKeyDown: isDrawerOpen || isListDrawerOpen,
disableMouseDown: isDrawerOpen || isListDrawerOpen,
onDocumentOpen,
onSave,
}}
disabled={readOnly || disabled || isDrawerOpen || isListDrawerOpen}
filterOption={enableWordBoundarySearch ? filterOption : undefined}
getOptionValue={(option) => {
if (!option) {
return undefined
}
return hasMany && Array.isArray(relationTo)
? `${option.relationTo}_${option.value}`
: (option.value as string)
}}
isLoading={appearance === 'select' && isLoading}
isMulti={hasMany}
isSearchable={appearance === 'select'}
<RelationshipInput
AfterInput={AfterInput}
allowCreate={allowCreate}
allowEdit={allowEdit}
appearance={appearance}
BeforeInput={BeforeInput}
className={className}
Description={Description}
description={description}
Error={Error}
filterOptions={filterOptions}
isSortable={isSortable}
menuIsOpen={appearance === 'select' ? menuIsOpen : false}
onChange={
!(readOnly || disabled)
? (selected) => {
if (selected === null) {
setValue(hasMany ? [] : null)
} else if (hasMany && Array.isArray(selected)) {
setValue(
selected
? selected.map((option) => {
if (hasMultipleRelations) {
return {
relationTo: option.relationTo,
value: option.value,
}
}
return option.value
})
: null,
)
} else if (hasMultipleRelations && !Array.isArray(selected)) {
setValue({
relationTo: selected.relationTo,
value: selected.value,
})
} else if (!Array.isArray(selected)) {
setValue(selected.value)
}
}
: undefined
}
onInputChange={(newSearch) => handleInputChange(newSearch, value)}
onMenuClose={() => {
setMenuIsOpen(false)
}}
onMenuOpen={() => {
if (appearance === 'drawer') {
openListDrawer()
} else if (appearance === 'select') {
setMenuIsOpen(true)
if (!hasLoadedFirstPageRef.current) {
setIsLoading(true)
void getResults({
filterOptions,
lastLoadedPage: {},
onSuccess: () => {
hasLoadedFirstPageRef.current = true
setIsLoading(false)
},
value: initialValue,
})
}
}
}}
onMenuScrollToBottom={() => {
void getResults({
filterOptions,
lastFullyLoadedRelation,
lastLoadedPage,
search,
sort: false,
value: initialValue,
})
}}
options={options}
placeholder={placeholder}
showError={showError}
value={valueToRender ?? null}
/>
{!(readOnly || disabled) && allowCreate && (
<AddNewRelation
hasMany={hasMany}
Label={Label}
label={label}
localized={localized}
maxResultsPerRequest={10}
maxRows={field?.maxRows}
minRows={field?.minRows}
path={path}
placeholder={placeholder}
readOnly={readOnly || disabled}
relationTo={relationTo}
setValue={setValue}
value={value}
required={required}
showError={showError}
sortOptions={sortOptions as any}
style={styles}
{...(hasMany === true
? {
hasMany: true,
initialValue: memoizedInitialValue as ValueWithRelation[],
onChange: handleChangeHasMulti,
value: memoizedValue as ValueWithRelation[],
}
: {
hasMany: false,
initialValue: memoizedInitialValue as ValueWithRelation,
onChange: handleChangeSingle,
value: memoizedValue as ValueWithRelation,
})}
/>
)}
</div>
)}
{errorLoading && <div className={`${baseClass}__error-loading`}>{errorLoading}</div>}
{AfterInput}
<RenderCustomComponent
CustomComponent={Description}
Fallback={<FieldDescription description={description} path={path} />}
/>
</div>
{currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && (
<DocumentDrawer onDelete={onDelete} onDuplicate={onDuplicate} onSave={onSave} />
)}
{appearance === 'drawer' && !readOnly && (
<ListDrawer allowCreate={allowCreate} enableRowSelections={false} onSelect={onListSelect} />
)}
</div>
)
}

View File

@@ -103,10 +103,7 @@ export const optionsReducer = (state: OptionGroup[], action: Action): OptionGrou
const clearedOptions = optionGroup.options.filter((option) => {
if (exemptValues) {
return exemptValues.some((exemptValue) => {
return (
exemptValue &&
option.value === (typeof exemptValue === 'object' ? exemptValue.value : exemptValue)
)
return exemptValue && option.value === exemptValue.value
})
}

View File

@@ -1,5 +1,13 @@
import type { I18nClient } from '@payloadcms/translations'
import type { ClientCollectionConfig, ClientConfig, FilterOptionsResult } from 'payload'
import type {
ClientCollectionConfig,
ClientConfig,
CollectionSlug,
FilterOptionsResult,
LabelFunction,
StaticDescription,
StaticLabel,
} from 'payload'
export type Option = {
allowEdit: boolean
@@ -14,15 +22,21 @@ export type OptionGroup = {
options: Option[]
}
export type ValueWithRelation = {
export type PolymorphicRelationValue = {
relationTo: string
value: number | string
}
export type Value = number | string | ValueWithRelation
export type MonomorphicRelationValue = number | string
export type Value =
| MonomorphicRelationValue
| MonomorphicRelationValue[]
| PolymorphicRelationValue
| PolymorphicRelationValue[]
type CLEAR = {
exemptValues?: Value | Value[]
exemptValues?: PolymorphicRelationValue | PolymorphicRelationValue[]
type: 'CLEAR'
}
@@ -54,12 +68,65 @@ type REMOVE = {
export type Action = ADD | CLEAR | REMOVE | UPDATE
export type GetResults = (args: {
export type HasManyValueUnion =
| {
hasMany: false
value?: PolymorphicRelationValue
}
| {
hasMany: true
value?: PolymorphicRelationValue[]
}
export type GetResults = (
args: {
filterOptions?: FilterOptionsResult
lastFullyLoadedRelation?: number
lastLoadedPage: Record<string, number>
onSuccess?: () => void
search?: string
sort?: boolean
value?: Value | Value[]
}) => Promise<void>
} & HasManyValueUnion,
) => Promise<void>
export type RelationshipInputProps = {
readonly AfterInput?: React.ReactNode
readonly allowCreate?: boolean
readonly allowEdit?: boolean
readonly appearance?: 'drawer' | 'select'
readonly BeforeInput?: React.ReactNode
readonly className?: string
readonly Description?: React.ReactNode
readonly description?: StaticDescription
readonly Error?: React.ReactNode
readonly filterOptions?: FilterOptionsResult
readonly isSortable?: boolean
readonly Label?: React.ReactNode
readonly label?: StaticLabel
readonly localized?: boolean
readonly maxResultsPerRequest?: number
readonly maxRows?: number
readonly minRows?: number
readonly path: string
readonly placeholder?: LabelFunction | string
readonly readOnly?: boolean
readonly relationTo: string[]
readonly required?: boolean
readonly showError?: boolean
readonly sortOptions?: Partial<Record<CollectionSlug, string>>
readonly style?: React.CSSProperties
} & SharedRelationshipInputProps
type SharedRelationshipInputProps =
| {
readonly hasMany: false
readonly initialValue?: null | PolymorphicRelationValue
readonly onChange: (value: PolymorphicRelationValue) => void
readonly value?: null | PolymorphicRelationValue
}
| {
readonly hasMany: true
readonly initialValue?: null | PolymorphicRelationValue[]
readonly onChange: (value: PolymorphicRelationValue[]) => void
readonly value?: null | PolymorphicRelationValue[]
}

View File

@@ -7,13 +7,13 @@ import { useCallback, useRef } from 'react'
* @param wait Wait period after function hasn't been called for
* @returns A memoized function that is debounced
*/
export const useDebouncedCallback = (func, wait) => {
export const useDebouncedCallback = <TFunctionArgs = any>(func, wait) => {
// Use a ref to store the timeout between renders
// and prevent changes to it from causing re-renders
const timeout = useRef<ReturnType<typeof setTimeout>>(undefined)
return useCallback(
(...args) => {
(...args: TFunctionArgs[]) => {
const later = () => {
clearTimeout(timeout.current)
func(...args)

View File

@@ -88,6 +88,7 @@ export interface Config {
'base-list-filters': BaseListFilter;
with300documents: With300Document;
'with-list-drawer': WithListDrawer;
placeholder: Placeholder;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -115,6 +116,7 @@ export interface Config {
'base-list-filters': BaseListFiltersSelect<false> | BaseListFiltersSelect<true>;
with300documents: With300DocumentsSelect<false> | With300DocumentsSelect<true>;
'with-list-drawer': WithListDrawerSelect<false> | WithListDrawerSelect<true>;
placeholder: PlaceholderSelect<false> | PlaceholderSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -484,6 +486,19 @@ export interface WithListDrawer {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "placeholder".
*/
export interface Placeholder {
id: string;
defaultSelect?: 'option1' | null;
placeholderSelect?: 'option1' | null;
defaultRelationship?: (string | null) | Post;
placeholderRelationship?: (string | null) | Post;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -574,6 +589,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'with-list-drawer';
value: string | WithListDrawer;
} | null)
| ({
relationTo: 'placeholder';
value: string | Placeholder;
} | null);
globalSlug?: string | null;
user: {
@@ -901,6 +920,18 @@ export interface WithListDrawerSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "placeholder_select".
*/
export interface PlaceholderSelect<T extends boolean = true> {
defaultSelect?: T;
placeholderSelect?: T;
defaultRelationship?: T;
placeholderRelationship?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -185,10 +185,21 @@ export async function login(args: LoginArgs): Promise<void> {
const { customAdminRoutes, customRoutes, data = devUser, page, serverURL } = args
const {
admin: { routes: { createFirstUser, login: incomingLoginRoute } = {} },
admin: {
routes: { createFirstUser, login: incomingLoginRoute, logout: incomingLogoutRoute } = {},
},
routes: { admin: incomingAdminRoute } = {},
} = getRoutes({ customAdminRoutes, customRoutes })
const logoutRoute = formatAdminURL({
serverURL,
adminRoute: incomingAdminRoute,
path: incomingLogoutRoute,
})
await page.goto(logoutRoute)
await wait(500)
const adminRoute = formatAdminURL({ serverURL, adminRoute: incomingAdminRoute, path: '' })
const loginRoute = formatAdminURL({
serverURL,

View File

@@ -128,10 +128,7 @@ export async function translateObject(props: {
for (const missingKey of missingKeys) {
const keys: string[] = missingKey.split('.')
const sourceText = keys.reduce(
(acc, key) => acc[key],
fromTranslationsObject,
)
const sourceText = keys.reduce((acc, key) => acc[key], fromTranslationsObject)
if (!sourceText || typeof sourceText !== 'string') {
throw new Error(
`Missing key ${missingKey} or key not "leaf" in fromTranslationsObject for lang ${targetLang}. (2)`,