Initial QuestionGraph library and documentation

This commit is contained in:
Peter Stockings
2026-09-13 12:59:36 +10:00
commit 0879a3e786
48 changed files with 7712 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import {
QuestionnaireRenderer, type QuestionnaireDefinition,
type QuestionnaireSaveHandler, type UploadedFileRef, type Answers,
} from "../../lib";
import { customControls } from "./CustomControls";
// Your application's API client implements these three operations.
type ClaimApi = {
saveDraft: QuestionnaireSaveHandler;
uploadFiles: (questionId: string, files: File[]) => Promise<UploadedFileRef[]>;
submit: (answers: Answers, outcomes: Record<string, boolean>) => Promise<void>;
};
// Pass the questionnaire JSON loaded by your route or data-fetching layer.
export function ClaimForm({ definition, api }: {
definition: QuestionnaireDefinition;
api: ClaimApi;
}) {
return <div className="questionnaire-pane">
<QuestionnaireRenderer
definition={definition}
components={customControls}
onSave={request => api.saveDraft(request)}
onUpload={(questionId, files) => api.uploadFiles(questionId, files)}
onSubmit={(answers, outcomes) => api.submit(answers, outcomes)}
/>
</div>;
}
+20
View File
@@ -0,0 +1,20 @@
import type { QuestionnaireComponents, QuestionnaireFieldProps } from "../../lib";
function TextControl({ id, value, question, accessibility, onChange }: QuestionnaireFieldProps) {
const props = {
id,
...accessibility,
className: "app-input",
value: String(value ?? ""),
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChange(event.target.value),
};
return question.ui?.widget === "textarea"
? <textarea {...props} rows={4} />
: <input {...props} type="text" />;
}
// Other field types keep their default controls.
// Labels, errors, and focus/blur are handled by the renderer.
export const customControls: QuestionnaireComponents = { text: TextControl };
+42
View File
@@ -0,0 +1,42 @@
import {
QuestionnaireRenderer, type QuestionnaireDefinition,
type QuestionnaireFieldProps,
} from "../../lib";
const definition: QuestionnaireDefinition = {
schemaVersion: 1, id: "contact", version: "1", title: "Contact",
pages: [{ id: "about", title: "About you", fields: [
{ id: "name", type: "text", label: "Your name", required: true,
constraints: { minLength: 2 } },
] }],
};
// This example handles text fields only. A general renderer must handle every
// field type in its definition; renderField has no automatic default fallback.
function FullTextField(props: QuestionnaireFieldProps) {
return <div id={props.id + "-question"} className="app-field"
onFocus={props.onFocus}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) props.onBlur();
}}>
<label htmlFor={props.id}>{props.question.label}{props.required ? " *" : ""}</label>
<p id={props.id + "-description"}>{props.question.description}</p>
<input id={props.id} {...props.accessibility} type="text"
value={String(props.value ?? "")}
onChange={event => props.onChange(event.target.value)} />
<div id={props.id + "-errors"} aria-live="polite">
{props.validation.map((issue, index) =>
<p key={index} className={issue.severity === "ERROR" ? "error" : "warning"}>
{issue.message}
</p>)}
</div>
</div>;
}
// In-memory example: no save handler, uploads, or backend submission.
export function CustomFieldExample() {
return <div className="questionnaire-pane">
<QuestionnaireRenderer definition={definition} renderField={FullTextField}
onSubmit={answers => { window.alert("Completed for " + answers.name); }} />
</div>;
}
+18
View File
@@ -0,0 +1,18 @@
import type { QuestionnaireSaveHandler } from "../../lib";
export const saveDraft: QuestionnaireSaveHandler = async request => {
const response = await fetch("/api/claims/current/draft", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"If-Match": JSON.stringify(request.revision),
},
body: JSON.stringify(request),
});
// Reject failures so the renderer stays on the page and allows a retry.
if (!response.ok) throw new Error("Could not save your answers.");
// The server returns { revision: number }.
return response.json();
};