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 (
{renderQuestionText(item, original, index)}
); }; const renderGroup = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, index: number) => { return (
{item.item?.length === 0 && ( <> {renderQuestionText(item, original)} {layoutEditable && } )} {selectedItem?.linkId !== item.linkId && (
)} {(item.answer as unknown as ExtendedQuestionnaireItem[][]).map((answerItem, answerGroupIndex) => ( {item.item.length !== 0 && ( <> {renderQuestionText(item, original, index, answerGroupIndex)} )} {Array.isArray(answerItem) && (
{renderRows(answerItem, (answer) => getOriginal(answer), `${item.linkId}-${answerGroupIndex}`, `${item.path}.item`, item.linkId)}
)}
))}
); }; 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`} )} ); }}/> ); }; const renderDateTime = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, answer: ExtendedQuestionnaireItemAnswerOption, answerIndex: number) => { const inputType = original.type === 'dateTime' ? 'datetime-local' : original.type; return ( <> {renderQuestionText(item, original, answerIndex)} { if (answer.value) { field.value = answer.value; } return ( <> {fieldState.error && ( {fieldState.error.message || `${original.text} is required`} )} ); }}/> ); }; const renderChoice = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, answer: ExtendedQuestionnaireItemAnswerOption, answerIndex: number) => { const isRadio = original.itemControl.code === 'radio-button'; // const isCheckbox = original.itemControl.code === 'check-box'; const isCheckbox = false; const isDropdown = original.itemControl.code === 'drop-down'; const repeats = original.repeats; const answerOption: ExtendedQuestionnaireItemAnswerOption[] = original.answerOption ?? []; const selectedOption = answerOption.find((option: ExtendedQuestionnaireItemAnswerOption) => option.initialSelected); const customSelectValue: CustomSelectOption | undefined = selectedOption ? { key: selectedOption.value.code, name: selectedOption.value.display } : undefined; return ( <> {renderQuestionText(item, original, answerIndex)} { if (isRadio || (!repeats && !isDropdown)) { const initialSelected = answer.value; if (initialSelected) { field.value = initialSelected; } return ( <> ( { value: option.value.code, label: option.value.display } ))} selected={field.value.code} onChange={(newValue) => { const selectedAnswer = answerOption.find((option: ExtendedQuestionnaireItemAnswerOption) => option.value.code === newValue); field.onChange(selectedAnswer?.value); }}/> {fieldState.error && ( <> {fieldState.error.message || `${original.text} is required`} )} ); } else if (isCheckbox || (repeats && !isDropdown)) { return ( <> {answerOption.map((option: ExtendedQuestionnaireItemAnswerOption) => (
option.initialSelected) .map((option: ExtendedQuestionnaireItemAnswerOption) => option.value.code) .includes(option.value?.code) } onChange={(newValue) => { // const selectedAnswer = answerOption.find((o: ExtendedQuestionnaireItemAnswerOption) => o.value.code === option.value?.code); // field.onChange(selectedAnswer?.value); field.onChange(newValue); }}/>
))} {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) => (
{row.map(({ item, original, index }) => ( layoutEditable && listPath ? ( {renderItem(item, original, index)} setValue(`${item.path}.columnSpan`, span, { shouldDirty: true })} /> ) : (
{renderItem(item, original, index)}
) ))}
)); if (!layoutEditable || !listPath) { return rows; } return ( sortableId(item))} strategy={rectSortingStrategy}> {rows} ); }; const renderItem = (item: ExtendedQuestionnaireItem, original: ExtendedQuestionnaireItem, index: number) => { if (original.hidden || !evaluateEnableWhen(item)) { return null; } if (original.type === 'group') { return renderGroup(item, original, index); } else if (original.type === 'display') { return renderDisplay(item, original, index); } return (
{item.answer.map((answer: ExtendedQuestionnaireItemAnswerOption, answerIndex: number) => { if (['string', 'integer', 'decimal', 'quantity', 'url'].includes(original.type)) { return renderTextInput(item, original, answer, answerIndex); } else if (original.type === 'boolean') { return renderBoolean(item, original, answer, answerIndex); } else if (original.type === 'text') { return renderTextArea(item, original, answer, answerIndex); } else if (['date', 'dateTime', 'time'].includes(original.type)) { return renderDateTime(item, original, answer, answerIndex); } else if (['choice', 'open-choice'].includes(original.type)) { return renderChoice(item, original, answer, answerIndex); } else if (original.type === 'attachment') { // - without this the chain ends here and an author who picked a // file question saw an empty row. See `renderAttachment` for which of the // three surfaces gets a working control and which does not. return renderAttachment(item, original, answer, answerIndex); } })}
); }; return allItemFields && ( <> {!hideTitle && ( {watch('title') || 'Untitled'} {layoutEditable && watch('description') && ( {watch('description')} )} {showVersion && watch('meta.versionId') && ( Version {watch('meta.versionId')} )} )} {layoutEditable ? ( {renderRows(allItemFields ?? [], (item) => item, 'root', 'item', null)} ) : ( renderRows(allItemFields ?? [], (item) => item, 'root') )} {elevated && } {pendingMove && ( !open && setPendingMove(null)}> {/* A move is either INTO a named group or OUT to the top level. Always saying "into ..." misdescribes the second direction -- and this is a data-loss warning, so the author has to be able to tell which move they are confirming. */} {pendingMove.targetGroupText ? <>Move “{pendingMove.itemText}” into “{pendingMove.targetGroupText}”? : <>Move “{pendingMove.itemText}” out to the top level?}

If patients have already answered this form, their answers to this question will stop appearing. The data is not deleted — it is recorded at the old position and will no longer show.

{ commitMoveBetween(pendingMove.sourceLinkId, pendingMove.targetParentLinkId, pendingMove.targetIndex); setPendingMove(null); }}> Move anyway
)} ); }; export default QuestionnairePreview;