200 lines
15 KiB
TypeScript
200 lines
15 KiB
TypeScript
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>;
|
||
}
|