Skip to content

Commit c48bcbd

Browse files
committed
Add offline-mode forms example
1 parent 9a0d323 commit c48bcbd

4 files changed

Lines changed: 756 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# aidbox-forms-renderer-offline-mode
2+
3+
Example of using the Aidbox Forms Renderer web component with offline support via request interception (`enable-fetch-proxy` + `onFetch`).
4+
5+
## How it works
6+
7+
This example uses a **response-centric (task-based) model**: QuestionnaireResponses are created upfront on the server (by an admin, workflow, or scheduler) and represent assigned forms that the user must fill out. The sidebar shows these pre-created QRs as the work list.
8+
9+
The `<aidbox-form-renderer>` web component supports a fetch proxy mode where all HTTP requests are intercepted by a custom `onFetch` handler. This example uses that mechanism to:
10+
11+
1. **Load assigned forms** — on startup, the app searches for QuestionnaireResponses assigned to the current patient, then fetches their referenced Questionnaires
12+
2. **Cache FHIR resources in `localStorage`** — Questionnaires, QuestionnaireResponses, and themes are stored locally
13+
3. **Serve forms from cache** — when offline (or always, for cached resources), the interceptor returns data from `localStorage` instead of making network requests
14+
4. **Queue changes when offline** — saves and submissions are written to `localStorage` immediately and queued as pending operations
15+
5. **Sync automatically on reconnect** — when the browser comes back online, pending operations are pushed to the Aidbox server in order
16+
17+
## Architecture
18+
19+
Single `index.html` file using CDN-loaded dependencies (React 18, Babel standalone, Tailwind CSS, aidbox-forms-renderer-webcomponent.js).
20+
21+
### Key parts
22+
23+
| Part | Purpose |
24+
|------|---------|
25+
| **Configuration** | `AIDBOX_BASE_URL`, JWT token, patient ID, QR search params, default SDC config |
26+
| **OfflineStorage** | `localStorage` wrapper with namespaced keys; emits custom events on changes |
27+
| **apiFetch()** | HTTP helper that prepends base URL and Bearer token |
28+
| **loadInitialData()** | Searches for assigned QRs with `_include` for Questionnaires, caches everything |
29+
| **syncPendingChanges()** | Processes queued save/submit operations when back online |
30+
| **createFetchInterceptor()** | `onFetch` handler — serves cached resources, queues writes when offline |
31+
| **App** | React component — sidebar with form list, status bar, form renderer |
32+
33+
### Request interceptor tags
34+
35+
The `onFetch` handler routes requests by `init.tag`:
36+
37+
| Tag | Behavior |
38+
|-----|----------|
39+
| `get-config` | Returns `DEFAULT_SDC_CONFIG` from memory |
40+
| `get-theme` | Returns cached theme, or delegates to renderer if online (this demo uses the default theme for brevity) |
41+
| `get-questionnaire` | Returns cached Questionnaire, or delegates to renderer if online |
42+
| `get-response` | Returns cached QuestionnaireResponse, or delegates to renderer if online |
43+
| `save-response` | Saves locally, forwards to server if online, queues if offline |
44+
| `submit-response` | Marks completed locally, forwards to server if online, queues if offline |
45+
| _(untagged)_ | Delegates to renderer if online, returns 503 if offline |
46+
47+
When the interceptor returns `null`, the renderer handles the request itself (using the `token` attribute for auth).
48+
49+
### localStorage key schema
50+
51+
| Key pattern | Content |
52+
|-------------|---------|
53+
| `aidbox-offline:questionnaire:{canonical}` | Cached Questionnaire resource |
54+
| `aidbox-offline:response:{id}` | Cached QuestionnaireResponse resource |
55+
| `aidbox-offline:response-list` | Array of `{responseId, questionnaire, status}` |
56+
| `aidbox-offline:theme:{id}` | Cached theme resource |
57+
| `aidbox-offline:pending-ops` | Array of queued save/submit operations |
58+
59+
### Custom events
60+
61+
`OfflineStorage` emits events on `window` to notify the React app of changes:
62+
63+
| Event | Trigger |
64+
|-------|---------|
65+
| `offline-responses-changed` | Response list updated |
66+
| `offline-pending-changed` | Pending ops queue updated |
67+
68+
## Prerequisites
69+
70+
- An Aidbox instance with FHIR resources loaded (Patient, Questionnaires, and pre-created QuestionnaireResponses)
71+
- A JWT token with access to the FHIR and SDC APIs
72+
- QuestionnaireResponses must be created upfront on the server with:
73+
- `status: in-progress`
74+
- `questionnaire` reference pointing to a Questionnaire canonical URL
75+
- `subject` reference pointing to the patient (e.g. `Patient/pt-1`)
76+
77+
## Setup
78+
79+
1. Open `index.html` in a text editor
80+
2. Set `AIDBOX_BASE_URL` to your Aidbox instance URL (e.g. `http://localhost:8081`)
81+
3. Set `AIDBOX_TOKEN` to a valid JWT token
82+
4. Set `PATIENT_ID` to the patient whose assigned forms should be loaded
83+
5. Load the fixture data into Aidbox:
84+
```bash
85+
./fixtures/load-fixtures.sh http://localhost:8081 basic:secret
86+
```
87+
6. Serve `index.html` via any HTTP server (e.g. `npx serve .`) and open in a browser
88+
89+
## Usage
90+
91+
1. **First load (online)** — the app fetches assigned QuestionnaireResponses and their Questionnaires, caches them in `localStorage`
92+
2. **Select a form** — click on a form in the sidebar; status shows "in-progress" or "completed"
93+
3. **Fill out forms** — changes auto-save to both `localStorage` and the server
94+
4. **Submit a form** — status changes to "completed" in the sidebar
95+
5. **Go offline** (e.g. DevTools Network → Offline) — forms render from cache; saves are queued
96+
6. **Come back online** — pending changes sync automatically
97+
98+
The header status bar shows online/offline state, pending change count, and sync progress.
99+
100+
## Web component attributes
101+
102+
| Attribute | Purpose |
103+
|-----------|---------|
104+
| `token` | JWT token for authenticating renderer's own requests |
105+
| `config` | SDC configuration object |
106+
| `questionnaire-id` | Canonical URL of the Questionnaire |
107+
| `questionnaire-response-id` | ID of the QuestionnaireResponse to edit |
108+
| `enable-fetch-proxy` | Enables `onFetch` interception |
109+
110+
The `onFetch` callback is set as a JS property via the `ref` callback.
111+
112+
See the [Aidbox Forms documentation](https://docs.aidbox.app/modules/aidbox-forms/aidbox-ui-builder-alpha/embedding-renderer) for the full list of attributes.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
{
2+
"resourceType": "Bundle",
3+
"type": "transaction",
4+
"entry": [
5+
{
6+
"resource": {
7+
"resourceType": "Patient",
8+
"id": "pt-1",
9+
"name": [
10+
{
11+
"use": "official",
12+
"family": "Smith",
13+
"given": ["John"]
14+
}
15+
],
16+
"gender": "male",
17+
"birthDate": "1990-01-15"
18+
},
19+
"request": {
20+
"method": "PUT",
21+
"url": "Patient/pt-1"
22+
}
23+
},
24+
{
25+
"resource": {
26+
"resourceType": "Questionnaire",
27+
"id": "100283-1",
28+
"url": "http://loinc.org/q/100283-1",
29+
"status": "active",
30+
"title": "Harris Hip Score panel",
31+
"name": "HarrisHipScorePanel",
32+
"item": [
33+
{
34+
"linkId": "100284-9",
35+
"text": "Pain level",
36+
"type": "choice",
37+
"answerOption": [
38+
{ "valueCoding": { "code": "none", "display": "None or ignores it" } },
39+
{ "valueCoding": { "code": "slight", "display": "Slight, occasional" } },
40+
{ "valueCoding": { "code": "mild", "display": "Mild, no effect on activities" } },
41+
{ "valueCoding": { "code": "moderate", "display": "Moderate, tolerable" } },
42+
{ "valueCoding": { "code": "marked", "display": "Marked, serious limitation" } },
43+
{ "valueCoding": { "code": "disabled", "display": "Totally disabled" } }
44+
]
45+
},
46+
{
47+
"linkId": "100285-6",
48+
"text": "Walking support",
49+
"type": "choice",
50+
"answerOption": [
51+
{ "valueCoding": { "code": "none", "display": "None" } },
52+
{ "valueCoding": { "code": "cane-long", "display": "Cane for long walks" } },
53+
{ "valueCoding": { "code": "cane-all", "display": "Cane most of the time" } },
54+
{ "valueCoding": { "code": "crutch", "display": "One crutch" } },
55+
{ "valueCoding": { "code": "two-canes", "display": "Two canes" } },
56+
{ "valueCoding": { "code": "unable", "display": "Unable to walk" } }
57+
]
58+
},
59+
{
60+
"linkId": "100286-4",
61+
"text": "Walking distance",
62+
"type": "choice",
63+
"answerOption": [
64+
{ "valueCoding": { "code": "unlimited", "display": "Unlimited" } },
65+
{ "valueCoding": { "code": "six-blocks", "display": "Six blocks" } },
66+
{ "valueCoding": { "code": "two-blocks", "display": "Two to three blocks" } },
67+
{ "valueCoding": { "code": "indoors", "display": "Indoors only" } },
68+
{ "valueCoding": { "code": "bed-chair", "display": "Bed and chair only" } }
69+
]
70+
}
71+
]
72+
},
73+
"request": {
74+
"method": "PUT",
75+
"url": "Questionnaire/100283-1"
76+
}
77+
},
78+
{
79+
"resource": {
80+
"resourceType": "Questionnaire",
81+
"id": "100751-7",
82+
"url": "http://loinc.org/q/100751-7",
83+
"status": "active",
84+
"title": "Meat allergen panel",
85+
"name": "MeatAllergenPanel",
86+
"item": [
87+
{
88+
"linkId": "100752-5",
89+
"text": "Beef allergy",
90+
"type": "choice",
91+
"answerOption": [
92+
{ "valueCoding": { "code": "positive", "display": "Positive" } },
93+
{ "valueCoding": { "code": "negative", "display": "Negative" } },
94+
{ "valueCoding": { "code": "inconclusive", "display": "Inconclusive" } }
95+
]
96+
},
97+
{
98+
"linkId": "100753-3",
99+
"text": "Pork allergy",
100+
"type": "choice",
101+
"answerOption": [
102+
{ "valueCoding": { "code": "positive", "display": "Positive" } },
103+
{ "valueCoding": { "code": "negative", "display": "Negative" } },
104+
{ "valueCoding": { "code": "inconclusive", "display": "Inconclusive" } }
105+
]
106+
},
107+
{
108+
"linkId": "100754-1",
109+
"text": "Lamb allergy",
110+
"type": "choice",
111+
"answerOption": [
112+
{ "valueCoding": { "code": "positive", "display": "Positive" } },
113+
{ "valueCoding": { "code": "negative", "display": "Negative" } },
114+
{ "valueCoding": { "code": "inconclusive", "display": "Inconclusive" } }
115+
]
116+
},
117+
{
118+
"linkId": "100755-8",
119+
"text": "Notes",
120+
"type": "text"
121+
}
122+
]
123+
},
124+
"request": {
125+
"method": "PUT",
126+
"url": "Questionnaire/100751-7"
127+
}
128+
},
129+
{
130+
"resource": {
131+
"resourceType": "Questionnaire",
132+
"id": "101549-4",
133+
"url": "http://loinc.org/q/101549-4",
134+
"status": "active",
135+
"title": "Schmid fall risk",
136+
"name": "SchmidFallRisk",
137+
"item": [
138+
{
139+
"linkId": "101550-2",
140+
"text": "Age over 65",
141+
"type": "boolean"
142+
},
143+
{
144+
"linkId": "101551-0",
145+
"text": "History of falls in the past 6 months",
146+
"type": "boolean"
147+
},
148+
{
149+
"linkId": "101552-8",
150+
"text": "Uses assistive device",
151+
"type": "boolean"
152+
},
153+
{
154+
"linkId": "101553-6",
155+
"text": "Unsteady gait",
156+
"type": "boolean"
157+
},
158+
{
159+
"linkId": "101554-4",
160+
"text": "Visual impairment",
161+
"type": "boolean"
162+
},
163+
{
164+
"linkId": "101555-1",
165+
"text": "Additional observations",
166+
"type": "text"
167+
}
168+
]
169+
},
170+
"request": {
171+
"method": "PUT",
172+
"url": "Questionnaire/101549-4"
173+
}
174+
},
175+
{
176+
"resource": {
177+
"resourceType": "QuestionnaireResponse",
178+
"id": "qr-1",
179+
"status": "in-progress",
180+
"questionnaire": "http://loinc.org/q/100283-1",
181+
"subject": {
182+
"reference": "Patient/pt-1"
183+
},
184+
"authored": "2026-02-20T10:00:00Z",
185+
"item": []
186+
},
187+
"request": {
188+
"method": "PUT",
189+
"url": "QuestionnaireResponse/qr-1"
190+
}
191+
},
192+
{
193+
"resource": {
194+
"resourceType": "QuestionnaireResponse",
195+
"id": "qr-2",
196+
"status": "in-progress",
197+
"questionnaire": "http://loinc.org/q/100751-7",
198+
"subject": {
199+
"reference": "Patient/pt-1"
200+
},
201+
"authored": "2026-02-20T10:05:00Z",
202+
"item": []
203+
},
204+
"request": {
205+
"method": "PUT",
206+
"url": "QuestionnaireResponse/qr-2"
207+
}
208+
},
209+
{
210+
"resource": {
211+
"resourceType": "QuestionnaireResponse",
212+
"id": "qr-3",
213+
"status": "in-progress",
214+
"questionnaire": "http://loinc.org/q/101549-4",
215+
"subject": {
216+
"reference": "Patient/pt-1"
217+
},
218+
"authored": "2026-02-20T10:10:00Z",
219+
"item": []
220+
},
221+
"request": {
222+
"method": "PUT",
223+
"url": "QuestionnaireResponse/qr-3"
224+
}
225+
}
226+
]
227+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env bash
2+
# Load FHIR fixture bundle into Aidbox
3+
#
4+
# Usage:
5+
# ./fixtures/load-fixtures.sh # defaults: localhost:8081, basic:secret
6+
# ./fixtures/load-fixtures.sh http://localhost:8888 # custom base URL
7+
# ./fixtures/load-fixtures.sh http://localhost:8888 client:secret # custom URL and credentials
8+
9+
set -euo pipefail
10+
11+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12+
AIDBOX_BASE_URL="${1:-http://localhost:8081}"
13+
AIDBOX_CREDENTIALS="${2:-basic:secret}"
14+
15+
echo "Loading fixtures into ${AIDBOX_BASE_URL} ..."
16+
17+
curl -s -f \
18+
-X POST \
19+
"${AIDBOX_BASE_URL}/fhir" \
20+
-H "Content-Type: application/json" \
21+
-H "Authorization: Basic $(echo -n "${AIDBOX_CREDENTIALS}" | base64)" \
22+
-d @"${SCRIPT_DIR}/bundle.json" \
23+
| python3 -m json.tool 2>/dev/null || cat
24+
25+
echo ""
26+
echo "Done."

0 commit comments

Comments
 (0)