8.3 KiB
QuestionGraph
A questionnaire authoring format, compiler, evaluation engine, and React renderer.
The claims questionnaire is a worked example. /docs includes a smaller event
registration example and an expression playground.
Run
Use Node.js 20.19+ or 22.12+ (24 recommended).
npm install
npm run dev
npm test
npm run build
Authoring
{
"$schema": "./questionnaire.schema.json",
"schemaVersion": 1,
"id": "registration",
"version": "1",
"title": "Registration",
"pages": [
{
"id": "details",
"title": "Your details",
"fields": [
{
"id": "name",
"type": "text",
"label": "Your name",
"required": true,
"constraints": { "minLength": 2, "maxLength": 100 }
}
]
}
]
}
Pages and fields appear in array order. Answer fields have globally unique IDs.
when on pages, groups, and fields controls applicability; ancestor conditions
apply to every descendant. Groups contain fields and optionally title and
description; they do not create answer values.
Types describe answers: text, number, date, boolean, select,
multiselect, and files. Optional ui.widget chooses presentation without
changing the answer contract. Constraints are type-specific and engine-enforced.
required defaults to false; use requiredWhen for conditional requirements,
never both. A required boolean accepts false. constraints.mustBeTrue requires
acceptance, including when the answer is missing.
Use validation expressions for cross-field or dynamic constraints:
{
"rule": "value <= answer('hoursBefore')",
"message": "Current hours cannot exceed previous hours."
}
Use route: { cases: [{ when, to }], otherwise } only for explicit jumps. First
matching case wins. Targets must be later page IDs or END; fallback is mandatory.
Skipped pages do not execute routes. Ordinary pages need no navigation properties.
Answer semantics
- Drafts retain inactive answers, restoring them when their branch applies again.
answer('id')reads effective answers: inactive, empty, or structurally invalid values return null. Business constraint failures remain visible for correction.- Whitespace-only text and empty arrays are unanswered; false and zero are answers.
- Validation and submission include only applicable fields.
- Null comparisons (
<,<=,>,>=) are false; arithmetic with null is null. - Conditions must return boolean. Equality is
===/!==. - Dates are valid
YYYY-MM-DDcalendar dates; month arithmetic clamps the day. - Sessions capture one date. Supply
{ today: "2026-09-12" }tocreateSessionfor a deterministic business date on the server or in tests.
Architecture
Author JSON → schema validation → compilation → evaluated session → React
src/lib/questionnaire/questionnaire.schema.json: formal editor/runtime JSON Schema.src/lib/questionnaire/questionnaire-schema.ts: discriminated author types and answer types.src/lib/questionnaire/questionnaire-compiler.ts: document validation, lookup tables, expression reference/type checks, forward routing validation, and dependency-cycle detection.src/lib/questionnaire/dsl.ts: bounded lexer/parser, AST, helper signatures, evaluator, dependencies.src/lib/questionnaire/questionnaire-engine.ts: immutable answer snapshots, applicability, derived values, validation, navigation, outcomes, and evaluated page state.src/lib/react/QuestionnaireRenderer.tsx: inputs, nested groups, touched/error display, focus, and save-event integration.src/demo/claim-questionnaire.json: full migrated claim questionnaire.src/demo/example-questionnaire.json: small standalone authoring example.tests/questionnaire.test.ts: compiler, semantics, branches, constraints, draft resume, save previews, and all four claim journey scenarios.
Conditions cannot depend on their own answers, directly or through groups,
pages, routing, or derived values. The compiler detects these dependency cycles.
value is only available inside field validation and requiredWhen.
The format has no compatibility layer for the previous nodes/questions document.
Using a custom component library
src/lib contains the reusable questionnaire library; src/demo contains the
claims example and inspector application. QuestionnaireRenderer owns questionnaire state, visibility, validation,
navigation, resume behavior, and save events. The default HTML controls can be
replaced with application components through components:
<QuestionnaireRenderer
definition={definition}
components={{
text: ({ id, value, accessibility, onChange }) => (
<DesignSystemTextInput
id={id}
value={String(value ?? "")}
{...accessibility}
onChange={event => onChange(event.target.value)}
/>
),
select: MySelectField
}}
onSubmit={handleSubmit}
/>
Components receive the evaluated question, current value, required state,
validation results, accessibility attributes, and an onChange callback. Use
renderField when the host application needs to own the complete field markup,
including labels and error presentation. In that case the custom renderer
should provide an element with the field's ${id}-question convention if it
wants the built-in resume-focus behavior.
Production persistence
Pass an async onSave handler to send draft patches to the host application.
The library does not know the API URL, authentication mechanism, or HTTP client.
The existing onSubmit callback may also be async and is called with effective
answers and evaluated outcomes:
<QuestionnaireRenderer
definition={definition}
resumedDraft={draft}
onSave={request => api.patchDraft({
...request,
headers: { "If-Match": `"${request.revision}"` }
})}
onUpload={async (questionId, files) => {
// Send the actual File bytes; return metadata and a real uploadId per file.
return api.uploadFiles(questionId, files);
}}
onSubmit={async (answers, outcomes) => {
await api.submitQuestionnaire({ answers, outcomes });
}}
onSaveEvent={event => telemetry.record(event)}
/>
onSave is serialized by the library and must return the server's new
revision. A rejected save is reported as a blocked save event. The host
application should use the revision for optimistic concurrency and handle
conflicts or retries according to its API policy.
Changes are acknowledged only after a successful save. A later save retries unacknowledged answers using the last confirmed revision. Continue waits for the queue and stays on the page when saving fails; Submit waits for draft saving before calling the host. Inputs and navigation are disabled during uploads and 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.
SaveResponse contains only the confirmed revision.
The built-in file input requires onUpload(questionId, files) for nonempty
selections. It validates metadata before uploading and requires an uploaded
reference for each file. Custom file controls can use onFilesSelected or pass
already uploaded references through onChange. Save and submit use those same
references; the library never generates upload IDs. Configure persistence when
mounting the renderer; remount it to switch between in-memory and server modes.
Boundaries
The demo uses explicit mock handlers in src/demo/mock-persistence.ts; they
perform no file transfer or durable persistence. The backend must validate
the exact questionnaire version independently and resolve authorized upload IDs.
Expressions cannot access JavaScript globals, properties, assignments, loops, or arbitrary functions. Length/token/nesting limits bound parsing. Native regular expressions have a pattern-length limit but no execution-time guarantee; review published patterns or replace that implementation before accepting untrusted rules.
Repeatable groups, reusable option libraries, localized messages, real uploads, and persistence are not implemented. Currency is currently a numeric presentation hint, not a decimal-money storage type.