/** * 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 { 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, EditIcon, ErrorText, HelpText, IconButton, Label, } from '@byline/ui/react' import cx from 'clsx' import { useFieldError, useFieldValue } from '../../forms/form-context' import { useScopedDomId } from '../../forms/form-dom-scope' import styles from './relation-field.module.css' import { RelationPicker } from './relation-picker' import { RelationSummary } from './relation-summary' // The raw form value for a relation field is `RelatedDocumentValue`, but // when the edit loader runs server-side populate the value arrives as a // `PopulatedRelationValue` (same base shape, plus `_resolved` / `document` // discriminator keys). We accept both here and let `RelationSummary` // narrow internally. type IncomingRelationValue = RelatedDocumentValue & { _resolved?: boolean _cycle?: boolean document?: Record } // --------------------------------------------------------------------------- // RelationField — widget for `type: 'relation'` fields // --------------------------------------------------------------------------- interface RelationFieldProps { field: FieldType value?: RelatedDocumentValue | null defaultValue?: RelatedDocumentValue | null onChange?: (value: RelatedDocumentValue | null) => void id?: string path?: string } export const RelationField = ({ field, value, defaultValue, onChange, id, path, }: RelationFieldProps) => { const fieldPath = path ?? field.name const htmlId = useScopedDomId(fieldPath, id) const fieldError = useFieldError(fieldPath) const fieldValue = useFieldValue(fieldPath) const incomingValue: IncomingRelationValue | null = fieldValue !== undefined ? ((fieldValue as IncomingRelationValue | null) ?? null) : ((value as IncomingRelationValue | null) ?? (defaultValue as IncomingRelationValue | null) ?? null) // Resolve the target collection definition + admin config. The admin // config drives the picker-column rendering inside RelationSummary so // the selected tile matches the picker row exactly. Missing target → // render an inline error and disable the picker. const targetDefinition = getCollectionDefinition(field.targetCollection) const targetDef: MultiCollectionDefinition | null = targetDefinition != null && !isSingleton(targetDefinition) ? targetDefinition : null const targetAdminConfig: CollectionAdminConfig | null = getCollectionAdminConfig( field.targetCollection ) const { t } = useTranslation('byline-admin') const [pickerOpen, setPickerOpen] = useState(false) // Cached target document from the most recent picker selection. Lets the // tile render real display data (name, thumbnail) immediately after a // pick without a round trip. Cleared via the `targetDocumentId` // comparison in the render path. const [pickedRecord, setPickedRecord] = useState<{ id: string record: Record } | null>(null) const handleSelect = (selection: { targetDocumentId: string targetCollectionId: string record?: Record }) => { if (field.readOnly) return setPickerOpen(false) if (selection.record) { setPickedRecord({ id: selection.targetDocumentId, record: selection.record }) } else { setPickedRecord(null) } onChange?.({ targetDocumentId: selection.targetDocumentId, targetCollectionId: selection.targetCollectionId, }) } const handleRemove = () => { if (field.readOnly) return setPickedRecord(null) onChange?.(null) } // Only carry the cached picker record through to the summary when it // still matches the current value — guards against a stale cache after // an external value change (e.g. patch rollback). const cachedRecord = pickedRecord && incomingValue && pickedRecord.id === incomingValue.targetDocumentId ? pickedRecord.record : null const isUnknown = targetDef == null 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 // (selected tile or select button) — see `.frame` in the CSS module.
{incomingValue ? (
setPickerOpen(true)} >
) : ( )}
)} {field.helpText != null && } {fieldError && } {!isUnknown && ( setPickerOpen(false)} /> )}
) }