@openmrs/esm-form-builder-app 1.0.1-pre.126

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 (60) hide show
  1. package/.eslintignore +2 -0
  2. package/.eslintrc +33 -0
  3. package/.husky/pre-commit +4 -0
  4. package/.husky/pre-push +6 -0
  5. package/.prettierignore +14 -0
  6. package/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs +541 -0
  7. package/.yarn/plugins/@yarnpkg/plugin-version.cjs +550 -0
  8. package/.yarn/versions/7d315ef1.yml +0 -0
  9. package/LICENSE +401 -0
  10. package/README.md +14 -0
  11. package/__mocks__/react-i18next.js +56 -0
  12. package/dist/openmrs-esm-form-builder-app.js +1 -0
  13. package/i18next-parser.config.js +89 -0
  14. package/jest.config.json +19 -0
  15. package/package.json +102 -0
  16. package/src/components/dashboard/dashboard.component.tsx +310 -0
  17. package/src/components/dashboard/dashboard.scss +112 -0
  18. package/src/components/empty-state/empty-data-illustration.component.tsx +51 -0
  19. package/src/components/empty-state/empty-state.component.tsx +41 -0
  20. package/src/components/empty-state/empty-state.scss +55 -0
  21. package/src/components/error-state/error-state.component.tsx +37 -0
  22. package/src/components/error-state/error-state.scss +49 -0
  23. package/src/components/form-editor/form-editor.component.tsx +297 -0
  24. package/src/components/form-editor/form-editor.scss +50 -0
  25. package/src/components/form-renderer/form-renderer.component.tsx +82 -0
  26. package/src/components/form-renderer/form-renderer.scss +31 -0
  27. package/src/components/interactive-builder/add-question-modal.component.tsx +494 -0
  28. package/src/components/interactive-builder/edit-question-modal.component.tsx +447 -0
  29. package/src/components/interactive-builder/editable-value.component.tsx +60 -0
  30. package/src/components/interactive-builder/editable-value.scss +23 -0
  31. package/src/components/interactive-builder/interactive-builder.component.tsx +403 -0
  32. package/src/components/interactive-builder/interactive-builder.scss +83 -0
  33. package/src/components/interactive-builder/new-form-modal.component.tsx +86 -0
  34. package/src/components/interactive-builder/page-modal.component.tsx +91 -0
  35. package/src/components/interactive-builder/question-modal.scss +35 -0
  36. package/src/components/interactive-builder/section-modal.component.tsx +94 -0
  37. package/src/components/interactive-builder/value-editor.component.tsx +55 -0
  38. package/src/components/interactive-builder/value-editor.scss +10 -0
  39. package/src/components/modals/save-form.component.tsx +310 -0
  40. package/src/components/modals/save-form.scss +5 -0
  41. package/src/components/schema-editor/schema-editor.component.tsx +190 -0
  42. package/src/components/schema-editor/schema-editor.scss +30 -0
  43. package/src/config-schema.ts +47 -0
  44. package/src/constants.ts +3 -0
  45. package/src/declarations.d.tsx +2 -0
  46. package/src/form-builder-app-menu-link.component.tsx +13 -0
  47. package/src/forms.resource.ts +178 -0
  48. package/src/hooks/useClobdata.ts +20 -0
  49. package/src/hooks/useConceptLookup.ts +18 -0
  50. package/src/hooks/useEncounterTypes.ts +18 -0
  51. package/src/hooks/useForm.ts +18 -0
  52. package/src/hooks/useForms.ts +20 -0
  53. package/src/index.ts +70 -0
  54. package/src/root.component.tsx +19 -0
  55. package/src/setup-tests.ts +1 -0
  56. package/src/types.ts +132 -0
  57. package/translations/en.json +110 -0
  58. package/tsconfig.json +23 -0
  59. package/turbo.json +26 -0
  60. package/webpack.config.js +19 -0
@@ -0,0 +1,494 @@
1
+ import React, { useState } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import {
4
+ Button,
5
+ ComposedModal,
6
+ Form,
7
+ FormGroup,
8
+ FormLabel,
9
+ Layer,
10
+ InlineLoading,
11
+ ModalBody,
12
+ ModalFooter,
13
+ ModalHeader,
14
+ MultiSelect,
15
+ RadioButton,
16
+ RadioButtonGroup,
17
+ Search,
18
+ Select,
19
+ SelectItem,
20
+ Stack,
21
+ Tag,
22
+ TextInput,
23
+ Tile,
24
+ } from "@carbon/react";
25
+ import flattenDeep from "lodash-es/flattenDeep";
26
+ import { showNotification, showToast, useConfig } from "@openmrs/esm-framework";
27
+ import {
28
+ Answer,
29
+ Concept,
30
+ ConceptMapping,
31
+ FieldTypes,
32
+ Question,
33
+ Schema,
34
+ } from "../../types";
35
+ import { useConceptLookup } from "../../hooks/useConceptLookup";
36
+ import styles from "./question-modal.scss";
37
+
38
+ type AddQuestionModalProps = {
39
+ onModalChange: (showModal: boolean) => void;
40
+ onQuestionEdit: (question: Question) => void;
41
+ onSchemaChange: (schema: Schema) => void;
42
+ pageIndex: number;
43
+ questionIndex: number;
44
+ questionToEdit: Question;
45
+ resetIndices: () => void;
46
+ schema: Schema;
47
+ sectionIndex: number;
48
+ showModal: boolean;
49
+ };
50
+
51
+ const AddQuestionModal: React.FC<AddQuestionModalProps> = ({
52
+ schema,
53
+ onSchemaChange,
54
+ pageIndex,
55
+ sectionIndex,
56
+ questionIndex,
57
+ resetIndices,
58
+ showModal,
59
+ onModalChange,
60
+ questionToEdit,
61
+ onQuestionEdit,
62
+ }) => {
63
+ const { t } = useTranslation();
64
+ const { fieldTypes, questionTypes } = useConfig();
65
+ const [max, setMax] = useState("");
66
+ const [min, setMin] = useState("");
67
+ const [questionLabel, setQuestionLabel] = useState("");
68
+ const [questionType, setQuestionType] = useState("");
69
+ const [isQuestionRequired, setIsQuestionRequired] = useState(false);
70
+ const [fieldType, setFieldType] = useState<FieldTypes>(null);
71
+ const [questionId, setQuestionId] = useState("");
72
+ const [answers, setAnswers] = useState<Answer[]>([]);
73
+ const [selectedConcept, setSelectedConcept] = useState(null);
74
+ const [conceptMappings, setConceptMappings] = useState<ConceptMapping[]>([]);
75
+ const [rows, setRows] = useState(2);
76
+ const [conceptToLookup, setConceptToLookup] = useState("");
77
+ const [selectedAnswers, setSelectedAnswers] = useState([]);
78
+ const { concepts, isLoadingConcepts } = useConceptLookup(conceptToLookup);
79
+
80
+ const handleConceptChange = (event) => {
81
+ setConceptToLookup(event.target.value);
82
+ };
83
+
84
+ const handleConceptSelect = (concept: Concept) => {
85
+ setConceptToLookup("");
86
+ setSelectedConcept(concept);
87
+ setAnswers(
88
+ concept?.answers?.map((answer) => ({
89
+ concept: answer?.uuid,
90
+ label: answer?.display,
91
+ }))
92
+ );
93
+ setConceptMappings(
94
+ concept?.mappings?.map((conceptMapping) => {
95
+ let data = conceptMapping.display.split(": ");
96
+ return { type: data[0], value: data[1] };
97
+ })
98
+ );
99
+ };
100
+
101
+ const questionIdExists = (idToTest) => {
102
+ if (questionToEdit?.id === idToTest) {
103
+ return false;
104
+ }
105
+
106
+ const nestedIds = schema?.pages?.map((page) => {
107
+ return page?.sections?.map((section) => {
108
+ return section?.questions?.map((question) => {
109
+ return question.id;
110
+ });
111
+ });
112
+ });
113
+
114
+ const questionIds = flattenDeep(nestedIds);
115
+
116
+ return questionIds.includes(idToTest);
117
+ };
118
+
119
+ const handleCreateQuestion = () => {
120
+ createQuestion();
121
+ onModalChange(false);
122
+ };
123
+
124
+ const createQuestion = () => {
125
+ try {
126
+ const computedQuestionId = `question-${questionIndex + 1}-section-${
127
+ sectionIndex + 1
128
+ }-page-${pageIndex + 1}`;
129
+
130
+ schema.pages[pageIndex]?.sections?.[sectionIndex]?.questions?.push({
131
+ label: questionLabel,
132
+ type: questionType,
133
+ required: isQuestionRequired,
134
+ id: questionId ?? computedQuestionId,
135
+ questionOptions: {
136
+ rendering: fieldType,
137
+ concept: selectedConcept.uuid,
138
+ conceptMappings: conceptMappings,
139
+ answers: selectedAnswers.map((answer) => ({
140
+ concept: answer.id,
141
+ label: answer.text,
142
+ })),
143
+ },
144
+ validators: [],
145
+ });
146
+
147
+ onSchemaChange({ ...schema });
148
+
149
+ resetIndices();
150
+ setQuestionLabel("");
151
+ setQuestionId("");
152
+ setIsQuestionRequired(false);
153
+ setQuestionType(null);
154
+ setFieldType(null);
155
+ setSelectedConcept(null);
156
+ setConceptMappings([]);
157
+ setAnswers([]);
158
+ setSelectedAnswers([]);
159
+
160
+ showToast({
161
+ title: t("success", "Success!"),
162
+ kind: "success",
163
+ critical: true,
164
+ description: t("questionCreated", "New question created"),
165
+ });
166
+ } catch (error) {
167
+ showNotification({
168
+ title: t("errorCreatingQuestion", "Error creating question"),
169
+ kind: "error",
170
+ critical: true,
171
+ description: error?.message,
172
+ });
173
+ }
174
+ };
175
+
176
+ const updateQuestion = (questionIndex) => {
177
+ try {
178
+ const mappedAnswers = selectedAnswers?.map((answer) => ({
179
+ concept: answer.id,
180
+ label: answer.text,
181
+ }));
182
+
183
+ const data = {
184
+ label: questionLabel ? questionLabel : questionToEdit.label,
185
+ type: questionType ? questionType : questionToEdit.type,
186
+ required: isQuestionRequired
187
+ ? isQuestionRequired
188
+ : /true/.test(questionToEdit?.required),
189
+ id: questionId ? questionId : questionToEdit.id,
190
+ questionOptions: {
191
+ rendering: fieldType
192
+ ? fieldType
193
+ : questionToEdit.questionOptions.rendering,
194
+ concept: selectedConcept?.uuid
195
+ ? selectedConcept.uuid
196
+ : questionToEdit.questionOptions.concept,
197
+ conceptMappings: conceptMappings.length
198
+ ? conceptMappings
199
+ : questionToEdit.questionOptions.conceptMappings,
200
+ answers: mappedAnswers.length
201
+ ? mappedAnswers
202
+ : questionToEdit.questionOptions.answers,
203
+ },
204
+ };
205
+
206
+ schema.pages[pageIndex].sections[sectionIndex].questions[questionIndex] =
207
+ data;
208
+
209
+ onSchemaChange({ ...schema });
210
+
211
+ resetIndices();
212
+ setQuestionLabel("");
213
+ setQuestionId("");
214
+ setIsQuestionRequired(false);
215
+ setQuestionType(null);
216
+ setFieldType(null);
217
+ setSelectedConcept(null);
218
+ setConceptMappings([]);
219
+ setAnswers([]);
220
+ setSelectedAnswers([]);
221
+ onQuestionEdit(null);
222
+
223
+ showToast({
224
+ title: t("success", "Success!"),
225
+ kind: "success",
226
+ critical: true,
227
+ description: t("questionCreated", "Question updated"),
228
+ });
229
+ } catch (error) {
230
+ showNotification({
231
+ title: t("errorUpdatingQuestion", "Error updating question"),
232
+ kind: "error",
233
+ critical: true,
234
+ description: error?.message,
235
+ });
236
+ }
237
+ };
238
+
239
+ return (
240
+ <ComposedModal open={showModal} onClose={() => onModalChange(false)}>
241
+ <ModalHeader title={t("createNewQuestion", "Create a new question")} />
242
+ <Form onSubmit={(event) => event.preventDefault()}>
243
+ <ModalBody hasScrollingContent>
244
+ <FormGroup legendText={""}>
245
+ <Stack gap={5}>
246
+ <TextInput
247
+ id="questionLabel"
248
+ labelText={t("questionLabel", "Label")}
249
+ placeholder={t("labelPlaceholder", "e.g. Type of Anaesthesia")}
250
+ value={questionLabel}
251
+ onChange={(event) => setQuestionLabel(event.target.value)}
252
+ required
253
+ />
254
+ <RadioButtonGroup
255
+ defaultSelected="optional"
256
+ name="isQuestionRequired"
257
+ legendText={t(
258
+ "isQuestionRequiredOrOptional",
259
+ "Is this question a required or optional field? Required fields must be answered before the form can be submitted."
260
+ )}
261
+ >
262
+ <RadioButton
263
+ id="questionIsNotRequired"
264
+ defaultChecked={true}
265
+ labelText={t("optional", "Optional")}
266
+ onClick={() => setIsQuestionRequired(false)}
267
+ value="optional"
268
+ />
269
+ <RadioButton
270
+ id="questionIsRequired"
271
+ defaultChecked={false}
272
+ labelText={t("required", "Required")}
273
+ onClick={() => setIsQuestionRequired(true)}
274
+ value="required"
275
+ />
276
+ </RadioButtonGroup>
277
+ <Select
278
+ value={questionType}
279
+ onChange={(event) => setQuestionType(event.target.value)}
280
+ id="questionType"
281
+ invalidText={t("typeRequired", "Type is required")}
282
+ labelText={t("questionType", "Question type")}
283
+ required
284
+ >
285
+ {!questionType && (
286
+ <SelectItem
287
+ text={t("chooseQuestionType", "Choose a question type")}
288
+ value=""
289
+ />
290
+ )}
291
+ {questionTypes.map((questionType, key) => (
292
+ <SelectItem
293
+ text={questionType}
294
+ value={questionType}
295
+ key={key}
296
+ />
297
+ ))}
298
+ </Select>
299
+ <Select
300
+ value={fieldType}
301
+ onChange={(event) => setFieldType(event.target.value)}
302
+ id="renderingType"
303
+ invalidText={t(
304
+ "validFieldTypeRequired",
305
+ "A valid field type value is required"
306
+ )}
307
+ labelText={t("fieldType", "Field type")}
308
+ required
309
+ >
310
+ {!fieldType && (
311
+ <SelectItem
312
+ text={t("chooseFieldType", "Choose a field type")}
313
+ value=""
314
+ />
315
+ )}
316
+ {fieldTypes.map((fieldType, key) => (
317
+ <SelectItem text={fieldType} value={fieldType} key={key} />
318
+ ))}
319
+ </Select>
320
+ {fieldType === FieldTypes.Number ? (
321
+ <>
322
+ <TextInput
323
+ id="min"
324
+ labelText="Min"
325
+ value={min || ""}
326
+ onChange={(event) => setMin(event.target.value)}
327
+ required
328
+ />
329
+ <TextInput
330
+ id="max"
331
+ labelText="Max"
332
+ value={max || ""}
333
+ onChange={(event) => setMax(event.target.value)}
334
+ required
335
+ />
336
+ </>
337
+ ) : fieldType === FieldTypes.TextArea ? (
338
+ <TextInput
339
+ id="textAreaRows"
340
+ labelText={t("rows", "Rows")}
341
+ value={rows || ""}
342
+ onChange={(event) => setRows(event.target.value)}
343
+ required
344
+ />
345
+ ) : null}
346
+ <TextInput
347
+ id="questionId"
348
+ invalid={questionIdExists(questionId)}
349
+ invalidText={t(
350
+ "questionIdExists",
351
+ "This question ID already exists in your schema"
352
+ )}
353
+ labelText={t("questionId", "Question ID")}
354
+ value={questionId}
355
+ onChange={(event) => {
356
+ setQuestionId(event.target.value);
357
+ }}
358
+ placeholder={t(
359
+ "questionIdPlaceholder",
360
+ 'Enter a unique ID e.g. "anaesthesiaType" for a question asking about the type of anaesthesia.'
361
+ )}
362
+ required
363
+ />
364
+ {fieldType !== FieldTypes.UiSelectExtended && (
365
+ <div>
366
+ <FormLabel className={styles.label}>
367
+ {t("selectBackingConcept", "Select backing concept")}
368
+ </FormLabel>
369
+ <Layer>
370
+ <Search
371
+ size="md"
372
+ id="conceptLookup"
373
+ labelText={t("enterConceptName", "Enter concept name")}
374
+ placeholder={t("searchConcept", "Search concept")}
375
+ onClear={() => {
376
+ setSelectedConcept(null);
377
+ }}
378
+ onChange={handleConceptChange}
379
+ onInputChange={(event) => {
380
+ setConceptToLookup(event);
381
+ }}
382
+ value={(() => {
383
+ if (conceptToLookup) {
384
+ return conceptToLookup;
385
+ }
386
+ if (selectedConcept) {
387
+ return selectedConcept.display;
388
+ }
389
+ return "";
390
+ })()}
391
+ required
392
+ />
393
+ </Layer>
394
+ {(() => {
395
+ if (!conceptToLookup) return null;
396
+ if (isLoadingConcepts)
397
+ return (
398
+ <InlineLoading
399
+ className={styles.loader}
400
+ description={t("searching", "Searching") + "..."}
401
+ />
402
+ );
403
+ if (concepts && concepts?.length && !isLoadingConcepts) {
404
+ return (
405
+ <ul className={styles.conceptList}>
406
+ {concepts?.map((concept, index) => (
407
+ <li
408
+ role="menuitem"
409
+ className={styles.concept}
410
+ key={index}
411
+ onClick={() => handleConceptSelect(concept)}
412
+ >
413
+ {concept.display}
414
+ </li>
415
+ ))}
416
+ </ul>
417
+ );
418
+ }
419
+ return (
420
+ <Layer>
421
+ <Tile className={styles.emptyResults}>
422
+ <span>
423
+ {t(
424
+ "noMatchingConcepts",
425
+ "No concepts were found that match"
426
+ )}{" "}
427
+ <strong>"{conceptToLookup}".</strong>
428
+ </span>
429
+ </Tile>
430
+ </Layer>
431
+ );
432
+ })()}
433
+ </div>
434
+ )}
435
+
436
+ {/* Handle Concept Mappings */}
437
+ {/* {conceptMappings && conceptMappings.length ? (
438
+ <div>{JSON.stringify(conceptMappings)}</div>
439
+ ) : null} */}
440
+
441
+ {selectedAnswers.length ? (
442
+ <div>
443
+ {selectedAnswers.map((answer) => (
444
+ <Tag className={styles.tag} key={answer.id} type={"blue"}>
445
+ {answer.text}
446
+ </Tag>
447
+ ))}
448
+ </div>
449
+ ) : null}
450
+
451
+ {answers && answers.length ? (
452
+ <MultiSelect
453
+ id="selectAnswers"
454
+ itemToString={(item) => item.text}
455
+ items={answers.map((answer) => ({
456
+ id: answer.concept,
457
+ text: answer.label,
458
+ }))}
459
+ onChange={({ selectedItems }) =>
460
+ setSelectedAnswers(selectedItems.sort())
461
+ }
462
+ size="md"
463
+ titleText={t(
464
+ "selectAnswersToDisplay",
465
+ "Select answers to display"
466
+ )}
467
+ />
468
+ ) : null}
469
+ </Stack>
470
+ </FormGroup>
471
+ </ModalBody>
472
+ </Form>
473
+ <ModalFooter>
474
+ <Button onClick={() => onModalChange(false)} kind="secondary">
475
+ {t("cancel", "Cancel")}
476
+ </Button>
477
+ <Button
478
+ disabled={
479
+ !questionLabel ||
480
+ !questionId ||
481
+ questionIdExists(questionId) ||
482
+ !fieldType ||
483
+ (fieldType !== FieldTypes.UiSelectExtended && !selectedConcept)
484
+ }
485
+ onClick={handleCreateQuestion}
486
+ >
487
+ <span>{t("save", "Save")}</span>
488
+ </Button>
489
+ </ModalFooter>
490
+ </ComposedModal>
491
+ );
492
+ };
493
+
494
+ export default AddQuestionModal;