Files
QuestionGraph/src/demo/SchemaEditorPage.tsx
T

200 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}