@mieweb/forms-renderer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +356 -0
  2. package/dist/index.js +130 -0
  3. package/package.json +27 -0
package/README.md ADDED
@@ -0,0 +1,356 @@
1
+ # @mieweb/forms-renderer
2
+
3
+ Read-only questionnaire renderer for displaying and filling out forms. Produces FHIR QuestionnaireResponse output.
4
+
5
+ ## 📦 Installation
6
+
7
+ ```bash
8
+ npm install @mieweb/forms-renderer
9
+ ```
10
+
11
+ ### Peer Dependencies
12
+
13
+ Ensure you have React 18+ installed:
14
+
15
+ ```bash
16
+ npm install react react-dom
17
+ ```
18
+
19
+ ### Automatic Dependencies
20
+
21
+ The following is installed automatically:
22
+
23
+ - `@mieweb/forms-engine` - Core form state and field components
24
+
25
+ ## 🚀 Quick Start
26
+
27
+ ### 1. Basic Usage
28
+
29
+ ```jsx
30
+ import { QuestionnaireRenderer } from '@mieweb/forms-renderer';
31
+ import '@mieweb/forms-engine/styles.css'; // Import styles
32
+
33
+ function App({ fields }) {
34
+ const handleChange = (updatedFields) => {
35
+ console.log('Form data:', updatedFields);
36
+ };
37
+
38
+ const handleSubmit = (fhirResponse) => {
39
+ console.log('FHIR QuestionnaireResponse:', fhirResponse);
40
+ // Send to your backend
41
+ };
42
+
43
+ return (
44
+ <QuestionnaireRenderer
45
+ fields={fields}
46
+ onChange={handleChange}
47
+ onSubmit={handleSubmit}
48
+ questionnaireId="patient-intake-v1"
49
+ subjectId="patient-12345"
50
+ />
51
+ );
52
+ }
53
+ ```
54
+
55
+ ### 2. Field Structure
56
+
57
+ The `fields` prop accepts any data source (API, database, local storage) that matches this JSON structure:
58
+
59
+ ```js
60
+ // Example: Simple questionnaire data
61
+ [
62
+ {
63
+ id: '1',
64
+ fieldType: 'input',
65
+ question: 'What is your name?',
66
+ answer: ''
67
+ },
68
+ {
69
+ id: '2',
70
+ fieldType: 'radio',
71
+ question: 'Select your role',
72
+ options: ['Developer', 'Designer', 'Manager'],
73
+ selected: null
74
+ }
75
+ ]
76
+
77
+ // Example: Complex medical screening with sections and conditional logic
78
+ [
79
+ {
80
+ fieldType: "section",
81
+ title: "Patient Information",
82
+ id: "sec-patient-info",
83
+ fields: [
84
+ { fieldType: "input", question: "First name", answer: "", id: "pi-first-name" },
85
+ { fieldType: "input", question: "Last name", answer: "", id: "pi-last-name" },
86
+ {
87
+ fieldType: "selection",
88
+ question: "Biological sex",
89
+ options: [
90
+ { id: "pi-sex-m", value: "Male" },
91
+ { id: "pi-sex-f", value: "Female" }
92
+ ],
93
+ selected: null,
94
+ id: "pi-sex"
95
+ }
96
+ ]
97
+ },
98
+ {
99
+ fieldType: "section",
100
+ title: "Pregnancy & OB",
101
+ id: "sec-pregnancy",
102
+ enableWhen: {
103
+ logic: "AND",
104
+ conditions: [
105
+ { targetId: "pi-sex", operator: "equals", value: "pi-sex-f" }
106
+ ]
107
+ },
108
+ fields: [
109
+ {
110
+ fieldType: "radio",
111
+ question: "Are you currently pregnant?",
112
+ options: [
113
+ { id: "preg-yes", value: "Yes" },
114
+ { id: "preg-no", value: "No" }
115
+ ],
116
+ selected: null,
117
+ id: "preg-status"
118
+ }
119
+ ]
120
+ }
121
+ ]
122
+ ```
123
+
124
+ **Any JSON object matching this structure works** - whether from your backend API, a database query, local storage, or a CMS.
125
+
126
+ ## 📖 Props
127
+
128
+ ### `QuestionnaireRenderer`
129
+
130
+ | Prop | Type | Default | Description |
131
+ |------|------|---------|-------------|
132
+ | `fields` | `Array` | **Required** | Questionnaire definition from your data source (API, database, etc.) |
133
+ | `onChange` | `Function` | `undefined` | Callback when answers change: `(fields) => void` |
134
+ | `onSubmit` | `Function` | `undefined` | Callback on submit: `(fhirResponse) => void` |
135
+ | `questionnaireId` | `String` | `'questionnaire-1'` | ID for FHIR Questionnaire reference |
136
+ | `subjectId` | `String` | `undefined` | Patient/subject ID for FHIR response |
137
+ | `className` | `String` | `''` | Additional CSS classes |
138
+ | `fullHeight` | `Boolean` | `false` | Use full viewport height |
139
+
140
+ ## ✨ Features
141
+
142
+ ### ✅ Read-Only Display
143
+
144
+ - Displays questionnaire fields without editing controls
145
+ - Users can **fill out** the form but **cannot add/remove/reorder** fields
146
+
147
+ ### 📋 Supported Field Types
148
+
149
+ - **Text Input** - Single-line text entry
150
+ - **Radio Buttons** - Single choice selection
151
+ - **Checkboxes** - Multiple choice selection
152
+ - **Dropdown** - Select menu
153
+ - **Section** - Grouped fields with collapse/expand
154
+
155
+ ### 🔀 Conditional Logic (enableWhen)
156
+
157
+ Automatically shows/hides fields based on answers:
158
+
159
+ ```jsx
160
+ const fields = [
161
+ {
162
+ id: '1',
163
+ fieldType: 'radio',
164
+ question: 'Do you have symptoms?',
165
+ options: ['Yes', 'No'],
166
+ selected: null
167
+ },
168
+ {
169
+ id: '2',
170
+ fieldType: 'input',
171
+ question: 'Describe your symptoms',
172
+ answer: '',
173
+ enableWhen: [
174
+ {
175
+ question: '1', // ID of field to check
176
+ operator: 'equals',
177
+ answer: 'Yes'
178
+ }
179
+ ]
180
+ }
181
+ ];
182
+ ```
183
+
184
+ Field `2` only appears when field `1` is answered with "Yes".
185
+
186
+ ### 🏥 FHIR QuestionnaireResponse
187
+
188
+ On submit, generates a standard FHIR R4 QuestionnaireResponse:
189
+
190
+ ```js
191
+ {
192
+ resourceType: "QuestionnaireResponse",
193
+ id: "response-uuid",
194
+ questionnaire: "questionnaire-1",
195
+ status: "completed",
196
+ authored: "2025-10-02T10:30:00Z",
197
+ subject: {
198
+ reference: "Patient/patient-12345"
199
+ },
200
+ item: [
201
+ {
202
+ linkId: "1",
203
+ text: "What is your name?",
204
+ answer: [
205
+ {
206
+ valueString: "John Doe"
207
+ }
208
+ ]
209
+ }
210
+ // ... more items
211
+ ]
212
+ }
213
+ ```
214
+
215
+ ## 🎯 Usage Examples
216
+
217
+ ### Saving Responses
218
+
219
+ ```jsx
220
+ import { QuestionnaireRenderer } from '@mieweb/forms-renderer';
221
+ import { useState } from 'react';
222
+
223
+ function FormPage() {
224
+ const [responses, setResponses] = useState([]);
225
+
226
+ const handleSubmit = async (fhirResponse) => {
227
+ // Save to backend
228
+ await fetch('/api/responses', {
229
+ method: 'POST',
230
+ headers: { 'Content-Type': 'application/json' },
231
+ body: JSON.stringify(fhirResponse)
232
+ });
233
+
234
+ setResponses([...responses, fhirResponse]);
235
+ alert('Form submitted successfully!');
236
+ };
237
+
238
+ return (
239
+ <QuestionnaireRenderer
240
+ fields={fields}
241
+ onSubmit={handleSubmit}
242
+ questionnaireId="patient-intake"
243
+ subjectId="patient-67890"
244
+ />
245
+ );
246
+ }
247
+ ```
248
+
249
+ ### Pre-filled Form
250
+
251
+ ```jsx
252
+ const prefilledFields = [
253
+ {
254
+ id: '1',
255
+ fieldType: 'input',
256
+ question: 'Full Name',
257
+ answer: 'Jane Doe' // Pre-filled
258
+ },
259
+ {
260
+ id: '2',
261
+ fieldType: 'radio',
262
+ question: 'Gender',
263
+ options: ['Male', 'Female', 'Other'],
264
+ selected: 'Female' // Pre-filled
265
+ }
266
+ ];
267
+
268
+ <QuestionnaireRenderer fields={prefilledFields} />
269
+ ```
270
+
271
+ ### Real-time Validation
272
+
273
+ ```jsx
274
+ function ValidatedForm() {
275
+ const [errors, setErrors] = useState({});
276
+
277
+ const handleChange = (fields) => {
278
+ const newErrors = {};
279
+
280
+ fields.forEach(field => {
281
+ if (field.required && !field.answer && !field.selected) {
282
+ newErrors[field.id] = 'This field is required';
283
+ }
284
+ });
285
+
286
+ setErrors(newErrors);
287
+ };
288
+
289
+ return (
290
+ <div>
291
+ <QuestionnaireRenderer
292
+ fields={fields}
293
+ onChange={handleChange}
294
+ />
295
+ {Object.values(errors).map(err => (
296
+ <p className="text-red-500">{err}</p>
297
+ ))}
298
+ </div>
299
+ );
300
+ }
301
+ ```
302
+
303
+ ## 🔧 Field Structure
304
+
305
+ Fields use the same structure as `@mieweb/forms-editor`:
306
+
307
+ ```js
308
+ {
309
+ id: 'unique-id',
310
+ fieldType: 'input' | 'radio' | 'check' | 'dropdown' | 'section',
311
+ question: 'Your question text',
312
+ answer: '', // For input
313
+ options: [], // For radio/check/dropdown
314
+ selected: null, // For radio
315
+ selectedOptions: [], // For check
316
+ fields: [], // For section
317
+ enableWhen: [] // Conditional logic
318
+ }
319
+ ```
320
+
321
+ ## 📦 Bundle Size
322
+
323
+ - **4.85 KB** (ESM, uncompressed)
324
+ - Very lightweight - perfect for embedding in patient portals
325
+
326
+ ## 🎨 Styling
327
+
328
+ Import the base styles from `forms-engine`:
329
+
330
+ ```jsx
331
+ import '@mieweb/forms-engine/styles.css';
332
+ ```
333
+
334
+ Override with custom CSS:
335
+
336
+ ```css
337
+ .qr-renderer-root {
338
+ max-width: 800px;
339
+ margin: 0 auto;
340
+ }
341
+
342
+ .qr-submit-btn {
343
+ background: #10b981;
344
+ color: white;
345
+ padding: 0.75rem 2rem;
346
+ }
347
+ ```
348
+
349
+ ## 🔗 Related Packages
350
+
351
+ - **@mieweb/forms-engine** - Core form primitives (auto-installed)
352
+ - **@mieweb/forms-editor** - Full questionnaire editor UI
353
+
354
+ ## 📄 License
355
+
356
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,130 @@
1
+ // src/QuestionnaireRenderer.jsx
2
+ import React from "react";
3
+ import { useFormStore, useUIApi, useFieldsArray, getFieldComponent, isVisible } from "@mieweb/forms-engine";
4
+ import "@mieweb/forms-engine/styles.css";
5
+ function QuestionnaireRenderer({
6
+ fields,
7
+ onChange,
8
+ onSubmit,
9
+ questionnaireId = "questionnaire-1",
10
+ subjectId,
11
+ className = "",
12
+ fullHeight = false
13
+ }) {
14
+ const initializedRef = React.useRef(false);
15
+ const ui = useUIApi();
16
+ React.useEffect(() => {
17
+ if (initializedRef.current || !Array.isArray(fields)) return;
18
+ const sectionChildIds = /* @__PURE__ */ new Set();
19
+ fields.forEach((f) => {
20
+ if ((f == null ? void 0 : f.fieldType) === "section" && Array.isArray(f.fields)) {
21
+ f.fields.forEach((ch) => (ch == null ? void 0 : ch.id) && sectionChildIds.add(ch.id));
22
+ }
23
+ });
24
+ const topOnly = fields.filter((f) => !sectionChildIds.has(f.id));
25
+ useFormStore.getState().replaceAll(topOnly);
26
+ ui.preview.set(true);
27
+ initializedRef.current = true;
28
+ }, [fields, ui.preview]);
29
+ React.useEffect(() => {
30
+ if (!onChange) return;
31
+ const unsub = useFormStore.subscribe((s) => {
32
+ const arr = s.flatArray ? s.flatArray() : Object.values(s.byId);
33
+ onChange(arr);
34
+ });
35
+ return unsub;
36
+ }, [onChange]);
37
+ const all = useFieldsArray();
38
+ const buildQuestionnaireResponse = React.useCallback(() => {
39
+ const items = [];
40
+ (all || []).forEach((f) => {
41
+ if (f.fieldType === "section" && Array.isArray(f.fields)) {
42
+ f.fields.forEach((ch) => {
43
+ if (!ch) return;
44
+ items.push({
45
+ linkId: ch.id,
46
+ text: ch.question || ch.title || "",
47
+ answer: toFhirAnswers(ch)
48
+ });
49
+ });
50
+ return;
51
+ }
52
+ if (f.fieldType !== "section") {
53
+ items.push({
54
+ linkId: f.id,
55
+ text: f.question || f.title || "",
56
+ answer: toFhirAnswers(f)
57
+ });
58
+ }
59
+ });
60
+ return {
61
+ resourceType: "QuestionnaireResponse",
62
+ questionnaire: questionnaireId,
63
+ status: "in-progress",
64
+ subject: subjectId ? { reference: `Patient/${subjectId}` } : void 0,
65
+ item: items
66
+ };
67
+ }, [all, questionnaireId, subjectId]);
68
+ const handleSubmit = (e) => {
69
+ e.preventDefault();
70
+ const qr = buildQuestionnaireResponse();
71
+ onSubmit == null ? void 0 : onSubmit(qr);
72
+ };
73
+ const rootClasses = [
74
+ "qb-render-root",
75
+ "bg-gray-100",
76
+ "font-titillium",
77
+ fullHeight ? "min-h-screen" : "",
78
+ className
79
+ ].filter(Boolean).join(" ");
80
+ return /* @__PURE__ */ React.createElement("div", { className: rootClasses }, /* @__PURE__ */ React.createElement("form", { onSubmit: handleSubmit, className: "max-w-4xl mx-auto px-2 pb-8 pt-4" }, /* @__PURE__ */ React.createElement(RendererBody, null), /* @__PURE__ */ React.createElement("div", { className: "pt-2" }, /* @__PURE__ */ React.createElement("button", { type: "submit", className: "px-4 py-2 rounded bg-[#0076a8] text-white shadow hover:bg-[#00628a] transition-colors" }, "Submit"))));
81
+ }
82
+ function toFhirAnswers(field) {
83
+ switch (field.fieldType) {
84
+ case "input":
85
+ return field.answer ? [{ valueString: String(field.answer) }] : [];
86
+ case "radio":
87
+ return field.selected ? [{ valueString: optionValue(field, field.selected) }] : [];
88
+ case "check":
89
+ return (field.selected || []).map((id) => ({ valueString: optionValue(field, id) }));
90
+ case "selection":
91
+ return field.selected ? [{ valueString: optionValue(field, field.selected) }] : [];
92
+ default:
93
+ return [];
94
+ }
95
+ }
96
+ function optionValue(field, optionId) {
97
+ const opt = (field.options || []).find((o) => o.id === optionId || o.value === optionId);
98
+ return opt ? opt.value : "";
99
+ }
100
+ function RendererBody() {
101
+ const ui = useUIApi();
102
+ const fields = useFieldsArray() || [];
103
+ const flat = React.useMemo(() => {
104
+ const out = [];
105
+ fields.forEach((f) => {
106
+ out.push(f);
107
+ if ((f == null ? void 0 : f.fieldType) === "section" && Array.isArray(f.fields)) out.push(...f.fields);
108
+ });
109
+ return out;
110
+ }, [fields]);
111
+ const visible = React.useMemo(() => {
112
+ if (!ui.state.isPreview) return fields;
113
+ return fields.filter((f) => isVisible(f, flat));
114
+ }, [fields, flat, ui.state.isPreview]);
115
+ return /* @__PURE__ */ React.createElement("div", null, visible.map((f) => /* @__PURE__ */ React.createElement(FieldNode, { key: f.id, field: f, allFlat: flat })));
116
+ }
117
+ function FieldNode({ field, allFlat }) {
118
+ const ui = useUIApi();
119
+ const Comp = getFieldComponent(field.fieldType);
120
+ if (!Comp) return null;
121
+ if (field.fieldType === "section" && ui.state.isPreview && Array.isArray(field.fields)) {
122
+ const visibleChildren = field.fields.filter((ch) => isVisible(ch, allFlat));
123
+ const filteredSection = { ...field, fields: visibleChildren };
124
+ return /* @__PURE__ */ React.createElement(Comp, { field: filteredSection });
125
+ }
126
+ return /* @__PURE__ */ React.createElement(Comp, { field });
127
+ }
128
+ export {
129
+ QuestionnaireRenderer
130
+ };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@mieweb/forms-renderer",
3
+ "version": "0.1.0",
4
+ "description": "Read-only questionnaire form renderer producing FHIR QuestionnaireResponse",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": ["dist", "README.md"],
15
+ "scripts": {
16
+ "build": "tsup index.js --format esm --external react --external react-dom --external @mieweb/forms-engine"
17
+ },
18
+ "sideEffects": false,
19
+ "peerDependencies": {
20
+ "react": ">=18.0.0",
21
+ "react-dom": ">=18.0.0"
22
+ },
23
+ "dependencies": {
24
+ "@mieweb/forms-engine": "0.1.0"
25
+ },
26
+ "publishConfig": { "access": "public" }
27
+ }