From 9649c48c2857fb2f68c71091f977c1ad4746ceac Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Tue, 6 May 2025 13:15:47 -0400 Subject: [PATCH 01/14] feat: extracts relationship input for exteral use --- packages/payload/src/fields/config/types.ts | 2 +- packages/ui/src/fields/Relationship/Input.tsx | 759 +++++++++++++++++ .../fields/Relationship/createRelationMap.ts | 35 +- packages/ui/src/fields/Relationship/index.tsx | 800 ++---------------- packages/ui/src/fields/Relationship/types.ts | 50 +- 5 files changed, 915 insertions(+), 731 deletions(-) create mode 100644 packages/ui/src/fields/Relationship/Input.tsx diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index 3050e6250cd..a1411e2f3c4 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -1174,7 +1174,7 @@ export type PolymorphicRelationshipField = { export type PolymorphicRelationshipFieldClient = { admin?: { - sortOptions?: Pick + sortOptions?: PolymorphicRelationshipField['admin']['sortOptions'] } & RelationshipAdminClient } & Pick & SharedRelationshipPropertiesClient diff --git a/packages/ui/src/fields/Relationship/Input.tsx b/packages/ui/src/fields/Relationship/Input.tsx new file mode 100644 index 00000000000..530fb313357 --- /dev/null +++ b/packages/ui/src/fields/Relationship/Input.tsx @@ -0,0 +1,759 @@ +'use client' +import type { FilterOptionsResult, PaginatedDocs, ValueWithRelation, Where } from 'payload' + +import { dequal } from 'dequal/lite' +import { 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, Option, RelationshipInputProps, 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 { 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 { 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, + isPolymorphic, + isSortable = true, + label, + Label, + localized, + maxResultsPerRequest = 10, + onChange, + path, + 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) { + ;(hasMany === false ? [value] : value).forEach((val) => { + relationTo.forEach((relation) => { + newFilterOptions = { + ...(filterOptions || {}), + [relation]: { + ...(typeof filterOptions?.[relation] === 'object' ? filterOptions[relation] : {}), + id: { + not_in: [typeof val === 'object' ? val.value : val], + }, + }, + } + }) + }) + } + + 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) { + const withSelection = Array.isArray(value) ? value : [] + withSelection.push({ + relationTo: collectionSlug, + value: doc.id, + }) + onChange(withSelection) + } 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, + lastFullyLoadedRelation: lastFullyLoadedRelationArg, + lastLoadedPage: lastLoadedPageArg, + onSuccess, + search: searchArg, + sort, + value: valueArg, + }) => { + if (!permissions) { + return + } + const lastFullyLoadedRelationToUse = + typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1 + + const relations = Array.isArray(relationTo) ? relationTo : [relationTo] + const relationsToFetch = + lastFullyLoadedRelationToUse === -1 + ? relations + : relations.slice(lastFullyLoadedRelationToUse + 1) + + let resultsFetched = 0 + const relationMap = createRelationMap({ + hasMany, + isPolymorphic, + 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, + 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) + } + + 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, + hasMany, + isPolymorphic, + errorLoading, + search, + getEntityConfig, + sortOptions, + maxResultsPerRequest, + locale, + serverURL, + 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, + isPolymorphic, + relationTo, + value, + }) + + void Object.entries(relationMap).reduce(async (priorRelation, [relation, ids]) => { + await priorRelation + + const idsToLoad = ids.filter((id) => { + return !options.find((optionGroup) => + optionGroup?.options?.find( + (option) => option.value === id && option.relationTo === relation, + ), + ) + }) + + if (idsToLoad.length > 0) { + const query = { + depth: 0, + draft: true, + limit: idsToLoad.length, + locale, + where: { + id: { + in: idsToLoad, + }, + }, + } + + if (!errorLoading) { + const response = await fetch(`${serverURL}${api}/${relation}`, { + body: qs.stringify(query), + credentials: 'include', + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-HTTP-Method-Override': 'GET', + }, + method: 'POST', + }) + + const collection = getEntityConfig({ collectionSlug: relation }) + let docs = [] + + if (response.ok) { + const data = await response.json() + docs = data.docs + } + + dispatchOptions({ + type: 'ADD', + collection, + config, + docs, + i18n, + ids: idsToLoad, + sort: true, + }) + } + } + }, Promise.resolve()) + }) + + const prevValue = useRef(value) + const isFirstRenderRef = useRef(true) + // /////////////////////////////////// + // Ensure we have an option for each value + // /////////////////////////////////// + useEffect(() => { + if (isFirstRenderRef.current || !dequal(value, prevValue.current)) { + handleValueChange(value) + } + isFirstRenderRef.current = false + prevValue.current = value + }, [value]) + + // Determine if we should switch to word boundary search + useEffect(() => { + const relations = Array.isArray(relationTo) ? relationTo : [relationTo] + const isIdOnly = relations.reduce((idOnly, relation) => { + const collection = getEntityConfig({ collectionSlug: relation }) + const fieldToSearch = collection?.admin?.useAsTitle || 'id' + return fieldToSearch === 'id' && idOnly + }, true) + setEnableWordBoundarySearch(!isIdOnly) + }, [relationTo, getEntityConfig]) + + const getResultsEffectEvent: GetResults = useEffectEvent(async (args) => { + return await getResults(args) + }) + + // When (`relationTo` || `filterOptions` || `locale`) changes, reset component + // Note - effect should not run on first run + useEffect(() => { + // If the menu is open while filterOptions changes + // due to latency of form state and fast clicking into this field, + // re-fetch options + if (hasLoadedFirstPageRef.current && menuIsOpen) { + setIsLoading(true) + void getResultsEffectEvent({ + filterOptions, + lastLoadedPage: {}, + onSuccess: () => { + hasLoadedFirstPageRef.current = true + setIsLoading(false) + }, + value: valueRef.current, + }) + } + + // If the menu is not open, still reset the field state + // because we need to get new options next time the menu opens + dispatchOptions({ + type: 'CLEAR', + exemptValues: valueRef.current, + }) + + setLastFullyLoadedRelation(-1) + setLastLoadedPage({}) + }, [relationTo, filterOptions, locale, path, menuIsOpen]) + + const onSave = useCallback( + (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 unchanged = currentValue.some((option: ValueWithRelation) => { + return option.value === docID && option.relationTo === args.collectionConfig.slug + }) + + const valuesToSet = currentValue.map((option: ValueWithRelation) => { + return { + relationTo: option.value === docID ? args.collectionConfig.slug : option.relationTo, + value: option.value, + } + }) + + onChange(valuesToSet, unchanged) + } else if (hasMany === false) { + const unchanged = !Array.isArray(valueRef.current) && valueRef.current.value === docID + + onChange({ relationTo: args.collectionConfig.slug, value: docID }, unchanged) + } + }, + [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 onDocumentDrawerOpen = useCallback< + ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen'] + >(({ id, collectionSlug, hasReadPermission }) => { + openDrawerWhenRelationChanges.current = true + setCurrentlyOpenRelationship({ + id, + collectionSlug, + hasReadPermission, + }) + }, []) + + 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 if (Array.isArray(selected)) { + onChange(selected as ValueWithRelation[]) + } + } else if (hasMany === false) { + if (selected === null) { + onChange(null) + } else if (!Array.isArray(selected)) { + // @todo: parent will need to handle polymorphic values + onChange(selected as ValueWithRelation) + } + } + } + : 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} + 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 6a3d030c2ae..593fa284828 100644 --- a/packages/ui/src/fields/Relationship/createRelationMap.ts +++ b/packages/ui/src/fields/Relationship/createRelationMap.ts @@ -7,20 +7,20 @@ type RelationMap = { type CreateRelationMap = (args: { hasMany: boolean - relationTo: string | string[] + isPolymorphic: boolean + relationTo: string[] value: null | Value | Value[] // really needs to be `ValueWithRelation` }) => 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]: [] } - } +export const createRelationMap: CreateRelationMap = ({ + hasMany, + isPolymorphic, + relationTo, + value, +}) => { + const relationMap: RelationMap = relationTo.reduce((map, current) => { + return { ...map, [current]: [] } + }, {}) if (value === null) { return relationMap @@ -38,25 +38,20 @@ export const createRelationMap: CreateRelationMap = ({ hasMany, relationTo, valu if (hasMany && Array.isArray(value)) { value.forEach((val) => { - if ( - hasMultipleRelations && - typeof val === 'object' && - 'relationTo' in val && - 'value' in val - ) { + if (isPolymorphic && typeof val === 'object' && 'relationTo' in val && 'value' in val) { add(val.relationTo, val.value) } - if (!hasMultipleRelations && typeof relationTo === 'string') { + if (!isPolymorphic && typeof relationTo === 'string') { add(relationTo, val) } }) - } else if (hasMultipleRelations && Array.isArray(relationTo)) { + } else if (isPolymorphic) { if (typeof value === 'object' && 'relationTo' in value && 'value' in value) { add(value.relationTo, value.value) } } else { - add(relationTo, value) + add(relationTo[0], value) } return relationMap diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index 449c1e1374b..473c0d62712 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -1,49 +1,15 @@ 'use client' -import type { - FilterOptionsResult, - PaginatedDocs, - RelationshipFieldClientComponent, - Where, -} from 'payload' +import type { RelationshipFieldClientComponent, ValueWithRelation } from 'payload' -import { dequal } from 'dequal/lite' -import { wordBoundariesRegex } from 'payload/shared' -import * as qs from 'qs-esm' -import React, { 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 { 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 { 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' - -const maxResultsPerRequest = 10 - -const baseClass = 'relationship' +import { RelationshipInput } from './Input.js' +import './index.scss' const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => { const { @@ -69,35 +35,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') { @@ -121,670 +58,115 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => 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) { - ;(Array.isArray(value) ? value : [value]).forEach((val) => { - ;(Array.isArray(relationTo) ? relationTo : [relationTo]).forEach((relationTo) => { - newFilterOptions = { - ...(filterOptions || {}), - [relationTo]: { - ...(typeof filterOptions?.[relationTo] === 'object' ? filterOptions[relationTo] : {}), - id: { - not_in: [typeof val === 'object' ? val.value : val], - }, - }, - } - }) - }) - } - - 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 : [] - withSelection.push(formattedSelection) - setValue(withSelection) - } else { - setValue(formattedSelection) - } - - closeListDrawer() - }, - [hasMany, hasMultipleRelations, setValue, closeListDrawer, value], - ) - - const openDrawerWhenRelationChanges = useRef(false) + const styles = useMemo(() => mergeFieldStyles(field), [field]) + const isPolymorphic = Array.isArray(relationTo) + const safeRelationTo = Array.isArray(relationTo) ? relationTo : [relationTo] - const getResults: GetResults = useCallback( - async ({ - filterOptions, - lastFullyLoadedRelation: lastFullyLoadedRelationArg, - lastLoadedPage: lastLoadedPageArg, - onSuccess, - search: searchArg, - sort, - value: valueArg, - }) => { - if (!permissions) { + const handleChangeHasMulti = useCallback( + (value: ValueWithRelation[], disableModifyForm?: boolean) => { + if (!value) { + setValue(null, Boolean(disableModifyForm)) return } - const lastFullyLoadedRelationToUse = - typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1 - - const relations = Array.isArray(relationTo) ? relationTo : [relationTo] - const relationsToFetch = - lastFullyLoadedRelationToUse === -1 - ? relations - : relations.slice(lastFullyLoadedRelationToUse + 1) - - let resultsFetched = 0 - const relationMap = createRelationMap({ - hasMany, - relationTo, - value: valueArg, - }) - - if (!errorLoading) { - await relationsToFetch.reduce(async (priorRelation, relation) => { - const relationFilterOption = filterOptions?.[relation] - - let lastLoadedPageToUse - if (search !== searchArg) { - lastLoadedPageToUse = 1 - } else { - lastLoadedPageToUse = lastLoadedPageArg[relation] + 1 - } - await priorRelation - - if (relationFilterOption === false) { - setLastFullyLoadedRelation(relations.indexOf(relation)) - return Promise.resolve() - } - - if (resultsFetched < 10) { - const collection = getEntityConfig({ collectionSlug: relation }) - const fieldToSearch = collection?.admin?.useAsTitle || 'id' - let fieldToSort = collection?.defaultSort || 'id' - if (typeof sortOptions === 'string') { - fieldToSort = sortOptions - } else if (sortOptions?.[relation]) { - fieldToSort = sortOptions[relation] - } - - const query: { - [key: string]: unknown - where: Where - } = { - depth: 0, - draft: true, - limit: maxResultsPerRequest, - locale, - page: lastLoadedPageToUse, - 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) - } - - 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, - 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, - }) + const dataToSet = value.map((val) => { + if (isPolymorphic) { + return val + } else { + return val.value } - } - }, Promise.resolve()) - }) - - const prevValue = useRef(value) - const isFirstRenderRef = useRef(true) - // /////////////////////////////////// - // Ensure we have an option for each value - // /////////////////////////////////// - useEffect(() => { - if (isFirstRenderRef.current || !dequal(value, prevValue.current)) { - handleValueChange(value) - } - isFirstRenderRef.current = false - prevValue.current = value - }, [value]) - - // Determine if we should switch to word boundary search - useEffect(() => { - const relations = Array.isArray(relationTo) ? relationTo : [relationTo] - const isIdOnly = relations.reduce((idOnly, relation) => { - const collection = getEntityConfig({ collectionSlug: relation }) - const fieldToSearch = collection?.admin?.useAsTitle || 'id' - return fieldToSearch === 'id' && idOnly - }, true) - setEnableWordBoundarySearch(!isIdOnly) - }, [relationTo, getEntityConfig]) - - const getResultsEffectEvent: GetResults = useEffectEvent(async (args) => { - return await getResults(args) - }) - - // When (`relationTo` || `filterOptions` || `locale`) changes, reset component - // Note - effect should not run on first run - useEffect(() => { - // If the menu is open while filterOptions changes - // due to latency of form state and fast clicking into this field, - // re-fetch options - if (hasLoadedFirstPageRef.current && menuIsOpen) { - setIsLoading(true) - void getResultsEffectEvent({ - filterOptions, - lastLoadedPage: {}, - onSuccess: () => { - hasLoadedFirstPageRef.current = true - setIsLoading(false) - }, - value: valueRef.current, - }) - } - - // If the menu is not open, still reset the field state - // because we need to get new options next time the menu opens - dispatchOptions({ - type: 'CLEAR', - exemptValues: valueRef.current, - }) - - setLastFullyLoadedRelation(-1) - setLastLoadedPage({}) - }, [relationTo, filterOptions, locale, path, menuIsOpen]) - - const onSave = useCallback( - (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) - } + setValue(dataToSet, Boolean(disableModifyForm)) }, - [i18n, config, hasMany, setValue], + [isPolymorphic, 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, - }) + const handleChangeSingle = useCallback( + (value: ValueWithRelation, disableModifyForm?: boolean) => { + if (!value) { + setValue(null, Boolean(disableModifyForm)) + return } - }, - [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, - ) + if (isPolymorphic) { + setValue(value, Boolean(disableModifyForm)) } else { - setValue(null) + setValue(value.value, Boolean(disableModifyForm)) } - - return }, - [i18n, config, hasMany, setValue], + [isPolymorphic, 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 onDocumentDrawerOpen = useCallback< - ReactSelectAdapterProps['customProps']['onDocumentDrawerOpen'] - >(({ id, collectionSlug, hasReadPermission }) => { - openDrawerWhenRelationChanges.current = true - setCurrentlyOpenRelationship({ - id, - collectionSlug, - hasReadPermission, - }) - }, []) - - 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 sharedProps = { + AfterInput, + allowCreate, + allowEdit, + appearance, + BeforeInput, + className, + Description, + description, + Error, + filterOptions, + initialValue, + isPolymorphic: Array.isArray(relationTo), + isSortable, + Label, + label, + localized, + maxResultsPerRequest: 10, + maxRows: field?.maxRows, + minRows: field?.minRows, + path, + readOnly: readOnly || disabled, + relationTo: safeRelationTo, + required, + showError, + sortOptions: sortOptions as any, // todo: fix this, handle Record type + style: styles, } - const styles = useMemo(() => mergeFieldStyles(field), [field]) - - return ( -
- + if (hasMany) { + return ( + { + if (isPolymorphic) { + return val as ValueWithRelation + } else { + return { + relationTo: safeRelationTo[0], + value: val as ValueWithRelation['value'], + } + } + }) + : null } /> -
- } - /> - {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 + return ( + 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} - showError={showError} - value={valueToRender ?? null} - /> - {!(readOnly || disabled) && allowCreate && ( - - )} -
- )} - {errorLoading &&
{errorLoading}
} - {AfterInput} - } - /> -
- {currentlyOpenRelationship.collectionSlug && currentlyOpenRelationship.hasReadPermission && ( - - )} - {appearance === 'drawer' && !readOnly && ( - - )} -
+ : null + } + /> ) } diff --git a/packages/ui/src/fields/Relationship/types.ts b/packages/ui/src/fields/Relationship/types.ts index 8336f39ebc2..1ac1797cfdc 100644 --- a/packages/ui/src/fields/Relationship/types.ts +++ b/packages/ui/src/fields/Relationship/types.ts @@ -1,5 +1,12 @@ import type { I18nClient } from '@payloadcms/translations' -import type { ClientCollectionConfig, ClientConfig, FilterOptionsResult } from 'payload' +import type { + ClientCollectionConfig, + ClientConfig, + CollectionSlug, + FilterOptionsResult, + StaticDescription, + StaticLabel, +} from 'payload' export type Option = { allowEdit: boolean @@ -63,3 +70,44 @@ export type GetResults = (args: { sort?: boolean value?: Value | Value[] }) => Promise + +export type RelationshipInputProps = { + readonly AfterInput: React.ReactNode // optional + readonly allowCreate: boolean // optional + readonly allowEdit: boolean // optional + readonly appearance: 'drawer' | 'select' // optional + readonly BeforeInput: React.ReactNode // optional + readonly className: string // optional + readonly Description: React.ReactNode // optional + readonly description: StaticDescription // optional + readonly Error: React.ReactNode // optional + readonly filterOptions: FilterOptionsResult // optional + readonly initialValue: Value | Value[] // optional + readonly isPolymorphic: boolean + readonly isSortable: boolean // optional + readonly Label: React.ReactNode // optional + readonly label: StaticLabel // optional + readonly localized: boolean // optional + readonly maxResultsPerRequest: number // optional + readonly maxRows: number // optional + readonly minRows: number // optional + readonly path: string + readonly readOnly: boolean // optional + readonly relationTo: string[] + readonly required: boolean // optional + readonly showError: boolean // optional + readonly sortOptions: Partial> // optional + readonly style: React.CSSProperties // optional +} & SharedRelationshipInputProps + +type SharedRelationshipInputProps = + | { + readonly hasMany: false + readonly onChange: (value: ValueWithRelation, modifyForm?: boolean) => void + readonly value?: null | ValueWithRelation + } + | { + readonly hasMany: true + readonly onChange: (value: ValueWithRelation[], modifyForm?: boolean) => void + readonly value?: null | ValueWithRelation[] + } From 65c202abb9bdf23a718f0cde307e0bec70450a07 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Tue, 6 May 2025 14:26:03 -0400 Subject: [PATCH 02/14] feat: adds confirm modal and syncs selector state --- .../src/components/TenantSelector/index.tsx | 90 +++++++++++++++++-- .../WatchTenantCollection/index.tsx | 35 ++++++++ .../plugin-multi-tenant/src/exports/client.ts | 1 + packages/plugin-multi-tenant/src/index.ts | 27 ++++++ .../TenantSelectionProvider/index.client.tsx | 28 +++++- test/plugin-multi-tenant/config.ts | 2 +- 6 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx diff --git a/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx b/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx index 38d11df84ab..5b714f77f8c 100644 --- a/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx +++ b/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx @@ -3,18 +3,52 @@ import type { ReactSelectOption } from '@payloadcms/ui' import type { ViewTypes } from 'payload' import { getTranslation } from '@payloadcms/translations' -import { SelectInput, useTranslation } from '@payloadcms/ui' +import { + ConfirmationModal, + SelectInput, + Translation, + useModal, + useTranslation, +} from '@payloadcms/ui' import React from 'react' +import type { + PluginMultiTenantTranslationKeys, + PluginMultiTenantTranslations, +} from '../../../translations/index.js' + import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js' import './index.scss' +const confirmSwitchTenantSlug = 'confirmSwitchTenant' + export const TenantSelector = ({ label, viewType }: { label: string; viewType?: ViewTypes }) => { - const { options, selectedTenantID, setTenant } = useTenantSelection() - const { i18n } = useTranslation() + const { options, preventRefreshOnChange, selectedTenantID, setTenant } = useTenantSelection() + const { openModal } = useModal() + const { i18n, t } = useTranslation< + PluginMultiTenantTranslations, + PluginMultiTenantTranslationKeys + >() + const [tenantSelection, setTenantSelection] = React.useState< + ReactSelectOption | ReactSelectOption[] + >() - const handleChange = React.useCallback( - (option: ReactSelectOption | ReactSelectOption[]) => { + const selectedValue = React.useMemo(() => { + if (selectedTenantID) { + return options.find((option) => option.value === selectedTenantID) + } + return undefined + }, [options, selectedTenantID]) + + const newSelectedValue = React.useMemo(() => { + if (tenantSelection && 'value' in tenantSelection) { + return options.find((option) => option.value === tenantSelection.value) + } + return undefined + }, [options, tenantSelection]) + + const switchTenant = React.useCallback( + (option: ReactSelectOption | ReactSelectOption[] | undefined) => { if (option && 'value' in option) { setTenant({ id: option.value as string, refresh: true }) } else { @@ -24,6 +58,19 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?: [setTenant], ) + const onChange = React.useCallback( + (option: ReactSelectOption | ReactSelectOption[]) => { + if (!preventRefreshOnChange) { + switchTenant(option) + return + } else { + setTenantSelection(option) + openModal(confirmSwitchTenantSlug) + } + }, + [openModal, preventRefreshOnChange, switchTenant], + ) + if (options.length <= 1) { return null } @@ -34,11 +81,42 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?: isClearable={viewType === 'list'} label={getTranslation(label, i18n)} name="setTenant" - onChange={handleChange} + onChange={onChange} options={options} path="setTenant" value={selectedTenantID as string | undefined} /> + + { + return {children} + }, + }} + i18nKey="plugin-multi-tenant:confirm-tenant-switch--body" + t={t} + variables={{ + fromTenant: selectedValue?.label, + toTenant: newSelectedValue?.label, + }} + /> + } + heading={ + + } + modalSlug={confirmSwitchTenantSlug} + onConfirm={() => { + switchTenant(tenantSelection) + }} + /> ) } diff --git a/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx b/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx new file mode 100644 index 00000000000..b20a559b310 --- /dev/null +++ b/packages/plugin-multi-tenant/src/components/WatchTenantCollection/index.tsx @@ -0,0 +1,35 @@ +'use client' + +import type { ClientCollectionConfig } from 'payload' + +import { useConfig, useDocumentInfo, useEffectEvent, useFormFields } from '@payloadcms/ui' +import React from 'react' + +import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js' + +export const WatchTenantCollection = () => { + const { id, collectionSlug, title } = useDocumentInfo() + const { getEntityConfig } = useConfig() + const [useAsTitleName] = React.useState( + () => (getEntityConfig({ collectionSlug }) as ClientCollectionConfig).admin.useAsTitle, + ) + const titleField = useFormFields(([fields]) => fields[useAsTitleName]) + + const { updateTenants } = useTenantSelection() + + const syncTenantTitle = useEffectEvent(() => { + if (id) { + updateTenants({ id, label: title }) + } + }) + + React.useEffect(() => { + // only update the tenant selector when the document saves + // → aka when initial value changes + if (id && titleField?.initialValue) { + syncTenantTitle() + } + }, [id, titleField?.initialValue]) + + return null +} diff --git a/packages/plugin-multi-tenant/src/exports/client.ts b/packages/plugin-multi-tenant/src/exports/client.ts index cd6fb65b94b..d14df7a70cb 100644 --- a/packages/plugin-multi-tenant/src/exports/client.ts +++ b/packages/plugin-multi-tenant/src/exports/client.ts @@ -1,3 +1,4 @@ export { TenantField } from '../components/TenantField/index.client.js' export { TenantSelector } from '../components/TenantSelector/index.js' +export { WatchTenantCollection } from '../components/WatchTenantCollection/index.js' export { useTenantSelection } from '../providers/TenantSelectionProvider/index.client.js' diff --git a/packages/plugin-multi-tenant/src/index.ts b/packages/plugin-multi-tenant/src/index.ts index 6d5bf308592..dccb0f888f5 100644 --- a/packages/plugin-multi-tenant/src/index.ts +++ b/packages/plugin-multi-tenant/src/index.ts @@ -1,8 +1,11 @@ import type { AcceptedLanguages } from '@payloadcms/translations' import type { CollectionConfig, Config } from 'payload' +import { deepMergeSimple } from 'payload' + import type { MultiTenantPluginConfig } from './types.js' +import { translations } from '../translations/index.js' import { defaults } from './defaults.js' import { tenantField } from './fields/tenantField/index.js' import { tenantsArrayField } from './fields/tenantsArrayField/index.js' @@ -229,6 +232,21 @@ export const multiTenantPlugin = usersTenantsArrayTenantFieldName: tenantsArrayTenantFieldName, }) } + + /** + * Add custom tenant field that watches and dispatches updates to the selector + */ + collection.fields.push({ + name: '_watchTenant', + type: 'ui', + admin: { + components: { + Field: { + path: '@payloadcms/plugin-multi-tenant/client#WatchTenantCollection', + }, + }, + }, + }) } else if (pluginConfig.collections?.[collection.slug]) { const isGlobal = Boolean(pluginConfig.collections[collection.slug]?.isGlobal) @@ -340,5 +358,14 @@ export const multiTenantPlugin = path: '@payloadcms/plugin-multi-tenant/client#TenantSelector', }) + /** + * Merge plugin translations + */ + + incomingConfig.i18n = { + ...incomingConfig.i18n, + translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {}), + } + return incomingConfig } diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx index 4aad06e2319..5a00ed80a36 100644 --- a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx @@ -11,6 +11,7 @@ type ContextType = { * Array of options to select from */ options: OptionObject[] + preventRefreshOnChange: boolean /** * The currently selected tenant ID */ @@ -28,20 +29,26 @@ type ContextType = { * @param args.refresh - Whether to refresh the page after changing the tenant */ setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void + /** + * + */ + updateTenants: (args: { id: number | string; label: string }) => void } const Context = createContext({ options: [], + preventRefreshOnChange: false, selectedTenantID: undefined, setPreventRefreshOnChange: () => null, setTenant: () => null, + updateTenants: () => null, }) export const TenantSelectionProviderClient = ({ children, initialValue, tenantCookie, - tenantOptions, + tenantOptions: tenantOptionsFromProps, }: { children: React.ReactNode initialValue?: number | string @@ -54,6 +61,9 @@ export const TenantSelectionProviderClient = ({ const [preventRefreshOnChange, setPreventRefreshOnChange] = React.useState(false) const { user } = useAuth() const userID = React.useMemo(() => user?.id, [user?.id]) + const [tenantOptions, setTenantOptions] = React.useState( + () => tenantOptionsFromProps, + ) const selectedTenantLabel = React.useMemo( () => tenantOptions.find((option) => option.value === selectedTenantID)?.label, [selectedTenantID, tenantOptions], @@ -91,6 +101,20 @@ export const TenantSelectionProviderClient = ({ [deleteCookie, preventRefreshOnChange, router, setCookie, setSelectedTenantID, tenantOptions], ) + const updateTenants = React.useCallback(({ id, label }) => { + setTenantOptions((prev) => { + return prev.map((currentTenant) => { + if (id === currentTenant.value) { + return { + label, + value: id, + } + } + return currentTenant + }) + }) + }, []) + React.useEffect(() => { if (selectedTenantID && !tenantOptions.find((option) => option.value === selectedTenantID)) { if (tenantOptions?.[0]?.value) { @@ -132,9 +156,11 @@ export const TenantSelectionProviderClient = ({ {children} diff --git a/test/plugin-multi-tenant/config.ts b/test/plugin-multi-tenant/config.ts index 999de7acbfd..1a4bb8b3093 100644 --- a/test/plugin-multi-tenant/config.ts +++ b/test/plugin-multi-tenant/config.ts @@ -41,7 +41,7 @@ export default buildConfigWithDefaults({ isGlobal: true, }, }, - tenantSelectorLabel: 'Sites', + tenantSelectorLabel: 'Site', }), ], typescript: { From 06b2800f339dfcf9a38240f18b1d100bbf35bce5 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Mon, 12 May 2025 11:43:48 -0400 Subject: [PATCH 03/14] setup and generate plugin translations --- packages/plugin-multi-tenant/package.json | 1 + .../scripts/generateTranslations.ts | 37 ++++++++ .../src/components/TenantSelector/index.tsx | 6 +- packages/plugin-multi-tenant/src/index.ts | 2 +- .../src/translations/index.ts | 91 +++++++++++++++++++ .../src/translations/languages/ar.ts | 14 +++ .../src/translations/languages/az.ts | 14 +++ .../src/translations/languages/bg.ts | 14 +++ .../src/translations/languages/ca.ts | 14 +++ .../src/translations/languages/cs.ts | 14 +++ .../src/translations/languages/da.ts | 14 +++ .../src/translations/languages/de.ts | 14 +++ .../src/translations/languages/en.ts | 14 +++ .../src/translations/languages/es.ts | 14 +++ .../src/translations/languages/et.ts | 14 +++ .../src/translations/languages/fa.ts | 14 +++ .../src/translations/languages/fr.ts | 14 +++ .../src/translations/languages/he.ts | 14 +++ .../src/translations/languages/hr.ts | 14 +++ .../src/translations/languages/hu.ts | 14 +++ .../src/translations/languages/hy.ts | 14 +++ .../src/translations/languages/it.ts | 14 +++ .../src/translations/languages/ja.ts | 14 +++ .../src/translations/languages/ko.ts | 14 +++ .../src/translations/languages/lt.ts | 14 +++ .../src/translations/languages/my.ts | 14 +++ .../src/translations/languages/nb.ts | 14 +++ .../src/translations/languages/nl.ts | 14 +++ .../src/translations/languages/pl.ts | 14 +++ .../src/translations/languages/pt.ts | 14 +++ .../src/translations/languages/rn.sh | 3 + .../src/translations/languages/ro.ts | 14 +++ .../src/translations/languages/rs.ts | 14 +++ .../src/translations/languages/rsLatin.ts | 14 +++ .../src/translations/languages/ru.ts | 14 +++ .../src/translations/languages/sk.ts | 14 +++ .../src/translations/languages/sl.ts | 14 +++ .../src/translations/languages/sv.ts | 14 +++ .../src/translations/languages/th.ts | 14 +++ .../src/translations/languages/tr.ts | 14 +++ .../src/translations/languages/uk.ts | 14 +++ .../src/translations/languages/vi.ts | 14 +++ .../src/translations/languages/zh.ts | 13 +++ .../src/translations/languages/zhTw.ts | 14 +++ .../src/translations/types.ts | 12 +++ packages/plugin-multi-tenant/tsconfig.json | 7 +- 46 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-multi-tenant/scripts/generateTranslations.ts create mode 100644 packages/plugin-multi-tenant/src/translations/index.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/ar.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/az.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/bg.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/ca.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/cs.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/da.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/de.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/en.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/es.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/et.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/fa.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/fr.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/he.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/hr.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/hu.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/hy.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/it.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/ja.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/ko.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/lt.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/my.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/nb.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/nl.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/pl.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/pt.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/rn.sh create mode 100644 packages/plugin-multi-tenant/src/translations/languages/ro.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/rs.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/rsLatin.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/ru.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/sk.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/sl.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/sv.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/th.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/tr.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/uk.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/vi.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/zh.ts create mode 100644 packages/plugin-multi-tenant/src/translations/languages/zhTw.ts create mode 100644 packages/plugin-multi-tenant/src/translations/types.ts diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json index 37f18983794..df26f29baa5 100644 --- a/packages/plugin-multi-tenant/package.json +++ b/packages/plugin-multi-tenant/package.json @@ -71,6 +71,7 @@ "build:types": "tsc --emitDeclarationOnly --outDir dist", "clean": "rimraf {dist,*.tsbuildinfo}", "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", + "generateTranslations": "node --no-deprecation --import @swc-node/register/esm-register scripts/generateTranslations.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "pack:plugin": "pnpm prepublishOnly && pnpm copyfiles && pnpm pack", diff --git a/packages/plugin-multi-tenant/scripts/generateTranslations.ts b/packages/plugin-multi-tenant/scripts/generateTranslations.ts new file mode 100644 index 00000000000..ce740fec735 --- /dev/null +++ b/packages/plugin-multi-tenant/scripts/generateTranslations.ts @@ -0,0 +1,37 @@ +import type { AcceptedLanguages, GenericTranslationsObject } from '@payloadcms/translations' + +import path from 'path' +import { fileURLToPath } from 'url' + +// @ts-ignore +import { translateObject } from '../../translations/scripts/translateNewKeys/index.js' +import { translations } from '../src/translations/index.js' +import { enTranslations } from '../src/translations/languages/en.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const allTranslations: { + [key in AcceptedLanguages]?: { + dateFNSKey: string + translations: GenericTranslationsObject + } +} = {} + +for (const key of Object.keys(translations)) { + allTranslations[key as AcceptedLanguages] = { + dateFNSKey: translations[key as AcceptedLanguages]?.dateFNSKey ?? 'unknown-date-fns-key', + translations: translations[key as AcceptedLanguages]?.translations ?? {}, + } +} + +void translateObject({ + allTranslationsObject: allTranslations, + fromTranslationsObject: enTranslations, + targetFolder: path.resolve(dirname, '../src/translations/languages'), + tsFilePrefix: `import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js'\n\nexport const {{locale}}Translations: PluginDefaultTranslationsObject = `, + tsFileSuffix: `\n\nexport const {{locale}}: PluginLanguage = { + dateFNSKey: {{dateFNSKey}}, + translations: {{locale}}Translations, + } `, +}) diff --git a/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx b/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx index 5b714f77f8c..16cba016934 100644 --- a/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx +++ b/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx @@ -15,7 +15,7 @@ import React from 'react' import type { PluginMultiTenantTranslationKeys, PluginMultiTenantTranslations, -} from '../../../translations/index.js' +} from '../../translations/index.js' import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js' import './index.scss' @@ -95,6 +95,8 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?: return {children} }, }} + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error i18nKey="plugin-multi-tenant:confirm-tenant-switch--body" t={t} variables={{ @@ -105,6 +107,8 @@ export const TenantSelector = ({ label, viewType }: { label: string; viewType?: } heading={ + +export type PluginMultiTenantTranslations = GenericTranslationsObject + +export type PluginMultiTenantTranslationKeys = NestedKeysStripped diff --git a/packages/plugin-multi-tenant/src/translations/languages/ar.ts b/packages/plugin-multi-tenant/src/translations/languages/ar.ts new file mode 100644 index 00000000000..ff50feeb56c --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/ar.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const arTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'أنت على وشك تغيير الملكية من <0>{{fromTenant}} إلى <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'تأكيد تغيير {{tenantLabel}}', + }, +} + +export const ar: PluginLanguage = { + dateFNSKey: 'ar', + translations: arTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/az.ts b/packages/plugin-multi-tenant/src/translations/languages/az.ts new file mode 100644 index 00000000000..3c7ace19b47 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/az.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const azTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Siz <0>{{fromTenant}} mülkiyyətini <0>{{toTenant}} mülkiyyətinə dəyişdirəcəksiniz.', + 'confirm-tenant-switch--heading': '{{tenantLabel}} dəyişikliyini təsdiqləyin', + }, +} + +export const az: PluginLanguage = { + dateFNSKey: 'az', + translations: azTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/bg.ts b/packages/plugin-multi-tenant/src/translations/languages/bg.ts new file mode 100644 index 00000000000..488b0b9b1a6 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/bg.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const bgTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Предстои да промените собствеността от <0>{{fromTenant}} на <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Потвърдете промяната на {{tenantLabel}}', + }, +} + +export const bg: PluginLanguage = { + dateFNSKey: 'bg', + translations: bgTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/ca.ts b/packages/plugin-multi-tenant/src/translations/languages/ca.ts new file mode 100644 index 00000000000..0de5a291cf4 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/ca.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const caTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Estàs a punt de canviar la propietat de <0>{{fromTenant}} a <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Confirmeu el canvi de {{tenantLabel}}', + }, +} + +export const ca: PluginLanguage = { + dateFNSKey: 'ca', + translations: caTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/cs.ts b/packages/plugin-multi-tenant/src/translations/languages/cs.ts new file mode 100644 index 00000000000..30d024ad51a --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/cs.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const csTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Chystáte se změnit vlastnictví z <0>{{fromTenant}} na <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Potvrďte změnu {{tenantLabel}}', + }, +} + +export const cs: PluginLanguage = { + dateFNSKey: 'cs', + translations: csTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/da.ts b/packages/plugin-multi-tenant/src/translations/languages/da.ts new file mode 100644 index 00000000000..a7c38980588 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/da.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const daTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Du er ved at ændre ejerskab fra <0>{{fromTenant}} til <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Bekræft {{tenantLabel}} ændring', + }, +} + +export const da: PluginLanguage = { + dateFNSKey: 'da', + translations: daTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/de.ts b/packages/plugin-multi-tenant/src/translations/languages/de.ts new file mode 100644 index 00000000000..d36e28346a8 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/de.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const deTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Sie sind dabei, den Besitz von <0>{{fromTenant}} auf <0>{{toTenant}} zu übertragen.', + 'confirm-tenant-switch--heading': 'Bestätigen Sie die Änderung von {{tenantLabel}}.', + }, +} + +export const de: PluginLanguage = { + dateFNSKey: 'de', + translations: deTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/en.ts b/packages/plugin-multi-tenant/src/translations/languages/en.ts new file mode 100644 index 00000000000..4e790e89d86 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/en.ts @@ -0,0 +1,14 @@ +import type { PluginLanguage } from '../types.js' + +export const enTranslations = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'You are about to change ownership from <0>{{fromTenant}} to <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Confirm {{tenantLabel}} change', + }, +} + +export const en: PluginLanguage = { + dateFNSKey: 'en-US', + translations: enTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/es.ts b/packages/plugin-multi-tenant/src/translations/languages/es.ts new file mode 100644 index 00000000000..4e72ff9d0b6 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/es.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const esTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Está a punto de cambiar la propiedad de <0>{{fromTenant}} a <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Confirme el cambio de {{tenantLabel}}', + }, +} + +export const es: PluginLanguage = { + dateFNSKey: 'es', + translations: esTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/et.ts b/packages/plugin-multi-tenant/src/translations/languages/et.ts new file mode 100644 index 00000000000..1b86bf72a5c --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/et.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const etTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Te olete tegemas omandiõiguse muudatust <0>{{fromTenant}}lt <0>{{toTenant}}le.', + 'confirm-tenant-switch--heading': 'Kinnita {{tenantLabel}} muutus', + }, +} + +export const et: PluginLanguage = { + dateFNSKey: 'et', + translations: etTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/fa.ts b/packages/plugin-multi-tenant/src/translations/languages/fa.ts new file mode 100644 index 00000000000..d64610a7296 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/fa.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const faTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'شما در حال تغییر مالکیت از <0>{{fromTenant}} به <0>{{toTenant}} هستید', + 'confirm-tenant-switch--heading': 'تایید تغییر {{tenantLabel}}', + }, +} + +export const fa: PluginLanguage = { + dateFNSKey: 'fa-IR', + translations: faTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/fr.ts b/packages/plugin-multi-tenant/src/translations/languages/fr.ts new file mode 100644 index 00000000000..720cb3d7978 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/fr.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const frTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Vous êtes sur le point de changer la propriété de <0>{{fromTenant}} à <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Confirmer le changement de {{tenantLabel}}', + }, +} + +export const fr: PluginLanguage = { + dateFNSKey: 'fr', + translations: frTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/he.ts b/packages/plugin-multi-tenant/src/translations/languages/he.ts new file mode 100644 index 00000000000..1a522ec86fc --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/he.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const heTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'אתה עומד לשנות בעלות מ- <0>{{fromTenant}} ל- <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'אשר שינוי {{tenantLabel}}', + }, +} + +export const he: PluginLanguage = { + dateFNSKey: 'he', + translations: heTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/hr.ts b/packages/plugin-multi-tenant/src/translations/languages/hr.ts new file mode 100644 index 00000000000..292ec35218a --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/hr.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const hrTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Upravo ćete promijeniti vlasništvo sa <0>{{fromTenant}} na <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Potvrdi promjenu {{tenantLabel}}', + }, +} + +export const hr: PluginLanguage = { + dateFNSKey: 'hr', + translations: hrTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/hu.ts b/packages/plugin-multi-tenant/src/translations/languages/hu.ts new file mode 100644 index 00000000000..5e7a03bf2e7 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/hu.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const huTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Ön azon van, hogy megváltoztassa a tulajdonjogot <0>{{fromTenant}}-ről <0>{{toTenant}}-re.', + 'confirm-tenant-switch--heading': 'Erősítse meg a(z) {{tenantLabel}} változtatást', + }, +} + +export const hu: PluginLanguage = { + dateFNSKey: 'hu', + translations: huTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/hy.ts b/packages/plugin-multi-tenant/src/translations/languages/hy.ts new file mode 100644 index 00000000000..e46f11ac5fd --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/hy.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const hyTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Դուք պատրաստ եք փոխել գերեցդիմատնին ընկերությունը <0>{{fromTenant}}-ից <0>{{toTenant}}-ին', + 'confirm-tenant-switch--heading': 'Հաստատեք {{tenantLabel}} փոփոխությունը', + }, +} + +export const hy: PluginLanguage = { + dateFNSKey: 'hy-AM', + translations: hyTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/it.ts b/packages/plugin-multi-tenant/src/translations/languages/it.ts new file mode 100644 index 00000000000..af0ce98a753 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/it.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const itTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Stai per cambiare proprietà da <0>{{fromTenant}} a <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Conferma il cambiamento di {{tenantLabel}}', + }, +} + +export const it: PluginLanguage = { + dateFNSKey: 'it', + translations: itTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/ja.ts b/packages/plugin-multi-tenant/src/translations/languages/ja.ts new file mode 100644 index 00000000000..23adbe41317 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/ja.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const jaTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'あなたは所有権を<0>{{fromTenant}}から<0>{{toTenant}}へ変更しようとしています', + 'confirm-tenant-switch--heading': '{{tenantLabel}}の変更を確認してください', + }, +} + +export const ja: PluginLanguage = { + dateFNSKey: 'ja', + translations: jaTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/ko.ts b/packages/plugin-multi-tenant/src/translations/languages/ko.ts new file mode 100644 index 00000000000..7836ab8f9e6 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/ko.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const koTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + '<0>{{fromTenant}}에서 <0>{{toTenant}}으로 소유권을 변경하려고 합니다.', + 'confirm-tenant-switch--heading': '{{tenantLabel}} 변경을 확인하세요', + }, +} + +export const ko: PluginLanguage = { + dateFNSKey: 'ko', + translations: koTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/lt.ts b/packages/plugin-multi-tenant/src/translations/languages/lt.ts new file mode 100644 index 00000000000..396855ada9c --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/lt.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const ltTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Jūs ketinate pakeisti nuosavybės teisę iš <0>{{fromTenant}} į <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Patvirtinkite {{tenantLabel}} pakeitimą', + }, +} + +export const lt: PluginLanguage = { + dateFNSKey: 'lt', + translations: ltTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/my.ts b/packages/plugin-multi-tenant/src/translations/languages/my.ts new file mode 100644 index 00000000000..ae1a6349b65 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/my.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const myTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Anda akan mengubah pemilikan dari <0>{{fromTenant}} ke <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Sahkan perubahan {{tenantLabel}}', + }, +} + +export const my: PluginLanguage = { + dateFNSKey: 'en-US', + translations: myTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/nb.ts b/packages/plugin-multi-tenant/src/translations/languages/nb.ts new file mode 100644 index 00000000000..c7f85e270d7 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/nb.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const nbTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Du er i ferd med å endre eierskap fra <0>{{fromTenant}} til <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Bekreft {{tenantLabel}} endring', + }, +} + +export const nb: PluginLanguage = { + dateFNSKey: 'nb', + translations: nbTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/nl.ts b/packages/plugin-multi-tenant/src/translations/languages/nl.ts new file mode 100644 index 00000000000..21ee10b6d0c --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/nl.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const nlTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'U staat op het punt het eigendom te wijzigen van <0>{{fromTenant}} naar <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Bevestig wijziging van {{tenantLabel}}', + }, +} + +export const nl: PluginLanguage = { + dateFNSKey: 'nl', + translations: nlTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/pl.ts b/packages/plugin-multi-tenant/src/translations/languages/pl.ts new file mode 100644 index 00000000000..4f1a7322461 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/pl.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const plTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Za chwilę nastąpi zmiana właściciela z <0>{{fromTenant}} na <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Potwierdź zmianę {{tenantLabel}}', + }, +} + +export const pl: PluginLanguage = { + dateFNSKey: 'pl', + translations: plTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/pt.ts b/packages/plugin-multi-tenant/src/translations/languages/pt.ts new file mode 100644 index 00000000000..1e52af5ec7d --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/pt.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const ptTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Você está prestes a alterar a propriedade de <0>{{fromTenant}} para <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Confirme a alteração de {{tenantLabel}}', + }, +} + +export const pt: PluginLanguage = { + dateFNSKey: 'pt', + translations: ptTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/rn.sh b/packages/plugin-multi-tenant/src/translations/languages/rn.sh new file mode 100644 index 00000000000..ef6b3ed6c4d --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/rn.sh @@ -0,0 +1,3 @@ +for file in *.js; do + mv -- "$file" "${file%.js}.ts" +done diff --git a/packages/plugin-multi-tenant/src/translations/languages/ro.ts b/packages/plugin-multi-tenant/src/translations/languages/ro.ts new file mode 100644 index 00000000000..b6a40cd36fb --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/ro.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const roTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Sunteți pe punctul de a schimba proprietatea de la <0>{{fromTenant}} la <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Confirmați schimbarea {{tenantLabel}}', + }, +} + +export const ro: PluginLanguage = { + dateFNSKey: 'ro', + translations: roTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/rs.ts b/packages/plugin-multi-tenant/src/translations/languages/rs.ts new file mode 100644 index 00000000000..19c63dc59ef --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/rs.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const rsTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Upravo ćete promeniti vlasništvo sa <0>{{fromTenant}} na <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Potvrdi promena {{tenantLabel}}', + }, +} + +export const rs: PluginLanguage = { + dateFNSKey: 'rs', + translations: rsTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/rsLatin.ts b/packages/plugin-multi-tenant/src/translations/languages/rsLatin.ts new file mode 100644 index 00000000000..c4f56096f0e --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/rsLatin.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const rsLatinTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Uskoro ćete promeniti vlasništvo sa <0>{{fromTenant}} na <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Potvrdite promenu {{tenantLabel}}', + }, +} + +export const rsLatin: PluginLanguage = { + dateFNSKey: 'rs-Latin', + translations: rsLatinTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/ru.ts b/packages/plugin-multi-tenant/src/translations/languages/ru.ts new file mode 100644 index 00000000000..66bfaa76cbf --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/ru.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const ruTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Вы собираетесь изменить владельца с <0>{{fromTenant}} на <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Подтвердите изменение {{tenantLabel}}', + }, +} + +export const ru: PluginLanguage = { + dateFNSKey: 'ru', + translations: ruTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/sk.ts b/packages/plugin-multi-tenant/src/translations/languages/sk.ts new file mode 100644 index 00000000000..22148c870fa --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/sk.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const skTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Chystáte sa zmeniť vlastníctvo z <0>{{fromTenant}} na <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Potvrďte zmenu {{tenantLabel}}', + }, +} + +export const sk: PluginLanguage = { + dateFNSKey: 'sk', + translations: skTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/sl.ts b/packages/plugin-multi-tenant/src/translations/languages/sl.ts new file mode 100644 index 00000000000..c2f6bb2bf67 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/sl.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const slTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Ravno ste pred spremembo lastništva iz <0>{{fromTenant}} na <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Potrdi spremembo {{tenantLabel}}', + }, +} + +export const sl: PluginLanguage = { + dateFNSKey: 'sl-SI', + translations: slTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/sv.ts b/packages/plugin-multi-tenant/src/translations/languages/sv.ts new file mode 100644 index 00000000000..57659262653 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/sv.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const svTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Du är på väg att ändra ägande från <0>{{fromTenant}} till <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Bekräfta ändring av {{tenantLabel}}', + }, +} + +export const sv: PluginLanguage = { + dateFNSKey: 'sv', + translations: svTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/th.ts b/packages/plugin-multi-tenant/src/translations/languages/th.ts new file mode 100644 index 00000000000..cdfdc45c9ae --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/th.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const thTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'คุณกำลังจะเปลี่ยนความเป็นเจ้าของจาก <0>{{fromTenant}} เป็น <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'ยืนยันการเปลี่ยนแปลง {{tenantLabel}}', + }, +} + +export const th: PluginLanguage = { + dateFNSKey: 'th', + translations: thTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/tr.ts b/packages/plugin-multi-tenant/src/translations/languages/tr.ts new file mode 100644 index 00000000000..2f969402e2d --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/tr.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const trTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + "Sahipliği <0>{{fromTenant}}'den <0>{{toTenant}}'e değiştirmek üzeresiniz.", + 'confirm-tenant-switch--heading': '{{tenantLabel}} değişikliğini onayla', + }, +} + +export const tr: PluginLanguage = { + dateFNSKey: 'tr', + translations: trTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/uk.ts b/packages/plugin-multi-tenant/src/translations/languages/uk.ts new file mode 100644 index 00000000000..8e0fa892332 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/uk.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const ukTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Ви збираєтесь змінити власність з <0>{{fromTenant}} на <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Підтвердіть зміну {{tenantLabel}}', + }, +} + +export const uk: PluginLanguage = { + dateFNSKey: 'uk', + translations: ukTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/vi.ts b/packages/plugin-multi-tenant/src/translations/languages/vi.ts new file mode 100644 index 00000000000..e017d9a05eb --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/vi.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const viTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + 'Bạn đang chuẩn bị chuyển quyền sở hữu từ <0>{{fromTenant}} sang <0>{{toTenant}}', + 'confirm-tenant-switch--heading': 'Xác nhận thay đổi {{tenantLabel}}', + }, +} + +export const vi: PluginLanguage = { + dateFNSKey: 'vi', + translations: viTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/zh.ts b/packages/plugin-multi-tenant/src/translations/languages/zh.ts new file mode 100644 index 00000000000..047847258cd --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/zh.ts @@ -0,0 +1,13 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const zhTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': '您即将将所有权从<0>{{fromTenant}}更改为<0>{{toTenant}}', + 'confirm-tenant-switch--heading': '确认更改{{tenantLabel}}', + }, +} + +export const zh: PluginLanguage = { + dateFNSKey: 'zh-CN', + translations: zhTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/languages/zhTw.ts b/packages/plugin-multi-tenant/src/translations/languages/zhTw.ts new file mode 100644 index 00000000000..4950b18cb4f --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/languages/zhTw.ts @@ -0,0 +1,14 @@ +import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.js' + +export const zhTwTranslations: PluginDefaultTranslationsObject = { + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': + '您即將將所有權從 <0>{{fromTenant}} 轉移至 <0>{{toTenant}}', + 'confirm-tenant-switch--heading': '確認{{tenantLabel}}更改', + }, +} + +export const zhTw: PluginLanguage = { + dateFNSKey: 'zh-TW', + translations: zhTwTranslations, +} diff --git a/packages/plugin-multi-tenant/src/translations/types.ts b/packages/plugin-multi-tenant/src/translations/types.ts new file mode 100644 index 00000000000..d1750a2c809 --- /dev/null +++ b/packages/plugin-multi-tenant/src/translations/types.ts @@ -0,0 +1,12 @@ +import type { Language } from '@payloadcms/translations' + +import type { enTranslations } from './languages/en.js' + +export type PluginLanguage = Language<{ + 'plugin-multi-tenant': { + 'confirm-tenant-switch--body': string + 'confirm-tenant-switch--heading': string + } +}> + +export type PluginDefaultTranslationsObject = typeof enTranslations diff --git a/packages/plugin-multi-tenant/tsconfig.json b/packages/plugin-multi-tenant/tsconfig.json index b14b853dc0b..561b5a63d76 100644 --- a/packages/plugin-multi-tenant/tsconfig.json +++ b/packages/plugin-multi-tenant/tsconfig.json @@ -1,4 +1,9 @@ { "extends": "../../tsconfig.base.json", - "references": [{ "path": "../payload" }, { "path": "../ui"}, { "path": "../translations"}] + "references": [{ "path": "../payload" }, { "path": "../ui"}, { "path": "../translations"}], + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "scripts/**/*.ts" + ] } From 4fd022dc2f3c11fb18794dfc66f2957c02c0c541 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Mon, 12 May 2025 12:31:28 -0400 Subject: [PATCH 04/14] revert changes that were moved into the feat/relationship-extract-input branch --- .../fields/Relationship/createRelationMap.ts | 35 +- packages/ui/src/fields/Relationship/index.tsx | 837 ++++++++++++++++-- .../MultiValueLabel/index.tsx | 7 +- .../select-components/SingleValue/index.tsx | 7 +- packages/ui/src/fields/Relationship/types.ts | 50 +- 5 files changed, 775 insertions(+), 161 deletions(-) diff --git a/packages/ui/src/fields/Relationship/createRelationMap.ts b/packages/ui/src/fields/Relationship/createRelationMap.ts index 593fa284828..6a3d030c2ae 100644 --- a/packages/ui/src/fields/Relationship/createRelationMap.ts +++ b/packages/ui/src/fields/Relationship/createRelationMap.ts @@ -7,20 +7,20 @@ type RelationMap = { type CreateRelationMap = (args: { hasMany: boolean - isPolymorphic: boolean - relationTo: string[] + relationTo: string | string[] value: null | Value | Value[] // really needs to be `ValueWithRelation` }) => RelationMap -export const createRelationMap: CreateRelationMap = ({ - hasMany, - isPolymorphic, - relationTo, - value, -}) => { - const relationMap: RelationMap = relationTo.reduce((map, current) => { - return { ...map, [current]: [] } - }, {}) +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]: [] } + } if (value === null) { return relationMap @@ -38,20 +38,25 @@ export const createRelationMap: CreateRelationMap = ({ if (hasMany && Array.isArray(value)) { value.forEach((val) => { - if (isPolymorphic && typeof val === 'object' && 'relationTo' in val && 'value' in val) { + if ( + hasMultipleRelations && + typeof val === 'object' && + 'relationTo' in val && + 'value' in val + ) { add(val.relationTo, val.value) } - if (!isPolymorphic && typeof relationTo === 'string') { + if (!hasMultipleRelations && typeof relationTo === 'string') { add(relationTo, val) } }) - } else if (isPolymorphic) { + } else if (hasMultipleRelations && Array.isArray(relationTo)) { if (typeof value === 'object' && 'relationTo' in value && 'value' in value) { add(value.relationTo, value.value) } } else { - add(relationTo[0], value) + add(relationTo, value) } return relationMap diff --git a/packages/ui/src/fields/Relationship/index.tsx b/packages/ui/src/fields/Relationship/index.tsx index 473c0d62712..21cbaa42993 100644 --- a/packages/ui/src/fields/Relationship/index.tsx +++ b/packages/ui/src/fields/Relationship/index.tsx @@ -1,15 +1,49 @@ 'use client' -import type { RelationshipFieldClientComponent, ValueWithRelation } from 'payload' +import type { + FilterOptionsResult, + PaginatedDocs, + RelationshipFieldClientComponent, + Where, +} from 'payload' -import React, { useCallback, useMemo } from 'react' +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 type { Value } from './types.js' +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 { 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 { mergeFieldStyles } from '../mergeFieldStyles.js' -import { RelationshipInput } from './Input.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 { 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' + +const maxResultsPerRequest = 10 + +const baseClass = 'relationship' const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => { const { @@ -22,6 +56,7 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => className, description, isSortable = true, + placeholder, sortOptions, } = {}, hasMany, @@ -35,6 +70,35 @@ 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') { @@ -58,115 +122,706 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) => validate: memoizedValidate, }) - const styles = useMemo(() => mergeFieldStyles(field), [field]) - const isPolymorphic = Array.isArray(relationTo) - const safeRelationTo = Array.isArray(relationTo) ? relationTo : [relationTo] + const [options, dispatchOptions] = useReducer(optionsReducer, []) - const handleChangeHasMulti = useCallback( - (value: ValueWithRelation[], disableModifyForm?: boolean) => { - if (!value) { - setValue(null, Boolean(disableModifyForm)) - return - } + 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 - const dataToSet = value.map((val) => { - if (isPolymorphic) { - return val - } else { - return val.value + 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], + }, + } + : {}), + }, } }) + } - setValue(dataToSet, Boolean(disableModifyForm)) + 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() }, - [isPolymorphic, setValue], + [hasMany, hasMultipleRelations, setValue, closeListDrawer, value], ) - const handleChangeSingle = useCallback( - (value: ValueWithRelation, disableModifyForm?: boolean) => { - if (!value) { - setValue(null, Boolean(disableModifyForm)) + const openDrawerWhenRelationChanges = useRef(false) + + const getResults: GetResults = useCallback( + async ({ + filterOptions, + lastFullyLoadedRelation: lastFullyLoadedRelationArg, + lastLoadedPage: lastLoadedPageArg, + onSuccess, + search: searchArg, + sort, + value: valueArg, + }) => { + if (!permissions) { return } + const lastFullyLoadedRelationToUse = + typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1 + + const relations = Array.isArray(relationTo) ? relationTo : [relationTo] + const relationsToFetch = + lastFullyLoadedRelationToUse === -1 + ? relations + : relations.slice(lastFullyLoadedRelationToUse + 1) + + let resultsFetched = 0 + const relationMap = createRelationMap({ + hasMany, + relationTo, + value: valueArg, + }) + + if (!errorLoading) { + await relationsToFetch.reduce(async (priorRelation, relation) => { + const relationFilterOption = filterOptions?.[relation] + + let lastLoadedPageToUse + if (search !== searchArg) { + lastLoadedPageToUse = 1 + } else { + lastLoadedPageToUse = lastLoadedPageArg[relation] + 1 + } + await priorRelation + + if (relationFilterOption === false) { + setLastFullyLoadedRelation(relations.indexOf(relation)) + return Promise.resolve() + } + + if (resultsFetched < 10) { + const collection = getEntityConfig({ collectionSlug: relation }) + const fieldToSearch = collection?.admin?.useAsTitle || 'id' + let fieldToSort = collection?.defaultSort || 'id' + if (typeof sortOptions === 'string') { + fieldToSort = sortOptions + } else if (sortOptions?.[relation]) { + fieldToSort = sortOptions[relation] + } + + const query: { + [key: string]: unknown + where: Where + } = { + depth: 0, + draft: true, + limit: maxResultsPerRequest, + locale, + page: lastLoadedPageToUse, + select: { + [fieldToSearch]: true, + }, + sort: fieldToSort, + where: { + and: [ + { + id: { + not_in: relationMap[relation], + }, + }, + ], + }, + } + + if (searchArg) { + query.where.and.push({ + [fieldToSearch]: { + like: searchArg, + }, + }) + } + + if (relationFilterOption && typeof relationFilterOption !== 'boolean') { + query.where.and.push(relationFilterOption) + } + + 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, + 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 (isPolymorphic) { - setValue(value, Boolean(disableModifyForm)) + if (idsToLoad.length > 0) { + const query = { + depth: 0, + draft: true, + limit: idsToLoad.length, + locale, + where: { + id: { + in: idsToLoad, + }, + }, + } + + if (!errorLoading) { + const response = await fetch(`${serverURL}${api}/${relation}`, { + body: qs.stringify(query), + credentials: 'include', + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-HTTP-Method-Override': 'GET', + }, + method: 'POST', + }) + + const collection = getEntityConfig({ collectionSlug: relation }) + let docs = [] + + if (response.ok) { + const data = await response.json() + docs = data.docs + } + + dispatchOptions({ + type: 'ADD', + collection, + config, + docs, + i18n, + ids: idsToLoad, + sort: true, + }) + } + } + }, Promise.resolve()) + }) + + const prevValue = useRef(value) + const isFirstRenderRef = useRef(true) + // /////////////////////////////////// + // Ensure we have an option for each value + // /////////////////////////////////// + useEffect(() => { + if (isFirstRenderRef.current || !dequal(value, prevValue.current)) { + handleValueChange(value) + } + isFirstRenderRef.current = false + prevValue.current = value + }, [value]) + + // Determine if we should switch to word boundary search + useEffect(() => { + const relations = Array.isArray(relationTo) ? relationTo : [relationTo] + const isIdOnly = relations.reduce((idOnly, relation) => { + const collection = getEntityConfig({ collectionSlug: relation }) + const fieldToSearch = collection?.admin?.useAsTitle || 'id' + return fieldToSearch === 'id' && idOnly + }, true) + setEnableWordBoundarySearch(!isIdOnly) + }, [relationTo, getEntityConfig]) + + const getResultsEffectEvent: GetResults = useEffectEvent(async (args) => { + return await getResults(args) + }) + + // When (`relationTo` || `filterOptions` || `locale`) changes, reset component + // Note - effect should not run on first run + useEffect(() => { + // If the menu is open while filterOptions changes + // due to latency of form state and fast clicking into this field, + // re-fetch options + if (hasLoadedFirstPageRef.current && menuIsOpen) { + setIsLoading(true) + void getResultsEffectEvent({ + filterOptions, + lastLoadedPage: {}, + onSuccess: () => { + hasLoadedFirstPageRef.current = true + setIsLoading(false) + }, + value: valueRef.current, + }) + } + + // If the menu is not open, still reset the field state + // because we need to get new options next time the menu opens + dispatchOptions({ + type: 'CLEAR', + exemptValues: valueRef.current, + }) + + setLastFullyLoadedRelation(-1) + setLastLoadedPage({}) + }, [relationTo, filterOptions, locale, path, menuIsOpen]) + + const onSave = useCallback( + (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 { - setValue(value.value, Boolean(disableModifyForm)) + const unchanged = currentValue === docID + + setValue({ relationTo: args.collectionConfig.slug, value: docID }, unchanged) } }, - [isPolymorphic, setValue], + [i18n, config, hasMany, setValue], ) - const sharedProps = { - AfterInput, - allowCreate, - allowEdit, - appearance, - BeforeInput, - className, - Description, - description, - Error, - filterOptions, - initialValue, - isPolymorphic: Array.isArray(relationTo), - isSortable, - Label, - label, - localized, - maxResultsPerRequest: 10, - maxRows: field?.maxRows, - minRows: field?.minRows, - path, - readOnly: readOnly || disabled, - relationTo: safeRelationTo, - required, - showError, - sortOptions: sortOptions as any, // todo: fix this, handle Record type - style: styles, - } + const onDuplicate = useCallback( + (args) => { + dispatchOptions({ + type: 'ADD', + collection: args.collectionConfig, + config, + docs: [args.doc], + i18n, + sort: true, + }) - if (hasMany) { - return ( - { - if (isPolymorphic) { - return val as ValueWithRelation - } else { - return { - relationTo: safeRelationTo[0], - value: val as ValueWithRelation['value'], - } - } + 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 + : 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]) + 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 } - : null - } - /> + 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 && ( + + )} + ) } diff --git a/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx b/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx index 03cb247a53a..adc01e11d4c 100644 --- a/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx +++ b/packages/ui/src/fields/Relationship/select-components/MultiValueLabel/index.tsx @@ -25,7 +25,7 @@ export const MultiValueLabel: React.FC< > = (props) => { const { data: { allowEdit, label, relationTo, value }, - selectProps: { customProps: { draggableProps, onDocumentDrawerOpen } = {} } = {}, + selectProps: { customProps: { draggableProps, onDocumentOpen } = {} } = {}, } = props const { permissions } = useAuth() @@ -49,12 +49,13 @@ export const MultiValueLabel: React.FC<