From 88769c824423362db5d89919a52d576dee8ab38f Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Thu, 15 May 2025 14:54:26 -0400 Subject: [PATCH] feat(ui): extracts relationship input for external use (#12339) --- .../src/lib/manage-env-files.ts | 4 +- packages/payload/src/fields/config/types.ts | 2 +- .../ui/src/elements/AddNewRelation/index.tsx | 39 +- .../ui/src/elements/AddNewRelation/types.ts | 19 +- packages/ui/src/exports/client/index.ts | 2 +- packages/ui/src/fields/Relationship/Input.tsx | 852 ++++++++++++++++ .../fields/Relationship/createRelationMap.ts | 66 +- .../fields/Relationship/findOptionsByValue.ts | 17 +- packages/ui/src/fields/Relationship/index.tsx | 906 +++--------------- .../src/fields/Relationship/optionsReducer.ts | 5 +- packages/ui/src/fields/Relationship/types.ts | 93 +- packages/ui/src/hooks/useDebouncedCallback.ts | 4 +- test/admin/payload-types.ts | 31 + test/helpers.ts | 13 +- .../src/generateTranslations/utils/index.ts | 5 +- 15 files changed, 1203 insertions(+), 855 deletions(-) create mode 100644 packages/ui/src/fields/Relationship/Input.tsx diff --git a/packages/create-payload-app/src/lib/manage-env-files.ts b/packages/create-payload-app/src/lib/manage-env-files.ts index 6ea4ba364b..aa87e4d6e0 100644 --- a/packages/create-payload-app/src/lib/manage-env-files.ts +++ b/packages/create-payload-app/src/lib/manage-env-files.ts @@ -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 diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 65fc86105c..55642663fc 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1208,7 +1208,7 @@ export type PolymorphicRelationshipField = { export type PolymorphicRelationshipFieldClient = { admin?: { - sortOptions?: Pick + sortOptions?: PolymorphicRelationshipField['admin']['sortOptions'] } & RelationshipAdminClient } & Pick & SharedRelationshipPropertiesClient diff --git a/packages/ui/src/elements/AddNewRelation/index.tsx b/packages/ui/src/elements/AddNewRelation/index.tsx index 0d1aa15322..9aefd0b0bf 100644 --- a/packages/ui/src/elements/AddNewRelation/index.tsx +++ b/packages/ui/src/elements/AddNewRelation/index.tsx @@ -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 = ({ Button: ButtonFromProps, hasMany, + onChange, path, relationTo, - setValue, unstyled, value, }) => { @@ -54,18 +53,15 @@ export const AddNewRelation: React.FC = ({ 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 = ({ // 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) => { diff --git a/packages/ui/src/elements/AddNewRelation/types.ts b/packages/ui/src/elements/AddNewRelation/types.ts index 11be593804..f57577f2b9 100644 --- a/packages/ui/src/elements/AddNewRelation/types.ts +++ b/packages/ui/src/elements/AddNewRelation/types.ts @@ -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[] + } diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index e4de8e3f59..255e18130f 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -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' diff --git a/packages/ui/src/fields/Relationship/Input.tsx b/packages/ui/src/fields/Relationship/Input.tsx new file mode 100644 index 0000000000..cab658c5e1 --- /dev/null +++ b/packages/ui/src/fields/Relationship/Input.tsx @@ -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 = (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[0] + >({ + id: undefined, + collectionSlug: undefined, + hasReadPermission: false, + }) + + const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1) + const [lastLoadedPage, setLastLoadedPage] = useState>({}) + 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(() => { + 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>( + ({ 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 = 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( + (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( + (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( + (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( + ({ 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 ( +
+ + } + /> +
+ } + /> + {BeforeInput} + {!errorLoading && ( +
+ { + 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 && ( + + )} +
+ )} + {errorLoading &&
{errorLoading}
} + {AfterInput} + } + /> +
+ {currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && ( + + )} + {appearance === 'drawer' && !readOnly && ( + + )} +
+ ) +} diff --git a/packages/ui/src/fields/Relationship/createRelationMap.ts b/packages/ui/src/fields/Relationship/createRelationMap.ts index 6a3d030c2a..5bc52d23c6 100644 --- a/packages/ui/src/fields/Relationship/createRelationMap.ts +++ b/packages/ui/src/fields/Relationship/createRelationMap.ts @@ -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 diff --git a/packages/ui/src/fields/Relationship/findOptionsByValue.ts b/packages/ui/src/fields/Relationship/findOptionsByValue.ts index 82c9132d5a..8f27f1e3e9 100644 --- a/packages/ui/src/fields/Relationship/findOptionsByValue.ts +++ b/packages/ui/src/fields/Relationship/findOptionsByValue.ts @@ -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 }) } }) diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index dc50b3d309..a4b4f6dd04 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -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[0] - >({ - id: undefined, - collectionSlug: undefined, - hasReadPermission: false, - }) - - const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1) - const [lastLoadedPage, setLastLoadedPage] = useState>({}) - 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({ + } = useField({ 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(() => { - 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>( - ({ 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 = 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( - (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( - (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( - (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( - ({ 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 ( -
- - } - /> -
- } - /> - {BeforeInput} - {!errorLoading && ( -
- { - 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 && ( - - )} -
- )} - {errorLoading &&
{errorLoading}
} - {AfterInput} - } - /> -
- {currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && ( - - )} - {appearance === 'drawer' && !readOnly && ( - - )} -
+ {...(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, + })} + /> ) } diff --git a/packages/ui/src/fields/Relationship/optionsReducer.ts b/packages/ui/src/fields/Relationship/optionsReducer.ts index 037af34428..a7547c0987 100644 --- a/packages/ui/src/fields/Relationship/optionsReducer.ts +++ b/packages/ui/src/fields/Relationship/optionsReducer.ts @@ -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 }) } diff --git a/packages/ui/src/fields/Relationship/types.ts b/packages/ui/src/fields/Relationship/types.ts index 8336f39ebc..b90fc47c0f 100644 --- a/packages/ui/src/fields/Relationship/types.ts +++ b/packages/ui/src/fields/Relationship/types.ts @@ -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 - onSuccess?: () => void - search?: string - sort?: boolean - value?: Value | Value[] -}) => Promise +export type HasManyValueUnion = + | { + hasMany: false + value?: PolymorphicRelationValue + } + | { + hasMany: true + value?: PolymorphicRelationValue[] + } + +export type GetResults = ( + args: { + filterOptions?: FilterOptionsResult + lastFullyLoadedRelation?: number + lastLoadedPage: Record + onSuccess?: () => void + search?: string + sort?: boolean + } & HasManyValueUnion, +) => Promise + +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> + 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[] + } diff --git a/packages/ui/src/hooks/useDebouncedCallback.ts b/packages/ui/src/hooks/useDebouncedCallback.ts index d5cac66f28..ac27c28115 100644 --- a/packages/ui/src/hooks/useDebouncedCallback.ts +++ b/packages/ui/src/hooks/useDebouncedCallback.ts @@ -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 = (func, wait) => { // Use a ref to store the timeout between renders // and prevent changes to it from causing re-renders const timeout = useRef>(undefined) return useCallback( - (...args) => { + (...args: TFunctionArgs[]) => { const later = () => { clearTimeout(timeout.current) func(...args) diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index 4d312ccf22..e29ed5f276 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -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 | BaseListFiltersSelect; with300documents: With300DocumentsSelect | With300DocumentsSelect; 'with-list-drawer': WithListDrawerSelect | WithListDrawerSelect; + placeholder: PlaceholderSelect | PlaceholderSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -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 { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "placeholder_select". + */ +export interface PlaceholderSelect { + 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". diff --git a/test/helpers.ts b/test/helpers.ts index f5e830de46..eb70eaffd7 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -185,10 +185,21 @@ export async function login(args: LoginArgs): Promise { 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, diff --git a/tools/scripts/src/generateTranslations/utils/index.ts b/tools/scripts/src/generateTranslations/utils/index.ts index 6a73e7fb6c..264006bc62 100644 --- a/tools/scripts/src/generateTranslations/utils/index.ts +++ b/tools/scripts/src/generateTranslations/utils/index.ts @@ -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)`,