Initial QuestionGraph library and documentation
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.vite
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
.DS_Store
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:24-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --include=dev
|
||||||
|
COPY . .
|
||||||
|
RUN npm test && npm run build
|
||||||
|
|
||||||
|
FROM nginx:stable-alpine
|
||||||
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
|
CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Dokku hosting
|
||||||
|
|
||||||
|
App: questiongraph
|
||||||
|
Server: root@peterstockings.com
|
||||||
|
Domain: questiongraph.peterstockings.com
|
||||||
|
|
||||||
|
Run from PowerShell to deploy the current source:
|
||||||
|
|
||||||
|
./scripts/deploy-dokku.ps1
|
||||||
|
|
||||||
|
The script uploads an explicitly selected source archive and invokes Dokku's
|
||||||
|
git:from-archive deployment. It does not use the parent Bitbucket git repository.
|
||||||
|
SSH key access, scp, and tar are required. Small deployment archives are retained
|
||||||
|
in the local and remote temporary directories for troubleshooting.
|
||||||
|
|
||||||
|
The Docker build runs npm ci, tests, TypeScript validation, and Vite on Node 24.
|
||||||
|
The runtime image serves dist with Nginx. Direct /docs links fall back to the SPA;
|
||||||
|
missing assets/downloads return 404. The library ZIP is generated with every build.
|
||||||
|
|
||||||
|
App setup (already performed on the server):
|
||||||
|
|
||||||
|
dokku apps:create questiongraph
|
||||||
|
dokku domains:set questiongraph questiongraph.peterstockings.com
|
||||||
|
dokku builder:set questiongraph selected dockerfile
|
||||||
|
dokku ports:set questiongraph http:80:80
|
||||||
|
dokku letsencrypt:enable questiongraph
|
||||||
|
|
||||||
|
Check status:
|
||||||
|
|
||||||
|
ssh root@peterstockings.com "dokku ps:report questiongraph"
|
||||||
|
ssh root@peterstockings.com "dokku logs questiongraph --num 100"
|
||||||
|
|
||||||
|
This hosts the demo and documentation publicly, including library source download.
|
||||||
|
Draft saves and uploads in the demo remain mocks; no claims API/database is deployed.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
location /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
location /downloads/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
add_header Content-Disposition "attachment";
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Claims Questionnaire DSL Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/demo/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2664
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "claims-questionnaire-dsl-demo",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "tsx --test tests/questionnaire.test.ts tests/persistence.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "latest",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
|
"mermaid": "^11.17.2",
|
||||||
|
"react": "latest",
|
||||||
|
"react-dom": "latest",
|
||||||
|
"typescript": "latest",
|
||||||
|
"vite": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "latest",
|
||||||
|
"@types/react-dom": "latest",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
|
"tsx": "^4.23.13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$projectPath = Split-Path $PSScriptRoot -Parent
|
||||||
|
$archiveName = 'questiongraph-' + [guid]::NewGuid().ToString('N') + '.tar'
|
||||||
|
$archivePath = Join-Path ([System.IO.Path]::GetTempPath()) $archiveName
|
||||||
|
$remotePath = '/tmp/' + $archiveName
|
||||||
|
|
||||||
|
# Explicit inputs avoid uploading neighbouring repositories, secrets, or dependencies.
|
||||||
|
tar -cf $archivePath -C $projectPath Dockerfile .dockerignore deploy src scripts tests package.json package-lock.json tsconfig.json vite.config.ts index.html README.md
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Could not create deployment archive.' }
|
||||||
|
scp $archivePath "root@peterstockings.com:$remotePath"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Could not upload deployment archive.' }
|
||||||
|
ssh root@peterstockings.com "dokku git:from-archive questiongraph -- < $remotePath"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Dokku deployment failed. Check the build output.' }
|
||||||
|
Write-Output 'Deployed: https://questiongraph.peterstockings.com'
|
||||||
|
Write-Output "Deployment archive retained locally at $archivePath and remotely at $remotePath"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# QuestionGraph library source
|
||||||
|
|
||||||
|
This ZIP contains the actual lib directory, including its JSON Schema.
|
||||||
|
It does not contain the demo, mock handlers, build tools, or node_modules.
|
||||||
|
It is source code for a React + TypeScript application, not a prebuilt npm package.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Copy lib into your application's src directory.
|
||||||
|
2. Install runtime dependencies:
|
||||||
|
|
||||||
|
npm install react react-dom ajv@^8.20.0
|
||||||
|
|
||||||
|
Existing React applications usually already have react and react-dom.
|
||||||
|
Use React 18 or newer. For TypeScript, install the matching React type
|
||||||
|
packages and enable JSX, resolveJsonModule, and esModuleInterop.
|
||||||
|
|
||||||
|
3. Import from the public entry point:
|
||||||
|
|
||||||
|
import { QuestionnaireRenderer } from "./lib";
|
||||||
|
|
||||||
|
4. Pass your API-loaded questionnaire JSON as definition. Supply onSave,
|
||||||
|
onUpload (for file questions), and onSubmit using your own API client.
|
||||||
|
|
||||||
|
onSave(request) returns a promise resolving to { revision: number }.
|
||||||
|
onUpload(questionId, files) returns uploaded file references with
|
||||||
|
uploadId, name, size, and type.
|
||||||
|
onSubmit(answers, outcomes) returns a promise that rejects on failure.
|
||||||
|
|
||||||
|
The compiler validates the questionnaire using the bundled JSON Schema.
|
||||||
|
No separate schema URL is required. The backend must also validate submissions.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
Styles are not included in the lib directory. Supply your application styles
|
||||||
|
for the renderer's shell, card, question-block, navigation, primary, secondary,
|
||||||
|
message, error, and warning classes. Style disabled and focus states.
|
||||||
|
The question-group fieldset is used for grouping and while saving; reset its
|
||||||
|
border and padding if needed. Wrap the renderer in a questionnaire-pane element
|
||||||
|
for its page-heading focus behavior.
|
||||||
|
|
||||||
|
Use components to replace input controls, or renderField for full field layouts.
|
||||||
|
Preserve the provided accessibility attributes and focus/blur callbacks.
|
||||||
|
|
||||||
|
## Drafts
|
||||||
|
|
||||||
|
Pass resumedDraft when mounting an existing draft. If using parseResumeDraft,
|
||||||
|
preserve the validated server revision explicitly: the current parser defaults
|
||||||
|
revision to zero. Remount to switch questionnaires or persistence modes.
|
||||||
|
|
||||||
|
The archive is generated from the same library source as the documentation build.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { zipSync } from "fflate";
|
||||||
|
|
||||||
|
export const archivePath = "downloads/questiongraph-library.zip";
|
||||||
|
|
||||||
|
export async function createLibraryArchive(root) {
|
||||||
|
const files = {};
|
||||||
|
async function collect(directory, prefix) {
|
||||||
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
const name = prefix + "/" + entry.name;
|
||||||
|
if (entry.isDirectory()) await collect(path, name);
|
||||||
|
else if (entry.isFile() && /\.(ts|tsx|json)$/.test(entry.name))
|
||||||
|
files[name] = new Uint8Array(await readFile(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await collect(join(root, "src/lib"), "questiongraph/lib");
|
||||||
|
files["questiongraph/README.md"] = new Uint8Array(await readFile(join(root, "scripts/library-README.md")));
|
||||||
|
return zipSync(files, { level: 6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build-time asset generation keeps the ZIP in sync with the shipped source.
|
||||||
|
// Dev requests generate it afresh; no generated ZIP is committed.
|
||||||
|
export function libraryDownload() {
|
||||||
|
let root;
|
||||||
|
let base;
|
||||||
|
return {
|
||||||
|
name: "questiongraph-library-download",
|
||||||
|
configResolved(config) { root = config.root; base = config.base; },
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
const pathname = (request.url ?? "").split("?")[0];
|
||||||
|
if (pathname !== "/" + archivePath && pathname !== base + archivePath) return next();
|
||||||
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
|
response.statusCode = 405; response.setHeader("Allow", "GET, HEAD"); response.end(); return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const archive = await createLibraryArchive(root);
|
||||||
|
response.setHeader("Content-Type", "application/zip");
|
||||||
|
response.setHeader("Content-Disposition", 'attachment; filename="questiongraph-library.zip"');
|
||||||
|
response.setHeader("Cache-Control", "no-store");
|
||||||
|
response.setHeader("Content-Length", archive.length);
|
||||||
|
response.end(request.method === "HEAD" ? undefined : Buffer.from(archive));
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async generateBundle() {
|
||||||
|
this.emitFile({ type: "asset", fileName: archivePath, source: await createLibraryArchive(root) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { SaveEventsPanel } from "./SaveEventsPanel";
|
||||||
|
import { mockSave, mockUpload } from "./mock-persistence";
|
||||||
|
import type { SaveEvent } from "../lib/persistence/save-events";
|
||||||
|
import { ResumePanel } from "./ResumePanel";
|
||||||
|
import type { ResumedDraft } from "../lib/questionnaire/resume-draft";
|
||||||
|
import questionnaireJson from "./claim-questionnaire.json";
|
||||||
|
import { QuestionnaireRenderer } from "../lib/react/QuestionnaireRenderer";
|
||||||
|
import { JsonViewer } from "./JsonViewer";
|
||||||
|
import { DerivedValues } from "./DerivedValues";
|
||||||
|
import { QuestionnaireGraph } from "./QuestionnaireGraph";
|
||||||
|
import { InspectorModal } from "./InspectorModal";
|
||||||
|
import { QuestionnaireEngine } from "../lib/questionnaire/questionnaire-engine";
|
||||||
|
import type { Answers, QuestionnaireDefinition } from "../lib/questionnaire/questionnaire-engine";
|
||||||
|
|
||||||
|
const inspectorEngine = new QuestionnaireEngine(questionnaireJson);
|
||||||
|
const questionnaire = inspectorEngine.definition;
|
||||||
|
const inspectorTabs = ["JSON", "Mermaid diagram", "Derived values"] as const;
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [submission, setSubmission] = useState<{ answers: Answers; outcomes: Record<string, boolean> } | null>(null);
|
||||||
|
const [currentNode, setCurrentNode] = useState(questionnaire.pages[0].id);
|
||||||
|
const [currentQuestion, setCurrentQuestion] = useState<string>();
|
||||||
|
const [activeTab, setActiveTab] = useState<typeof inspectorTabs[number]>("JSON");
|
||||||
|
const [expandedView, setExpandedView] = useState<"JSON" | "Mermaid diagram">();
|
||||||
|
const [answers, setAnswers] = useState<Answers>({});
|
||||||
|
const [draftAnswers, setDraftAnswers] = useState<Answers>({});
|
||||||
|
const [saveEvents, setSaveEvents] = useState<SaveEvent[]>([]);
|
||||||
|
const [resumedDraft, setResumedDraft] = useState<ResumedDraft>();
|
||||||
|
const [sessionKey, setSessionKey] = useState(0);
|
||||||
|
const recordSaveEvent = useCallback((event: SaveEvent) => {
|
||||||
|
setSaveEvents((current) => [event, ...current]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workspace">
|
||||||
|
<div className="questionnaire-pane">
|
||||||
|
{submission ? (
|
||||||
|
<main className="shell">
|
||||||
|
<section className="card success-card">
|
||||||
|
<p className="eyebrow">Demo submission</p>
|
||||||
|
<h1>Claim questionnaire completed</h1>
|
||||||
|
<p>The front-end engine has evaluated the journey, validation rules and outcomes.</p>
|
||||||
|
<h2>Outcomes</h2>
|
||||||
|
<pre>{JSON.stringify(submission.outcomes, null, 2)}</pre>
|
||||||
|
<h2>Answers</h2>
|
||||||
|
<pre>{JSON.stringify(submission.answers, null, 2)}</pre>
|
||||||
|
<button className="primary" onClick={() => { setResumedDraft(undefined); setSessionKey((key) => key + 1); setSaveEvents([]); setSubmission(null); }}>Start again</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
) : (
|
||||||
|
<QuestionnaireRenderer
|
||||||
|
key={sessionKey}
|
||||||
|
resumedDraft={resumedDraft}
|
||||||
|
definition={questionnaire}
|
||||||
|
onNodeChange={setCurrentNode}
|
||||||
|
onQuestionFocus={setCurrentQuestion}
|
||||||
|
onAnswersChange={setAnswers}
|
||||||
|
onDraftAnswersChange={setDraftAnswers}
|
||||||
|
onSaveEvent={recordSaveEvent}
|
||||||
|
onSave={mockSave}
|
||||||
|
onUpload={mockUpload}
|
||||||
|
onSubmit={(answers, outcomes) => setSubmission({ answers, outcomes })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<aside className="json-sidebar" aria-label="Questionnaire inspector">
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-tools">
|
||||||
|
<ResumePanel definition={questionnaire} answers={draftAnswers} nodeId={currentNode} questionId={currentQuestion} onResume={(draft) => {
|
||||||
|
setResumedDraft(draft); setAnswers(draft.answers); setCurrentNode(draft.nodeId); setCurrentQuestion(draft.questionId); setSubmission(null); setSaveEvents([]); setSessionKey((key) => key + 1);
|
||||||
|
}} />
|
||||||
|
<SaveEventsPanel events={saveEvents} onClear={() => setSaveEvents([])} />
|
||||||
|
</div>
|
||||||
|
<section className="inspector-views" aria-label="Questionnaire views">
|
||||||
|
<div className="inspector-tabs" role="tablist" aria-label="Questionnaire views">
|
||||||
|
{inspectorTabs.map((tab, index) => <button key={tab} type="button" role="tab" id={`view-tab-${index}`} aria-controls={`view-panel-${index}`} aria-selected={activeTab === tab} tabIndex={activeTab === tab ? 0 : -1}
|
||||||
|
onClick={() => setActiveTab(tab)} onKeyDown={(event) => {
|
||||||
|
const next = event.key === "ArrowRight" ? (index + 1) % inspectorTabs.length : event.key === "ArrowLeft" ? (index + inspectorTabs.length - 1) % inspectorTabs.length : event.key === "Home" ? 0 : event.key === "End" ? inspectorTabs.length - 1 : undefined;
|
||||||
|
if (next === undefined) return;
|
||||||
|
event.preventDefault(); setActiveTab(inspectorTabs[next]); document.getElementById(`view-tab-${next}`)?.focus();
|
||||||
|
}}>{tab}</button>)}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" id="view-panel-0" aria-labelledby="view-tab-0" hidden={activeTab !== "JSON"}>
|
||||||
|
<div className="json-panel">
|
||||||
|
<div className="json-panel-header">
|
||||||
|
<strong>claim-questionnaire.json</strong>
|
||||||
|
<div className="json-header-actions">
|
||||||
|
<span className="json-readonly">Read only</span>
|
||||||
|
<button className="inspector-expand" type="button" onClick={() => setExpandedView("JSON")} aria-label="Expand JSON" aria-haspopup="dialog"><span aria-hidden="true">⛶</span> Expand</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<JsonViewer value={questionnaireJson} activeNode={submission ? undefined : currentNode} activeQuestion={submission ? undefined : currentQuestion} isOpen={activeTab === "JSON"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" id="view-panel-1" aria-labelledby="view-tab-1" hidden={activeTab !== "Mermaid diagram"}>
|
||||||
|
<QuestionnaireGraph definition={questionnaire} currentNode={submission ? "END" : currentNode} currentQuestion={submission ? undefined : currentQuestion} inspectorOpen={activeTab === "Mermaid diagram" && !expandedView} onExpand={() => setExpandedView("Mermaid diagram")} />
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" id="view-panel-2" aria-labelledby="view-tab-2" hidden={activeTab !== "Derived values"}>
|
||||||
|
<DerivedValues definition={questionnaire} session={inspectorEngine.createSession(submission?.answers ?? answers)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{expandedView && <InspectorModal title={expandedView} onClose={() => setExpandedView(undefined)}>
|
||||||
|
{expandedView === "JSON"
|
||||||
|
? <JsonViewer value={questionnaireJson} activeNode={submission ? undefined : currentNode} activeQuestion={submission ? undefined : currentQuestion} />
|
||||||
|
: <QuestionnaireGraph definition={questionnaire} currentNode={submission ? "END" : currentNode} currentQuestion={submission ? undefined : currentQuestion} inspectorOpen />}
|
||||||
|
</InspectorModal>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { QuestionnaireDefinition, QuestionnaireSession } from "../lib/questionnaire/questionnaire-engine";
|
||||||
|
|
||||||
|
export function DerivedValues({ definition, session }: {
|
||||||
|
definition: QuestionnaireDefinition;
|
||||||
|
session: QuestionnaireSession;
|
||||||
|
}) {
|
||||||
|
const entries = Object.entries(definition.derived ?? {});
|
||||||
|
if (!entries.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="derived-panel">
|
||||||
|
<p className="derived-note">Calculated from your current answers. Null means no value has been calculated.</p>
|
||||||
|
<dl className="derived-list">
|
||||||
|
{entries.map(([id, expression]) => {
|
||||||
|
let result: unknown;
|
||||||
|
let error: string | undefined;
|
||||||
|
try {
|
||||||
|
result = session.getDerived(id);
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
}
|
||||||
|
const kind = error ? "error" : result == null ? "null" : typeof result;
|
||||||
|
return (
|
||||||
|
<div className="derived-entry" key={id}>
|
||||||
|
<dt>{id}</dt>
|
||||||
|
<dd>
|
||||||
|
<output className={`derived-value derived-${kind}`} aria-label={`${id} value`}>
|
||||||
|
{error ? "Unable to calculate" : JSON.stringify(result) ?? "null"}
|
||||||
|
</output>
|
||||||
|
{error && <p className="message error">{error}</p>}
|
||||||
|
<details className="derived-expression">
|
||||||
|
<summary>Expression</summary>
|
||||||
|
<code>{expression}</code>
|
||||||
|
</details>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
// Display-only tokenization. Render React text nodes so source is always escaped.
|
||||||
|
const tokens = /\/\/[^\n]*|\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|[A-Za-z_$][\w$]*|[^\w\s]/g;
|
||||||
|
const keywords = new Set("import from export default type interface const let function return async await if else throw new typeof void true false null undefined".split(" "));
|
||||||
|
|
||||||
|
export function DocsCode({ children, language = "tsx" }: {
|
||||||
|
children: string; language?: "tsx" | "json" | "text";
|
||||||
|
}) {
|
||||||
|
const highlighted = useMemo(() => {
|
||||||
|
if (language === "text") return children;
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const match of children.matchAll(tokens)) {
|
||||||
|
const value = match[0];
|
||||||
|
const index = match.index!;
|
||||||
|
parts.push(children.slice(cursor, index));
|
||||||
|
const next = children.slice(index + value.length);
|
||||||
|
const kind = value.startsWith("//") || value.startsWith("/*") ? "comment"
|
||||||
|
: /^["'`]/.test(value) ? (language === "json" && /^\s*:/.test(next) ? "property" : "string")
|
||||||
|
: /^\d/.test(value) ? "number"
|
||||||
|
: keywords.has(value) ? "keyword"
|
||||||
|
: /^[A-Z]/.test(value) ? "type"
|
||||||
|
: /^\s*\(/.test(next) && /^\w/.test(value) ? "function"
|
||||||
|
: /^\s*=/.test(next) && /^\w/.test(value) ? "property"
|
||||||
|
: /^[^\w]$/.test(value) ? "punctuation" : undefined;
|
||||||
|
parts.push(kind ? <span className={`syntax-${kind}`} key={index}>{value}</span> : value);
|
||||||
|
cursor = index + value.length;
|
||||||
|
}
|
||||||
|
parts.push(children.slice(cursor));
|
||||||
|
return parts;
|
||||||
|
}, [children, language]);
|
||||||
|
return <pre className="docs-code" tabIndex={0} aria-label={`${language.toUpperCase()} example`}>
|
||||||
|
<code>{highlighted}</code>
|
||||||
|
</pre>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { compileExpression, extractDependencies, FUNCTION_SIGNATURES } from "../lib/questionnaire/dsl";
|
||||||
|
import { QuestionnaireEngine } from "../lib/questionnaire/questionnaire-engine";
|
||||||
|
import example from "./example-questionnaire.json";
|
||||||
|
import claimFormSource from "./docs-examples/ClaimForm.tsx?raw";
|
||||||
|
import controlsSource from "./docs-examples/CustomControls.tsx?raw";
|
||||||
|
import { DocsCode as Code } from "./DocsCode";
|
||||||
|
import saveDraftSource from "./docs-examples/SaveDraft.ts?raw";
|
||||||
|
import fullFieldSource from "./docs-examples/FullField.tsx?raw";
|
||||||
|
|
||||||
|
const engine = new QuestionnaireEngine(example);
|
||||||
|
const sampleAnswers = { fullName: "Alex", attending: true, supportRequired: true, supportDetails: "Step-free access" };
|
||||||
|
const sections = [["overview", "Overview"], ["integration", "Use the library"], ["custom-controls", "Custom controls"], ["persistence", "Save answers"], ["full-field", "Custom field layout"], ["schema", "Author JSON"], ["example", "Example JSON"], ["rules", "Rules & answers"], ["dsl", "Expressions"], ["parser", "Compilation"], ["playground", "Playground"], ["authoring", "Authoring"]];
|
||||||
|
function ReferenceTable({ rows }: { rows: string[][] }) {
|
||||||
|
return <div className="docs-table-wrap" tabIndex={0}><table className="docs-table"><thead><tr><th>Property</th><th>Meaning</th></tr></thead><tbody>{rows.map(([key, value]) => <tr key={key}><th scope="row"><code>{key}</code></th><td>{value}</td></tr>)}</tbody></table></div>;
|
||||||
|
}
|
||||||
|
export function DocsPage() {
|
||||||
|
const [expression, setExpression] = useState("derived('needsSupport') && length(trim(value)) >= 2");
|
||||||
|
const evaluation = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const ast = compileExpression(expression);
|
||||||
|
return { ast, dependencies: extractDependencies(ast), result: engine.createSession(sampleAnswers, { today: "2026-09-12" }).evaluate(expression, sampleAnswers.fullName) };
|
||||||
|
} catch (error) { return { error: error instanceof Error ? error.message : String(error) }; }
|
||||||
|
}, [expression]);
|
||||||
|
return <main className="docs-page">
|
||||||
|
<aside className="docs-toc"><p className="eyebrow">Documentation</p><nav aria-label="On this page">{sections.map(([id, label], index) => <a key={id} href={`#${id}`}><span>{String(index + 1).padStart(2, "0")}</span>{label}</a>)}</nav></aside>
|
||||||
|
<article className="docs-article">
|
||||||
|
<section id="overview" className="docs-hero"><p className="eyebrow">QuestionGraph / Docs</p><h1 tabIndex={-1}>Write the questions.<br />Let the engine guide the journey.</h1><p className="docs-intro">Ordered pages and nested fields describe the questionnaire. Conditions describe when they apply. The compiler builds the executable model.</p><Code language="text">{"Author JSON → schema validation → expression and dependency checks → session → renderer"}</Code></section>
|
||||||
|
<section id="integration"><h2>Use the library</h2>
|
||||||
|
<div className="docs-download">
|
||||||
|
<a href={import.meta.env.BASE_URL + "downloads/questiongraph-library.zip"} download="questiongraph-library.zip">Download library ZIP ↓</a>
|
||||||
|
<p>The actual <code>lib/</code> source, JSON Schema, and setup guide. Copy into your React + TypeScript app and install <code>ajv</code>. Bring your own styles.</p>
|
||||||
|
</div>
|
||||||
|
<p>Pass in your questionnaire JSON, choose your controls, and connect your API functions. The renderer handles questions, validation, and navigation.</p>
|
||||||
|
<Code>{claimFormSource}</Code>
|
||||||
|
<p><code>definition</code> is the JSON from your API. <code>api</code> is your application’s API client. Replace <code>../../lib</code> with your library import path.</p>
|
||||||
|
</section>
|
||||||
|
<section id="custom-controls"><h2>Use your own controls</h2>
|
||||||
|
<p>Map a field type to your component. Keep <code>id</code>, <code>accessibility</code>, and <code>onChange</code> connected; the renderer supplies labels and errors.</p>
|
||||||
|
<Code>{controlsSource}</Code>
|
||||||
|
<p>Only text fields are replaced here. All other types keep their default controls.</p>
|
||||||
|
</section>
|
||||||
|
<section id="persistence"><h2>Save answers</h2>
|
||||||
|
<p><code>onSave</code> receives changed answers, the resume position, and the current revision. Send them to your backend and return its new revision.</p>
|
||||||
|
<Code>{saveDraftSource}</Code>
|
||||||
|
<p>The URL is your choice. Saves run in order. Continue and Submit wait for saving; throw an error to report a failure.</p>
|
||||||
|
<h3>Uploads and submission</h3>
|
||||||
|
<ReferenceTable rows={[
|
||||||
|
["onUpload(questionId, files)", "Upload the actual File objects. Return one { uploadId, name, size, type } reference per file."],
|
||||||
|
["onSubmit(answers, outcomes)", "Send the completed answers and calculated outcomes to your backend. Return a promise and reject it if submission fails."],
|
||||||
|
["onSaveEvent(event)", "Optional: show save status using Saved, Blocked, or Changed (in-memory mode)."]
|
||||||
|
]} />
|
||||||
|
<details className="docs-example"><summary>Optional: resume an existing draft</summary>
|
||||||
|
<p>Pass a validated draft through <code>resumedDraft</code> when mounting. The current parser defaults revision to zero, so preserve the server revision explicitly.</p>
|
||||||
|
<Code>{'const draft = {\n ...parseResumeDraft(JSON.stringify(serverDraft), definition),\n revision: serverDraft.revision,\n};\n\n<QuestionnaireRenderer\n definition={definition}\n resumedDraft={draft}\n onSave={api.saveDraft}\n onSubmit={api.submit}\n/>'}</Code>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section id="full-field"><h2>Custom field layout</h2>
|
||||||
|
<p>Need control over the label and error layout too? Use <code>renderField</code> instead of <code>components</code>.</p>
|
||||||
|
<details className="docs-example"><summary>Show a complete custom field example</summary>
|
||||||
|
<Code>{fullFieldSource}</Code>
|
||||||
|
<p>This example handles text fields only. Keep the wrapper ID and focus/blur callbacks so validation and resume focus work.</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section id="schema"><h2>Author JSON</h2><p>The formal schema is <code>src/lib/questionnaire/questionnaire.schema.json</code>. Add its relative path as <code>$schema</code> for editor completion. The engine validates every document before use.</p>
|
||||||
|
<ReferenceTable rows={[["schemaVersion", "The format version, currently 1. Independent of the questionnaire content version."], ["id, version, title", "Stable questionnaire ID, content version, and display title."], ["pages", "Ordered page array. The first applicable page starts the journey."], ["page.fields", "Ordered fields and nested groups. Every answer field has a globally unique ID."], ["when", "Boolean expression on a page, group, or field. Ancestor conditions also apply."], ["required", "Boolean; defaults to false. A required field is required only while applicable."], ["requiredWhen", "Conditional requiredness. Cannot be combined with required."], ["constraints", "Type-specific built-in validation. Enforced in the engine."], ["validation", "Custom rule expressions with messages, optional when, and ERROR or WARNING severity."], ["ui.widget", "Optional presentation choice. It does not change the answer type."], ["derived", "Named expressions calculated from effective answers."], ["outcomes", "Named boolean expressions evaluated for the submission."]]} />
|
||||||
|
<h3>Field types</h3><ReferenceTable rows={[["text", "String. minLength, maxLength, pattern. Input or textarea widget."], ["number", "Finite number. min, max, integer. Number or currency widget; currency currently uses numeric input."], ["date", "Calendar date in YYYY-MM-DD. Optional literal min and max dates; use validation for dynamic date rules."], ["boolean", "true, false, or unanswered. Radio or checkbox widget. mustBeTrue requires acceptance."], ["select", "One declared option value. Select or radio widget."], ["multiselect", "Unique declared option values. minItems and maxItems constraints."], ["files", "File references. allowedExtensions, maxFileSizeMb (MiB), and maxFiles."], ["group", "Nested fields, with optional title, description, and when. Creates no answer value."]]} />
|
||||||
|
</section>
|
||||||
|
<section id="example"><h2>A complete example</h2><p>The preferences page is skipped when the respondent declines. Hidden support details remain in their draft but cannot affect the outcome.</p><Code language="json">{JSON.stringify(example, null, 2)}</Code></section>
|
||||||
|
<section id="rules"><h2>Rules, navigation, and answers</h2><p>Navigation follows array order and skips inapplicable pages. A field applies when its page, all containing groups, and its own condition apply. Back follows the current applicable journey.</p><p>Draft answers preserve hidden values. <code>answer('id')</code> returns null for inactive, empty, or structurally invalid answers. Validation and final submission use applicable fields only. Changing a branch back restores retained values.</p><p>Required accepts false for a boolean question. Use <code>constraints.mustBeTrue</code> for mandatory acceptance. Whitespace-only strings and empty arrays count as unanswered. Nonempty strings are preserved; minLength checks trimmed length, while maxLength checks stored length.</p><h3>Explicit forward routing</h3><p>Use routing only when sequential conditional pages are insufficient. The first matching case wins; otherwise is mandatory. Destinations must be later page IDs or END. A skipped page does not execute its route.</p><Code language="json">{JSON.stringify({ route: { cases: [{ when: "answer('attending') === false", to: "END" }], otherwise: "preferences" } }, null, 2)}</Code><p>Routing and applicability must not depend on answers whose applicability depends on that decision. The compiler rejects these cycles, including cycles through derived values.</p></section>
|
||||||
|
<section id="dsl"><h2>Expression language</h2><p>Expressions support literals, arrays, helper calls, arithmetic, comparisons, boolean operators, null coalescing, and ternaries. There is no property access, assignment, arbitrary JavaScript, or eval.</p><Code>{"answer('attending') === true\nlength(trim(value)) >= 20\nanswer('date') !== null ? daysBetween(answer('date'), today()) : null\nderived('needsSupport')"}</Code><p>Use === and !== for equality. Relational comparisons involving null return false. Arithmetic with null returns null; division by zero is an error. Conditions must return boolean. Use explicit comparisons for nullable boolean answers. value is available only in field validation and requiredWhen.</p><ReferenceTable rows={Object.entries(FUNCTION_SIGNATURES).map(([name, signature]) => [name, `${signature.args.length} arguments: ${signature.args.map(types => types.join(" | ")).join(", ") || "none"}`])} /><p>The session captures today once. Supply a business date when constructing server sessions or tests. Calendar operations use ISO dates; month arithmetic clamps to the last day of the target month.</p></section>
|
||||||
|
<section id="parser"><h2>Compilation and evaluation</h2><p>The JSON Schema catches unknown properties and invalid field shapes. Semantic checks resolve IDs, reject duplicate IDs and option values, check function arity and expression types, and detect dependency cycles. Errors include the JSON location; syntax errors also identify token positions.</p><p>The engine compiles expressions once. Each answer snapshot has its own derived and applicability caches. The renderer consumes evaluated page state for navigation, progress, requiredness, and validation.</p><p>Expressions are bounded to 4096 characters, 512 tokens, and 64 delimiter nesting levels. Regex patterns are bounded to 200 characters; native regex execution is not time-bounded, so published patterns still require review.</p></section>
|
||||||
|
<section id="playground"><h2>Try an expression</h2><p>Uses the example answers and a fixed date of 2026-09-12. This playground parses and evaluates expressions; full document compilation additionally checks references, types, and dependency cycles.</p><label htmlFor="expression">Expression</label><textarea id="expression" value={expression} rows={3} spellCheck={false} onChange={event => setExpression(event.target.value)} /><Code language="json">{JSON.stringify(evaluation, null, 2)}</Code></section>
|
||||||
|
<section id="authoring"><h2>Authoring workflow</h2><ol><li>Write pages in reading order and fields alongside their labels.</li><li>Give fields stable IDs. Nest shared conditions in groups.</li><li>Use built-in constraints for ordinary validation and expressions for relationships.</li><li>Compile the document and resolve diagnostics.</li><li>Test each branch, branch changes, draft resume, and final outcomes.</li></ol><p>Repeatable groups and reusable option libraries are not part of schema version 1. Backend persistence and file uploads remain simulated in this demo. A backend must independently validate the same questionnaire version and resolve authorized upload IDs.</p></section>
|
||||||
|
</article>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export function InspectorModal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current!;
|
||||||
|
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
const overflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
dialog.showModal();
|
||||||
|
return () => {
|
||||||
|
dialog.close();
|
||||||
|
document.body.style.overflow = overflow;
|
||||||
|
opener?.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<dialog ref={dialogRef} className="inspector-modal" aria-labelledby="inspector-modal-title"
|
||||||
|
onCancel={event => { event.preventDefault(); onClose(); }}
|
||||||
|
onClick={event => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose();
|
||||||
|
}}>
|
||||||
|
<header className="inspector-modal-header">
|
||||||
|
<div><p className="eyebrow">Questionnaire inspector</p><h2 id="inspector-modal-title">{title}</h2></div>
|
||||||
|
<button type="button" className="secondary" onClick={onClose} autoFocus aria-label="Close expanded inspector">Close <span aria-hidden="true">×</span></button>
|
||||||
|
</header>
|
||||||
|
<div className={`inspector-modal-content${title === "JSON" ? " inspector-modal-json" : ""}`}>{children}</div>
|
||||||
|
</dialog>, document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const jsonToken = /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false)\b|\b(null)\b|([{}\[\],:])/g;
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
export function highlightJson(source: string) {
|
||||||
|
const parts = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const match of source.matchAll(jsonToken)) {
|
||||||
|
const index = match.index;
|
||||||
|
if (index > cursor) parts.push(source.slice(cursor, index));
|
||||||
|
const kind = match[1] ? "key" : match[2] ? "string" : match[3] ? "number" : match[4] ? "boolean" : match[5] ? "null" : "punctuation";
|
||||||
|
parts.push(<span className={`json-${kind}`} key={index}>{match[0]}</span>);
|
||||||
|
cursor = index + match[0].length;
|
||||||
|
}
|
||||||
|
parts.push(source.slice(cursor));
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonViewer({ value, activeNode, activeQuestion, isOpen = true }: { value: unknown; activeNode?: string; activeQuestion?: string; isOpen?: boolean }) {
|
||||||
|
const containerRef = useRef<HTMLPreElement>(null);
|
||||||
|
const activeRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const lines = useMemo(() => (JSON.stringify(value, null, 2) ?? "null").split("\n"), [value]);
|
||||||
|
// IDs are globally unique for fields and unique within pages. Match the object containing its id.
|
||||||
|
const rangeFor = (id: string | undefined, page = false): [number, number] => {
|
||||||
|
if (!id) return [-1, -1];
|
||||||
|
const idLine = lines.findIndex(line => line.trim() === `"id": ${JSON.stringify(id)},` && (!page || line.startsWith(' "id"')));
|
||||||
|
if (idLine < 0) return [-1, -1];
|
||||||
|
const indent = lines[idLine].length - lines[idLine].trimStart().length - 2;
|
||||||
|
const start = idLine - 1;
|
||||||
|
const end = lines.findIndex((line, i) => i > idLine && (line === " ".repeat(indent) + "}" || line === " ".repeat(indent) + "},"));
|
||||||
|
return [start, end];
|
||||||
|
};
|
||||||
|
const [start, end] = rangeFor(activeNode, true);
|
||||||
|
const [questionStart, questionEnd] = rangeFor(activeQuestion);
|
||||||
|
const scrollTarget = questionStart >= 0 ? questionStart : start;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const active = activeRef.current;
|
||||||
|
if (isOpen && container && active) {
|
||||||
|
container.scrollTop += active.getBoundingClientRect().top - container.getBoundingClientRect().top - 16;
|
||||||
|
}
|
||||||
|
}, [activeNode, activeQuestion, isOpen, lines]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre ref={containerRef} className="json-code" tabIndex={0} aria-label="Questionnaire JSON source">
|
||||||
|
<code>{lines.map((line, index) => (
|
||||||
|
<span key={index} ref={index === scrollTarget ? activeRef : undefined}
|
||||||
|
className={`json-line${start >= 0 && index >= start && index <= end ? " json-active-node" : ""}${questionStart >= 0 && index >= questionStart && index <= questionEnd ? " json-active-question" : ""}`}
|
||||||
|
aria-current={index === scrollTarget ? "location" : undefined}
|
||||||
|
>{highlightJson(line)}{"\n"}</span>
|
||||||
|
))}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { QuestionnaireEngine, type QuestionnaireDefinition } from "../lib/questionnaire/questionnaire-engine";
|
||||||
|
|
||||||
|
import { summarizeExpression } from "./expression-summary";
|
||||||
|
|
||||||
|
let renderId = 0;
|
||||||
|
const GraphArtwork = memo(function GraphArtwork({ svg }: { svg: string }) {
|
||||||
|
return <div dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
|
});
|
||||||
|
const label = (text: string) => text.replace(/[&"#<>\n]/g, (character) => `#${character.charCodeAt(0)};`);
|
||||||
|
|
||||||
|
function briefRule(expression: string) {
|
||||||
|
const summary = summarizeExpression(expression);
|
||||||
|
return summary.length > 52 ? `${summary.slice(0, 49)}…` : summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuestionnaireGraph({ definition, currentNode, currentQuestion, inspectorOpen, onExpand }: {
|
||||||
|
definition: QuestionnaireDefinition;
|
||||||
|
currentNode?: string;
|
||||||
|
currentQuestion?: string;
|
||||||
|
inspectorOpen: boolean;
|
||||||
|
onExpand?: () => void;
|
||||||
|
}) {
|
||||||
|
const open = inspectorOpen;
|
||||||
|
const [svg, setSvg] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [view, setView] = useState({ x: 20, y: 20, scale: 0.8 });
|
||||||
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
const drag = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const [width, setWidth] = useState(1000);
|
||||||
|
const renderedSource = useRef("");
|
||||||
|
const [smooth, setSmooth] = useState(false);
|
||||||
|
const [selectedRule, setSelectedRule] = useState<number>();
|
||||||
|
const rulePress = useRef<{ index?: number; x: number; y: number; moved: boolean } | null>(null);
|
||||||
|
const zoom = Math.round(view.scale * 100);
|
||||||
|
function zoomAt(scale: number, x: number, y: number) {
|
||||||
|
setSmooth(false);
|
||||||
|
setView((previous) => {
|
||||||
|
const next = Math.max(0.05, Math.min(3, scale));
|
||||||
|
const ratio = next / previous.scale;
|
||||||
|
return { scale: next, x: x - (x - previous.x) * ratio, y: y - (y - previous.y) * ratio };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function focusCurrent() {
|
||||||
|
const container = viewport.current;
|
||||||
|
const selected = container?.querySelector('.selected') ?? container?.querySelector('.current');
|
||||||
|
if (!container || !selected) return;
|
||||||
|
const svgElement = container.querySelector("svg");
|
||||||
|
if (!svgElement) return;
|
||||||
|
const bounds = selected.getBoundingClientRect();
|
||||||
|
const svgBounds = svgElement.getBoundingClientRect();
|
||||||
|
const renderedScale = svgBounds.width / width;
|
||||||
|
if (!renderedScale) return;
|
||||||
|
const centerX = (bounds.left + bounds.width / 2 - svgBounds.left) / renderedScale;
|
||||||
|
const centerY = (bounds.top + bounds.height / 2 - svgBounds.top) / renderedScale;
|
||||||
|
setSmooth(true);
|
||||||
|
setView((previous) => ({ ...previous, x: container.clientWidth / 2 - centerX * previous.scale, y: container.clientHeight / 2 - centerY * previous.scale }));
|
||||||
|
}
|
||||||
|
const compiled = useMemo(() => new QuestionnaireEngine(definition).compiled, [definition]);
|
||||||
|
const graph = useMemo(() => {
|
||||||
|
const nodes = Object.fromEntries(definition.pages.map((page, index) => [page.id, {
|
||||||
|
title: page.title, questions: compiled.pages[page.id].fieldIds, when: page.when,
|
||||||
|
transitions: page.route?.cases, next: page.route?.otherwise ?? definition.pages[index + 1]?.id ?? "END"
|
||||||
|
}]));
|
||||||
|
const entries = Object.entries(nodes);
|
||||||
|
const ids = new Map(entries.map(([id], index) => [id, `page${index}`]));
|
||||||
|
const target = (id: string) => id === "END" ? "finish" : ids.get(id)!;
|
||||||
|
const entryTarget = (id: string): string => id !== "END" && nodes[id].when ? `${target(id)}_when` : target(id);
|
||||||
|
const lines = ["flowchart TD", 'entry(["Start"])', 'finish(["End"])'];
|
||||||
|
const rules: { name: string; expression: string; summary: string; context: string }[] = [];
|
||||||
|
for (const [id, node] of entries) {
|
||||||
|
if (node.when) {
|
||||||
|
const index = definition.pages.findIndex(page => page.id === id);
|
||||||
|
const next = definition.pages[index + 1]?.id ?? "END";
|
||||||
|
const name = `R${rules.length + 1}`;
|
||||||
|
rules.push({ name, expression: node.when, summary: briefRule(node.when), context: `Include ${node.title} when true. Otherwise continue to the next page in order.` });
|
||||||
|
lines.push(`${entryTarget(id)}{"${name}: ${label(briefRule(node.when))}"}`, `${entryTarget(id)} -->|Show| ${target(id)}`, `${entryTarget(id)} -->|Skip| ${entryTarget(next)}`);
|
||||||
|
}
|
||||||
|
lines.push(`subgraph group_${target(id)}["${label(node.title)}"]`);
|
||||||
|
lines.push(`${target(id)}["${label(node.title)}"]`);
|
||||||
|
let previous = target(id);
|
||||||
|
node.questions.forEach((questionId, index) => {
|
||||||
|
const question = compiled.fields[questionId].definition;
|
||||||
|
const questionNode = `${target(id)}_question${index}`;
|
||||||
|
lines.push(`${questionNode}["${label(questionId)}: ${label(question.label)}"]`);
|
||||||
|
if (compiled.fields[questionId].conditions.map(c => `(${c})`).join(" && ")) {
|
||||||
|
const condition = `${questionNode}_visible`;
|
||||||
|
const merge = `${questionNode}_merge`;
|
||||||
|
const name = `R${rules.length + 1}`;
|
||||||
|
const summary = briefRule(compiled.fields[questionId].conditions.map(c => `(${c})`).join(" && "));
|
||||||
|
rules.push({ name, expression: compiled.fields[questionId].conditions.map(c => `(${c})`).join(" && "), summary, context: `Show “${question.label}” when true; skip it when false.` });
|
||||||
|
lines.push(`${previous} --> ${condition}{"${name}: ${label(summary)}"}`, `${condition} -->|Show| ${questionNode}`, `${condition} -->|Skip| ${merge}((" "))`, `${questionNode} --> ${merge}`);
|
||||||
|
previous = merge;
|
||||||
|
} else {
|
||||||
|
lines.push(`${previous} --> ${questionNode}`);
|
||||||
|
previous = questionNode;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lines.push("end");
|
||||||
|
for (const transition of node.transitions ?? []) {
|
||||||
|
const name = `R${rules.length + 1}`;
|
||||||
|
const summary = briefRule(transition.when);
|
||||||
|
rules.push({ name, expression: transition.when, summary, context: `From “${node.title}” to “${nodes[transition.to]?.title ?? "End"}”. The first matching page rule wins.` });
|
||||||
|
lines.push(`${previous} -->|"${name}: ${label(summary)}"| ${entryTarget(transition.to)}`);
|
||||||
|
}
|
||||||
|
lines.push(`${previous} -->${node.transitions?.length ? '|"Otherwise"|' : ""} ${entryTarget(node.next ?? "END")}`);
|
||||||
|
}
|
||||||
|
lines.push(`entry --> ${entryTarget(definition.pages[0].id)}`);
|
||||||
|
lines.push("classDef current fill:#1f5eff,color:#fff,stroke:#123ba3,stroke-width:3px");
|
||||||
|
lines.push("classDef selected fill:#16715d,color:#fff,stroke:#084c3d,stroke-width:3px");
|
||||||
|
return { source: lines.join("\n"), rules, ids };
|
||||||
|
}, [definition, compiled]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = viewport.current;
|
||||||
|
if (!container || !svg || !open || !inspectorOpen) return;
|
||||||
|
container.querySelectorAll(".current, .selected").forEach((element) => element.classList.remove("current", "selected"));
|
||||||
|
const page = currentNode === "END" ? "finish" : graph.ids.get(currentNode ?? "");
|
||||||
|
if (page) container.querySelector(`g.node[id*="flowchart-${page}-"]`)?.classList.add("current");
|
||||||
|
const index = currentNode && currentQuestion ? compiled.pages[currentNode]?.fieldIds.indexOf(currentQuestion) : undefined;
|
||||||
|
if (page && index !== undefined && index >= 0) {
|
||||||
|
container.querySelector(`g.node[id*="flowchart-${page}_question${index}-"]`)?.classList.add("selected");
|
||||||
|
}
|
||||||
|
focusCurrent();
|
||||||
|
}, [svg, currentNode, currentQuestion, graph, definition, open, inspectorOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = viewport.current;
|
||||||
|
if (!container) return;
|
||||||
|
const wheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSmooth(false);
|
||||||
|
const bounds = container.getBoundingClientRect();
|
||||||
|
setView((previous) => {
|
||||||
|
const scale = Math.max(0.05, Math.min(3, previous.scale * Math.exp(-event.deltaY * 0.002)));
|
||||||
|
const ratio = scale / previous.scale;
|
||||||
|
const x = event.clientX - bounds.left;
|
||||||
|
const y = event.clientY - bounds.top;
|
||||||
|
return { scale, x: x - (x - previous.x) * ratio, y: y - (y - previous.y) * ratio };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
container.addEventListener("wheel", wheel, { passive: false });
|
||||||
|
return () => container.removeEventListener("wheel", wheel);
|
||||||
|
}, [svg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !inspectorOpen || renderedSource.current === graph.source) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setError("");
|
||||||
|
async function render() {
|
||||||
|
try {
|
||||||
|
const { default: mermaid } = await import("mermaid");
|
||||||
|
if (cancelled) return;
|
||||||
|
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "default", flowchart: { htmlLabels: false, useMaxWidth: true } });
|
||||||
|
const result = await mermaid.render(`questionnaire-graph-${++renderId}`, graph.source);
|
||||||
|
if (!cancelled) {
|
||||||
|
const document = new DOMParser().parseFromString(result.svg, "image/svg+xml");
|
||||||
|
document.querySelectorAll("g.node, g.edgeLabel").forEach((element) => {
|
||||||
|
const match = element.textContent?.match(/\bR\s*(\d+)\s*:/);
|
||||||
|
if (!match) return;
|
||||||
|
const index = Number(match[1]) - 1;
|
||||||
|
const rule = graph.rules[index];
|
||||||
|
if (!rule) return;
|
||||||
|
element.setAttribute("data-rule", String(index));
|
||||||
|
element.setAttribute("tabindex", "0");
|
||||||
|
element.setAttribute("role", "button");
|
||||||
|
element.setAttribute("aria-label", `${rule.name}: ${rule.summary}. Show branch logic`);
|
||||||
|
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
||||||
|
title.textContent = `${rule.context}\n${rule.expression}`;
|
||||||
|
element.appendChild(title);
|
||||||
|
});
|
||||||
|
setWidth(Number(document.documentElement.getAttribute("viewBox")?.split(/\s+/)[2]) || 1000);
|
||||||
|
setSvg(new XMLSerializer().serializeToString(document.documentElement));
|
||||||
|
renderedSource.current = graph.source;
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void render();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [graph, open, inspectorOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="derived-panel graph-panel">
|
||||||
|
<p className="derived-note">Select a diamond or branch label to inspect its logic. Blue: current page. Green: selected question. Drag to pan; scroll to zoom.</p>
|
||||||
|
<div className="graph-controls">
|
||||||
|
<label className="graph-zoom">Zoom {zoom}% <input type="range" min="5" max="300" step="1" value={zoom} onChange={(event) => zoomAt(Number(event.target.value) / 100, (viewport.current?.clientWidth ?? 400) / 2, (viewport.current?.clientHeight ?? 480) / 2)} /></label>
|
||||||
|
<button type="button" className="secondary" onClick={focusCurrent}>Current selection</button>
|
||||||
|
{onExpand && <button type="button" className="secondary inspector-expand" onClick={onExpand} aria-label="Expand Mermaid diagram" aria-haspopup="dialog"><span aria-hidden="true">⛶</span> Expand</button>}
|
||||||
|
</div>
|
||||||
|
{error ? <p className="message error">Could not render graph: {error}</p> : !svg ? <p className="derived-note" role="status">Loading graph…</p> : (
|
||||||
|
<div ref={viewport} className="graph-viewport" tabIndex={0} role="region" aria-label="Questionnaire navigation graph"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
const rule = (event.target as Element).closest("[data-rule]")?.getAttribute("data-rule");
|
||||||
|
rulePress.current = { index: rule == null ? undefined : Number(rule), x: event.clientX, y: event.clientY, moved: false };
|
||||||
|
setSmooth(false);
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
drag.current = { x: event.clientX, y: event.clientY };
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
if (!drag.current) return;
|
||||||
|
if (rulePress.current && Math.hypot(event.clientX - rulePress.current.x, event.clientY - rulePress.current.y) > 5) rulePress.current.moved = true;
|
||||||
|
const dx = event.clientX - drag.current.x;
|
||||||
|
const dy = event.clientY - drag.current.y;
|
||||||
|
drag.current = { x: event.clientX, y: event.clientY };
|
||||||
|
setView((previous) => ({ ...previous, x: previous.x + dx, y: previous.y + dy }));
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
if (rulePress.current && !rulePress.current.moved && rulePress.current.index !== undefined) setSelectedRule(rulePress.current.index);
|
||||||
|
rulePress.current = null;
|
||||||
|
drag.current = null;
|
||||||
|
}} onPointerCancel={() => { drag.current = null; rulePress.current = null; }} onLostPointerCapture={() => { drag.current = null; rulePress.current = null; }}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
const rule = (event.target as Element).closest("[data-rule]")?.getAttribute("data-rule");
|
||||||
|
if (rule != null) { event.preventDefault(); setSelectedRule(Number(rule)); return; }
|
||||||
|
}
|
||||||
|
const direction = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] }[event.key];
|
||||||
|
if (direction) { event.preventDefault(); setSmooth(false); setView((previous) => ({ ...previous, x: previous.x + direction[0], y: previous.y + direction[1] })); }
|
||||||
|
}}>
|
||||||
|
<div className={`graph-svg${smooth ? " graph-smooth" : ""}`} style={{ width, transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}><GraphArtwork svg={svg} /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="graph-rule-detail" aria-live="polite">
|
||||||
|
{selectedRule !== undefined && graph.rules[selectedRule] ? <>
|
||||||
|
<strong>{graph.rules[selectedRule].name} · {graph.rules[selectedRule].summary}</strong>
|
||||||
|
<button className="secondary" type="button" onClick={() => setSelectedRule(undefined)} aria-label="Close branch logic">×</button>
|
||||||
|
<p>{graph.rules[selectedRule].context}</p>
|
||||||
|
<code>{graph.rules[selectedRule].expression}</code>
|
||||||
|
</> : <p>Select a condition in the graph to see its exact expression here.</p>}
|
||||||
|
</div>
|
||||||
|
<details className="graph-rules">
|
||||||
|
<summary>Branch conditions</summary>
|
||||||
|
<p>Visibility rules show or skip individual questions. Page transition rules are evaluated in order; otherwise follows the default path. Required and validation rules remain in the JSON.</p>
|
||||||
|
{graph.rules.map((rule) => <p key={rule.name}><strong>{rule.name}</strong><code>{rule.expression}</code></p>)}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { QuestionnaireEngine, type Answers, type QuestionnaireDefinition } from "../lib/questionnaire/questionnaire-engine";
|
||||||
|
import { parseResumeDraft, type ResumedDraft } from "../lib/questionnaire/resume-draft";
|
||||||
|
import { highlightJson } from "./JsonViewer";
|
||||||
|
|
||||||
|
export function ResumePanel({ definition, answers, nodeId, questionId, onResume }: {
|
||||||
|
definition: QuestionnaireDefinition; answers: Answers; nodeId: string; questionId?: string; onResume: (draft: ResumedDraft) => void;
|
||||||
|
}) {
|
||||||
|
const engine = useMemo(() => new QuestionnaireEngine(definition), [definition]);
|
||||||
|
const resumeAnswers = useMemo(() => engine.createSession(answers).getEffectiveAnswers(), [engine, answers]);
|
||||||
|
const snapshot = JSON.stringify({ answers: resumeAnswers, resume: { nodeId, questionId: questionId ?? null } }, null, 2);
|
||||||
|
const [text, setText] = useState(snapshot);
|
||||||
|
useEffect(() => { setText(snapshot); }, [snapshot]);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
return <details className="derived-panel resume-panel" open>
|
||||||
|
<summary>Simulate resume <span>Draft JSON</span></summary>
|
||||||
|
<div className="resume-editor">
|
||||||
|
<pre aria-hidden="true" className="resume-highlight">{highlightJson(text)}{"\n"}</pre>
|
||||||
|
<textarea id="resume-json" aria-label="Draft JSON" spellCheck={false} autoCapitalize="off" autoCorrect="off" rows={1} value={text} onChange={(event) => { setText(event.target.value); setMessage(""); }} />
|
||||||
|
</div>
|
||||||
|
<div className="resume-actions">
|
||||||
|
<button type="button" className="primary" onClick={() => {
|
||||||
|
try { const draft = parseResumeDraft(text, definition); onResume(draft); setError(false); setMessage(draft.message); }
|
||||||
|
catch (cause) { setError(true); setMessage(cause instanceof Error ? cause.message : String(cause)); }
|
||||||
|
}}>Simulate resume</button>
|
||||||
|
</div>
|
||||||
|
{message && <p role="status" className={error ? "message error" : "resume-message"}>{message}</p>}
|
||||||
|
</details>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { SaveEvent } from "../lib/persistence/save-events";
|
||||||
|
import { JsonViewer } from "./JsonViewer";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined) return "cleared";
|
||||||
|
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
||||||
|
if (typeof value === "string") return value.length > 28 ? `${value.slice(0, 25)}…` : value || "cleared";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventChanges(event: SaveEvent) {
|
||||||
|
if (!event.data || !isRecord(event.data)) return [];
|
||||||
|
const body = event.data;
|
||||||
|
return Object.entries(body.changes).map(([key, value]) => ({ key, value: compactValue(value) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveEventsPanel({ events, onClear }: { events: SaveEvent[]; onClear: () => void }) {
|
||||||
|
return <details className="derived-panel save-events-panel">
|
||||||
|
<summary>Save events <span>{events.length} · Preview only</span></summary>
|
||||||
|
<button type="button" className="secondary" onClick={onClear} disabled={!events.length}>Clear log</button>
|
||||||
|
<div className="save-events-list">
|
||||||
|
{!events.length && <p className="derived-note">Edit a question and leave it, or press Continue, to see what would be saved.</p>}
|
||||||
|
{events.map((event) => {
|
||||||
|
const changes = eventChanges(event);
|
||||||
|
const resume = isRecord(event.data) && isRecord(event.data.resume) ? event.data.resume : undefined;
|
||||||
|
return <article key={event.id} className={`save-event save-event-${event.status.toLowerCase()}`}>
|
||||||
|
<div className="save-event-heading">
|
||||||
|
<div className="save-event-title"><span className="save-event-dot" aria-hidden="true" /><span className="save-event-id">#{event.id}</span><strong>{event.trigger}</strong></div>
|
||||||
|
<time dateTime={event.time}>{new Date(event.time).toLocaleTimeString()}</time>
|
||||||
|
</div>
|
||||||
|
{changes.length > 0 && <div className="save-event-changes" aria-label="Changed fields">
|
||||||
|
{changes.map(change => <span className="save-change" key={change.key}><b>{change.key}</b><span>{change.value}</span></span>)}
|
||||||
|
</div>}
|
||||||
|
{resume && <p className="save-event-resume">Resume point <b>{String(resume.nodeId)}</b>{resume.questionId ? ` · ${String(resume.questionId)}` : ""}</p>}
|
||||||
|
{event.note && <p className="save-event-note">{event.note}</p>}
|
||||||
|
{event.data && <details className="save-event-payload">
|
||||||
|
<summary>View payload</summary>
|
||||||
|
<JsonViewer value={{ changes: event.data.changes, resume: event.data.resume }} />
|
||||||
|
</details>}
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</details>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState, type ReactNode, type MouseEvent } from "react";
|
||||||
|
import { DocsPage } from "./DocsPage";
|
||||||
|
|
||||||
|
export function SiteLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [path, setPath] = useState(window.location.pathname);
|
||||||
|
const docs = path.replace(/\/$/, "") === "/docs";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setPath(window.location.pathname);
|
||||||
|
window.addEventListener("popstate", sync);
|
||||||
|
return () => window.removeEventListener("popstate", sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = docs ? "Docs | QuestionGraph" : "QuestionGraph";
|
||||||
|
if (docs && window.location.hash) {
|
||||||
|
document.getElementById(decodeURIComponent(window.location.hash.slice(1)))?.scrollIntoView();
|
||||||
|
}
|
||||||
|
}, [docs]);
|
||||||
|
|
||||||
|
function navigate(event: MouseEvent<HTMLAnchorElement>, target: string) {
|
||||||
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
window.history.pushState(null, "", target);
|
||||||
|
setPath(target);
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
requestAnimationFrame(() => document.querySelector<HTMLElement>(target === "/docs" ? ".docs-page h1" : ".questionnaire-pane h1")?.focus({ preventScroll: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<header className="site-header">
|
||||||
|
<a className="site-brand" href="/" onClick={(event) => navigate(event, "/")}><span aria-hidden="true">Q</span>QuestionGraph</a>
|
||||||
|
<nav aria-label="Main navigation">
|
||||||
|
<a href="/" aria-current={!docs ? "page" : undefined} onClick={(event) => navigate(event, "/")}>Questionnaire</a>
|
||||||
|
<a href="/docs" aria-current={docs ? "page" : undefined} onClick={(event) => navigate(event, "/docs")}>Docs</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<div hidden={docs}>{children}</div>
|
||||||
|
{docs && <DocsPage />}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../lib/questionnaire/questionnaire.schema.json",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "CLAIM_LODGEMENT",
|
||||||
|
"version": "1",
|
||||||
|
"title": "Member Claim Lodgement",
|
||||||
|
"derived": {
|
||||||
|
"claimantAge": "answer('dateOfBirth') !== null ? age(answer('dateOfBirth'), today()) : null",
|
||||||
|
"daysSinceIncident": "answer('incidentDate') !== null ? daysBetween(answer('incidentDate'), today()) : null",
|
||||||
|
"daysUnableToWork": "answer('unableToWorkFrom') !== null ? daysBetween(answer('unableToWorkFrom'), answer('returnedToWorkDate') ?? today()) : null",
|
||||||
|
"isCurrentlyEmployed": "contains(['FULL_TIME', 'PART_TIME', 'CASUAL', 'SELF_EMPLOYED'], answer('employmentStatus'))",
|
||||||
|
"isRecentIncident": "answer('incidentDate') !== null && between(answer('incidentDate'), addDays(today(), -90), today())",
|
||||||
|
"requiresMedicalEvidence": "contains(['ILLNESS', 'TPD'], answer('claimType')) || derived('daysUnableToWork') >= 30",
|
||||||
|
"requiresFinancialEvidence": "answer('claimType') === 'INCOME_PROTECTION' && derived('isCurrentlyEmployed')",
|
||||||
|
"requiresManualReview": "answer('previousClaim') === true || (derived('claimantAge') !== null && derived('claimantAge') < 18) || derived('daysSinceIncident') > 3650"
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": "claimantDetails",
|
||||||
|
"title": "About you",
|
||||||
|
"description": "Basic claimant and employment details.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "dateOfBirth",
|
||||||
|
"type": "date",
|
||||||
|
"label": "What is your date of birth?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "Date of birth cannot be in the future."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "value >= addYears(today(), -120)",
|
||||||
|
"message": "Please enter a valid date of birth."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "employmentStatus",
|
||||||
|
"type": "select",
|
||||||
|
"label": "What is your current employment status?",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "FULL_TIME",
|
||||||
|
"label": "Full-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "PART_TIME",
|
||||||
|
"label": "Part-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "CASUAL",
|
||||||
|
"label": "Casual"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "SELF_EMPLOYED",
|
||||||
|
"label": "Self-employed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "UNEMPLOYED",
|
||||||
|
"label": "Unemployed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "RETIRED",
|
||||||
|
"label": "Retired"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "occupation",
|
||||||
|
"type": "text",
|
||||||
|
"label": "What is your occupation?",
|
||||||
|
"when": "derived('isCurrentlyEmployed')",
|
||||||
|
"required": true,
|
||||||
|
"constraints": {
|
||||||
|
"minLength": 2,
|
||||||
|
"maxLength": 150
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "claimDetails",
|
||||||
|
"title": "Your claim",
|
||||||
|
"description": "Tell us about your claim.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "claimType",
|
||||||
|
"type": "select",
|
||||||
|
"label": "What type of claim are you making?",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "INJURY",
|
||||||
|
"label": "Injury"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "ILLNESS",
|
||||||
|
"label": "Illness"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "TPD",
|
||||||
|
"label": "Total and Permanent Disablement"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "INCOME_PROTECTION",
|
||||||
|
"label": "Income Protection"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "incidentDate",
|
||||||
|
"type": "date",
|
||||||
|
"label": "When did the injury occur?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "The incident date cannot be in the future."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "value >= answer('dateOfBirth')",
|
||||||
|
"message": "The incident date cannot be before your date of birth."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "value >= addYears(today(), -20)",
|
||||||
|
"severity": "WARNING",
|
||||||
|
"message": "Incidents more than 20 years old may require manual review."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"when": "answer('claimType') === 'INJURY'",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "diagnosisDate",
|
||||||
|
"type": "date",
|
||||||
|
"label": "When were you first diagnosed?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "Diagnosis date cannot be in the future."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "value >= answer('dateOfBirth')",
|
||||||
|
"message": "Diagnosis date cannot be before your date of birth."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"when": "contains(['ILLNESS', 'TPD'], answer('claimType'))",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conditionDescription",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Please describe your injury or condition.",
|
||||||
|
"when": "answer('claimType') !== null",
|
||||||
|
"required": true,
|
||||||
|
"ui": {
|
||||||
|
"widget": "textarea"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"minLength": 30,
|
||||||
|
"maxLength": 3000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bodyAreas",
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Which parts of your body were affected?",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "HEAD",
|
||||||
|
"label": "Head"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "NECK",
|
||||||
|
"label": "Neck"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "BACK",
|
||||||
|
"label": "Back"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "SHOULDER",
|
||||||
|
"label": "Shoulder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "ARM",
|
||||||
|
"label": "Arm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "LEG",
|
||||||
|
"label": "Leg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "KNEE",
|
||||||
|
"label": "Knee"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "OTHER",
|
||||||
|
"label": "Other"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"when": "answer('claimType') === 'INJURY'",
|
||||||
|
"required": true,
|
||||||
|
"constraints": {
|
||||||
|
"maxItems": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "otherBodyArea",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Please describe the other affected area.",
|
||||||
|
"when": "contains(answer('bodyAreas'), 'OTHER')",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "injuryDetails",
|
||||||
|
"title": "About the injury",
|
||||||
|
"when": "answer('claimType') === 'INJURY'",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "injuryCause",
|
||||||
|
"type": "select",
|
||||||
|
"label": "How did the injury occur?",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "FALL",
|
||||||
|
"label": "Fall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "SPORT",
|
||||||
|
"label": "Sport"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "MOTOR_VEHICLE",
|
||||||
|
"label": "Motor vehicle accident"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "WORKPLACE",
|
||||||
|
"label": "Workplace incident"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "ASSAULT",
|
||||||
|
"label": "Assault"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "OTHER",
|
||||||
|
"label": "Other"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workRelated",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Was the injury related to your work?",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "motorVehicleAccident",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Did the injury involve a motor vehicle?",
|
||||||
|
"when": "answer('injuryCause') === 'MOTOR_VEHICLE' || contains(lower(answer('conditionDescription')), 'car accident') || contains(lower(answer('conditionDescription')), 'motorbike')",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workersCompClaim",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Have you lodged a workers compensation claim?",
|
||||||
|
"when": "answer('workRelated') === true || answer('injuryCause') === 'WORKPLACE'",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workersCompReference",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Workers compensation claim number",
|
||||||
|
"when": "answer('workersCompClaim') === true",
|
||||||
|
"required": true,
|
||||||
|
"constraints": {
|
||||||
|
"pattern": "^[A-Za-z0-9-]{4,30}$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "thirdPartyInvolved",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Was another person or organisation responsible?",
|
||||||
|
"when": "answer('injuryCause') === 'MOTOR_VEHICLE' || answer('injuryCause') === 'ASSAULT' || answer('workRelated') === false",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "thirdPartyDetails",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Please provide details about the third party.",
|
||||||
|
"when": "answer('thirdPartyInvolved') === true",
|
||||||
|
"required": true,
|
||||||
|
"ui": {
|
||||||
|
"widget": "textarea"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"minLength": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "illnessDetails",
|
||||||
|
"title": "About your illness",
|
||||||
|
"when": "answer('claimType') === 'ILLNESS'",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "symptomsStartedDate",
|
||||||
|
"type": "date",
|
||||||
|
"label": "When did you first experience symptoms?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "Symptoms date cannot be in the future."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "answer('diagnosisDate') === null || value <= answer('diagnosisDate')",
|
||||||
|
"message": "Symptoms should be on or before the diagnosis date."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "value >= answer('dateOfBirth')",
|
||||||
|
"message": "Symptoms date cannot be before your date of birth."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conditionCategory",
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Which categories describe your condition?",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "CANCER",
|
||||||
|
"label": "Cancer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "CARDIAC",
|
||||||
|
"label": "Heart or cardiovascular"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "NEUROLOGICAL",
|
||||||
|
"label": "Neurological"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "MUSCULOSKELETAL",
|
||||||
|
"label": "Musculoskeletal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "MENTAL_HEALTH",
|
||||||
|
"label": "Mental health"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "OTHER",
|
||||||
|
"label": "Other"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conditionOther",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Please describe the other condition.",
|
||||||
|
"when": "contains(answer('conditionCategory'), 'OTHER')",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hospitalised",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Have you been admitted to hospital because of this condition?",
|
||||||
|
"when": "answer('claimType') === 'ILLNESS' && (contains(answer('conditionCategory'), 'CANCER') || contains(answer('conditionCategory'), 'CARDIAC') || derived('claimantAge') >= 65)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "treatment",
|
||||||
|
"title": "Medical treatment",
|
||||||
|
"when": "contains(['INJURY', 'ILLNESS'], answer('claimType'))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "receivedMedicalTreatment",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Have you received medical treatment for this condition?",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "treatmentProviders",
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "Who have you received treatment from?",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "GP",
|
||||||
|
"label": "General practitioner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "SPECIALIST",
|
||||||
|
"label": "Specialist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "PHYSIO",
|
||||||
|
"label": "Physiotherapist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "PSYCHOLOGIST",
|
||||||
|
"label": "Psychologist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "HOSPITAL",
|
||||||
|
"label": "Hospital"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "OTHER",
|
||||||
|
"label": "Other"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"when": "answer('receivedMedicalTreatment') === true",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpName",
|
||||||
|
"type": "text",
|
||||||
|
"label": "What is the name of your GP?",
|
||||||
|
"when": "contains(answer('treatmentProviders'), 'GP')",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "specialistName",
|
||||||
|
"type": "text",
|
||||||
|
"label": "What is the name of your specialist?",
|
||||||
|
"when": "contains(answer('treatmentProviders'), 'SPECIALIST')",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hospitalName",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Which hospital were you admitted to?",
|
||||||
|
"when": "contains(answer('treatmentProviders'), 'HOSPITAL') || answer('hospitalised') === true",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hospitalAdmissionDate",
|
||||||
|
"type": "date",
|
||||||
|
"label": "When were you admitted to hospital?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "Admission date cannot be in the future."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "answer('incidentDate') === null || value >= answer('incidentDate')",
|
||||||
|
"message": "Admission date cannot be before the incident date."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "answer('diagnosisDate') === null || value >= addDays(answer('diagnosisDate'), -30)",
|
||||||
|
"severity": "WARNING",
|
||||||
|
"message": "Please check this date against the diagnosis date."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"when": "answer('hospitalName') !== null",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hospitalDischargeDate",
|
||||||
|
"type": "date",
|
||||||
|
"label": "When were you discharged?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value >= answer('hospitalAdmissionDate')",
|
||||||
|
"message": "Discharge date cannot be before admission date."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "Discharge date cannot be in the future."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"when": "answer('hospitalAdmissionDate') !== null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ongoingTreatment",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Are you still receiving treatment?",
|
||||||
|
"when": "answer('receivedMedicalTreatment') === true",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nextAppointmentDate",
|
||||||
|
"type": "date",
|
||||||
|
"label": "When is your next appointment?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "between(value, today(), addYears(today(), 2))",
|
||||||
|
"message": "Please enter an appointment date within the next two years."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"when": "answer('ongoingTreatment') === true"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workImpact",
|
||||||
|
"title": "Impact on work",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "unableToWork",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Has your condition prevented you from working?",
|
||||||
|
"when": "derived('isCurrentlyEmployed')",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "group",
|
||||||
|
"when": "answer('unableToWork') === true",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "unableToWorkFrom",
|
||||||
|
"type": "date",
|
||||||
|
"label": "From what date were you unable to work?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "The date cannot be in the future."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "answer('incidentDate') === null || value >= addDays(answer('incidentDate'), -7)",
|
||||||
|
"severity": "WARNING",
|
||||||
|
"message": "Please check this date against the incident date."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stillUnableToWork",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Are you still unable to work?",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "returnedToWorkDate",
|
||||||
|
"type": "date",
|
||||||
|
"label": "When did you return to work?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value >= answer('unableToWorkFrom')",
|
||||||
|
"message": "Return-to-work date must be after the date you stopped working."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "Return-to-work date cannot be in the future."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"when": "answer('unableToWork') === true && answer('stillUnableToWork') === false",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "modifiedDuties",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Are you working reduced hours or modified duties?",
|
||||||
|
"when": "answer('unableToWork') === false || answer('stillUnableToWork') === false",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "group",
|
||||||
|
"when": "answer('modifiedDuties') === true",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "hoursBefore",
|
||||||
|
"type": "number",
|
||||||
|
"label": "How many hours per week did you normally work?",
|
||||||
|
"required": true,
|
||||||
|
"constraints": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hoursNow",
|
||||||
|
"type": "number",
|
||||||
|
"label": "How many hours per week are you working now?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value <= answer('hoursBefore')",
|
||||||
|
"message": "Current hours cannot exceed your previous hours."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": true,
|
||||||
|
"constraints": {
|
||||||
|
"min": 0,
|
||||||
|
"max": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "incomeReduced",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Has your income reduced because of your condition?",
|
||||||
|
"when": "answer('modifiedDuties') === true || answer('unableToWork') === true",
|
||||||
|
"requiredWhen": "answer('claimType') === 'INCOME_PROTECTION'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "claimHistory",
|
||||||
|
"title": "Previous claims",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "previousClaim",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Have you previously made a claim relating to this condition?",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "group",
|
||||||
|
"when": "answer('previousClaim') === true",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "previousClaimTypes",
|
||||||
|
"type": "multiselect",
|
||||||
|
"label": "What type of previous claim did you make?",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "LIFE",
|
||||||
|
"label": "Life insurance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "TPD",
|
||||||
|
"label": "TPD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "INCOME_PROTECTION",
|
||||||
|
"label": "Income protection"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "WORKERS_COMP",
|
||||||
|
"label": "Workers compensation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "CTP",
|
||||||
|
"label": "CTP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "OTHER",
|
||||||
|
"label": "Other"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "previousClaimDate",
|
||||||
|
"type": "date",
|
||||||
|
"label": "Approximately when was the previous claim?",
|
||||||
|
"validation": [
|
||||||
|
{
|
||||||
|
"rule": "value <= today()",
|
||||||
|
"message": "Previous claim date cannot be in the future."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "value >= answer('dateOfBirth')",
|
||||||
|
"message": "Previous claim date cannot be before your date of birth."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "previousClaimInsurer",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Who was the claim made with?",
|
||||||
|
"requiredWhen": "contains(answer('previousClaimTypes'), 'LIFE') || contains(answer('previousClaimTypes'), 'TPD') || contains(answer('previousClaimTypes'), 'INCOME_PROTECTION')"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "documents",
|
||||||
|
"title": "Supporting documents",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "medicalDocuments",
|
||||||
|
"type": "files",
|
||||||
|
"label": "Upload medical certificates or reports.",
|
||||||
|
"when": "derived('requiresMedicalEvidence')",
|
||||||
|
"requiredWhen": "answer('claimType') === 'TPD' || derived('daysUnableToWork') >= 60",
|
||||||
|
"constraints": {
|
||||||
|
"allowedExtensions": [
|
||||||
|
"pdf",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png"
|
||||||
|
],
|
||||||
|
"maxFileSizeMb": 20,
|
||||||
|
"maxFiles": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workersCompDocuments",
|
||||||
|
"type": "files",
|
||||||
|
"label": "Upload workers compensation documents.",
|
||||||
|
"when": "answer('workersCompClaim') === true",
|
||||||
|
"constraints": {
|
||||||
|
"allowedExtensions": [
|
||||||
|
"pdf",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png"
|
||||||
|
],
|
||||||
|
"maxFileSizeMb": 20,
|
||||||
|
"maxFiles": 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "financialDocuments",
|
||||||
|
"type": "files",
|
||||||
|
"label": "Upload payslips or other evidence of income.",
|
||||||
|
"when": "derived('requiresFinancialEvidence')",
|
||||||
|
"requiredWhen": "answer('incomeReduced') === true",
|
||||||
|
"constraints": {
|
||||||
|
"allowedExtensions": [
|
||||||
|
"pdf",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png"
|
||||||
|
],
|
||||||
|
"maxFileSizeMb": 20,
|
||||||
|
"maxFiles": 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "policeReport",
|
||||||
|
"type": "files",
|
||||||
|
"label": "Upload a police report if available.",
|
||||||
|
"when": "answer('injuryCause') === 'ASSAULT' || answer('motorVehicleAccident') === true",
|
||||||
|
"constraints": {
|
||||||
|
"allowedExtensions": [
|
||||||
|
"pdf",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png"
|
||||||
|
],
|
||||||
|
"maxFileSizeMb": 20,
|
||||||
|
"maxFiles": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "otherDocuments",
|
||||||
|
"type": "files",
|
||||||
|
"label": "Upload any other supporting documents.",
|
||||||
|
"constraints": {
|
||||||
|
"allowedExtensions": [
|
||||||
|
"pdf",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png"
|
||||||
|
],
|
||||||
|
"maxFileSizeMb": 20,
|
||||||
|
"maxFiles": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "declaration",
|
||||||
|
"title": "Declaration",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "informationCorrect",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "I declare that the information I have provided is true and correct.",
|
||||||
|
"required": true,
|
||||||
|
"ui": {
|
||||||
|
"widget": "checkbox"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"mustBeTrue": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "medicalConsent",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "I consent to the insurer obtaining relevant medical information.",
|
||||||
|
"when": "derived('requiresMedicalEvidence')",
|
||||||
|
"required": true,
|
||||||
|
"ui": {
|
||||||
|
"widget": "checkbox"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"mustBeTrue": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "electronicCommunicationConsent",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "I agree to receive communications about this claim electronically.",
|
||||||
|
"ui": {
|
||||||
|
"widget": "checkbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outcomes": {
|
||||||
|
"manualReview": {
|
||||||
|
"when": "derived('requiresManualReview')"
|
||||||
|
},
|
||||||
|
"potentialWorkersComp": {
|
||||||
|
"when": "answer('workRelated') === true || answer('workersCompClaim') === true"
|
||||||
|
},
|
||||||
|
"potentialThirdPartyRecovery": {
|
||||||
|
"when": "answer('thirdPartyInvolved') === true || answer('motorVehicleAccident') === true"
|
||||||
|
},
|
||||||
|
"longDurationClaim": {
|
||||||
|
"when": "derived('daysUnableToWork') >= 90"
|
||||||
|
},
|
||||||
|
"recentInjury": {
|
||||||
|
"when": "answer('claimType') === 'INJURY' && derived('isRecentIncident')"
|
||||||
|
},
|
||||||
|
"complexMedicalClaim": {
|
||||||
|
"when": "count(answer('treatmentProviders')) >= 3 || contains(answer('conditionCategory'), 'CANCER') || contains(answer('conditionCategory'), 'NEUROLOGICAL')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../lib/questionnaire/questionnaire.schema.json",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "eventRegistration",
|
||||||
|
"version": "1",
|
||||||
|
"title": "Event registration",
|
||||||
|
"derived": {
|
||||||
|
"needsSupport": "answer('supportRequired') === true"
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": "registration",
|
||||||
|
"title": "Your registration",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "fullName",
|
||||||
|
"type": "text",
|
||||||
|
"label": "What is your name?",
|
||||||
|
"required": true,
|
||||||
|
"constraints": {
|
||||||
|
"minLength": 2,
|
||||||
|
"maxLength": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "attending",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Will you attend?",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preferences",
|
||||||
|
"title": "Your preferences",
|
||||||
|
"when": "answer('attending') === true",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "supportRequired",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Do you need accessibility support?",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "group",
|
||||||
|
"when": "derived('needsSupport')",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "supportDetails",
|
||||||
|
"type": "text",
|
||||||
|
"label": "What support do you need?",
|
||||||
|
"required": true,
|
||||||
|
"ui": {
|
||||||
|
"widget": "textarea"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outcomes": {
|
||||||
|
"arrangeSupport": {
|
||||||
|
"when": "derived('needsSupport')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { compileExpression, type Expr } from "../lib/questionnaire/dsl";
|
||||||
|
|
||||||
|
/** Labels are produced from syntax, so quote styles and nesting cannot change their meaning. */
|
||||||
|
export function summarizeExpression(source: string): string {
|
||||||
|
const words = (id: string) => id.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").toLowerCase();
|
||||||
|
const operators: Record<string, string> = { "===": "=", "!==": "≠", "&&": "and", "||": "or" };
|
||||||
|
const render = (ast: Expr): string => {
|
||||||
|
switch (ast.type) {
|
||||||
|
case "literal": return JSON.stringify(ast.value);
|
||||||
|
case "identifier": return ast.name;
|
||||||
|
case "array": return `[${ast.items.map(render).join(", ")}]`;
|
||||||
|
case "call":
|
||||||
|
if (["answer", "derived", "visible"].includes(ast.name) && ast.args[0]?.type === "literal") {
|
||||||
|
const id = words(String(ast.args[0].value));
|
||||||
|
return ast.name === "visible" ? `${id} is applicable` : id;
|
||||||
|
}
|
||||||
|
return `${ast.name}(${ast.args.map(render).join(", ")})`;
|
||||||
|
case "unary": return `${ast.operator === "!" ? "not " : ast.operator}${render(ast.argument)}`;
|
||||||
|
case "binary": return `(${render(ast.left)} ${operators[ast.operator] ?? ast.operator} ${render(ast.right)})`;
|
||||||
|
case "conditional": return `if ${render(ast.condition)} then ${render(ast.whenTrue)} else ${render(ast.whenFalse)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ast = compileExpression(source);
|
||||||
|
let summary = render(ast);
|
||||||
|
if (ast.type === "binary") summary = summary.slice(1, -1);
|
||||||
|
return summary.charAt(0).toUpperCase() + summary.slice(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import { SiteLayout } from "./SiteLayout";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<SiteLayout><App /></SiteLayout>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { SaveRequest, SaveResponse, UploadedFileRef } from "../lib";
|
||||||
|
|
||||||
|
/** Demo only: no server persistence or file transfer. */
|
||||||
|
export async function mockSave(request: SaveRequest): Promise<SaveResponse> {
|
||||||
|
return { revision: request.revision + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mockUpload(_questionId: string, files: File[]): Promise<UploadedFileRef[]> {
|
||||||
|
return files.map(file => ({
|
||||||
|
name: file.name, size: file.size, type: file.type,
|
||||||
|
uploadId: `demo-upload-${crypto.randomUUID()}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare module "*?raw" {
|
||||||
|
const source: string;
|
||||||
|
export default source;
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #172033;
|
||||||
|
background: #f5f7fb;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
.site-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 32px; background: #fff; border-bottom: 1px solid #e0e6ef; }
|
||||||
|
.site-brand { display: inline-flex; gap: 10px; align-items: center; text-decoration: none; color: #172033; font-weight: 750; letter-spacing: -.03em; }
|
||||||
|
.site-brand > span { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: #1f5eff; color: white; }
|
||||||
|
.site-header nav { display: flex; gap: 5px; }
|
||||||
|
.site-header nav a { padding: 9px 13px; color: #59677e; font-size: 13px; font-weight: 650; text-decoration: none; border-radius: 8px; }
|
||||||
|
.site-header nav a[aria-current="page"] { background: #edf3ff; color: #2457bd; }
|
||||||
|
.site-header a:focus-visible, .docs-page a:focus-visible, .docs-page summary:focus-visible, .docs-page pre:focus-visible, .docs-table-wrap:focus-visible { outline: 2px solid #1f5eff; outline-offset: 3px; }
|
||||||
|
.docs-page { display: grid; grid-template-columns: 210px minmax(0, 900px); gap: 64px; width: min(1240px, calc(100% - 64px)); margin: 56px auto 100px; }
|
||||||
|
.docs-toc { position: sticky; top: 32px; align-self: start; }
|
||||||
|
.docs-toc nav { display: grid; gap: 5px; margin-top: 20px; }
|
||||||
|
.docs-toc a { display: flex; gap: 12px; align-items: baseline; padding: 10px 0; text-decoration: none; color: #475671; font-size: 13px; }
|
||||||
|
.docs-toc a span { color: #8794a8; font: 11px ui-monospace, Consolas, monospace; }
|
||||||
|
.docs-toc a:hover { color: #1f5eff; }
|
||||||
|
.docs-toc > p:last-child { border-top: 1px solid #dfe5ee; padding-top: 20px; margin-top: 26px; color: #718097; font-size: 12px; line-height: 1.8; }
|
||||||
|
.docs-article { min-width: 0; }
|
||||||
|
.docs-article section { scroll-margin-top: 28px; padding: 40px 0; border-bottom: 1px solid #dfe5ee; }
|
||||||
|
.docs-article section:first-child { padding-top: 0; }
|
||||||
|
.docs-article section:last-child { border-bottom: 0; }
|
||||||
|
.docs-article h1 { font-size: clamp(32px, 4vw, 48px); line-height: 1.13; margin: 18px 0 22px; }
|
||||||
|
.docs-article h2 { margin: 0 0 20px; font-size: 28px; letter-spacing: -.025em; }
|
||||||
|
.docs-article h3 { margin: 28px 0 12px; font-size: 18px; }
|
||||||
|
.docs-article p, .docs-article li { font-size: 14px; line-height: 1.85; color: #526079; }
|
||||||
|
.docs-article .docs-intro { font-size: 18px; line-height: 1.75; max-width: 760px; }
|
||||||
|
.docs-article code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9em; overflow-wrap: anywhere; }
|
||||||
|
.docs-article p code, .docs-article li code { color: #2457bd; background: #eaf0fc; border-radius: 4px; padding: 2px 4px; }
|
||||||
|
.docs-concepts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 30px 0; }
|
||||||
|
.docs-concepts > div { padding: 22px; border: 1px solid #dfe5ee; border-radius: 14px; background: #fff; }
|
||||||
|
.docs-concepts span { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #476baf; }
|
||||||
|
.docs-concepts h2 { font-size: 18px; margin: 15px 0 8px; }
|
||||||
|
.docs-concepts p { font-size: 12px; margin: 0; line-height: 1.7; }
|
||||||
|
.docs-note { background: #edf3ff; border-left: 3px solid #5c85e6; padding: 18px 22px; margin: 24px 0; border-radius: 0 10px 10px 0; }
|
||||||
|
.docs-note strong { font-size: 14px; color: #294f99; }
|
||||||
|
.docs-note p { margin: 6px 0 0; }
|
||||||
|
.docs-table-wrap { overflow-x: auto; border: 1px solid #dfe5ee; border-radius: 12px; margin: 20px 0; background: #fff; }
|
||||||
|
.docs-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; line-height: 1.7; }
|
||||||
|
.docs-table th, .docs-table td { padding: 14px 18px; vertical-align: top; border-bottom: 1px solid #e9edf4; }
|
||||||
|
.docs-table thead { background: #eef2f8; color: #475671; }
|
||||||
|
.docs-table tbody th { width: 34%; font-weight: 500; color: #2457bd; }
|
||||||
|
.docs-table td { color: #526079; }
|
||||||
|
.docs-table tr:last-child > * { border-bottom: 0; }
|
||||||
|
.docs-code { background: #152036; color: #dce7fa; border-radius: 12px; padding: 22px; overflow: auto; font: 12px/1.8 ui-monospace, Consolas, monospace; tab-size: 2; }
|
||||||
|
.docs-code code { font-size: inherit; overflow-wrap: normal; }
|
||||||
|
.docs-code .syntax-comment { color: #94a3b8; font-style: italic; }
|
||||||
|
.docs-code .syntax-string { color: #a7f3d0; }
|
||||||
|
.docs-code .syntax-property { color: #93c5fd; }
|
||||||
|
.docs-code .syntax-keyword { color: #c4b5fd; }
|
||||||
|
.docs-code .syntax-number { color: #fdba74; }
|
||||||
|
.docs-code .syntax-type { color: #67e8f9; }
|
||||||
|
.docs-code .syntax-function { color: #fde68a; }
|
||||||
|
.docs-code .syntax-punctuation { color: #cbd5e1; }
|
||||||
|
.docs-example > p { margin: 16px 20px; }
|
||||||
|
.docs-download { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 24px; padding: 20px; margin: 24px 0; border: 1px solid #d8e3f7; border-radius: 12px; background: #edf3ff; }
|
||||||
|
.docs-download > a { flex-shrink: 0; padding: 12px 16px; border-radius: 8px; background: #2457bd; color: white; font-size: 13px; font-weight: 700; text-decoration: none; }
|
||||||
|
.docs-download > a:hover { background: #194798; }
|
||||||
|
.docs-download > p { flex: 1 1 260px; margin: 0; font-size: 12px; }
|
||||||
|
.docs-example, .docs-inspect details { border: 1px solid #dfe5ee; border-radius: 12px; background: #fff; overflow: hidden; }
|
||||||
|
.docs-page summary { cursor: pointer; padding: 16px 20px; color: #344d76; font-size: 13px; font-weight: 650; }
|
||||||
|
.docs-example .docs-code, .docs-inspect .docs-code { margin: 0; border-radius: 0; max-height: 560px; }
|
||||||
|
.docs-article ol { padding-left: 24px; }
|
||||||
|
.docs-article li { padding-left: 8px; margin: 14px 0; }
|
||||||
|
.docs-pipeline strong { color: #24324a; }
|
||||||
|
.docs-expression-label { display: block; margin: 20px 0 10px; font-size: 13px; font-weight: 700; }
|
||||||
|
.docs-page .docs-expression { font: 13px/1.8 ui-monospace, Consolas, monospace; }
|
||||||
|
.docs-result { margin: 12px 0; padding: 0 16px; border: 1px solid #dfe5ee; background: #fff; border-radius: 10px; }
|
||||||
|
.docs-result strong { margin-right: 12px; color: #24324a; }
|
||||||
|
.docs-result .error { color: #b4233e; }
|
||||||
|
.docs-inspect { display: grid; gap: 10px; }
|
||||||
|
.docs-source-note { margin-top: 30px; padding-top: 20px; border-top: 1px solid #dfe5ee; }
|
||||||
|
@media (max-width: 1000px) { .docs-page { gap: 32px; grid-template-columns: 180px minmax(0, 1fr); } .docs-concepts { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.site-header { padding: 12px 16px; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.site-header nav a { padding: 8px 10px; }
|
||||||
|
.docs-page { display: block; width: calc(100% - 32px); margin-top: 28px; }
|
||||||
|
.docs-toc { position: static; margin-bottom: 32px; }
|
||||||
|
.docs-toc nav { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; margin-top: 10px; }
|
||||||
|
.docs-toc > p:last-child { display: none; }
|
||||||
|
.docs-article h2 { font-size: 25px; }
|
||||||
|
.docs-table th, .docs-table td { padding: 12px; }
|
||||||
|
.docs-table tbody th { min-width: 125px; }
|
||||||
|
.docs-code { padding: 16px; }
|
||||||
|
}
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
|
||||||
|
.shell { width: min(820px, calc(100% - 32px)); margin: 48px auto; }
|
||||||
|
.app-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 18px; }
|
||||||
|
.eyebrow { margin: 0 0 8px; text-transform: uppercase; letter-spacing: .12em; font-size: 12px; font-weight: 800; color: #54627a; }
|
||||||
|
h1 { margin: 0; font-size: clamp(30px, 5vw, 46px); line-height: 1.05; letter-spacing: -.03em; }
|
||||||
|
h2 { margin-top: 28px; }
|
||||||
|
.lede { color: #68758a; margin-bottom: 0; }
|
||||||
|
.version { background: #e8eef8; padding: 7px 10px; border-radius: 999px; font-size: 12px; color: #475671; }
|
||||||
|
.progress { height: 6px; background: #e5e9f0; border-radius: 99px; overflow: hidden; margin-bottom: 18px; }
|
||||||
|
.progress > div { height: 100%; background: #1f5eff; transition: width .2s ease; }
|
||||||
|
.card { background: white; border: 1px solid #e6eaf1; border-radius: 20px; padding: 30px; box-shadow: 0 16px 50px rgba(36, 48, 72, .08); }
|
||||||
|
.question-block { padding: 22px 0; border-bottom: 1px solid #edf0f5; }
|
||||||
|
.question-block:first-child { padding-top: 0; }
|
||||||
|
.question-label { display: block; font-weight: 750; font-size: 17px; margin-bottom: 10px; }
|
||||||
|
.question-description { margin: -2px 0 10px; color: #6b7689; font-size: 14px; }
|
||||||
|
.required { color: #bd1e3d; }
|
||||||
|
input[type="text"], input[type="number"], input[type="date"], select, textarea {
|
||||||
|
width: 100%; border: 1px solid #cfd6e3; border-radius: 10px; padding: 12px 13px; color: #172033; background: #fff;
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline: 3px solid rgba(31, 94, 255, .12); border-color: #1f5eff; }
|
||||||
|
.radio-row, .check-grid { display: grid; gap: 10px; }
|
||||||
|
.radio-row { grid-template-columns: repeat(2, minmax(120px, 1fr)); max-width: 340px; }
|
||||||
|
.radio-row label, .checkbox-row { display: flex; gap: 9px; align-items: center; padding: 11px 13px; border: 1px solid #d9dfea; border-radius: 10px; background: #fbfcfe; }
|
||||||
|
.check-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
||||||
|
.message { margin-top: 8px; font-size: 14px; font-weight: 650; }
|
||||||
|
.error { color: #b4233e; }
|
||||||
|
.warning { color: #9a6700; }
|
||||||
|
.file-note { color: #66748a; font-size: 13px; }
|
||||||
|
.navigation { display: flex; justify-content: space-between; margin-top: 28px; gap: 12px; }
|
||||||
|
button { border-radius: 10px; border: 0; padding: 12px 18px; cursor: pointer; font-weight: 750; }
|
||||||
|
button:disabled { opacity: .45; cursor: default; }
|
||||||
|
.primary { background: #1f5eff; color: white; }
|
||||||
|
.secondary { background: #edf1f7; color: #24324a; }
|
||||||
|
.success-card pre { overflow: auto; background: #111827; color: #e5e7eb; padding: 16px; border-radius: 12px; font-size: 12px; }
|
||||||
|
|
||||||
|
.workspace { display: flex; align-items: flex-start; }
|
||||||
|
.questionnaire-pane { flex: 1; min-width: 0; }
|
||||||
|
.json-sidebar { position: sticky; top: 0; flex: 0 0 auto; width: clamp(390px, 42vw, 720px); height: 100dvh; background: #fafbfd; border-left: 1px solid #dfe5ee; }
|
||||||
|
.inspector-content { padding: 20px; max-height: 100dvh; overflow: auto; scrollbar-width: thin; scrollbar-color: #cbd5e1 transparent; }
|
||||||
|
.inspector-tools { border: 1px solid #e0e6ef; border-radius: 12px; background: white; overflow: hidden; margin-bottom: 22px; }
|
||||||
|
.inspector-tools .derived-panel { border: 0; border-radius: 0; margin: 0; }
|
||||||
|
.inspector-tools .derived-panel + .derived-panel { border-top: 1px solid #edf0f5; }
|
||||||
|
.inspector-tools .derived-panel > summary { padding: 14px 16px; font-size: 12px; font-weight: 650; }
|
||||||
|
.inspector-tools .derived-panel > summary::marker { color: #8a98ae; font-size: 10px; }
|
||||||
|
.inspector-tools .derived-panel > summary span { float: right; margin: 0; font-size: 10px; color: #738096; padding: 2px 7px; background: #f2f5f9; border-radius: 5px; }
|
||||||
|
.inspector-tools .derived-note { padding: 0 16px 14px; font-size: 12px; line-height: 1.65; }
|
||||||
|
.inspector-tools summary:hover { background: #f8faff; }
|
||||||
|
.inspector-content .json-code { max-height: 58dvh; }
|
||||||
|
.inspector-views { border: 1px solid #dfe5ee; border-radius: 12px; overflow: hidden; background: white; box-shadow: 0 4px 16px #24304805; }
|
||||||
|
.inspector-tabs { display: flex; gap: 4px; padding: 6px; border-bottom: 1px solid #e6eaf1; background: #f0f3f8; }
|
||||||
|
.inspector-tabs button { flex: 1; padding: 10px 6px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: #6b7890; font-size: 12px; font-weight: 650; }
|
||||||
|
.inspector-tabs button:hover { color: #2457bd; background: #ffffff80; }
|
||||||
|
.inspector-tabs button[aria-selected="true"] { background: white; color: #2457bd; border-color: #dfe5ee; box-shadow: 0 1px 3px #24304809; }
|
||||||
|
.inspector-tabs button:focus-visible { outline: 2px solid #1f5eff; outline-offset: -4px; }
|
||||||
|
.inspector-views .json-panel, .inspector-views .derived-panel { margin: 0; border: 0; border-radius: 0; }
|
||||||
|
.json-sidebar summary:focus-visible, .json-code:focus-visible { outline: 3px solid #6492ff; outline-offset: 2px; }
|
||||||
|
.json-panel { margin-top: 12px; border-radius: 12px; overflow: hidden; background: #111827; }
|
||||||
|
.json-panel-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 15px 18px; background: #192438; border-bottom: 1px solid #2a364b; color: #d9e3f4; font-size: 12px; }
|
||||||
|
.json-panel-header strong { font-weight: 500; overflow-wrap: anywhere; }
|
||||||
|
.json-panel-header .json-readonly { padding: 3px 7px; border: 1px solid #3a465b; border-radius: 5px; font-size: 10px; }
|
||||||
|
.json-panel-header span { color: #abb9d0; font-size: 12px; }
|
||||||
|
.json-code { margin: 0; padding: 20px; max-height: calc(100dvh - 180px); overflow: auto; color: #e5e7eb; font: 13px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
|
||||||
|
.json-key { color: #93c5fd; }
|
||||||
|
.json-string { color: #a7f3d0; }
|
||||||
|
.json-number { color: #fdba74; }
|
||||||
|
.json-boolean { color: #c4b5fd; }
|
||||||
|
.json-null { color: #f9a8d4; }
|
||||||
|
.json-punctuation { color: #cbd5e1; }
|
||||||
|
.json-code code { display: block; min-width: 100%; width: max-content; }
|
||||||
|
.json-line { display: block; min-height: 1.7em; border-left: 3px solid transparent; padding: 0 8px; }
|
||||||
|
.json-active-node { background: #233d61; border-left-color: #93c5fd; }
|
||||||
|
.json-active-question { background: #203f3d; border-left-color: #a7f3d0; }
|
||||||
|
|
||||||
|
.derived-panel { margin-bottom: 18px; background: white; border: 1px solid #d6deea; border-radius: 12px; overflow: hidden; }
|
||||||
|
.derived-panel > summary { cursor: pointer; padding: 16px 20px; font-weight: 750; }
|
||||||
|
.derived-panel > summary span { margin-left: 10px; color: #54627a; font-size: 12px; font-weight: 500; }
|
||||||
|
.derived-note { margin: 0; padding: 0 20px 16px; color: #54627a; font-size: 13px; }
|
||||||
|
.derived-list { margin: 0; max-height: 360px; overflow: auto; }
|
||||||
|
.inspector-views .derived-panel > .derived-note { padding: 18px; margin: 0; font-size: 12px; line-height: 1.65; }
|
||||||
|
.inspector-views .derived-list { max-height: 58dvh; }
|
||||||
|
.derived-entry { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; padding: 14px 20px; border-top: 1px solid #edf0f5; }
|
||||||
|
.derived-entry dt { font: 13px/1.6 ui-monospace, Consolas, monospace; overflow-wrap: anywhere; }
|
||||||
|
.derived-entry dd { margin: 0; min-width: 0; }
|
||||||
|
.derived-value { display: inline-block; border-radius: 6px; padding: 3px 8px; background: #edf1f7; color: #24324a; font: 13px/1.5 ui-monospace, Consolas, monospace; overflow-wrap: anywhere; }
|
||||||
|
.derived-boolean { background: #eee9ff; color: #59339b; }
|
||||||
|
.derived-number { background: #fff0df; color: #8b4600; }
|
||||||
|
.derived-null { color: #657085; }
|
||||||
|
.derived-error { background: #ffe8ec; color: #b4233e; }
|
||||||
|
.derived-expression { margin-top: 8px; font-size: 12px; color: #54627a; }
|
||||||
|
.derived-expression summary { cursor: pointer; }
|
||||||
|
.derived-expression code { display: block; margin-top: 8px; line-height: 1.6; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||||
|
.graph-zoom { display: flex; align-items: center; gap: 12px; padding: 0 20px 12px; font-size: 13px; }
|
||||||
|
.graph-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 0 12px 12px; }
|
||||||
|
.graph-controls .graph-zoom { padding: 0; }
|
||||||
|
.graph-controls button { font-size: 12px; padding: 8px; }
|
||||||
|
.graph-viewport { height: 480px; overflow: hidden; position: relative; background: #f8faff; cursor: grab; touch-action: none; user-select: none; }
|
||||||
|
.graph-viewport:active { cursor: grabbing; }
|
||||||
|
.graph-svg { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
|
||||||
|
.graph-smooth { transition: transform 280ms ease-out; }
|
||||||
|
.graph-svg .current > rect { fill: #1f5eff !important; stroke: #123ba3 !important; stroke-width: 3px !important; }
|
||||||
|
.graph-svg .selected > rect { fill: #16715d !important; stroke: #084c3d !important; stroke-width: 3px !important; }
|
||||||
|
.graph-svg .current .nodeLabel, .graph-svg .selected .nodeLabel { fill: white !important; color: white !important; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .graph-smooth { transition: none; } }
|
||||||
|
.graph-svg svg { display: block; width: 100%; height: auto; max-width: none !important; }
|
||||||
|
.graph-rules { padding: 16px 20px; font-size: 12px; color: #54627a; }
|
||||||
|
.graph-rules summary { cursor: pointer; font-weight: 750; }
|
||||||
|
.graph-rules code { display: block; margin-top: 6px; overflow-wrap: anywhere; line-height: 1.6; }
|
||||||
|
.graph-svg [data-rule] { cursor: pointer; }
|
||||||
|
.graph-svg [data-rule]:focus { outline: 2px solid #1f5eff; outline-offset: 4px; }
|
||||||
|
.graph-rule-detail { position: relative; height: 140px; overflow: auto; padding: 14px 40px 14px 16px; border-top: 1px solid #d6deea; background: #f0f5fc; font-size: 12px; }
|
||||||
|
.graph-rule-detail p { margin: 8px 0; color: #54627a; }
|
||||||
|
.graph-rule-detail code { display: block; overflow-wrap: anywhere; line-height: 1.6; }
|
||||||
|
.graph-rule-detail button { position: absolute; right: 8px; top: 8px; padding: 4px 8px; }
|
||||||
|
.save-events-panel > button { margin: 0 16px 12px; padding: 8px 12px; font-size: 12px; }
|
||||||
|
.save-events-list { max-height: 440px; overflow: auto; }
|
||||||
|
.save-event { padding: 14px 16px 16px 18px; border-top: 1px solid #d6deea; font-size: 12px; position: relative; }
|
||||||
|
.save-event::before { content: ""; position: absolute; left: 24px; top: 0; bottom: 0; border-left: 1px solid #e5eaf2; z-index: 0; }
|
||||||
|
.save-event-heading, .save-event-meta, .save-event-changes, .save-event-resume, .save-event-note, .save-event-payload { position: relative; z-index: 1; }
|
||||||
|
.save-event-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
|
||||||
|
.save-event-heading time { color: #7b8799; font-size: 11px; white-space: nowrap; }
|
||||||
|
.save-event-title { display: flex; gap: 8px; align-items: center; min-width: 0; }
|
||||||
|
.save-event-title strong { overflow-wrap: anywhere; }
|
||||||
|
.save-event-dot { width: 13px; height: 13px; flex: 0 0 auto; border: 3px solid #c9d5e8; border-radius: 50%; background: #fff; }
|
||||||
|
.save-event-saved .save-event-dot, .save-event-changed .save-event-dot { border-color: #5c85e6; }
|
||||||
|
.save-event-blocked .save-event-dot { border-color: #d85c73; }
|
||||||
|
.save-event-meta { display: flex; gap: 8px; margin: 6px 0 9px 21px; color: #6d7a90; font: 11px ui-monospace, Consolas, monospace; overflow-wrap: anywhere; }
|
||||||
|
.save-event-meta b { color: #2457bd; font-weight: 700; }
|
||||||
|
.save-event-id { color: #9aa6b8; }
|
||||||
|
.save-event-changes { display: flex; flex-wrap: wrap; gap: 6px; margin-left: 21px; }
|
||||||
|
.save-change { display: inline-flex; gap: 6px; max-width: 100%; padding: 5px 7px; border: 1px solid #e1e7f0; border-radius: 6px; background: #f7f9fc; overflow-wrap: anywhere; }
|
||||||
|
.save-change b { color: #344d76; font-weight: 650; }
|
||||||
|
.save-change span { color: #627089; }
|
||||||
|
.save-event-resume, .save-event-note { margin: 9px 0 0 21px; color: #66748a; line-height: 1.5; }
|
||||||
|
.save-event-resume { color: #53627b; }
|
||||||
|
.save-event-resume b { color: #2457bd; font-weight: 650; }
|
||||||
|
.save-event-note { font-style: italic; }
|
||||||
|
.save-event-payload { margin: 11px 0 0 21px; }
|
||||||
|
.save-event-payload summary { cursor: pointer; width: fit-content; color: #2457bd; font-size: 11px; font-weight: 700; }
|
||||||
|
.save-event-payload .json-code { margin-top: 9px; background: #111827; border-radius: 8px; max-height: 280px; }
|
||||||
|
.resume-editor { position: relative; margin: 0 16px; overflow: hidden; border: 1px solid #29374d; border-radius: 9px; background: #111827; }
|
||||||
|
.resume-editor:focus-within { border-color: #6492ff; box-shadow: 0 0 0 3px #1f5eff14; }
|
||||||
|
.resume-highlight, .resume-editor textarea { margin: 0; padding: 12px 14px; border: 0; border-radius: 0; font: 12px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: normal; tab-size: 2; white-space: pre-wrap; overflow-wrap: break-word; }
|
||||||
|
.resume-highlight { color: #e5e7eb; pointer-events: none; }
|
||||||
|
.resume-editor textarea { position: absolute; inset: 0; width: 100%; height: 100%; resize: none; overflow: hidden; background: transparent; color: transparent; caret-color: #f8fafc; -webkit-text-fill-color: transparent; }
|
||||||
|
.resume-editor textarea:focus { outline: none; }
|
||||||
|
.resume-editor textarea::selection { background: #6492ff55; }
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
.resume-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; background: Canvas; }
|
||||||
|
}
|
||||||
|
.resume-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 16px; }
|
||||||
|
.resume-actions button { padding: 10px 12px; font-size: 12px; }
|
||||||
|
.resume-panel > p.message, .resume-message { margin: 0; padding: 0 16px 16px; font-size: 13px; }
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.workspace { flex-direction: column; }
|
||||||
|
.questionnaire-pane { width: 100%; }
|
||||||
|
.json-sidebar { order: -1; position: static; width: 100%; height: auto; padding: 0; border-left: 0; border-bottom: 1px solid #dfe5ee; }
|
||||||
|
.inspector-content { max-height: 75dvh; }
|
||||||
|
.json-code { max-height: 55dvh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.shell { width: min(100% - 20px, 820px); margin: 20px auto; }
|
||||||
|
.card { padding: 20px; border-radius: 14px; }
|
||||||
|
.app-header { flex-direction: column; gap: 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-group { border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||||
|
.question-group > legend { margin-bottom: 10px; }
|
||||||
|
.question-description:empty { display: none; }
|
||||||
|
|
||||||
|
.json-header-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.inspector-expand { padding: 5px 8px; font-size: 12px; white-space: nowrap; }
|
||||||
|
.json-header-actions .inspector-expand { color: #d9e3f4; background: transparent; border: 1px solid #3a465b; border-radius: 5px; }
|
||||||
|
.json-header-actions .inspector-expand:hover { background: #2a364b; }
|
||||||
|
.inspector-expand span { margin-right: 3px; }
|
||||||
|
.inspector-modal { width: calc(100vw - 48px); max-width: 1800px; height: calc(100dvh - 48px); max-height: none; margin: auto; padding: 0; border: 1px solid #d6deea; border-radius: 16px; background: #fff; color: #172033; box-shadow: 0 24px 100px #09122555; overflow: hidden; }
|
||||||
|
.inspector-modal[open] { display: flex; flex-direction: column; }
|
||||||
|
.inspector-modal::backdrop { background: #10182cb3; backdrop-filter: blur(4px); }
|
||||||
|
.inspector-modal-header { display: flex; align-items: center; justify-content: space-between; flex: 0 0 auto; gap: 16px; padding: 18px 24px; border-bottom: 1px solid #dfe5ee; }
|
||||||
|
.inspector-modal-header .eyebrow { margin: 0 0 4px; font-size: 10px; }
|
||||||
|
.inspector-modal-header h2 { margin: 0; font-size: 20px; }
|
||||||
|
.inspector-modal-header button { flex-shrink: 0; }
|
||||||
|
.inspector-modal-content { flex: 1; min-height: 0; overflow: auto; }
|
||||||
|
.inspector-modal-json { background: #111827; overflow: hidden; }
|
||||||
|
.inspector-modal-json .json-code { height: 100%; max-height: none; box-sizing: border-box; }
|
||||||
|
.inspector-modal .graph-panel { min-height: 100%; margin: 0; border: 0; border-radius: 0; }
|
||||||
|
.inspector-modal .graph-panel > .derived-note { padding: 16px 20px; }
|
||||||
|
.inspector-modal .graph-viewport { height: max(260px, calc(100dvh - 380px)); }
|
||||||
|
.inspector-modal .graph-rule-detail { height: 110px; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.inspector-modal { width: calc(100vw - 16px); height: calc(100dvh - 16px); border-radius: 12px; }
|
||||||
|
.inspector-modal-header { padding: 12px 16px; }
|
||||||
|
.inspector-modal-header h2 { font-size: 17px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Public entry point for the reusable questionnaire library. */
|
||||||
|
export { QuestionnaireEngine, QuestionnaireSession } from "./questionnaire/questionnaire-engine";
|
||||||
|
export { compileQuestionnaire } from "./questionnaire/questionnaire-compiler";
|
||||||
|
export { compileExpression, evaluateExpression, extractDependencies, FUNCTION_SIGNATURES } from "./questionnaire/dsl";
|
||||||
|
export { parseResumeDraft } from "./questionnaire/resume-draft";
|
||||||
|
export { QuestionnaireRenderer } from "./react/QuestionnaireRenderer";
|
||||||
|
export type { SaveRequest, SaveResponse } from "./persistence/types";
|
||||||
|
export type { SaveEvent } from "./persistence/save-events";
|
||||||
|
export type { ResumedDraft } from "./questionnaire/resume-draft";
|
||||||
|
export type { QuestionnaireComponents, QuestionnaireFieldComponent, QuestionnaireFieldProps, QuestionnaireSaveHandler, FieldAccessibility } from "./react/QuestionnaireRenderer";
|
||||||
|
export type * from "./questionnaire/questionnaire-schema";
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { QuestionnaireEngine, type Answers, type QuestionnaireSession, type UploadedFileRef } from "../questionnaire/questionnaire-engine";
|
||||||
|
import type { SaveRequest, SaveResponse } from "./types";
|
||||||
|
|
||||||
|
export interface SaveEvent {
|
||||||
|
id: number;
|
||||||
|
time: string;
|
||||||
|
trigger: string;
|
||||||
|
status: "Saved" | "Changed" | "Blocked";
|
||||||
|
data?: SaveRequest;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coordinates draft changes; the host owns transport and persistence. */
|
||||||
|
export class SaveEventRecorder {
|
||||||
|
private saved: Answers;
|
||||||
|
private revision: number;
|
||||||
|
private sequence = 0;
|
||||||
|
private saveQueue: Promise<unknown> = Promise.resolve();
|
||||||
|
private position: { nodeId: string; questionId: string | null };
|
||||||
|
|
||||||
|
constructor(private engine: QuestionnaireEngine, private emit: (event: SaveEvent) => void, initialAnswers: Answers = {}, revision = 0, position?: { nodeId: string; questionId?: string }, private onSave?: (request: SaveRequest) => Promise<SaveResponse> | SaveResponse) {
|
||||||
|
this.saved = structuredClone(initialAnswers);
|
||||||
|
this.revision = revision;
|
||||||
|
this.position = { nodeId: position?.nodeId ?? engine.definition.pages[0].id, questionId: position?.questionId ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(event: Omit<SaveEvent, "id" | "time">) {
|
||||||
|
// An observer must not change the outcome of persistence.
|
||||||
|
try { this.emit(structuredClone({ ...event, id: ++this.sequence, time: new Date().toISOString() })); } catch { /* observer only */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(action: () => Promise<boolean>): Promise<boolean> {
|
||||||
|
const result = this.saveQueue.then(action);
|
||||||
|
this.saveQueue = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async patch(changes: Answers, accepted: Answers, trigger: string, nodeId: string, questionId?: string): Promise<boolean> {
|
||||||
|
const request: SaveRequest = { changes: structuredClone(changes), resume: { nodeId, questionId: questionId ?? null }, revision: this.revision, trigger };
|
||||||
|
try {
|
||||||
|
if (this.onSave) {
|
||||||
|
const response = await this.onSave(structuredClone(request));
|
||||||
|
if (!Number.isInteger(response.revision) || response.revision < 0) throw new Error("Save handler must return a valid revision.");
|
||||||
|
this.revision = response.revision;
|
||||||
|
}
|
||||||
|
this.saved = structuredClone(accepted);
|
||||||
|
this.position = request.resume;
|
||||||
|
this.record({ trigger, status: this.onSave ? "Saved" : "Changed", data: request });
|
||||||
|
return true;
|
||||||
|
} catch (cause) {
|
||||||
|
this.record({ trigger, status: "Blocked", data: request, note: cause instanceof Error ? cause.message : String(cause) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
savePosition(nodeId: string, questionId?: string, trigger = "Question focus") {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
if (this.position.nodeId === nodeId && this.position.questionId === (questionId ?? null)) return true;
|
||||||
|
return this.patch({}, this.saved, trigger, nodeId, questionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filesSelected(questionId: string, files: UploadedFileRef[], nodeId: string, session: QuestionnaireSession) {
|
||||||
|
return this.save(session, [questionId], files.length ? "Upload completion" : "File removal", nodeId, false, questionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
save(session: QuestionnaireSession, questionIds: string[], trigger: string, nodeId: string, force = false, questionId?: string): Promise<boolean> {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
const changes: Answers = {};
|
||||||
|
const accepted = { ...this.saved };
|
||||||
|
let blocked = false;
|
||||||
|
for (const id of questionIds) {
|
||||||
|
if (!session.isQuestionVisible(id)) continue;
|
||||||
|
const value = session.answers[id];
|
||||||
|
if (JSON.stringify(value) === JSON.stringify(this.saved[id])) continue;
|
||||||
|
const clearing = value == null || value === "" || (Array.isArray(value) && value.length === 0);
|
||||||
|
const errors = session.validateQuestion(id).filter(issue => issue.severity === "ERROR");
|
||||||
|
const missingUpload = this.engine.compiled.fields[id].definition.type === "files" && Array.isArray(value) && (value as UploadedFileRef[]).some(file => !file.uploadId);
|
||||||
|
if ((errors.length && !clearing) || missingUpload) {
|
||||||
|
this.blocked(trigger, id + ": " + (missingUpload ? "Files must finish uploading before saving." : errors.map(error => error.message).join(" ")));
|
||||||
|
blocked = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changes[id] = value === "" || value === undefined ? null : structuredClone(value);
|
||||||
|
accepted[id] = changes[id];
|
||||||
|
}
|
||||||
|
const previousSession = this.engine.createSession(this.saved);
|
||||||
|
const acceptedSession = this.engine.createSession(accepted);
|
||||||
|
const cleared = acceptedSession.getAnswersWithInactiveCleared();
|
||||||
|
for (const id of Object.keys(this.engine.compiled.fields)) {
|
||||||
|
if (!acceptedSession.isQuestionVisible(id) && (previousSession.isQuestionVisible(id) || accepted[id] != null)) changes[id] = null;
|
||||||
|
}
|
||||||
|
if (blocked && !Object.keys(changes).length) return false;
|
||||||
|
if (Object.keys(changes).length || force || this.position.nodeId !== nodeId || this.position.questionId !== (questionId ?? null)) {
|
||||||
|
const saved = await this.patch(changes, cleared, trigger, nodeId, questionId);
|
||||||
|
return saved && !blocked;
|
||||||
|
}
|
||||||
|
return !blocked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked(trigger: string, note: string) { this.record({ trigger, status: "Blocked", note }); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Answers } from "../questionnaire/questionnaire-schema";
|
||||||
|
|
||||||
|
export type SaveRequest = {
|
||||||
|
changes: Answers;
|
||||||
|
resume: { nodeId: string; questionId: string | null };
|
||||||
|
revision: number;
|
||||||
|
trigger: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveResponse = {
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
export type Expr =
|
||||||
|
| { type: "literal"; value: unknown }
|
||||||
|
| { type: "identifier"; name: string }
|
||||||
|
| { type: "array"; items: Expr[] }
|
||||||
|
| { type: "call"; name: string; args: Expr[] }
|
||||||
|
| { type: "unary"; operator: string; argument: Expr }
|
||||||
|
| { type: "binary"; operator: string; left: Expr; right: Expr }
|
||||||
|
| { type: "conditional"; condition: Expr; whenTrue: Expr; whenFalse: Expr };
|
||||||
|
|
||||||
|
type TokenType = "number" | "string" | "identifier" | "operator" | "punctuation" | "eof";
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
type: TokenType;
|
||||||
|
value: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATORS = ["===", "!==", ">=", "<=", "&&", "||", "??", ">", "<", "+", "-", "*", "/", "%", "!"];
|
||||||
|
const PUNCTUATION = ["(", ")", "[", "]", ",", "?", ":"];
|
||||||
|
|
||||||
|
class Lexer {
|
||||||
|
private position = 0;
|
||||||
|
|
||||||
|
constructor(private readonly input: string) {}
|
||||||
|
|
||||||
|
tokenize(): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
|
||||||
|
while (this.position < this.input.length) {
|
||||||
|
this.skipWhitespace();
|
||||||
|
if (this.position >= this.input.length) break;
|
||||||
|
|
||||||
|
const char = this.input[this.position];
|
||||||
|
|
||||||
|
if (char === "'" || char === '"') {
|
||||||
|
tokens.push(this.readString());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[0-9]/.test(char)) {
|
||||||
|
tokens.push(this.readNumber());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[A-Za-z_$]/.test(char)) {
|
||||||
|
tokens.push(this.readIdentifier());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const operator = OPERATORS.find((op) => this.input.startsWith(op, this.position));
|
||||||
|
if (operator) {
|
||||||
|
tokens.push({ type: "operator", value: operator, position: this.position });
|
||||||
|
this.position += operator.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PUNCTUATION.includes(char)) {
|
||||||
|
tokens.push({ type: "punctuation", value: char, position: this.position++ });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected character "${char}" at position ${this.position}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({ type: "eof", value: "", position: this.position });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private skipWhitespace() {
|
||||||
|
while (this.position < this.input.length && /\s/.test(this.input[this.position])) this.position++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readIdentifier(): Token {
|
||||||
|
const start = this.position;
|
||||||
|
while (this.position < this.input.length && /[A-Za-z0-9_$]/.test(this.input[this.position])) this.position++;
|
||||||
|
return { type: "identifier", value: this.input.slice(start, this.position), position: start };
|
||||||
|
}
|
||||||
|
|
||||||
|
private readNumber(): Token {
|
||||||
|
const start = this.position;
|
||||||
|
while (this.position < this.input.length && /[0-9.]/.test(this.input[this.position])) this.position++;
|
||||||
|
const value = this.input.slice(start, this.position);
|
||||||
|
if (!/^\d+(\.\d+)?$/.test(value)) throw new Error(`Invalid number "${value}"`);
|
||||||
|
return { type: "number", value, position: start };
|
||||||
|
}
|
||||||
|
|
||||||
|
private readString(): Token {
|
||||||
|
const start = this.position;
|
||||||
|
const quote = this.input[this.position++];
|
||||||
|
let value = "";
|
||||||
|
|
||||||
|
while (this.position < this.input.length) {
|
||||||
|
const char = this.input[this.position++];
|
||||||
|
if (char === quote) return { type: "string", value, position: start };
|
||||||
|
|
||||||
|
if (char === "\\") {
|
||||||
|
const next = this.input[this.position++];
|
||||||
|
const escaped: Record<string, string> = { n: "\n", r: "\r", t: "\t", "\\": "\\", "'": "'", '"': '"' };
|
||||||
|
value += escaped[next] ?? next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
value += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unterminated string at position ${start}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRECEDENCE: Record<string, number> = {
|
||||||
|
"??": 1,
|
||||||
|
"||": 2,
|
||||||
|
"&&": 3,
|
||||||
|
"===": 4,
|
||||||
|
"!==": 4,
|
||||||
|
">": 5,
|
||||||
|
">=": 5,
|
||||||
|
"<": 5,
|
||||||
|
"<=": 5,
|
||||||
|
"+": 6,
|
||||||
|
"-": 6,
|
||||||
|
"*": 7,
|
||||||
|
"/": 7,
|
||||||
|
"%": 7
|
||||||
|
};
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
private index = 0;
|
||||||
|
|
||||||
|
constructor(private readonly tokens: Token[]) {}
|
||||||
|
|
||||||
|
parse(): Expr {
|
||||||
|
const expression = this.parseConditional();
|
||||||
|
if (this.current().type !== "eof") {
|
||||||
|
throw new Error(`Unexpected token "${this.current().value}" at position ${this.current().position}`);
|
||||||
|
}
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseConditional(): Expr {
|
||||||
|
let expression = this.parseBinary(0);
|
||||||
|
if (this.match("punctuation", "?")) {
|
||||||
|
const whenTrue = this.parseConditional();
|
||||||
|
this.expect("punctuation", ":");
|
||||||
|
const whenFalse = this.parseConditional();
|
||||||
|
expression = { type: "conditional", condition: expression, whenTrue, whenFalse };
|
||||||
|
}
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBinary(minPrecedence: number): Expr {
|
||||||
|
let left = this.parseUnary();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const token = this.current();
|
||||||
|
if (token.type !== "operator") break;
|
||||||
|
const precedence = PRECEDENCE[token.value];
|
||||||
|
if (precedence === undefined || precedence < minPrecedence) break;
|
||||||
|
|
||||||
|
this.index++;
|
||||||
|
const right = this.parseBinary(precedence + 1);
|
||||||
|
left = { type: "binary", operator: token.value, left, right };
|
||||||
|
}
|
||||||
|
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUnary(): Expr {
|
||||||
|
const token = this.current();
|
||||||
|
if (token.type === "operator" && ["!", "-", "+"].includes(token.value)) {
|
||||||
|
this.index++;
|
||||||
|
return { type: "unary", operator: token.value, argument: this.parseUnary() };
|
||||||
|
}
|
||||||
|
return this.parsePrimary();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePrimary(): Expr {
|
||||||
|
const token = this.current();
|
||||||
|
|
||||||
|
if (token.type === "number") {
|
||||||
|
this.index++;
|
||||||
|
return { type: "literal", value: Number(token.value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === "string") {
|
||||||
|
this.index++;
|
||||||
|
return { type: "literal", value: token.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === "identifier") {
|
||||||
|
this.index++;
|
||||||
|
if (token.value === "true") return { type: "literal", value: true };
|
||||||
|
if (token.value === "false") return { type: "literal", value: false };
|
||||||
|
if (token.value === "null") return { type: "literal", value: null };
|
||||||
|
|
||||||
|
if (this.match("punctuation", "(")) {
|
||||||
|
const args: Expr[] = [];
|
||||||
|
if (!this.check("punctuation", ")")) {
|
||||||
|
do args.push(this.parseConditional()); while (this.match("punctuation", ","));
|
||||||
|
}
|
||||||
|
this.expect("punctuation", ")");
|
||||||
|
return { type: "call", name: token.value, args };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "identifier", name: token.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.match("punctuation", "(")) {
|
||||||
|
const expression = this.parseConditional();
|
||||||
|
this.expect("punctuation", ")");
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.match("punctuation", "[")) {
|
||||||
|
const items: Expr[] = [];
|
||||||
|
if (!this.check("punctuation", "]")) {
|
||||||
|
do items.push(this.parseConditional()); while (this.match("punctuation", ","));
|
||||||
|
}
|
||||||
|
this.expect("punctuation", "]");
|
||||||
|
return { type: "array", items };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected token "${token.value}" at position ${token.position}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private current() {
|
||||||
|
return this.tokens[this.index];
|
||||||
|
}
|
||||||
|
|
||||||
|
private check(type: TokenType, value?: string) {
|
||||||
|
const token = this.current();
|
||||||
|
return token.type === type && (value === undefined || token.value === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private match(type: TokenType, value?: string) {
|
||||||
|
if (!this.check(type, value)) return false;
|
||||||
|
this.index++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private expect(type: TokenType, value?: string) {
|
||||||
|
const token = this.current();
|
||||||
|
if (!this.match(type, value)) {
|
||||||
|
throw new Error(`Expected ${value ?? type} at position ${token.position}, found "${token.value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvaluationContext {
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
functions: Record<string, (...args: unknown[]) => unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExpressionType = "string" | "number" | "boolean" | "array" | "null";
|
||||||
|
export const FUNCTION_SIGNATURES: Record<string, { args: ExpressionType[][]; result: ExpressionType }> = {
|
||||||
|
answer: { args: [["string"]], result: "null" },
|
||||||
|
derived: { args: [["string"]], result: "null" },
|
||||||
|
visible: { args: [["string"]], result: "boolean" },
|
||||||
|
today: { args: [], result: "string" },
|
||||||
|
age: { args: [["string", "null"], ["string", "null"]], result: "number" },
|
||||||
|
daysBetween: { args: [["string", "null"], ["string", "null"]], result: "number" },
|
||||||
|
addDays: { args: [["string", "null"], ["number"]], result: "string" },
|
||||||
|
addMonths: { args: [["string", "null"], ["number"]], result: "string" },
|
||||||
|
addYears: { args: [["string", "null"], ["number"]], result: "string" },
|
||||||
|
between: { args: [["string", "number", "null"], ["string", "number", "null"], ["string", "number", "null"]], result: "boolean" },
|
||||||
|
contains: { args: [["array", "string", "null"], ["string", "number", "boolean", "null"]], result: "boolean" },
|
||||||
|
count: { args: [["array", "string", "null"]], result: "number" },
|
||||||
|
length: { args: [["array", "string", "null"]], result: "number" },
|
||||||
|
trim: { args: [["string", "null"]], result: "string" },
|
||||||
|
lower: { args: [["string", "null"]], result: "string" },
|
||||||
|
upper: { args: [["string", "null"]], result: "string" },
|
||||||
|
matches: { args: [["string", "null"], ["string"]], result: "boolean" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_IDENTIFIERS = new Set(["value"]);
|
||||||
|
|
||||||
|
export function parseExpression(expression: string): Expr {
|
||||||
|
if (expression.length > 4096) throw new Error("Expression exceeds 4096 characters");
|
||||||
|
const tokens = new Lexer(expression).tokenize();
|
||||||
|
if (tokens.length > 512) throw new Error("Expression exceeds 512 tokens");
|
||||||
|
let depth = 0;
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.type === "punctuation" && ["(", "["].includes(token.value) && ++depth > 64) throw new Error("Expression nesting exceeds 64 levels");
|
||||||
|
if (token.type === "punctuation" && [")", "]"].includes(token.value)) depth--;
|
||||||
|
}
|
||||||
|
return new Parser(tokens).parse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAst(ast: Expr): void {
|
||||||
|
switch (ast.type) {
|
||||||
|
case "literal":
|
||||||
|
return;
|
||||||
|
case "identifier":
|
||||||
|
if (!ALLOWED_IDENTIFIERS.has(ast.name)) throw new Error(`Identifier "${ast.name}" is not allowed`);
|
||||||
|
return;
|
||||||
|
case "array":
|
||||||
|
ast.items.forEach(validateAst);
|
||||||
|
return;
|
||||||
|
case "unary":
|
||||||
|
validateAst(ast.argument);
|
||||||
|
return;
|
||||||
|
case "binary":
|
||||||
|
validateAst(ast.left);
|
||||||
|
validateAst(ast.right);
|
||||||
|
return;
|
||||||
|
case "conditional":
|
||||||
|
validateAst(ast.condition);
|
||||||
|
validateAst(ast.whenTrue);
|
||||||
|
validateAst(ast.whenFalse);
|
||||||
|
return;
|
||||||
|
case "call":
|
||||||
|
if (!Object.hasOwn(FUNCTION_SIGNATURES, ast.name)) throw new Error(`Function "${ast.name}" is not allowed`);
|
||||||
|
if (ast.args.length !== FUNCTION_SIGNATURES[ast.name].args.length) throw new Error(`${ast.name}() expects ${FUNCTION_SIGNATURES[ast.name].args.length} arguments, received ${ast.args.length}`);
|
||||||
|
if (["answer", "derived", "visible"].includes(ast.name)) {
|
||||||
|
const first = ast.args[0];
|
||||||
|
if (!first || first.type !== "literal" || typeof first.value !== "string") {
|
||||||
|
throw new Error(`${ast.name}() requires a literal string ID`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ast.args.forEach(validateAst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileExpression(expression: string): Expr {
|
||||||
|
const ast = parseExpression(expression);
|
||||||
|
validateAst(ast);
|
||||||
|
return ast;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateExpression(expression: Expr, context: EvaluationContext): unknown {
|
||||||
|
switch (expression.type) {
|
||||||
|
case "literal":
|
||||||
|
return expression.value;
|
||||||
|
|
||||||
|
case "identifier": {
|
||||||
|
if (!context.variables || !(expression.name in context.variables)) {
|
||||||
|
throw new Error(`Unknown identifier "${expression.name}"`);
|
||||||
|
}
|
||||||
|
return context.variables[expression.name];
|
||||||
|
}
|
||||||
|
|
||||||
|
case "array":
|
||||||
|
return expression.items.map((x) => evaluateExpression(x, context));
|
||||||
|
|
||||||
|
case "call": {
|
||||||
|
const fn = context.functions[expression.name];
|
||||||
|
if (!fn) throw new Error(`Function "${expression.name}" is not allowed`);
|
||||||
|
return fn(...expression.args.map((x) => evaluateExpression(x, context)));
|
||||||
|
}
|
||||||
|
|
||||||
|
case "unary": {
|
||||||
|
const value = evaluateExpression(expression.argument, context);
|
||||||
|
if (expression.operator === "!") return !value;
|
||||||
|
if (expression.operator === "-") return value == null ? null : -Number(value);
|
||||||
|
if (expression.operator === "+") return value == null ? null : Number(value);
|
||||||
|
throw new Error(`Unsupported unary operator ${expression.operator}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "binary": {
|
||||||
|
if (expression.operator === "&&") {
|
||||||
|
const left = evaluateExpression(expression.left, context);
|
||||||
|
return left ? evaluateExpression(expression.right, context) : left;
|
||||||
|
}
|
||||||
|
if (expression.operator === "||") {
|
||||||
|
const left = evaluateExpression(expression.left, context);
|
||||||
|
return left ? left : evaluateExpression(expression.right, context);
|
||||||
|
}
|
||||||
|
if (expression.operator === "??") {
|
||||||
|
const left = evaluateExpression(expression.left, context);
|
||||||
|
return left === null || left === undefined ? evaluateExpression(expression.right, context) : left;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = evaluateExpression(expression.left, context) as any;
|
||||||
|
const right = evaluateExpression(expression.right, context) as any;
|
||||||
|
|
||||||
|
if (!["===", "!=="].includes(expression.operator) && (left == null || right == null)) {
|
||||||
|
return [">", ">=", "<", "<="].includes(expression.operator) ? false : null;
|
||||||
|
}
|
||||||
|
if (["/", "%"].includes(expression.operator) && right === 0) throw new Error("Division by zero");
|
||||||
|
switch (expression.operator) {
|
||||||
|
case "===": return left === right;
|
||||||
|
case "!==": return left !== right;
|
||||||
|
case ">": return left > right;
|
||||||
|
case ">=": return left >= right;
|
||||||
|
case "<": return left < right;
|
||||||
|
case "<=": return left <= right;
|
||||||
|
case "+": return left + right;
|
||||||
|
case "-": return Number(left) - Number(right);
|
||||||
|
case "*": return Number(left) * Number(right);
|
||||||
|
case "/": return Number(left) / Number(right);
|
||||||
|
case "%": return Number(left) % Number(right);
|
||||||
|
default: throw new Error(`Unsupported operator "${expression.operator}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "conditional":
|
||||||
|
return evaluateExpression(expression.condition, context)
|
||||||
|
? evaluateExpression(expression.whenTrue, context)
|
||||||
|
: evaluateExpression(expression.whenFalse, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDependencies(ast: Expr): { answers: string[]; derived: string[]; visible: string[] } {
|
||||||
|
const answers = new Set<string>();
|
||||||
|
const derived = new Set<string>();
|
||||||
|
const visible = new Set<string>();
|
||||||
|
|
||||||
|
const visit = (node: Expr) => {
|
||||||
|
if (node.type === "call" && ["answer", "derived", "visible"].includes(node.name)) {
|
||||||
|
const first = node.args[0];
|
||||||
|
if (first?.type === "literal" && typeof first.value === "string") {
|
||||||
|
if (node.name === "answer") answers.add(first.value);
|
||||||
|
if (node.name === "derived") derived.add(first.value);
|
||||||
|
if (node.name === "visible") visible.add(first.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case "array": node.items.forEach(visit); break;
|
||||||
|
case "call": node.args.forEach(visit); break;
|
||||||
|
case "unary": visit(node.argument); break;
|
||||||
|
case "binary": visit(node.left); visit(node.right); break;
|
||||||
|
case "conditional": visit(node.condition); visit(node.whenTrue); visit(node.whenFalse); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(ast);
|
||||||
|
return { answers: [...answers], derived: [...derived], visible: [...visible] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import Ajv from "ajv";
|
||||||
|
import schema from "./questionnaire.schema.json";
|
||||||
|
import { compileExpression, extractDependencies, FUNCTION_SIGNATURES, type Expr, type ExpressionType } from "./dsl";
|
||||||
|
import type { FieldDefinition, PageDefinition, QuestionDefinition, QuestionnaireDefinition } from "./questionnaire-schema";
|
||||||
|
|
||||||
|
const validateSchema = new Ajv({ allErrors: true, strict: false, discriminator: true }).compile(schema);
|
||||||
|
export interface CompiledField { definition: QuestionDefinition; pageId: string; conditions: string[]; path: string }
|
||||||
|
export interface CompiledPage { definition: PageDefinition; fieldIds: string[]; index: number }
|
||||||
|
export interface CompiledQuestionnaire {
|
||||||
|
definition: QuestionnaireDefinition;
|
||||||
|
fields: Record<string, CompiledField>;
|
||||||
|
pages: Record<string, CompiledPage>;
|
||||||
|
expressions: Map<string, Expr>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates author JSON before constructing any executable session. */
|
||||||
|
export function compileQuestionnaire(input: unknown): CompiledQuestionnaire {
|
||||||
|
if (!validateSchema(input)) {
|
||||||
|
const errors = validateSchema.errors ?? [];
|
||||||
|
// oneOf emits failed alternatives as well as the useful errors; keep the report bounded.
|
||||||
|
throw new Error(`Invalid questionnaire:\n${errors.slice(0, 12).map(e => `${e.instancePath || "/"}: ${e.message}${e.params.additionalProperty ? ` (${e.params.additionalProperty})` : ""}`).join("\n")}`);
|
||||||
|
}
|
||||||
|
const definition = structuredClone(input) as unknown as QuestionnaireDefinition;
|
||||||
|
const fields: CompiledQuestionnaire["fields"] = Object.create(null);
|
||||||
|
const pages: CompiledQuestionnaire["pages"] = Object.create(null);
|
||||||
|
const expressions = new Map<string, Expr>();
|
||||||
|
const graph = new Map<string, Set<string>>();
|
||||||
|
const paths = new Map<string, string>();
|
||||||
|
const checks: { expression: string; path: string; field?: QuestionDefinition; boolean: boolean }[] = [];
|
||||||
|
const fail = (path: string, message: string): never => { throw new Error(`${path}: ${message}`); };
|
||||||
|
const edge = (from: string, to: string) => { if (!graph.has(from)) graph.set(from, new Set()); graph.get(from)!.add(to); };
|
||||||
|
function expression(source: string, path: string, key?: string, field?: QuestionDefinition, boolean = true) {
|
||||||
|
let ast = expressions.get(source);
|
||||||
|
if (!ast) {
|
||||||
|
try { ast = compileExpression(source); } catch (error) { return fail(path, (error as Error).message); }
|
||||||
|
expressions.set(source, ast);
|
||||||
|
}
|
||||||
|
checks.push({ expression: source, path, field, boolean });
|
||||||
|
if (key) {
|
||||||
|
paths.set(key, path);
|
||||||
|
const deps = extractDependencies(ast);
|
||||||
|
for (const id of deps.answers) edge(key, `answer:${id}`);
|
||||||
|
for (const id of deps.visible) edge(key, `visible:${id}`);
|
||||||
|
for (const id of deps.derived) edge(key, `derived:${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [index, page] of definition.pages.entries()) {
|
||||||
|
if (page.id === "END" || pages[page.id]) fail(`pages[${index}].id`, `Duplicate or reserved page ID "${page.id}"`);
|
||||||
|
pages[page.id] = { definition: page, fieldIds: [], index };
|
||||||
|
}
|
||||||
|
function visit(items: FieldDefinition[], pageId: string, path: string, conditions: string[]) {
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const here = `${path}[${index}]`;
|
||||||
|
const inherited = item.when ? [...conditions, item.when] : conditions;
|
||||||
|
if (item.type === "group") {
|
||||||
|
if (item.when) expression(item.when, `${here}.when`);
|
||||||
|
visit(item.fields, pageId, `${here}.fields`, inherited);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fields[item.id]) fail(`${here}.id`, `Duplicate field ID "${item.id}"`);
|
||||||
|
fields[item.id] = { definition: item, pageId, conditions: inherited, path: here };
|
||||||
|
pages[pageId].fieldIds.push(item.id);
|
||||||
|
paths.set(`answer:${item.id}`, here);
|
||||||
|
edge(`answer:${item.id}`, `visible:${item.id}`);
|
||||||
|
edge(`visible:${item.id}`, `page:${pageId}`);
|
||||||
|
for (const condition of inherited) expression(condition, `${here}.when (including ancestors)`, `visible:${item.id}`);
|
||||||
|
if (item.requiredWhen) expression(item.requiredWhen, `${here}.requiredWhen`, undefined, item);
|
||||||
|
item.validation?.forEach((rule, i) => {
|
||||||
|
expression(rule.rule, `${here}.validation[${i}].rule`, undefined, item);
|
||||||
|
if (rule.when) expression(rule.when, `${here}.validation[${i}].when`, undefined, item);
|
||||||
|
});
|
||||||
|
if (item.type === "select" || item.type === "multiselect") {
|
||||||
|
const values = item.options.map(o => o.value);
|
||||||
|
if (new Set(values).size !== values.length) fail(`${here}.options`, "Option values must be unique");
|
||||||
|
}
|
||||||
|
const c = item.constraints as Record<string, unknown> | undefined;
|
||||||
|
for (const [min, max] of [["min", "max"], ["minLength", "maxLength"], ["minItems", "maxItems"]]) {
|
||||||
|
if (c?.[min] !== undefined && c[max] !== undefined && c[min]! > c[max]!) fail(`${here}.constraints`, `${min} must not exceed ${max}`);
|
||||||
|
}
|
||||||
|
if (item.type === "date") for (const bound of [item.constraints?.min, item.constraints?.max]) {
|
||||||
|
if (bound && !isISODate(bound)) fail(`${here}.constraints`, "Date bounds must be valid YYYY-MM-DD dates");
|
||||||
|
}
|
||||||
|
if (item.type === "text" && item.constraints?.pattern) {
|
||||||
|
try { validatePattern(item.constraints.pattern); } catch (error) { fail(`${here}.constraints.pattern`, (error as Error).message); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [index, page] of definition.pages.entries()) {
|
||||||
|
const path = `pages[${index}]`;
|
||||||
|
paths.set(`page:${page.id}`, path);
|
||||||
|
if (page.when) expression(page.when, `${path}.when`, `page:${page.id}`);
|
||||||
|
visit(page.fields, page.id, `${path}.fields`, []);
|
||||||
|
if (page.route) {
|
||||||
|
edge(`route:${page.id}`, `page:${page.id}`);
|
||||||
|
page.route.cases.forEach((c, i) => expression(c.when, `${path}.route.cases[${i}].when`, `route:${page.id}`));
|
||||||
|
for (const to of [...page.route.cases.map(c => c.to), page.route.otherwise]) {
|
||||||
|
if (to !== "END" && (!pages[to] || pages[to].index <= index)) fail(`${path}.route`, `Destination "${to}" must be a later page or END`);
|
||||||
|
}
|
||||||
|
for (const later of definition.pages.slice(index + 1)) edge(`page:${later.id}`, `route:${page.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, source] of Object.entries(definition.derived ?? {})) expression(source, `derived.${id}`, `derived:${id}`, undefined, false);
|
||||||
|
for (const [id, outcome] of Object.entries(definition.outcomes ?? {})) expression(outcome.when, `outcomes.${id}.when`);
|
||||||
|
for (const check of checks) {
|
||||||
|
const deps = extractDependencies(expressions.get(check.expression)!);
|
||||||
|
for (const id of [...deps.answers, ...deps.visible]) if (!fields[id]) fail(check.path, `Unknown field "${id}"`);
|
||||||
|
for (const id of deps.derived) if (!Object.hasOwn(definition.derived ?? {}, id)) fail(check.path, `Unknown derived value "${id}"`);
|
||||||
|
}
|
||||||
|
const done = new Set<string>();
|
||||||
|
const stack: string[] = [];
|
||||||
|
function visitDependency(key: string) {
|
||||||
|
if (stack.includes(key)) fail(paths.get(key) ?? key, `Circular dependency: ${[...stack.slice(stack.indexOf(key)), key].join(" → ")}`);
|
||||||
|
if (done.has(key)) return;
|
||||||
|
stack.push(key);
|
||||||
|
for (const dependency of graph.get(key) ?? []) visitDependency(dependency);
|
||||||
|
stack.pop(); done.add(key);
|
||||||
|
}
|
||||||
|
for (const key of graph.keys()) visitDependency(key);
|
||||||
|
|
||||||
|
const fieldType = (field: QuestionDefinition): ExpressionType => field.type === "files" || field.type === "multiselect" ? "array" : field.type === "date" || field.type === "text" || field.type === "select" ? "string" : field.type;
|
||||||
|
const derivedTypes = new Map<string, Set<ExpressionType>>();
|
||||||
|
const union = (...sets: Set<ExpressionType>[]) => new Set(sets.flatMap(s => [...s]));
|
||||||
|
function infer(ast: Expr, path: string, field?: QuestionDefinition): Set<ExpressionType> {
|
||||||
|
const one = (t: ExpressionType) => new Set([t]);
|
||||||
|
const accepts = (types: Set<ExpressionType>, allowed: ExpressionType[], message: string) => {
|
||||||
|
if ([...types].some(t => !allowed.includes(t))) fail(path, message);
|
||||||
|
};
|
||||||
|
switch (ast.type) {
|
||||||
|
case "literal": return one(ast.value === null ? "null" : typeof ast.value as ExpressionType);
|
||||||
|
case "identifier": if (!field) return fail(path, "value is only available inside field validation and requiredWhen"); return new Set([fieldType(field), "null"]);
|
||||||
|
case "array": ast.items.forEach(x => infer(x, path, field)); return one("array");
|
||||||
|
case "call": {
|
||||||
|
const args = ast.args.map(x => infer(x, path, field));
|
||||||
|
const signature = FUNCTION_SIGNATURES[ast.name];
|
||||||
|
args.forEach((types, i) => accepts(types, signature.args[i], `${ast.name}() argument ${i + 1} expects ${signature.args[i].join(" or ")}`));
|
||||||
|
const id = ast.args[0]?.type === "literal" ? String(ast.args[0].value) : "";
|
||||||
|
if (ast.name === "answer") return new Set([fieldType(fields[id].definition), "null"]);
|
||||||
|
if (ast.name === "derived") {
|
||||||
|
if (!derivedTypes.has(id)) derivedTypes.set(id, infer(expressions.get(definition.derived![id])!, `derived.${id}`));
|
||||||
|
return derivedTypes.get(id)!;
|
||||||
|
}
|
||||||
|
if (ast.name === "matches" && ast.args[1].type === "literal") {
|
||||||
|
try { validatePattern(String(ast.args[1].value)); } catch (error) { fail(path, (error as Error).message); }
|
||||||
|
}
|
||||||
|
return new Set([signature.result, ...(["age", "daysBetween", "addDays", "addMonths", "addYears"].includes(ast.name) && args.some(types => types.has("null")) ? ["null" as const] : [])]);
|
||||||
|
}
|
||||||
|
case "unary": {
|
||||||
|
accepts(infer(ast.argument, path, field), ast.operator === "!" ? ["boolean", "null"] : ["number", "null"], `Invalid operand for ${ast.operator}`);
|
||||||
|
return ast.operator === "!" ? one("boolean") : infer(ast.argument, path, field);
|
||||||
|
}
|
||||||
|
case "conditional": {
|
||||||
|
accepts(infer(ast.condition, path, field), ["boolean"], "Conditional test must be boolean");
|
||||||
|
return union(infer(ast.whenTrue, path, field), infer(ast.whenFalse, path, field));
|
||||||
|
}
|
||||||
|
case "binary": {
|
||||||
|
const left = infer(ast.left, path, field), right = infer(ast.right, path, field);
|
||||||
|
if (ast.operator === "??") return union(new Set([...left].filter(t => t !== "null")), right);
|
||||||
|
if (["&&", "||"].includes(ast.operator)) {
|
||||||
|
accepts(left, ["boolean"], "Logical operands must be boolean"); accepts(right, ["boolean"], "Logical operands must be boolean"); return one("boolean");
|
||||||
|
}
|
||||||
|
if (["===", "!=="].includes(ast.operator)) return one("boolean");
|
||||||
|
if ([">", ">=", "<", "<="].includes(ast.operator)) {
|
||||||
|
accepts(union(left, right), ["number", "string", "null"], "Comparison requires numbers or strings");
|
||||||
|
if (union(left, right).has("number") && union(left, right).has("string")) fail(path, "Cannot compare numbers with strings");
|
||||||
|
return one("boolean");
|
||||||
|
}
|
||||||
|
accepts(union(left, right), ["number", "null"], "Arithmetic requires numbers");
|
||||||
|
return new Set(["number", ...(left.has("null") || right.has("null") ? ["null" as const] : [])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const check of checks) {
|
||||||
|
const types = infer(expressions.get(check.expression)!, check.path, check.field);
|
||||||
|
if (check.boolean && (types.size !== 1 || !types.has("boolean"))) fail(check.path, "Condition must return a boolean; use an explicit comparison or ?? false");
|
||||||
|
}
|
||||||
|
freezeData(definition);
|
||||||
|
return { definition, fields, pages, expressions };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isISODate(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T00:00:00Z`)) && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
|
||||||
|
}
|
||||||
|
export function validatePattern(pattern: string) {
|
||||||
|
if (pattern.length > 200) throw new Error("Pattern exceeds 200 characters");
|
||||||
|
new RegExp(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freeze JSON data to keep compilation and per-session caches trustworthy. */
|
||||||
|
export function freezeData<T>(value: T): T {
|
||||||
|
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
||||||
|
for (const child of Object.values(value)) freezeData(child);
|
||||||
|
Object.freeze(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { compileExpression, evaluateExpression, extractDependencies, type Expr } from "./dsl";
|
||||||
|
import { compileQuestionnaire, freezeData, isISODate, validatePattern, type CompiledQuestionnaire } from "./questionnaire-compiler";
|
||||||
|
import type { Answers, AnswerValue, QuestionDefinition, ValidationResult } from "./questionnaire-schema";
|
||||||
|
export type * from "./questionnaire-schema";
|
||||||
|
|
||||||
|
function parseDate(value: unknown): Date {
|
||||||
|
if (!isISODate(value)) {
|
||||||
|
throw new Error(`Expected ISO date YYYY-MM-DD, received "${String(value)}"`);
|
||||||
|
}
|
||||||
|
const date = new Date(`${value}T00:00:00Z`);
|
||||||
|
if (Number.isNaN(date.getTime())) throw new Error(`Invalid date "${value}"`);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return [date.getUTCFullYear(), String(date.getUTCMonth() + 1).padStart(2, "0"), String(date.getUTCDate()).padStart(2, "0")].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToday(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateValue: unknown, daysValue: unknown) {
|
||||||
|
const date = parseDate(dateValue);
|
||||||
|
date.setUTCDate(date.getUTCDate() + Number(daysValue));
|
||||||
|
return formatDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(dateValue: unknown, monthsValue: unknown) {
|
||||||
|
const original = parseDate(dateValue);
|
||||||
|
const desiredDay = original.getUTCDate();
|
||||||
|
const target = new Date(Date.UTC(original.getUTCFullYear(), original.getUTCMonth() + Number(monthsValue), 1));
|
||||||
|
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
||||||
|
target.setUTCDate(Math.min(desiredDay, lastDay));
|
||||||
|
return formatDate(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addYears(dateValue: unknown, yearsValue: unknown) {
|
||||||
|
return addMonths(dateValue, Number(yearsValue) * 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(startValue: unknown, endValue: unknown) {
|
||||||
|
return Math.floor((parseDate(endValue).getTime() - parseDate(startValue).getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function age(birthDateValue: unknown, atDateValue: unknown) {
|
||||||
|
const birth = parseDate(birthDateValue);
|
||||||
|
const at = parseDate(atDateValue);
|
||||||
|
let years = at.getUTCFullYear() - birth.getUTCFullYear();
|
||||||
|
const beforeBirthday = at.getUTCMonth() < birth.getUTCMonth() || (at.getUTCMonth() === birth.getUTCMonth() && at.getUTCDate() < birth.getUTCDate());
|
||||||
|
if (beforeBirthday) years--;
|
||||||
|
return years;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmpty(value: unknown): boolean {
|
||||||
|
return value == null || (typeof value === "string" && value.trim() === "") || (Array.isArray(value) && value.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuestionnaireEngine {
|
||||||
|
readonly compiled: CompiledQuestionnaire;
|
||||||
|
get definition() { return this.compiled.definition; }
|
||||||
|
constructor(input: unknown) { this.compiled = compileQuestionnaire(input); }
|
||||||
|
createSession(answers: Answers, context: { today?: string } = {}) {
|
||||||
|
return new QuestionnaireSession(this, freezeData(structuredClone(answers)), context.today ?? getToday());
|
||||||
|
}
|
||||||
|
compile(expression: string): Expr { return this.compiled.expressions.get(expression) ?? compileExpression(expression); }
|
||||||
|
getExpressionDependencies(expression: string) { return extractDependencies(this.compile(expression)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvaluatedField {
|
||||||
|
definition: QuestionDefinition;
|
||||||
|
value: AnswerValue;
|
||||||
|
required: boolean;
|
||||||
|
validation: ValidationResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuestionnaireSession {
|
||||||
|
private readonly derivedCache = new Map<string, unknown>();
|
||||||
|
private readonly visibilityCache = new Map<string, boolean>();
|
||||||
|
private readonly pageCache = new Map<string, boolean>();
|
||||||
|
private readonly stack = new Set<string>();
|
||||||
|
constructor(readonly engine: QuestionnaireEngine, readonly answers: Answers, readonly today: string) {
|
||||||
|
if (!isISODate(today)) throw new Error("Session today must be a valid YYYY-MM-DD date");
|
||||||
|
for (const id of Object.keys(answers)) if (!engine.compiled.fields[id]) throw new Error(`Unknown field "${id}"`);
|
||||||
|
}
|
||||||
|
private guard<T>(key: string, evaluate: () => T): T {
|
||||||
|
if (this.stack.has(key)) throw new Error(`Circular evaluation involving ${key}`);
|
||||||
|
this.stack.add(key);
|
||||||
|
try { return evaluate(); } finally { this.stack.delete(key); }
|
||||||
|
}
|
||||||
|
evaluate(expression: string, value?: unknown): unknown {
|
||||||
|
const nullable = (fn: (...args: any[]) => unknown) => (...args: unknown[]) => args.some(x => x == null) ? null : fn(...args);
|
||||||
|
return evaluateExpression(this.engine.compile(expression), {
|
||||||
|
variables: { value: value ?? null },
|
||||||
|
functions: {
|
||||||
|
answer: id => this.getAnswer(String(id)),
|
||||||
|
derived: id => this.getDerived(String(id)),
|
||||||
|
visible: id => this.isQuestionVisible(String(id)),
|
||||||
|
today: () => this.today,
|
||||||
|
age: nullable(age), daysBetween: nullable(daysBetween), addDays: nullable(addDays), addMonths: nullable(addMonths), addYears: nullable(addYears),
|
||||||
|
between: (value, min, max) => value != null && min != null && max != null && typeof value === typeof min && typeof value === typeof max && (value as any) >= min && (value as any) <= max,
|
||||||
|
contains: (collection, value) => Array.isArray(collection) ? collection.includes(value) : typeof collection === "string" ? collection.includes(String(value)) : false,
|
||||||
|
count: value => Array.isArray(value) || typeof value === "string" ? value.length : 0,
|
||||||
|
length: value => Array.isArray(value) || typeof value === "string" ? value.length : 0,
|
||||||
|
trim: value => String(value ?? "").trim(), lower: value => String(value ?? "").toLowerCase(), upper: value => String(value ?? "").toUpperCase(),
|
||||||
|
matches: (value, pattern) => { validatePattern(String(pattern)); return new RegExp(String(pattern)).test(String(value ?? "")); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
condition(expression: string, value?: unknown): boolean {
|
||||||
|
const result = this.evaluate(expression, value);
|
||||||
|
if (typeof result !== "boolean") throw new Error(`Condition did not return boolean: ${expression}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
getAnswer(id: string): AnswerValue {
|
||||||
|
if (!this.engine.compiled.fields[id]) throw new Error(`Unknown field "${id}"`);
|
||||||
|
const value = this.answers[id];
|
||||||
|
// Invalid or empty draft values are absent from business rules, but still render for correction.
|
||||||
|
return this.isQuestionVisible(id) && !isEmpty(value) && !this.validateAnswerType(id, value) ? value : null;
|
||||||
|
}
|
||||||
|
getDerived(id: string): unknown {
|
||||||
|
if (this.derivedCache.has(id)) return this.derivedCache.get(id);
|
||||||
|
if (!Object.hasOwn(this.engine.definition.derived ?? {}, id)) throw new Error(`Unknown derived value "${id}"`);
|
||||||
|
return this.guard(`derived:${id}`, () => {
|
||||||
|
const value = this.evaluate(this.engine.definition.derived![id]);
|
||||||
|
this.derivedCache.set(id, value); return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isPageApplicable(id: string): boolean {
|
||||||
|
if (this.pageCache.has(id)) return this.pageCache.get(id)!;
|
||||||
|
const page = this.engine.compiled.pages[id];
|
||||||
|
if (!page) throw new Error(`Unknown page "${id}"`);
|
||||||
|
return this.guard(`page:${id}`, () => {
|
||||||
|
let active = true;
|
||||||
|
for (const previous of this.engine.definition.pages.slice(0, page.index)) {
|
||||||
|
if (!previous.route || !this.isPageApplicable(previous.id)) continue;
|
||||||
|
const target = previous.route.cases.find(c => this.condition(c.when))?.to ?? previous.route.otherwise;
|
||||||
|
if (target === "END" || this.engine.compiled.pages[target].index > page.index) { active = false; break; }
|
||||||
|
}
|
||||||
|
if (active && page.definition.when) active = this.condition(page.definition.when);
|
||||||
|
this.pageCache.set(id, active); return active;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isQuestionVisible(id: string): boolean {
|
||||||
|
if (this.visibilityCache.has(id)) return this.visibilityCache.get(id)!;
|
||||||
|
const field = this.engine.compiled.fields[id];
|
||||||
|
if (!field) throw new Error(`Unknown field "${id}"`);
|
||||||
|
return this.guard(`visible:${id}`, () => {
|
||||||
|
const visible = this.isPageApplicable(field.pageId) && field.conditions.every(c => this.condition(c));
|
||||||
|
this.visibilityCache.set(id, visible); return visible;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isQuestionRequired(id: string): boolean {
|
||||||
|
const field = this.engine.compiled.fields[id].definition;
|
||||||
|
return this.isQuestionVisible(id) && ((field.type === "boolean" && field.constraints?.mustBeTrue) || (field.required ?? (field.requiredWhen ? this.condition(field.requiredWhen, this.getAnswer(id)) : false)));
|
||||||
|
}
|
||||||
|
validateAnswerType(id: string, value: unknown): string | undefined {
|
||||||
|
if (value == null) return;
|
||||||
|
const field = this.engine.compiled.fields[id].definition;
|
||||||
|
let valid = false;
|
||||||
|
switch (field.type) {
|
||||||
|
case "text": valid = typeof value === "string"; break;
|
||||||
|
case "number": valid = typeof value === "number" && Number.isFinite(value); break;
|
||||||
|
case "date": valid = isISODate(value); break;
|
||||||
|
case "boolean": valid = typeof value === "boolean"; break;
|
||||||
|
case "select": valid = typeof value === "string" && field.options.some(o => o.value === value); break;
|
||||||
|
case "multiselect": valid = Array.isArray(value) && new Set(value).size === value.length && value.every(v => field.options.some(o => o.value === v)); break;
|
||||||
|
case "files": valid = Array.isArray(value) && value.every(f => f && typeof f === "object" && typeof f.name === "string" && typeof f.size === "number" && Number.isFinite(f.size) && f.size >= 0 && typeof f.type === "string"); break;
|
||||||
|
}
|
||||||
|
return valid ? undefined : `Enter a valid ${field.type} answer.`;
|
||||||
|
}
|
||||||
|
validateQuestion(id: string): ValidationResult[] {
|
||||||
|
if (!this.isQuestionVisible(id)) return [];
|
||||||
|
const field = this.engine.compiled.fields[id].definition;
|
||||||
|
const value = this.answers[id];
|
||||||
|
const results: ValidationResult[] = [];
|
||||||
|
const error = (message: string) => { results.push({ questionId: id, message, severity: "ERROR" }); };
|
||||||
|
const invalid = this.validateAnswerType(id, value);
|
||||||
|
if (invalid) { error(invalid); return results; }
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
if (this.isQuestionRequired(id) || (field.type === "boolean" && field.constraints?.mustBeTrue)) error("This question is required.");
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
switch (field.type) {
|
||||||
|
case "text": {
|
||||||
|
const text = value as string, c = field.constraints;
|
||||||
|
if (c?.minLength !== undefined && text.trim().length < c.minLength) error(`Enter at least ${c.minLength} characters.`);
|
||||||
|
if (c?.maxLength !== undefined && text.length > c.maxLength) error(`Enter at most ${c.maxLength} characters.`);
|
||||||
|
if (c?.pattern && !new RegExp(c.pattern).test(text)) error("Use the requested format.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "number": {
|
||||||
|
const number = value as number, c = field.constraints;
|
||||||
|
if (c?.min !== undefined && number < c.min) error(`Enter ${c.min} or more.`);
|
||||||
|
if (c?.max !== undefined && number > c.max) error(`Enter ${c.max} or less.`);
|
||||||
|
if (c?.integer && !Number.isInteger(number)) error("Enter a whole number.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "date": {
|
||||||
|
const date = value as string, c = field.constraints;
|
||||||
|
if (c?.min && date < c.min) error(`Enter a date on or after ${c.min}.`);
|
||||||
|
if (c?.max && date > c.max) error(`Enter a date on or before ${c.max}.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "boolean": if (field.constraints?.mustBeTrue && value !== true) error("You must accept this declaration."); break;
|
||||||
|
case "multiselect": {
|
||||||
|
const count = (value as string[]).length, c = field.constraints;
|
||||||
|
if (c?.minItems !== undefined && count < c.minItems) error(`Select at least ${c.minItems} options.`);
|
||||||
|
if (c?.maxItems !== undefined && count > c.maxItems) error(`Select at most ${c.maxItems} options.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "files": {
|
||||||
|
const files = value as import("./questionnaire-schema").UploadedFileRef[], c = field.constraints;
|
||||||
|
if (c?.maxFiles !== undefined && files.length > c.maxFiles) error(`Upload at most ${c.maxFiles} files.`);
|
||||||
|
for (const file of files) {
|
||||||
|
if (c?.maxFileSizeMb !== undefined && file.size > c.maxFileSizeMb * 1024 * 1024) error(`${file.name} exceeds ${c.maxFileSizeMb} MB.`);
|
||||||
|
if (c?.allowedExtensions && !c.allowedExtensions.includes(file.name.split(".").pop()!.toLowerCase())) error(`${file.name} has an unsupported extension.`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const validation of field.validation ?? []) {
|
||||||
|
try {
|
||||||
|
if (validation.when && !this.condition(validation.when, value)) continue;
|
||||||
|
if (!this.condition(validation.rule, value)) results.push({ questionId: id, severity: validation.severity ?? "ERROR", message: validation.message });
|
||||||
|
} catch (cause) { error(`Unable to evaluate validation: ${(cause as Error).message}`); }
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
getAnswersWithInactiveCleared(): Answers {
|
||||||
|
return Object.fromEntries(Object.entries(this.answers).map(([id, value]) =>
|
||||||
|
[id, this.isQuestionVisible(id) ? value : null]));
|
||||||
|
}
|
||||||
|
|
||||||
|
validateNode(id: string): ValidationResult[] { return this.engine.compiled.pages[id].fieldIds.flatMap(field => this.validateQuestion(field)); }
|
||||||
|
buildJourney(): string[] { return this.engine.definition.pages.filter(p => this.isPageApplicable(p.id)).map(p => p.id); }
|
||||||
|
getNextNode(id: string): string {
|
||||||
|
return this.buildJourney().find(next => this.engine.compiled.pages[next].index > this.engine.compiled.pages[id].index) ?? "END";
|
||||||
|
}
|
||||||
|
validateJourney(): ValidationResult[] { return this.buildJourney().flatMap(id => this.validateNode(id)); }
|
||||||
|
getEffectiveAnswers(): Answers {
|
||||||
|
return Object.fromEntries(Object.keys(this.engine.compiled.fields).filter(id => this.isQuestionVisible(id) && this.answers[id] !== undefined).map(id => [id, this.getAnswer(id)]));
|
||||||
|
}
|
||||||
|
getOutcomes(): Record<string, boolean> {
|
||||||
|
return Object.fromEntries(Object.entries(this.engine.definition.outcomes ?? {}).map(([id, outcome]) => [id, this.condition(outcome.when)]));
|
||||||
|
}
|
||||||
|
getPageState(id: string) {
|
||||||
|
const journey = this.buildJourney();
|
||||||
|
const index = journey.indexOf(id);
|
||||||
|
return {
|
||||||
|
definition: this.engine.compiled.pages[id].definition,
|
||||||
|
fields: this.engine.compiled.pages[id].fieldIds.filter(id => this.isQuestionVisible(id)).map(id => ({ definition: this.engine.compiled.fields[id].definition, value: this.answers[id], required: this.isQuestionRequired(id), validation: this.validateQuestion(id) })),
|
||||||
|
previous: journey[index - 1], next: journey[index + 1] ?? "END",
|
||||||
|
progress: journey.length ? (index + 1) / journey.length * 100 : 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type UploadedFileRef = { name: string; size: number; type: string; uploadId?: string };
|
||||||
|
export type AnswerValue = string | number | boolean | string[] | UploadedFileRef[] | null | undefined;
|
||||||
|
export type Answers = Record<string, AnswerValue>;
|
||||||
|
export interface ValidationRule { rule: string; when?: string; message: string; severity?: "ERROR" | "WARNING" }
|
||||||
|
export interface QuestionOption { value: string; label: string }
|
||||||
|
interface BaseField {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
when?: string;
|
||||||
|
required?: boolean;
|
||||||
|
requiredWhen?: string;
|
||||||
|
validation?: ValidationRule[];
|
||||||
|
}
|
||||||
|
export type QuestionDefinition = BaseField & (
|
||||||
|
| { type: "text"; constraints?: { minLength?: number; maxLength?: number; pattern?: string }; ui?: { widget: "input" | "textarea" } }
|
||||||
|
| { type: "number"; constraints?: { min?: number; max?: number; integer?: boolean }; ui?: { widget: "number" | "currency" } }
|
||||||
|
| { type: "date"; constraints?: { min?: string; max?: string }; ui?: never }
|
||||||
|
| { type: "boolean"; constraints?: { mustBeTrue?: boolean }; ui?: { widget: "radio" | "checkbox" } }
|
||||||
|
| { type: "select"; options: QuestionOption[]; constraints?: never; ui?: { widget: "select" | "radio" } }
|
||||||
|
| { type: "multiselect"; options: QuestionOption[]; constraints?: { minItems?: number; maxItems?: number }; ui?: never }
|
||||||
|
| { type: "files"; constraints?: { allowedExtensions?: string[]; maxFileSizeMb?: number; maxFiles?: number }; ui?: never }
|
||||||
|
);
|
||||||
|
export interface FieldGroup { type: "group"; title?: string; description?: string; when?: string; fields: FieldDefinition[] }
|
||||||
|
export type FieldDefinition = QuestionDefinition | FieldGroup;
|
||||||
|
export interface PageDefinition {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
when?: string;
|
||||||
|
fields: FieldDefinition[];
|
||||||
|
route?: { cases: { when: string; to: string }[]; otherwise: string };
|
||||||
|
}
|
||||||
|
export interface QuestionnaireDefinition {
|
||||||
|
$schema?: string;
|
||||||
|
schemaVersion: 1;
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
title: string;
|
||||||
|
derived?: Record<string, string>;
|
||||||
|
pages: PageDefinition[];
|
||||||
|
outcomes?: Record<string, { when: string }>;
|
||||||
|
}
|
||||||
|
export interface ValidationResult { questionId: string; message: string; severity: "ERROR" | "WARNING" }
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "QuestionGraph questionnaire",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"$schema": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"schemaVersion": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/page"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"derived": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outcomes": {
|
||||||
|
"type": "object",
|
||||||
|
"propertyNames": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"when"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"schemaVersion",
|
||||||
|
"id",
|
||||||
|
"version",
|
||||||
|
"title",
|
||||||
|
"pages"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$defs": {
|
||||||
|
"rule": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"rule": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"enum": [
|
||||||
|
"ERROR",
|
||||||
|
"WARNING"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"rule",
|
||||||
|
"message"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"option": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"value",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"requiredWhen": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/rule"
|
||||||
|
},
|
||||||
|
"minItems": 0
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"const": "text"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"minLength": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"maxLength": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"widget": {
|
||||||
|
"enum": [
|
||||||
|
"input",
|
||||||
|
"textarea"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"widget"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"required",
|
||||||
|
"requiredWhen"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "An answer field. Required applies only while the field and its ancestors are applicable."
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"requiredWhen": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/rule"
|
||||||
|
},
|
||||||
|
"minItems": 0
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"const": "number"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"min": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"integer": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"widget": {
|
||||||
|
"enum": [
|
||||||
|
"number",
|
||||||
|
"currency"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"widget"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"required",
|
||||||
|
"requiredWhen"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "An answer field. Required applies only while the field and its ancestors are applicable."
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"requiredWhen": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/rule"
|
||||||
|
},
|
||||||
|
"minItems": 0
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"const": "date"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"min": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"required",
|
||||||
|
"requiredWhen"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "An answer field. Required applies only while the field and its ancestors are applicable."
|
||||||
|
},
|
||||||
|
"boolean": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"requiredWhen": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/rule"
|
||||||
|
},
|
||||||
|
"minItems": 0
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"const": "boolean"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"mustBeTrue": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"widget": {
|
||||||
|
"enum": [
|
||||||
|
"radio",
|
||||||
|
"checkbox"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"widget"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"required",
|
||||||
|
"requiredWhen"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "An answer field. Required applies only while the field and its ancestors are applicable."
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"requiredWhen": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/rule"
|
||||||
|
},
|
||||||
|
"minItems": 0
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"const": "select"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"widget": {
|
||||||
|
"enum": [
|
||||||
|
"select",
|
||||||
|
"radio"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"widget"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/option"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label",
|
||||||
|
"options"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"required",
|
||||||
|
"requiredWhen"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "An answer field. Required applies only while the field and its ancestors are applicable."
|
||||||
|
},
|
||||||
|
"multiselect": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"requiredWhen": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/rule"
|
||||||
|
},
|
||||||
|
"minItems": 0
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"const": "multiselect"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"minItems": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"maxItems": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/option"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label",
|
||||||
|
"options"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"required",
|
||||||
|
"requiredWhen"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "An answer field. Required applies only while the field and its ancestors are applicable."
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"requiredWhen": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/rule"
|
||||||
|
},
|
||||||
|
"minItems": 0
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"const": "files"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allowedExtensions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9]+$"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"maxFileSizeMb": {
|
||||||
|
"type": "number",
|
||||||
|
"exclusiveMinimum": 0
|
||||||
|
},
|
||||||
|
"maxFiles": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"type",
|
||||||
|
"label"
|
||||||
|
],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"required",
|
||||||
|
"requiredWhen"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "An answer field. Required applies only while the field and its ancestors are applicable."
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "group"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/field"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"fields"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/select"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/multiselect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/files"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/group"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"discriminator": {
|
||||||
|
"propertyName": "type"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/field"
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cases": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"when": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A restricted expression. Conditions must evaluate to a boolean."
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"when",
|
||||||
|
"to"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"minItems": 1
|
||||||
|
},
|
||||||
|
"otherwise": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"cases",
|
||||||
|
"otherwise"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"fields"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { QuestionnaireEngine, type Answers, type QuestionnaireDefinition } from "./questionnaire-engine";
|
||||||
|
|
||||||
|
export interface ResumedDraft {
|
||||||
|
answers: Answers;
|
||||||
|
nodeId: string;
|
||||||
|
questionId?: string;
|
||||||
|
history: string[];
|
||||||
|
revision: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseResumeDraft(text: string, definition: QuestionnaireDefinition): ResumedDraft {
|
||||||
|
const object = (value: unknown): value is Record<string, any> => !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
let draft: unknown;
|
||||||
|
try { draft = JSON.parse(text); } catch { throw new Error("Enter valid JSON before resuming."); }
|
||||||
|
if (!object(draft)) throw new Error("The draft must be a JSON object.");
|
||||||
|
if (!object(draft.answers)) throw new Error("The draft needs an answers object.");
|
||||||
|
const revision = 0;
|
||||||
|
if (draft.resume !== undefined && (!object(draft.resume) || typeof draft.resume.nodeId !== "string" || (draft.resume.questionId != null && typeof draft.resume.questionId !== "string"))) throw new Error("Resume needs a nodeId and an optional questionId.");
|
||||||
|
const engine = new QuestionnaireEngine(definition);
|
||||||
|
const answers: Answers = {};
|
||||||
|
const typeSession = engine.createSession({});
|
||||||
|
for (const [id, raw] of Object.entries(draft.answers)) {
|
||||||
|
const field = engine.compiled.fields[id]?.definition;
|
||||||
|
if (!field) throw new Error(`Unknown field: ${id}.`);
|
||||||
|
let value = raw;
|
||||||
|
if (field.type === "files" && Array.isArray(raw)) {
|
||||||
|
value = raw.map(file => {
|
||||||
|
if (!object(file) || typeof file.uploadId !== "string" || !file.uploadId) throw new Error(`Missing uploadId for ${id}.`);
|
||||||
|
return { uploadId: file.uploadId, name: file.name ?? file.uploadId, size: file.size ?? 0, type: file.type ?? "" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const error = typeSession.validateAnswerType(id, value);
|
||||||
|
if (error) throw new Error(`${id}: ${error}`);
|
||||||
|
answers[id] = value as Answers[string];
|
||||||
|
}
|
||||||
|
const session = engine.createSession(answers);
|
||||||
|
const journey = session.buildJourney();
|
||||||
|
if (!journey.length) throw new Error("No pages apply to these answers.");
|
||||||
|
const requested = draft.resume?.nodeId ?? definition.pages[0].id;
|
||||||
|
const requestedIndex = journey.indexOf(requested);
|
||||||
|
const firstInvalid = journey.findIndex((nodeId) => session.validateNode(nodeId).some((issue) => issue.severity === "ERROR"));
|
||||||
|
const index = requestedIndex < 0 ? (firstInvalid < 0 ? journey.length - 1 : firstInvalid) : firstInvalid >= 0 && firstInvalid < requestedIndex ? firstInvalid : requestedIndex;
|
||||||
|
const nodeId = journey[index];
|
||||||
|
const questionId = draft.resume?.questionId;
|
||||||
|
const selected = nodeId === requested && typeof questionId === "string" && engine.compiled.pages[nodeId].fieldIds.includes(questionId) && session.isQuestionVisible(questionId) ? questionId : undefined;
|
||||||
|
return { answers, nodeId, questionId: selected, history: journey.slice(0, index), revision,
|
||||||
|
message: nodeId === requested ? `Resumed at ${engine.compiled.pages[nodeId].definition.title}.` : `Resumed at ${engine.compiled.pages[nodeId].definition.title}: the saved page was unreachable or an earlier page needs correction.` };
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { SaveEventRecorder, type SaveEvent } from "../persistence/save-events";
|
||||||
|
import type { SaveRequest, SaveResponse } from "../persistence/types";
|
||||||
|
import type { ResumedDraft } from "../questionnaire/resume-draft";
|
||||||
|
import { QuestionnaireEngine, type AnswerValue, type Answers, type QuestionDefinition, type QuestionnaireDefinition, type UploadedFileRef, type ValidationResult, type FieldDefinition } from "../questionnaire/questionnaire-engine";
|
||||||
|
|
||||||
|
export type FieldAccessibility = {
|
||||||
|
"aria-describedby": string;
|
||||||
|
"aria-invalid": boolean;
|
||||||
|
"aria-required": boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuestionnaireFieldProps = {
|
||||||
|
id: string;
|
||||||
|
question: QuestionDefinition;
|
||||||
|
value: AnswerValue;
|
||||||
|
required: boolean;
|
||||||
|
validation: ValidationResult[];
|
||||||
|
accessibility: FieldAccessibility;
|
||||||
|
onFilesSelected?: (files: File[]) => Promise<void>;
|
||||||
|
onChange: (value: AnswerValue) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuestionnaireFieldComponent = (props: QuestionnaireFieldProps) => ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supply components from the host application's design system. Components
|
||||||
|
* replace only the input control; labels, grouping, and validation messages
|
||||||
|
* remain owned by the questionnaire renderer.
|
||||||
|
*/
|
||||||
|
export type QuestionnaireComponents = Partial<Record<QuestionDefinition["type"], QuestionnaireFieldComponent>>;
|
||||||
|
export type QuestionnaireSaveHandler = (request: SaveRequest) => Promise<SaveResponse> | SaveResponse;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
definition: QuestionnaireDefinition;
|
||||||
|
initialAnswers?: Answers;
|
||||||
|
resumedDraft?: ResumedDraft;
|
||||||
|
onSubmit: (answers: Answers, outcomes: Record<string, boolean>) => void | Promise<void>;
|
||||||
|
onSave?: QuestionnaireSaveHandler;
|
||||||
|
onUpload?: (questionId: string, files: File[]) => Promise<UploadedFileRef[]>;
|
||||||
|
onNodeChange?: (nodeId: string) => void;
|
||||||
|
onQuestionFocus?: (questionId: string | undefined) => void;
|
||||||
|
onAnswersChange?: (answers: Answers) => void;
|
||||||
|
onDraftAnswersChange?: (answers: Answers) => void;
|
||||||
|
onSaveEvent?: (event: SaveEvent) => void;
|
||||||
|
components?: QuestionnaireComponents;
|
||||||
|
/** Replace the complete field rendering, including labels and errors. */
|
||||||
|
renderField?: (props: QuestionnaireFieldProps) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuestionnaireRenderer({ definition, initialAnswers = {}, resumedDraft, onSubmit, onSave, onUpload, onNodeChange, onQuestionFocus, onAnswersChange, onDraftAnswersChange, onSaveEvent, components, renderField }: Props) {
|
||||||
|
const engine = useMemo(() => new QuestionnaireEngine(definition), [definition]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
const [operationError, setOperationError] = useState("");
|
||||||
|
const handlers = useRef({ onSave, onSaveEvent });
|
||||||
|
handlers.current = { onSave, onSaveEvent };
|
||||||
|
const [answers, setAnswers] = useState<Answers>(resumedDraft?.answers ?? initialAnswers);
|
||||||
|
const [draftAnswers, setDraftAnswers] = useState<Answers>(resumedDraft?.answers ?? initialAnswers);
|
||||||
|
const session = useMemo(() => engine.createSession(answers), [engine, answers]);
|
||||||
|
const journey = session.buildJourney();
|
||||||
|
const [selectedPage, setSelectedPage] = useState(resumedDraft?.nodeId ?? journey[0]);
|
||||||
|
const currentNode = journey.includes(selectedPage) ? selectedPage : journey[0];
|
||||||
|
const [touched, setTouched] = useState<Set<string>>(new Set());
|
||||||
|
const [recorder] = useState(() => new SaveEventRecorder(engine, event => handlers.current.onSaveEvent?.(event), resumedDraft?.answers ?? initialAnswers, resumedDraft?.revision, resumedDraft, onSave ? request => {
|
||||||
|
if (!handlers.current.onSave) throw new Error("Save handler is unavailable.");
|
||||||
|
return handlers.current.onSave(request);
|
||||||
|
} : undefined));
|
||||||
|
const [focusRevision, setFocusRevision] = useState(0);
|
||||||
|
const [focusTarget, setFocusTarget] = useState(resumedDraft?.questionId);
|
||||||
|
useEffect(() => { onNodeChange?.(currentNode); }, [currentNode, onNodeChange]);
|
||||||
|
useEffect(() => { onAnswersChange?.(answers); }, [answers, onAnswersChange]);
|
||||||
|
useEffect(() => { onDraftAnswersChange?.(draftAnswers); }, [draftAnswers, onDraftAnswersChange]);
|
||||||
|
useEffect(() => {
|
||||||
|
const field = focusTarget ? document.getElementById(`${focusTarget}-question`)?.querySelector<HTMLElement>("input, select, textarea") : undefined;
|
||||||
|
onQuestionFocus?.(field ? focusTarget : undefined);
|
||||||
|
(field ?? document.querySelector<HTMLElement>(".questionnaire-pane h1"))?.focus({ preventScroll: true });
|
||||||
|
}, [currentNode, focusTarget, focusRevision, onQuestionFocus]);
|
||||||
|
if (!currentNode) return <main className="shell"><h1>No applicable pages</h1><p>This questionnaire has no pages for the current answers.</p></main>;
|
||||||
|
const state = session.getPageState(currentNode);
|
||||||
|
const pageHasErrors = session.validateNode(currentNode).some(issue => issue.severity === "ERROR");
|
||||||
|
const evaluated = new Map(state.fields.map(field => [field.definition.id, field]));
|
||||||
|
function go(pageId: string, fieldId?: string) {
|
||||||
|
setSelectedPage(pageId); setFocusTarget(fieldId); setFocusRevision(previous => previous + 1);
|
||||||
|
recorder.savePosition(pageId, fieldId);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
async function next() {
|
||||||
|
if (busyRef.current) return;
|
||||||
|
const result = state.next === "END" ? session.validateJourney() : session.validateNode(currentNode);
|
||||||
|
setTouched(new Set([...touched, ...state.fields.map(f => f.definition.id), ...result.map(r => r.questionId)]));
|
||||||
|
const firstError = result.find(r => r.severity === "ERROR");
|
||||||
|
if (firstError) {
|
||||||
|
recorder.blocked(state.next === "END" ? "Final submit" : "Continue", "Validation failed. Correct the highlighted answers before continuing.");
|
||||||
|
go(engine.compiled.fields[firstError.questionId].pageId, firstError.questionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busyRef.current = true; setBusy(true); setOperationError("");
|
||||||
|
try {
|
||||||
|
const saved = await recorder.save(session, state.next === "END" ? Object.keys(engine.compiled.fields) : state.fields.map(field => field.definition.id), "Continue", state.next === "END" ? currentNode : state.next, true);
|
||||||
|
if (!saved) { setOperationError("Your answers could not be saved. Please try again."); return; }
|
||||||
|
if (state.next === "END") await onSubmit(session.getEffectiveAnswers(), session.getOutcomes());
|
||||||
|
else go(state.next);
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
setOperationError(message); recorder.blocked("Final submit", message);
|
||||||
|
} finally { busyRef.current = false; setBusy(false); }
|
||||||
|
}
|
||||||
|
function renderFields(fields: FieldDefinition[]): React.ReactNode {
|
||||||
|
return fields.map((field, index) => {
|
||||||
|
if (field.type === "group") {
|
||||||
|
const hasVisibleField = (items: FieldDefinition[]): boolean => items.some(item => item.type === "group" ? hasVisibleField(item.fields) : evaluated.has(item.id));
|
||||||
|
if (!hasVisibleField(field.fields)) return null;
|
||||||
|
return <fieldset className="question-group" key={`group-${index}`}>
|
||||||
|
{field.title && <legend>{field.title}</legend>}
|
||||||
|
{field.description && <p>{field.description}</p>}
|
||||||
|
{renderFields(field.fields)}
|
||||||
|
</fieldset>;
|
||||||
|
}
|
||||||
|
const current = evaluated.get(field.id);
|
||||||
|
if (!current) return null;
|
||||||
|
const fieldProps: QuestionnaireFieldProps = {
|
||||||
|
id: field.id,
|
||||||
|
question: field,
|
||||||
|
value: current.value,
|
||||||
|
required: current.required,
|
||||||
|
validation: touched.has(field.id) ? current.validation : [],
|
||||||
|
accessibility: {
|
||||||
|
"aria-describedby": `${field.id}-description ${field.id}-errors`,
|
||||||
|
"aria-invalid": touched.has(field.id) && current.validation.some(v => v.severity === "ERROR"),
|
||||||
|
"aria-required": current.required
|
||||||
|
},
|
||||||
|
onFilesSelected: async files => {
|
||||||
|
if (busyRef.current) return;
|
||||||
|
const metadata = files.map(file => ({ name: file.name, size: file.size, type: file.type }));
|
||||||
|
const candidate = engine.createSession({ ...answers, [field.id]: metadata });
|
||||||
|
const errors = candidate.validateQuestion(field.id).filter(issue => issue.severity === "ERROR");
|
||||||
|
setTouched(previous => new Set([...previous, field.id]));
|
||||||
|
if (errors.length && files.length) {
|
||||||
|
setOperationError(errors.map(issue => issue.message).join(" "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busyRef.current = true; setBusy(true); setOperationError("");
|
||||||
|
try {
|
||||||
|
if (files.length && !onUpload) throw new Error("A file upload handler is required.");
|
||||||
|
const references = files.length ? await onUpload!(field.id, files) : [];
|
||||||
|
if (references.length !== files.length || references.some(file => !file.uploadId)) throw new Error("Upload handler must return an upload ID for every file.");
|
||||||
|
const updated = engine.createSession({ ...answers, [field.id]: references });
|
||||||
|
if (updated.validateQuestion(field.id).some(issue => issue.severity === "ERROR") && references.length) throw new Error("Upload handler returned invalid file references.");
|
||||||
|
setAnswers(updated.getAnswersWithInactiveCleared());
|
||||||
|
if (!await recorder.filesSelected(field.id, references, currentNode, updated)) throw new Error("Uploaded files could not be saved. Please try again.");
|
||||||
|
setDraftAnswers(updated.getAnswersWithInactiveCleared());
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
setOperationError(message); recorder.blocked("File upload", message);
|
||||||
|
} finally { busyRef.current = false; setBusy(false); }
|
||||||
|
},
|
||||||
|
onChange: value => {
|
||||||
|
setAnswers(previous => engine.createSession({ ...previous, [field.id]: value }).getAnswersWithInactiveCleared());
|
||||||
|
if (field.type === "files") {
|
||||||
|
const updated = engine.createSession({ ...answers, [field.id]: value });
|
||||||
|
setTouched(previous => new Set([...previous, field.id]));
|
||||||
|
recorder.filesSelected(field.id, value as UploadedFileRef[], currentNode, updated);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFocus: () => { onQuestionFocus?.(field.id); recorder.savePosition(currentNode, field.id); },
|
||||||
|
onBlur: () => {
|
||||||
|
setTouched(previous => new Set([...previous, field.id]));
|
||||||
|
void recorder.save(session, [field.id], "Question blur", currentNode, false, field.id).then(saved => {
|
||||||
|
if (saved) setDraftAnswers(previous => engine.createSession({ ...previous, [field.id]: structuredClone(answers[field.id]) }).getAnswersWithInactiveCleared());
|
||||||
|
else setOperationError("Your answer could not be saved. Continue to retry.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renderedField = renderField
|
||||||
|
? renderField(fieldProps)
|
||||||
|
: <Question key={field.id} {...fieldProps} components={components} />;
|
||||||
|
return <Fragment key={field.id}>{renderedField}</Fragment>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return <main className="shell">
|
||||||
|
<header className="app-header"><div><p className="eyebrow">{definition.title}</p><h1 tabIndex={-1}>{state.definition.title}</h1>{state.definition.description && <p className="lede">{state.definition.description}</p>}</div><span className="version">v{definition.version}</span></header>
|
||||||
|
<div className="progress" role="progressbar" aria-label="Questionnaire progress" aria-valuenow={Math.round(state.progress)} aria-valuemin={0} aria-valuemax={100}><div style={{ width: `${state.progress}%` }} /></div>
|
||||||
|
<section className="card"><fieldset disabled={busy} className="question-group">{renderFields(state.definition.fields)}</fieldset>
|
||||||
|
{operationError && <p className="message error" role="alert">{operationError}</p>}
|
||||||
|
<div className="navigation"><button className="secondary" type="button" disabled={busy || !state.previous} onClick={() => go(state.previous!)}>Back</button><button className="primary" type="button" disabled={busy || pageHasErrors} onClick={next}>{state.next === "END" ? "Submit" : "Continue"}</button></div>
|
||||||
|
</section>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Question({ id, question, value, required, validation, onChange, onBlur, onFocus, onFilesSelected, accessibility, components }: QuestionnaireFieldProps & { onBlur: () => void; onFocus: () => void; components?: QuestionnaireComponents }) {
|
||||||
|
const grouped = question.type === "boolean" || question.type === "multiselect" || (question.type === "select" && question.ui?.widget === "radio");
|
||||||
|
const label = <>{question.label} {required && <span className="required">*</span>}</>;
|
||||||
|
const content = <>
|
||||||
|
<p id={`${id}-description`} className="question-description">{question.description}</p>
|
||||||
|
<QuestionInput id={id} question={question} value={value} required={required} validation={validation} onChange={onChange} onBlur={onBlur} onFocus={onFocus} accessibility={accessibility} components={components} onFilesSelected={onFilesSelected} />
|
||||||
|
<div id={`${id}-errors`} aria-live="polite">{validation.map((result, index) => <div key={index} className={result.severity === "ERROR" ? "message error" : "message warning"}>{result.message}</div>)}</div>
|
||||||
|
</>;
|
||||||
|
return <div id={`${id}-question`} className="question-block" onFocus={onFocus} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) onBlur(); }}>
|
||||||
|
{grouped ? <fieldset className="question-group"><legend className="question-label">{label}</legend>{content}</fieldset> : <><label className="question-label" htmlFor={id}>{label}</label>{content}</>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionInput({ id, question, value, required, validation, onChange, onBlur, onFocus, onFilesSelected, accessibility, components }: {
|
||||||
|
onFilesSelected?: (files: File[]) => Promise<void>;
|
||||||
|
accessibility: FieldAccessibility;
|
||||||
|
id: string;
|
||||||
|
question: QuestionDefinition;
|
||||||
|
value: AnswerValue;
|
||||||
|
required: boolean;
|
||||||
|
validation: ValidationResult[];
|
||||||
|
onChange: (value: AnswerValue) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
components?: QuestionnaireComponents;
|
||||||
|
}) {
|
||||||
|
const CustomComponent = components?.[question.type];
|
||||||
|
if (CustomComponent) return <CustomComponent id={id} question={question} value={value} required={required} validation={validation} accessibility={accessibility} onChange={onChange} onBlur={onBlur} onFocus={onFocus} onFilesSelected={onFilesSelected} />;
|
||||||
|
|
||||||
|
switch (question.type) {
|
||||||
|
case "text":
|
||||||
|
return question.ui?.widget === "textarea"
|
||||||
|
? <textarea {...accessibility} id={id} rows={5} value={String(value ?? "")} onChange={e => onChange(e.target.value)} />
|
||||||
|
: <input {...accessibility} id={id} type="text" value={String(value ?? "")} onChange={e => onChange(e.target.value)} />;
|
||||||
|
case "number":
|
||||||
|
return <input {...accessibility} id={id} type="number" step={question.constraints?.integer ? 1 : "any"} value={value == null ? "" : Number(value)} onChange={e => onChange(e.target.value === "" ? null : Number(e.target.value))} />;
|
||||||
|
case "date":
|
||||||
|
return <input {...accessibility} id={id} type="date" value={String(value ?? "")} onChange={(e) => onChange(e.target.value || null)} />;
|
||||||
|
case "boolean":
|
||||||
|
if (question.ui?.widget === "checkbox") return <label className="checkbox-row"><input {...accessibility} id={id} type="checkbox" checked={value === true} onChange={e => onChange(e.target.checked)} /> <span>Agree</span></label>;
|
||||||
|
return (
|
||||||
|
<div className="radio-row">
|
||||||
|
<label><input {...accessibility} type="radio" name={id} checked={value === true} onChange={() => onChange(true)} /> Yes</label>
|
||||||
|
<label><input {...accessibility} type="radio" name={id} checked={value === false} onChange={() => onChange(false)} /> No</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "select":
|
||||||
|
if (question.ui?.widget === "radio") return <div className="radio-row">{question.options.map(option => <label key={option.value}><input {...accessibility} type="radio" name={id} checked={value === option.value} onChange={() => onChange(option.value)} />{option.label}</label>)}</div>;
|
||||||
|
return (
|
||||||
|
<select {...accessibility} id={id} value={String(value ?? "")} onChange={(e) => onChange(e.target.value || null)}>
|
||||||
|
<option value="">Please select</option>
|
||||||
|
{question.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
case "multiselect": {
|
||||||
|
const selected = Array.isArray(value) && value.every((x) => typeof x === "string") ? value as string[] : [];
|
||||||
|
return (
|
||||||
|
<div className="check-grid">
|
||||||
|
{question.options?.map((option) => (
|
||||||
|
<label key={option.value} className="checkbox-row">
|
||||||
|
<input {...accessibility}
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(option.value)}
|
||||||
|
onChange={(e) => onChange(e.target.checked ? [...selected, option.value] : selected.filter((x) => x !== option.value))}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "files": {
|
||||||
|
const files = Array.isArray(value) && value.every((x) => typeof x === "object") ? value as UploadedFileRef[] : [];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input {...accessibility}
|
||||||
|
id={id}
|
||||||
|
type="file"
|
||||||
|
multiple={(question.constraints?.maxFiles ?? Infinity) > 1}
|
||||||
|
accept={question.constraints?.allowedExtensions?.map((x) => `.${x}`).join(",")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selectedFiles = Array.from(e.target.files ?? []);
|
||||||
|
void onFilesSelected?.(selectedFiles);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{files.length > 0 && <p className="file-note">Selected: {files.map((x) => x.name).join(", ")}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { QuestionnaireEngine } from "../src/lib/questionnaire/questionnaire-engine";
|
||||||
|
import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events";
|
||||||
|
import type { SaveRequest } from "../src/lib/persistence/types";
|
||||||
|
import example from "../src/demo/example-questionnaire.json";
|
||||||
|
|
||||||
|
test("file saves reject missing upload IDs and preserve real host references", async () => {
|
||||||
|
const fileEngine = new QuestionnaireEngine({
|
||||||
|
schemaVersion: 1, id: "uploads", version: "1", title: "Uploads",
|
||||||
|
pages: [{ id: "files", title: "Files", fields: [{ type: "files", label: "Attachment", id: "attachment" }] }]
|
||||||
|
});
|
||||||
|
const calls: SaveRequest[] = [];
|
||||||
|
const recorder = new SaveEventRecorder(fileEngine, () => {}, {}, 0, undefined, request => {
|
||||||
|
calls.push(request); return { revision: 1 };
|
||||||
|
});
|
||||||
|
const metadata = { name: "evidence.pdf", size: 10, type: "application/pdf" };
|
||||||
|
assert.equal(await recorder.filesSelected("attachment", [metadata], "files", fileEngine.createSession({ attachment: [metadata] })), false);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
const uploaded = { ...metadata, uploadId: "host-upload-123" };
|
||||||
|
const session = fileEngine.createSession({ attachment: [uploaded] });
|
||||||
|
assert.equal(await recorder.filesSelected("attachment", [uploaded], "files", session), true);
|
||||||
|
assert.deepEqual(calls[0].changes.attachment, [uploaded]);
|
||||||
|
assert.deepEqual(session.getEffectiveAnswers().attachment, [uploaded]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed changes retry and queued saves use the acknowledged revision", async () => {
|
||||||
|
const engine = new QuestionnaireEngine(example);
|
||||||
|
const calls: SaveRequest[] = [];
|
||||||
|
const events: SaveEvent[] = [];
|
||||||
|
let fail = true;
|
||||||
|
const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 4, undefined, async request => {
|
||||||
|
calls.push(request);
|
||||||
|
if (fail) { fail = false; throw new Error("Offline"); }
|
||||||
|
return { revision: request.revision + 1 };
|
||||||
|
});
|
||||||
|
const session = engine.createSession({ fullName: "Alex" });
|
||||||
|
assert.equal(await recorder.save(session, ["fullName"], "blur", "registration"), false);
|
||||||
|
const retry = recorder.save(session, ["fullName"], "Continue", "registration");
|
||||||
|
const next = recorder.savePosition("registration", "attending");
|
||||||
|
assert.equal(await retry, true);
|
||||||
|
await next;
|
||||||
|
assert.deepEqual(calls[0].changes, calls[1].changes);
|
||||||
|
assert.deepEqual(calls.map(call => call.revision), [4, 4, 5]);
|
||||||
|
assert.deepEqual(events.map(event => event.status), ["Blocked", "Saved", "Saved"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("save promises wait for the host and duplicate queued answers are deduplicated", async () => {
|
||||||
|
const engine = new QuestionnaireEngine(example);
|
||||||
|
let finish!: () => void;
|
||||||
|
const gate = new Promise<void>(resolve => { finish = resolve; });
|
||||||
|
let calls = 0;
|
||||||
|
const events: SaveEvent[] = [];
|
||||||
|
const recorder = new SaveEventRecorder(engine, event => events.push(event), {}, 0, undefined, async () => {
|
||||||
|
calls++; await gate; return { revision: 1 };
|
||||||
|
});
|
||||||
|
const session = engine.createSession({ fullName: "Alex" });
|
||||||
|
const first = recorder.save(session, ["fullName"], "blur", "registration");
|
||||||
|
const second = recorder.save(session, ["fullName"], "blur", "registration");
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(events.length, 0);
|
||||||
|
finish();
|
||||||
|
assert.equal(await first, true);
|
||||||
|
assert.equal(await second, true);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in-memory changes never claim server persistence or advance revisions", async () => {
|
||||||
|
const engine = new QuestionnaireEngine(example);
|
||||||
|
const events: SaveEvent[] = [];
|
||||||
|
const recorder = new SaveEventRecorder(engine, event => events.push(event));
|
||||||
|
await recorder.save(engine.createSession({ fullName: "Alex" }), ["fullName"], "blur", "registration");
|
||||||
|
await recorder.savePosition("registration", "attending");
|
||||||
|
assert.ok(events.every(event => event.status === "Changed" && event.data?.revision === 0));
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { QuestionnaireEngine, type Answers, type QuestionnaireDefinition, type QuestionDefinition } from "../src/lib/questionnaire/questionnaire-engine";
|
||||||
|
import { compileExpression, evaluateExpression } from "../src/lib/questionnaire/dsl";
|
||||||
|
import { parseResumeDraft } from "../src/lib/questionnaire/resume-draft";
|
||||||
|
import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events";
|
||||||
|
|
||||||
|
const claim = JSON.parse(readFileSync(new URL("../src/demo/claim-questionnaire.json", import.meta.url), "utf8"));
|
||||||
|
const example: QuestionnaireDefinition = JSON.parse(readFileSync(new URL("../src/demo/example-questionnaire.json", import.meta.url), "utf8"));
|
||||||
|
const today = { today: "2026-09-12" };
|
||||||
|
const basic = (fields: QuestionDefinition[]): QuestionnaireDefinition => ({ schemaVersion: 1, id: "test", version: "1", title: "Test", pages: [{ id: "first", title: "First", fields }] });
|
||||||
|
const bool = (id: string): QuestionDefinition => ({ id, type: "boolean", label: id });
|
||||||
|
const text = (id: string): QuestionDefinition => ({ id, type: "text", label: id });
|
||||||
|
const evaluate = (source: string) => evaluateExpression(compileExpression(source), { functions: {} });
|
||||||
|
|
||||||
|
test("migrated claim and documented example compile", () => {
|
||||||
|
assert.equal(new QuestionnaireEngine(claim).definition.pages.length, 9);
|
||||||
|
assert.equal(new QuestionnaireEngine(example).definition.pages.length, 2);
|
||||||
|
});
|
||||||
|
test("schema rejects misspelled properties with useful paths", () => {
|
||||||
|
const definition = basic([text("name")]) as any;
|
||||||
|
definition.pages[0].fields[0].requried = true;
|
||||||
|
assert.throws(() => new QuestionnaireEngine(definition), /\/pages\/0\/fields\/0.*requried/);
|
||||||
|
});
|
||||||
|
test("schema enforces type-specific options, widgets and requiredness", () => {
|
||||||
|
for (const field of [
|
||||||
|
{ ...text("x"), constraints: { maxFiles: 1 } },
|
||||||
|
{ ...text("x"), required: true, requiredWhen: "true" },
|
||||||
|
{ id: "x", type: "select", label: "X" },
|
||||||
|
{ ...text("x"), ui: { widget: "checkbox" } }
|
||||||
|
]) assert.throws(() => new QuestionnaireEngine(basic([field as QuestionDefinition])), /Invalid questionnaire/);
|
||||||
|
});
|
||||||
|
test("duplicate IDs, duplicate options and inconsistent bounds fail compilation", () => {
|
||||||
|
assert.throws(() => new QuestionnaireEngine(basic([text("x"), text("x")])), /Duplicate field/);
|
||||||
|
assert.throws(() => new QuestionnaireEngine(basic([{ id: "x", type: "select", label: "X", options: [{ value: "a", label: "A" }, { value: "a", label: "B" }] }])), /unique/);
|
||||||
|
assert.throws(() => new QuestionnaireEngine(basic([{ id: "x", type: "number", label: "X", constraints: { min: 5, max: 2 } }])), /must not exceed/);
|
||||||
|
});
|
||||||
|
test("references, helper arity, argument types, conditions and value scope are checked", () => {
|
||||||
|
for (const [when, message] of [
|
||||||
|
["answer('missing') === true", /Unknown field/],
|
||||||
|
["derived('missing')", /Unknown derived/],
|
||||||
|
["today(1) === 'x'", /expects 0 arguments/],
|
||||||
|
["length(42) > 1", /argument 1/],
|
||||||
|
["123", /boolean/],
|
||||||
|
["value === true", /only available/]
|
||||||
|
] as const) assert.throws(() => new QuestionnaireEngine(basic([{ ...text("x"), when }])), message);
|
||||||
|
});
|
||||||
|
test("dependency checks include page, group, effective-answer and derived cycles", () => {
|
||||||
|
const self = basic([{ ...bool("x"), when: "answer('x') === true" }]);
|
||||||
|
assert.throws(() => new QuestionnaireEngine(self), /Circular dependency/);
|
||||||
|
const page = basic([bool("x")]); page.pages[0].when = "answer('x') === true";
|
||||||
|
assert.throws(() => new QuestionnaireEngine(page), /Circular dependency/);
|
||||||
|
const derived = basic([text("x")]); derived.derived = { a: "derived('b')", b: "derived('a')" };
|
||||||
|
assert.throws(() => new QuestionnaireEngine(derived), /Circular dependency/);
|
||||||
|
const group = basic([text("x")]); group.pages[0].fields = [{ type: "group", when: "answer('x') !== null", fields: [text("x")] }];
|
||||||
|
assert.throws(() => new QuestionnaireEngine(group), /Circular dependency/);
|
||||||
|
});
|
||||||
|
test("ordered pages skip conditions and progress reflects the current journey", () => {
|
||||||
|
const engine = new QuestionnaireEngine(example);
|
||||||
|
const declined = engine.createSession({ attending: false }, today);
|
||||||
|
assert.deepEqual(declined.buildJourney(), ["registration"]);
|
||||||
|
assert.equal(declined.getPageState("registration").progress, 100);
|
||||||
|
const attending = engine.createSession({ attending: true }, today);
|
||||||
|
assert.deepEqual(attending.buildJourney(), ["registration", "preferences"]);
|
||||||
|
assert.equal(attending.getPageState("preferences").previous, "registration");
|
||||||
|
});
|
||||||
|
test("page and nested group applicability masks answers while drafts retain them", () => {
|
||||||
|
const engine = new QuestionnaireEngine(example);
|
||||||
|
const draft = { attending: false, supportRequired: true, supportDetails: "Ramp" };
|
||||||
|
const hidden = engine.createSession(draft, today);
|
||||||
|
assert.equal(hidden.getAnswer("supportRequired"), null);
|
||||||
|
assert.equal(hidden.getDerived("needsSupport"), false);
|
||||||
|
assert.equal(hidden.getOutcomes().arrangeSupport, false);
|
||||||
|
assert.equal(hidden.answers.supportDetails, "Ramp");
|
||||||
|
assert.ok(!Object.hasOwn(hidden.getEffectiveAnswers(), "supportDetails"));
|
||||||
|
const restored = engine.createSession({ ...draft, attending: true }, today);
|
||||||
|
assert.equal(restored.getAnswer("supportDetails"), "Ramp");
|
||||||
|
assert.equal(engine.createSession({ ...draft, attending: true, supportRequired: false }, today).getAnswer("supportDetails"), null);
|
||||||
|
});
|
||||||
|
test("switching injury to illness removes stale injury evidence and outcomes", () => {
|
||||||
|
const engine = new QuestionnaireEngine(claim);
|
||||||
|
const answers = { claimType: "ILLNESS", injuryCause: "MOTOR_VEHICLE", motorVehicleAccident: true, workRelated: true, workersCompClaim: true };
|
||||||
|
const session = engine.createSession(answers, today);
|
||||||
|
assert.equal(session.isQuestionVisible("policeReport"), false);
|
||||||
|
assert.equal(session.isQuestionVisible("workersCompDocuments"), false);
|
||||||
|
assert.equal(session.getOutcomes().potentialWorkersComp, false);
|
||||||
|
assert.equal(session.getOutcomes().potentialThirdPartyRecovery, false);
|
||||||
|
assert.equal(session.answers.injuryCause, "MOTOR_VEHICLE");
|
||||||
|
});
|
||||||
|
test("required false is a valid boolean answer; mustBeTrue requires acceptance", () => {
|
||||||
|
const engine = new QuestionnaireEngine(basic([{ ...bool("answer"), required: true }, { ...bool("consent"), constraints: { mustBeTrue: true } }]));
|
||||||
|
const session = engine.createSession({ answer: false, consent: false }, today);
|
||||||
|
assert.equal(session.validateQuestion("answer").length, 0);
|
||||||
|
assert.equal(session.validateQuestion("consent").length, 1);
|
||||||
|
assert.equal(engine.createSession({}, today).validateQuestion("consent").length, 1);
|
||||||
|
assert.equal(session.isQuestionRequired("consent"), true);
|
||||||
|
});
|
||||||
|
test("whitespace, invalid dates, nonfinite numbers and invalid options are handled centrally", () => {
|
||||||
|
const engine = new QuestionnaireEngine(basic([
|
||||||
|
{ ...text("name"), required: true }, { id: "date", type: "date", label: "Date" },
|
||||||
|
{ id: "number", type: "number", label: "Number" },
|
||||||
|
{ id: "choices", type: "multiselect", label: "Choices", options: [{ value: "a", label: "A" }] }
|
||||||
|
]));
|
||||||
|
const session = engine.createSession({ name: " ", date: "2026-02-30", number: Infinity, choices: ["a", "a"] }, today);
|
||||||
|
assert.equal(session.validateJourney().length, 4);
|
||||||
|
assert.equal(session.getAnswer("date"), null);
|
||||||
|
assert.equal(session.getAnswer("name"), null);
|
||||||
|
assert.equal(engine.createSession({ name: [], number: "" }, today).validateJourney().length, 2);
|
||||||
|
});
|
||||||
|
test("constraints enforce text, numbers and file limits", () => {
|
||||||
|
const engine = new QuestionnaireEngine(basic([
|
||||||
|
{ ...text("name"), constraints: { minLength: 3, maxLength: 5 } },
|
||||||
|
{ id: "hours", type: "number", label: "Hours", constraints: { min: 0, max: 10, integer: true } },
|
||||||
|
{ id: "files", type: "files", label: "Files", constraints: { maxFiles: 1, maxFileSizeMb: 1, allowedExtensions: ["pdf"] } }
|
||||||
|
]));
|
||||||
|
const session = engine.createSession({ name: " a ", hours: 12.5, files: [{ name: "bad.exe", size: 2 * 1024 * 1024, type: "application/octet-stream" }, { name: "ok.pdf", size: 10, type: "application/pdf" }] }, today);
|
||||||
|
assert.equal(session.validateQuestion("name").length, 1);
|
||||||
|
assert.equal(session.validateQuestion("hours").length, 2);
|
||||||
|
assert.equal(session.validateQuestion("files").length, 3);
|
||||||
|
});
|
||||||
|
test("forward routes use first matching case and required fallback", () => {
|
||||||
|
const definition = basic([bool("skip")]);
|
||||||
|
definition.pages.push({ id: "second", title: "Second", fields: [text("secondAnswer")] }, { id: "third", title: "Third", fields: [text("thirdAnswer")] });
|
||||||
|
definition.pages[0].route = { cases: [{ when: "answer('skip') === true", to: "third" }, { when: "true", to: "second" }], otherwise: "END" };
|
||||||
|
const engine = new QuestionnaireEngine(definition);
|
||||||
|
assert.deepEqual(engine.createSession({ skip: true }, today).buildJourney(), ["first", "third"]);
|
||||||
|
assert.deepEqual(engine.createSession({ skip: false }, today).buildJourney(), ["first", "second", "third"]);
|
||||||
|
definition.pages[0].route.otherwise = "first";
|
||||||
|
assert.throws(() => new QuestionnaireEngine(definition), /later page/);
|
||||||
|
});
|
||||||
|
test("routes cannot depend on the applicability of later answers", () => {
|
||||||
|
const definition = basic([text("firstAnswer")]);
|
||||||
|
definition.pages.push({ id: "second", title: "Second", fields: [bool("later")] });
|
||||||
|
definition.pages[0].route = { cases: [{ when: "answer('later') === true", to: "END" }], otherwise: "second" };
|
||||||
|
assert.throws(() => new QuestionnaireEngine(definition), /Circular dependency/);
|
||||||
|
});
|
||||||
|
test("null semantics, precedence, short circuiting and parser bounds", () => {
|
||||||
|
assert.equal(evaluate("null >= 0"), false);
|
||||||
|
assert.equal(evaluate("null + 1"), null);
|
||||||
|
assert.equal(evaluate("1 + 2 * 3"), 7);
|
||||||
|
assert.equal(evaluate("false && (1 / 0 > 0)"), false);
|
||||||
|
assert.equal(evaluate("null ?? 7"), 7);
|
||||||
|
assert.throws(() => evaluate("1 / 0"), /zero/);
|
||||||
|
assert.throws(() => compileExpression("(".repeat(65) + "1" + ")".repeat(65)), /nesting/);
|
||||||
|
assert.throws(() => compileExpression("1".repeat(4097)), /4096/);
|
||||||
|
assert.throws(() => compileExpression("answer('x').constructor"), /Unexpected character/);
|
||||||
|
});
|
||||||
|
test("session date is injectable; month arithmetic clamps and invalid calendar dates fail", () => {
|
||||||
|
const session = new QuestionnaireEngine(example).createSession({}, today);
|
||||||
|
assert.equal(session.evaluate("today()"), "2026-09-12");
|
||||||
|
assert.equal(session.evaluate("addMonths('2024-01-31', 1)"), "2024-02-29");
|
||||||
|
assert.equal(session.evaluate("addYears('2024-02-29', 1)"), "2025-02-28");
|
||||||
|
assert.throws(() => session.evaluate("age('2026-02-30', today())"), /Expected ISO/);
|
||||||
|
});
|
||||||
|
test("snapshots protect caches from subsequent mutation", () => {
|
||||||
|
const engine = new QuestionnaireEngine(example);
|
||||||
|
const answers = { attending: true, supportRequired: true };
|
||||||
|
const session = engine.createSession(answers, today);
|
||||||
|
answers.attending = false;
|
||||||
|
assert.equal(session.getDerived("needsSupport"), true);
|
||||||
|
assert.throws(() => { session.answers.attending = false; }, TypeError);
|
||||||
|
assert.throws(() => { engine.definition.pages[0].title = "mutated"; }, TypeError);
|
||||||
|
});
|
||||||
|
test("draft resume keeps inactive values and redirects unreachable or invalid positions", () => {
|
||||||
|
const draft = parseResumeDraft(JSON.stringify({ answers: { fullName: "Alex", attending: false, supportDetails: "Ramp" }, resume: { nodeId: "preferences" } }), example);
|
||||||
|
assert.equal(draft.nodeId, "registration");
|
||||||
|
assert.equal(draft.answers.supportDetails, "Ramp");
|
||||||
|
assert.throws(() => parseResumeDraft(JSON.stringify({ answers: { attending: "yes" } }), example), /valid boolean/);
|
||||||
|
});
|
||||||
|
test("draft saves clear inactive answers and submit only effective answers", async () => {
|
||||||
|
const engine = new QuestionnaireEngine(example);
|
||||||
|
const events: SaveEvent[] = [];
|
||||||
|
const recorder = new SaveEventRecorder(engine, event => events.push(event), { attending: true, supportRequired: true, supportDetails: "Ramp" });
|
||||||
|
const session = engine.createSession({ fullName: "Alex", attending: false, supportRequired: true, supportDetails: "Ramp" }, today);
|
||||||
|
await recorder.save(session, ["attending"], "blur", "registration");
|
||||||
|
const patch = events.at(-1)!.data! as { changes: Answers };
|
||||||
|
assert.deepEqual(patch.changes, { attending: false, supportRequired: null, supportDetails: null });
|
||||||
|
assert.deepEqual(session.getEffectiveAnswers(), { fullName: "Alex", attending: false });
|
||||||
|
});
|
||||||
|
test("employment changes clear occupation from live answers and draft patches", async () => {
|
||||||
|
const engine = new QuestionnaireEngine(claim);
|
||||||
|
const initial = { employmentStatus: "FULL_TIME", occupation: "Engineer" };
|
||||||
|
const updated = engine.createSession({ ...initial, employmentStatus: "UNEMPLOYED" }, today).getAnswersWithInactiveCleared();
|
||||||
|
assert.deepEqual(updated, { employmentStatus: "UNEMPLOYED", occupation: null });
|
||||||
|
const events: SaveEvent[] = [];
|
||||||
|
const recorder = new SaveEventRecorder(engine, event => events.push(event), initial);
|
||||||
|
await recorder.save(engine.createSession(updated, today), ["employmentStatus"], "Question blur", "claimantDetails");
|
||||||
|
assert.deepEqual((events.at(-1)!.data! as { changes: Answers }).changes, { ...updated, unableToWork: null });
|
||||||
|
const restored = engine.createSession({ ...updated, employmentStatus: "FULL_TIME" }, today);
|
||||||
|
assert.equal(restored.answers.occupation, null);
|
||||||
|
assert.ok(restored.validateQuestion("occupation").some(issue => issue.severity === "ERROR"));
|
||||||
|
await recorder.save(restored, ["employmentStatus"], "Question blur", "claimantDetails");
|
||||||
|
assert.deepEqual((events.at(-1)!.data! as { changes: Answers }).changes, { employmentStatus: "FULL_TIME" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("newly hidden occupation is explicitly reset even without a previously saved value", async () => {
|
||||||
|
const engine = new QuestionnaireEngine(claim);
|
||||||
|
for (const occupation of [undefined, null]) {
|
||||||
|
const events: SaveEvent[] = [];
|
||||||
|
const initial: Answers = { employmentStatus: "FULL_TIME" };
|
||||||
|
if (occupation === null) initial.occupation = null;
|
||||||
|
const recorder = new SaveEventRecorder(engine, event => events.push(event), initial);
|
||||||
|
const session = engine.createSession({ employmentStatus: "UNEMPLOYED", occupation: null }, today);
|
||||||
|
await recorder.save(session, ["employmentStatus"], "Question blur", "claimantDetails");
|
||||||
|
const changes = (events.at(-1)!.data! as { changes: Answers }).changes;
|
||||||
|
assert.equal(changes.employmentStatus, "UNEMPLOYED");
|
||||||
|
assert.equal(changes.occupation, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid file selections do not simulate upload or patch requests", async () => {
|
||||||
|
const engine = new QuestionnaireEngine(basic([{ id: "files", type: "files", label: "Files", constraints: { maxFiles: 1 } }]));
|
||||||
|
const files = [{ name: "a.pdf", size: 1, type: "application/pdf" }, { name: "b.pdf", size: 1, type: "application/pdf" }];
|
||||||
|
const events: SaveEvent[] = [];
|
||||||
|
await new SaveEventRecorder(engine, event => events.push(event)).filesSelected("files", files, "first", engine.createSession({ files }, today));
|
||||||
|
assert.equal(events.length, 1); assert.equal(events[0].status, "Blocked"); assert.equal(events[0].data, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const claimType of ["INJURY", "ILLNESS", "TPD", "INCOME_PROTECTION"]) test(`complete ${claimType} journey validates`, () => {
|
||||||
|
const session = new QuestionnaireEngine(claim).createSession({
|
||||||
|
dateOfBirth: "1980-01-01", employmentStatus: "FULL_TIME", occupation: "Software developer", claimType,
|
||||||
|
incidentDate: "2026-08-01", diagnosisDate: "2026-08-01", conditionDescription: "A sufficiently detailed description of the condition for testing.",
|
||||||
|
bodyAreas: ["BACK"], injuryCause: "FALL", workRelated: false, thirdPartyInvolved: false,
|
||||||
|
conditionCategory: ["MUSCULOSKELETAL"], receivedMedicalTreatment: false,
|
||||||
|
unableToWork: false, modifiedDuties: false, previousClaim: false,
|
||||||
|
medicalDocuments: [{ uploadId: "upload-1", name: "report.pdf", size: 100, type: "application/pdf" }],
|
||||||
|
informationCorrect: true, medicalConsent: true
|
||||||
|
}, today);
|
||||||
|
assert.deepEqual(session.validateJourney(), []);
|
||||||
|
assert.equal(session.getNextNode("declaration"), "END");
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { libraryDownload } from "./scripts/library-download.mjs";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), libraryDownload()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user