Add interactive questionnaire editor and typed save payloads
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,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]);
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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 />}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,77 @@ body { margin: 0; min-width: 320px; min-height: 100vh; }
|
|||||||
.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
@@ -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";
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }];
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -24,8 +24,13 @@ export function parseResumeDraft(text: string, definition: QuestionnaireDefiniti
|
|||||||
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 ?? "" };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"]]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user