Skip to content

Commit d265073

Browse files
committed
Implement fhirpath expression evaluation
1 parent c93f086 commit d265073

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+4413
-751
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,6 @@ dist
109109

110110
# Gatsby files
111111
.cache/
112-
public
113112

114113
# Storybook build outputs
115114
.out

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@
4444
* prefer undefined over null to encode absence of value
4545
* when writing tests use describe/it functions extensively to group related checks and assertions with meaningful text
4646
* prefer small isolated tests with dedicated test data
47+
* prefer having functions over class methods if `this` is not used

demo/app.tsx

Lines changed: 144 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,74 @@
1+
import type { ChangeEvent, ReactNode } from "react";
12
import { useCallback, useEffect, useMemo, useState } from "react";
2-
import type { ChangeEvent } from "react";
33

44
import { default as Renderer } from "../lib";
55

66
import type { Questionnaire, QuestionnaireResponse } from "fhir/r5";
7-
import CodeMirror from "@uiw/react-codemirror";
87
import { json } from "@codemirror/lang-json";
8+
import CodeMirror from "@uiw/react-codemirror";
9+
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
910
import { demoSamples } from "./samples";
1011

12+
type DensePanelProps = {
13+
title: string;
14+
children: ReactNode;
15+
defaultSize: number;
16+
minSize: number;
17+
minWidthClass?: string;
18+
headerAction?: ReactNode;
19+
};
20+
21+
type ApplyQuestionnaireButtonProps = {
22+
onClick: () => void;
23+
};
24+
25+
function ApplyQuestionnaireButton({
26+
onClick,
27+
}: ApplyQuestionnaireButtonProps) {
28+
return (
29+
<button
30+
type="button"
31+
onClick={onClick}
32+
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-700 text-white text-xs transition hover:bg-blue-600 active:translate-y-px"
33+
>
34+
<span aria-hidden="true"></span>
35+
<span className="sr-only">Apply Questionnaire</span>
36+
</button>
37+
);
38+
}
39+
40+
function DensePanel({
41+
title,
42+
headerAction,
43+
children,
44+
defaultSize,
45+
minSize,
46+
minWidthClass,
47+
}: DensePanelProps) {
48+
const actionSlot =
49+
headerAction ?? (
50+
<span className="block h-6 w-6 flex-shrink-0" aria-hidden="true" />
51+
);
52+
53+
return (
54+
<Panel
55+
defaultSize={defaultSize}
56+
minSize={minSize}
57+
className={`flex h-full flex-col border rounded border-slate-200 bg-white ${minWidthClass ?? ""}`}
58+
>
59+
<div className="flex items-center justify-between gap-1 border-b border-slate-200 bg-slate-100 pl-2 pr-3">
60+
<h2 className="py-2.5 flex-1 min-w-0 text-xs font-semibold uppercase tracking-wide text-slate-700 whitespace-nowrap">
61+
{title}
62+
</h2>
63+
{actionSlot}
64+
</div>
65+
<div className="flex flex-1 flex-col gap-2 overflow-hidden px-3 py-3">
66+
{children}
67+
</div>
68+
</Panel>
69+
);
70+
}
71+
1172
const cloneQuestionnaire = (input: Questionnaire): Questionnaire =>
1273
JSON.parse(JSON.stringify(input)) as Questionnaire;
1374

@@ -114,72 +175,89 @@ export function App() {
114175
}, [questionnaireSource]);
115176

116177
return (
117-
<main className="grid min-h-screen grid-cols-[minmax(24rem,28rem)_minmax(40rem,1fr)_minmax(24rem,28rem)] items-start gap-8 p-8">
118-
<aside className="sticky top-8 flex h-[calc(100vh-4rem)] flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4">
119-
<div className="flex items-center justify-between gap-4">
120-
<h2 className="py-1 text-lg font-medium">Questionnaire</h2>
121-
{hasPendingChanges ? (
122-
<button
123-
type="button"
124-
onClick={handleApplyQuestionnaire}
125-
className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-700 text-white transition hover:bg-blue-600 active:translate-y-px"
126-
>
127-
<span aria-hidden="true"></span>
128-
<span className="sr-only">Apply Questionnaire</span>
129-
</button>
178+
<main className="h-screen w-screen bg-slate-50 p-3">
179+
<PanelGroup
180+
direction="horizontal"
181+
autoSaveId="demo-panel-layout"
182+
className="flex h-full w-full"
183+
>
184+
<DensePanel
185+
title="Questionnaire"
186+
defaultSize={26}
187+
minSize={18}
188+
minWidthClass="min-w-[16rem]"
189+
headerAction={
190+
hasPendingChanges ? (
191+
<ApplyQuestionnaireButton
192+
onClick={handleApplyQuestionnaire}
193+
/>
194+
) : undefined
195+
}
196+
>
197+
<select
198+
id="sample-select"
199+
value={selectedSampleId}
200+
onChange={handleSelectSample}
201+
className="w-full rounded border border-slate-200 bg-white px-2 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
202+
>
203+
{sampleOptions.map((sample) => (
204+
<option key={sample.id} value={sample.id}>
205+
{sample.label}
206+
</option>
207+
))}
208+
</select>
209+
<div className="flex flex-1 overflow-hidden border border-slate-200">
210+
<CodeMirror
211+
aria-label="Questionnaire JSON"
212+
value={questionnaireSource}
213+
className="h-full text-xs"
214+
style={{ height: "100%", width: "100%" }}
215+
extensions={editorExtensions}
216+
basicSetup={{ lineNumbers: false, foldGutter: false }}
217+
onChange={(value) => setQuestionnaireSource(value)}
218+
/>
219+
</div>
220+
{questionnaireError ? (
221+
<p className="w-full rounded bg-red-200 px-2 py-2 text-xs text-red-800">
222+
{questionnaireError}
223+
</p>
130224
) : null}
131-
</div>
132-
<select
133-
id="sample-select"
134-
value={selectedSampleId}
135-
onChange={handleSelectSample}
136-
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
225+
</DensePanel>
226+
<PanelResizeHandle className="w-3 cursor-col-resize bg-transparent" />
227+
<DensePanel
228+
title="Form"
229+
defaultSize={48}
230+
minSize={30}
231+
minWidthClass="min-w-[24rem]"
232+
>
233+
<div className="h-full flex-1 overflow-auto">
234+
<Renderer
235+
questionnaire={questionnaire}
236+
onSubmit={setQuestionnaireResponse}
237+
onChange={setQuestionnaireResponse}
238+
/>
239+
</div>
240+
</DensePanel>
241+
<PanelResizeHandle className="w-3 cursor-col-resize bg-transparent" />
242+
<DensePanel
243+
title="Questionnaire Response"
244+
defaultSize={26}
245+
minSize={18}
246+
minWidthClass="min-w-[16rem]"
137247
>
138-
{sampleOptions.map((sample) => (
139-
<option key={sample.id} value={sample.id}>
140-
{sample.label}
141-
</option>
142-
))}
143-
</select>
144-
<div className="flex-1 overflow-hidden rounded-lg border border-slate-200 flex">
145-
<CodeMirror
146-
aria-label="Questionnaire JSON"
147-
value={questionnaireSource}
148-
className="h-full"
149-
style={{ height: "100%", width: "100%" }}
150-
extensions={editorExtensions}
151-
onChange={(value) => setQuestionnaireSource(value)}
152-
/>
153-
</div>
154-
{questionnaireError ? (
155-
<p className="rounded-lg bg-red-200 px-3 py-2 text-sm text-red-800">
156-
{questionnaireError}
157-
</p>
158-
) : null}
159-
</aside>
160-
161-
<section className="flex h-[calc(100vh-4rem)] flex-col overflow-auto rounded-xl border border-slate-200 bg-white">
162-
<Renderer
163-
questionnaire={questionnaire}
164-
onSubmit={setQuestionnaireResponse}
165-
onChange={setQuestionnaireResponse}
166-
/>
167-
</section>
168-
169-
<aside className="sticky top-8 flex h-[calc(100vh-4rem)] flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4">
170-
<div className="flex items-center justify-between gap-4">
171-
<h2 className="py-1 text-lg font-medium">Questionnaire Response</h2>
172-
</div>
173-
<div className="flex-1 overflow-hidden rounded-lg border border-slate-200">
174-
<CodeMirror
175-
aria-label="Questionnaire Response JSON"
176-
value={JSON.stringify(questionnaireResponse ?? {}, null, 2)}
177-
className="h-full"
178-
extensions={responseViewerExtensions}
179-
readOnly
180-
/>
181-
</div>
182-
</aside>
248+
<div className="flex flex-1 overflow-hidden border border-slate-200">
249+
<CodeMirror
250+
aria-label="Questionnaire Response JSON"
251+
value={JSON.stringify(questionnaireResponse ?? {}, null, 2)}
252+
className="h-full text-xs"
253+
style={{ height: "100%", width: "100%" }}
254+
extensions={responseViewerExtensions}
255+
basicSetup={{ lineNumbers: false, foldGutter: false }}
256+
readOnly
257+
/>
258+
</div>
259+
</DensePanel>
260+
</PanelGroup>
183261
</main>
184262
);
185263
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"resourceType": "Questionnaire",
3+
"id": "expression-calculated",
4+
"status": "active",
5+
"title": "Calculated BMI",
6+
"description": "Demonstrates calculatedExpression with shared variables.",
7+
"item": [
8+
{
9+
"linkId": "intro",
10+
"type": "display",
11+
"text": "Enter height and weight to calculate BMI automatically. Changing either value recalculates BMI until you override it."
12+
},
13+
{
14+
"linkId": "metrics",
15+
"type": "group",
16+
"text": "Body metrics",
17+
"extension": [
18+
{
19+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-variable",
20+
"valueExpression": {
21+
"language": "text/fhirpath",
22+
"name": "heightCm",
23+
"expression": "%context.item.where(linkId='height').answer.valueDecimal.last()"
24+
}
25+
},
26+
{
27+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-variable",
28+
"valueExpression": {
29+
"language": "text/fhirpath",
30+
"name": "weightKg",
31+
"expression": "%context.item.where(linkId='weight').answer.valueDecimal.last()"
32+
}
33+
}
34+
],
35+
"item": [
36+
{
37+
"linkId": "height",
38+
"type": "decimal",
39+
"text": "Height (cm)"
40+
},
41+
{
42+
"linkId": "weight",
43+
"type": "decimal",
44+
"text": "Weight (kg)"
45+
},
46+
{
47+
"linkId": "bmi",
48+
"type": "decimal",
49+
"text": "Body mass index",
50+
"extension": [
51+
{
52+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression",
53+
"valueExpression": {
54+
"language": "text/fhirpath",
55+
"name": "bmiCalc",
56+
"expression": "iif(%heightCm.exists() and %weightKg.exists(), (%weightKg / ((%heightCm / 100) * (%heightCm / 100))).round(1), {})"
57+
}
58+
}
59+
]
60+
},
61+
{
62+
"linkId": "category",
63+
"type": "display",
64+
"text": "BMI category updates once BMI is available.",
65+
"extension": [
66+
{
67+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression",
68+
"valueExpression": {
69+
"language": "text/fhirpath",
70+
"expression": "%context.item.where(linkId='bmi').answer.valueDecimal.exists()"
71+
}
72+
}
73+
]
74+
}
75+
]
76+
}
77+
]
78+
}

0 commit comments

Comments
 (0)