'use client' /** * 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 { getCollectionDefinition, isSingleton } from '@byline/core' import { useTranslation } from '@byline/i18n/react' import { Button } from '@byline/ui/react' import cx from 'clsx' import { useBylineFieldServices } from '../fields/field-services-context.js' import { RelationPicker } from '../fields/relation/relation-picker.js' import styles from './tree-placement-widget.module.css' export interface TreePlacementWidgetProps { disabled?: boolean onMutationError?: (error: unknown) => 'blocked' | 'committed' | null | void onCommitted?: (receipt: import('@byline/core').StructuralMutationReceipt) => void expectedRevision: number /** The collection path (`tree: true`). */ collectionPath: string /** The logical id of the document being edited. */ documentId: string /** The collection's `useAsTitle` field, used to label the chosen parent. */ useAsTitle?: string } /** * Sidebar widget for placing the current document within its collection's * single-parent document tree (the `tree: true` primitive — docs/04-collections/04-document-trees.md). * * The tree is document-grain and **unversioned**, so changes here write * immediately (independent of the form's content save). The editor picks a * parent through the collection's own relation picker — same search / columns * UX as a relation field — or moves the document to the top level; the server * enforces the cycle / same-collection invariants and fires the * structural-change invalidation event. * * Renders only in edit mode (placement needs a persisted document) and only when * the host wires the tree services. Stable override handle: `.byline-form-tree`. */ export const TreePlacementWidget = ({ collectionPath, disabled = false, onMutationError, onCommitted, expectedRevision, documentId, useAsTitle, }: TreePlacementWidgetProps) => { const { t } = useTranslation('byline-admin') const { getTreeAncestors, getTreeParent, placeTreeNode } = useBylineFieldServices() const definition = getCollectionDefinition(collectionPath) const targetDefinition = definition != null && !isSingleton(definition) ? definition : null const [parent, setParent] = useState<{ id: string; title: string } | null>(null) // Whether the document has an edge row at all. `false` = *unplaced* (no row); // `true` with a null `parent` = a *root*. Distinguishing the two drives the // "Add to tree" (unplaced) vs "Top level" (root) display. Hosts that don't // wire `getTreeParent` fall back to `true` — the pre-tri-state behaviour. const [placed, setPlaced] = useState(true) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [pickerOpen, setPickerOpen] = useState(false) const treeServicesReady = getTreeAncestors != null && placeTreeNode != null // Load the document's placement state and current parent on mount. The parent // title comes from the (hydrated) ancestor chain; `getTreeParent` (when wired) // tells unplaced from root, which the ancestor chain alone cannot. useEffect(() => { if (getTreeAncestors == null) return let cancelled = false setLoading(true) Promise.all([ getTreeAncestors({ collection: collectionPath, documentId }), getTreeParent?.({ collection: collectionPath, documentId }) ?? Promise.resolve({ placed: true, parentDocumentId: null }), ]) .then(([ancestors, placement]) => { if (cancelled) return const immediate = ancestors.at(-1) setParent(immediate ? { id: immediate.id, title: immediate.title } : null) setPlaced(placement.placed) }) .catch(() => { if (!cancelled) setError(t('treeWidget.error')) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [getTreeAncestors, getTreeParent, collectionPath, documentId, t]) // Place (or re-parent) the node, optimistically updating the current-parent // display and reverting on a server rejection (e.g. a cycle). Any successful // placement (including making the node a root) leaves it placed. const place = useCallback( async (parentDocumentId: string | null, optimistic: { id: string; title: string } | null) => { if (disabled || placeTreeNode == null || busy) return const previousParent = parent const previousPlaced = placed setError(null) setBusy(true) setParent(optimistic) setPlaced(true) try { const result = await placeTreeNode({ expectedRevision, collection: collectionPath, documentId, parentDocumentId, }) onCommitted?.(result) } catch (error) { if (onMutationError?.(error) === 'committed') return setParent(previousParent) setPlaced(previousPlaced) setError(t('treeWidget.error')) } finally { setBusy(false) } }, [ disabled, onMutationError, onCommitted, expectedRevision, placeTreeNode, busy, parent, placed, collectionPath, documentId, t, ] ) const handlePick = useCallback( (selection: { targetDocumentId: string; record?: Record }) => { setPickerOpen(false) const title = useAsTitle ? selection.record?.[useAsTitle] : undefined place(selection.targetDocumentId, { id: selection.targetDocumentId, title: typeof title === 'string' && title.length > 0 ? title : selection.targetDocumentId, }) }, [place, useAsTitle] ) if (!treeServicesReady) return null return (
{t('treeWidget.label')}
{t('treeWidget.parentPrefix')}{' '} {!placed ? ( {t('treeWidget.notInTree')} ) : parent ? ( {parent.title} ) : ( {t('treeWidget.rootOption')} )}
{!placed ? ( ) : ( parent != null && ( ) )}
{error != null &&

{error}

} {targetDefinition != null && ( setPickerOpen(false)} /> )} {t('treeWidget.srDescription')}
) }