Add interactive questionnaire editor and typed save payloads

This commit is contained in:
Peter Stockings
2026-09-28 10:02:45 +10:00
parent 0879a3e786
commit 834d07d9bc
18 changed files with 687 additions and 54 deletions
+199
View File
@@ -0,0 +1,199 @@
import { useMemo, useState, type ReactNode } from "react";
import { QuestionnaireEngine, type FieldDefinition, type QuestionDefinition, type QuestionnaireDefinition, type Answers } from "../lib/questionnaire/questionnaire-engine";
import { QuestionnaireRenderer } from "../lib/react/QuestionnaireRenderer";
import { mockSave, mockUpload } from "./mock-persistence";
import { JsonViewer } from "./JsonViewer";
import { SchemaDiagram, type DiagramSelection } from "./SchemaDiagram";
import { ValidationEditor } from "./ValidationEditor";
import starter from "./editor-questionnaire.json";
const basic = starter as QuestionnaireDefinition;
const types: QuestionDefinition["type"][] = ["text", "number", "date", "boolean", "select", "multiselect", "files"];
const json = (value: unknown) => JSON.stringify(value, null, 2);
function Input({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <label className="schema-input"><span>{label}</span><input type="text" value={value} onChange={event => onChange(event.target.value)} /></label>;
}
function MoveButtons({ index, count, move, remove, name }: { index: number; count: number; move: (offset: number) => void; remove: () => void; name: string }) {
return <div className="schema-item-actions">
<button type="button" className="secondary" aria-label={`Move ${name} up`} disabled={index === 0} onClick={() => move(-1)}>↑</button>
<button type="button" className="secondary" aria-label={`Move ${name} down`} disabled={index === count - 1} onClick={() => move(1)}>↓</button>
<button type="button" className="secondary" onClick={remove} aria-label={`Remove ${name}`}>Remove</button>
</div>;
}
function reordered<T>(items: T[], index: number, offset: number) {
const next = [...items];
[next[index], next[index + offset]] = [next[index + offset], next[index]];
return next;
}
function FieldsEditor({ fields, update, newId, selectedIndex }: { fields: FieldDefinition[]; update: (fields: FieldDefinition[]) => void; newId: () => string; selectedIndex?: number }) {
function replace(index: number, field: FieldDefinition) { update(fields.map((item, i) => i === index ? field : item)); }
return <div className="schema-fields">
{fields.map((field, index) => selectedIndex !== undefined && index !== selectedIndex ? null : <div className="schema-field" key={index}>
<div className="schema-section-heading"><strong>{field.type === "group" ? "Group" : `Question ${index + 1}`}</strong>
<MoveButtons index={index} count={fields.length} name={field.type === "group" ? `group ${index + 1}` : field.label || `question ${index + 1}`} move={offset => update(reordered(fields, index, offset))} remove={() => update(fields.filter((_, i) => i !== index))} />
</div>
{field.type === "group" ? <>
<Input label="Group title" value={field.title ?? ""} onChange={title => replace(index, { ...field, title })} />
<Input label="Show when (expression)" value={field.when ?? ""} onChange={when => replace(index, { ...field, when: when || undefined })} />
<FieldsEditor fields={field.fields} update={children => replace(index, { ...field, fields: children })} newId={newId} />
</> : <>
<div className="schema-input-grid">
<Input label="Question ID" value={field.id} onChange={id => replace(index, { ...field, id })} />
<label className="schema-input"><span>Answer type</span><select value={field.type} onChange={event => {
const type = event.target.value as QuestionDefinition["type"];
const { constraints: _constraints, ui: _ui, ...common } = field;
const next = { ...common, type } as QuestionDefinition;
if ("options" in next) delete (next as Partial<typeof next>).options;
if (next.type === "select" || next.type === "multiselect") next.options = "options" in field ? field.options : [{ value: "option1", label: "Option 1" }];
replace(index, next);
}}>{types.map(type => <option key={type}>{type}</option>)}</select></label>
</div>
<Input label="Question label" value={field.label} onChange={label => replace(index, { ...field, label })} />
<Input label="Description" value={field.description ?? ""} onChange={description => replace(index, { ...field, description: description || undefined })} />
<div className="schema-input-grid">
<Input label="Show when (expression)" value={field.when ?? ""} onChange={when => replace(index, { ...field, when: when || undefined })} />
<Input label="Required when (expression)" value={field.requiredWhen ?? ""} onChange={requiredWhen => replace(index, { ...field, requiredWhen: requiredWhen || undefined })} />
</div>
<label className="checkbox-row"><input type="checkbox" checked={field.required ?? false} onChange={event => replace(index, { ...field, required: event.target.checked })} />Required answer</label>
<ValidationEditor field={field} update={next => replace(index, next)} />
{(field.type === "select" || field.type === "multiselect") && <fieldset className="schema-options"><legend>Options</legend>
{field.options.map((option, optionIndex) => <div className="schema-option" key={optionIndex}>
<Input label="Value" value={option.value} onChange={value => replace(index, { ...field, options: field.options.map((item, i) => i === optionIndex ? { ...item, value } : item) })} />
<Input label="Label" value={option.label} onChange={label => replace(index, { ...field, options: field.options.map((item, i) => i === optionIndex ? { ...item, label } : item) })} />
<button type="button" className="secondary" aria-label={`Remove option ${optionIndex + 1}`} onClick={() => replace(index, { ...field, options: field.options.filter((_, i) => i !== optionIndex) })}>×</button>
</div>)}
<button type="button" className="secondary" onClick={() => {
let number = field.options.length + 1;
while (field.options.some(option => option.value === `option${number}`)) number++;
replace(index, { ...field, options: [...field.options, { value: `option${number}`, label: `Option ${number}` }] });
}}>Add option</button>
</fieldset>}
</>}
</div>)}
{selectedIndex === undefined && <div className="schema-actions">
<button type="button" className="secondary" onClick={() => update([...fields, { id: newId(), type: "text", label: "New question" }])}>+ Add question</button>
<button type="button" className="secondary" onClick={() => update([...fields, { type: "group", title: "New group", fields: [] }])}>+ Add group</button>
</div>}
</div>;
}
export function SchemaEditorPage() {
const [definition, setDefinition] = useState<QuestionnaireDefinition>(() => structuredClone(basic));
const [source, setSource] = useState(() => json(basic));
const [tab, setTab] = useState<"Visual" | "JSON">("Visual");
const [selection, setSelection] = useState<DiagramSelection>();
const [playing, setPlaying] = useState<QuestionnaireDefinition>();
const [run, setRun] = useState(0);
const [result, setResult] = useState<{ answers: Answers; outcomes: Record<string, boolean> }>();
const validation = useMemo(() => {
try { return { definition: new QuestionnaireEngine(JSON.parse(source)).definition, error: "" }; }
catch (cause) { return { definition: undefined, error: cause instanceof Error ? cause.message : String(cause) }; }
}, [source]);
function update(next: QuestionnaireDefinition) { setDefinition(next); setSource(json(next)); }
function selectTab(next: "Visual" | "JSON") {
if (next === "Visual") {
if (!validation.definition) return;
setDefinition(validation.definition);
setSelection(undefined);
}
setTab(next);
}
function newId() {
const ids = new Set<string>();
function collect(fields: FieldDefinition[]) { fields.forEach(field => field.type === "group" ? collect(field.fields) : ids.add(field.id)); }
definition.pages.forEach(page => collect(page.fields));
let index = 1;
while (ids.has(`question${index}`)) index++;
return `question${index}`;
}
let content: ReactNode;
if (playing) {
content = <section className="schema-preview" aria-label="Questionnaire preview">
<div className="schema-preview-bar"><span>Preview · {playing.title}</span><div className="schema-actions">
<button className="secondary" onClick={() => { setResult(undefined); setRun(run + 1); }}>Restart</button>
<button className="secondary" onClick={() => setPlaying(undefined)}>Back to editor</button>
</div></div>
{result ? <div className="schema-result"><h2>Questionnaire completed</h2><p>Your preview answers and outcomes:</p><JsonViewer value={result} /></div> : <div className="questionnaire-pane">
<QuestionnaireRenderer key={run} idPrefix="editor-" definition={playing} onSave={mockSave} onUpload={mockUpload} onSubmit={(answers, outcomes) => setResult({ answers, outcomes })} />
</div>}
</section>;
} else content = <div className="schema-editor-card">
<div className="inspector-tabs" role="tablist" aria-label="Schema editor mode">
{(["Visual", "JSON"] as const).map(mode => <button key={mode} type="button" id={`schema-tab-${mode}`} role="tab" aria-controls={`schema-panel-${mode}`} aria-selected={tab === mode} tabIndex={tab === mode ? 0 : -1} disabled={mode === "Visual" && tab === "JSON" && !!validation.error} onClick={() => selectTab(mode)} onKeyDown={event => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? "Visual" : event.key === "End" ? "JSON" : tab === "Visual" ? "JSON" : "Visual";
if (next === "Visual" && !validation.definition) return;
selectTab(next); document.getElementById(`schema-tab-${next}`)?.focus();
}}>{mode}</button>)}
</div>
<div className="schema-validation" role="status">{validation.error ? <><strong>Fix the schema to play{tab === "JSON" ? " or open Visual" : ""}.</strong><pre>{validation.error}</pre></> : <span>✓ Ready to play · {validation.definition!.pages.length} page{validation.definition!.pages.length === 1 ? "" : "s"}</span>}</div>
{tab === "JSON" ? <div role="tabpanel" id="schema-panel-JSON" aria-labelledby="schema-tab-JSON" className="schema-json-panel">
<p>Edit the full schema, including validation rules, routes, derived values, and outcomes.</p>
<textarea aria-label="Questionnaire schema JSON" className="schema-json" value={source} spellCheck={false} onChange={event => setSource(event.target.value)} />
</div> : <div role="tabpanel" id="schema-panel-Visual" aria-labelledby="schema-tab-Visual" className="schema-visual schema-visual-diagram">
<SchemaDiagram definition={definition} selection={selection} onSelect={setSelection} />
<aside className="schema-node-editor" aria-label="Selected node editor">
<button className="secondary" onClick={() => setSelection(undefined)}>Questionnaire details</button>
{!selection && <section className="schema-metadata"><h2>Questionnaire details</h2>
<Input label="Title" value={definition.title} onChange={title => update({ ...definition, title })} />
<div className="schema-input-grid"><Input label="Questionnaire ID" value={definition.id} onChange={id => update({ ...definition, id })} /><Input label="Version" value={definition.version} onChange={version => update({ ...definition, version })} /></div>
<p className="schema-hint">Select a question to edit its validation. Use JSON for routes, derived values, and outcomes. Changing an answer type resets its constraints and display settings.</p>
<p className="schema-hint">Click a page, question, group, or condition in the diagram to edit it.</p>
</section>}
{definition.pages.map((page, index) => {
if (selection?.page !== index) return null;
const updatePage = (next: typeof page) => update({ ...definition, pages: definition.pages.map((item, i) => i === index ? next : item) });
if (selection.path?.length) {
const path = selection.path;
let siblings = page.fields;
for (const part of path.slice(0, -1)) {
const group = siblings[part];
if (group?.type !== "group") return null;
siblings = group.fields;
}
return <section key={index}><h2>Edit question or group</h2><button className="secondary" onClick={() => setSelection({ page: index })}>Page settings</button>
<FieldsEditor fields={siblings} selectedIndex={path.at(-1)} newId={newId} update={fields => {
const next = structuredClone(page);
let parent: { fields: FieldDefinition[] } = next;
for (const part of path.slice(0, -1)) {
const group = parent.fields[part];
if (group.type !== "group") return;
parent = group;
}
parent.fields = fields;
updatePage(next);
if (fields.length !== siblings.length) setSelection({ page: index });
}} />
</section>;
}
return <section className="schema-page-card" key={index} aria-label={`Page ${index + 1}`}>
<div className="schema-section-heading"><h2>Page {index + 1}</h2><MoveButtons index={index} count={definition.pages.length} name={`page ${index + 1}`} move={offset => update({ ...definition, pages: reordered(definition.pages, index, offset) })} remove={() => update({ ...definition, pages: definition.pages.filter((_, i) => i !== index) })} /></div>
<div className="schema-input-grid"><Input label="Page ID" value={page.id} onChange={id => updatePage({ ...page, id })} /><Input label="Page title" value={page.title} onChange={title => updatePage({ ...page, title })} /></div>
<Input label="Page description" value={page.description ?? ""} onChange={description => updatePage({ ...page, description: description || undefined })} />
<Input label="Show page when (expression)" value={page.when ?? ""} onChange={when => updatePage({ ...page, when: when || undefined })} />
<p className="schema-hint">Click a question in the diagram to edit it.</p>
<FieldsEditor fields={[]} update={fields => { updatePage({ ...page, fields: [...page.fields, ...fields] }); setSelection({ page: index, path: [page.fields.length] }); }} newId={newId} />
</section>;
})}
<button className="secondary" onClick={() => {
let number = definition.pages.length + 1;
while (definition.pages.some(page => page.id === `page${number}`)) number++;
update({ ...definition, pages: [...definition.pages, { id: `page${number}`, title: "New page", fields: [] }] });
setSelection({ page: definition.pages.length });
}}>+ Add page</button>
</aside>
</div>}
</div>;
return <div className="schema-editor-page">
<header className="schema-editor-header"><div><p className="eyebrow">Build and try</p><h1 tabIndex={-1}>Questionnaire editor</h1><p>Shape your questions, then play through the journey.</p></div>
{!playing && <button className="primary" disabled={!validation.definition} onClick={() => { setPlaying(validation.definition); setResult(undefined); setRun(run + 1); }}><span aria-hidden="true">▶</span> Play</button>}
</header>
{content}
</div>;
}