From 8da11a8dd340c2536d6683aea6197dc5e7f432ce Mon Sep 17 00:00:00 2001 From: Bodhish Thomas Date: Mon, 3 Aug 2026 17:07:44 +0530 Subject: [PATCH 1/2] feat(questionnaire-v2): fill outline as an overlay rail + panel, per the reference design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outline no longer reserves a fixed column. A slim tick rail hugs the canvas' left edge (one hairline per visible top-level question, the one in view longer and indigo, distributed minimap-style so long sessions compress instead of clipping); the 320px panel floats OVER the full-width canvas on hover, focus or click. Rows are numbered with live completion adornments, the active row tracks a scroll-spy over the data-question-id anchors, and a clicked row pins active until the user scrolls. Each form of a multi-questionnaire session portals its rows and ticks into the shared overlay, keeping the per-form store architecture. Interaction model: hover governs mice (a mouse click on the open rail is deliberately a no-op), tap toggles for touch (judged by the state at pointerdown, so the tap's own focus-open can't flash it shut), Escape and focus-out close for keyboards with focus restored to the rail. Dismissal rides NATIVE listeners on the shell — the portaled rows never propagate React synthetic events through the overlay's tree, and some embedded browsers don't synthesize React's enter/leave pair at all. Ride-along fill fixes from the same review pass: - Draft safety: resume retains added-form snapshots through the re-fetch window, merges into a form the clinician already re-added by hand, the picker seeds from a retained snapshot instead of displacing it, Discard drops only the STORED draft (typed work stays and persists), a resumed record must still be status "draft", and a background refetch of the draft query no longer unmounts the live session. - Context fetch failures show an error page instead of a headerless clinical form; the loading skeleton uses the real fullscreen shell. - A11y: aria-required on every engine input (the asterisk was visual-only), date/dateTime pickers get named groups, keyboard row activation moves focus to the question, tab strip and action band no longer clip at phone widths, Save Changes gains a pending spinner and primary-token styling. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 190e20e46e89 --- public/locale/en.json | 2 + src/components/QuestionnaireV2/README.md | 11 +- .../QuestionnaireV2/fill/FillFormSection.tsx | 14 +- .../QuestionnaireV2/fill/FillHeader.tsx | 10 +- .../QuestionnaireV2/fill/FillOutline.tsx | 150 ++++++-- .../fill/FillOutlineOverlay.tsx | 331 +++++++++++++++++ .../QuestionnaireV2/fill/FillOutlineRail.tsx | 61 +++ .../fill/QuestionnaireFillPage.tsx | 350 +++++++++++++----- .../fill/draft/useFillAutosave.ts | 54 ++- .../QuestionnaireV2/form/QuestionBlock.tsx | 8 +- .../form/engine/inputs/BooleanInput.tsx | 1 + .../form/engine/inputs/ChoiceInput.tsx | 3 + .../form/engine/inputs/DateInput.tsx | 23 +- .../engine/inputs/DateTimeQuestionInput.tsx | 14 +- .../form/engine/inputs/NumberInput.tsx | 1 + .../form/engine/inputs/QuantityInput.tsx | 2 + .../form/engine/inputs/TextInput.tsx | 1 + .../form/engine/inputs/TimeInput.tsx | 5 +- 18 files changed, 861 insertions(+), 180 deletions(-) create mode 100644 src/components/QuestionnaireV2/fill/FillOutlineOverlay.tsx create mode 100644 src/components/QuestionnaireV2/fill/FillOutlineRail.tsx diff --git a/public/locale/en.json b/public/locale/en.json index 6cbe69658f7..15b26365b10 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -2676,6 +2676,7 @@ "filed_by": "filed by", "files": "Files", "fill_color": "Fill Color", + "fill_context_load_failed": "Couldn't load the patient context for this form. Go back and try again.", "fill_draft_form_dropped": "Couldn't restore \"{{title}}\" — the questionnaire changed since the draft was saved.", "fill_draft_form_unavailable": "Couldn't load \"{{title}}\" just now — it's still in your saved draft.", "fill_draft_includes_added_forms_one": "Includes {{count}} added questionnaire.", @@ -4802,6 +4803,7 @@ "questionnaire_updated_successfully": "Questionnaire updated successfully", "questions": "Questions", "questions_count": "Question count", + "questions_outline": "Questions outline", "queue": "Queue", "queue_board": "Queue board", "queue_created_successfully": "Queue created successfully", diff --git a/src/components/QuestionnaireV2/README.md b/src/components/QuestionnaireV2/README.md index 0b8d0398ec7..2525e4e1a54 100644 --- a/src/components/QuestionnaireV2/README.md +++ b/src/components/QuestionnaireV2/README.md @@ -42,7 +42,13 @@ questions, that is the bug. Nothing here renders layout; `form/` and `fill/` are its consumers. - `fill/` — the fill experience mounted on the encounter/patient/resource questionnaire routes (fullscreen shell, two tabs: form canvas + embedded - clinical history). What it is filling FOR is `subject.ts`'s `FillSubject` + clinical history). The outline is an OVERLAY, not a column + (`FillOutlineOverlay`): a slim tick rail on the canvas' left edge opens + the panel over the full-width canvas on hover/focus/click; scroll-spy + (`useFillOutlineNav`) tracks the block topping the viewport. Each form + portals its rows (`FillOutline`) and ticks (`FillOutlineRail`) into the + overlay's hosts — they must render inside that form's provider. What it + is filling FOR is `subject.ts`'s `FillSubject` union (encounter/patient/location/device…); `rendererSubjectOf` flattens it into the engine's `RendererSubject` and `subjectKeyOf` scopes drafts. A session may hold SEVERAL questionnaires: the route-mounted one plus any @@ -227,6 +233,7 @@ save it. Playwright — authoring: `tests/facility/settings/questionnaires/` and `tests/admin/questionnaires/`. Fill: `tests/facility/patient/encounter/fill/` (page, validation, autosave, -multi-form), `tests/facility/patient/encounter/structuredQuestions/`, and +multi-form, server drafts, outline overlay), +`tests/facility/patient/encounter/structuredQuestions/`, and `tests/facility/{location,device}Questionnaire.spec.ts` for the resource-subject mounts. Shared helpers: `tests/helper/questionnaireV2.ts`. diff --git a/src/components/QuestionnaireV2/fill/FillFormSection.tsx b/src/components/QuestionnaireV2/fill/FillFormSection.tsx index b9c58c82363..ee646b3f77f 100644 --- a/src/components/QuestionnaireV2/fill/FillFormSection.tsx +++ b/src/components/QuestionnaireV2/fill/FillFormSection.tsx @@ -12,20 +12,22 @@ import type { RendererSubject } from "@/components/QuestionnaireV2/form/types"; import { FillCanvas } from "./FillCanvas"; import { FillOutline } from "./FillOutline"; +import { FillOutlineRail } from "./FillOutlineRail"; import type { FormStore } from "./StoreRegistrar"; import { StoreRegistrar } from "./StoreRegistrar"; import type { FillFormEntry } from "./formSession"; /** * One questionnaire of the session: its own provider (one store), its - * canvas block in the shared scroll, and its outline section PORTALED - * into the shared aside — the outline must live inside this provider to - * read this form's store. + * canvas block in the shared scroll, and its outline pieces PORTALED + * into the overlay's shared hosts (panel rows + rail ticks) — they must + * live inside this provider to read this form's store. */ export function FillFormSection({ form, subject, outlineHost, + railHost, outlineLabel, onStore, onRemove, @@ -33,6 +35,7 @@ export function FillFormSection({ form: FillFormEntry; subject: RendererSubject; outlineHost: HTMLElement | null; + railHost: HTMLElement | null; /** Accessible name for this form's outline landmark. The host passes * the questionnaire title once a session holds more than one form, so * the stacked navs stay distinguishable. */ @@ -51,14 +54,15 @@ export function FillFormSection({ {outlineHost && createPortal( -
-

+

+

{form.questionnaire.title}

, outlineHost, )} + {railHost && createPortal(, railHost)} {/* The divider keys off `isPrimary`, not `:first-child` — the restore bar and the error panel share this scroll, so DOM position is not a reliable "first form" signal. */} diff --git a/src/components/QuestionnaireV2/fill/FillHeader.tsx b/src/components/QuestionnaireV2/fill/FillHeader.tsx index f4e1f52bec6..01391c273b1 100644 --- a/src/components/QuestionnaireV2/fill/FillHeader.tsx +++ b/src/components/QuestionnaireV2/fill/FillHeader.tsx @@ -157,7 +157,7 @@ export function FillHeader({
)} -
+
diff --git a/src/components/QuestionnaireV2/fill/FillOutline.tsx b/src/components/QuestionnaireV2/fill/FillOutline.tsx index 6c692a62c0a..2f276f1e7e0 100644 --- a/src/components/QuestionnaireV2/fill/FillOutline.tsx +++ b/src/components/QuestionnaireV2/fill/FillOutline.tsx @@ -1,22 +1,35 @@ import { CheckCheck, Dot } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + import { useAnsweredQuestionIds, useFormRenderer, useHiddenQuestionIds, } from "@/components/QuestionnaireV2/form/FormContext"; -import { QuestionTreeNav } from "@/components/QuestionnaireV2/shared/QuestionTreeNav"; +import type { TreeItem } from "@/components/QuestionnaireV2/shared/questionTree"; +import { + findFirstQuestion, + findTopLevelIndex, + numberQuestions, +} from "@/components/QuestionnaireV2/shared/questionTree"; + +import type { Question } from "@/types/questionnaire/question"; + +import { useFillOutlineNav } from "./FillOutlineOverlay"; /** - * The fill page's left outline (≥lg only): the shared tree nav with live - * completion adornments — answered questions get the double-check, open - * ones a dot — and enable_when-hidden rows dropped, exactly like the - * canvas. Selecting a row scrolls its block into view via the renderer's - * `data-question-id` anchors. + * One form's rows inside the outline overlay panel, per the reference: + * numbered rows with live completion adornments — answered questions get + * the double-check, open ones a dot — the question currently in view in + * indigo with a right-edge bar (scroll-spy via `useFillOutlineNav`), and + * enable_when-hidden rows dropped, exactly like the canvas. Selecting a + * row scrolls its block into view via the renderer's `data-question-id` + * anchors. Group children indent behind a connector line. * * `ariaLabel` names the nav landmark: a multi-questionnaire session - * renders one outline per form into the same aside, and repeating the + * renders one outline per form into the same panel, and repeating the * generic name would leave a screen reader with several * indistinguishable "Questions" landmarks — the host passes each form's * title there instead. @@ -26,28 +39,111 @@ export function FillOutline({ ariaLabel }: { ariaLabel?: string }) { const { questionnaire } = useFormRenderer(); const hiddenIds = useHiddenQuestionIds(); const answeredIds = useAnsweredQuestionIds(); + const { activeQuestionId, scrollToQuestion } = useFillOutlineNav(); - return ( - { - document - .querySelector(`[data-question-id="${questionId}"]`) - ?.scrollIntoView({ behavior: "smooth", block: "start" }); - }} - rowAdornment={(question) => { - if (question.type === "group" || question.type === "display") { - return null; + const items = numberQuestions(questionnaire.questions).filter( + (item) => !hiddenIds.has(item.question.id), + ); + + // The outline shows two levels; the scroll-spy reports any depth. An + // active id with its own row highlights that row, a deeper descendant + // highlights its top-level ancestor, another form's id highlights + // nothing here. + const hasRow = (questionId: string) => + items.some( + (item) => + item.question.id === questionId || + item.children.some((child) => child.question.id === questionId), + ); + const activeRowId = + activeQuestionId === null + ? null + : hasRow(activeQuestionId) + ? activeQuestionId + : findFirstQuestion( + questionnaire.questions, + (question) => question.id === activeQuestionId, + ) + ? questionnaire.questions[ + findTopLevelIndex(questionnaire.questions, activeQuestionId) + ]?.id + : null; + + const stateIcon = (question: Question) => { + if (question.type === "group" || question.type === "display") return null; + return answeredIds.has(question.id) ? ( + + ) : ( + + ); + }; + + const row = (item: TreeItem, indent: boolean) => { + const active = activeRowId === item.question.id; + return ( + + ); + }; + + return ( + ); } diff --git a/src/components/QuestionnaireV2/fill/FillOutlineOverlay.tsx b/src/components/QuestionnaireV2/fill/FillOutlineOverlay.tsx new file mode 100644 index 00000000000..1364a4ab469 --- /dev/null +++ b/src/components/QuestionnaireV2/fill/FillOutlineOverlay.tsx @@ -0,0 +1,331 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +/** + * The fill page's outline navigation chrome (≥lg): a slim always-visible + * rail of per-question tick marks hugging the canvas' left edge, and the + * full outline panel that floats OVER the canvas on hover/focus/click — + * per the reference, the outline no longer reserves a fixed column, so + * the form gets the whole width. + * + * This module owns the overlay shell and the shared nav state (active + * question + scroll command). The rows and ticks themselves are portaled + * in per form by `FillFormSection` — they must render inside each form's + * provider to read that form's store — so this component only provides + * the host elements. + */ + +interface FillOutlineNavValue { + /** The question whose block currently tops the canvas viewport (any + * depth — consumers map it to the row/tick they actually render). */ + activeQuestionId: string | null; + /** `focus` moves keyboard focus to the question's input (or its block) + * as well — keyboard activation of an outline row must land the user + * AT the question, not leave them parked inside the overlay. */ + scrollToQuestion: (questionId: string, options?: { focus?: boolean }) => void; +} + +const FillOutlineNavContext = createContext({ + activeQuestionId: null, + scrollToQuestion: () => {}, +}); + +export function useFillOutlineNav(): FillOutlineNavValue { + return useContext(FillOutlineNavContext); +} + +/** + * Scroll-spy over the canvas' `[data-question-id]` anchors: the active + * question is the last block whose top sits above the tracking line + * (96px into the scroll viewport), so it flips exactly when a block + * scrolls under the reader's eye. Recomputes on scroll/resize and on DOM + * mutations (enable_when showing/hiding blocks, forms added or removed). + */ +function useActiveQuestionId(container: HTMLElement | null): string | null { + const [activeId, setActiveId] = useState(null); + + useEffect(() => { + if (!container) return; + let frame = 0; + const compute = () => { + frame = 0; + const blocks = + container.querySelectorAll("[data-question-id]"); + if (blocks.length === 0) { + setActiveId(null); + return; + } + const viewportTop = container.getBoundingClientRect().top; + // Document order puts a group before its children, so "last block + // above the line" naturally descends into sub-questions as they + // pass it. + let current: HTMLElement | undefined; + for (const block of blocks) { + if (block.getBoundingClientRect().top - viewportTop <= 96) { + current = block; + } + } + setActiveId((current ?? blocks[0]).dataset.questionId ?? null); + }; + const schedule = () => { + if (!frame) frame = requestAnimationFrame(compute); + }; + + compute(); + container.addEventListener("scroll", schedule, { passive: true }); + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(container); + const mutationObserver = new MutationObserver(schedule); + mutationObserver.observe(container, { childList: true, subtree: true }); + return () => { + container.removeEventListener("scroll", schedule); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + if (frame) cancelAnimationFrame(frame); + }; + }, [container]); + + return activeId; +} + +export function FillOutlineNavProvider({ + scrollContainer, + children, +}: { + /** The canvas' scrolling element — anchors are queried inside it. */ + scrollContainer: HTMLElement | null; + children: React.ReactNode; +}) { + const spyActiveId = useActiveQuestionId(scrollContainer); + // A row click PINS its question as active: near the scroll floor the + // chosen block can never top the viewport, so pure scroll-spy would + // highlight an earlier question than the one the clinician just picked. + // The pin yields the moment they scroll themselves. + const [pinnedId, setPinnedId] = useState(null); + + useEffect(() => { + if (!scrollContainer || pinnedId === null) return; + const release = () => setPinnedId(null); + scrollContainer.addEventListener("wheel", release, { passive: true }); + scrollContainer.addEventListener("touchstart", release, { passive: true }); + return () => { + scrollContainer.removeEventListener("wheel", release); + scrollContainer.removeEventListener("touchstart", release); + }; + }, [scrollContainer, pinnedId]); + + const scrollToQuestion = useCallback( + (questionId: string, options?: { focus?: boolean }) => { + setPinnedId(questionId); + const root: ParentNode = scrollContainer ?? document; + const block = root.querySelector( + `[data-question-id="${CSS.escape(questionId)}"]`, + ); + if (!block) return; + block.scrollIntoView({ + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth", + block: "start", + }); + if (!options?.focus) return; + // Same landing rule as the submit path's scroll-to-error: the + // question's own input when it has one, the block itself otherwise. + const input = document.getElementById(`question-input-${questionId}`); + if (input) { + input.focus({ preventScroll: true }); + return; + } + block.setAttribute("tabindex", "-1"); + block.focus({ preventScroll: true }); + }, + [scrollContainer], + ); + + const activeQuestionId = pinnedId ?? spyActiveId; + const value = useMemo( + () => ({ activeQuestionId, scrollToQuestion }), + [activeQuestionId, scrollToQuestion], + ); + return ( + + {children} + + ); +} + +const PANEL_ID = "fill-outline-panel"; + +/** + * The overlay shell. Interaction model: hovering or focusing the rail + * opens the panel; leaving both (or Escape, or focus moving elsewhere) + * closes it; the rail is also a plain toggle button for touch and + * keyboard. The rail stays on top of the open panel — its ticks double + * as the panel's minimap, exactly as in the reference. + */ +export function FillOutlineOverlay({ + onPanelHost, + onRailHost, +}: { + onPanelHost: (element: HTMLElement | null) => void; + onRailHost: (element: HTMLElement | null) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const closeTimer = useRef>(undefined); + const rootRef = useRef(null); + const toggleRef = useRef(null); + // What produced the CURRENT click: pointerdown always precedes a + // pointer click and carries the type; a keyboard "click" (Enter/Space) + // has no pointerdown, so the empty string means keyboard. + const clickPointerTypeRef = useRef(""); + // Whether the panel was open when the CURRENT gesture began. A tap on + // an unfocused button fires pointerdown → focus (which opens the + // panel) → click; toggling on the click's view of `open` would flash + // the panel open and shut in that one gesture. + const openAtPointerDownRef = useRef(false); + + const openPanel = useCallback(() => { + clearTimeout(closeTimer.current); + setOpen(true); + }, []); + // Grace delay so the pointer can cross from rail to panel (and between + // panel rows) without the overlay snapping shut. + const scheduleClose = useCallback(() => { + clearTimeout(closeTimer.current); + closeTimer.current = setTimeout(() => setOpen(false), 200); + }, []); + useEffect(() => () => clearTimeout(closeTimer.current), []); + + // ALL shell-level dismissal handling rides NATIVE listeners on the root + // element, never React props. Two reasons: (1) the outline rows and + // ticks are PORTALED in by each form, and React synthetic events + // propagate through the tree where a portal is DECLARED — the canvas + // section — so a root onKeyDown/onBlurCapture would never see an Escape + // pressed on a panel row; native events follow the DOM tree, which the + // portal content IS inside. (2) React's synthesized pointerenter/leave + // pair doesn't fire at all in some embedded browsers that deliver mouse + // input without the full pointerover/out stream. + // Hover is mouse-only: on touch, the tap's simulated enter would open + // the panel an instant before click toggles it shut again. + useEffect(() => { + const root = rootRef.current; + if (!root) return; + const enter = (event: PointerEvent) => { + if (event.pointerType === "mouse") openPanel(); + }; + const leave = (event: PointerEvent) => { + if (event.pointerType === "mouse") scheduleClose(); + }; + const keydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + setOpen(false); + toggleRef.current?.focus(); + } + }; + // Focus leaving the overlay entirely closes it (focusout bubbles; + // blur does not). + const focusout = (event: FocusEvent) => { + if (!root.contains(event.relatedTarget as Node | null)) { + setOpen(false); + } + }; + root.addEventListener("pointerenter", enter); + root.addEventListener("pointerleave", leave); + root.addEventListener("keydown", keydown); + root.addEventListener("focusout", focusout); + return () => { + root.removeEventListener("pointerenter", enter); + root.removeEventListener("pointerleave", leave); + root.removeEventListener("keydown", keydown); + root.removeEventListener("focusout", focusout); + }; + }, [openPanel, scheduleClose]); + + return ( +
+ {/* The button comes FIRST in DOM so forward Tab from the rail + enters the open panel's rows; z-10 keeps the ticks painted on + top of the panel, per the reference. */} + + +
+ ); +} diff --git a/src/components/QuestionnaireV2/fill/FillOutlineRail.tsx b/src/components/QuestionnaireV2/fill/FillOutlineRail.tsx new file mode 100644 index 00000000000..8b259d1b65d --- /dev/null +++ b/src/components/QuestionnaireV2/fill/FillOutlineRail.tsx @@ -0,0 +1,61 @@ +import { cn } from "@/lib/utils"; + +import { + useFormRenderer, + useHiddenQuestionIds, +} from "@/components/QuestionnaireV2/form/FormContext"; +import { + findFirstQuestion, + findTopLevelIndex, +} from "@/components/QuestionnaireV2/shared/questionTree"; + +import { useFillOutlineNav } from "./FillOutlineOverlay"; + +/** + * One form's tick marks on the outline rail — the collapsed minimap per + * the reference: one hairline per visible top-level question, the one + * containing the question currently in view drawn longer and indigo. + * Purely decorative (the rail button carries the accessible name); lives + * inside this form's provider to read enable_when visibility, portaled + * into the shared rail by `FillFormSection`. + */ +export function FillOutlineRail() { + const { questionnaire } = useFormRenderer(); + const hiddenIds = useHiddenQuestionIds(); + const { activeQuestionId } = useFillOutlineNav(); + + const activeTopId = + activeQuestionId !== null && + findFirstQuestion( + questionnaire.questions, + (question) => question.id === activeQuestionId, + ) + ? questionnaire.questions[ + findTopLevelIndex(questionnaire.questions, activeQuestionId) + ]?.id + : null; + + return ( + // justify-evenly over the segment's share of the rail height: tick + // spacing scales with the questionnaire instead of overflowing the + // fixed-height rail (the host gives each form's segment flex-1). + + {questionnaire.questions + .filter((question) => !hiddenIds.has(question.id)) + .map((question) => ( + // data-question-tick mirrors the canvas' data-question-id: the + // stable hook for tests (the ticks are aria-hidden decoration, + // so no role reaches them). + + ))} + + ); +} diff --git a/src/components/QuestionnaireV2/fill/QuestionnaireFillPage.tsx b/src/components/QuestionnaireV2/fill/QuestionnaireFillPage.tsx index 5d1928af7dc..10fd33c00f2 100644 --- a/src/components/QuestionnaireV2/fill/QuestionnaireFillPage.tsx +++ b/src/components/QuestionnaireV2/fill/QuestionnaireFillPage.tsx @@ -14,6 +14,7 @@ import { FormSkeleton } from "@/components/Common/SkeletonLoading"; import { QuestionnaireSearch } from "@/components/Questionnaire/QuestionnaireSearch"; import { FIXED_QUESTIONNAIRES } from "@/components/Questionnaire/data/StructuredFormData"; +import { responsesAtom } from "@/components/QuestionnaireV2/form/engine/store"; import { questionnaireKeys } from "@/components/QuestionnaireV2/queryKeys"; import useAuthUser from "@/hooks/useAuthUser"; @@ -36,6 +37,10 @@ import { ClinicalHistoryTab } from "./ClinicalHistoryTab"; import { DraftRestoreBar } from "./DraftRestoreBar"; import { FillFormSection } from "./FillFormSection"; import { FillHeader } from "./FillHeader"; +import { + FillOutlineNavProvider, + FillOutlineOverlay, +} from "./FillOutlineOverlay"; import { ServerErrorsPanel } from "./ServerErrorsPanel"; import type { FormStore } from "./StoreRegistrar"; import { sweepExpiredFillDrafts } from "./draft/fillDraftCache"; @@ -47,6 +52,7 @@ import type { import { loadFillDraft, mergeDraftIntoSeed, + preserveExcludedStructured, reviveDraftResponses, } from "./draft/fillDraftStore"; import { useFillSessionAutosave } from "./draft/useFillAutosave"; @@ -122,7 +128,11 @@ export default function QuestionnaireFillPage({ const encounterId = subject.type === "encounter" ? subject.encounterId : undefined; - const { data: encounter, isLoading: isEncounterLoading } = useQuery({ + const { + data: encounter, + isLoading: isEncounterLoading, + isError: isEncounterError, + } = useQuery({ queryKey: ["encounter", encounterId], queryFn: query(encounterApi.get, { pathParams: { id: encounterId ?? "" }, @@ -135,7 +145,7 @@ export default function QuestionnaireFillPage({ // Patient-subject fills have no encounter to borrow the patient from; // resource subjects have no patient at all. - const { data: fetchedPatient } = useQuery({ + const { data: fetchedPatient, isError: isPatientError } = useQuery({ queryKey: ["patient", patientBound?.patientId], queryFn: query(patientApi.get, { pathParams: { id: patientBound?.patientId ?? "" }, @@ -145,7 +155,11 @@ export default function QuestionnaireFillPage({ const { data: serverDraft, - isFetching: isServerDraftLoading, + // isLoading, NOT isFetching: a background refetch (window focus, cache + // invalidation) flips isFetching while data is still present, and the + // skeleton branch below would unmount the whole session — every form + // store and every answer typed since resume — mid-edit. + isLoading: isServerDraftLoading, isError: isServerDraftError, } = useQuery({ queryKey: ["formSubmission", continueDraftId], @@ -163,6 +177,13 @@ export default function QuestionnaireFillPage({ // guard. const serverDraftState = useMemo(() => { if (!continueDraftId || !serverDraft || !questionnaire) return undefined; + // Only an open draft resumes. A record already submitted (or marked + // entered-in-error) re-opening as editable would let one submission + // file twice — the overview's drafts card filters these out, but the + // URL is shareable and outlives that filter. + if (serverDraft.status !== "draft") { + return { mismatch: true as const }; + } const dump = serverDraft.response_dump as | { questionnaireResponses?: { @@ -250,7 +271,16 @@ export default function QuestionnaireFillPage({ (encounterId && isEncounterLoading) || (continueDraftId && isServerDraftLoading) ) { - return ; + // Same fullscreen shell the loaded page uses, so the layout doesn't + // jump shells when data lands — and the close affordance exists even + // while loading. + return ( + navigate(exitTarget)}> +
+ +
+
+ ); } if (isQuestionnaireError || !questionnaire) { @@ -259,6 +289,20 @@ export default function QuestionnaireFillPage({ ); } + // The clinical context could not be LOADED (the app's query default is + // retry:false, so one blip lands here). Mounting the form anyway would + // show a headerless page with no patient identity, blood group or + // allergy badges — safety-relevant context — while the clinician types + // clinical data into it. + if ((encounterId && isEncounterError) || isPatientError) { + return ( + + ); + } + // The draft record could not be READ (404, permissions, a network // hiccup — the app's query default is retry:false). Mounting the form // anyway would show a blank questionnaire with no explanation, invite a @@ -359,12 +403,16 @@ function FillShell({ const { t } = useTranslation(); return (
+ {/* min-w-0 + overflow on the strip: a long questionnaire title (or + the two tabs) scrolls within its own row on narrow screens + instead of pushing the close button off-viewport. */}
- {tabs ??
} +
{tabs ??
}
+ +
+ +
+ {autosave.restoredDraft && ( + + )} + + {forms.map((form) => ( + 1 ? form.questionnaire.title : undefined } + onStore={handleStore} + onRemove={forms.length > 1 ? removeForm : undefined} /> -
- )} - {/* Renders nothing unless a plugin provides Scribe. */} - - -
+ ))} + {/* A resumed SERVER draft is one questionnaire's + submission by construction — no adding to it. */} + {!continueDraftId && ( +
+ + + {t("add_questionnaire")} + + } + /> +
+ )} + {/* Renders nothing unless a plugin provides Scribe. */} + + +
+
diff --git a/src/components/QuestionnaireV2/fill/draft/useFillAutosave.ts b/src/components/QuestionnaireV2/fill/draft/useFillAutosave.ts index d550ca74ba4..50c42e8d331 100644 --- a/src/components/QuestionnaireV2/fill/draft/useFillAutosave.ts +++ b/src/components/QuestionnaireV2/fill/draft/useFillAutosave.ts @@ -1,10 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; -import { - initializeResponses, - responsesAtom, -} from "@/components/QuestionnaireV2/form/engine/store"; +import { responsesAtom } from "@/components/QuestionnaireV2/form/engine/store"; import type { FillFormEntry } from "@/components/QuestionnaireV2/fill/formSession"; import type { FormStore } from "@/components/QuestionnaireV2/fill/StoreRegistrar"; @@ -59,10 +56,10 @@ interface UseFillSessionAutosaveArgs { * flushes on pagehide/unmount so quick closes keep the last keystrokes, * and exposes the state the chrome renders (Draft chip, restore bar). * - * `discardRestoredDraft` clears the stored draft AND resets the forms that - * draft covered back to a pristine seed — the restore bar's one - * destructive affordance, deliberately scoped so it cannot reach a form - * the clinician added after the prompt appeared. + * `discardRestoredDraft` clears the stored draft and nothing else — what + * is on screen is this session's own work (a draft only reaches the + * stores through Resume), so the affordance destroys exactly the bytes + * it names. */ export function useFillSessionAutosave({ scope, @@ -234,32 +231,23 @@ export function useFillSessionAutosave({ const discardRestoredDraft = useCallback(() => { const current = scopeRef.current; if (current) clearFillDraft(current); - // Only the forms the DISCARDED DRAFT covered go back to a pristine - // seed. "Add questionnaire" is not gated on the prompt, so a - // clinician can add a form and type real answers into it while the - // stale-draft banner is still up — resetting that form too would - // silently destroy work they just did, on a button that promises only - // to drop an old draft. - const covered = new Set( - restoredDraftRef.current?.forms.map( - (snapshot) => snapshot.questionnaireId, - ) ?? [], - ); - for (const form of forms) { - if (!covered.has(form.key)) continue; - const store = getStore(form.key); - if (!store) continue; - store.set( - responsesAtom, - preserveExcludedStructured( - store.get(responsesAtom), - initializeResponses(form.questionnaire.questions), - ), - ); - } - setDirty(false); + // Discard drops the STORED draft — never what is on screen. The draft + // only ever reaches the stores through Resume, so while the bar shows + // they hold nothing but this session's own work: prefetched clinical + // rows and whatever the clinician typed while ignoring the prompt. + // Resetting them (as this once did) destroyed exactly the + // un-persisted work the prompt gate was protecting. + // + // Persistence was standing down while the prompt was pending — flip + // the gate synchronously (the ref recomputes only on the next render) + // and write now, so anything typed in the meantime becomes a fresh + // draft of its own instead of living un-persisted until the next + // keystroke. An untouched session hits saveFillDraft's clear-on-empty + // branch, which is a no-op on the already-cleared key. + restorePendingRef.current = false; setRestoreDismissed(true); - }, [forms, getStore]); + persistNow(); + }, [persistNow]); const dismissRestoreBar = useCallback(() => setRestoreDismissed(true), []); diff --git a/src/components/QuestionnaireV2/form/QuestionBlock.tsx b/src/components/QuestionnaireV2/form/QuestionBlock.tsx index 026d38bd9e3..94859750ace 100644 --- a/src/components/QuestionnaireV2/form/QuestionBlock.tsx +++ b/src/components/QuestionnaireV2/form/QuestionBlock.tsx @@ -171,7 +171,13 @@ function LeafBlock({ > {question.text} - {question.required && *} + {/* Visual-only: the programmatic required state is aria-required + on the input itself (every engine input sets it). */} + {question.required && ( + + * + + )} {/* Question-level unit, any type (legacy QuestionLabel contract): integer/decimal/choice have no answer-time unit picker, so this suffix is their only unit display. */} diff --git a/src/components/QuestionnaireV2/form/engine/inputs/BooleanInput.tsx b/src/components/QuestionnaireV2/form/engine/inputs/BooleanInput.tsx index 0f05f66b72d..6ef8b177410 100644 --- a/src/components/QuestionnaireV2/form/engine/inputs/BooleanInput.tsx +++ b/src/components/QuestionnaireV2/form/engine/inputs/BooleanInput.tsx @@ -42,6 +42,7 @@ export function BooleanInput({
{question.answer_option.map((option) => ( @@ -114,6 +116,7 @@ export function ChoiceInput({
{question.answer_option.map((option) => ( diff --git a/src/components/QuestionnaireV2/form/engine/inputs/DateInput.tsx b/src/components/QuestionnaireV2/form/engine/inputs/DateInput.tsx index c3e771ad7fa..3ac815c4372 100644 --- a/src/components/QuestionnaireV2/form/engine/inputs/DateInput.tsx +++ b/src/components/QuestionnaireV2/form/engine/inputs/DateInput.tsx @@ -10,6 +10,7 @@ import { withEntryAt } from "./withEntryAt"; export function DateInput({ question, disabled, + labelId, valueIndex, }: RendererInputProps) { const [response, updateResponse] = useQuestionResponse(question.id); @@ -33,11 +34,21 @@ export function DateInput({ }; return ( - + // The picker's trigger button takes no id/aria props (ui/ primitives + // stay unmodified), so the question association rides on a named + // group — without it every date question announces as an identical + // bare "Pick a date" stop. +
+ +
); } diff --git a/src/components/QuestionnaireV2/form/engine/inputs/DateTimeQuestionInput.tsx b/src/components/QuestionnaireV2/form/engine/inputs/DateTimeQuestionInput.tsx index 41c3479ab93..96eb8ea35c4 100644 --- a/src/components/QuestionnaireV2/form/engine/inputs/DateTimeQuestionInput.tsx +++ b/src/components/QuestionnaireV2/form/engine/inputs/DateTimeQuestionInput.tsx @@ -17,6 +17,7 @@ function formatTime(date: Date | undefined) { export function DateTimeQuestionInput({ question, disabled, + labelId, valueIndex, }: RendererInputProps) { const [response, updateResponse] = useQuestionResponse(question.id); @@ -60,7 +61,15 @@ export function DateTimeQuestionInput({ }; return ( -
+ // Named group for the same reason as DateInput: the picker trigger + // takes no id/aria props, and the bare time input would otherwise + // reach screen readers nameless. +
handleValueChange(e.target.value)} step="0.01" @@ -115,6 +116,7 @@ export function QuantityInput({ type="number" inputMode="decimal" pattern="[0-9]*[.]?[0-9]*" + aria-required={question.required || undefined} value={value?.toString() ?? ""} onChange={(e) => handleValueChange(e.target.value)} step="0.01" diff --git a/src/components/QuestionnaireV2/form/engine/inputs/TextInput.tsx b/src/components/QuestionnaireV2/form/engine/inputs/TextInput.tsx index 2e81c566057..a1106dc124b 100644 --- a/src/components/QuestionnaireV2/form/engine/inputs/TextInput.tsx +++ b/src/components/QuestionnaireV2/form/engine/inputs/TextInput.tsx @@ -38,6 +38,7 @@ export function TextInput({ id: inputId, value, disabled, + "aria-required": question.required || undefined, placeholder: t("enter_details"), maxLength: question.max_length, onChange: (e: React.ChangeEvent) => diff --git a/src/components/QuestionnaireV2/form/engine/inputs/TimeInput.tsx b/src/components/QuestionnaireV2/form/engine/inputs/TimeInput.tsx index cbca461e293..9f595cfd40f 100644 --- a/src/components/QuestionnaireV2/form/engine/inputs/TimeInput.tsx +++ b/src/components/QuestionnaireV2/form/engine/inputs/TimeInput.tsx @@ -33,11 +33,14 @@ export function TimeInput({ }; return ( + // No text-size override: the base Input's 16px-on-phones scale is + // deliberate (a smaller font makes iOS zoom the page on focus). From aaee1fdbe4dcc009a9cd06fbfcb35c9c040e1542 Mon Sep 17 00:00:00 2001 From: Bodhish Thomas Date: Mon, 3 Aug 2026 19:53:20 +0530 Subject: [PATCH 2/2] Questionnaire v2: outline overlay and submit/draft regression coverage (#16630) --- .../Questionnaire/QuestionLabel.tsx | 57 ---- .../QuestionTypes/AllergyQuestion.tsx | 3 - .../QuestionTypes/AppointmentQuestion.tsx | 2 - .../QuestionTypes/ChargeItemQuestion.tsx | 3 - .../QuestionTypes/DeathQuestion.tsx | 6 +- .../QuestionTypes/DiagnosisQuestion.tsx | 5 +- .../QuestionTypes/EncounterQuestion.tsx | 4 - .../QuestionTypes/FileQuestion.tsx | 2 - .../MedicationRequestQuestion.tsx | 6 +- .../MedicationStatementQuestion.tsx | 4 +- .../QuestionTypes/ServiceRequestQuestion.tsx | 3 - .../QuestionTypes/SymptomQuestion.tsx | 5 +- src/components/QuestionnaireV2/README.md | 2 +- .../manage/questionnaireFormSchema.ts | 2 +- .../encounter/fill/fillAutosave.spec.ts | 44 +++ .../encounter/fill/fillMultiForm.spec.ts | 45 +++ .../encounter/fill/fillOutlineNav.spec.ts | 263 ++++++++++++++++++ .../patient/encounter/fill/fillPage.spec.ts | 56 +++- .../encounter/fill/fillServerDraft.spec.ts | 45 +++ .../structuredRendering.spec.ts | 98 +++++++ .../patient/fill/fillPatientSubject.spec.ts | 6 +- 21 files changed, 561 insertions(+), 100 deletions(-) delete mode 100644 src/components/Questionnaire/QuestionLabel.tsx create mode 100644 tests/facility/patient/encounter/fill/fillOutlineNav.spec.ts create mode 100644 tests/facility/patient/encounter/structuredQuestions/structuredRendering.spec.ts diff --git a/src/components/Questionnaire/QuestionLabel.tsx b/src/components/Questionnaire/QuestionLabel.tsx deleted file mode 100644 index b610385910d..00000000000 --- a/src/components/Questionnaire/QuestionLabel.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { cn } from "@/lib/utils"; - -import { Label } from "@/components/ui/label"; - -import type { Question } from "@/types/questionnaire/question"; - -interface QuestionLabelProps { - question: Question; - className?: string; - groupLabel?: boolean; - isSubQuestion?: boolean; -} - -const defaultGroupClass = "text-lg font-medium text-gray-900"; -const defaultInputClass = "text-base font-medium block"; - -export function QuestionLabel({ - question, - className, - groupLabel, - isSubQuestion = false, -}: QuestionLabelProps) { - const defaultClass = groupLabel ? defaultGroupClass : defaultInputClass; - - return ( -