/** * 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 { type ReactNode, useState } from 'react' import type { CollectionAdminConfig, RelationField as FieldType, MultiCollectionDefinition, RelatedDocumentValue, } from '@byline/core' import { getCollectionAdminConfig, getCollectionDefinition, isSingleton } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import { Button, CloseIcon, DraggableSortable, ErrorText, GripperVerticalIcon, HelpText, IconButton, Label, moveItem, PlusIcon, useSortable, } from '@byline/ui/react' import cx from 'clsx' import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context' import { useScopedDomId } from '../../forms/form-dom-scope' import styles from './relation-field.module.css' import { RelationPicker, type RelationPickerSelection } from './relation-picker' import { RelationSummary } from './relation-summary' // A single stored item. On the edit path the loader's populate pass attaches // `_resolved` / `document`; freshly-picked items are bare refs. Both flatten // the same way (storage ignores the envelope keys), so we never strip them. type IncomingRelationValue = RelatedDocumentValue & { _resolved?: boolean _cycle?: boolean document?: Record } // --------------------------------------------------------------------------- // RelationManyField — `type: 'relation'` with `hasMany: true`. // // An ordered list of summary tiles: drag to reorder, ✕ to remove, "+ Add" to // open the standard picker and append. The value is an array of relation // envelopes; every mutation writes the whole array back via `setFieldValue`, // which emits a coalesced `field.set` patch — relations carry no stable `_id`, // so the granular `array.*` patches (which key on `_id`) don't apply, and a // whole-array set is both correct and simplest for these small lists. // --------------------------------------------------------------------------- interface RelationManyTileProps { id: string children: ReactNode onRemove: () => void readOnly?: boolean removeAriaLabel: string } const RelationManyTile = ({ id, children, onRemove, removeAriaLabel, readOnly, }: RelationManyTileProps) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: readOnly, transition: { duration: 250, easing: 'cubic-bezier(0, 0.2, 0.2, 1)' }, }) const style = { transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined, transition, zIndex: isDragging ? 10 : 'auto', } return (
{children}
) } interface RelationManyFieldProps { field: FieldType defaultValue?: RelatedDocumentValue[] | null id?: string path?: string } export const RelationManyField = ({ field, defaultValue, id, path }: RelationManyFieldProps) => { const fieldPath = path ?? field.name const htmlId = useScopedDomId(fieldPath, id) const { t } = useTranslation('byline-admin') const fieldError = useFieldError(fieldPath) const { getFieldValue, setFieldValue } = useFormContext() const targetDefinition = getCollectionDefinition(field.targetCollection) const targetDef: MultiCollectionDefinition | null = targetDefinition != null && !isSingleton(targetDefinition) ? targetDefinition : null const targetAdminConfig: CollectionAdminConfig | null = getCollectionAdminConfig( field.targetCollection ) const isUnknown = targetDef == null const targetLabel = targetDef?.labels.singular ?? field.targetCollection const [pickerOpen, setPickerOpen] = useState(false) // Records handed back by the picker, keyed by target id — lets a freshly // added tile render real display data (title / thumbnail) without a refetch. const [pickedRecords, setPickedRecords] = useState>>({}) // Subscribe to the live array so reorders / adds / removes re-render. const liveValue = useFieldValue(fieldPath) const items: IncomingRelationValue[] = Array.isArray(liveValue) ? liveValue : Array.isArray(defaultValue) ? (defaultValue as IncomingRelationValue[]) : [] const currentArray = (): IncomingRelationValue[] => { const v = getFieldValue(fieldPath) if (Array.isArray(v)) return v as IncomingRelationValue[] return Array.isArray(defaultValue) ? (defaultValue as IncomingRelationValue[]) : [] } const handleAddMany = (selections: RelationPickerSelection[]) => { if (field.readOnly) return setPickerOpen(false) const current = currentArray() // Dedup the batch against the current array — a target may appear at most // once. The picker already disables already-added rows, so this is a // belt-and-braces guard for stale state (e.g. a concurrent remove). const existing = new Set(current.map((v) => v.targetDocumentId)) const additions = selections.filter((s) => !existing.has(s.targetDocumentId)) if (additions.length === 0) return const records = additions.filter((s) => s.record != null) if (records.length > 0) { setPickedRecords((prev) => { const next = { ...prev } for (const s of records) next[s.targetDocumentId] = s.record! return next }) } setFieldValue(fieldPath, [ ...current, ...additions.map((s) => ({ targetDocumentId: s.targetDocumentId, targetCollectionId: s.targetCollectionId, })), ]) } const handleRemove = (targetDocumentId: string) => { if (field.readOnly) return setFieldValue( fieldPath, currentArray().filter((v) => v.targetDocumentId !== targetDocumentId) ) } const handleDragEnd = ({ moveFromIndex, moveToIndex, }: { moveFromIndex: number moveToIndex: number }) => { if (field.readOnly || moveFromIndex === moveToIndex) return setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex)) } return (
{isUnknown ? (
{t('fields.relation.unknownError', { name: field.name, target: field.targetCollection, })} {t('fields.relation.unknownHint')}
) : ( <> {/* Rounded frame below the label wrapping the interactive body (tile list and add button) — see `.frame` in the CSS module. */}
{items.length === 0 ? (

{t('fields.relation.manyEmpty', { label: targetDef.labels.plural })}

) : ( v.targetDocumentId)} onDragEnd={handleDragEnd} className={cx('byline-field-relation-many', styles.many)} > {items.map((value) => ( handleRemove(value.targetDocumentId)} removeAriaLabel={t('fields.relation.removeAriaLabel', { label: targetLabel })} > ))} )}
{field.helpText != null && } {fieldError && } v.targetDocumentId)} onSelectMany={handleAddMany} onDismiss={() => setPickerOpen(false)} /> )}
) }