// Pure sibling-list and whole-tree mutation for the builder's drag-and-drop reorder. // // The drop handler's whole job is: produce the new item state, hand it to setValue, then let the // builder's transformTo -> transformFrom -> reset() cycle rebuild every item's `path`. This module is that // maths and nothing else -- no React, no FHIR, no services barrel -- so it is directly testable. // // Every function returns NEW arrays. The caller passes react-hook-form state; mutating it in place would // desync the form. // // See docs/_CLAUDE/2026-07-16-questionnaire-drag-reorder-design.md. const inRange = (index: number, length: number): boolean => Number.isInteger(index) && index >= 0 && index < length; /** Clamp an insertion point into [0, length]. A non-integer index appends rather than corrupting the list. */ const clampIndex = (index: number, length: number): number => Number.isInteger(index) ? Math.max(0, Math.min(index, length)) : length; /** Reorder one item within its own sibling list. Out-of-range indices return the list untouched. */ export const moveWithinList = (list: T[], from: number, to: number): T[] => { if (!inRange(from, list.length) || !inRange(to, list.length)) { return [...list]; } const next = [...list]; const [moved] = next.splice(from, 1); next.splice(to, 0, moved); return next; }; /** * Move one item out of `source` and into `target`. Used for cross-group drags, where the item is re-parented. * `targetIndex` is clamped to the target's bounds so a drop past the end appends rather than leaving a hole. */ export const moveBetweenLists = ( source: T[], sourceIndex: number, target: T[], targetIndex: number ): { source: T[]; target: T[] } => { if (!inRange(sourceIndex, source.length)) { return { source: [...source], target: [...target] }; } const nextSource = [...source]; const [moved] = nextSource.splice(sourceIndex, 1); const nextTarget = [...target]; nextTarget.splice(clampIndex(targetIndex, nextTarget.length), 0, moved); return { source: nextSource, target: nextTarget }; }; // --- Whole-tree move ------------------------------------------------------------------------- // // moveWithinList/moveBetweenLists operate on sibling arrays, which forces the caller to address each list // by an index-based path (`item[2].item`). Those paths are only valid against an UNMUTATED tree: writing // one list reindexes every path that resolves through it, so a second write lands on the wrong item -- // silently, on a draft, where there is no confirm. moveItemInTree exists so a re-parenting drop is ONE // pure tree transform committed with ONE write at the root, addressing nodes by linkId (stable, and // unique per definition tree) rather than by index (not stable across a write). /** * Structural node shape: addressable by linkId, may own children of its own type. F-bounded so `item` * stays the caller's element type all the way down, with no casting. */ type TreeNodeOf = { linkId: string; item?: T[] }; const findNode = >(list: T[], linkId: string): T | null => { for (const node of list) { if (node.linkId === linkId) { return node; } const found = node.item ? findNode(node.item, linkId) : null; if (found) { return found; } } return null; }; /** True when `linkId` is `node` itself or lives anywhere in its subtree. */ const subtreeContains = >(node: T, linkId: string): boolean => node.linkId === linkId || (node.item ?? []).some((child) => subtreeContains(child, linkId)); /** Lift `linkId` out of the tree, copying only the nodes on the path down to it. */ const removeNode = >(list: T[], linkId: string): { list: T[]; removed: T | null } => { for (let i = 0; i < list.length; i++) { if (list[i].linkId === linkId) { const next = [...list]; const [removed] = next.splice(i, 1); return { list: next, removed }; } } for (let i = 0; i < list.length; i++) { const node = list[i]; if (!node.item) { continue; } const result = removeNode(node.item, linkId); if (result.removed) { const next = [...list]; // `as T`: the literal keeps every field of T and re-points `item`, which the constraint types // as T[]. TS cannot prove a spread of an open generic is still T, so state it. No `any`. next[i] = { ...node, item: result.list } as T; return { list: next, removed: result.removed }; } } return { list, removed: null }; }; /** Insert `node` into `parentLinkId`'s children, copying only the nodes on the path. Null when absent. */ const insertNode = >(list: T[], parentLinkId: string, node: T, index: number): T[] | null => { for (let i = 0; i < list.length; i++) { const current = list[i]; if (current.linkId === parentLinkId) { const children = [...(current.item ?? [])]; children.splice(clampIndex(index, children.length), 0, node); const next = [...list]; next[i] = { ...current, item: children } as T; return next; } if (current.item) { const inserted = insertNode(current.item, parentLinkId, node, index); if (inserted) { const next = [...list]; next[i] = { ...current, item: inserted } as T; return next; } } } return null; }; /** * Move `sourceLinkId` -- wherever it currently sits in `tree` -- into `targetParentLinkId`'s children, or * into the ROOT list when `targetParentLinkId` is null, at `targetIndex`. Returns a NEW tree; branches the * move did not touch keep their object identity. Removal happens before insertion, so `targetIndex` is * applied against the post-removal list (same convention as moveWithinList's `to`). An out-of-range index * is clamped instead of leaving a hole. * * Returns null -- meaning "refuse, change nothing" -- when the source is not found, the target parent is * not found, or the target parent IS the source item or sits inside the source item's own subtree. That * last case is reachable in the UI: groups are draggable and their children are droppables rendered inside * the dragged group's own rect, and dnd-kit's closestCenter has no ancestor filter, so `over` can resolve * to a descendant of `active`. Nesting a group inside itself makes the FHIR transform's unguarded group * recursion never terminate (stack overflow), so it is refused here, where it is testable. */ export const moveItemInTree = >( tree: T[], sourceLinkId: string, targetParentLinkId: string | null, targetIndex: number ): T[] | null => { const source = findNode(tree, sourceLinkId); if (!source) { return null; } if (targetParentLinkId !== null) { // Covers both "into itself" and "into its own descendant". if (subtreeContains(source, targetParentLinkId)) { return null; } if (!findNode(tree, targetParentLinkId)) { return null; } } const { list: pruned, removed } = removeNode(tree, sourceLinkId); if (!removed) { return null; } if (targetParentLinkId === null) { const next = [...pruned]; next.splice(clampIndex(targetIndex, next.length), 0, removed); return next; } // The parent exists (checked above) and cannot be the node just removed (subtree guard), so this // resolves. The null branch stays because refusing beats committing a half-applied move. return insertNode(pruned, targetParentLinkId, removed, targetIndex); };