diff --git a/README.md b/README.md
index 15d8f12..20b41ac 100644
--- a/README.md
+++ b/README.md
@@ -175,7 +175,16 @@ Continue/Submit operations. Submission failures are displayed for retry.
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`
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.
The built-in file input requires `onUpload(questionId, files)` for nonempty
diff --git a/src/demo/DocsPage.tsx b/src/demo/DocsPage.tsx
index b7742d8..0537b0d 100644
--- a/src/demo/DocsPage.tsx
+++ b/src/demo/DocsPage.tsx
@@ -41,8 +41,9 @@ export function DocsPage() {
Only text fields are replaced here. All other types keep their default controls.
Save answers
- onSave receives changed answers, the resume position, and the current revision. Send them to your backend and return its new revision.
+ onSave receives separate requests: kind: "changes" carries answer changes, and kind: "focus" carries the resume position. Each includes the current revision. Send it to your backend and return its new revision.
{saveDraftSource}
+ Each changed answer includes its field type and value, for example {'"dateOfBirth": { "type": "date", "value": "1992-03-04" }'}. Resume drafts use the same entries under answers; cleared answers use value: null.
The URL is your choice. Saves run in order. Continue and Submit wait for saving; throw an error to report a failure.
Uploads and submission
void;
}) {
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 [text, setText] = useState(snapshot);
useEffect(() => { setText(snapshot); }, [snapshot]);
diff --git a/src/demo/SaveEventsPanel.tsx b/src/demo/SaveEventsPanel.tsx
index 11a9ed0..05f88d8 100644
--- a/src/demo/SaveEventsPanel.tsx
+++ b/src/demo/SaveEventsPanel.tsx
@@ -13,18 +13,20 @@ function compactValue(value: unknown) {
}
function eventChanges(event: SaveEvent) {
- if (!event.data || !isRecord(event.data)) return [];
+ if (event.data?.kind !== "changes") return [];
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 }) {
return
Save events {events.length} · Preview only
Clear log
-
- {!events.length &&
Edit a question and leave it, or press Continue, to see what would be saved.
}
- {events.map((event) => {
+ {(["changes", "focus"] as const).map(kind =>
+ {kind === "changes" ? "Changes" : "Focus"} {events.filter(event => event.kind === kind).length}
+
+ {!events.some(event => event.kind === kind) &&
{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."}
}
+ {events.filter(event => event.kind === kind).map((event) => {
const changes = eventChanges(event);
const resume = isRecord(event.data) && isRecord(event.data.resume) ? event.data.resume : undefined;
return
@@ -39,10 +41,11 @@ export function SaveEventsPanel({ events, onClear }: { events: SaveEvent[]; onCl
{event.note && {event.note}
}
{event.data &&
View payload
-
+
}
;
})}
-
+
+ )}
;
}
diff --git a/src/demo/SchemaDiagram.tsx b/src/demo/SchemaDiagram.tsx
new file mode 100644
index 0000000..c283408
--- /dev/null
+++ b/src/demo/SchemaDiagram.tsx
@@ -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(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 = {};
+ 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
+ Drag to pan · Scroll to zoom · Click to edit
+ setView(old => ({ ...old, scale: Math.max(.1, old.scale / 1.2) }))} aria-label="Zoom out">− {Math.round(view.scale * 100)}%
+ setView(old => ({ ...old, scale: Math.min(3, old.scale * 1.2) }))} aria-label="Zoom in">+ Fit
+
+ {error && {error}
}
+ {
+ 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 &&
Drawing questionnaire…
}
+
+
+ ;
+}
diff --git a/src/demo/SchemaEditorPage.tsx b/src/demo/SchemaEditorPage.tsx
new file mode 100644
index 0000000..1e938fe
--- /dev/null
+++ b/src/demo/SchemaEditorPage.tsx
@@ -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} onChange(event.target.value)} /> ;
+}
+
+function MoveButtons({ index, count, move, remove, name }: { index: number; count: number; move: (offset: number) => void; remove: () => void; name: string }) {
+ return
+ move(-1)}>↑
+ move(1)}>↓
+ Remove
+
;
+}
+
+function reordered(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
+ {fields.map((field, index) => selectedIndex !== undefined && index !== selectedIndex ? null :
+
{field.type === "group" ? "Group" : `Question ${index + 1}`}
+ update(reordered(fields, index, offset))} remove={() => update(fields.filter((_, i) => i !== index))} />
+
+ {field.type === "group" ? <>
+
replace(index, { ...field, title })} />
+
replace(index, { ...field, when: when || undefined })} />
+
replace(index, { ...field, fields: children })} newId={newId} />
+ > : <>
+
+ replace(index, { ...field, id })} />
+ Answer type {
+ 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).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 => {type} )}
+
+ replace(index, { ...field, label })} />
+ replace(index, { ...field, description: description || undefined })} />
+
+ replace(index, { ...field, when: when || undefined })} />
+ replace(index, { ...field, requiredWhen: requiredWhen || undefined })} />
+
+ replace(index, { ...field, required: event.target.checked })} />Required answer
+ replace(index, next)} />
+ {(field.type === "select" || field.type === "multiselect") && Options
+ {field.options.map((option, optionIndex) =>
+ replace(index, { ...field, options: field.options.map((item, i) => i === optionIndex ? { ...item, value } : item) })} />
+ replace(index, { ...field, options: field.options.map((item, i) => i === optionIndex ? { ...item, label } : item) })} />
+ replace(index, { ...field, options: field.options.filter((_, i) => i !== optionIndex) })}>×
+
)}
+ {
+ 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
+ }
+ >}
+ )}
+ {selectedIndex === undefined &&
+ update([...fields, { id: newId(), type: "text", label: "New question" }])}>+ Add question
+ update([...fields, { type: "group", title: "New group", fields: [] }])}>+ Add group
+
}
+
;
+}
+
+export function SchemaEditorPage() {
+ const [definition, setDefinition] = useState(() => structuredClone(basic));
+ const [source, setSource] = useState(() => json(basic));
+ const [tab, setTab] = useState<"Visual" | "JSON">("Visual");
+ const [selection, setSelection] = useState();
+ const [playing, setPlaying] = useState();
+ const [run, setRun] = useState(0);
+ const [result, setResult] = useState<{ answers: Answers; outcomes: Record }>();
+ 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();
+ 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 =
+ Preview · {playing.title}
+ { setResult(undefined); setRun(run + 1); }}>Restart
+ setPlaying(undefined)}>Back to editor
+
+ {result ? Questionnaire completed Your preview answers and outcomes:
:
+ setResult({ answers, outcomes })} />
+
}
+ ;
+ } else content =
+
+ {(["Visual", "JSON"] as const).map(mode => 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} )}
+
+
{validation.error ? <>
Fix the schema to play{tab === "JSON" ? " or open Visual" : ""}. {validation.error} > :
✓ Ready to play · {validation.definition!.pages.length} page{validation.definition!.pages.length === 1 ? "" : "s"} }
+ {tab === "JSON" ?
+
Edit the full schema, including validation rules, routes, derived values, and outcomes.
+
:
+
+
+ setSelection(undefined)}>Questionnaire details
+ {!selection && }
+ {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 Edit question or group setSelection({ page: index })}>Page settings
+ {
+ 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 });
+ }} />
+ ;
+ }
+ return
+
Page {index + 1} update({ ...definition, pages: reordered(definition.pages, index, offset) })} remove={() => update({ ...definition, pages: definition.pages.filter((_, i) => i !== index) })} />
+ updatePage({ ...page, id })} /> updatePage({ ...page, title })} />
+ updatePage({ ...page, description: description || undefined })} />
+ updatePage({ ...page, when: when || undefined })} />
+ Click a question in the diagram to edit it.
+ { updatePage({ ...page, fields: [...page.fields, ...fields] }); setSelection({ page: index, path: [page.fields.length] }); }} newId={newId} />
+ ;
+ })}
+ {
+ 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
+
+
}
+
;
+ return ;
+}
diff --git a/src/demo/SiteLayout.tsx b/src/demo/SiteLayout.tsx
index 9365022..f47d265 100644
--- a/src/demo/SiteLayout.tsx
+++ b/src/demo/SiteLayout.tsx
@@ -1,9 +1,11 @@
import { useEffect, useState, type ReactNode, type MouseEvent } from "react";
import { DocsPage } from "./DocsPage";
+import { SchemaEditorPage } from "./SchemaEditorPage";
export function SiteLayout({ children }: { children: ReactNode }) {
const [path, setPath] = useState(window.location.pathname);
const docs = path.replace(/\/$/, "") === "/docs";
+ const editor = path.replace(/\/$/, "") === "/editor";
useEffect(() => {
const sync = () => setPath(window.location.pathname);
@@ -12,11 +14,11 @@ export function SiteLayout({ children }: { children: ReactNode }) {
}, []);
useEffect(() => {
- document.title = docs ? "Docs | QuestionGraph" : "QuestionGraph";
+ document.title = docs ? "Docs | QuestionGraph" : editor ? "Editor | QuestionGraph" : "QuestionGraph";
if (docs && window.location.hash) {
document.getElementById(decodeURIComponent(window.location.hash.slice(1)))?.scrollIntoView();
}
- }, [docs]);
+ }, [docs, editor]);
function navigate(event: MouseEvent, target: string) {
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);
setPath(target);
window.scrollTo(0, 0);
- requestAnimationFrame(() => document.querySelector(target === "/docs" ? ".docs-page h1" : ".questionnaire-pane h1")?.focus({ preventScroll: true }));
+ requestAnimationFrame(() => document.querySelector(target === "/docs" ? ".docs-page h1" : target === "/editor" ? ".schema-editor-page h1" : ".questionnaire-pane h1")?.focus({ preventScroll: true }));
}
return <>
- {children}
+ {children}
+
{docs && }
>;
}
diff --git a/src/demo/ValidationEditor.tsx b/src/demo/ValidationEditor.tsx
new file mode 100644
index 0000000..f37c37f
--- /dev/null
+++ b/src/demo/ValidationEditor.tsx
@@ -0,0 +1,43 @@
+import type { QuestionDefinition } from "../lib/questionnaire/questionnaire-engine";
+
+const constraintFields: Record = {
+ 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;
+ 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) {
+ update({ ...field, validation: rules.map((item, i) => i === index ? { ...item, ...patch } : item) });
+ }
+ return
+ Validation
+ Set limits for this answer, or add a rule that must evaluate to true. Errors block saving; warnings allow the user to continue.
+ {constraintFields[field.type].map(([key, label, type]) => type === "checkbox" ? constraint(key, event.target.checked || undefined)} />{label} :
+ {label} {
+ const value = event.target.value;
+ constraint(key, value === "" ? undefined : type === "number" ? Number(value) : key === "allowedExtensions" ? value.split(",").map(item => item.trim()).filter(Boolean) : value);
+ }} /> )}
+ Custom rules
+ {rules.map((item, index) => Rule {index + 1}
+ Rule expression
+ Error or warning message rule(index, { message: event.target.value })} />
+ Only apply when (optional) rule(index, { when: event.target.value || undefined })} />
+ Severity rule(index, { severity: event.target.value as "ERROR" | "WARNING" })}>Error — blocks progress Warning — allows progress
+ update({ ...field, validation: rules.filter((_, i) => i !== index) })}>Remove rule
+ )}
+ update({ ...field, validation: [...rules, { rule: "true", message: "Please check this answer.", severity: "ERROR" }] })}>+ Add validation rule
+ Example: {`answer('${field.id}') !== null`}. Replace true in a new rule with your condition.
+ ;
+}
diff --git a/src/demo/editor-questionnaire.json b/src/demo/editor-questionnaire.json
new file mode 100644
index 0000000..56dab30
--- /dev/null
+++ b/src/demo/editor-questionnaire.json
@@ -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" }
+ }
+}
diff --git a/src/demo/styles.css b/src/demo/styles.css
index 5de2dea..faf8d53 100644
--- a/src/demo/styles.css
+++ b/src/demo/styles.css
@@ -14,7 +14,78 @@ body { margin: 0; min-width: 320px; min-height: 100vh; }
.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[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-toc { position: sticky; top: 32px; align-self: start; }
.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; }
.save-events-panel > button { margin: 0 16px 12px; padding: 8px 12px; font-size: 12px; }
.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::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; }
diff --git a/src/lib/index.ts b/src/lib/index.ts
index db20471..b7d4cee 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -4,7 +4,8 @@ export { compileQuestionnaire } from "./questionnaire/questionnaire-compiler";
export { compileExpression, evaluateExpression, extractDependencies, FUNCTION_SIGNATURES } from "./questionnaire/dsl";
export { parseResumeDraft } from "./questionnaire/resume-draft";
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 { ResumedDraft } from "./questionnaire/resume-draft";
export type { QuestionnaireComponents, QuestionnaireFieldComponent, QuestionnaireFieldProps, QuestionnaireSaveHandler, FieldAccessibility } from "./react/QuestionnaireRenderer";
diff --git a/src/lib/persistence/save-events.ts b/src/lib/persistence/save-events.ts
index d6d45f5..1cd34a3 100644
--- a/src/lib/persistence/save-events.ts
+++ b/src/lib/persistence/save-events.ts
@@ -1,10 +1,12 @@
import { QuestionnaireEngine, type Answers, type QuestionnaireSession, type UploadedFileRef } from "../questionnaire/questionnaire-engine";
import type { SaveRequest, SaveResponse } from "./types";
+import { serializeAnswers } from "./typed-answers";
export interface SaveEvent {
id: number;
time: string;
trigger: string;
+ kind: SaveRequest["kind"];
status: "Saved" | "Changed" | "Blocked";
data?: SaveRequest;
note?: string;
@@ -35,29 +37,31 @@ export class SaveEventRecorder {
return result;
}
- private async patch(changes: Answers, accepted: Answers, trigger: string, nodeId: string, questionId?: string): Promise {
- const request: SaveRequest = { changes: structuredClone(changes), resume: { nodeId, questionId: questionId ?? null }, revision: this.revision, trigger };
+ private async patch(request: SaveRequest, accepted?: Answers): Promise {
+ const { trigger, kind } = request;
try {
if (this.onSave) {
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.");
this.revision = response.revision;
}
- this.saved = structuredClone(accepted);
- this.position = request.resume;
- this.record({ trigger, status: this.onSave ? "Saved" : "Changed", data: request });
+ if (request.kind === "changes" && accepted) this.saved = structuredClone(accepted);
+ if (request.kind === "focus") this.position = request.resume;
+ this.record({ trigger, kind, status: this.onSave ? "Saved" : "Changed", data: request });
return true;
} 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;
}
}
savePosition(nodeId: string, questionId?: string, trigger = "Question focus") {
- return this.enqueue(async () => {
- if (this.position.nodeId === nodeId && this.position.questionId === (questionId ?? null)) return true;
- return this.patch({}, this.saved, trigger, nodeId, questionId);
- });
+ return this.enqueue(() => this.patchPosition(nodeId, questionId, trigger));
+ }
+
+ 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) {
@@ -91,13 +95,14 @@ export class SaveEventRecorder {
if (!acceptedSession.isQuestionVisible(id) && (previousSession.isQuestionVisible(id) || accepted[id] != null)) changes[id] = null;
}
if (blocked && !Object.keys(changes).length) return false;
- if (Object.keys(changes).length || force || this.position.nodeId !== nodeId || this.position.questionId !== (questionId ?? null)) {
- const saved = await this.patch(changes, cleared, trigger, nodeId, questionId);
- return saved && !blocked;
+ if (Object.keys(changes).length || force) {
+ const saved = await this.patch({ kind: "changes", changes: serializeAnswers(this.engine, changes), revision: this.revision, trigger }, cleared);
+ 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 }); }
}
diff --git a/src/lib/persistence/typed-answers.ts b/src/lib/persistence/typed-answers.ts
new file mode 100644
index 0000000..7a9f999
--- /dev/null
+++ b/src/lib/persistence/typed-answers.ts
@@ -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) }];
+ }));
+}
diff --git a/src/lib/persistence/types.ts b/src/lib/persistence/types.ts
index d043327..3c33f43 100644
--- a/src/lib/persistence/types.ts
+++ b/src/lib/persistence/types.ts
@@ -1,11 +1,19 @@
-import type { Answers } from "../questionnaire/questionnaire-schema";
+import type { AnswerValue, QuestionDefinition } from "../questionnaire/questionnaire-schema";
+
+export type TypedAnswers = Record }>;
export type SaveRequest = {
- changes: Answers;
- resume: { nodeId: string; questionId: string | null };
revision: number;
trigger: string;
-};
+} & ({
+ kind: "changes";
+ changes: TypedAnswers;
+ resume?: never;
+} | {
+ kind: "focus";
+ resume: { nodeId: string; questionId: string | null };
+ changes?: never;
+});
export type SaveResponse = {
revision: number;
diff --git a/src/lib/questionnaire/resume-draft.ts b/src/lib/questionnaire/resume-draft.ts
index 22920e2..611e509 100644
--- a/src/lib/questionnaire/resume-draft.ts
+++ b/src/lib/questionnaire/resume-draft.ts
@@ -23,9 +23,14 @@ export function parseResumeDraft(text: string, definition: QuestionnaireDefiniti
for (const [id, raw] of Object.entries(draft.answers)) {
const field = engine.compiled.fields[id]?.definition;
if (!field) throw new Error(`Unknown field: ${id}.`);
- let value = raw;
- if (field.type === "files" && Array.isArray(raw)) {
- value = raw.map(file => {
+ let value = raw;
+ if (object(raw)) {
+ 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}.`);
return { uploadId: file.uploadId, name: file.name ?? file.uploadId, size: file.size ?? 0, type: file.type ?? "" };
});
diff --git a/src/lib/react/QuestionnaireRenderer.tsx b/src/lib/react/QuestionnaireRenderer.tsx
index 48f9419..6a125e1 100644
--- a/src/lib/react/QuestionnaireRenderer.tsx
+++ b/src/lib/react/QuestionnaireRenderer.tsx
@@ -34,6 +34,8 @@ export type QuestionnaireComponents = Partial Promise | SaveResponse;
interface Props {
+ /** Distinguishes DOM IDs when multiple questionnaires are mounted. */
+ idPrefix?: string;
definition: QuestionnaireDefinition;
initialAnswers?: Answers;
resumedDraft?: ResumedDraft;
@@ -50,10 +52,11 @@ interface Props {
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 [busy, setBusy] = useState(false);
const busyRef = useRef(false);
+ const rootRef = useRef(null);
const [operationError, setOperationError] = useState("");
const handlers = useRef({ onSave, onSaveEvent });
handlers.current = { onSave, onSaveEvent };
@@ -74,10 +77,10 @@ export function QuestionnaireRenderer({ definition, initialAnswers = {}, resumed
useEffect(() => { onAnswersChange?.(answers); }, [answers, onAnswersChange]);
useEffect(() => { onDraftAnswersChange?.(draftAnswers); }, [draftAnswers, onDraftAnswersChange]);
useEffect(() => {
- const field = focusTarget ? document.getElementById(`${focusTarget}-question`)?.querySelector("input, select, textarea") : undefined;
+ const field = focusTarget ? rootRef.current?.querySelector(`[id="${CSS.escape(idPrefix + focusTarget)}-question"]`)?.querySelector("input, select, textarea") : undefined;
onQuestionFocus?.(field ? focusTarget : undefined);
- (field ?? document.querySelector(".questionnaire-pane h1"))?.focus({ preventScroll: true });
- }, [currentNode, focusTarget, focusRevision, onQuestionFocus]);
+ (field ?? rootRef.current?.querySelector("h1"))?.focus({ preventScroll: true });
+ }, [currentNode, focusTarget, focusRevision, onQuestionFocus, idPrefix]);
if (!currentNode) return No applicable pages This questionnaire has no pages for the current answers.
;
const state = session.getPageState(currentNode);
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);
if (!current) return null;
const fieldProps: QuestionnaireFieldProps = {
- id: field.id,
+ id: idPrefix + field.id,
question: field,
value: current.value,
required: current.required,
validation: touched.has(field.id) ? current.validation : [],
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-required": current.required
},
@@ -180,7 +183,7 @@ export function QuestionnaireRenderer({ definition, initialAnswers = {}, resumed
return {renderedField} ;
});
}
- return
+ return
{renderFields(state.definition.fields)}
diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts
index e126686..3f9efb7 100644
--- a/tests/persistence.test.ts
+++ b/tests/persistence.test.ts
@@ -4,6 +4,39 @@ import { QuestionnaireEngine } from "../src/lib/questionnaire/questionnaire-engi
import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events";
import type { SaveRequest } from "../src/lib/persistence/types";
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 () => {
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 session = fileEngine.createSession({ attachment: [uploaded] });
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]);
});
@@ -73,3 +107,40 @@ test("in-memory changes never claim server persistence or advance revisions", as
await recorder.savePosition("registration", "attending");
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"]]);
+});
diff --git a/tests/questionnaire.test.ts b/tests/questionnaire.test.ts
index 835d2ff..7081481 100644
--- a/tests/questionnaire.test.ts
+++ b/tests/questionnaire.test.ts
@@ -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 session = engine.createSession({ fullName: "Alex", attending: false, supportRequired: true, supportDetails: "Ramp" }, today);
await recorder.save(session, ["attending"], "blur", "registration");
- const patch = events.at(-1)!.data! as { changes: Answers };
- assert.deepEqual(patch.changes, { attending: false, supportRequired: null, supportDetails: null });
+ assert.deepEqual(events.at(-1)!.data!.changes, {
+ attending: { type: "boolean", value: false },
+ supportRequired: { type: "boolean", value: null },
+ supportDetails: { type: "text", value: null },
+ });
assert.deepEqual(session.getEffectiveAnswers(), { fullName: "Alex", attending: false });
});
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 recorder = new SaveEventRecorder(engine, event => events.push(event), initial);
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);
assert.equal(restored.answers.occupation, null);
assert.ok(restored.validateQuestion("occupation").some(issue => issue.severity === "ERROR"));
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 () => {
@@ -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 session = engine.createSession({ employmentStatus: "UNEMPLOYED", occupation: null }, today);
await recorder.save(session, ["employmentStatus"], "Question blur", "claimantDetails");
- const changes = (events.at(-1)!.data! as { changes: Answers }).changes;
- assert.equal(changes.employmentStatus, "UNEMPLOYED");
- assert.equal(changes.occupation, null);
+ const changes = events.at(-1)!.data!.changes!;
+ assert.deepEqual(changes.employmentStatus, { type: "select", value: "UNEMPLOYED" });
+ assert.deepEqual(changes.occupation, { type: "text", value: null });
}
});