Files

202 lines
8.3 KiB
Markdown

# 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).
```sh
npm install
npm run dev
npm test
npm run build
```
## Authoring
```json
{
"$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:
```json
{
"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-DD` calendar dates; month arithmetic clamps the day.
- Sessions capture one date. Supply `{ today: "2026-09-12" }` to `createSession`
for 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`:
```tsx
<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:
```tsx
<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.