Initial QuestionGraph library and documentation
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user