Skip to content

Commit 7b4bdd2

Browse files
committed
Add questionnaire renderer components and update styles
1 parent f9d4073 commit 7b4bdd2

39 files changed

+2294
-133
lines changed

README.md

Lines changed: 19 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,33 @@
1-
# FHIRPath Editor
1+
# Aidbox Forms Renderer
22

3-
A React component library for editing and evaluating FHIRPath expressions with real-time feedback.
4-
5-
## Online Demo
6-
7-
Try the interactive demo at [https://healthsamurai.github.io/aidbox-forms-renderer/](https://healthsamurai.github.io/aidbox-forms-renderer/)
8-
9-
## Installation
10-
11-
```bash
12-
npm install aidbox-forms-renderer
13-
# or
14-
yarn add aidbox-forms-renderer
15-
```
16-
17-
## Usage
18-
19-
The library provides an `Editor` component for working with FHIRPath expressions:
20-
21-
### Uncontrolled Component (with defaultValue)
22-
23-
```tsx
24-
import { Editor } from "aidbox-forms-renderer";
25-
import r4 from "fhirpath/fhir-context/r4";
26-
27-
function MyFhirPathEditor() {
28-
// FHIRPath expression to evaluate
29-
const defaultExpr = "(1 + 2) * 3";
30-
const handleChange = (newValue) =>
31-
console.log("Expression changed:", newValue);
32-
33-
// Simple FHIR resource to evaluate FHIRPath against
34-
const data = {
35-
resourceType: "Patient",
36-
id: "example",
37-
name: [
38-
{
39-
family: "Smith",
40-
given: ["John"],
41-
},
42-
],
43-
birthDate: "1970-01-01",
44-
};
45-
46-
const fhirSchema = []; // Replace with actual schema data
47-
48-
return (
49-
<Editor
50-
defaultValue={defaultExpr}
51-
onChange={handleChange}
52-
data={data}
53-
schema={fhirSchema}
54-
model={r4}
55-
/>
56-
);
57-
}
58-
```
59-
60-
### Controlled Component (with value)
3+
Minimal React renderer for HL7® FHIR® Questionnaires. State is always a canonical `QuestionnaireResponse`, so the data you display is the data you can submit.
614

625
```tsx
63-
import { Editor } from "aidbox-forms-renderer";
64-
import r4 from "fhirpath/fhir-context/r4";
6+
import { Renderer, type Questionnaire, type QuestionnaireResponse } from "aidbox-forms-renderer";
657
import { useState } from "react";
668

67-
function MyFhirPathEditor() {
68-
// FHIRPath expression state
69-
const [expression, setExpression] = useState("(1 + 2) * 3");
70-
71-
// Simple FHIR resource to evaluate FHIRPath against
72-
const data = {
73-
resourceType: "Patient",
74-
id: "example",
75-
name: [
76-
{
77-
family: "Smith",
78-
given: ["John"],
79-
},
80-
],
81-
birthDate: "1970-01-01",
82-
};
9+
const questionnaire: Questionnaire = {
10+
resourceType: "Questionnaire",
11+
item: [
12+
{ linkId: "first", text: "First name", type: "string", required: true },
13+
{ linkId: "consent", text: "Consent to treatment", type: "boolean" },
14+
],
15+
};
8316

84-
const fhirSchema = []; // Replace with actual schema data
17+
export function IntakeForm() {
18+
const [response, setResponse] = useState<QuestionnaireResponse | null>(null);
8519

8620
return (
87-
<Editor
88-
value={expression}
89-
onChange={setExpression}
90-
data={data}
91-
schema={fhirSchema}
92-
model={r4}
21+
<Renderer
22+
questionnaire={questionnaire}
23+
initialResponse={response ?? undefined}
24+
onChange={setResponse}
25+
onSubmit={setResponse}
9326
/>
9427
);
9528
}
9629
```
9730

98-
## Props
99-
100-
| Prop | Type | Required | Description |
101-
| -------------- | ----------------------- | -------- | ----------------------------------------------- |
102-
| `value` | string | No | Current FHIRPath expression (controlled mode) |
103-
| `defaultValue` | string | No | Initial FHIRPath expression (uncontrolled mode) |
104-
| `onChange` | (value: string) => void | No | Callback for expression changes |
105-
| `data` | any | Yes | The context data to evaluate FHIRPath against |
106-
| `variables` | Record<string, any> | No | External bindings available to expressions |
107-
| `schema` | FhirSchema[] | Yes | FHIR schema definitions for validation |
108-
| `model` | Model | Yes | FHIR version model data |
109-
| `debug` | boolean | No | Enable debug mode |
110-
111-
## Component Modes
112-
113-
The Editor component supports both controlled and uncontrolled modes:
114-
115-
- **Controlled Mode**: Provide the `value` prop and handle changes with `onChange`.
116-
- **Uncontrolled Mode**: Provide only the `defaultValue` prop (initial value) and optionally handle changes with `onChange`.
117-
118-
Note: Do not provide both `value` and `defaultValue` at the same time. If both are provided, `defaultValue` will be ignored and a warning will be logged to the console.
119-
120-
## Features
121-
122-
- Low-code environment for developing and testing FHIRPath expressions
123-
- Interactive visual editor with instant feedback
124-
- Real-time evaluation against FHIR resources
125-
- Support for external variable bindings
126-
- Schema-based validation with autocomplete suggestions
127-
- Syntax highlighting and error detection
128-
- Reduces development time for complex FHIRPath queries
31+
Useful scripts: `npm run dev` (playground), `npm run build` (type-check + bundle), `npm test`, `npm run lint`.
12932

130-
## Todo
131-
- [x] Support $total special variable
132-
- [x] Support type evaluation of `aggregate` call
133-
- [ ] Support dynamic index access
134-
- [ ] Support unit token
33+
Architecture and rationale live as doc comments in the source (`lib/state/`, `lib/form-provider.tsx`, `lib/questionnaire-renderer.tsx`).

eslint.config.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import reactHooks from "eslint-plugin-react-hooks";
44
import reactRefresh from "eslint-plugin-react-refresh";
55
import tseslint from "typescript-eslint";
66

7+
const tsconfigRootDir = new URL("./", import.meta.url).pathname;
8+
79
export default tseslint.config(
810
{ ignores: ["dist"] },
911
{
@@ -23,7 +25,19 @@ export default tseslint.config(
2325
"warn",
2426
{ allowConstantExport: true },
2527
],
26-
"@typescript-eslint/no-explicit-any": "off",
28+
"@typescript-eslint/no-explicit-any": "error",
29+
},
30+
},
31+
{
32+
files: ["lib/**/*.{ts,tsx}"],
33+
languageOptions: {
34+
parserOptions: {
35+
project: ["./tsconfig.lib.json"],
36+
tsconfigRootDir,
37+
},
38+
},
39+
rules: {
40+
"@typescript-eslint/switch-exhaustiveness-check": "error",
2741
},
2842
},
2943
);

lib/Renderer.tsx

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useStateContext } from "../../state/state-context.ts";
2+
import type { QuestionnaireItem, Attachment } from "fhir/r5";
3+
import { coerceAttachment } from "../../utils/answer-coercions";
4+
import { renderItemChildren } from "./item-children";
5+
import { renderItemLabel } from "./item-label";
6+
7+
interface AttachmentItemProps {
8+
item: QuestionnaireItem;
9+
}
10+
11+
export function AttachmentItem({ item }: AttachmentItemProps) {
12+
const { readValue, writeValue } = useStateContext();
13+
const value = readValue(item);
14+
const attachment = coerceAttachment(value) ?? {};
15+
const inputId = `q-${item.linkId}`;
16+
17+
const update = (partial: Partial<Attachment>) => {
18+
const next: Attachment = { ...attachment, ...partial };
19+
if (!next.url && !next.data && !next.title && !next.contentType) {
20+
writeValue(item, undefined);
21+
} else {
22+
writeValue(item, next);
23+
}
24+
};
25+
26+
return (
27+
<div className="q-item q-item-attachment">
28+
{renderItemLabel(item, inputId)}
29+
<input
30+
id={`${inputId}-url`}
31+
type="url"
32+
placeholder="URL"
33+
value={attachment.url ?? ""}
34+
onChange={(event) => update({ url: event.target.value || undefined })}
35+
disabled={item.readOnly}
36+
/>
37+
<input
38+
id={`${inputId}-content-type`}
39+
type="text"
40+
placeholder="Content type"
41+
value={attachment.contentType ?? ""}
42+
onChange={(event) => update({ contentType: event.target.value || undefined })}
43+
disabled={item.readOnly}
44+
/>
45+
<textarea
46+
id={`${inputId}-data`}
47+
placeholder="Base64 data"
48+
value={attachment.data ?? ""}
49+
onChange={(event) => update({ data: event.target.value || undefined })}
50+
disabled={item.readOnly}
51+
/>
52+
{renderItemChildren(item)}
53+
</div>
54+
);
55+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useStateContext } from "../../state/state-context.ts";
2+
import type { QuestionnaireItem } from "fhir/r5";
3+
import { renderItemChildren } from "./item-children";
4+
5+
interface BooleanItemProps {
6+
item: QuestionnaireItem;
7+
}
8+
9+
export function BooleanItem({ item }: BooleanItemProps) {
10+
const { readValue, writeValue } = useStateContext();
11+
const value = readValue(item);
12+
const checked = typeof value === "boolean" ? value : false;
13+
const inputId = `q-${item.linkId}`;
14+
15+
return (
16+
<div className="q-item q-item-boolean">
17+
<label className="q-item-label q-item-label-inline">
18+
<input
19+
id={inputId}
20+
type="checkbox"
21+
checked={checked}
22+
required={item.required}
23+
onChange={(event) => writeValue(item, event.target.checked)}
24+
disabled={item.readOnly}
25+
/>
26+
<span>{item.text ?? item.linkId}</span>
27+
{item.required ? <span className="q-item-required">*</span> : null}
28+
</label>
29+
{renderItemChildren(item)}
30+
</div>
31+
);
32+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useStateContext } from "../../state/state-context.ts";
2+
import type { QuestionnaireItem } from "fhir/r5";
3+
import {
4+
getCodingLabel,
5+
getCodingValue,
6+
isSameCodingValue,
7+
} from "../../utils/coding-options";
8+
import { renderItemChildren } from "./item-children";
9+
import { renderItemLabel } from "./item-label";
10+
11+
interface CodingItemProps {
12+
item: QuestionnaireItem;
13+
}
14+
15+
export function CodingItem({ item }: CodingItemProps) {
16+
const { readValue, writeValue } = useStateContext();
17+
const value = readValue(item);
18+
const inputId = `q-${item.linkId}`;
19+
const options = item.answerOption ?? [];
20+
21+
if (options.length === 0) {
22+
return (
23+
<div className="q-item q-item-coding">
24+
{renderItemLabel(item, inputId)}
25+
<p className="q-item-unsupported">No answer options provided.</p>
26+
{renderItemChildren(item)}
27+
</div>
28+
);
29+
}
30+
31+
const internalOptions = options.map((option, index) => {
32+
const optionValue = getCodingValue(option);
33+
return {
34+
key: String(index),
35+
value: optionValue,
36+
label: getCodingLabel(option),
37+
};
38+
});
39+
40+
const selected = internalOptions.find((option) => isSameCodingValue(option.value, value));
41+
42+
return (
43+
<div className="q-item q-item-coding">
44+
{renderItemLabel(item, inputId)}
45+
<select
46+
id={inputId}
47+
required={item.required}
48+
disabled={item.readOnly}
49+
value={selected?.key ?? ""}
50+
onChange={(event) => {
51+
const next = internalOptions.find((option) => option.key === event.target.value);
52+
writeValue(item, next ? next.value : undefined);
53+
}}
54+
>
55+
<option value="" disabled>
56+
Select an option
57+
</option>
58+
{internalOptions.map((option) => (
59+
<option key={option.key} value={option.key}>
60+
{option.label}
61+
</option>
62+
))}
63+
</select>
64+
{renderItemChildren(item)}
65+
</div>
66+
);
67+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { QuestionnaireItem } from "fhir/r5";
2+
import { renderItemChildren } from "./item-children";
3+
4+
interface DisplayItemProps {
5+
item: QuestionnaireItem;
6+
}
7+
8+
export function DisplayItem({ item }: DisplayItemProps) {
9+
return (
10+
<div className="q-item q-item-display-wrapper">
11+
<p className="q-item-display">{item.text}</p>
12+
{renderItemChildren(item)}
13+
</div>
14+
);
15+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { QuestionnaireItem } from "fhir/r5";
2+
import { renderItemChildren } from "./item-children";
3+
4+
interface GroupItemProps {
5+
item: QuestionnaireItem;
6+
}
7+
8+
export function GroupItem({ item }: GroupItemProps) {
9+
return (
10+
<fieldset className="q-item q-item-group">
11+
<legend>
12+
{item.prefix ? <span className="q-item-prefix">{item.prefix} </span> : null}
13+
<span>{item.text ?? item.linkId}</span>
14+
</legend>
15+
{renderItemChildren(item)}
16+
</fieldset>
17+
);
18+
}

0 commit comments

Comments
 (0)