feat(ui): extracts relationship input for external use (#12339)
This commit is contained in:
@@ -22,7 +22,9 @@ const updateEnvExampleVariables = (
|
|||||||
|
|
||||||
const [key] = line.split('=')
|
const [key] = line.split('=')
|
||||||
|
|
||||||
if (!key) {return}
|
if (!key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'DATABASE_URI' || key === 'POSTGRES_URL' || key === 'MONGODB_URI') {
|
if (key === 'DATABASE_URI' || key === 'POSTGRES_URL' || key === 'MONGODB_URI') {
|
||||||
const dbChoice = databaseType ? dbChoiceRecord[databaseType] : null
|
const dbChoice = databaseType ? dbChoiceRecord[databaseType] : null
|
||||||
|
|||||||
@@ -1208,7 +1208,7 @@ export type PolymorphicRelationshipField = {
|
|||||||
|
|
||||||
export type PolymorphicRelationshipFieldClient = {
|
export type PolymorphicRelationshipFieldClient = {
|
||||||
admin?: {
|
admin?: {
|
||||||
sortOptions?: Pick<PolymorphicRelationshipField['admin'], 'sortOptions'>
|
sortOptions?: PolymorphicRelationshipField['admin']['sortOptions']
|
||||||
} & RelationshipAdminClient
|
} & RelationshipAdminClient
|
||||||
} & Pick<PolymorphicRelationshipField, 'relationTo'> &
|
} & Pick<PolymorphicRelationshipField, 'relationTo'> &
|
||||||
SharedRelationshipPropertiesClient
|
SharedRelationshipPropertiesClient
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { ClientCollectionConfig } from 'payload'
|
|||||||
import { getTranslation } from '@payloadcms/translations'
|
import { getTranslation } from '@payloadcms/translations'
|
||||||
import React, { Fragment, useCallback, useEffect, useState } from 'react'
|
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 { DocumentDrawerContextType } from '../DocumentDrawer/Provider.js'
|
||||||
import type { Props } from './types.js'
|
import type { Props } from './types.js'
|
||||||
|
|
||||||
@@ -24,9 +23,9 @@ const baseClass = 'relationship-add-new'
|
|||||||
export const AddNewRelation: React.FC<Props> = ({
|
export const AddNewRelation: React.FC<Props> = ({
|
||||||
Button: ButtonFromProps,
|
Button: ButtonFromProps,
|
||||||
hasMany,
|
hasMany,
|
||||||
|
onChange,
|
||||||
path,
|
path,
|
||||||
relationTo,
|
relationTo,
|
||||||
setValue,
|
|
||||||
unstyled,
|
unstyled,
|
||||||
value,
|
value,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -54,18 +53,15 @@ export const AddNewRelation: React.FC<Props> = ({
|
|||||||
const onSave: DocumentDrawerContextType['onSave'] = useCallback(
|
const onSave: DocumentDrawerContextType['onSave'] = useCallback(
|
||||||
({ doc, operation }) => {
|
({ doc, operation }) => {
|
||||||
if (operation === 'create') {
|
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
|
// ensure the value is not already in the array
|
||||||
const isNewValue =
|
let isNewValue = false
|
||||||
Array.isArray(relationTo) && Array.isArray(value)
|
if (!value) {
|
||||||
? !value.some((v) => v && typeof v === 'object' && v.value === doc.id)
|
isNewValue = true
|
||||||
: value !== doc.id
|
} else {
|
||||||
|
isNewValue = Array.isArray(value)
|
||||||
|
? !value.some((v) => v && v.value === doc.id)
|
||||||
|
: value.value !== doc.id
|
||||||
|
}
|
||||||
|
|
||||||
if (isNewValue) {
|
if (isNewValue) {
|
||||||
// dispatchOptions({
|
// dispatchOptions({
|
||||||
@@ -79,17 +75,26 @@ export const AddNewRelation: React.FC<Props> = ({
|
|||||||
// sort: true,
|
// sort: true,
|
||||||
// })
|
// })
|
||||||
|
|
||||||
if (hasMany) {
|
if (hasMany === true) {
|
||||||
setValue([...(Array.isArray(value) ? value : []), newValue])
|
onChange([
|
||||||
|
...(Array.isArray(value) ? value : []),
|
||||||
|
{
|
||||||
|
relationTo: collectionConfig?.slug,
|
||||||
|
value: doc.id,
|
||||||
|
},
|
||||||
|
])
|
||||||
} else {
|
} else {
|
||||||
setValue(newValue)
|
onChange({
|
||||||
|
relationTo: relatedCollections[0].slug,
|
||||||
|
value: doc.id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedCollection(undefined)
|
setSelectedCollection(undefined)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[relationTo, collectionConfig, hasMany, setValue, value],
|
[collectionConfig, hasMany, onChange, value, relatedCollections],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPopupToggle = useCallback((state) => {
|
const onPopupToggle = useCallback((state) => {
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { Value } from '../../fields/Relationship/types.js'
|
import type { ValueWithRelation } from 'payload'
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
readonly Button?: React.ReactNode
|
readonly Button?: React.ReactNode
|
||||||
readonly hasMany: boolean
|
|
||||||
readonly path: string
|
readonly path: string
|
||||||
readonly relationTo: string | string[]
|
readonly relationTo: string | string[]
|
||||||
readonly setValue: (value: unknown) => void
|
|
||||||
readonly unstyled?: boolean
|
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 { PasswordField } from '../../fields/Password/index.js'
|
||||||
export { PointField } from '../../fields/Point/index.js'
|
export { PointField } from '../../fields/Point/index.js'
|
||||||
export { RadioGroupField } from '../../fields/RadioGroup/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 { RichTextField } from '../../fields/RichText/index.js'
|
||||||
export { RowField } from '../../fields/Row/index.js'
|
export { RowField } from '../../fields/Row/index.js'
|
||||||
export { SelectField, SelectInput } from '../../fields/Select/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'
|
'use client'
|
||||||
import type { Value } from './types.js'
|
import type { HasManyValueUnion } from './types.js'
|
||||||
|
|
||||||
type RelationMap = {
|
type RelationMap = {
|
||||||
[relation: string]: (number | string)[]
|
[relation: string]: (number | string)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRelationMap = (args: {
|
type CreateRelationMap = (
|
||||||
hasMany: boolean
|
args: {
|
||||||
relationTo: string | string[]
|
relationTo: string[]
|
||||||
value: null | Value | Value[] // really needs to be `ValueWithRelation`
|
} & HasManyValueUnion,
|
||||||
}) => RelationMap
|
) => RelationMap
|
||||||
|
|
||||||
export const createRelationMap: CreateRelationMap = ({ hasMany, relationTo, value }) => {
|
export const createRelationMap: CreateRelationMap = ({ hasMany, relationTo, value }) => {
|
||||||
const hasMultipleRelations = Array.isArray(relationTo)
|
const relationMap: RelationMap = relationTo.reduce((map, current) => {
|
||||||
let relationMap: RelationMap
|
return { ...map, [current]: [] }
|
||||||
if (Array.isArray(relationTo)) {
|
}, {})
|
||||||
relationMap = relationTo.reduce((map, current) => {
|
|
||||||
return { ...map, [current]: [] }
|
|
||||||
}, {})
|
|
||||||
} else {
|
|
||||||
relationMap = { [relationTo]: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return relationMap
|
return relationMap
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = (relation: string, id: unknown) => {
|
if (value) {
|
||||||
if ((typeof id === 'string' || typeof id === 'number') && typeof relation === 'string') {
|
const add = (relation: string, id: number | string) => {
|
||||||
if (relationMap[relation]) {
|
if ((typeof id === 'string' || typeof id === 'number') && typeof relation === 'string') {
|
||||||
relationMap[relation].push(id)
|
if (relationMap[relation]) {
|
||||||
} else {
|
relationMap[relation].push(id)
|
||||||
relationMap[relation] = [id]
|
} else {
|
||||||
|
relationMap[relation] = [id]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (hasMany === true) {
|
||||||
|
value.forEach((val) => {
|
||||||
if (hasMany && Array.isArray(value)) {
|
if (val) {
|
||||||
value.forEach((val) => {
|
add(val.relationTo, val.value)
|
||||||
if (
|
}
|
||||||
hasMultipleRelations &&
|
})
|
||||||
typeof val === 'object' &&
|
} else {
|
||||||
'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) {
|
|
||||||
add(value.relationTo, value.value)
|
add(value.relationTo, value.value)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
add(relationTo, value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return relationMap
|
return relationMap
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import type { ValueWithRelation } from 'payload'
|
||||||
|
|
||||||
import type { Option } from '../../elements/ReactSelect/types.js'
|
import type { Option } from '../../elements/ReactSelect/types.js'
|
||||||
import type { OptionGroup, Value } from './types.js'
|
import type { OptionGroup } from './types.js'
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
allowEdit: boolean
|
allowEdit: boolean
|
||||||
options: OptionGroup[]
|
options: OptionGroup[]
|
||||||
value: Value | Value[]
|
value: ValueWithRelation | ValueWithRelation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const findOptionsByValue = ({ allowEdit, options, value }: Args): Option | Option[] => {
|
export const findOptionsByValue = ({ allowEdit, options, value }: Args): Option | Option[] => {
|
||||||
@@ -17,11 +19,7 @@ export const findOptionsByValue = ({ allowEdit, options, value }: Args): Option
|
|||||||
options.forEach((optGroup) => {
|
options.forEach((optGroup) => {
|
||||||
if (!matchedOption) {
|
if (!matchedOption) {
|
||||||
matchedOption = optGroup.options.find((option) => {
|
matchedOption = optGroup.options.find((option) => {
|
||||||
if (typeof val === 'object') {
|
return option.value === val.value && option.relationTo === val.relationTo
|
||||||
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) => {
|
options.forEach((optGroup) => {
|
||||||
if (!matchedOption) {
|
if (!matchedOption) {
|
||||||
matchedOption = optGroup.options.find((option) => {
|
matchedOption = optGroup.options.find((option) => {
|
||||||
if (typeof value === 'object') {
|
return option.value === value.value && option.relationTo === value.relationTo
|
||||||
return option.value === value.value && option.relationTo === value.relationTo
|
|
||||||
}
|
|
||||||
return value === option.value
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,50 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type {
|
import type { RelationshipFieldClientComponent, ValueWithRelation } from 'payload'
|
||||||
FilterOptionsResult,
|
|
||||||
PaginatedDocs,
|
|
||||||
RelationshipFieldClientComponent,
|
|
||||||
Where,
|
|
||||||
} from 'payload'
|
|
||||||
|
|
||||||
import { dequal } from 'dequal/lite'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { formatAdminURL, wordBoundariesRegex } from 'payload/shared'
|
|
||||||
import * as qs from 'qs-esm'
|
|
||||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
import type { DocumentDrawerProps } from '../../elements/DocumentDrawer/types.js'
|
import type { PolymorphicRelationValue, Value } from './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 { 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 { useField } from '../../forms/useField/index.js'
|
||||||
import { withCondition } from '../../forms/withCondition/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 { mergeFieldStyles } from '../mergeFieldStyles.js'
|
||||||
import { fieldBaseClass } from '../shared/index.js'
|
import { RelationshipInput } from './Input.js'
|
||||||
import { createRelationMap } from './createRelationMap.js'
|
import './index.scss'
|
||||||
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'
|
|
||||||
|
|
||||||
const maxResultsPerRequest = 10
|
export { RelationshipInput }
|
||||||
|
|
||||||
const baseClass = 'relationship'
|
|
||||||
|
|
||||||
const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => {
|
const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => {
|
||||||
const {
|
const {
|
||||||
@@ -63,7 +30,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
|
|||||||
hasMany,
|
hasMany,
|
||||||
label,
|
label,
|
||||||
localized,
|
localized,
|
||||||
relationTo,
|
relationTo: relationToProp,
|
||||||
required,
|
required,
|
||||||
},
|
},
|
||||||
path: pathFromProps,
|
path: pathFromProps,
|
||||||
@@ -71,35 +38,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
|
|||||||
validate,
|
validate,
|
||||||
} = props
|
} = 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(
|
const memoizedValidate = useCallback(
|
||||||
(value, validationOptions) => {
|
(value, validationOptions) => {
|
||||||
if (typeof validate === 'function') {
|
if (typeof validate === 'function') {
|
||||||
@@ -118,713 +56,175 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
|
|||||||
setValue,
|
setValue,
|
||||||
showError,
|
showError,
|
||||||
value,
|
value,
|
||||||
} = useField<Value | Value[]>({
|
} = useField<Value>({
|
||||||
potentiallyStalePath: pathFromProps,
|
potentiallyStalePath: pathFromProps,
|
||||||
validate: memoizedValidate,
|
validate: memoizedValidate,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [options, dispatchOptions] = useReducer(optionsReducer, [])
|
const styles = useMemo(() => mergeFieldStyles(field), [field])
|
||||||
|
const isPolymorphic = Array.isArray(relationToProp)
|
||||||
const valueRef = useRef(value)
|
const [relationTo] = React.useState(() =>
|
||||||
valueRef.current = value
|
Array.isArray(relationToProp) ? relationToProp : [relationToProp],
|
||||||
|
|
||||||
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 handleChangeHasMulti = useCallback(
|
||||||
|
(newValue: ValueWithRelation[]) => {
|
||||||
const getResults: GetResults = useCallback(
|
if (!newValue) {
|
||||||
async ({
|
setValue(null, newValue === value)
|
||||||
filterOptions,
|
|
||||||
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
|
|
||||||
lastLoadedPage: lastLoadedPageArg,
|
|
||||||
onSuccess,
|
|
||||||
search: searchArg,
|
|
||||||
sort,
|
|
||||||
value: valueArg,
|
|
||||||
}) => {
|
|
||||||
if (!permissions) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lastFullyLoadedRelationToUse =
|
|
||||||
typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1
|
|
||||||
|
|
||||||
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
|
let disableFormModication = false
|
||||||
const relationsToFetch =
|
if (isPolymorphic) {
|
||||||
lastFullyLoadedRelationToUse === -1
|
disableFormModication =
|
||||||
? relations
|
Array.isArray(value) &&
|
||||||
: relations.slice(lastFullyLoadedRelationToUse + 1)
|
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 dataToSet = newValue.map((val) => {
|
||||||
const relationMap = createRelationMap({
|
if (isPolymorphic) {
|
||||||
hasMany,
|
return val
|
||||||
relationTo,
|
} else {
|
||||||
value: valueArg,
|
return val.value
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
setValue(dataToSet, disableFormModication)
|
||||||
|
},
|
||||||
|
[isPolymorphic, setValue, value],
|
||||||
|
)
|
||||||
|
|
||||||
if (!errorLoading) {
|
const handleChangeSingle = useCallback(
|
||||||
await relationsToFetch.reduce(async (priorRelation, relation) => {
|
(newValue: ValueWithRelation) => {
|
||||||
const relationFilterOption = filterOptions?.[relation]
|
if (!newValue) {
|
||||||
|
setValue(null, newValue === value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let lastLoadedPageToUse
|
let disableFormModication = false
|
||||||
if (search !== searchArg) {
|
if (isPolymorphic) {
|
||||||
lastLoadedPageToUse = 1
|
disableFormModication =
|
||||||
} else {
|
value &&
|
||||||
lastLoadedPageToUse = lastLoadedPageArg[relation] + 1
|
newValue &&
|
||||||
}
|
(value as PolymorphicRelationValue).value === newValue.value &&
|
||||||
await priorRelation
|
(value as PolymorphicRelationValue).relationTo === newValue.relationTo
|
||||||
|
} else {
|
||||||
|
disableFormModication = value && newValue && value === newValue.value
|
||||||
|
}
|
||||||
|
|
||||||
if (relationFilterOption === false) {
|
const dataToSet = isPolymorphic ? newValue : newValue.value
|
||||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
setValue(dataToSet, disableFormModication)
|
||||||
return Promise.resolve()
|
},
|
||||||
}
|
[isPolymorphic, setValue, value],
|
||||||
|
)
|
||||||
|
|
||||||
if (resultsFetched < 10) {
|
const memoizedValue: ValueWithRelation | ValueWithRelation[] = React.useMemo(() => {
|
||||||
const collection = getEntityConfig({ collectionSlug: relation })
|
if (hasMany === true) {
|
||||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id'
|
return (
|
||||||
let fieldToSort = collection?.defaultSort || 'id'
|
Array.isArray(value)
|
||||||
if (typeof sortOptions === 'string') {
|
? value.map((val) => {
|
||||||
fieldToSort = sortOptions
|
return isPolymorphic
|
||||||
} else if (sortOptions?.[relation]) {
|
? val
|
||||||
fieldToSort = sortOptions[relation]
|
: {
|
||||||
}
|
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
|
||||||
|
value: val,
|
||||||
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',
|
|
||||||
})
|
})
|
||||||
|
: value
|
||||||
if (response.ok) {
|
) as ValueWithRelation[]
|
||||||
const data: PaginatedDocs<unknown> = await response.json()
|
} else {
|
||||||
setLastLoadedPage((prevState) => {
|
return (
|
||||||
return {
|
value
|
||||||
...prevState,
|
? isPolymorphic
|
||||||
[relation]: lastLoadedPageToUse,
|
? value
|
||||||
}
|
: {
|
||||||
})
|
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
|
||||||
|
value,
|
||||||
if (!data.nextPage) {
|
|
||||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
|
||||||
}
|
}
|
||||||
|
: value
|
||||||
|
) as ValueWithRelation
|
||||||
|
}
|
||||||
|
}, [hasMany, value, isPolymorphic, relationTo])
|
||||||
|
|
||||||
if (data.docs.length > 0) {
|
const memoizedInitialValue: ValueWithRelation | ValueWithRelation[] = React.useMemo(() => {
|
||||||
resultsFetched += data.docs.length
|
if (hasMany === true) {
|
||||||
|
return (
|
||||||
dispatchOptions({
|
Array.isArray(initialValue)
|
||||||
type: 'ADD',
|
? initialValue.map((val) => {
|
||||||
collection,
|
return isPolymorphic
|
||||||
config,
|
? val
|
||||||
docs: data.docs,
|
: {
|
||||||
i18n,
|
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
|
||||||
sort,
|
value: val,
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
: initialValue
|
||||||
|
) as ValueWithRelation[]
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
initialValue
|
||||||
|
? isPolymorphic
|
||||||
|
? initialValue
|
||||||
|
: {
|
||||||
|
relationTo: Array.isArray(relationTo) ? relationTo[0] : relationTo,
|
||||||
|
value: initialValue,
|
||||||
}
|
}
|
||||||
} else if (response.status === 403) {
|
: initialValue
|
||||||
setLastFullyLoadedRelation(relations.indexOf(relation))
|
) as ValueWithRelation
|
||||||
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
|
}, [initialValue, isPolymorphic, relationTo, hasMany])
|
||||||
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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<RelationshipInput
|
||||||
className={[
|
AfterInput={AfterInput}
|
||||||
fieldBaseClass,
|
allowCreate={allowCreate}
|
||||||
baseClass,
|
allowEdit={allowEdit}
|
||||||
className,
|
appearance={appearance}
|
||||||
showError && 'error',
|
BeforeInput={BeforeInput}
|
||||||
errorLoading && 'error-loading',
|
className={className}
|
||||||
(readOnly || disabled) && `${baseClass}--read-only`,
|
Description={Description}
|
||||||
!(readOnly || disabled) && allowCreate && `${baseClass}--allow-create`,
|
description={description}
|
||||||
]
|
Error={Error}
|
||||||
.filter(Boolean)
|
filterOptions={filterOptions}
|
||||||
.join(' ')}
|
isSortable={isSortable}
|
||||||
id={`field-${path.replace(/\./g, '__')}`}
|
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}
|
style={styles}
|
||||||
>
|
{...(hasMany === true
|
||||||
<RenderCustomComponent
|
? {
|
||||||
CustomComponent={Label}
|
hasMany: true,
|
||||||
Fallback={
|
initialValue: memoizedInitialValue as ValueWithRelation[],
|
||||||
<FieldLabel label={label} localized={localized} path={path} required={required} />
|
onChange: handleChangeHasMulti,
|
||||||
}
|
value: memoizedValue as ValueWithRelation[],
|
||||||
/>
|
}
|
||||||
<div className={`${fieldBaseClass}__wrap`}>
|
: {
|
||||||
<RenderCustomComponent
|
hasMany: false,
|
||||||
CustomComponent={Error}
|
initialValue: memoizedInitialValue as ValueWithRelation,
|
||||||
Fallback={<FieldError path={path} showError={showError} />}
|
onChange: handleChangeSingle,
|
||||||
/>
|
value: memoizedValue as ValueWithRelation,
|
||||||
{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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ export const optionsReducer = (state: OptionGroup[], action: Action): OptionGrou
|
|||||||
const clearedOptions = optionGroup.options.filter((option) => {
|
const clearedOptions = optionGroup.options.filter((option) => {
|
||||||
if (exemptValues) {
|
if (exemptValues) {
|
||||||
return exemptValues.some((exemptValue) => {
|
return exemptValues.some((exemptValue) => {
|
||||||
return (
|
return exemptValue && option.value === exemptValue.value
|
||||||
exemptValue &&
|
|
||||||
option.value === (typeof exemptValue === 'object' ? exemptValue.value : exemptValue)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { I18nClient } from '@payloadcms/translations'
|
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 = {
|
export type Option = {
|
||||||
allowEdit: boolean
|
allowEdit: boolean
|
||||||
@@ -14,15 +22,21 @@ export type OptionGroup = {
|
|||||||
options: Option[]
|
options: Option[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValueWithRelation = {
|
export type PolymorphicRelationValue = {
|
||||||
relationTo: string
|
relationTo: string
|
||||||
value: number | string
|
value: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Value = number | string | ValueWithRelation
|
export type MonomorphicRelationValue = number | string
|
||||||
|
|
||||||
|
export type Value =
|
||||||
|
| MonomorphicRelationValue
|
||||||
|
| MonomorphicRelationValue[]
|
||||||
|
| PolymorphicRelationValue
|
||||||
|
| PolymorphicRelationValue[]
|
||||||
|
|
||||||
type CLEAR = {
|
type CLEAR = {
|
||||||
exemptValues?: Value | Value[]
|
exemptValues?: PolymorphicRelationValue | PolymorphicRelationValue[]
|
||||||
type: 'CLEAR'
|
type: 'CLEAR'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +68,65 @@ type REMOVE = {
|
|||||||
|
|
||||||
export type Action = ADD | CLEAR | REMOVE | UPDATE
|
export type Action = ADD | CLEAR | REMOVE | UPDATE
|
||||||
|
|
||||||
export type GetResults = (args: {
|
export type HasManyValueUnion =
|
||||||
filterOptions?: FilterOptionsResult
|
| {
|
||||||
lastFullyLoadedRelation?: number
|
hasMany: false
|
||||||
lastLoadedPage: Record<string, number>
|
value?: PolymorphicRelationValue
|
||||||
onSuccess?: () => void
|
}
|
||||||
search?: string
|
| {
|
||||||
sort?: boolean
|
hasMany: true
|
||||||
value?: Value | Value[]
|
value?: PolymorphicRelationValue[]
|
||||||
}) => Promise<void>
|
}
|
||||||
|
|
||||||
|
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
|
* @param wait Wait period after function hasn't been called for
|
||||||
* @returns A memoized function that is debounced
|
* @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
|
// Use a ref to store the timeout between renders
|
||||||
// and prevent changes to it from causing re-renders
|
// and prevent changes to it from causing re-renders
|
||||||
const timeout = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const timeout = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(...args) => {
|
(...args: TFunctionArgs[]) => {
|
||||||
const later = () => {
|
const later = () => {
|
||||||
clearTimeout(timeout.current)
|
clearTimeout(timeout.current)
|
||||||
func(...args)
|
func(...args)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export interface Config {
|
|||||||
'base-list-filters': BaseListFilter;
|
'base-list-filters': BaseListFilter;
|
||||||
with300documents: With300Document;
|
with300documents: With300Document;
|
||||||
'with-list-drawer': WithListDrawer;
|
'with-list-drawer': WithListDrawer;
|
||||||
|
placeholder: Placeholder;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
'payload-migrations': PayloadMigration;
|
'payload-migrations': PayloadMigration;
|
||||||
@@ -115,6 +116,7 @@ export interface Config {
|
|||||||
'base-list-filters': BaseListFiltersSelect<false> | BaseListFiltersSelect<true>;
|
'base-list-filters': BaseListFiltersSelect<false> | BaseListFiltersSelect<true>;
|
||||||
with300documents: With300DocumentsSelect<false> | With300DocumentsSelect<true>;
|
with300documents: With300DocumentsSelect<false> | With300DocumentsSelect<true>;
|
||||||
'with-list-drawer': WithListDrawerSelect<false> | WithListDrawerSelect<true>;
|
'with-list-drawer': WithListDrawerSelect<false> | WithListDrawerSelect<true>;
|
||||||
|
placeholder: PlaceholderSelect<false> | PlaceholderSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
@@ -484,6 +486,19 @@ export interface WithListDrawer {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-locked-documents".
|
* via the `definition` "payload-locked-documents".
|
||||||
@@ -574,6 +589,10 @@ export interface PayloadLockedDocument {
|
|||||||
| ({
|
| ({
|
||||||
relationTo: 'with-list-drawer';
|
relationTo: 'with-list-drawer';
|
||||||
value: string | WithListDrawer;
|
value: string | WithListDrawer;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'placeholder';
|
||||||
|
value: string | Placeholder;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user: {
|
user: {
|
||||||
@@ -901,6 +920,18 @@ export interface WithListDrawerSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-locked-documents_select".
|
* 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 { customAdminRoutes, customRoutes, data = devUser, page, serverURL } = args
|
||||||
|
|
||||||
const {
|
const {
|
||||||
admin: { routes: { createFirstUser, login: incomingLoginRoute } = {} },
|
admin: {
|
||||||
|
routes: { createFirstUser, login: incomingLoginRoute, logout: incomingLogoutRoute } = {},
|
||||||
|
},
|
||||||
routes: { admin: incomingAdminRoute } = {},
|
routes: { admin: incomingAdminRoute } = {},
|
||||||
} = getRoutes({ customAdminRoutes, customRoutes })
|
} = 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 adminRoute = formatAdminURL({ serverURL, adminRoute: incomingAdminRoute, path: '' })
|
||||||
const loginRoute = formatAdminURL({
|
const loginRoute = formatAdminURL({
|
||||||
serverURL,
|
serverURL,
|
||||||
|
|||||||
@@ -128,10 +128,7 @@ export async function translateObject(props: {
|
|||||||
|
|
||||||
for (const missingKey of missingKeys) {
|
for (const missingKey of missingKeys) {
|
||||||
const keys: string[] = missingKey.split('.')
|
const keys: string[] = missingKey.split('.')
|
||||||
const sourceText = keys.reduce(
|
const sourceText = keys.reduce((acc, key) => acc[key], fromTranslationsObject)
|
||||||
(acc, key) => acc[key],
|
|
||||||
fromTranslationsObject,
|
|
||||||
)
|
|
||||||
if (!sourceText || typeof sourceText !== 'string') {
|
if (!sourceText || typeof sourceText !== 'string') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Missing key ${missingKey} or key not "leaf" in fromTranslationsObject for lang ${targetLang}. (2)`,
|
`Missing key ${missingKey} or key not "leaf" in fromTranslationsObject for lang ${targetLang}. (2)`,
|
||||||
|
|||||||
Reference in New Issue
Block a user