/** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ import { useCallback, useEffect, useState } from 'react' import type { CollectionAdminConfig, MultiCollectionDefinition } from '@byline/core' import { getCollectionAdminConfig } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import { Button, CheckIcon, LoaderRing, Modal, Search } from '@byline/ui/react' import cx from 'clsx' import { useBylineFieldServices } from '../field-services-context' import { PickerCell, resolveFallbackDisplayField, resolveRowLabel, resolveSelectFields, } from './relation-display' import styles from './relation-picker.module.css' // --------------------------------------------------------------------------- // RelationPicker — modal listing for selecting a target document // --------------------------------------------------------------------------- /** * Row rendering strategy, in priority order: * 1. `CollectionAdminConfig.itemView` — a ColumnDefinition[] from the target * admin config. Each row renders the declared columns side-by-side, * reusing any column formatters (thumbnail, date, etc). * 2. Explicit `displayField` prop on this component (forwarded from * `RelationField.displayField`). * 3. `CollectionDefinition.useAsTitle` on the target. * 4. First top-level `text` field on the target. * * Paths 2–4 render a single-line label (primary) + `path` (secondary). */ /** * One confirmed pick. `record` is the raw document the picker row rendered — * the caller can use it to show the selected value in its own tile without a * refetch. The fields available on `record` are whatever `resolveSelectFields` * asked the listing endpoint for (picker columns + `useAsTitle` + * `displayField`), so any display surface downstream of the picker that also * renders from those same columns will find the data it needs. */ export interface RelationPickerSelection { targetDocumentId: string targetCollectionId: string record?: Record } interface RelationPickerBaseProps { /** The target collection path (e.g. `'media'`). */ targetCollectionPath: string /** The target collection definition (used for labels + displayField fallback). */ targetDefinition?: MultiCollectionDefinition | null /** Explicit display field to render as row label. */ displayField?: string /** * Extra field names to load into each row's `record.fields` beyond the * display columns. Not rendered — available to the `onSelect` consumer * (e.g. the inline-image modal seeding alt-text from the picked media). * * Pass a stable (module-level) array — this feeds the fetch effect's * dependency list, so a fresh array each render would refetch on every * render. */ extraSelectFields?: string[] /** Modal open/close state. */ isOpen: boolean /** Called when the user dismisses the modal. */ onDismiss: () => void } interface RelationPickerSingleProps extends RelationPickerBaseProps { /** Single-select (default): clicking a row selects it; confirm returns one pick. */ multiple?: false /** Called with the picked selection when the user confirms. */ onSelect: (selection: RelationPickerSelection) => void onSelectMany?: never excludeIds?: never } interface RelationPickerMultiProps extends RelationPickerBaseProps { /** * Multi-select mode (`hasMany` widgets): rows toggle a check state and the * confirm action returns every selection in pick order — several picks in * one trip instead of reopening the modal per item. */ multiple: true /** Called with the full selection set (pick order) when the user confirms. */ onSelectMany: (selections: RelationPickerSelection[]) => void onSelect?: never /** * Target ids already present on the caller's value. Rendered as disabled * "already added" rows so the same target can't be picked twice. */ excludeIds?: string[] } type RelationPickerProps = RelationPickerSingleProps | RelationPickerMultiProps const PAGE_SIZE = 15 export const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, multiple = false, excludeIds, onSelect, onSelectMany, onDismiss, }: RelationPickerProps) => { const [query, setQuery] = useState('') const [page, setPage] = useState(1) const { t } = useTranslation('byline-admin') const [selectedDocumentId, setSelectedDocumentId] = useState(null) // Multi-select state. A Map keyed by target id preserves pick order (the // confirmed batch appends in that order) and carries each row's record so // picks survive page/search changes that swap out `documents`. const [selectedMap, setSelectedMap] = useState | undefined>>( () => new Map() ) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [documents, setDocuments] = useState([]) const [totalPages, setTotalPages] = useState(1) const [collectionId, setCollectionId] = useState(null) // Refresh is a dependency of the fetch effect rather than an imperative // refetch, so re-running it goes through the effect's `cancelled` cleanup // below: a superseded request that resolves late is discarded instead of // overwriting newer results. Calling the service directly would lose that. const [refreshNonce, setRefreshNonce] = useState(0) const { getCollectionDocuments, canCreateInCollection, getCreateDocumentUrl } = useBylineFieldServices() // Both capabilities are optional, and the affordance needs both: without // `getCreateDocumentUrl` there is nowhere to send the reader, and without // `canCreateInCollection` their permission is unknowable — offering a link that // leads to a refusal is worse than offering none. The check is cosmetic; the // create view enforces the ability server-side regardless. const createHref = getCreateDocumentUrl != null && canCreateInCollection?.(targetCollectionPath) === true ? getCreateDocumentUrl(targetCollectionPath) : null const targetAdminConfig: CollectionAdminConfig | null = getCollectionAdminConfig(targetCollectionPath) const pickerColumns = targetAdminConfig?.itemView // Reset local state each time the modal opens so prior queries don't leak. useEffect(() => { if (isOpen) { setQuery('') setPage(1) setSelectedDocumentId(null) setSelectedMap(new Map()) setError(null) } }, [isOpen]) // Fetch whenever the modal is open and the query / page changes. // // `refreshNonce` is a re-run trigger rather than a value this effect reads, so // the exhaustive-dependencies rule sees it as surplus. It is not: bumping it is // how the refresh button re-runs the fetch, and routing refresh through the // effect is what makes the `cancelled` cleanup discard a superseded request. // biome-ignore lint/correctness/useExhaustiveDependencies: see above useEffect(() => { if (!isOpen) return let cancelled = false const selectFields = resolveSelectFields( targetDefinition, displayField, pickerColumns, extraSelectFields ) setLoading(true) setError(null) // Item-view sort: the target collection's `itemViewSort` (boot-validated) // orders the picker independently of its list view's `defaultSort`. // Passed as explicit params because the list server fn gives an explicit // `order` top precedence; when absent the server falls back through // `defaultSort` → `created_at desc` (or `order_key asc` for orderable // collections) exactly as before. const itemViewSort = targetAdminConfig?.itemViewSort getCollectionDocuments({ collection: targetCollectionPath, params: { page, page_size: PAGE_SIZE, query: query.length > 0 ? query : undefined, fields: selectFields, ...(itemViewSort != null ? { order: String(itemViewSort.field), desc: itemViewSort.direction === 'desc' } : {}), }, }) .then((response: any) => { if (cancelled) return setDocuments(response.docs) setTotalPages(response.meta.totalPages ?? 1) setCollectionId(response.included.collection.id as string) }) .catch((err: any) => { if (cancelled) return setError(err instanceof Error ? err.message : t('fields.relation.picker.loadFailed')) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [ isOpen, targetCollectionPath, query, page, displayField, extraSelectFields, targetDefinition, pickerColumns, getCollectionDocuments, t, targetAdminConfig?.itemViewSort, refreshNonce, ]) const resolvedDisplayField = displayField ?? targetDefinition?.useAsTitle ?? resolveFallbackDisplayField(targetDefinition) ?? null const handleSelect = useCallback(() => { if (!selectedDocumentId || !collectionId || !onSelect) return const record = documents.find((d) => d?.id === selectedDocumentId) onSelect({ targetDocumentId: selectedDocumentId, targetCollectionId: collectionId, record, }) }, [selectedDocumentId, collectionId, documents, onSelect]) const handleSelectMany = useCallback(() => { if (selectedMap.size === 0 || !collectionId || !onSelectMany) return onSelectMany( Array.from(selectedMap, ([targetDocumentId, record]) => ({ targetDocumentId, targetCollectionId: collectionId, record, })) ) }, [selectedMap, collectionId, onSelectMany]) const toggleSelected = useCallback((doc: Record) => { const id = doc.id as string setSelectedMap((prev) => { const next = new Map(prev) if (next.has(id)) { next.delete(id) } else { next.set(id, doc) } return next }) }, []) const title = t('fields.relation.selectPickerTitle', { label: targetDefinition?.labels.singular ?? targetCollectionPath, }) return (

{title}

{/* Deliberately not disabled while loading: a request that never settles must not leave the reader unable to retry, and a refresh issued mid-flight supersedes the earlier one rather than racing it. */} {createHref != null && ( // Rendered as an anchor, styled as a button: keyboard activation and // middle-click work without help and popup blockers leave real links // alone, while `Button`'s `render` prop keeps it visually part of the // toolbar. A new tab keeps this picker and the parent editor mounted, // so unsaved work survives — the whole point of the affordance. // `noopener` keeps the opened page out of `window.opener`.
{ setPage(1) setQuery(q ?? '') }} onClear={() => { setPage(1) setQuery('') }} inputSize="sm" placeholder={t('fields.relation.picker.searchPlaceholder')} />
{loading && documents.length === 0 && (
)} {!loading && error && (
{error}
)} {!loading && !error && documents.length === 0 && (
{t('fields.relation.picker.empty')}
)} {documents.length > 0 && (
    {documents.map((doc) => { const id = doc.id as string const selected = multiple ? selectedMap.has(id) : selectedDocumentId === id const excluded = multiple && (excludeIds?.includes(id) ?? false) return (
  • ) })}
)}
{totalPages > 1 && (
{t('common.pager.pageOf', { page, total: totalPages })}
)}
) }