-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Questionnaire v2: submit-path hardening and render-failure containment #16628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,6 +67,13 @@ export interface ComposeBatchArgs { | |
| questionnaire: QuestionnaireRead; | ||
| responses: Record<string, QuestionnaireResponse>; | ||
| subject: FillSubject; | ||
| /** Question ids whose structured slot threw and shows the error | ||
| * boundary's notice (`structuredRenderFailedAtom`). The validators skip | ||
| * these on the premise that their data never submits — this is the | ||
| * compose half of that bargain. Without it, rows recorded BEFORE the | ||
| * component broke would post to the domain APIs with their type's | ||
| * `validate` never run, from a section the UI presents as inert. */ | ||
| renderFailed?: ReadonlySet<string>; | ||
| /** Resuming a server draft — appends the completion PUT. */ | ||
| continueDraftId?: string; | ||
| } | ||
|
|
@@ -104,6 +111,7 @@ export async function composeBatch({ | |
| questionnaire, | ||
| responses, | ||
| subject, | ||
| renderFailed, | ||
| continueDraftId, | ||
| }: ComposeBatchArgs): Promise<StructuredBatchEntry[]> { | ||
| // Narrowed once, up front: the structured leg and the draft PUT both | ||
|
|
@@ -129,6 +137,11 @@ export async function composeBatch({ | |
| if (!response) continue; | ||
|
|
||
| if (question.type === "structured" && question.structured_type) { | ||
| // The slot's component threw — the clinician sees a notice, not | ||
| // their data, and validateStructured skipped this question's | ||
| // `validate` for the same reason. What the UI shows as inert must | ||
| // not submit behind its back. | ||
| if (renderFailed?.has(question.id)) continue; | ||
| // The recorded entries must belong to this question's type — the | ||
| // guard `structuredDataOf` used to carry, kept now that the data | ||
| // read is untyped. | ||
|
|
@@ -165,11 +178,14 @@ export async function composeBatch({ | |
| continue; | ||
| } | ||
|
|
||
| if ( | ||
| response.values.length > 0 && | ||
| response.values[0]?.value !== "" && | ||
| !response.structured_type | ||
| ) { | ||
| // Every plain leaf goes through serialization; the content decision | ||
| // is `serializeResponseValues`' alone (its filter keeps value-, | ||
| // coding- and unit-carrying entries). Gating here on values[0] | ||
| // dropped a repeats answer wholesale when its FIRST row was cleared | ||
| // in place — later rows silently never submitted, while the | ||
| // required check (which scans every entry) reported the question | ||
| // answered. | ||
| if (!response.structured_type) { | ||
| answeredLeaves.push(response); | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The safety of this removal depends entirely on the |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,12 @@ | ||
| import { useMutation } from "@tanstack/react-query"; | ||
| import { useCallback, useState } from "react"; | ||
| import { useCallback, useRef, useState } from "react"; | ||
| import { useTranslation } from "react-i18next"; | ||
| import { toast } from "sonner"; | ||
|
|
||
| import { | ||
| errorsAtom, | ||
| responsesAtom, | ||
| structuredRenderFailedAtom, | ||
| } from "@/components/QuestionnaireV2/form/engine/store"; | ||
| import { collectRequiredErrors } from "@/components/QuestionnaireV2/form/validation"; | ||
|
|
||
|
|
@@ -52,7 +53,12 @@ function scrollToQuestion(questionId: string) { | |
| `[data-question-id="${questionId}"]`, | ||
| ); | ||
| if (!block) return; | ||
| block.scrollIntoView({ block: "center", behavior: "smooth" }); | ||
| block.scrollIntoView({ | ||
| block: "center", | ||
| behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches | ||
| ? "auto" | ||
| : "smooth", | ||
| }); | ||
| const input = document.getElementById(`question-input-${questionId}`); | ||
| if (input) { | ||
| input.focus({ preventScroll: true }); | ||
|
|
@@ -148,35 +154,53 @@ export function useSubmitFillSession({ | |
| }, | ||
| }); | ||
|
|
||
| const submit = useCallback(async () => { | ||
| const runSubmit = useCallback(async () => { | ||
| // 1) Validate every form against its own store; the first failure | ||
| // anywhere in the session decides where we scroll. The renderer's | ||
| // flat subject view goes in so the required check can tell a | ||
| // structured question that HAS an input from one showing a | ||
| // placeholder (see form/validation.ts). | ||
| // flat subject view and the form's render-failed set go in so the | ||
| // required check can tell a structured question that HAS an input | ||
| // from one showing a notice (see form/validation.ts). | ||
| const rendererSubject = rendererSubjectOf(subject); | ||
| let firstError: { formKey: string; questionId: string } | undefined; | ||
| for (const form of forms) { | ||
| const store = getStore(form.key); | ||
| if (!store) continue; | ||
| const responses = store.get(responsesAtom); | ||
| const renderFailed = store.get(structuredRenderFailedAtom); | ||
| const clientErrors: QuestionValidationError[] = [ | ||
| ...collectRequiredErrors(form.questionnaire.questions, responses, t, { | ||
| questionnaire: form.questionnaire, | ||
| subject: rendererSubject, | ||
| renderFailed, | ||
| }), | ||
| ...collectStructuredErrors( | ||
| form.questionnaire, | ||
| responses, | ||
| rendererSubject, | ||
| renderFailed, | ||
| t, | ||
| ), | ||
| ]; | ||
| store.set(errorsAtom, clientErrors); | ||
| if (clientErrors.length > 0 && !firstError) { | ||
| // "First" in the clinician's reading order, not in validator | ||
| // order — the array concatenates every required failure before | ||
| // any structured one, so its head can sit far below an earlier | ||
| // failing question on screen. | ||
| const failing = new Set(clientErrors.map((error) => error.question_id)); | ||
| const inTreeOrder = (questions: Question[]): string | undefined => { | ||
| for (const question of questions) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. const inTreeOrder = (questions: Question[]): string | undefined => {
for (const question of questions) {
if (failing.has(question.id)) return question.id;
const found = inTreeOrder(question.questions ?? []);
if (found) return found;
}
return undefined;
};This recursive helper is defined INSIDE Also: this walks the tree for EVERY form's errors, but you only need it when |
||
| if (failing.has(question.id)) return question.id; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| const found = inTreeOrder(question.questions ?? []); | ||
| if (found) return found; | ||
| } | ||
| return undefined; | ||
| }; | ||
| firstError = { | ||
| formKey: form.key, | ||
| questionId: clientErrors[0].question_id, | ||
| questionId: | ||
| inTreeOrder(form.questionnaire.questions) ?? | ||
| clientErrors[0].question_id, | ||
| }; | ||
| } | ||
| } | ||
|
|
@@ -205,6 +229,7 @@ export function useSubmitFillSession({ | |
| questionnaire: form.questionnaire, | ||
| responses: store.get(responsesAtom), | ||
| subject, | ||
| renderFailed: store.get(structuredRenderFailedAtom), | ||
| continueDraftId: form.isPrimary ? continueDraftId : undefined, | ||
| }); | ||
| }), | ||
|
|
@@ -240,5 +265,39 @@ export function useSubmitFillSession({ | |
| submitBatch({ requests }); | ||
| }, [forms, getStore, subject, continueDraftId, submitBatch, t]); | ||
|
|
||
| return { submit, isPending, serverErrors }; | ||
| // Compose runs BEFORE the mutation starts, and structured | ||
| // `buildRequests` are async — during that window the mutation's | ||
| // isPending is still false, so a second click would validate and | ||
| // compose a second identical batch. The ref closes the window | ||
| // synchronously (state alone leaves the same-tick gap); the state twin | ||
| // keeps the button disabled for the same span. | ||
| const composingRef = useRef(false); | ||
| const [isComposing, setIsComposing] = useState(false); | ||
|
|
||
| /** | ||
| * The one entry point, and the outermost containment boundary. The page | ||
| * fires this as `onSubmit={() => void submit()}`, so ANY escaping | ||
| * rejection — a plugin component's getter, a malformed store record, a | ||
| * future call added inside `runSubmit` — would become an unhandled | ||
| * promise rejection and turn Save Changes into a silent no-op. Failing | ||
| * loudly is the floor: the clinician always learns the submission did | ||
| * not happen, and the original error still reaches the console for the | ||
| * developer. | ||
| */ | ||
| const submit = useCallback(async () => { | ||
| if (composingRef.current || isPending) return; | ||
| composingRef.current = true; | ||
| setIsComposing(true); | ||
| try { | ||
| await runSubmit(); | ||
| } catch (error) { | ||
| console.error("Questionnaire submission failed unexpectedly", error); | ||
| toast.error(t("questionnaire_submission_failed")); | ||
| } finally { | ||
| composingRef.current = false; | ||
| setIsComposing(false); | ||
| } | ||
| }, [runSubmit, isPending, t]); | ||
|
|
||
| return { submit, isPending: isPending || isComposing, serverErrors }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,13 @@ | ||
| import { Suspense, useCallback, useSyncExternalStore } from "react"; | ||
| import { Suspense, useCallback, useEffect, useSyncExternalStore } from "react"; | ||
| import { useTranslation } from "react-i18next"; | ||
|
|
||
| import { PluginErrorBoundary } from "@/components/Common/PluginErrorBoundary"; | ||
| import { FormSkeleton } from "@/components/Common/SkeletonLoading"; | ||
|
|
||
| import { | ||
| useClearQuestionErrors, | ||
| useClearStructuredRenderFailed, | ||
| useMarkStructuredRenderFailed, | ||
| useQuestionErrors, | ||
| useQuestionResponse, | ||
| } from "@/components/QuestionnaireV2/form/engine/store"; | ||
|
|
@@ -23,6 +25,24 @@ import type { Question } from "@/types/questionnaire/question"; | |
|
|
||
| import { useFormRenderer } from "./FormContext"; | ||
|
|
||
| /** | ||
| * Clears the question's render-failed mark when it mounts. Rendered INSIDE | ||
| * the error boundary, beside the structured component: if the component | ||
| * throws during the mounting render, the whole subtree — this probe | ||
| * included — is discarded before effects run, so the clear only ever fires | ||
| * for a commit whose input actually made it to the screen. That placement | ||
| * is what makes the mark track reality across an unmount/remount cycle | ||
| * (enable_when toggling the question): a recovered slot un-exempts itself, | ||
| * a still-broken one re-marks via the boundary's onError. | ||
| */ | ||
| function ClearRenderFailedOnMount({ questionId }: { questionId: string }) { | ||
| const clearRenderFailed = useClearStructuredRenderFailed(questionId); | ||
| useEffect(() => { | ||
| clearRenderFailed(); | ||
| }, [clearRenderFailed]); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The In practice this race matters if something triggers a submit in that window — unlikely in normal use, but not impossible with a fast autosave or keyboard shortcut. A |
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Structured questions render through `resolveStructuredType` — core types | ||
| * from STRUCTURED_TYPE_REGISTRY, plugin types from the runtime registry, | ||
|
|
@@ -56,6 +76,7 @@ export function StructuredSlot({ | |
| const [response, updateResponse] = useQuestionResponse(question.id); | ||
| const errors = useQuestionErrors(question.id); | ||
| const clearErrors = useClearQuestionErrors(question.id); | ||
| const markRenderFailed = useMarkStructuredRenderFailed(question.id); | ||
|
|
||
| const handleChange = useCallback( | ||
| (values: ResponseValue[], note?: string) => | ||
|
|
@@ -131,13 +152,18 @@ export function StructuredSlot({ | |
| // the same dashed notice the other degradations use. | ||
| <PluginErrorBoundary | ||
| pluginName={definition.type} | ||
| // Once the notice is showing there is no input to answer, so | ||
| // submit-time enforcement must stop requiring one — same reasoning | ||
| // as the subject-mismatch and missing-context skips. | ||
| onError={markRenderFailed} | ||
| fallback={ | ||
| <div className="rounded-lg border border-dashed border-amber-300 bg-amber-50 p-4 text-sm text-amber-800"> | ||
| <p className="font-medium">{label}</p> | ||
| <p>{t("structured_question_render_failed")}</p> | ||
| </div> | ||
| } | ||
| > | ||
| <ClearRenderFailedOnMount questionId={question.id} /> | ||
| {/* Plugin components arrive through React.lazy — the boundary keeps | ||
| a still-loading remote from suspending the whole form. */} | ||
| <Suspense fallback={<FormSkeleton rows={2} />}> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,59 @@ export const questionnaireAtom = atom<QuestionnaireRead | null>(null); | |
| export const responsesAtom = atom<Record<string, QuestionnaireResponse>>({}); | ||
| export const errorsAtom = atom<QuestionValidationError[]>([]); | ||
|
|
||
| /** | ||
| * Question ids whose structured slot THREW during render and are now | ||
| * showing the error boundary's notice instead of an input. | ||
| * | ||
| * Submit-time enforcement reads this alongside the subject-mismatch and | ||
| * missing-context cases: all three mean "this question has no input on | ||
| * screen", and requiring an unanswerable question makes the entire form — | ||
| * every other answer included — permanently unsubmittable. Lives in the | ||
| * store because the boundary that discovers it and the validators that | ||
| * must respect it never meet in the component tree. | ||
| */ | ||
| export const structuredRenderFailedAtom = atom<ReadonlySet<string>>( | ||
| new Set<string>(), | ||
| ); | ||
|
|
||
| /** Record a structured slot's render failure. Idempotent: re-entering the | ||
| * boundary for a question already marked keeps the same Set identity, so | ||
| * it cannot loop a subscriber. */ | ||
| export function useMarkStructuredRenderFailed(questionId: string) { | ||
| const markAtom = useMemo( | ||
| () => | ||
| atom(null, (get, set) => { | ||
| const failed = get(structuredRenderFailedAtom); | ||
| if (failed.has(questionId)) return; | ||
| set(structuredRenderFailedAtom, new Set(failed).add(questionId)); | ||
| }), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Creating write-only atoms inside |
||
| [questionId], | ||
| ); | ||
| return useAtom(markAtom)[1]; | ||
| } | ||
|
|
||
| /** Clear a question's render-failed mark — the recovery half of the pair | ||
| * above. A slot unmounts and remounts whenever enable_when toggles it (or | ||
| * an ancestor group), and the fresh boundary may well render fine; the | ||
| * mark must not outlive the notice it described, or a LIVE required input | ||
| * would stay exempt from validation for the rest of the session. | ||
| * Idempotent the same way: clearing an unmarked question keeps the Set | ||
| * identity. */ | ||
| export function useClearStructuredRenderFailed(questionId: string) { | ||
| const clearAtom = useMemo( | ||
| () => | ||
| atom(null, (get, set) => { | ||
| const failed = get(structuredRenderFailedAtom); | ||
| if (!failed.has(questionId)) return; | ||
| const next = new Set(failed); | ||
| next.delete(questionId); | ||
| set(structuredRenderFailedAtom, next); | ||
| }), | ||
| [questionId], | ||
| ); | ||
| return useAtom(clearAtom)[1]; | ||
| } | ||
|
|
||
| /** link_id → question_id for enable_when lookups — pure so non-atom | ||
| * consumers (form/validation.ts) share the exact same resolution. */ | ||
| export function buildLinkIndex(questions: Question[]): Record<string, string> { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The old code checked
response.values.length > 0 && response.values[0]?.value !== "". Now you're pushing responses with an EMPTYvaluesarray toansweredLeaves. Yes, you sayserializeResponseValueshandles it — but that means you're now calling serialize on every leaf response regardless, including ones with no data at all. Whether that's a silent no-op or eventually becomes a backend validation error depends entirely on whatserializeResponseValuesdoes with an empty array and whether the API rejects empty leaf objects in the batch.This fix is correct for the multi-row repeats bug, but dropping the
values.length > 0guard entirely is broader than the fix requires. At minimum you should be checkingresponse.values.length > 0still, and lettingserializeResponseValuesdecide content; the original bug was gating onvalues[0]not on length.