feat(ui): extracts relationship input for external use (#12339)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
852
packages/ui/src/fields/Relationship/Input.tsx
Normal file
852
packages/ui/src/fields/Relationship/Input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +1,44 @@
|
||||
'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) => {
|
||||
return { ...map, [current]: [] }
|
||||
}, {})
|
||||
} else {
|
||||
relationMap = { [relationTo]: [] }
|
||||
}
|
||||
const relationMap: RelationMap = relationTo.reduce((map, current) => {
|
||||
return { ...map, [current]: [] }
|
||||
}, {})
|
||||
|
||||
if (value === null) {
|
||||
return relationMap
|
||||
}
|
||||
|
||||
const add = (relation: string, id: unknown) => {
|
||||
if ((typeof id === 'string' || typeof id === 'number') && typeof relation === 'string') {
|
||||
if (relationMap[relation]) {
|
||||
relationMap[relation].push(id)
|
||||
} else {
|
||||
relationMap[relation] = [id]
|
||||
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)
|
||||
} else {
|
||||
relationMap[relation] = [id]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMany && Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (
|
||||
hasMultipleRelations &&
|
||||
typeof val === 'object' &&
|
||||
'relationTo' in val &&
|
||||
'value' in 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) {
|
||||
if (hasMany === true) {
|
||||
value.forEach((val) => {
|
||||
if (val) {
|
||||
add(val.relationTo, val.value)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
add(value.relationTo, value.value)
|
||||
}
|
||||
} else {
|
||||
add(relationTo, value)
|
||||
}
|
||||
|
||||
return relationMap
|
||||
|
||||
@@ -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
|
||||
return option.value === val.value && option.relationTo === val.relationTo
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
return option.value === value.value && option.relationTo === value.relationTo
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 styles = useMemo(() => mergeFieldStyles(field), [field])
|
||||
const isPolymorphic = Array.isArray(relationToProp)
|
||||
const [relationTo] = React.useState(() =>
|
||||
Array.isArray(relationToProp) ? relationToProp : [relationToProp],
|
||||
)
|
||||
|
||||
const openDrawerWhenRelationChanges = useRef(false)
|
||||
|
||||
const getResults: GetResults = useCallback(
|
||||
async ({
|
||||
filterOptions,
|
||||
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
|
||||
lastLoadedPage: lastLoadedPageArg,
|
||||
onSuccess,
|
||||
search: searchArg,
|
||||
sort,
|
||||
value: valueArg,
|
||||
}) => {
|
||||
if (!permissions) {
|
||||
const handleChangeHasMulti = useCallback(
|
||||
(newValue: ValueWithRelation[]) => {
|
||||
if (!newValue) {
|
||||
setValue(null, newValue === value)
|
||||
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 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)
|
||||
}
|
||||
|
||||
let resultsFetched = 0
|
||||
const relationMap = createRelationMap({
|
||||
hasMany,
|
||||
relationTo,
|
||||
value: valueArg,
|
||||
const dataToSet = newValue.map((val) => {
|
||||
if (isPolymorphic) {
|
||||
return val
|
||||
} else {
|
||||
return val.value
|
||||
}
|
||||
})
|
||||
setValue(dataToSet, disableFormModication)
|
||||
},
|
||||
[isPolymorphic, setValue, value],
|
||||
)
|
||||
|
||||
if (!errorLoading) {
|
||||
await relationsToFetch.reduce(async (priorRelation, relation) => {
|
||||
const relationFilterOption = filterOptions?.[relation]
|
||||
const handleChangeSingle = useCallback(
|
||||
(newValue: ValueWithRelation) => {
|
||||
if (!newValue) {
|
||||
setValue(null, newValue === value)
|
||||
return
|
||||
}
|
||||
|
||||
let lastLoadedPageToUse
|
||||
if (search !== searchArg) {
|
||||
lastLoadedPageToUse = 1
|
||||
} else {
|
||||
lastLoadedPageToUse = lastLoadedPageArg[relation] + 1
|
||||
}
|
||||
await priorRelation
|
||||
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
|
||||
}
|
||||
|
||||
if (relationFilterOption === false) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
||||
return Promise.resolve()
|
||||
}
|
||||
const dataToSet = isPolymorphic ? newValue : newValue.value
|
||||
setValue(dataToSet, disableFormModication)
|
||||
},
|
||||
[isPolymorphic, setValue, value],
|
||||
)
|
||||
|
||||
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',
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: PaginatedDocs<unknown> = await response.json()
|
||||
setLastLoadedPage((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
[relation]: lastLoadedPageToUse,
|
||||
}
|
||||
})
|
||||
|
||||
if (!data.nextPage) {
|
||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
||||
: value
|
||||
) as ValueWithRelation[]
|
||||
} else {
|
||||
return (
|
||||
value
|
||||
? isPolymorphic
|
||||
? value
|
||||
: {
|
||||
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
|
||||
value,
|
||||
}
|
||||
: value
|
||||
) as ValueWithRelation
|
||||
}
|
||||
}, [hasMany, value, isPolymorphic, relationTo])
|
||||
|
||||
if (data.docs.length > 0) {
|
||||
resultsFetched += data.docs.length
|
||||
|
||||
dispatchOptions({
|
||||
type: 'ADD',
|
||||
collection,
|
||||
config,
|
||||
docs: data.docs,
|
||||
i18n,
|
||||
sort,
|
||||
})
|
||||
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,
|
||||
}
|
||||
} 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)
|
||||
: initialValue
|
||||
) as ValueWithRelation
|
||||
}
|
||||
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])
|
||||
}, [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, '__')}`}
|
||||
<RelationshipInput
|
||||
AfterInput={AfterInput}
|
||||
allowCreate={allowCreate}
|
||||
allowEdit={allowEdit}
|
||||
appearance={appearance}
|
||||
BeforeInput={BeforeInput}
|
||||
className={className}
|
||||
Description={Description}
|
||||
description={description}
|
||||
Error={Error}
|
||||
filterOptions={filterOptions}
|
||||
isSortable={isSortable}
|
||||
Label={Label}
|
||||
label={label}
|
||||
localized={localized}
|
||||
maxResultsPerRequest={10}
|
||||
maxRows={field?.maxRows}
|
||||
minRows={field?.minRows}
|
||||
path={path}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly || disabled}
|
||||
relationTo={relationTo}
|
||||
required={required}
|
||||
showError={showError}
|
||||
sortOptions={sortOptions as any}
|
||||
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'}
|
||||
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}
|
||||
path={path}
|
||||
relationTo={relationTo}
|
||||
setValue={setValue}
|
||||
value={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>
|
||||
{...(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,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
filterOptions?: FilterOptionsResult
|
||||
lastFullyLoadedRelation?: number
|
||||
lastLoadedPage: Record<string, number>
|
||||
onSuccess?: () => void
|
||||
search?: string
|
||||
sort?: boolean
|
||||
value?: Value | Value[]
|
||||
}) => Promise<void>
|
||||
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
|
||||
} & 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[]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
Reference in New Issue
Block a user