import { Controller, useFormContext } from 'react-hook-form';
import { TextareaControl } from "../../../../../components/ui/textarea-control";
import { CheckboxControl } from "../../../../../components/ui/checkbox-control";
import { RadioControl } from "../../../../../components/ui/radio-control";
import { RangeControl } from "../../../../../components/ui/range-control";
import { Tooltip } from "../../../../../components/ui/tooltip";
import { ExternalLink } from "../../../../../components/ui/external-link";
import { CustomSelectControl } from "../../../../../components/ui/custom-select-control";
import { Popover, PopoverContent, PopoverTrigger } from "../../../../../components/ui/popover";
import { UnitControl } from "../../../../../components/ui/unit-control";
import type { CustomSelectOption } from "../../../../../components/ui/custom-select-control";
import type { WPUnitControlUnit } from "../../../../../components/ui/unit-control";
import { DndContext, DragEndEvent, PointerSensor, closestCenter, useDroppable, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, rectSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { moveItemInTree, moveWithinList } from '../../../../../utils/questionnaireReorder';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../../../../components/ui/dialog';
import { Button as UiButton } from '../../../../../components/ui/button';
import { Card, CardHeader, CardBody } from '../../../../../components/ui/wp-card';
import { Elevation } from '../../../../../components/ui/elevation';
import { InputControl } from '../../../../../components/ui/input-control';
import { Divider } from '../../../../../components/ui/divider';
import { DismissibleAlert } from '../../../../../components/ui/dismissible-alert';
import { Heading } from '../../../../../components/ui/heading';
import { Flex, FlexItem, FlexBlock } from '../../../../../components/ui/flex';
import { Text } from '../../../../../components/ui/text';
import { Spacer } from '../../../../../components/ui/spacer';
import { Disabled } from '../../../../../components/ui/disabled';
import { Info, PlusCircle, Trash2 } from 'lucide-react';
import {
ExtendedQuestionnaireItem,
ExtendedQuestionnaireItemAnswerOption,
ExtendedQuestionnaireItemEnableWhen,
ExtendedQuestionnaireItemInitial,
useQuestionnaireServiceContext
} from '../../../../../services';
import { Questionnaire, QuestionnaireItem } from 'fhir/r4';
import { normalizeColumnSpan, packIntoRows } from '../../../../../utils/questionnaireLayout';
import React, { useState } from 'react';
import ColumnResizeHandle from './ColumnResizeHandle';
// The renderer's own stylesheet. Its import was lost on 2026-08-29 (a4a1736e), which silently
// dropped the column grid, the builder's selection and every class below with it.
import './QuestionnairePreview.css';
import FileAnswer from '../../../../../components/FileAnswer/FileAnswer';
interface QuestionnairePreviewProps {
selectedItem?: ExtendedQuestionnaireItem | null;
showVersion?: boolean;
ignoreValidation?: boolean;
elevated?: boolean;
// Builder-only. Defaults to false so the 7 non-builder call sites -- including the patient
// filling out their form (TaskPage) -- can never render a resize handle.
layoutEditable?: boolean;
// Builder-only, same pattern as layoutEditable: optional, no-op when omitted, so the 7
// non-builder call sites are unaffected. Lets a reorder re-point the caller's selection to the
// fresh (post-reset) item instead of leaving it pointed at a stale object/path.
onSelectItem?: (item: ExtendedQuestionnaireItem | null) => void;
// Builder-only: a click on a field selects it for editing. Receives the field's linkId.
onFieldClick?: (linkId: string) => void;
// IS SOMEBODY ACTUALLY FILLING THIS IN? This component is three surfaces wearing one name: the
// AUTHOR's preview in the builder, the PATIENT's answering form on TaskPage, and the staff
// READ-BACK of a submitted response. For every question type but one that distinction did not
// matter, because a text box that is disabled by its caller looks like a text box.
//
// IT MATTERS FOR A FILE. There is nothing to upload to from a preview, so the file question
// was hardcoded `disabled` - correct where it was written, and wrong the moment the answering
// surface reused the component. A patient assigned a file question met a control they could not
// operate, under a sentence describing them in the third person.
//
// Defaults to FALSE like the two flags above, so the read-back and builder call sites are
// unchanged and only the surface that can really take a file offers to.
answerable?: boolean;
// Where the surface around the form already names it, as the task dialog does.
hideTitle?: boolean;
}
interface SortableColumnProps {
id: string;
linkId: string;
parentLinkId: string | null;
span: number;
listPath: string;
onSelect?: (linkId: string) => void;
children: React.ReactNode;
}
// A sortable column. Must be its own component: useSortable is a hook and cannot run inside a.map.
//
// Hoisted to MODULE scope (closes over nothing local -- everything it needs arrives as props). Declaring
// this inside QuestionnairePreview's render body would make it a new component type on every render, and
// this builder re-renders constantly (selectedItem changes on every field click, popover state, watch
// ('title')). React would then unmount/remount every column's whole subtree each render: lost focus/caret
// while typing, useSortable re-registering, and an aborted drag if anything re-renders mid-drag.
//
// `id` is the composite, per-rendered-instance sortable id (see renderRows); `linkId` is the real FHIR
// linkId of the underlying item, carried on the sortable data so handleDragEnd never has to parse `id`.
// `parentLinkId` is the linkId of the GROUP that owns this sibling list, or null at the top level. It
// rides along for the same reason `linkId` does: when this column is the drop target, handleDragEnd needs
// the destination parent's identity, and recovering it by string-slicing `listPath` would be guesswork.
const SortableColumn = ({ id, linkId, parentLinkId, span, listPath, onSelect, children }: SortableColumnProps) => {
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, data: { listPath, linkId, parentLinkId } });
// Pressing a field or tabbing into one selects it. Capture runs outer to inner, so the innermost
// field in a nested group is the one left selected.
const select = onSelect ? () => onSelect(linkId) : undefined;
// The WHOLE column is the drag surface -- no visible grip. dnd-kit's pointer `listeners` go on the
// wrapper, and the `--draggable` modifier paints a `move` cursor as the affordance. `attributes`
// (role=button/tabIndex, dnd-kit's keyboard-drag hooks) are deliberately NOT spread: keyboard moves
// stay in field settings (Move up/down, Move to), and role=button wrapping a live input is an a11y smell.
// A press on an input still just interacts with it (the 4px activation threshold lets clicks through);
// the resize handle stops propagation on the right edge, so it resizes rather than drags.
return (
{children}
);
};
interface GroupDropZoneProps {
listPath: string;
parentLinkId: string;
}
// A landing zone for a structurally EMPTY group (no child item definitions). renderRows returns a
// SortableContext with zero children for an empty list, and a SortableContext with nothing inside it
// renders no DOM -- there is nothing for dnd-kit's collision detection to hit, so a drop could never land
// "inside" an empty group. This gives it a real droppable region, same convention as dnd-kit's own
// "empty container" pattern.
//
// Module-scope for the same reason as SortableColumn: useDroppable is a hook, and renderGroup (the caller)
// is a plain function invoked a variable number of times per render -- calling a hook directly inside it
// would violate the rules of hooks. Used via JSX (), so React tracks each instance
// correctly regardless of how many times renderGroup itself runs.
// `parentLinkId` is non-null by construction: a drop zone only ever renders INSIDE a group (renderGroup),
// so there is always an owning group to name as the destination parent.
const GroupDropZone = ({ listPath, parentLinkId }: GroupDropZoneProps) => {
const { setNodeRef, isOver } = useDroppable({ id: `${listPath}::empty-group`, data: { listPath, parentLinkId } });
return (
Drop a field here to move it into this group
);
};
const QuestionnairePreview = ({ selectedItem, showVersion = false, ignoreValidation = false, elevated = false, layoutEditable = false, answerable = false, hideTitle = false, onSelectItem, onFieldClick }: QuestionnairePreviewProps) => {
const [visiblePopovers, setVisiblePopovers] = useState>({});
// A pending cross-group move, held while the confirm is open. Null when nothing is pending.
//
// WHAT is moved and WHERE it lands are held as linkIds, not paths: the author can keep editing while
// this dialog is open, and an index-based path captured now could resolve to a different item entirely
// by the time they hit "Move anyway". linkIds are stable, so the commit re-resolves them against a
// fresh snapshot. `targetIndex` is still an index, so an edit made while the dialog is open can shift
// the landing POSITION within the destination group -- it is clamped, and the destination parent is
// still correct, so the worst case is an off-by-one slot, not a corrupted tree.
//
// `targetGroupText` is null when the destination is the top level (moving a field OUT of a group),
// which has no owning group to name.
const [pendingMove, setPendingMove] = useState<{
sourceLinkId: string;
targetParentLinkId: string | null;
targetIndex: number;
itemText: string;
targetGroupText: string | null;
} | null>(null);
const {
transformFromFhirQuestionnaireItem,
transformToFhirQuestionnaireItem,
transformToFhirQuestionnaire,
transformFromFhirQuestionnaire
} = useQuestionnaireServiceContext();
const { control, watch, getValues, setValue, reset } = useFormContext();
const spacerMargin = '24px';
const allItemFields = getValues('item');
const sensors = useSensors(
// A small distance threshold so a plain click/tap on a field still interacts with it (focus a text
// box, toggle a checkbox, open a choice) instead of starting a drag. Keyboard drag is intentionally
// omitted -- Move up/down and Move to in field settings are the keyboard path.
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
const findItemInTree = (linkId: string, itemArray?: ExtendedQuestionnaireItem[]): ExtendedQuestionnaireItem | null => {
const itemsArray = itemArray ? itemArray : allItemFields;
for (let i = 0; i < itemsArray.length; i++) {
const item: ExtendedQuestionnaireItem = itemsArray[i];
if (item.linkId === linkId) {
return item;
}
const nestedItems = item.item;
if (nestedItems) {
const result: ExtendedQuestionnaireItem | null = findItemInTree(linkId, nestedItems);
if (result) {
return result;
}
}
}
return null;
};
const findRootGroup = (item: ExtendedQuestionnaireItem): ExtendedQuestionnaireItem => {
if (!item.parent) {
return item;
}
return findRootGroup(item.parent);
};
const findAnswer = (node: ExtendedQuestionnaireItem, item: ExtendedQuestionnaireItem) => {
const path = node.answerPath;
const answers = getValues(`${path}.answer`) ?? [];
for (let i = 0; i < answers.length; i++) {
const answerGroup = answers[i];
for (let j = 0; j < answerGroup.length; j++) {
const answerItem = answerGroup[j];
if (answerItem.linkId === item.linkId) {
return answerItem;
} else if (answerItem.type === 'group') {
return findAnswer(answerItem, item);
}
}
}
};
const evaluateEnableWhen = (item: ExtendedQuestionnaireItem): boolean => {
const enableWhen = (item.enableWhen || []) as unknown as ExtendedQuestionnaireItemEnableWhen[];
const enableBehavior: 'all' | 'any' = item.enableBehavior ?? 'all';
if (enableWhen.length === 0) {
return true;
}
return enableWhen[enableBehavior === 'all' ? 'every' : 'some']((condition: ExtendedQuestionnaireItemEnableWhen) => {
const { question, operator, answer } = condition;
const predicateQuestion = findItemInTree(question?.linkId);
const parent = predicateQuestion?.parent;
const isInGroup: boolean = parent?.type === 'group';
let answers;
if (isInGroup && predicateQuestion) {
const root = findRootGroup(predicateQuestion);
const answerItem = findAnswer(root, predicateQuestion);
answers = answerItem?.value.answer;
} else {
answers = predicateQuestion?.answer;
}
switch (operator) {
case 'exists':
return answer ? answers.some((a: { value: any }) => a.value) : answers.some((a: { value: any }) => !a.value);
case '=':
return answers.some((a: { value: any }) => {
if (['choice', 'open-choice'].includes(predicateQuestion?.type ?? '')) {
return a.value.code === answer.code;
}
if (typeof answer === 'number') {
return +a.value === answer;
}
return a.value === answer;
});
case '!=':
return answers.some((a: { value: any }) => {
if (['choice', 'open-choice'].includes(predicateQuestion?.type ?? '')) {
return a.value.code !== answer.code;
}
if (typeof answer === 'number') {
return +a.value !== answer;
}
return a.value !== answer;
});
case '>':
return answers.some((a: { value: any }) => {
if (typeof answer === 'number') {
return +a.value > answer;
}
return a.value && a.value > answer;
});
case '>=':
return answers.some((a: { value: any }) => {
if (typeof answer === 'number') {
return +a.value >= answer;
}
return a.value && a.value >= answer;
});
case '<':
return answers.some((a: { value: any }) => {
if (typeof answer === 'number') {
return +a.value < answer;
}
return a.value && a.value < answer;
});
case '<=':
return answers.some((a: { value: any }) => {
if (typeof answer === 'number') {
return +a.value <= answer;
}
return a.value && a.value <= answer;
});
}
});
};
// const findItemPath = (linkId: string, groupIndex: number, itemArray?: any[], basePath: string = 'item'): string | null => {
// const itemsArray = itemArray || getValues('item') || [];
//
// for (let i = 0; i < itemsArray.length; i++) {
// const currentPath = `${basePath}[${i}]`;
// const item = itemsArray[i];
//
// if (item.linkId === linkId) {
// return currentPath;
// }
//
// if (item.type === 'group' && item.answer && Array.isArray(item.answer)) {
// for (let j = groupIndex; j < item.answer.length; j++) {
// const answerGroup = item.answer[j];
// if (Array.isArray(answerGroup)) {
// for (let k = 0; k < answerGroup.length; k++) {
// const answerItem = answerGroup[k];
// if (answerItem.linkId === linkId) {
// return `${currentPath}.answer[${j}][${k}]`;
// }
//
// if (answerItem.item && Array.isArray(answerItem.item) && answerItem.item.length > 0) {
// const deepNestedPath = findItemPath(linkId, groupIndex, answerItem.item, `${currentPath}.answer[${j}][${k}].answer`);
// if (deepNestedPath) {
// return deepNestedPath;
// }
// }
// }
// }
// }
// }
// }
//
// return null;
// };
const addAnswer = (item: ExtendedQuestionnaireItem, original?: ExtendedQuestionnaireItem) => {
// const path = findItemPath(item.linkId, groupIndex);
const path = item.answerPath;
if (!path) return;
const answerArray = getValues(`${path}.answer`) ?? [];
const questionnaireItem = transformToFhirQuestionnaireItem(item);
const childItems = questionnaireItem.item ?? [];
// item is a group
if (item.type === 'group') {
const newGroupAnswer = childItems
.filter((childItem: QuestionnaireItem) => childItem.linkId !== `${item.linkId}_help`)
.map((childItem: QuestionnaireItem, childIndex: number) =>
transformFromFhirQuestionnaireItem(childItem, getValues() as Questionnaire, childIndex, undefined, item, `${item.path}.item`, `${path}.answer[${item.answer.length}]`)
);
setValue(`${path}.answer`, [...answerArray, newGroupAnswer]);
return;
}
let initialArray;
// item is inside a group
if (original) {
if (original.type === 'group') {
const itemIndex = original.item.findIndex((originalItem: ExtendedQuestionnaireItem) => originalItem.linkId === item.linkId);
const question = original.item[itemIndex];
initialArray = question.initial;
} else {
initialArray = original.initial;
}
} else {
initialArray = item.initial;
}
const index = answerArray.length;
const initialValue = (initialArray as ExtendedQuestionnaireItemInitial[] | undefined)?.[index]?.value ?? '';
setValue(`${path}.answer`, [...answerArray, { value: initialValue }]);
};
const removeAnswer = (item: ExtendedQuestionnaireItem, index: number) => {
const path = item.answerPath;
if (path) {
const answerArray = getValues(`${path}.answer`) || [];
answerArray.splice(index, 1);
setValue(`${path}.answer`, answerArray);
}
};
const renderQuestionText = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, index?: number, groupIndex?: number) => {
return (
<>
{original.type === 'group' ? (
{original.prefix ? `${original.prefix} ${original.text} ` : `${original.text} `}
{item.answer?.length > 1 ? (groupIndex ?? 0) + 1 : ''}
{original.required && (
*
)}
) : (
<>
{/* The prompt is the shared card title: 13px in dark ink, over the answer it asks for. */}
{original.prefix ? `${original.prefix} ${original.text} ` : `${original.text} `}
{item.answer?.length > 1 ? (index ?? 0) + 1 : ''}
{original.required && (
*
)}
>
)}
{original.supportLink && (
Help
)}
{original.help && (
{/* THE BUBBLE IS NO LONGER NESTED INSIDE THE BUTTON. WordPress's Popover
anchored itself off wherever it was mounted, so it was written as a
child of the button that opens it - which also put a block of text
inside a control, where it does not belong. Radix anchors off the
trigger instead, so the content moves out and the button goes back to
being a button.
`aria-label` because this control has no accessible name of its own:
the icon gives none, and the Tooltip contributes a DESCRIPTION rather
than a name. No `title` - the Tooltip already shows the same text. */}
{
setVisiblePopovers(prev => ({ ...prev, [original.linkId]: open }));
}}>
{original.help}
)}
{/* Rule: Can only have multiple initial values for repeating items */}
{/*
For multiple choice questions repeating items are either:
1. Checkboxes
2. Multi-select dropdowns
*/}
{original.repeats && !original.readOnly && original.type !== 'choice' && original.type !== 'open-choice' && (
<>
{item.answer.length > original.minOccurs && (
)}
{((original.type === 'group' ? groupIndex : index) ?? 0) + 1 === item.answer?.length && item.answer?.length < original.maxOccurs && (
)}
>
)}
>
);
};
const renderDisplay = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, index: number) => {
return (
);
};
const renderTextInput = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, answer: ExtendedQuestionnaireItemAnswerOption, answerIndex: number) => {
const type = original.type;
const isSlider = (type === 'integer' || type === 'decimal') && original.itemControl.code === 'slider';
const minValue = original.minValue;
const maxValue = original.maxValue;
const sliderStepValue = original.sliderStepValue;
const unit = original.unit;
return (
<>
{renderQuestionText(item, original, answerIndex)}
{
if (answer.value) {
field.value = answer.value;
}
if (isSlider) {
return (
<>
{fieldState.error && (
{fieldState.error.message || `${original.text} is required`}
)}
>
);
} else if (type === 'quantity' || (type === 'integer' && unit) || (type === 'decimal' && unit)) {
let units: WPUnitControlUnit[] = [];
if (type === 'quantity') {
units = original.unitOption.map((unit) => ({
label: unit.code,
value: unit.code
}));
} else if (unit) {
units = [{ label: unit.code ?? '', value: unit.code ?? '' }];
}
return (
<>
{}}/>
{fieldState.error && (
{fieldState.error.message || `${original.text} is required`}
)}
>
);
} else {
return (
<>
{fieldState.error && (
{fieldState.error.message || `${original.text} is required`}
)}
>
);
}
}}/>
>
);
};
const renderTextArea = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, answer: ExtendedQuestionnaireItemAnswerOption, answerIndex: number) => {
return (
<>
{renderQuestionText(item, original, answerIndex)}
{
if (answer.value) {
field.value = answer.value;
}
return (
<>
{fieldState.error && (
{fieldState.error.message || `${original.text} is required`}
)}
>
);
}}/>
>
);
};
/**
* - A FILE QUESTION, ON THE THREE SURFACES THIS COMPONENT IS.
*
* THE DEFECT IT STARTED AS: the type chain ended with no branch for `attachment`, so an author who
* picked it got nothing - not the question text they had just typed, not a control, not a
* placeholder. Measured in a browser on 2026-08-23 with its control: the same item switched to
* String rendered its label and an input in the same pane, so the preview was live and had no case.
*
* THE DEFECT IT BECAME: the branch added then was a hardcoded `` under
* the sentence "the person filling this in can attach an image or a PDF". That is right for the
* AUTHOR - there is nothing to upload to from a preview - and it was wrong the moment `TaskPage`
* reused this component as the patient's answering form. A practice could build a file question the
* product invited it to build, and no patient could answer it. A permanently disabled control is
* the denial rule's silent form: it does not refuse, does not explain, and cannot be told apart from
* one waiting on something.
*
* SO THE SURFACE DECIDES, rather than the component guessing:
*
* answerable the real control - choose a file or take a photo, uploaded on selection
* an answer exists what was sent, shown rather than offered - this is the staff read-back,
* and it must not offer a control because those callers wrap this in an
* inert
* otherwise the author's preview, disabled, exactly as before
*/
const renderAttachment = (
item: ExtendedQuestionnaireItem,
original: ExtendedQuestionnaireItem,
answer: ExtendedQuestionnaireItemAnswerOption,
answerIndex: number,
) => {
return (
<>
{renderQuestionText(item, original, answerIndex)}
{answerable ? (
{
if (answer.value) {
field.value = answer.value;
}
return (
<>
{fieldState.error && (
{fieldState.error.message || `${original.text} is required`}
)}
>
);
}}/>
) : answer.value ? (
// THE STAFF READ-BACK. `FhirQuestionnaireResponse`, `PatientQuestionnaireResponses`
// and `FhirHistory` all wrap this component in ``, which is `inert` -
// nothing inside can be clicked. So the file is SHOWN rather than offered, and a
// format the browser cannot decode says so rather than rendering a broken image.
{}} readOnly/>
) : (
<>
{/* EACH ON ITS OWN LINE, and this needed fixing after seeing it. `Text`
renders inline, so the hint flowed around the file input and stranded the
word "The" at the right edge of the row - read on screen as a broken layout
rather than as guidance. Caught by looking at the rendered page; nothing in
a type-check or a source assertion can see it. */}
The person filling this in can attach an image or a PDF.
>
)}
>
);
};
const renderBoolean = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, answer: ExtendedQuestionnaireItemAnswerOption, answerIndex: number) => {
return (
<>
{renderQuestionText(item, original, answerIndex)}
ignoreValidation || !original.required
|| typeof value === 'boolean' || `${original.text} is required`,
}}
render={({ field, fieldState }) => {
field.value = answer.value;
// YES AND NO AS TWO CHOICES, NOTHING PICKED UNTIL THE PATIENT PICKS -
// the task deck's yes/no question (patient-portal.html:16047). A switch
// has no unanswered position, so an untouched one read, counted and sent
// as "No". A saved false still reads back as No.
return (
<>
{([['Yes', true], ['No', false]] as const).map(([label, value]) => (
))}
))}
{fieldState.error && (
<>
{fieldState.error.message || `${original.text} is required`}
>
)}
>
);
} else {
return (
<>
{/* handle repeating item with FormTokenField */}
{/* The question text is drawn by renderQuestionText above,
which does not label this control. */}
(
{ key: option.value.code, name: option.value.display }
)) as CustomSelectOption[]
]}
value={customSelectValue}
onChange={(newValue: { selectedItem: CustomSelectOption }) => {
const selectedValueSet = answerOption.find((option: ExtendedQuestionnaireItemAnswerOption) => option.value.code === newValue.selectedItem.key);
field.onChange(selectedValueSet?.value);
}}/>
{fieldState.error && (
<>
{fieldState.error.message || `${original.text} is required`}
>
)}
>
);
}
}}/>
>
);
};
const getOriginal = (item: ExtendedQuestionnaireItem) => {
// return findItemInTree(item.linkId);
return getValues(item.path);
};
// A field only occupies grid space if it actually renders. renderItem returns null for hidden /
// enableWhen-disabled items, so they must be filtered BEFORE packing -- otherwise they leave a
// hole in the row.
const isRendered = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem): boolean =>
!original.hidden && evaluateEnableWhen(item);
// Reordering shifts sibling indices, which invalidates every item's `path` string. So a drop MUST go
// through the same transformTo -> transformFrom -> reset() cycle Move Up/Down uses; a bare setValue would
// leave stale paths. Commit on drop only -- dnd-kit owns the visuals during the drag.
const commitReorder = (listPath: string, from: number, to: number) => {
const list = getValues(listPath) ?? [];
setValue(listPath, moveWithinList(list, from, to));
const q = transformToFhirQuestionnaire(getValues());
reset(transformFromFhirQuestionnaire(q));
// reset() just rebuilt the whole tree with fresh objects and fresh `path` strings. selectedItem
// is a prop owned by the caller and still points at the OLD object/path -- if this drag moved
// something other than the selected item, the settings panel would silently keep reading/writing
// the stale path (a different field than the one highlighted). Re-derive the fresh object by
// linkId from the tree reset() just produced (NOT findItemInTree's default arg, which is the
// stale allItemFields captured at the top of this render) and re-point the caller's selection.
if (selectedItem) {
const fresh = findItemInTree(selectedItem.linkId, getValues('item'));
onSelectItem?.(fresh ?? null);
}
};
// Cross-list move: the item leaves its current parent and joins `targetParentLinkId` (null = the top
// level), which re-parents it. Same transformTo -> transformFrom -> reset() cycle as commitReorder,
// for the same reason (paths shift).
//
// INVARIANT: never resolve an index-based path after mutating anything above it. Item paths are
// `item[2].item` (QuestionnaireService builds them as `${basePath}[${index}]`), so writing the source
// list REINDEXES the array every other path resolves through -- a second setValue would then land on
// the wrong item, corrupting the tree silently on a draft, where there is no confirm. Swapping the two
// writes only moves the corruption to the other direction. So: one snapshot, one pure tree transform
// that addresses nodes by linkId, one write at the root. There is no intermediate state to be stale.
const commitMoveBetween = (sourceLinkId: string, targetParentLinkId: string | null, targetIndex: number) => {
const tree: ExtendedQuestionnaireItem[] = getValues('item') ?? [];
const nextTree = moveItemInTree(tree, sourceLinkId, targetParentLinkId, targetIndex);
// null = moveItemInTree refused (source/target missing, or the target parent is the dragged group
// itself or one of its own descendants -- which would nest a group inside itself and send the FHIR
// transform's group recursion into a stack overflow). Refusing means commit NOTHING.
if (!nextTree) {
return;
}
setValue('item', nextTree);
const q = transformToFhirQuestionnaire(getValues());
reset(transformFromFhirQuestionnaire(q));
// Same reasoning as commitReorder: re-derive the fresh object from the tree reset() just produced
// and re-point the caller's selection, rather than leaving it on a stale path.
if (selectedItem) {
const fresh = findItemInTree(selectedItem.linkId, getValues('item'));
onSelectItem?.(fresh ?? null);
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
// Sortable ids are composite (per rendered instance, see renderRows) so they can't be treated as
// linkIds -- the real linkId travels on the sortable data instead (set in SortableColumn). The
// empty-group drop zone (GroupDropZone) carries a listPath but no linkId -- there is no sibling to
// point at when the group has no children yet -- so overLinkId is NOT required up front.
const activeLinkId = active.data.current?.linkId as string | undefined;
const overLinkId = over.data.current?.linkId as string | undefined;
const sourcePath = String(active.data.current?.listPath ?? '');
const targetPath = String(over.data.current?.listPath ?? '');
// The destination GROUP's linkId, or null for the top-level list. Carried on the sortable /
// droppable data by whatever was hovered (a sibling column or the empty-group drop zone), so it
// never has to be recovered by slicing `targetPath`.
const targetParentLinkId = (over.data.current?.parentLinkId ?? null) as string | null;
if (!activeLinkId || !sourcePath || !targetPath) {
return;
}
const source: ExtendedQuestionnaireItem[] = getValues(sourcePath) ?? [];
const from = source.findIndex((i) => i.linkId === activeLinkId);
if (from === -1) {
return;
}
// Same parent: always safe. A QuestionnaireResponse keys answers on linkId, not position, so
// sibling order never moves an existing answer. No confirm, ever.
if (sourcePath === targetPath) {
const to = overLinkId ? source.findIndex((i) => i.linkId === overLinkId) : -1;
if (to !== -1) {
commitReorder(sourcePath, from, to);
}
return;
}
// Read BEFORE anything is written -- targetPath is index-based, so it is only trustworthy against
// an unmutated tree. Nothing has mutated yet here; the commit itself never re-resolves it.
const target: ExtendedQuestionnaireItem[] = getValues(targetPath) ?? [];
const targetIndex = Math.max(0, target.findIndex((i) => i.linkId === overLinkId));
const tree: ExtendedQuestionnaireItem[] = getValues('item') ?? [];
// A group is draggable, and its own children are droppables sitting inside the dragged group's
// rect. closestCenter has no ancestor filter, so `over` can be a DESCENDANT of `active`.
// moveItemInTree is the authoritative guard (commitMoveBetween bails on null); run it here as a
// dry run too, so an illegal drop never opens a data-loss confirm for a move that cannot happen.
if (!moveItemInTree(tree, activeLinkId, targetParentLinkId, targetIndex)) {
return;
}
// Re-parenting relocates the answer in the QuestionnaireResponse. On a draft nobody has answered
// yet, so just do it; on anything else, make the author confirm.
if (getValues('status') === 'draft') {
commitMoveBetween(activeLinkId, targetParentLinkId, targetIndex);
return;
}
// Named by linkId off the same pre-mutation tree -- null at the top level, where there is no
// owning group, which the confirm copy renders as "out to the top level" rather than "into...".
const targetGroup = targetParentLinkId ? findItemInTree(targetParentLinkId, tree) : null;
setPendingMove({
sourceLinkId: activeLinkId,
targetParentLinkId,
targetIndex,
itemText: source[from]?.text || 'this question',
targetGroupText: targetParentLinkId ? (targetGroup?.text || 'this group') : null
});
};
// Group a sibling list into rows and wrap each field in a grid column. The original index is
// carried alongside, because renderItem's `index` must stay the item's index in its OWN sibling
// list -- not its position within a row.
// `parentLinkId` is the linkId of the group owning this sibling list, null at the top level. Only
// meaningful alongside listPath (i.e. when dnd is wired at all); it rides onto each column's sortable
// data so a drop can name its destination parent without parsing paths.
const renderRows = (
siblings: ExtendedQuestionnaireItem[],
getOriginalItem: (item: ExtendedQuestionnaireItem) => ExtendedQuestionnaireItem,
keyPrefix: string,
listPath?: string,
parentLinkId: string | null = null
) => {
const visible = siblings
.map((item, index) => ({ item, original: getOriginalItem(item), index }))
.filter(({ item, original }) => isRendered(item, original));
// Sortable ids must be unique per RENDERED INSTANCE, not per linkId. A repeating group
// (maxOccurs > 1) transforms the same template children under the same base path for every
// repetition, so every repetition's items carry identical linkIds -- a bare linkId would
// register more than once inside one DndContext, and dnd-kit keys draggableNodes/
// droppableContainers by id in a Map, so the instances would clobber each other. keyPrefix
// already carries the answerGroupIndex for exactly this reason (see renderGroup's call),
// so namespace the sortable id with it; the real linkId still travels separately via
// SortableColumn's sortable `data`, which is what handleDragEnd reads.
const sortableId = (item: ExtendedQuestionnaireItem) => `${keyPrefix}::${item.linkId}`;
const rows = packIntoRows(visible, ({ original }) => original.columnSpan).map((row, rowIndex) => (