@openmrs/esm-form-builder-app 1.0.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 (52) hide show
  1. package/LICENSE +401 -0
  2. package/README.md +35 -0
  3. package/package.json +106 -0
  4. package/src/components/action-buttons/action-buttons.component.tsx +185 -0
  5. package/src/components/action-buttons/action-buttons.scss +16 -0
  6. package/src/components/dashboard/dashboard.component.tsx +309 -0
  7. package/src/components/dashboard/dashboard.scss +112 -0
  8. package/src/components/dashboard/dashboard.test.tsx +208 -0
  9. package/src/components/empty-state/empty-data-illustration.component.tsx +51 -0
  10. package/src/components/empty-state/empty-state.component.tsx +41 -0
  11. package/src/components/empty-state/empty-state.scss +55 -0
  12. package/src/components/error-state/error-state.component.tsx +37 -0
  13. package/src/components/error-state/error-state.scss +49 -0
  14. package/src/components/form-editor/form-editor.component.tsx +125 -0
  15. package/src/components/form-editor/form-editor.scss +33 -0
  16. package/src/components/form-renderer/form-renderer.component.tsx +123 -0
  17. package/src/components/form-renderer/form-renderer.scss +57 -0
  18. package/src/components/interactive-builder/add-question-modal.component.tsx +427 -0
  19. package/src/components/interactive-builder/delete-page-modal.component.tsx +89 -0
  20. package/src/components/interactive-builder/delete-question-modal.component.tsx +93 -0
  21. package/src/components/interactive-builder/delete-section-modal.component.tsx +91 -0
  22. package/src/components/interactive-builder/edit-question-modal.component.tsx +465 -0
  23. package/src/components/interactive-builder/editable-value.component.tsx +64 -0
  24. package/src/components/interactive-builder/editable-value.scss +23 -0
  25. package/src/components/interactive-builder/interactive-builder.component.tsx +569 -0
  26. package/src/components/interactive-builder/interactive-builder.scss +100 -0
  27. package/src/components/interactive-builder/new-form-modal.component.tsx +86 -0
  28. package/src/components/interactive-builder/page-modal.component.tsx +91 -0
  29. package/src/components/interactive-builder/question-modal.scss +35 -0
  30. package/src/components/interactive-builder/section-modal.component.tsx +94 -0
  31. package/src/components/interactive-builder/value-editor.component.tsx +55 -0
  32. package/src/components/interactive-builder/value-editor.scss +10 -0
  33. package/src/components/modals/save-form.component.tsx +310 -0
  34. package/src/components/modals/save-form.scss +5 -0
  35. package/src/components/schema-editor/schema-editor.component.tsx +191 -0
  36. package/src/components/schema-editor/schema-editor.scss +26 -0
  37. package/src/config-schema.ts +47 -0
  38. package/src/constants.ts +3 -0
  39. package/src/declarations.d.tsx +2 -0
  40. package/src/form-builder-app-menu-link.component.tsx +13 -0
  41. package/src/forms.resource.ts +178 -0
  42. package/src/hooks/useClobdata.ts +20 -0
  43. package/src/hooks/useConceptLookup.ts +18 -0
  44. package/src/hooks/useConceptName.ts +18 -0
  45. package/src/hooks/useEncounterTypes.ts +18 -0
  46. package/src/hooks/useForm.ts +18 -0
  47. package/src/hooks/useForms.ts +20 -0
  48. package/src/index.ts +70 -0
  49. package/src/root.component.tsx +19 -0
  50. package/src/setup-tests.ts +11 -0
  51. package/src/test-helpers.tsx +37 -0
  52. package/src/types.ts +132 -0
@@ -0,0 +1,310 @@
1
+ import React, { useCallback, useState } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import { useParams } from "react-router-dom";
4
+ import {
5
+ Button,
6
+ ComposedModal,
7
+ Form,
8
+ FormGroup,
9
+ InlineLoading,
10
+ ModalBody,
11
+ ModalFooter,
12
+ ModalHeader,
13
+ Select,
14
+ SelectItem,
15
+ Stack,
16
+ TextArea,
17
+ TextInput,
18
+ } from "@carbon/react";
19
+ import { showNotification, showToast } from "@openmrs/esm-framework";
20
+ import {
21
+ uploadSchema,
22
+ saveNewForm,
23
+ updateName,
24
+ updateVersion,
25
+ updateDescription,
26
+ getResourceUUID,
27
+ deleteClobData,
28
+ deleteResource,
29
+ updateEncounterType,
30
+ } from "../../forms.resource";
31
+ import { EncounterType, Resource, RouteParams, Schema } from "../../types";
32
+ import { useEncounterTypes } from "../../hooks/useEncounterTypes";
33
+ import styles from "./save-form.scss";
34
+
35
+ type FormGroupData = {
36
+ name: string;
37
+ uuid: string;
38
+ version: string;
39
+ encounterType: EncounterType;
40
+ description: string;
41
+ resources: Array<Resource>;
42
+ };
43
+ type SaveFormModalProps = { form: FormGroupData; schema: Schema };
44
+
45
+ const SaveForm: React.FC<SaveFormModalProps> = ({ form, schema }) => {
46
+ const { t } = useTranslation();
47
+ const { formUuid } = useParams<RouteParams>();
48
+ const isSavingNewForm = !formUuid;
49
+ const { encounterTypes } = useEncounterTypes();
50
+ const [openSaveFormModal, setOpenSaveFormModal] = useState(false);
51
+ const [openConfirmSaveModal, setOpenConfirmSaveModal] = useState(false);
52
+ const [saveState, setSaveState] = useState("");
53
+ const [isSavingForm, setIsSavingForm] = useState(false);
54
+
55
+ const openModal = useCallback((option) => {
56
+ if (option === "newVersion") {
57
+ setSaveState("newVersion");
58
+ setOpenConfirmSaveModal(false);
59
+ setOpenSaveFormModal(true);
60
+ } else if (option === "new") {
61
+ setSaveState("newVersion");
62
+ setOpenSaveFormModal(true);
63
+ } else if (option === "update") {
64
+ setSaveState("update");
65
+ setOpenConfirmSaveModal(false);
66
+ setOpenSaveFormModal(true);
67
+ }
68
+ }, []);
69
+
70
+ const handleSubmit = async (event) => {
71
+ event.preventDefault();
72
+ setIsSavingForm(true);
73
+ let name = event.target.name.value,
74
+ version = event.target.version.value,
75
+ encounterType = event.target.encounterType.value,
76
+ description = event.target.description.value,
77
+ encounterTypeUUID;
78
+
79
+ if (encounterType == "undefined") {
80
+ encounterTypeUUID = undefined;
81
+ } else {
82
+ encounterTypes.forEach((encType) => {
83
+ if (encounterType == encType.name) {
84
+ encounterTypeUUID = encType.uuid;
85
+ }
86
+ });
87
+ }
88
+
89
+ if (saveState === "new" || saveState === "newVersion") {
90
+ try {
91
+ const newForm = await saveNewForm(
92
+ name,
93
+ version,
94
+ false,
95
+ description,
96
+ encounterTypeUUID
97
+ );
98
+ const newValueReference = await uploadSchema(schema);
99
+ await getResourceUUID(newForm.uuid, newValueReference.toString());
100
+ showToast({
101
+ title: t("formCreated", "New form created"),
102
+ kind: "success",
103
+ critical: true,
104
+ description:
105
+ name +
106
+ " " +
107
+ t(
108
+ "saveSuccessMessage",
109
+ "was created successfully. It is now visible on the Forms dashboard."
110
+ ),
111
+ });
112
+ setOpenSaveFormModal(false);
113
+ } catch (error) {
114
+ showNotification({
115
+ title: t("errorCreatingForm", "Error creating form"),
116
+ kind: "error",
117
+ critical: true,
118
+ description: error?.message,
119
+ });
120
+ }
121
+ } else {
122
+ try {
123
+ if (form?.resources.length != 0) {
124
+ deleteClobData(form?.resources[0].valueReference);
125
+ deleteResource(form?.uuid, form?.resources[0].uuid);
126
+ }
127
+ const newValueReference = await uploadSchema(schema);
128
+ await getResourceUUID(form?.uuid, newValueReference.toString());
129
+
130
+ if (name !== form?.name) {
131
+ await updateName(name, form?.uuid);
132
+ }
133
+ if (version !== form?.version) {
134
+ await updateVersion(version, form?.uuid);
135
+ }
136
+ if (encounterTypeUUID !== form?.encounterType?.uuid) {
137
+ await updateEncounterType(encounterTypeUUID, form?.uuid);
138
+ }
139
+ if (description !== form?.description) {
140
+ await updateDescription(description, form?.uuid);
141
+ }
142
+ showToast({
143
+ title: t("success", "Success!"),
144
+ kind: "success",
145
+ critical: true,
146
+ description:
147
+ name + " " + t("saveSuccess", "was updated successfully"),
148
+ });
149
+ setOpenSaveFormModal(false);
150
+ } catch (error) {
151
+ showNotification({
152
+ title: t("errorUpdatingForm", "Error updating form"),
153
+ kind: "error",
154
+ critical: true,
155
+ description: error?.message,
156
+ });
157
+ }
158
+ }
159
+ };
160
+
161
+ return (
162
+ <div>
163
+ {!isSavingNewForm ? (
164
+ <ComposedModal
165
+ open={openConfirmSaveModal}
166
+ onClose={() => setOpenConfirmSaveModal(false)}
167
+ >
168
+ <ModalHeader title={t("saveConfirmation", "Save or Update form")} />
169
+ <ModalBody>
170
+ <p>
171
+ {t(
172
+ "saveAsModal",
173
+ "A version of the form you're working on already exists on the server. Do you want to update the form or to save it as a new version?"
174
+ )}
175
+ </p>
176
+ </ModalBody>
177
+ <ModalFooter>
178
+ <Button kind={"tertiary"} onClick={() => openModal("update")}>
179
+ {t("updateExistingForm", "Update existing version")}
180
+ </Button>
181
+ <Button kind={"primary"} onClick={() => openModal("newVersion")}>
182
+ {t("saveAsNewForm", "Save as a new")}
183
+ </Button>
184
+ <Button
185
+ kind={"secondary"}
186
+ onClick={() => setOpenConfirmSaveModal(false)}
187
+ >
188
+ {t("close", "Close")}
189
+ </Button>
190
+ </ModalFooter>
191
+ </ComposedModal>
192
+ ) : null}
193
+
194
+ <ComposedModal
195
+ open={openSaveFormModal}
196
+ onClose={() => setOpenSaveFormModal(false)}
197
+ >
198
+ <ModalHeader
199
+ title={t("saveFormToServer", "Save form to server")}
200
+ ></ModalHeader>
201
+ <Form onSubmit={handleSubmit}>
202
+ <ModalBody>
203
+ <p>
204
+ {t(
205
+ "saveExplainerText",
206
+ "Clicking the Save button saves your form schema to the database. To see your form in your frontend, you first need to publish it. Click the Publish button to publish your form."
207
+ )}
208
+ </p>
209
+ <FormGroup legendText={""}>
210
+ <Stack gap={5}>
211
+ <TextInput
212
+ id="name"
213
+ labelText={t("formName", "Form name")}
214
+ defaultValue={saveState === "update" ? form?.name : ""}
215
+ placeholder="e.g. OHRI Express Care Patient Encounter Form"
216
+ required
217
+ />
218
+ {saveState === "update" ? (
219
+ <TextInput
220
+ id="uuid"
221
+ labelText="UUID (auto-generated)"
222
+ disabled
223
+ defaultValue={saveState === "update" ? form?.uuid : ""}
224
+ />
225
+ ) : null}
226
+ <TextInput
227
+ id="version"
228
+ labelText="Version"
229
+ defaultValue={saveState === "update" ? form?.version : ""}
230
+ placeholder="e.g. 1.0"
231
+ required
232
+ />
233
+ <Select
234
+ id="encounterType"
235
+ defaultValue={
236
+ form?.encounterType
237
+ ? form?.encounterType?.name
238
+ : "undefined"
239
+ }
240
+ labelText={t("encounterType", "Encounter Type")}
241
+ required
242
+ >
243
+ {!form?.encounterType ? (
244
+ <SelectItem
245
+ text={t(
246
+ "chooseEncounterType",
247
+ "Choose an encounter type to link your form to"
248
+ )}
249
+ />
250
+ ) : null}
251
+ {encounterTypes?.map((encounterType, key) => (
252
+ <SelectItem
253
+ key={key}
254
+ value={encounterType.name}
255
+ text={encounterType.name}
256
+ />
257
+ ))}
258
+ </Select>
259
+ <TextArea
260
+ labelText={t("description", "Description")}
261
+ defaultValue={saveState === "update" ? form?.description : ""}
262
+ cols={6}
263
+ rows={3}
264
+ id="description"
265
+ placeholder={t(
266
+ "descriptionPlaceholderText",
267
+ "e.g. A form used to collect encounter data for clients in the Express Care program."
268
+ )}
269
+ required
270
+ />
271
+ </Stack>
272
+ </FormGroup>
273
+ </ModalBody>
274
+ <ModalFooter>
275
+ <Button
276
+ kind={"secondary"}
277
+ onClick={() => setOpenSaveFormModal(false)}
278
+ >
279
+ {t("close", "Close")}
280
+ </Button>
281
+ <Button
282
+ disabled={isSavingForm}
283
+ className={styles.spinner}
284
+ type={"submit"}
285
+ kind={"primary"}
286
+ >
287
+ {isSavingForm ? (
288
+ <InlineLoading description={t("saving", "Saving") + "..."} />
289
+ ) : (
290
+ <span>{t("save", "Save")}</span>
291
+ )}
292
+ </Button>
293
+ </ModalFooter>
294
+ </Form>
295
+ </ComposedModal>
296
+
297
+ <Button
298
+ disabled={!schema}
299
+ kind="primary"
300
+ onClick={() =>
301
+ isSavingNewForm ? openModal("new") : setOpenConfirmSaveModal(true)
302
+ }
303
+ >
304
+ {t("saveForm", "Save form")}
305
+ </Button>
306
+ </div>
307
+ );
308
+ };
309
+
310
+ export default SaveForm;
@@ -0,0 +1,5 @@
1
+ .spinner {
2
+ &:global(.cds--inline-loading) {
3
+ min-height: 1rem;
4
+ }
5
+ }
@@ -0,0 +1,191 @@
1
+ import React, { useCallback, useEffect, useState } from "react";
2
+ import { useParams } from "react-router-dom";
3
+ import AceEditor from "react-ace";
4
+ import "ace-builds/webpack-resolver";
5
+ import "ace-builds/src-noconflict/ext-language_tools";
6
+ import { Button, InlineLoading } from "@carbon/react";
7
+ import { useTranslation } from "react-i18next";
8
+ import { OHRIFormSchema } from "@ohri/openmrs-ohri-form-engine-lib";
9
+ import { RouteParams, Schema } from "../../types";
10
+ import styles from "./schema-editor.scss";
11
+
12
+ type SchemaEditorProps = {
13
+ isLoading: boolean;
14
+ onSchemaChange: (schema: Schema) => void;
15
+ schema: Schema;
16
+ };
17
+
18
+ const SchemaEditor: React.FC<SchemaEditorProps> = ({
19
+ isLoading,
20
+ onSchemaChange,
21
+ schema,
22
+ }) => {
23
+ const { t } = useTranslation();
24
+ const { formUuid } = useParams<RouteParams>();
25
+ const isNewSchema = !formUuid;
26
+
27
+ const [stringifiedSchema, setStringifiedSchema] = useState(
28
+ schema ? JSON.stringify(schema, null, 2) : ""
29
+ );
30
+ const [invalidJsonErrorMessage, setInvalidJsonErrorMessage] = useState("");
31
+ const [isRendering, setIsRendering] = useState(false);
32
+
33
+ const resetErrorMessage = useCallback(() => {
34
+ setInvalidJsonErrorMessage("");
35
+ }, []);
36
+
37
+ const handleSchemaChange = (updatedSchema: string) => {
38
+ setStringifiedSchema(updatedSchema);
39
+ };
40
+
41
+ const inputDummySchema = useCallback(() => {
42
+ const dummySchema: OHRIFormSchema = {
43
+ encounterType: "",
44
+ name: "Sample Form",
45
+ pages: [
46
+ {
47
+ label: "First Page",
48
+ sections: [
49
+ {
50
+ label: "A Section",
51
+ isExpanded: "true",
52
+ questions: [
53
+ {
54
+ label: "A Question of type obs that renders a text input",
55
+ type: "obs",
56
+ questionOptions: {
57
+ rendering: "text",
58
+ concept: "a-system-defined-concept-uuid",
59
+ },
60
+ id: "sampleQuestion",
61
+ },
62
+ ],
63
+ },
64
+ {
65
+ label: "Another Section",
66
+ isExpanded: "true",
67
+ questions: [
68
+ {
69
+ label:
70
+ "Another Question of type obs whose answers get rendered as radio inputs",
71
+ type: "obs",
72
+ questionOptions: {
73
+ rendering: "radio",
74
+ concept: "system-defined-concept-uuid",
75
+ answers: [
76
+ {
77
+ concept: "another-system-defined-concept-uuid",
78
+ label: "Choice 1",
79
+ conceptMappings: [],
80
+ },
81
+ {
82
+ concept: "yet-another-system-defined-concept-uuid",
83
+ label: "Choice 2",
84
+ conceptMappings: [],
85
+ },
86
+ {
87
+ concept: "yet-one-more-system-defined-concept-uuid",
88
+ label: "Choice 3",
89
+ conceptMappings: [],
90
+ },
91
+ ],
92
+ },
93
+ id: "anotherSampleQuestion",
94
+ },
95
+ ],
96
+ },
97
+ ],
98
+ },
99
+ ],
100
+ processor: "EncounterFormProcessor",
101
+ referencedForms: [],
102
+ uuid: "xxx",
103
+ };
104
+
105
+ setStringifiedSchema(JSON.stringify(dummySchema, null, 2));
106
+ onSchemaChange({ ...dummySchema });
107
+ }, [onSchemaChange]);
108
+
109
+ const renderSchemaChanges = useCallback(() => {
110
+ setIsRendering(true);
111
+ resetErrorMessage();
112
+
113
+ try {
114
+ const parsedJson: Schema = JSON.parse(stringifiedSchema);
115
+ onSchemaChange(parsedJson);
116
+ setStringifiedSchema(JSON.stringify(parsedJson, null, 2));
117
+ } catch (error) {
118
+ setInvalidJsonErrorMessage(error.message);
119
+ }
120
+ setIsRendering(false);
121
+ }, [stringifiedSchema, onSchemaChange, resetErrorMessage]);
122
+
123
+ useEffect(() => {
124
+ setStringifiedSchema(JSON.stringify(schema, null, 2));
125
+ }, [schema]);
126
+
127
+ return (
128
+ <>
129
+ <div className={styles.actionButtons}>
130
+ {isLoading ? (
131
+ <InlineLoading
132
+ description={t("loadingSchema", "Loading schema") + "..."}
133
+ />
134
+ ) : null}
135
+
136
+ {isNewSchema ? (
137
+ <Button kind="secondary" onClick={inputDummySchema}>
138
+ {t("inputDummySchema", "Input dummy schema")}
139
+ </Button>
140
+ ) : null}
141
+
142
+ <Button
143
+ disabled={isRendering}
144
+ kind="primary"
145
+ onClick={renderSchemaChanges}
146
+ >
147
+ {isRendering ? (
148
+ <InlineLoading
149
+ className={styles.spinner}
150
+ description={t("rendering", "Rendering") + "..."}
151
+ />
152
+ ) : (
153
+ <span>{t("renderChanges", "Render changes")}</span>
154
+ )}
155
+ </Button>
156
+ </div>
157
+
158
+ {invalidJsonErrorMessage ? (
159
+ <div className={styles.errorContainer}>
160
+ <p className={styles.heading}>
161
+ {t("schemaError", "There's an error in your schema.")}
162
+ </p>
163
+ <p>{invalidJsonErrorMessage}</p>
164
+ </div>
165
+ ) : null}
166
+
167
+ <AceEditor
168
+ style={{ height: "100vh", width: "100%" }}
169
+ mode="json"
170
+ theme="textmate"
171
+ name="schemaEditor"
172
+ onChange={handleSchemaChange}
173
+ fontSize={15}
174
+ showPrintMargin={false}
175
+ showGutter={true}
176
+ highlightActiveLine={true}
177
+ value={stringifiedSchema}
178
+ setOptions={{
179
+ enableBasicAutocompletion: false,
180
+ enableLiveAutocompletion: false,
181
+ displayIndentGuides: true,
182
+ enableSnippets: false,
183
+ showLineNumbers: true,
184
+ tabSize: 2,
185
+ }}
186
+ />
187
+ </>
188
+ );
189
+ };
190
+
191
+ export default SchemaEditor;
@@ -0,0 +1,26 @@
1
+ @use '@carbon/styles/scss/colors';
2
+ @use '@carbon/styles/scss/type';
3
+
4
+ .actionButtons {
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: flex-end;
8
+ margin: 1rem 0;
9
+
10
+ button {
11
+ margin-left: 1rem
12
+ }
13
+ }
14
+
15
+ .errorContainer {
16
+ @include type.type-style("body-compact-02");
17
+ background-color: colors.$red-20;
18
+ color: colors.$red-70;
19
+ padding: 1.5rem;
20
+ margin: 1rem 0;
21
+ }
22
+
23
+ .heading {
24
+ @include type.type-style('heading-compact-02');
25
+ margin-bottom: 1rem;
26
+ }
@@ -0,0 +1,47 @@
1
+ import { Type } from "@openmrs/esm-framework";
2
+
3
+ export const configSchema = {
4
+ questionTypes: {
5
+ _type: Type.Array,
6
+ _description:
7
+ "Provides information that the processor uses to render a field",
8
+ _default: [
9
+ "complex-obs",
10
+ "control",
11
+ "encounterDatetime",
12
+ "encounterLocation",
13
+ "encounterProvider",
14
+ "obs",
15
+ "obsGroup",
16
+ "personAttribute",
17
+ "testOrder",
18
+ ],
19
+ },
20
+ fieldTypes: {
21
+ _type: Type.Array,
22
+ _description:
23
+ "An array of available field types. A question can have only one field type, and the field type determines how the question is rendered.",
24
+ _default: [
25
+ "date",
26
+ "drug",
27
+ "field-set",
28
+ "file",
29
+ "group",
30
+ "multiCheckbox",
31
+ "number",
32
+ "problem",
33
+ "radio",
34
+ "repeating",
35
+ "select",
36
+ "text",
37
+ "textarea",
38
+ "ui-select-extended",
39
+ ],
40
+ },
41
+ patientUuid: {
42
+ _type: "String",
43
+ _default: "88f1032f-adae-4ef2-9025-2c40b71dd897",
44
+ _description:
45
+ "UUID of the test patient whose information gets rendered in a patient banner within the form renderer",
46
+ },
47
+ };
@@ -0,0 +1,3 @@
1
+ export const spaRoot = window["getOpenmrsSpaBase"];
2
+ export const basePath = "/form-builder";
3
+ export const spaBasePath = `${window.spaBase}${basePath}`;
@@ -0,0 +1,2 @@
1
+ declare module "*.css";
2
+ declare module "*.scss";
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { ConfigurableLink } from "@openmrs/esm-framework";
3
+ import { useTranslation } from "react-i18next";
4
+ import { spaBasePath } from "./constants";
5
+
6
+ export default function FormBuilderAppMenuLink() {
7
+ const { t } = useTranslation();
8
+ return (
9
+ <ConfigurableLink to={spaBasePath}>
10
+ {t("formBuilder", "Form Builder")}
11
+ </ConfigurableLink>
12
+ );
13
+ }