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 -
- {!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
+ {Math.round(view.scale * 100)}% + +
+ {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 ; +} + +function MoveButtons({ index, count, move, remove, name }: { index: number; count: number; move: (offset: number) => void; remove: () => void; name: string }) { + return
+ + + +
; +} + +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 })} /> + +
+ replace(index, { ...field, label })} /> + replace(index, { ...field, description: description || undefined })} /> +
+ replace(index, { ...field, when: when || undefined })} /> + replace(index, { ...field, requiredWhen: requiredWhen || undefined })} /> +
+ + 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) })} /> + +
)} + +
} + } +
)} + {selectedIndex === undefined &&
+ + +
} +
; +} + +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}
+ + +
+ {result ?

Questionnaire completed

Your preview answers and outcomes:

:
+ setResult({ answers, outcomes })} /> +
} +
; + } else content =
+
+ {(["Visual", "JSON"] as const).map(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.

+