Add interactive questionnaire editor and typed save payloads

This commit is contained in:
Peter Stockings
2026-09-28 10:02:45 +10:00
parent 0879a3e786
commit 834d07d9bc
18 changed files with 687 additions and 54 deletions
+10 -1
View File
@@ -175,7 +175,16 @@ Continue/Submit operations. Submission failures are displayed for retry.
Without an `onSave` handler the renderer operates in memory, emits `Changed` Without an `onSave` handler the renderer operates in memory, emits `Changed`
events, and does not advance a server revision. With a handler it emits `Saved` events, and does not advance a server revision. With a handler it emits `Saved`
after success or `Blocked` on failure. Events contain transport-independent after success or `Blocked` on failure. Events contain transport-independent
`data` (changes, resume position, revision, trigger), not HTTP requests. `data`, not HTTP requests. Requests and events have a `kind`: `changes` requests
carry `changes` without a resume position; `focus` requests carry `resume`
without answer changes. Both include `revision` and `trigger`. A save that
updates answers and position sends two serialized requests, using the revision
acknowledged by the first request for the second. The demo displays them in
separate Changes and Focus lists.
Each answer in `changes` and in the resume editor's `answers` uses a typed
entry, for example `"dateOfBirth": { "type": "date", "value": "1992-03-04" }`.
Cleared answers retain their field type with `value: null`. `parseResumeDraft`
validates typed entries and also accepts older drafts containing plain values.
`SaveResponse` contains only the confirmed revision. `SaveResponse` contains only the confirmed revision.
The built-in file input requires `onUpload(questionId, files)` for nonempty The built-in file input requires `onUpload(questionId, files)` for nonempty
+2 -1
View File
@@ -41,8 +41,9 @@ export function DocsPage() {
<p>Only text fields are replaced here. All other types keep their default controls.</p> <p>Only text fields are replaced here. All other types keep their default controls.</p>
</section> </section>
<section id="persistence"><h2>Save answers</h2> <section id="persistence"><h2>Save answers</h2>
<p><code>onSave</code> receives changed answers, the resume position, and the current revision. Send them to your backend and return its new revision.</p> <p><code>onSave</code> receives separate requests: <code>kind: "changes"</code> carries answer changes, and <code>kind: "focus"</code> carries the resume position. Each includes the current revision. Send it to your backend and return its new revision.</p>
<Code>{saveDraftSource}</Code> <Code>{saveDraftSource}</Code>
<p>Each changed answer includes its field type and value, for example <code>{'"dateOfBirth": { "type": "date", "value": "1992-03-04" }'}</code>. Resume drafts use the same entries under <code>answers</code>; cleared answers use <code>value: null</code>.</p>
<p>The URL is your choice. Saves run in order. Continue and Submit wait for saving; throw an error to report a failure.</p> <p>The URL is your choice. Saves run in order. Continue and Submit wait for saving; throw an error to report a failure.</p>
<h3>Uploads and submission</h3> <h3>Uploads and submission</h3>
<ReferenceTable rows={[ <ReferenceTable rows={[
+2 -1
View File
@@ -2,12 +2,13 @@ import { useEffect, useMemo, useState } from "react";
import { QuestionnaireEngine, type Answers, type QuestionnaireDefinition } from "../lib/questionnaire/questionnaire-engine"; import { QuestionnaireEngine, type Answers, type QuestionnaireDefinition } from "../lib/questionnaire/questionnaire-engine";
import { parseResumeDraft, type ResumedDraft } from "../lib/questionnaire/resume-draft"; import { parseResumeDraft, type ResumedDraft } from "../lib/questionnaire/resume-draft";
import { highlightJson } from "./JsonViewer"; import { highlightJson } from "./JsonViewer";
import { serializeAnswers } from "../lib/persistence/typed-answers";
export function ResumePanel({ definition, answers, nodeId, questionId, onResume }: { export function ResumePanel({ definition, answers, nodeId, questionId, onResume }: {
definition: QuestionnaireDefinition; answers: Answers; nodeId: string; questionId?: string; onResume: (draft: ResumedDraft) => void; definition: QuestionnaireDefinition; answers: Answers; nodeId: string; questionId?: string; onResume: (draft: ResumedDraft) => void;
}) { }) {
const engine = useMemo(() => new QuestionnaireEngine(definition), [definition]); const engine = useMemo(() => new QuestionnaireEngine(definition), [definition]);
const resumeAnswers = useMemo(() => engine.createSession(answers).getEffectiveAnswers(), [engine, answers]); const resumeAnswers = useMemo(() => serializeAnswers(engine, engine.createSession(answers).getEffectiveAnswers()), [engine, answers]);
const snapshot = JSON.stringify({ answers: resumeAnswers, resume: { nodeId, questionId: questionId ?? null } }, null, 2); const snapshot = JSON.stringify({ answers: resumeAnswers, resume: { nodeId, questionId: questionId ?? null } }, null, 2);
const [text, setText] = useState(snapshot); const [text, setText] = useState(snapshot);
useEffect(() => { setText(snapshot); }, [snapshot]); useEffect(() => { setText(snapshot); }, [snapshot]);
+10 -7
View File
@@ -13,18 +13,20 @@ function compactValue(value: unknown) {
} }
function eventChanges(event: SaveEvent) { function eventChanges(event: SaveEvent) {
if (!event.data || !isRecord(event.data)) return []; if (event.data?.kind !== "changes") return [];
const body = event.data; const body = event.data;
return Object.entries(body.changes).map(([key, value]) => ({ key, value: compactValue(value) })); return Object.entries(body.changes).map(([key, answer]) => ({ key, value: compactValue(answer.value) }));
} }
export function SaveEventsPanel({ events, onClear }: { events: SaveEvent[]; onClear: () => void }) { export function SaveEventsPanel({ events, onClear }: { events: SaveEvent[]; onClear: () => void }) {
return <details className="derived-panel save-events-panel"> return <details className="derived-panel save-events-panel">
<summary>Save events <span>{events.length} · Preview only</span></summary> <summary>Save events <span>{events.length} · Preview only</span></summary>
<button type="button" className="secondary" onClick={onClear} disabled={!events.length}>Clear log</button> <button type="button" className="secondary" onClick={onClear} disabled={!events.length}>Clear log</button>
<div className="save-events-list"> {(["changes", "focus"] as const).map(kind => <section className="save-events-group" key={kind} aria-label={`${kind === "changes" ? "Changes" : "Focus"} events`}>
{!events.length && <p className="derived-note">Edit a question and leave it, or press Continue, to see what would be saved.</p>} <h3>{kind === "changes" ? "Changes" : "Focus"} <span>{events.filter(event => event.kind === kind).length}</span></h3>
{events.map((event) => { <div className="save-events-list">
{!events.some(event => event.kind === kind) && <p className="derived-note">{kind === "changes" ? "Edit a question and leave it, or press Continue, to see answer saves." : "Focus a question or move between pages to see resume updates."}</p>}
{events.filter(event => event.kind === kind).map((event) => {
const changes = eventChanges(event); const changes = eventChanges(event);
const resume = isRecord(event.data) && isRecord(event.data.resume) ? event.data.resume : undefined; const resume = isRecord(event.data) && isRecord(event.data.resume) ? event.data.resume : undefined;
return <article key={event.id} className={`save-event save-event-${event.status.toLowerCase()}`}> return <article key={event.id} className={`save-event save-event-${event.status.toLowerCase()}`}>
@@ -39,10 +41,11 @@ export function SaveEventsPanel({ events, onClear }: { events: SaveEvent[]; onCl
{event.note && <p className="save-event-note">{event.note}</p>} {event.note && <p className="save-event-note">{event.note}</p>}
{event.data && <details className="save-event-payload"> {event.data && <details className="save-event-payload">
<summary>View payload</summary> <summary>View payload</summary>
<JsonViewer value={{ changes: event.data.changes, resume: event.data.resume }} /> <JsonViewer value={event.data} />
</details>} </details>}
</article>; </article>;
})} })}
</div> </div>
</section>)}
</details>; </details>;
} }
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { FieldDefinition, QuestionnaireDefinition } from "../lib/questionnaire/questionnaire-engine";
export type DiagramSelection = { page: number; path?: number[] };
const escape = (value: string) => value.replace(/[&"#<>\n]/g, character => `#${character.charCodeAt(0)};`);
let sequence = 0;
export function SchemaDiagram({ definition, selection, onSelect }: { definition: QuestionnaireDefinition; selection?: DiagramSelection; onSelect: (selection: DiagramSelection) => void }) {
const viewport = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState("");
const [error, setError] = useState("");
const [size, setSize] = useState({ width: 700, height: 700 });
const [view, setView] = useState({ x: 30, y: 30, scale: 0.8 });
const press = useRef<{ x: number; y: number; startX: number; startY: number; node?: string; moved: boolean } | undefined>(undefined);
const graph = useMemo(() => {
const lines = ['flowchart TD', 'start(["Start"])', 'finish(["End"])'];
const nodes: Record<string, DiagramSelection> = {};
const target = (id: string) => {
const index = definition.pages.findIndex(page => page.id === id);
return index < 0 ? "finish" : `p${index}${definition.pages[index].when ? "when" : ""}`;
};
lines.push(`start --> ${definition.pages.length ? target(definition.pages[0].id) : "finish"}`);
definition.pages.forEach((page, pageIndex) => {
const id = `p${pageIndex}`;
nodes[id] = { page: pageIndex };
lines.push(`${id}["Page: ${escape(page.title || page.id || "Untitled")}"]:::page`);
let previous = id;
if (page.when) { lines.push(`${id}when{"${escape(page.when)}"}`, `${id}when -->|Show| ${id}`, `${id}when -->|Skip| ${target(definition.pages[pageIndex + 1]?.id ?? "END")}`); nodes[`${id}when`] = { page: pageIndex }; }
function fields(items: FieldDefinition[], path: number[] = []) {
items.forEach((field, index) => {
const nextPath = [...path, index];
const fieldId = `${id}f${nextPath.join("f")}`;
nodes[fieldId] = { page: pageIndex, path: nextPath };
if (field.when) {
lines.push(`${fieldId}when{"${escape(field.when)}"}`, `${previous} --> ${fieldId}when`);
nodes[`${fieldId}when`] = nodes[fieldId]; previous = `${fieldId}when`;
}
const title = field.type === "group" ? `Group: ${field.title || "Untitled"}` : `${field.label || field.id || "Untitled"} · ${field.type}${field.required ? " *" : ""}`;
lines.push(`${fieldId}["${escape(title)}"]:::${field.type === "group" ? "group" : "question"}`, `${previous} -->${field.when ? "|Show|" : ""} ${fieldId}`);
previous = fieldId;
if (field.type === "group") fields(field.fields, nextPath);
if (field.when) { lines.push(`${fieldId}merge((" "))`, `${fieldId}when -->|Skip| ${fieldId}merge`, `${previous} --> ${fieldId}merge`); previous = `${fieldId}merge`; }
});
}
fields(page.fields);
if (page.route) {
page.route.cases.forEach((route, index) => {
const rule = `${id}route${index}`;
nodes[rule] = { page: pageIndex };
lines.push(`${rule}{"${escape(route.when)}"}`, `${rule} -->|Yes| ${target(route.to)}`);
if (index === 0) lines.push(`${previous} --> ${rule}`);
const nextRule = `${id}route${index + 1}`;
if (index + 1 < page.route!.cases.length) lines.push(`${rule} -->|No| ${nextRule}`);
else lines.push(`${rule} -->|No| ${target(page.route!.otherwise)}`);
previous = rule;
});
}
if (!page.route?.cases.length) lines.push(`${previous} --> ${target(page.route?.otherwise ?? definition.pages[pageIndex + 1]?.id ?? "END")}`);
});
lines.push('classDef page fill:#e8efff,stroke:#537ddd,stroke-width:2px,color:#244c96', 'classDef question fill:#fff,stroke:#a8bbd5,color:#273b58', 'classDef group fill:#ecf8f3,stroke:#74a893,color:#245844');
return { source: lines.join("\n"), nodes };
}, [definition]);
function fit() {
const box = viewport.current;
if (!box) return;
const scale = Math.max(.1, Math.min(1.2, (box.clientWidth - 60) / size.width, (box.clientHeight - 60) / size.height));
setView({ scale, x: (box.clientWidth - size.width * scale) / 2, y: (box.clientHeight - size.height * scale) / 2 });
}
useEffect(() => {
let cancelled = false;
const timer = window.setTimeout(async () => {
try {
const { default: mermaid } = await import("mermaid");
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", flowchart: { htmlLabels: false, useMaxWidth: false } });
const rendered = await mermaid.render(`schema-diagram-${++sequence}`, graph.source);
if (cancelled) return;
const doc = new DOMParser().parseFromString(rendered.svg, "image/svg+xml");
doc.querySelectorAll("g.node").forEach(node => {
const id = Object.keys(graph.nodes).find(key => node.id.includes(`flowchart-${key}-`));
if (!id) return;
node.setAttribute("data-editor-node", id); node.setAttribute("role", "button"); node.setAttribute("tabindex", "0");
node.setAttribute("aria-label", `Edit ${node.textContent}`);
});
const [, , width, height] = (doc.documentElement.getAttribute("viewBox") ?? "0 0 700 700").split(/\s+/).map(Number);
setSize({ width, height }); setSvg(new XMLSerializer().serializeToString(doc.documentElement)); setError("");
} catch (cause) { if (!cancelled) setError(String(cause)); }
}, 200);
return () => { cancelled = true; clearTimeout(timer); };
}, [graph]);
useEffect(() => {
if (!svg || !viewport.current) return;
const observer = new ResizeObserver(() => { if (viewport.current?.clientWidth) fit(); });
observer.observe(viewport.current);
return () => observer.disconnect();
}, [!!svg]);
useEffect(() => {
viewport.current?.querySelectorAll("[data-editor-node]").forEach(node => {
const target = graph.nodes[node.getAttribute("data-editor-node")!];
node.classList.toggle("schema-node-selected", JSON.stringify(target) === JSON.stringify(selection));
});
}, [svg, selection, graph]);
useEffect(() => {
const box = viewport.current;
if (!box) return;
function wheel(event: WheelEvent) {
event.preventDefault();
const rect = box!.getBoundingClientRect();
setView(old => {
const scale = Math.max(.1, Math.min(3, old.scale * Math.exp(-event.deltaY * .002)));
const ratio = scale / old.scale;
return { scale, x: event.clientX - rect.left - (event.clientX - rect.left - old.x) * ratio, y: event.clientY - rect.top - (event.clientY - rect.top - old.y) * ratio };
});
}
box.addEventListener("wheel", wheel, { passive: false });
return () => box.removeEventListener("wheel", wheel);
}, []);
function choose(id?: string) { if (id && graph.nodes[id]) onSelect(graph.nodes[id]); }
return <section className="schema-diagram">
<div className="schema-diagram-toolbar"><span>Drag to pan · Scroll to zoom · Click to edit</span><div className="schema-actions">
<button className="secondary" onClick={() => setView(old => ({ ...old, scale: Math.max(.1, old.scale / 1.2) }))} aria-label="Zoom out">−</button><span>{Math.round(view.scale * 100)}%</span>
<button className="secondary" onClick={() => setView(old => ({ ...old, scale: Math.min(3, old.scale * 1.2) }))} aria-label="Zoom in">+</button><button className="secondary" onClick={fit}>Fit</button>
</div></div>
{error && <p role="alert">{error}</p>}
<div className="schema-diagram-viewport" ref={viewport} tabIndex={0} role="region" aria-label="Editable questionnaire diagram"
onPointerDown={event => {
if (event.button !== 0) return;
press.current = { x: event.clientX, y: event.clientY, startX: event.clientX, startY: event.clientY, moved: false, node: (event.target as Element).closest("[data-editor-node]")?.getAttribute("data-editor-node") ?? undefined };
event.currentTarget.setPointerCapture(event.pointerId);
}} onPointerMove={event => {
const p = press.current; if (!p) return;
if (Math.hypot(event.clientX - p.startX, event.clientY - p.startY) > 5) p.moved = true;
const dx = event.clientX - p.x, dy = event.clientY - p.y; p.x = event.clientX; p.y = event.clientY;
setView(old => ({ ...old, x: old.x + dx, y: old.y + dy }));
}} onPointerUp={() => { if (!press.current?.moved) choose(press.current?.node); press.current = undefined; }} onPointerCancel={() => { press.current = undefined; }} onLostPointerCapture={() => { press.current = undefined; }}
onKeyDown={event => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose((event.target as Element).closest("[data-editor-node]")?.getAttribute("data-editor-node") ?? undefined); }
const delta = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] }[event.key];
if (delta) { event.preventDefault(); setView(old => ({ ...old, x: old.x + delta[0], y: old.y + delta[1] })); }
}}>
{!svg && <p role="status">Drawing questionnaire…</p>}
<div className="schema-diagram-art" style={{ width: size.width, transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }} dangerouslySetInnerHTML={{ __html: svg }} />
</div>
</section>;
}
+199
View File
@@ -0,0 +1,199 @@
import { useMemo, useState, type ReactNode } from "react";
import { QuestionnaireEngine, type FieldDefinition, type QuestionDefinition, type QuestionnaireDefinition, type Answers } from "../lib/questionnaire/questionnaire-engine";
import { QuestionnaireRenderer } from "../lib/react/QuestionnaireRenderer";
import { mockSave, mockUpload } from "./mock-persistence";
import { JsonViewer } from "./JsonViewer";
import { SchemaDiagram, type DiagramSelection } from "./SchemaDiagram";
import { ValidationEditor } from "./ValidationEditor";
import starter from "./editor-questionnaire.json";
const basic = starter as QuestionnaireDefinition;
const types: QuestionDefinition["type"][] = ["text", "number", "date", "boolean", "select", "multiselect", "files"];
const json = (value: unknown) => JSON.stringify(value, null, 2);
function Input({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <label className="schema-input"><span>{label}</span><input type="text" value={value} onChange={event => onChange(event.target.value)} /></label>;
}
function MoveButtons({ index, count, move, remove, name }: { index: number; count: number; move: (offset: number) => void; remove: () => void; name: string }) {
return <div className="schema-item-actions">
<button type="button" className="secondary" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(-1)}>↑</button>
<button type="button" className="secondary" aria-label={`Move ${name} down`} disabled={index === count - 1} onClick={() => move(1)}>↓</button>
<button type="button" className="secondary" onClick={remove} aria-label={`Remove ${name}`}>Remove</button>
</div>;
}
function reordered<T>(items: T[], index: number, offset: number) {
const next = [...items];
[next[index], next[index + offset]] = [next[index + offset], next[index]];
return next;
}
function FieldsEditor({ fields, update, newId, selectedIndex }: { fields: FieldDefinition[]; update: (fields: FieldDefinition[]) => void; newId: () => string; selectedIndex?: number }) {
function replace(index: number, field: FieldDefinition) { update(fields.map((item, i) => i === index ? field : item)); }
return <div className="schema-fields">
{fields.map((field, index) => selectedIndex !== undefined && index !== selectedIndex ? null : <div className="schema-field" key={index}>
<div className="schema-section-heading"><strong>{field.type === "group" ? "Group" : `Question ${index + 1}`}</strong>
<MoveButtons index={index} count={fields.length} name={field.type === "group" ? `group ${index + 1}` : field.label || `question ${index + 1}`} move={offset => update(reordered(fields, index, offset))} remove={() => update(fields.filter((_, i) => i !== index))} />
</div>
{field.type === "group" ? <>
<Input label="Group title" value={field.title ?? ""} onChange={title => replace(index, { ...field, title })} />
<Input label="Show when (expression)" value={field.when ?? ""} onChange={when => replace(index, { ...field, when: when || undefined })} />
<FieldsEditor fields={field.fields} update={children => replace(index, { ...field, fields: children })} newId={newId} />
</> : <>
<div className="schema-input-grid">
<Input label="Question ID" value={field.id} onChange={id => replace(index, { ...field, id })} />
<label className="schema-input"><span>Answer type</span><select value={field.type} onChange={event => {
const type = event.target.value as QuestionDefinition["type"];
const { constraints: _constraints, ui: _ui, ...common } = field;
const next = { ...common, type } as QuestionDefinition;
if ("options" in next) delete (next as Partial<typeof next>).options;
if (next.type === "select" || next.type === "multiselect") next.options = "options" in field ? field.options : [{ value: "option1", label: "Option 1" }];
replace(index, next);
}}>{types.map(type => <option key={type}>{type}</option>)}</select></label>
</div>
<Input label="Question label" value={field.label} onChange={label => replace(index, { ...field, label })} />
<Input label="Description" value={field.description ?? ""} onChange={description => replace(index, { ...field, description: description || undefined })} />
<div className="schema-input-grid">
<Input label="Show when (expression)" value={field.when ?? ""} onChange={when => replace(index, { ...field, when: when || undefined })} />
<Input label="Required when (expression)" value={field.requiredWhen ?? ""} onChange={requiredWhen => replace(index, { ...field, requiredWhen: requiredWhen || undefined })} />
</div>
<label className="checkbox-row"><input type="checkbox" checked={field.required ?? false} onChange={event => replace(index, { ...field, required: event.target.checked })} />Required answer</label>
<ValidationEditor field={field} update={next => replace(index, next)} />
{(field.type === "select" || field.type === "multiselect") && <fieldset className="schema-options"><legend>Options</legend>
{field.options.map((option, optionIndex) => <div className="schema-option" key={optionIndex}>
<Input label="Value" value={option.value} onChange={value => replace(index, { ...field, options: field.options.map((item, i) => i === optionIndex ? { ...item, value } : item) })} />
<Input label="Label" value={option.label} onChange={label => replace(index, { ...field, options: field.options.map((item, i) => i === optionIndex ? { ...item, label } : item) })} />
<button type="button" className="secondary" aria-label={`Remove option ${optionIndex + 1}`} onClick={() => replace(index, { ...field, options: field.options.filter((_, i) => i !== optionIndex) })}>×</button>
</div>)}
<button type="button" className="secondary" onClick={() => {
let number = field.options.length + 1;
while (field.options.some(option => option.value === `option${number}`)) number++;
replace(index, { ...field, options: [...field.options, { value: `option${number}`, label: `Option ${number}` }] });
}}>Add option</button>
</fieldset>}
</>}
</div>)}
{selectedIndex === undefined && <div className="schema-actions">
<button type="button" className="secondary" onClick={() => update([...fields, { id: newId(), type: "text", label: "New question" }])}>+ Add question</button>
<button type="button" className="secondary" onClick={() => update([...fields, { type: "group", title: "New group", fields: [] }])}>+ Add group</button>
</div>}
</div>;
}
export function SchemaEditorPage() {
const [definition, setDefinition] = useState<QuestionnaireDefinition>(() => structuredClone(basic));
const [source, setSource] = useState(() => json(basic));
const [tab, setTab] = useState<"Visual" | "JSON">("Visual");
const [selection, setSelection] = useState<DiagramSelection>();
const [playing, setPlaying] = useState<QuestionnaireDefinition>();
const [run, setRun] = useState(0);
const [result, setResult] = useState<{ answers: Answers; outcomes: Record<string, boolean> }>();
const validation = useMemo(() => {
try { return { definition: new QuestionnaireEngine(JSON.parse(source)).definition, error: "" }; }
catch (cause) { return { definition: undefined, error: cause instanceof Error ? cause.message : String(cause) }; }
}, [source]);
function update(next: QuestionnaireDefinition) { setDefinition(next); setSource(json(next)); }
function selectTab(next: "Visual" | "JSON") {
if (next === "Visual") {
if (!validation.definition) return;
setDefinition(validation.definition);
setSelection(undefined);
}
setTab(next);
}
function newId() {
const ids = new Set<string>();
function collect(fields: FieldDefinition[]) { fields.forEach(field => field.type === "group" ? collect(field.fields) : ids.add(field.id)); }
definition.pages.forEach(page => collect(page.fields));
let index = 1;
while (ids.has(`question${index}`)) index++;
return `question${index}`;
}
let content: ReactNode;
if (playing) {
content = <section className="schema-preview" aria-label="Questionnaire preview">
<div className="schema-preview-bar"><span>Preview · {playing.title}</span><div className="schema-actions">
<button className="secondary" onClick={() => { setResult(undefined); setRun(run + 1); }}>Restart</button>
<button className="secondary" onClick={() => setPlaying(undefined)}>Back to editor</button>
</div></div>
{result ? <div className="schema-result"><h2>Questionnaire completed</h2><p>Your preview answers and outcomes:</p><JsonViewer value={result} /></div> : <div className="questionnaire-pane">
<QuestionnaireRenderer key={run} idPrefix="editor-" definition={playing} onSave={mockSave} onUpload={mockUpload} onSubmit={(answers, outcomes) => setResult({ answers, outcomes })} />
</div>}
</section>;
} else content = <div className="schema-editor-card">
<div className="inspector-tabs" role="tablist" aria-label="Schema editor mode">
{(["Visual", "JSON"] as const).map(mode => <button key={mode} type="button" id={`schema-tab-${mode}`} role="tab" aria-controls={`schema-panel-${mode}`} aria-selected={tab === mode} tabIndex={tab === mode ? 0 : -1} disabled={mode === "Visual" && tab === "JSON" && !!validation.error} onClick={() => selectTab(mode)} onKeyDown={event => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? "Visual" : event.key === "End" ? "JSON" : tab === "Visual" ? "JSON" : "Visual";
if (next === "Visual" && !validation.definition) return;
selectTab(next); document.getElementById(`schema-tab-${next}`)?.focus();
}}>{mode}</button>)}
</div>
<div className="schema-validation" role="status">{validation.error ? <><strong>Fix the schema to play{tab === "JSON" ? " or open Visual" : ""}.</strong><pre>{validation.error}</pre></> : <span>✓ Ready to play · {validation.definition!.pages.length} page{validation.definition!.pages.length === 1 ? "" : "s"}</span>}</div>
{tab === "JSON" ? <div role="tabpanel" id="schema-panel-JSON" aria-labelledby="schema-tab-JSON" className="schema-json-panel">
<p>Edit the full schema, including validation rules, routes, derived values, and outcomes.</p>
<textarea aria-label="Questionnaire schema JSON" className="schema-json" value={source} spellCheck={false} onChange={event => setSource(event.target.value)} />
</div> : <div role="tabpanel" id="schema-panel-Visual" aria-labelledby="schema-tab-Visual" className="schema-visual schema-visual-diagram">
<SchemaDiagram definition={definition} selection={selection} onSelect={setSelection} />
<aside className="schema-node-editor" aria-label="Selected node editor">
<button className="secondary" onClick={() => setSelection(undefined)}>Questionnaire details</button>
{!selection && <section className="schema-metadata"><h2>Questionnaire details</h2>
<Input label="Title" value={definition.title} onChange={title => update({ ...definition, title })} />
<div className="schema-input-grid"><Input label="Questionnaire ID" value={definition.id} onChange={id => update({ ...definition, id })} /><Input label="Version" value={definition.version} onChange={version => update({ ...definition, version })} /></div>
<p className="schema-hint">Select a question to edit its validation. Use JSON for routes, derived values, and outcomes. Changing an answer type resets its constraints and display settings.</p>
<p className="schema-hint">Click a page, question, group, or condition in the diagram to edit it.</p>
</section>}
{definition.pages.map((page, index) => {
if (selection?.page !== index) return null;
const updatePage = (next: typeof page) => update({ ...definition, pages: definition.pages.map((item, i) => i === index ? next : item) });
if (selection.path?.length) {
const path = selection.path;
let siblings = page.fields;
for (const part of path.slice(0, -1)) {
const group = siblings[part];
if (group?.type !== "group") return null;
siblings = group.fields;
}
return <section key={index}><h2>Edit question or group</h2><button className="secondary" onClick={() => setSelection({ page: index })}>Page settings</button>
<FieldsEditor fields={siblings} selectedIndex={path.at(-1)} newId={newId} update={fields => {
const next = structuredClone(page);
let parent: { fields: FieldDefinition[] } = next;
for (const part of path.slice(0, -1)) {
const group = parent.fields[part];
if (group.type !== "group") return;
parent = group;
}
parent.fields = fields;
updatePage(next);
if (fields.length !== siblings.length) setSelection({ page: index });
}} />
</section>;
}
return <section className="schema-page-card" key={index} aria-label={`Page ${index + 1}`}>
<div className="schema-section-heading"><h2>Page {index + 1}</h2><MoveButtons index={index} count={definition.pages.length} name={`page ${index + 1}`} move={offset => update({ ...definition, pages: reordered(definition.pages, index, offset) })} remove={() => update({ ...definition, pages: definition.pages.filter((_, i) => i !== index) })} /></div>
<div className="schema-input-grid"><Input label="Page ID" value={page.id} onChange={id => updatePage({ ...page, id })} /><Input label="Page title" value={page.title} onChange={title => updatePage({ ...page, title })} /></div>
<Input label="Page description" value={page.description ?? ""} onChange={description => updatePage({ ...page, description: description || undefined })} />
<Input label="Show page when (expression)" value={page.when ?? ""} onChange={when => updatePage({ ...page, when: when || undefined })} />
<p className="schema-hint">Click a question in the diagram to edit it.</p>
<FieldsEditor fields={[]} update={fields => { updatePage({ ...page, fields: [...page.fields, ...fields] }); setSelection({ page: index, path: [page.fields.length] }); }} newId={newId} />
</section>;
})}
<button className="secondary" onClick={() => {
let number = definition.pages.length + 1;
while (definition.pages.some(page => page.id === `page${number}`)) number++;
update({ ...definition, pages: [...definition.pages, { id: `page${number}`, title: "New page", fields: [] }] });
setSelection({ page: definition.pages.length });
}}>+ Add page</button>
</aside>
</div>}
</div>;
return <div className="schema-editor-page">
<header className="schema-editor-header"><div><p className="eyebrow">Build and try</p><h1 tabIndex={-1}>Questionnaire editor</h1><p>Shape your questions, then play through the journey.</p></div>
{!playing && <button className="primary" disabled={!validation.definition} onClick={() => { setPlaying(validation.definition); setResult(undefined); setRun(run + 1); }}><span aria-hidden="true">▶</span> Play</button>}
</header>
{content}
</div>;
}
+9 -5
View File
@@ -1,9 +1,11 @@
import { useEffect, useState, type ReactNode, type MouseEvent } from "react"; import { useEffect, useState, type ReactNode, type MouseEvent } from "react";
import { DocsPage } from "./DocsPage"; import { DocsPage } from "./DocsPage";
import { SchemaEditorPage } from "./SchemaEditorPage";
export function SiteLayout({ children }: { children: ReactNode }) { export function SiteLayout({ children }: { children: ReactNode }) {
const [path, setPath] = useState(window.location.pathname); const [path, setPath] = useState(window.location.pathname);
const docs = path.replace(/\/$/, "") === "/docs"; const docs = path.replace(/\/$/, "") === "/docs";
const editor = path.replace(/\/$/, "") === "/editor";
useEffect(() => { useEffect(() => {
const sync = () => setPath(window.location.pathname); const sync = () => setPath(window.location.pathname);
@@ -12,11 +14,11 @@ export function SiteLayout({ children }: { children: ReactNode }) {
}, []); }, []);
useEffect(() => { useEffect(() => {
document.title = docs ? "Docs | QuestionGraph" : "QuestionGraph"; document.title = docs ? "Docs | QuestionGraph" : editor ? "Editor | QuestionGraph" : "QuestionGraph";
if (docs && window.location.hash) { if (docs && window.location.hash) {
document.getElementById(decodeURIComponent(window.location.hash.slice(1)))?.scrollIntoView(); document.getElementById(decodeURIComponent(window.location.hash.slice(1)))?.scrollIntoView();
} }
}, [docs]); }, [docs, editor]);
function navigate(event: MouseEvent<HTMLAnchorElement>, target: string) { function navigate(event: MouseEvent<HTMLAnchorElement>, target: string) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
@@ -24,18 +26,20 @@ export function SiteLayout({ children }: { children: ReactNode }) {
window.history.pushState(null, "", target); window.history.pushState(null, "", target);
setPath(target); setPath(target);
window.scrollTo(0, 0); window.scrollTo(0, 0);
requestAnimationFrame(() => document.querySelector<HTMLElement>(target === "/docs" ? ".docs-page h1" : ".questionnaire-pane h1")?.focus({ preventScroll: true })); requestAnimationFrame(() => document.querySelector<HTMLElement>(target === "/docs" ? ".docs-page h1" : target === "/editor" ? ".schema-editor-page h1" : ".questionnaire-pane h1")?.focus({ preventScroll: true }));
} }
return <> return <>
<header className="site-header"> <header className="site-header">
<a className="site-brand" href="/" onClick={(event) => navigate(event, "/")}><span aria-hidden="true">Q</span>QuestionGraph</a> <a className="site-brand" href="/" onClick={(event) => navigate(event, "/")}><span aria-hidden="true">Q</span>QuestionGraph</a>
<nav aria-label="Main navigation"> <nav aria-label="Main navigation">
<a href="/" aria-current={!docs ? "page" : undefined} onClick={(event) => navigate(event, "/")}>Questionnaire</a> <a href="/" aria-current={!docs && !editor ? "page" : undefined} onClick={(event) => navigate(event, "/")}>Questionnaire</a>
<a href="/editor" aria-current={editor ? "page" : undefined} onClick={(event) => navigate(event, "/editor")}>Editor</a>
<a href="/docs" aria-current={docs ? "page" : undefined} onClick={(event) => navigate(event, "/docs")}>Docs</a> <a href="/docs" aria-current={docs ? "page" : undefined} onClick={(event) => navigate(event, "/docs")}>Docs</a>
</nav> </nav>
</header> </header>
<div hidden={docs}>{children}</div> <div hidden={docs || editor}>{children}</div>
<div hidden={!editor}><SchemaEditorPage /></div>
{docs && <DocsPage />} {docs && <DocsPage />}
</>; </>;
} }
+43
View File
@@ -0,0 +1,43 @@
import type { QuestionDefinition } from "../lib/questionnaire/questionnaire-engine";
const constraintFields: Record<QuestionDefinition["type"], [string, string, "number" | "text" | "date" | "checkbox"][]> = {
text: [["minLength", "Minimum length", "number"], ["maxLength", "Maximum length", "number"], ["pattern", "Pattern (regular expression)", "text"]],
number: [["min", "Minimum value", "number"], ["max", "Maximum value", "number"], ["integer", "Whole numbers only", "checkbox"]],
date: [["min", "Earliest date", "date"], ["max", "Latest date", "date"]],
boolean: [["mustBeTrue", "Must be checked / Yes", "checkbox"]],
select: [],
multiselect: [["minItems", "Minimum selections", "number"], ["maxItems", "Maximum selections", "number"]],
files: [["maxFiles", "Maximum files", "number"], ["maxFileSizeMb", "Maximum file size (MB)", "number"], ["allowedExtensions", "Allowed extensions (comma separated)", "text"]],
};
export function ValidationEditor({ field, update }: { field: QuestionDefinition; update: (field: QuestionDefinition) => void }) {
const constraints = (field.constraints ?? {}) as Record<string, unknown>;
function constraint(key: string, value: unknown) {
const next = { ...constraints };
if (value === undefined) delete next[key]; else next[key] = value;
update({ ...field, constraints: Object.keys(next).length ? next : undefined } as QuestionDefinition);
}
const rules = field.validation ?? [];
function rule(index: number, patch: Partial<typeof rules[number]>) {
update({ ...field, validation: rules.map((item, i) => i === index ? { ...item, ...patch } : item) });
}
return <details className="schema-validation-editor" open>
<summary>Validation</summary>
<p className="schema-hint">Set limits for this answer, or add a rule that must evaluate to true. Errors block saving; warnings allow the user to continue.</p>
{constraintFields[field.type].map(([key, label, type]) => type === "checkbox" ? <label className="checkbox-row" key={key}><input type="checkbox" checked={constraints[key] === true} onChange={event => constraint(key, event.target.checked || undefined)} />{label}</label> :
<label className="schema-input" key={key}><span>{label}</span><input type={type} step={key === "maxFileSizeMb" || ((key === "min" || key === "max") && field.type === "number") ? "any" : "1"} value={Array.isArray(constraints[key]) ? (constraints[key] as string[]).join(", ") : String(constraints[key] ?? "")} onChange={event => {
const value = event.target.value;
constraint(key, value === "" ? undefined : type === "number" ? Number(value) : key === "allowedExtensions" ? value.split(",").map(item => item.trim()).filter(Boolean) : value);
}} /></label>)}
<h3>Custom rules</h3>
{rules.map((item, index) => <fieldset className="schema-options" key={index}><legend>Rule {index + 1}</legend>
<label className="schema-input"><span>Rule expression</span><textarea rows={3} value={item.rule} onChange={event => rule(index, { rule: event.target.value })} /></label>
<label className="schema-input"><span>Error or warning message</span><input type="text" value={item.message} onChange={event => rule(index, { message: event.target.value })} /></label>
<label className="schema-input"><span>Only apply when (optional)</span><input type="text" value={item.when ?? ""} onChange={event => rule(index, { when: event.target.value || undefined })} /></label>
<label className="schema-input"><span>Severity</span><select value={item.severity ?? "ERROR"} onChange={event => rule(index, { severity: event.target.value as "ERROR" | "WARNING" })}><option value="ERROR">Error — blocks progress</option><option value="WARNING">Warning — allows progress</option></select></label>
<button className="secondary" onClick={() => update({ ...field, validation: rules.filter((_, i) => i !== index) })}>Remove rule</button>
</fieldset>)}
<button className="secondary" onClick={() => update({ ...field, validation: [...rules, { rule: "true", message: "Please check this answer.", severity: "ERROR" }] })}>+ Add validation rule</button>
<p className="schema-hint">Example: <code>{`answer('${field.id}') !== null`}</code>. Replace <code>true</code> in a new rule with your condition.</p>
</details>;
}
+44
View File
@@ -0,0 +1,44 @@
{
"schemaVersion": 1,
"id": "workshopRegistration",
"version": "1",
"title": "Workshop registration",
"derived": { "inPerson": "answer('attendance') === 'IN_PERSON'" },
"pages": [
{
"id": "aboutYou", "title": "About you",
"fields": [
{ "id": "name", "type": "text", "label": "What is your name?", "required": true, "constraints": { "minLength": 2, "maxLength": 80 } },
{ "id": "dateOfBirth", "type": "date", "label": "When were you born?", "required": true, "validation": [{ "rule": "age(answer('dateOfBirth'), today()) >= 18", "message": "You must be at least 18 to register." }] },
{ "id": "attendance", "type": "select", "label": "How will you attend?", "required": true, "options": [{ "value": "IN_PERSON", "label": "In person" }, { "value": "ONLINE", "label": "Online" }] }
],
"route": { "cases": [{ "when": "answer('attendance') === 'ONLINE'", "to": "interests" }], "otherwise": "visit" }
},
{
"id": "visit", "title": "Plan your visit", "when": "derived('inPerson')",
"fields": [
{ "id": "guests", "type": "number", "label": "How many guests are you bringing?", "required": true, "constraints": { "min": 0, "max": 3, "integer": true } },
{ "id": "needsSupport", "type": "boolean", "label": "Do you need accessibility support?", "required": true },
{ "type": "group", "title": "Accessibility requirements", "when": "answer('needsSupport') === true", "fields": [
{ "id": "supportDetails", "type": "text", "label": "Tell us what support you need", "required": true, "ui": { "widget": "textarea" }, "constraints": { "minLength": 10, "maxLength": 500 } }
] }
]
},
{
"id": "interests", "title": "Choose your sessions",
"fields": [
{ "id": "topics", "type": "multiselect", "label": "Which topics interest you?", "required": true, "constraints": { "minItems": 1, "maxItems": 2 }, "options": [{ "value": "DESIGN", "label": "Design" }, { "value": "DEVELOPMENT", "label": "Development" }, { "value": "RESEARCH", "label": "Research" }] },
{ "id": "experience", "type": "number", "label": "How many years of experience do you have?", "constraints": { "min": 0, "max": 60, "integer": true }, "validation": [{ "rule": "answer('experience') >= 1", "when": "answer('experience') !== null", "severity": "WARNING", "message": "Beginners are welcome; consider the introductory sessions." }] },
{ "id": "attachment", "type": "files", "label": "Optional supporting document", "constraints": { "maxFiles": 1, "maxFileSizeMb": 5, "allowedExtensions": ["pdf", "png"] } }
]
},
{
"id": "confirm", "title": "Confirm your registration",
"fields": [{ "id": "consent", "type": "boolean", "label": "I confirm these details are correct", "required": true, "ui": { "widget": "checkbox" }, "constraints": { "mustBeTrue": true } }]
}
],
"outcomes": {
"sendJoiningLink": { "when": "answer('attendance') === 'ONLINE'" },
"arrangeAccessibility": { "when": "derived('inPerson') && answer('needsSupport') === true" }
}
}
+75 -1
View File
@@ -14,7 +14,78 @@ body { margin: 0; min-width: 320px; min-height: 100vh; }
.site-header nav { display: flex; gap: 5px; } .site-header nav { display: flex; gap: 5px; }
.site-header nav a { padding: 9px 13px; color: #59677e; font-size: 13px; font-weight: 650; text-decoration: none; border-radius: 8px; } .site-header nav a { padding: 9px 13px; color: #59677e; font-size: 13px; font-weight: 650; text-decoration: none; border-radius: 8px; }
.site-header nav a[aria-current="page"] { background: #edf3ff; color: #2457bd; } .site-header nav a[aria-current="page"] { background: #edf3ff; color: #2457bd; }
.site-header a:focus-visible, .docs-page a:focus-visible, .docs-page summary:focus-visible, .docs-page pre:focus-visible, .docs-table-wrap:focus-visible { outline: 2px solid #1f5eff; outline-offset: 3px; } .site-header a:focus-visible, .docs-page a:focus-visible, .docs-page summary:focus-visible, .docs-page pre:focus-visible, .docs-table-wrap:focus-visible { outline: 2px solid #1f5eff; outline-offset: 3px; }
.schema-editor-page { width: min(1000px, calc(100% - 40px)); margin: 42px auto 80px; }
.schema-editor-header, .schema-section-heading, .schema-preview-bar { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.schema-editor-header { margin-bottom: 28px; }
.schema-editor-header h1 { margin: 10px 0; font-size: clamp(26px, 4vw, 38px); letter-spacing: -.035em; }
.schema-editor-header p:last-child, .schema-hint, .schema-json-panel > p { color: #65738b; font-size: 13px; line-height: 1.7; }
.schema-editor-header > button { white-space: nowrap; }
.schema-editor-card, .schema-preview { border: 1px solid #dfe5ee; border-radius: 14px; overflow: hidden; background: white; }
.schema-visual, .schema-json-panel { padding: 24px; }
.schema-validation { padding: 12px 24px; background: #f5f8ff; color: #34578e; border-bottom: 1px solid #dfe5ee; font-size: 12px; }
.schema-validation pre { color: #a22c47; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 200px; overflow: auto; }
.schema-input { display: grid; gap: 7px; margin: 12px 0; min-width: 0; font-size: 12px; font-weight: 600; color: #475671; }
.schema-input input, .schema-input select { width: 100%; min-width: 0; }
.schema-input-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.schema-metadata h2, .schema-page-card h2 { font-size: 17px; margin: 0; }
.schema-page-card { margin: 24px 0; padding: 20px; border: 1px solid #dfe5ee; border-radius: 12px; }
.schema-section-heading { margin-bottom: 12px; }
.schema-section-heading strong { font-size: 13px; }
.schema-item-actions, .schema-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.schema-item-actions button { padding: 7px 10px; font-size: 11px; }
.schema-field { background: #f7f9fc; border: 1px solid #e1e7f0; border-radius: 10px; margin: 16px 0; padding: 16px; }
.schema-field .schema-field { background: #fff; }
.schema-fields > .schema-actions { margin-top: 16px; }
.schema-field .checkbox-row { font-size: 13px; }
.schema-options { margin: 16px 0 0; border: 1px solid #dfe5ee; border-radius: 8px; padding: 12px; }
.schema-options legend { font-size: 12px; color: #475671; }
.schema-option { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: center; }
.schema-option > button { margin-top: 18px; }
.schema-json { width: 100%; min-height: 560px; resize: vertical; border-radius: 10px; background: #111827; color: #e5e7eb; padding: 20px; font: 13px/1.7 ui-monospace, Consolas, monospace; tab-size: 2; }
.schema-preview-bar { padding: 16px 24px; background: #f0f5fc; border-bottom: 1px solid #dfe5ee; font-size: 13px; }
.schema-result { padding: 28px; }
.schema-result .json-code { border-radius: 10px; }
.schema-editor-page button:focus-visible { outline: 2px solid #1f5eff; outline-offset: 3px; }
.schema-editor-page:has(.schema-visual-diagram) { width: min(1600px, calc(100% - 40px)); }
.schema-visual-diagram { display: grid; grid-template-columns: minmax(0, 1fr) 350px; padding: 0; }
.schema-node-editor { padding: 20px; border-left: 1px solid #dfe5ee; max-height: 760px; overflow: auto; }
.schema-node-editor h2 { font-size: 16px; margin: 20px 0 14px; }
.schema-validation-editor { margin: 20px 0; padding-top: 16px; border-top: 1px solid #dfe5ee; }
.schema-validation-editor summary { cursor: pointer; font-weight: 700; font-size: 14px; }
.schema-validation-editor h3 { font-size: 13px; margin-top: 22px; }
.schema-validation-editor > button { margin-top: 14px; }
.schema-validation-editor code { overflow-wrap: anywhere; }
.schema-node-editor .schema-input-grid { grid-template-columns: 1fr; gap: 0; }
.schema-node-editor .schema-page-card, .schema-node-editor .schema-field { padding: 0; border: 0; background: transparent; }
.schema-node-editor .schema-section-heading { flex-wrap: wrap; }
.schema-diagram { min-width: 0; }
.schema-diagram-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #dfe5ee; font-size: 11px; color: #627089; }
.schema-diagram-toolbar .schema-actions { align-items: center; }
.schema-diagram-toolbar button { padding: 7px 10px; font-size: 12px; }
.schema-diagram-viewport { height: 680px; overflow: hidden; position: relative; cursor: grab; touch-action: none; background-color: #f8faff; background-image: radial-gradient(#cdd7e6 1px, transparent 1px); background-size: 20px 20px; }
.schema-diagram-viewport:active { cursor: grabbing; }
.schema-diagram-art { transform-origin: 0 0; user-select: none; }
.schema-diagram-art svg { max-width: none !important; width: 100%; height: auto; }
.schema-diagram-art [data-editor-node] { cursor: pointer; }
.schema-diagram-art [data-editor-node]:focus { outline: 3px solid #6492ff; }
.schema-diagram-art .schema-node-selected > rect, .schema-diagram-art .schema-node-selected > polygon { stroke: #1f5eff !important; stroke-width: 4px !important; }
@media (max-width: 850px) {
.schema-visual-diagram { grid-template-columns: 1fr; }
.schema-node-editor { border-left: 0; border-top: 1px solid #dfe5ee; max-height: none; }
.schema-diagram-viewport { height: 480px; }
}
@media (max-width: 640px) {
.site-header { padding: 12px; flex-wrap: wrap; }
.site-header nav a { padding: 8px; }
.schema-editor-page { width: calc(100% - 24px); margin-top: 24px; }
.schema-visual, .schema-json-panel { padding: 12px; }
.schema-page-card, .schema-field { padding: 12px; }
.schema-input-grid { grid-template-columns: 1fr; gap: 0; }
.schema-section-heading, .schema-preview-bar { flex-wrap: wrap; }
.schema-option { grid-template-columns: 1fr; gap: 0; }
.schema-option > button { margin: 0 0 10px; }
}
.docs-page { display: grid; grid-template-columns: 210px minmax(0, 900px); gap: 64px; width: min(1240px, calc(100% - 64px)); margin: 56px auto 100px; } .docs-page { display: grid; grid-template-columns: 210px minmax(0, 900px); gap: 64px; width: min(1240px, calc(100% - 64px)); margin: 56px auto 100px; }
.docs-toc { position: sticky; top: 32px; align-self: start; } .docs-toc { position: sticky; top: 32px; align-self: start; }
.docs-toc nav { display: grid; gap: 5px; margin-top: 20px; } .docs-toc nav { display: grid; gap: 5px; margin-top: 20px; }
@@ -206,6 +277,9 @@ button:disabled { opacity: .45; cursor: default; }
.graph-rule-detail button { position: absolute; right: 8px; top: 8px; padding: 4px 8px; } .graph-rule-detail button { position: absolute; right: 8px; top: 8px; padding: 4px 8px; }
.save-events-panel > button { margin: 0 16px 12px; padding: 8px 12px; font-size: 12px; } .save-events-panel > button { margin: 0 16px 12px; padding: 8px 12px; font-size: 12px; }
.save-events-list { max-height: 440px; overflow: auto; } .save-events-list { max-height: 440px; overflow: auto; }
.save-events-group { border-top: 1px solid #d6deea; }
.save-events-group h3 { display: flex; align-items: center; gap: 8px; margin: 0; padding: 12px 16px; font-size: 12px; color: #344d76; background: #f7f9fc; }
.save-events-group h3 span { color: #738096; font-size: 11px; font-weight: 400; }
.save-event { padding: 14px 16px 16px 18px; border-top: 1px solid #d6deea; font-size: 12px; position: relative; } .save-event { padding: 14px 16px 16px 18px; border-top: 1px solid #d6deea; font-size: 12px; position: relative; }
.save-event::before { content: ""; position: absolute; left: 24px; top: 0; bottom: 0; border-left: 1px solid #e5eaf2; z-index: 0; } .save-event::before { content: ""; position: absolute; left: 24px; top: 0; bottom: 0; border-left: 1px solid #e5eaf2; z-index: 0; }
.save-event-heading, .save-event-meta, .save-event-changes, .save-event-resume, .save-event-note, .save-event-payload { position: relative; z-index: 1; } .save-event-heading, .save-event-meta, .save-event-changes, .save-event-resume, .save-event-note, .save-event-payload { position: relative; z-index: 1; }
+2 -1
View File
@@ -4,7 +4,8 @@ export { compileQuestionnaire } from "./questionnaire/questionnaire-compiler";
export { compileExpression, evaluateExpression, extractDependencies, FUNCTION_SIGNATURES } from "./questionnaire/dsl"; export { compileExpression, evaluateExpression, extractDependencies, FUNCTION_SIGNATURES } from "./questionnaire/dsl";
export { parseResumeDraft } from "./questionnaire/resume-draft"; export { parseResumeDraft } from "./questionnaire/resume-draft";
export { QuestionnaireRenderer } from "./react/QuestionnaireRenderer"; export { QuestionnaireRenderer } from "./react/QuestionnaireRenderer";
export type { SaveRequest, SaveResponse } from "./persistence/types"; export type { SaveRequest, SaveResponse, TypedAnswers } from "./persistence/types";
export { serializeAnswers } from "./persistence/typed-answers";
export type { SaveEvent } from "./persistence/save-events"; export type { SaveEvent } from "./persistence/save-events";
export type { ResumedDraft } from "./questionnaire/resume-draft"; export type { ResumedDraft } from "./questionnaire/resume-draft";
export type { QuestionnaireComponents, QuestionnaireFieldComponent, QuestionnaireFieldProps, QuestionnaireSaveHandler, FieldAccessibility } from "./react/QuestionnaireRenderer"; export type { QuestionnaireComponents, QuestionnaireFieldComponent, QuestionnaireFieldProps, QuestionnaireSaveHandler, FieldAccessibility } from "./react/QuestionnaireRenderer";
+20 -15
View File
@@ -1,10 +1,12 @@
import { QuestionnaireEngine, type Answers, type QuestionnaireSession, type UploadedFileRef } from "../questionnaire/questionnaire-engine"; import { QuestionnaireEngine, type Answers, type QuestionnaireSession, type UploadedFileRef } from "../questionnaire/questionnaire-engine";
import type { SaveRequest, SaveResponse } from "./types"; import type { SaveRequest, SaveResponse } from "./types";
import { serializeAnswers } from "./typed-answers";
export interface SaveEvent { export interface SaveEvent {
id: number; id: number;
time: string; time: string;
trigger: string; trigger: string;
kind: SaveRequest["kind"];
status: "Saved" | "Changed" | "Blocked"; status: "Saved" | "Changed" | "Blocked";
data?: SaveRequest; data?: SaveRequest;
note?: string; note?: string;
@@ -35,29 +37,31 @@ export class SaveEventRecorder {
return result; return result;
} }
private async patch(changes: Answers, accepted: Answers, trigger: string, nodeId: string, questionId?: string): Promise<boolean> { private async patch(request: SaveRequest, accepted?: Answers): Promise<boolean> {
const request: SaveRequest = { changes: structuredClone(changes), resume: { nodeId, questionId: questionId ?? null }, revision: this.revision, trigger }; const { trigger, kind } = request;
try { try {
if (this.onSave) { if (this.onSave) {
const response = await this.onSave(structuredClone(request)); const response = await this.onSave(structuredClone(request));
if (!Number.isInteger(response.revision) || response.revision < 0) throw new Error("Save handler must return a valid revision."); if (!Number.isInteger(response.revision) || response.revision < 0) throw new Error("Save handler must return a valid revision.");
this.revision = response.revision; this.revision = response.revision;
} }
this.saved = structuredClone(accepted); if (request.kind === "changes" && accepted) this.saved = structuredClone(accepted);
this.position = request.resume; if (request.kind === "focus") this.position = request.resume;
this.record({ trigger, status: this.onSave ? "Saved" : "Changed", data: request }); this.record({ trigger, kind, status: this.onSave ? "Saved" : "Changed", data: request });
return true; return true;
} catch (cause) { } catch (cause) {
this.record({ trigger, status: "Blocked", data: request, note: cause instanceof Error ? cause.message : String(cause) }); this.record({ trigger, kind, status: "Blocked", data: request, note: cause instanceof Error ? cause.message : String(cause) });
return false; return false;
} }
} }
savePosition(nodeId: string, questionId?: string, trigger = "Question focus") { savePosition(nodeId: string, questionId?: string, trigger = "Question focus") {
return this.enqueue(async () => { return this.enqueue(() => this.patchPosition(nodeId, questionId, trigger));
if (this.position.nodeId === nodeId && this.position.questionId === (questionId ?? null)) return true; }
return this.patch({}, this.saved, trigger, nodeId, questionId);
}); private async patchPosition(nodeId: string, questionId?: string, trigger = "Question focus") {
if (this.position.nodeId === nodeId && this.position.questionId === (questionId ?? null)) return true;
return this.patch({ kind: "focus", resume: { nodeId, questionId: questionId ?? null }, revision: this.revision, trigger });
} }
filesSelected(questionId: string, files: UploadedFileRef[], nodeId: string, session: QuestionnaireSession) { filesSelected(questionId: string, files: UploadedFileRef[], nodeId: string, session: QuestionnaireSession) {
@@ -91,13 +95,14 @@ export class SaveEventRecorder {
if (!acceptedSession.isQuestionVisible(id) && (previousSession.isQuestionVisible(id) || accepted[id] != null)) changes[id] = null; if (!acceptedSession.isQuestionVisible(id) && (previousSession.isQuestionVisible(id) || accepted[id] != null)) changes[id] = null;
} }
if (blocked && !Object.keys(changes).length) return false; if (blocked && !Object.keys(changes).length) return false;
if (Object.keys(changes).length || force || this.position.nodeId !== nodeId || this.position.questionId !== (questionId ?? null)) { if (Object.keys(changes).length || force) {
const saved = await this.patch(changes, cleared, trigger, nodeId, questionId); const saved = await this.patch({ kind: "changes", changes: serializeAnswers(this.engine, changes), revision: this.revision, trigger }, cleared);
return saved && !blocked; if (!saved) return false;
} }
return !blocked; const positioned = await this.patchPosition(nodeId, questionId, trigger);
return positioned && !blocked;
}); });
} }
blocked(trigger: string, note: string) { this.record({ trigger, status: "Blocked", note }); } blocked(trigger: string, note: string) { this.record({ trigger, kind: "changes", status: "Blocked", note }); }
} }
+11
View File
@@ -0,0 +1,11 @@
import type { Answers, QuestionnaireEngine } from "../questionnaire/questionnaire-engine";
import type { TypedAnswers } from "./types";
/** Adds schema types at the persistence boundary; the engine uses raw values. */
export function serializeAnswers(engine: QuestionnaireEngine, answers: Answers): TypedAnswers {
return Object.fromEntries(Object.entries(answers).map(([id, value]) => {
const field = engine.compiled.fields[id]?.definition;
if (!field) throw new Error(`Unknown field: ${id}.`);
return [id, { type: field.type, value: structuredClone(value ?? null) }];
}));
}
+12 -4
View File
@@ -1,11 +1,19 @@
import type { Answers } from "../questionnaire/questionnaire-schema"; import type { AnswerValue, QuestionDefinition } from "../questionnaire/questionnaire-schema";
export type TypedAnswers = Record<string, { type: QuestionDefinition["type"]; value: Exclude<AnswerValue, undefined> }>;
export type SaveRequest = { export type SaveRequest = {
changes: Answers;
resume: { nodeId: string; questionId: string | null };
revision: number; revision: number;
trigger: string; trigger: string;
}; } & ({
kind: "changes";
changes: TypedAnswers;
resume?: never;
} | {
kind: "focus";
resume: { nodeId: string; questionId: string | null };
changes?: never;
});
export type SaveResponse = { export type SaveResponse = {
revision: number; revision: number;
+8 -3
View File
@@ -23,9 +23,14 @@ export function parseResumeDraft(text: string, definition: QuestionnaireDefiniti
for (const [id, raw] of Object.entries(draft.answers)) { for (const [id, raw] of Object.entries(draft.answers)) {
const field = engine.compiled.fields[id]?.definition; const field = engine.compiled.fields[id]?.definition;
if (!field) throw new Error(`Unknown field: ${id}.`); if (!field) throw new Error(`Unknown field: ${id}.`);
let value = raw; let value = raw;
if (field.type === "files" && Array.isArray(raw)) { if (object(raw)) {
value = raw.map(file => { if (raw.type !== field.type) throw new Error(`${id}: expected type ${field.type}.`);
if (!Object.prototype.hasOwnProperty.call(raw, "value")) throw new Error(`${id}: the answer needs a value.`);
value = raw.value;
}
if (field.type === "files" && Array.isArray(value)) {
value = value.map(file => {
if (!object(file) || typeof file.uploadId !== "string" || !file.uploadId) throw new Error(`Missing uploadId for ${id}.`); if (!object(file) || typeof file.uploadId !== "string" || !file.uploadId) throw new Error(`Missing uploadId for ${id}.`);
return { uploadId: file.uploadId, name: file.name ?? file.uploadId, size: file.size ?? 0, type: file.type ?? "" }; return { uploadId: file.uploadId, name: file.name ?? file.uploadId, size: file.size ?? 0, type: file.type ?? "" };
}); });
+10 -7
View File
@@ -34,6 +34,8 @@ export type QuestionnaireComponents = Partial<Record<QuestionDefinition["type"],
export type QuestionnaireSaveHandler = (request: SaveRequest) => Promise<SaveResponse> | SaveResponse; export type QuestionnaireSaveHandler = (request: SaveRequest) => Promise<SaveResponse> | SaveResponse;
interface Props { interface Props {
/** Distinguishes DOM IDs when multiple questionnaires are mounted. */
idPrefix?: string;
definition: QuestionnaireDefinition; definition: QuestionnaireDefinition;
initialAnswers?: Answers; initialAnswers?: Answers;
resumedDraft?: ResumedDraft; resumedDraft?: ResumedDraft;
@@ -50,10 +52,11 @@ interface Props {
renderField?: (props: QuestionnaireFieldProps) => ReactNode; renderField?: (props: QuestionnaireFieldProps) => ReactNode;
} }
export function QuestionnaireRenderer({ definition, initialAnswers = {}, resumedDraft, onSubmit, onSave, onUpload, onNodeChange, onQuestionFocus, onAnswersChange, onDraftAnswersChange, onSaveEvent, components, renderField }: Props) { export function QuestionnaireRenderer({ definition, idPrefix = "", initialAnswers = {}, resumedDraft, onSubmit, onSave, onUpload, onNodeChange, onQuestionFocus, onAnswersChange, onDraftAnswersChange, onSaveEvent, components, renderField }: Props) {
const engine = useMemo(() => new QuestionnaireEngine(definition), [definition]); const engine = useMemo(() => new QuestionnaireEngine(definition), [definition]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const busyRef = useRef(false); const busyRef = useRef(false);
const rootRef = useRef<HTMLElement>(null);
const [operationError, setOperationError] = useState(""); const [operationError, setOperationError] = useState("");
const handlers = useRef({ onSave, onSaveEvent }); const handlers = useRef({ onSave, onSaveEvent });
handlers.current = { onSave, onSaveEvent }; handlers.current = { onSave, onSaveEvent };
@@ -74,10 +77,10 @@ export function QuestionnaireRenderer({ definition, initialAnswers = {}, resumed
useEffect(() => { onAnswersChange?.(answers); }, [answers, onAnswersChange]); useEffect(() => { onAnswersChange?.(answers); }, [answers, onAnswersChange]);
useEffect(() => { onDraftAnswersChange?.(draftAnswers); }, [draftAnswers, onDraftAnswersChange]); useEffect(() => { onDraftAnswersChange?.(draftAnswers); }, [draftAnswers, onDraftAnswersChange]);
useEffect(() => { useEffect(() => {
const field = focusTarget ? document.getElementById(`${focusTarget}-question`)?.querySelector<HTMLElement>("input, select, textarea") : undefined; const field = focusTarget ? rootRef.current?.querySelector<HTMLElement>(`[id="${CSS.escape(idPrefix + focusTarget)}-question"]`)?.querySelector<HTMLElement>("input, select, textarea") : undefined;
onQuestionFocus?.(field ? focusTarget : undefined); onQuestionFocus?.(field ? focusTarget : undefined);
(field ?? document.querySelector<HTMLElement>(".questionnaire-pane h1"))?.focus({ preventScroll: true }); (field ?? rootRef.current?.querySelector<HTMLElement>("h1"))?.focus({ preventScroll: true });
}, [currentNode, focusTarget, focusRevision, onQuestionFocus]); }, [currentNode, focusTarget, focusRevision, onQuestionFocus, idPrefix]);
if (!currentNode) return <main className="shell"><h1>No applicable pages</h1><p>This questionnaire has no pages for the current answers.</p></main>; if (!currentNode) return <main className="shell"><h1>No applicable pages</h1><p>This questionnaire has no pages for the current answers.</p></main>;
const state = session.getPageState(currentNode); const state = session.getPageState(currentNode);
const pageHasErrors = session.validateNode(currentNode).some(issue => issue.severity === "ERROR"); const pageHasErrors = session.validateNode(currentNode).some(issue => issue.severity === "ERROR");
@@ -122,13 +125,13 @@ export function QuestionnaireRenderer({ definition, initialAnswers = {}, resumed
const current = evaluated.get(field.id); const current = evaluated.get(field.id);
if (!current) return null; if (!current) return null;
const fieldProps: QuestionnaireFieldProps = { const fieldProps: QuestionnaireFieldProps = {
id: field.id, id: idPrefix + field.id,
question: field, question: field,
value: current.value, value: current.value,
required: current.required, required: current.required,
validation: touched.has(field.id) ? current.validation : [], validation: touched.has(field.id) ? current.validation : [],
accessibility: { accessibility: {
"aria-describedby": `${field.id}-description ${field.id}-errors`, "aria-describedby": `${idPrefix}${field.id}-description ${idPrefix}${field.id}-errors`,
"aria-invalid": touched.has(field.id) && current.validation.some(v => v.severity === "ERROR"), "aria-invalid": touched.has(field.id) && current.validation.some(v => v.severity === "ERROR"),
"aria-required": current.required "aria-required": current.required
}, },
@@ -180,7 +183,7 @@ export function QuestionnaireRenderer({ definition, initialAnswers = {}, resumed
return <Fragment key={field.id}>{renderedField}</Fragment>; return <Fragment key={field.id}>{renderedField}</Fragment>;
}); });
} }
return <main className="shell"> return <main className="shell" ref={rootRef}>
<header className="app-header"><div><p className="eyebrow">{definition.title}</p><h1 tabIndex={-1}>{state.definition.title}</h1>{state.definition.description && <p className="lede">{state.definition.description}</p>}</div><span className="version">v{definition.version}</span></header> <header className="app-header"><div><p className="eyebrow">{definition.title}</p><h1 tabIndex={-1}>{state.definition.title}</h1>{state.definition.description && <p className="lede">{state.definition.description}</p>}</div><span className="version">v{definition.version}</span></header>
<div className="progress" role="progressbar" aria-label="Questionnaire progress" aria-valuenow={Math.round(state.progress)} aria-valuemin={0} aria-valuemax={100}><div style={{ width: `${state.progress}%` }} /></div> <div className="progress" role="progressbar" aria-label="Questionnaire progress" aria-valuenow={Math.round(state.progress)} aria-valuemin={0} aria-valuemax={100}><div style={{ width: `${state.progress}%` }} /></div>
<section className="card"><fieldset disabled={busy} className="question-group">{renderFields(state.definition.fields)}</fieldset> <section className="card"><fieldset disabled={busy} className="question-group">{renderFields(state.definition.fields)}</fieldset>
+72 -1
View File
@@ -4,6 +4,39 @@ import { QuestionnaireEngine } from "../src/lib/questionnaire/questionnaire-engi
import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events"; import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events";
import type { SaveRequest } from "../src/lib/persistence/types"; import type { SaveRequest } from "../src/lib/persistence/types";
import example from "../src/demo/example-questionnaire.json"; import example from "../src/demo/example-questionnaire.json";
import { serializeAnswers } from "../src/lib/persistence/typed-answers";
import { parseResumeDraft } from "../src/lib/questionnaire/resume-draft";
test("typed changes and resume drafts round-trip dates, falsy values, arrays and uploads", async () => {
const definition = {
schemaVersion: 1, id: "typed", version: "1", title: "Typed answers",
pages: [{ id: "first", title: "First", fields: [
{ id: "dateOfBirth", type: "date", label: "Date of birth" },
{ id: "consent", type: "boolean", label: "Consent" },
{ id: "count", type: "number", label: "Count" },
{ id: "notes", type: "text", label: "Notes" },
{ id: "choices", type: "multiselect", label: "Choices", options: [{ value: "a", label: "A" }] },
{ id: "attachment", type: "files", label: "Attachment" },
] }],
};
const engine = new QuestionnaireEngine(definition);
const answers = {
dateOfBirth: "1992-03-04", consent: false, count: 0, notes: null, choices: ["a"],
attachment: [{ uploadId: "upload-1", name: "proof.pdf", size: 10, type: "application/pdf" }],
};
const typed = serializeAnswers(engine, answers);
assert.deepEqual(typed.dateOfBirth, { type: "date", value: "1992-03-04" });
assert.deepEqual(parseResumeDraft(JSON.stringify({ answers: typed }), engine.definition).answers, answers);
const calls: SaveRequest[] = [];
const recorder = new SaveEventRecorder(engine, () => {}, {}, 0, undefined, request => {
calls.push(request); return { revision: request.revision + 1 };
});
await recorder.save(engine.createSession(answers), Object.keys(answers), "Continue", "first");
assert.deepEqual(calls[0].changes, typed);
assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { dateOfBirth: { type: "text", value: "1992-03-04" } } }), engine.definition), /expected type date/);
assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { dateOfBirth: { type: "date" } } }), engine.definition), /needs a value/);
assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { consent: { type: "boolean", value: "yes" } } }), engine.definition), /valid boolean/);
});
test("file saves reject missing upload IDs and preserve real host references", async () => { test("file saves reject missing upload IDs and preserve real host references", async () => {
const fileEngine = new QuestionnaireEngine({ const fileEngine = new QuestionnaireEngine({
@@ -20,7 +53,8 @@ test("file saves reject missing upload IDs and preserve real host references", a
const uploaded = { ...metadata, uploadId: "host-upload-123" }; const uploaded = { ...metadata, uploadId: "host-upload-123" };
const session = fileEngine.createSession({ attachment: [uploaded] }); const session = fileEngine.createSession({ attachment: [uploaded] });
assert.equal(await recorder.filesSelected("attachment", [uploaded], "files", session), true); assert.equal(await recorder.filesSelected("attachment", [uploaded], "files", session), true);
assert.deepEqual(calls[0].changes.attachment, [uploaded]); assert.equal(calls[0].kind, "changes");
assert.deepEqual(calls[0].changes?.attachment, { type: "files", value: [uploaded] });
assert.deepEqual(session.getEffectiveAnswers().attachment, [uploaded]); assert.deepEqual(session.getEffectiveAnswers().attachment, [uploaded]);
}); });
@@ -73,3 +107,40 @@ test("in-memory changes never claim server persistence or advance revisions", as
await recorder.savePosition("registration", "attending"); await recorder.savePosition("registration", "attending");
assert.ok(events.every(event => event.status === "Changed" && event.data?.revision === 0)); assert.ok(events.every(event => event.status === "Changed" && event.data?.revision === 0));
}); });
test("answer and focus requests have separate payloads and share acknowledged revisions", async () => {
const engine = new QuestionnaireEngine(example);
const calls: SaveRequest[] = [];
const events: SaveEvent[] = [];
const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 4, undefined, request => {
calls.push(request);
return { revision: request.revision + 1 };
});
await recorder.savePosition("registration", "fullName");
await recorder.save(engine.createSession({ fullName: "Alex" }), ["fullName"], "Question blur", "registration", false, "fullName");
await recorder.savePosition("registration", "attending");
await recorder.savePosition("registration", "attending");
assert.deepEqual(calls, [
{ kind: "focus", resume: { nodeId: "registration", questionId: "fullName" }, revision: 4, trigger: "Question focus" },
{ kind: "changes", changes: { fullName: { type: "text", value: "Alex" } }, revision: 5, trigger: "Question blur" },
{ kind: "focus", resume: { nodeId: "registration", questionId: "attending" }, revision: 6, trigger: "Question focus" },
]);
assert.deepEqual(events.map(event => event.kind), ["focus", "changes", "focus"]);
});
test("failed focus after an answer save retries only the unacknowledged position", async () => {
const engine = new QuestionnaireEngine(example);
const calls: SaveRequest[] = [];
const events: SaveEvent[] = [];
let failFocus = true;
const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 0, undefined, request => {
calls.push(request);
if (request.kind === "focus" && failFocus) { failFocus = false; throw new Error("Offline"); }
return { revision: request.revision + 1 };
});
const session = engine.createSession({ fullName: "Alex" });
assert.equal(await recorder.save(session, ["fullName"], "Continue", "registration", false, "attending"), false);
assert.equal(await recorder.save(session, ["fullName"], "Continue", "registration", false, "attending"), true);
assert.deepEqual(calls.map(call => [call.kind, call.revision]), [["changes", 0], ["focus", 1], ["focus", 1]]);
assert.deepEqual(events.map(event => [event.kind, event.status]), [["changes", "Saved"], ["focus", "Blocked"], ["focus", "Saved"]]);
});
+14 -7
View File
@@ -174,8 +174,11 @@ test("draft saves clear inactive answers and submit only effective answers", asy
const recorder = new SaveEventRecorder(engine, event => events.push(event), { attending: true, supportRequired: true, supportDetails: "Ramp" }); const recorder = new SaveEventRecorder(engine, event => events.push(event), { attending: true, supportRequired: true, supportDetails: "Ramp" });
const session = engine.createSession({ fullName: "Alex", attending: false, supportRequired: true, supportDetails: "Ramp" }, today); const session = engine.createSession({ fullName: "Alex", attending: false, supportRequired: true, supportDetails: "Ramp" }, today);
await recorder.save(session, ["attending"], "blur", "registration"); await recorder.save(session, ["attending"], "blur", "registration");
const patch = events.at(-1)!.data! as { changes: Answers }; assert.deepEqual(events.at(-1)!.data!.changes, {
assert.deepEqual(patch.changes, { attending: false, supportRequired: null, supportDetails: null }); attending: { type: "boolean", value: false },
supportRequired: { type: "boolean", value: null },
supportDetails: { type: "text", value: null },
});
assert.deepEqual(session.getEffectiveAnswers(), { fullName: "Alex", attending: false }); assert.deepEqual(session.getEffectiveAnswers(), { fullName: "Alex", attending: false });
}); });
test("employment changes clear occupation from live answers and draft patches", async () => { test("employment changes clear occupation from live answers and draft patches", async () => {
@@ -186,12 +189,16 @@ test("employment changes clear occupation from live answers and draft patches",
const events: SaveEvent[] = []; const events: SaveEvent[] = [];
const recorder = new SaveEventRecorder(engine, event => events.push(event), initial); const recorder = new SaveEventRecorder(engine, event => events.push(event), initial);
await recorder.save(engine.createSession(updated, today), ["employmentStatus"], "Question blur", "claimantDetails"); await recorder.save(engine.createSession(updated, today), ["employmentStatus"], "Question blur", "claimantDetails");
assert.deepEqual((events.at(-1)!.data! as { changes: Answers }).changes, { ...updated, unableToWork: null }); assert.deepEqual(events.at(-1)!.data!.changes, {
employmentStatus: { type: "select", value: "UNEMPLOYED" },
occupation: { type: "text", value: null },
unableToWork: { type: "boolean", value: null },
});
const restored = engine.createSession({ ...updated, employmentStatus: "FULL_TIME" }, today); const restored = engine.createSession({ ...updated, employmentStatus: "FULL_TIME" }, today);
assert.equal(restored.answers.occupation, null); assert.equal(restored.answers.occupation, null);
assert.ok(restored.validateQuestion("occupation").some(issue => issue.severity === "ERROR")); assert.ok(restored.validateQuestion("occupation").some(issue => issue.severity === "ERROR"));
await recorder.save(restored, ["employmentStatus"], "Question blur", "claimantDetails"); await recorder.save(restored, ["employmentStatus"], "Question blur", "claimantDetails");
assert.deepEqual((events.at(-1)!.data! as { changes: Answers }).changes, { employmentStatus: "FULL_TIME" }); assert.deepEqual(events.at(-1)!.data!.changes, { employmentStatus: { type: "select", value: "FULL_TIME" } });
}); });
test("newly hidden occupation is explicitly reset even without a previously saved value", async () => { test("newly hidden occupation is explicitly reset even without a previously saved value", async () => {
@@ -203,9 +210,9 @@ test("newly hidden occupation is explicitly reset even without a previously save
const recorder = new SaveEventRecorder(engine, event => events.push(event), initial); const recorder = new SaveEventRecorder(engine, event => events.push(event), initial);
const session = engine.createSession({ employmentStatus: "UNEMPLOYED", occupation: null }, today); const session = engine.createSession({ employmentStatus: "UNEMPLOYED", occupation: null }, today);
await recorder.save(session, ["employmentStatus"], "Question blur", "claimantDetails"); await recorder.save(session, ["employmentStatus"], "Question blur", "claimantDetails");
const changes = (events.at(-1)!.data! as { changes: Answers }).changes; const changes = events.at(-1)!.data!.changes!;
assert.equal(changes.employmentStatus, "UNEMPLOYED"); assert.deepEqual(changes.employmentStatus, { type: "select", value: "UNEMPLOYED" });
assert.equal(changes.occupation, null); assert.deepEqual(changes.occupation, { type: "text", value: null });
} }
}); });