Skip to content

Commit 776a031

Browse files
committed
Introduce answer expression support
1 parent 9f6bdc4 commit 776a031

File tree

13 files changed

+562
-35
lines changed

13 files changed

+562
-35
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{
2+
"resourceType": "Questionnaire",
3+
"id": "answer-expression",
4+
"status": "active",
5+
"title": "Answer Expression Showcase",
6+
"description": "Demonstrates runtime answer lists populated by sdc-questionnaire-answerExpression for multiple data types.",
7+
"item": [
8+
{
9+
"linkId": "dynamic-colors",
10+
"text": "Collect and reuse color choices",
11+
"type": "group",
12+
"extension": [
13+
{
14+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-variable",
15+
"valueExpression": {
16+
"name": "collectedColors",
17+
"language": "text/fhirpath",
18+
"expression": "%context.item.where(linkId='color-source').answer.valueString"
19+
}
20+
}
21+
],
22+
"item": [
23+
{
24+
"linkId": "color-source",
25+
"type": "string",
26+
"repeats": true,
27+
"text": "Add every color that should be available"
28+
},
29+
{
30+
"linkId": "color-select",
31+
"type": "string",
32+
"text": "Pick a favorite color",
33+
"extension": [
34+
{
35+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression",
36+
"valueExpression": {
37+
"language": "text/fhirpath",
38+
"expression": "%collectedColors"
39+
}
40+
}
41+
]
42+
}
43+
]
44+
},
45+
{
46+
"linkId": "sleep-patterns",
47+
"text": "Choose typical sleep duration",
48+
"type": "decimal",
49+
"extension": [
50+
{
51+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression",
52+
"valueExpression": {
53+
"language": "text/fhirpath",
54+
"expression": "(6.0 | 7.0 | 7.5 | 8.0)"
55+
}
56+
}
57+
]
58+
},
59+
{
60+
"linkId": "dose-options",
61+
"text": "Suggested dosage",
62+
"type": "quantity",
63+
"extension": [
64+
{
65+
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression",
66+
"valueExpression": {
67+
"language": "text/fhirpath",
68+
"expression": "(%factory.Quantity('http://unitsofmeasure.org', 'mg', 50, 'mg') | %factory.Quantity('http://unitsofmeasure.org', 'mg', 100, 'mg') | %factory.Quantity('http://unitsofmeasure.org', 'mg', 150, 'mg'))"
69+
}
70+
}
71+
]
72+
}
73+
]
74+
}

demo/samples/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import expressionCalculated from "./expression-calculated.json" assert { type: "
1515
import expressionEnableWhen from "./expression-enable-when.json" assert { type: "json" };
1616
import expressionDynamicBounds from "./expression-dynamic-bounds.json" assert { type: "json" };
1717
import answerOptions from "./answer-options.json" assert { type: "json" };
18+
import answerExpression from "./answer-expression.json" assert { type: "json" };
1819
import validation from "./validation.json" assert { type: "json" };
1920
import targetConstraint from "./target-constraint.json" assert { type: "json" };
2021

@@ -100,6 +101,11 @@ export const demoSamples: readonly DemoSample[] = [
100101
label: "Answer options",
101102
questionnaire: answerOptions as Questionnaire,
102103
},
104+
{
105+
id: "answer-expression",
106+
label: "Answer expressions",
107+
questionnaire: answerExpression as Questionnaire,
108+
},
103109
{
104110
id: "validation",
105111
label: "Validation",

lib/__tests__/utils.test.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { describe, expect, it } from "vitest";
2-
import type { Attachment, Coding, Element, Quantity, Reference } from "fhir/r5";
2+
import type {
3+
Attachment,
4+
Coding,
5+
Element,
6+
Quantity,
7+
QuestionnaireItemAnswerOption,
8+
Reference,
9+
} from "fhir/r5";
310

411
import {
12+
answerify,
513
areValuesEqual,
614
cloneValue,
715
countDecimalPlaces,
@@ -252,6 +260,143 @@ describe("stringifyValue", () => {
252260
});
253261
});
254262

263+
describe("answerify", () => {
264+
it("flattens nested collections", () => {
265+
const result = answerify("string", ["Alpha", ["Beta", ["Gamma"]]]);
266+
expect(result).toEqual([
267+
{ valueString: "Alpha" },
268+
{ valueString: "Beta" },
269+
{ valueString: "Gamma" },
270+
]);
271+
});
272+
273+
it("coerces boolean strings", () => {
274+
const result = answerify("boolean", ["true", "FALSE", "maybe"]);
275+
expect(result).toEqual([{ valueBoolean: true }, { valueBoolean: false }]);
276+
});
277+
278+
it("parses numerics for integer questions", () => {
279+
const result = answerify("integer", ["5", 7, "oops"]);
280+
expect(result).toEqual([{ valueInteger: 5 }, { valueInteger: 7 }]);
281+
});
282+
283+
it("parses numerics for decimal questions", () => {
284+
const result = answerify("decimal", ["1.5", 2, "oops"]);
285+
expect(result).toEqual([{ valueDecimal: 1.5 }, { valueDecimal: 2 }]);
286+
});
287+
288+
it("wraps bare coding objects", () => {
289+
const coding: Coding = { system: "http://loinc.org", code: "718-7" };
290+
const [option] = answerify("coding", coding);
291+
expect(option).toEqual({ valueCoding: coding });
292+
expect(option.valueCoding).toBe(coding);
293+
});
294+
295+
it("returns structured codings unchanged when provided as answerOption", () => {
296+
const option: QuestionnaireItemAnswerOption = {
297+
valueCoding: { system: "http://loinc.org", code: "890-5" },
298+
extension: [{ url: "test", valueString: "meta" }],
299+
};
300+
const [result] = answerify("coding", [option]);
301+
expect(result).not.toBe(option);
302+
expect(result.valueCoding).toEqual(option.valueCoding);
303+
expect(result.extension).toBe(option.extension);
304+
});
305+
306+
it("clones provided answerOption wrappers", () => {
307+
const original = {
308+
valueCoding: { code: "opt", display: "Option" },
309+
extension: [{ url: "x", valueString: "meta" }],
310+
} satisfies QuestionnaireItemAnswerOption;
311+
312+
const [option] = answerify("coding", [original]);
313+
314+
expect(option.valueCoding).toEqual(original.valueCoding);
315+
expect(option.valueCoding).toBe(original.valueCoding);
316+
expect(option.extension).toEqual(original.extension);
317+
expect(option.extension).toBe(original.extension);
318+
});
319+
320+
it("filters unsupported values", () => {
321+
const result = answerify("boolean", [null, undefined, 1, "maybe"]);
322+
expect(result).toEqual([]);
323+
});
324+
325+
it("clones structured quantity answers", () => {
326+
const quantity = { value: 42, unit: "kg" } satisfies Quantity;
327+
const result = answerify("quantity", quantity);
328+
expect(result).toHaveLength(1);
329+
expect((result[0] as { valueQuantity?: Quantity }).valueQuantity).toBe(
330+
quantity,
331+
);
332+
});
333+
334+
it("passes through references", () => {
335+
const reference = {
336+
reference: "Patient/1",
337+
display: "Alice",
338+
} satisfies Reference;
339+
const result = answerify("reference", reference);
340+
expect(result).toHaveLength(1);
341+
expect((result[0] as { valueReference?: Reference }).valueReference).toBe(
342+
reference,
343+
);
344+
});
345+
346+
it("passes through attachments", () => {
347+
const attachment = {
348+
url: "https://example.org",
349+
title: "Scan",
350+
} satisfies Attachment;
351+
const result = answerify("attachment", attachment);
352+
expect(result).toHaveLength(1);
353+
expect(
354+
(result[0] as { valueAttachment?: Attachment }).valueAttachment,
355+
).toBe(attachment);
356+
});
357+
358+
it("accepts string-like types", () => {
359+
const result = answerify("string", "alpha");
360+
expect(result).toEqual([{ valueString: "alpha" }]);
361+
});
362+
363+
it("accepts text type", () => {
364+
const result = answerify("text", "long form");
365+
expect(result).toEqual([{ valueString: "long form" }]);
366+
});
367+
368+
it("accepts date values", () => {
369+
const result = answerify("date", ["2025-01-01", "invalid"]);
370+
expect(result.map((option) => getValue(option, "date"))).toEqual([
371+
"2025-01-01",
372+
"invalid",
373+
]);
374+
});
375+
376+
it("accepts dateTime values", () => {
377+
const result = answerify("dateTime", ["2025-01-01T09:30:00Z", 42]);
378+
expect(result).toEqual([{ valueDateTime: "2025-01-01T09:30:00Z" }]);
379+
});
380+
381+
it("accepts time values", () => {
382+
const result = answerify("time", ["08:15:00", null]);
383+
expect(result).toEqual([{ valueTime: "08:15:00" }]);
384+
});
385+
386+
it("rejects unsupported types", () => {
387+
const result = answerify("reference", ["string", 42, null]);
388+
expect(result).toEqual([]);
389+
});
390+
391+
it("handles empty source arrays", () => {
392+
expect(answerify("string", [])).toEqual([]);
393+
});
394+
395+
it("ignores undefined root value", () => {
396+
expect(answerify("string", undefined)).toEqual([]);
397+
});
398+
});
399+
255400
describe("areValuesEqual", () => {
256401
describe("string", () => {
257402
it("returns true for equal strings", () => {

lib/components/nodes/question-node.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const QuestionNode = observer(function QuestionNode({
2222
}: {
2323
item: IQuestionNode;
2424
}) {
25-
if ((item.template.answerOption?.length ?? 0) > 0) {
25+
if (item.answerOptions.length > 0) {
2626
return <AnswerOptionNode item={item} />;
2727
}
2828

lib/components/questions/__tests__/answer-option-node.test.tsx

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { describe, expect, it } from "vitest";
2-
import { render, screen } from "@testing-library/react";
2+
import { act, render, screen } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import type { Questionnaire, QuestionnaireResponse } from "fhir/r5";
55

66
import { FormStore } from "../../../stores/form-store.ts";
77
import { isQuestionNode } from "../../../stores/question-store.ts";
88
import { QuestionNode } from "../../nodes/question-node.tsx";
9+
import {
10+
makeAnswerExpression,
11+
makeVariable,
12+
} from "../../../stores/__tests__/expression-fixtures.ts";
913

1014
function getQuestionNode(form: FormStore, linkId: string) {
1115
const node = form.scope.lookupNode(linkId);
@@ -45,7 +49,9 @@ describe("AnswerOptionNode", () => {
4549
expect(combobox).toHaveValue("");
4650

4751
const user = userEvent.setup();
48-
const greenOption = screen.getByRole("option", { name: "Green" }) as HTMLOptionElement;
52+
const greenOption = screen.getByRole("option", {
53+
name: "Green",
54+
}) as HTMLOptionElement;
4955
await user.selectOptions(combobox, greenOption.value);
5056

5157
expect(greenOption.selected).toBe(true);
@@ -88,7 +94,9 @@ describe("AnswerOptionNode", () => {
8894
render(<QuestionNode item={question} />);
8995

9096
const combobox = screen.getByRole("combobox", { name: /severity/i });
91-
const selectedOption = screen.getByRole("option", { name: "Moderate" }) as HTMLOptionElement;
97+
const selectedOption = screen.getByRole("option", {
98+
name: "Moderate",
99+
}) as HTMLOptionElement;
92100
expect(combobox).toBeInTheDocument();
93101
expect(selectedOption.selected).toBe(true);
94102
expect(question.answers.at(0)?.value).toMatchObject({
@@ -149,4 +157,61 @@ describe("AnswerOptionNode", () => {
149157
).toBeNull();
150158
expect(question.answers.at(0)?.value).toBe("Vanilla");
151159
});
160+
161+
it("upgrades a question to a select when answerExpression yields options", async () => {
162+
const questionnaire: Questionnaire = {
163+
resourceType: "Questionnaire",
164+
status: "active",
165+
item: [
166+
{
167+
linkId: "panel",
168+
type: "group",
169+
extension: [
170+
makeVariable(
171+
"sourceValues",
172+
"%context.item.where(linkId='source').answer.valueString",
173+
),
174+
],
175+
item: [
176+
{
177+
linkId: "source",
178+
text: "Source",
179+
type: "string",
180+
},
181+
{
182+
linkId: "mirror",
183+
text: "Mirror",
184+
type: "string",
185+
extension: [makeAnswerExpression("%sourceValues")],
186+
},
187+
],
188+
},
189+
],
190+
};
191+
192+
const form = new FormStore(questionnaire);
193+
const source = getQuestionNode(form, "source");
194+
const mirror = getQuestionNode(form, "mirror");
195+
196+
render(<QuestionNode item={mirror} />);
197+
198+
expect(
199+
screen.getByRole("textbox", { name: /mirror/i }),
200+
).toBeInTheDocument();
201+
202+
await act(async () => {
203+
source.setAnswer(0, "Alpha");
204+
});
205+
206+
const combobox = await screen.findByRole("combobox", { name: /mirror/i });
207+
expect(combobox).toBeInTheDocument();
208+
expect(screen.getByRole("option", { name: "Alpha" })).toBeInTheDocument();
209+
210+
await act(async () => {
211+
source.setAnswer(0, "Beta");
212+
});
213+
214+
expect(screen.getByRole("option", { name: "Beta" })).toBeInTheDocument();
215+
expect(screen.queryByRole("option", { name: "Alpha" })).toBeNull();
216+
});
152217
});

0 commit comments

Comments
 (0)