@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,447 @@
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";
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 EditQuestionModalProps = {
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 EditQuestionModal: React.FC<EditQuestionModalProps> = ({
52
+ questionToEdit,
53
+ schema,
54
+ onSchemaChange,
55
+ pageIndex,
56
+ sectionIndex,
57
+ questionIndex,
58
+ resetIndices,
59
+ showModal,
60
+ onModalChange,
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 handleUpdateQuestion = () => {
120
+ updateQuestion(questionIndex);
121
+ onModalChange(false);
122
+ };
123
+
124
+ const updateQuestion = (questionIndex) => {
125
+ try {
126
+ const mappedAnswers = selectedAnswers?.map((answer) => ({
127
+ concept: answer.id,
128
+ label: answer.text,
129
+ }));
130
+
131
+ const data = {
132
+ label: questionLabel ? questionLabel : questionToEdit.label,
133
+ type: questionType ? questionType : questionToEdit.type,
134
+ required: isQuestionRequired
135
+ ? isQuestionRequired
136
+ : /true/.test(questionToEdit?.required),
137
+ id: questionId ? questionId : questionToEdit.id,
138
+ questionOptions: {
139
+ rendering: fieldType
140
+ ? fieldType
141
+ : questionToEdit.questionOptions.rendering,
142
+ concept: selectedConcept?.uuid
143
+ ? selectedConcept.uuid
144
+ : questionToEdit.questionOptions.concept,
145
+ conceptMappings: conceptMappings.length
146
+ ? conceptMappings
147
+ : questionToEdit.questionOptions.conceptMappings,
148
+ answers: mappedAnswers.length
149
+ ? mappedAnswers
150
+ : questionToEdit.questionOptions.answers,
151
+ },
152
+ };
153
+
154
+ schema.pages[pageIndex].sections[sectionIndex].questions[questionIndex] =
155
+ data;
156
+
157
+ onSchemaChange({ ...schema });
158
+
159
+ resetIndices();
160
+ setQuestionLabel("");
161
+ setQuestionId("");
162
+ setIsQuestionRequired(false);
163
+ setQuestionType(null);
164
+ setFieldType(null);
165
+ setSelectedConcept(null);
166
+ setConceptMappings([]);
167
+ setAnswers([]);
168
+ setSelectedAnswers([]);
169
+ onQuestionEdit(null);
170
+
171
+ showToast({
172
+ title: t("success", "Success!"),
173
+ kind: "success",
174
+ critical: true,
175
+ description: t("questionCreated", "Question updated"),
176
+ });
177
+ } catch (error) {
178
+ showNotification({
179
+ title: t("errorUpdatingQuestion", "Error updating question"),
180
+ kind: "error",
181
+ critical: true,
182
+ description: error?.message,
183
+ });
184
+ }
185
+ };
186
+
187
+ return (
188
+ <ComposedModal open={showModal} onClose={() => onModalChange(false)}>
189
+ <ModalHeader title={t("editQuestion", "Edit question")} />
190
+ <Form onSubmit={(event) => event.preventDefault()}>
191
+ <ModalBody hasScrollingContent>
192
+ <FormGroup legendText={""}>
193
+ <Stack gap={5}>
194
+ <TextInput
195
+ defaultValue={questionToEdit.label}
196
+ id={questionToEdit.id}
197
+ labelText={t("questionLabel", "Label")}
198
+ onChange={(event) => setQuestionLabel(event.target.value)}
199
+ required
200
+ />
201
+ <RadioButtonGroup
202
+ defaultSelected={
203
+ /true/.test(questionToEdit?.required)
204
+ ? "required"
205
+ : "optional"
206
+ }
207
+ name="isQuestionRequired"
208
+ legendText={t(
209
+ "isQuestionRequiredOrOptional",
210
+ "Is this question a required or optional field? Required fields must be answered before the form can be submitted."
211
+ )}
212
+ >
213
+ <RadioButton
214
+ id="questionIsNotRequired"
215
+ defaultChecked={true}
216
+ labelText={t("optional", "Optional")}
217
+ onClick={() => setIsQuestionRequired(false)}
218
+ value="optional"
219
+ />
220
+ <RadioButton
221
+ id="questionIsRequired"
222
+ defaultChecked={false}
223
+ labelText={t("required", "Required")}
224
+ onClick={() => setIsQuestionRequired(true)}
225
+ value="required"
226
+ />
227
+ </RadioButtonGroup>
228
+ <Select
229
+ defaultValue={questionToEdit.type}
230
+ onChange={(event) => setQuestionType(event.target.value)}
231
+ id={"questionType"}
232
+ invalidText={t("typeRequired", "Type is required")}
233
+ labelText={t("questionType", "Question type")}
234
+ required
235
+ >
236
+ {!questionType && (
237
+ <SelectItem
238
+ text={t("chooseQuestionType", "Choose a question type")}
239
+ value=""
240
+ />
241
+ )}
242
+ {questionTypes.map((questionType, key) => (
243
+ <SelectItem
244
+ text={questionType}
245
+ value={questionType}
246
+ key={key}
247
+ />
248
+ ))}
249
+ </Select>
250
+ <Select
251
+ defaultValue={questionToEdit.questionOptions.rendering}
252
+ onChange={(event) => setFieldType(event.target.value)}
253
+ id="renderingType"
254
+ invalidText={t(
255
+ "validFieldTypeRequired",
256
+ "A valid field type value is required"
257
+ )}
258
+ labelText={t("fieldType", "Field type")}
259
+ required
260
+ >
261
+ {!fieldType && (
262
+ <SelectItem
263
+ text={t("chooseFieldType", "Choose a field type")}
264
+ value=""
265
+ />
266
+ )}
267
+ {fieldTypes.map((fieldType, key) => (
268
+ <SelectItem text={fieldType} value={fieldType} key={key} />
269
+ ))}
270
+ </Select>
271
+ {fieldType === FieldTypes.Number ? (
272
+ <>
273
+ <TextInput
274
+ id="min"
275
+ labelText="Min"
276
+ value={min || ""}
277
+ onChange={(event) => setMin(event.target.value)}
278
+ required
279
+ />
280
+ <TextInput
281
+ id="max"
282
+ labelText="Max"
283
+ value={max || ""}
284
+ onChange={(event) => setMax(event.target.value)}
285
+ required
286
+ />
287
+ </>
288
+ ) : fieldType === FieldTypes.TextArea ? (
289
+ <TextInput
290
+ id="textAreaRows"
291
+ labelText={t("rows", "Rows")}
292
+ value={rows || ""}
293
+ onChange={(event) => setRows(event.target.value)}
294
+ required
295
+ />
296
+ ) : null}
297
+ <TextInput
298
+ defaultValue={questionToEdit.id}
299
+ id="questionId"
300
+ invalid={questionIdExists(questionId)}
301
+ invalidText={t(
302
+ "questionIdExists",
303
+ "This question ID already exists in your schema"
304
+ )}
305
+ labelText={t("questionId", "Question ID")}
306
+ onChange={(event) => setQuestionId(event.target.value)}
307
+ placeholder={t(
308
+ "questionIdPlaceholder",
309
+ 'Enter a unique ID e.g. "anaesthesiaType" for a question asking about the type of anaesthesia.'
310
+ )}
311
+ required
312
+ />
313
+ {fieldType !== FieldTypes.UiSelectExtended && (
314
+ <div>
315
+ <FormLabel className={styles.label}>
316
+ {t("selectBackingConcept", "Select backing concept")}
317
+ </FormLabel>
318
+ <Layer>
319
+ <Search
320
+ defaultValue={questionToEdit?.questionOptions?.concept}
321
+ id="conceptLookup"
322
+ labelText={t("enterConceptName", "Enter concept name")}
323
+ placeholder={t("searchConcept", "Search concept")}
324
+ onClear={() => {
325
+ setSelectedConcept(null);
326
+ }}
327
+ onChange={handleConceptChange}
328
+ onInputChange={(event) => {
329
+ setConceptToLookup(event);
330
+ }}
331
+ required
332
+ size="md"
333
+ />
334
+ </Layer>
335
+ {(() => {
336
+ if (!conceptToLookup) return null;
337
+ if (isLoadingConcepts)
338
+ return (
339
+ <InlineLoading
340
+ className={styles.loader}
341
+ description={t("searching", "Searching") + "..."}
342
+ />
343
+ );
344
+ if (concepts && concepts?.length && !isLoadingConcepts) {
345
+ return (
346
+ <ul className={styles.conceptList}>
347
+ {concepts?.map((concept, index) => (
348
+ <li
349
+ role="menuitem"
350
+ className={styles.concept}
351
+ key={index}
352
+ onClick={() => handleConceptSelect(concept)}
353
+ >
354
+ {concept.display}
355
+ </li>
356
+ ))}
357
+ </ul>
358
+ );
359
+ }
360
+ return (
361
+ <Layer>
362
+ <Tile className={styles.emptyResults}>
363
+ <span>
364
+ {t(
365
+ "noMatchingConcepts",
366
+ "No concepts were found that match"
367
+ )}{" "}
368
+ <strong>"{conceptToLookup}".</strong>
369
+ </span>
370
+ </Tile>
371
+ </Layer>
372
+ );
373
+ })()}
374
+ </div>
375
+ )}
376
+
377
+ {selectedAnswers.length ? (
378
+ <div>
379
+ {selectedAnswers.map((selectedAnswer) => (
380
+ <Tag
381
+ className={styles.tag}
382
+ key={selectedAnswer.id}
383
+ type={"blue"}
384
+ >
385
+ {selectedAnswer.text}
386
+ </Tag>
387
+ ))}
388
+ </div>
389
+ ) : (
390
+ <div>
391
+ {questionToEdit?.questionOptions?.answers?.map((answer) => (
392
+ <Tag
393
+ className={styles.tag}
394
+ key={answer?.concept}
395
+ type={"blue"}
396
+ >
397
+ {answer?.label}
398
+ </Tag>
399
+ ))}
400
+ </div>
401
+ )}
402
+
403
+ {questionToEdit?.questionOptions?.answers &&
404
+ questionToEdit?.questionOptions.answers?.length ? (
405
+ <MultiSelect
406
+ direction="top"
407
+ id="selectAnswers"
408
+ itemToString={(item) => item.text}
409
+ initialSelectedItems={questionToEdit?.questionOptions?.answers?.map(
410
+ (answer) => ({
411
+ id: answer.concept,
412
+ text: answer.label,
413
+ })
414
+ )}
415
+ items={questionToEdit?.questionOptions?.answers?.map(
416
+ (answer) => ({
417
+ id: answer.concept,
418
+ text: answer.label,
419
+ })
420
+ )}
421
+ onChange={({ selectedItems }) =>
422
+ setSelectedAnswers(selectedItems.sort())
423
+ }
424
+ size="md"
425
+ titleText={t(
426
+ "selectAnswersToDisplay",
427
+ "Select answers to display"
428
+ )}
429
+ />
430
+ ) : null}
431
+ </Stack>
432
+ </FormGroup>
433
+ </ModalBody>
434
+ <ModalFooter>
435
+ <Button onClick={() => onModalChange(false)} kind="secondary">
436
+ {t("cancel", "Cancel")}
437
+ </Button>
438
+ <Button onClick={handleUpdateQuestion}>
439
+ <span>{t("save", "Save")}</span>
440
+ </Button>
441
+ </ModalFooter>
442
+ </Form>
443
+ </ComposedModal>
444
+ );
445
+ };
446
+
447
+ export default EditQuestionModal;
@@ -0,0 +1,60 @@
1
+ import React from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import { Button } from "@carbon/react";
4
+ import { Edit } from "@carbon/react/icons";
5
+ import ValueEditor from "./value-editor.component";
6
+ import styles from "./editable-value.scss";
7
+
8
+ type EditableValueProps = {
9
+ elementType?: "schema" | "page" | "section";
10
+ id: string;
11
+ value: string;
12
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
13
+ onSave: (value: string) => void;
14
+ };
15
+
16
+ const EditableValue: React.FC<EditableValueProps> = ({
17
+ elementType,
18
+ id,
19
+ value,
20
+ onChange,
21
+ onSave,
22
+ }) => {
23
+ const { t } = useTranslation();
24
+ const [editing, setEditing] = React.useState(false);
25
+
26
+ const closeEditor = () => {
27
+ setEditing(false);
28
+ };
29
+
30
+ return (
31
+ <>
32
+ {editing ? (
33
+ <ValueEditor
34
+ handleCancel={closeEditor}
35
+ handleSave={(val) => {
36
+ onSave(val);
37
+ closeEditor();
38
+ }}
39
+ onChange={onChange}
40
+ id={id}
41
+ value={value}
42
+ />
43
+ ) : (
44
+ <>
45
+ <h1 className={styles[`${elementType}` + "Label"]}>{value}</h1>
46
+ <Button
47
+ kind="ghost"
48
+ size="sm"
49
+ iconDescription={t("editNameButton", "Edit")}
50
+ onClick={() => setEditing(true)}
51
+ renderIcon={(props) => <Edit size={16} {...props} />}
52
+ hasIconOnly
53
+ />
54
+ </>
55
+ )}
56
+ </>
57
+ );
58
+ };
59
+
60
+ export default EditableValue;
@@ -0,0 +1,23 @@
1
+ @use '@carbon/styles/scss/type';
2
+ @import '~@openmrs/esm-styleguide/src/vars';
3
+
4
+ .schemaLabel {
5
+ @include type.type-style('heading-03');
6
+ }
7
+
8
+ .pageLabel {
9
+ @include type.type-style('heading-02');
10
+ margin: 0.5rem 0rem;
11
+
12
+ &:after {
13
+ content: "";
14
+ display: block;
15
+ width: 2rem;
16
+ padding-top: 0.188rem;
17
+ border-bottom: 0.375rem solid var(--brand-03);
18
+ }
19
+ }
20
+
21
+ .sectionLabel {
22
+ @include type.type-style('heading-02');
23
+ }