@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,185 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ Button,
4
+ ComposedModal,
5
+ InlineLoading,
6
+ ModalBody,
7
+ ModalFooter,
8
+ ModalHeader,
9
+ } from "@carbon/react";
10
+ import { useParams } from "react-router-dom";
11
+ import { useSWRConfig } from "swr";
12
+ import { showToast, showNotification } from "@openmrs/esm-framework";
13
+
14
+ import { RouteParams } from "../../types";
15
+ import { publishForm, unpublishForm } from "../../forms.resource";
16
+ import { useForm } from "../../hooks/useForm";
17
+ import SaveForm from "../modals/save-form.component";
18
+ import styles from "./action-buttons.scss";
19
+
20
+ type Status =
21
+ | "idle"
22
+ | "publishing"
23
+ | "published"
24
+ | "unpublishing"
25
+ | "unpublished"
26
+ | "error";
27
+
28
+ function ActionButtons({ schema, t }) {
29
+ const { cache, mutate }: { cache: any; mutate: Function } = useSWRConfig();
30
+ const { formUuid } = useParams<RouteParams>();
31
+ const { form } = useForm(formUuid);
32
+ const [status, setStatus] = useState<Status>("idle");
33
+ const [showUnpublishModal, setShowUnpublishModal] = useState(false);
34
+
35
+ const launchUnpublishModal = () => {
36
+ setShowUnpublishModal(true);
37
+ };
38
+
39
+ const revalidate = () => {
40
+ const apiUrlPattern = new RegExp("\\/ws\\/rest\\/v1\\/form");
41
+
42
+ // Find matching keys from SWR's cache and broadcast a revalidation message to their pre-bound SWR hooks
43
+ Array.from(cache.keys())
44
+ .filter((url: string) => apiUrlPattern.test(url))
45
+ .forEach((url: string) => mutate(url));
46
+ };
47
+
48
+ async function handlePublish() {
49
+ setStatus("publishing");
50
+ try {
51
+ await publishForm(form.uuid);
52
+
53
+ showToast({
54
+ title: t("formPublished", "Form published"),
55
+ kind: "success",
56
+ critical: true,
57
+ description:
58
+ `${form.name} ` +
59
+ t("formPublishedSuccessfully", "form was published successfully"),
60
+ });
61
+
62
+ setStatus("published");
63
+ revalidate();
64
+ } catch (error) {
65
+ showNotification({
66
+ title: t("errorPublishingForm", "Error publishing form"),
67
+ kind: "error",
68
+ critical: true,
69
+ description: error?.message,
70
+ });
71
+ setStatus("error");
72
+ }
73
+ }
74
+
75
+ async function handleUnpublish() {
76
+ setStatus("unpublishing");
77
+ try {
78
+ await unpublishForm(form.uuid);
79
+
80
+ showToast({
81
+ title: t("formUnpublished", "Form unpublished"),
82
+ kind: "success",
83
+ critical: true,
84
+ description:
85
+ `${form.name} ` +
86
+ t("formUnpublishedSuccessfully", "form was unpublished successfully"),
87
+ });
88
+
89
+ setStatus("unpublished");
90
+ revalidate();
91
+ } catch (error) {
92
+ showNotification({
93
+ title: t("errorUnpublishingForm", "Error unpublishing form"),
94
+ kind: "error",
95
+ critical: true,
96
+ description: error?.message,
97
+ });
98
+ setStatus("error");
99
+ }
100
+ setShowUnpublishModal(false);
101
+ }
102
+
103
+ return (
104
+ <div className={styles.actionButtons}>
105
+ <SaveForm form={form} schema={schema} />
106
+
107
+ <>
108
+ {form && !form.published ? (
109
+ <Button
110
+ kind="secondary"
111
+ onClick={handlePublish}
112
+ disabled={status === "publishing"}
113
+ >
114
+ {status === "publishing" && !form?.published ? (
115
+ <InlineLoading
116
+ className={styles.spinner}
117
+ description={t("publishing", "Publishing") + "..."}
118
+ />
119
+ ) : (
120
+ <span>{t("publishForm", "Publish form")}</span>
121
+ )}
122
+ </Button>
123
+ ) : null}
124
+
125
+ {form && form.published ? (
126
+ <Button
127
+ kind="danger"
128
+ onClick={launchUnpublishModal}
129
+ disabled={status === "unpublishing"}
130
+ >
131
+ {t("unpublishForm", "Unpublish form")}
132
+ </Button>
133
+ ) : null}
134
+
135
+ {showUnpublishModal ? (
136
+ <ComposedModal
137
+ open={true}
138
+ onClose={() => setShowUnpublishModal(false)}
139
+ >
140
+ <ModalHeader
141
+ title={t(
142
+ "unpublishConfirmation",
143
+ "Are you sure you want to unpublish this form?"
144
+ )}
145
+ ></ModalHeader>
146
+ <ModalBody>
147
+ <p>
148
+ {t(
149
+ "unpublishExplainerText",
150
+ "Unpublishing a form means you can no longer access it from your frontend. Unpublishing forms does not delete their associated schemas, it only affects whether or not you can access them in your frontend."
151
+ )}
152
+ </p>
153
+ </ModalBody>
154
+ <ModalFooter>
155
+ <Button
156
+ kind="secondary"
157
+ onClick={() => setShowUnpublishModal(false)}
158
+ >
159
+ {t("cancel", "Cancel")}
160
+ </Button>
161
+ <Button
162
+ disabled={status === "unpublishing"}
163
+ kind={status === "unpublishing" ? "secondary" : "danger"}
164
+ onClick={handleUnpublish}
165
+ >
166
+ {status === "unpublishing" ? (
167
+ <InlineLoading
168
+ className={styles.spinner}
169
+ description={t("unpublishing", "Unpublishing") + "..."}
170
+ />
171
+ ) : (
172
+ <span>{t("unpublishForm", "Unpublish form")}</span>
173
+ )}
174
+ </Button>
175
+ </ModalFooter>
176
+ </ComposedModal>
177
+ ) : (
178
+ false
179
+ )}
180
+ </>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export default ActionButtons;
@@ -0,0 +1,16 @@
1
+ .actionButtons {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: flex-end;
5
+ margin: 1rem 0;
6
+
7
+ button {
8
+ margin-left: 1rem
9
+ }
10
+ }
11
+
12
+ .spinner {
13
+ &:global(.cds--inline-loading) {
14
+ min-height: 1rem;
15
+ }
16
+ }
@@ -0,0 +1,309 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+ import {
4
+ Button,
5
+ DataTable,
6
+ DataTableSkeleton,
7
+ Dropdown,
8
+ InlineLoading,
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableContainer,
13
+ TableHead,
14
+ TableHeader,
15
+ TableRow,
16
+ TableToolbar,
17
+ TableToolbarContent,
18
+ TableToolbarSearch,
19
+ Tag,
20
+ Tile,
21
+ } from "@carbon/react";
22
+ import { Add, DocumentImport, Download, Edit } from "@carbon/react/icons";
23
+ import { navigate, useLayoutType } from "@openmrs/esm-framework";
24
+
25
+ import { FilterProps } from "../../types";
26
+ import { useClobdata } from "../../hooks/useClobdata";
27
+ import { useForms } from "../../hooks/useForms";
28
+ import EmptyState from "../empty-state/empty-state.component";
29
+ import ErrorState from "../error-state/error-state.component";
30
+ import styles from "./dashboard.scss";
31
+
32
+ function CustomTag({ condition }) {
33
+ const { t } = useTranslation();
34
+
35
+ return condition ? (
36
+ <Tag type="green" size="md" title="Clear Filter">
37
+ {t("yes", "Yes")}
38
+ </Tag>
39
+ ) : (
40
+ <Tag type="red" size="md" title="Clear Filter">
41
+ {t("no", "No")}
42
+ </Tag>
43
+ );
44
+ }
45
+
46
+ function ActionButtons({ form }) {
47
+ const { t } = useTranslation();
48
+ const { clobdata } = useClobdata(form);
49
+ const formResources = form?.resources;
50
+
51
+ const downloadableSchema = useMemo(
52
+ () =>
53
+ new Blob([JSON.stringify(clobdata, null, 2)], {
54
+ type: "application/json",
55
+ }),
56
+ [clobdata]
57
+ );
58
+
59
+ return formResources.length == 0 || !form?.resources[0] ? (
60
+ <Button
61
+ className={styles.importButton}
62
+ renderIcon={DocumentImport}
63
+ onClick={() => navigate({ to: `form-builder/edit/${form.uuid}` })}
64
+ kind={"ghost"}
65
+ iconDescription={t("import", "Import")}
66
+ hasIconOnly
67
+ />
68
+ ) : (
69
+ <>
70
+ <Button
71
+ className={styles.editButton}
72
+ enterDelayMs={300}
73
+ renderIcon={Edit}
74
+ onClick={() =>
75
+ navigate({
76
+ to: `${window.spaBase}/form-builder/edit/${form.uuid}`,
77
+ })
78
+ }
79
+ kind={"ghost"}
80
+ iconDescription={t("editSchema", "Edit schema")}
81
+ hasIconOnly
82
+ tooltipAlignment="start"
83
+ />
84
+ <a
85
+ className={styles.downloadLink}
86
+ download={`${form?.name}.json`}
87
+ href={window.URL.createObjectURL(downloadableSchema)}
88
+ >
89
+ <Button
90
+ className={styles.downloadButton}
91
+ enterDelayMs={300}
92
+ renderIcon={Download}
93
+ kind={"ghost"}
94
+ iconDescription={t("downloadSchema", "Download schema")}
95
+ hasIconOnly
96
+ tooltipAlignment="start"
97
+ ></Button>
98
+ </a>
99
+ </>
100
+ );
101
+ }
102
+
103
+ function FormsList({ forms, isValidating, t }) {
104
+ const isTablet = useLayoutType() === "tablet";
105
+ const [filter, setFilter] = useState("");
106
+
107
+ const filteredRows = useMemo(() => {
108
+ if (!filter) {
109
+ return forms;
110
+ }
111
+
112
+ if (filter === "Published") {
113
+ return forms.filter((form) => form.published);
114
+ }
115
+
116
+ if (filter === "Unpublished") {
117
+ return forms.filter((form) => !form.published);
118
+ }
119
+
120
+ return forms;
121
+ }, [filter, forms]);
122
+
123
+ const tableHeaders = [
124
+ {
125
+ header: t("name", "Name"),
126
+ key: "name",
127
+ },
128
+ {
129
+ header: t("version", "Version"),
130
+ key: "version",
131
+ },
132
+ {
133
+ header: t("published", "Published"),
134
+ key: "published",
135
+ },
136
+ {
137
+ header: t("retired", "Retired"),
138
+ key: "retired",
139
+ },
140
+ {
141
+ header: t("schemaActions", "Schema actions"),
142
+ key: "actions",
143
+ },
144
+ ];
145
+
146
+ const tableRows = useMemo(
147
+ () =>
148
+ (filteredRows ? filteredRows : forms)?.map((form) => ({
149
+ ...form,
150
+ id: form.uuid,
151
+ published: <CustomTag condition={form.published} />,
152
+ retired: <CustomTag condition={form.retired} />,
153
+ actions: <ActionButtons form={form} />,
154
+ })),
155
+ [filteredRows, forms]
156
+ );
157
+
158
+ const handlePublishStatusChange = ({ selectedItem }) =>
159
+ setFilter(selectedItem);
160
+
161
+ const handleFilter = ({
162
+ rowIds,
163
+ headers,
164
+ cellsById,
165
+ inputValue,
166
+ getCellId,
167
+ }: FilterProps): Array<string> => {
168
+ return rowIds.filter((rowId) =>
169
+ headers.some(({ key }) => {
170
+ const cellId = getCellId(rowId, key);
171
+ const filterableValue = cellsById[cellId].value;
172
+ const filterTerm = inputValue.toLowerCase();
173
+
174
+ return ("" + filterableValue).toLowerCase().includes(filterTerm);
175
+ })
176
+ );
177
+ };
178
+
179
+ return (
180
+ <>
181
+ <div className={styles.flexContainer}>
182
+ <div className={styles.filterContainer}>
183
+ <Dropdown
184
+ id="publishStatusFilter"
185
+ initialSelectedItem={"All"}
186
+ label=""
187
+ titleText={
188
+ t("filterByPublishedStatus", "Filter by publish status") + ":"
189
+ }
190
+ type="inline"
191
+ items={["All", "Published", "Unpublished"]}
192
+ onChange={handlePublishStatusChange}
193
+ />
194
+ </div>
195
+ <div className={styles.backgroundDataFetchingIndicator}>
196
+ <span>{isValidating ? <InlineLoading /> : null}</span>
197
+ </div>
198
+ </div>
199
+ <DataTable
200
+ filterRows={handleFilter}
201
+ rows={tableRows}
202
+ headers={tableHeaders}
203
+ size={isTablet ? "sm" : "lg"}
204
+ useZebraStyles
205
+ >
206
+ {({
207
+ rows,
208
+ headers,
209
+ getTableProps,
210
+ getHeaderProps,
211
+ getRowProps,
212
+ onInputChange,
213
+ }) => (
214
+ <>
215
+ <TableContainer className={styles.tableContainer}>
216
+ <div className={styles.toolbarWrapper}>
217
+ <TableToolbar className={styles.tableToolbar}>
218
+ <TableToolbarContent>
219
+ <TableToolbarSearch
220
+ onChange={onInputChange}
221
+ placeholder={t("searchThisList", "Search this list")}
222
+ />
223
+ <Button
224
+ kind="primary"
225
+ iconDescription={t("createNewForm", "Create a new form")}
226
+ renderIcon={(props) => <Add size={16} {...props} />}
227
+ onClick={() =>
228
+ navigate({
229
+ to: `${window.spaBase}/form-builder/new`,
230
+ })
231
+ }
232
+ >
233
+ {t("createNewForm", "Create a new form")}
234
+ </Button>
235
+ </TableToolbarContent>
236
+ </TableToolbar>
237
+ </div>
238
+ <Table {...getTableProps()} className={styles.table}>
239
+ <TableHead>
240
+ <TableRow>
241
+ {headers.map((header) => (
242
+ <TableHeader {...getHeaderProps({ header })}>
243
+ {header.header}
244
+ </TableHeader>
245
+ ))}
246
+ </TableRow>
247
+ </TableHead>
248
+ <TableBody>
249
+ {rows.map((row) => (
250
+ <TableRow {...getRowProps({ row })}>
251
+ {row.cells.map((cell) => (
252
+ <TableCell key={cell.id}>{cell.value}</TableCell>
253
+ ))}
254
+ </TableRow>
255
+ ))}
256
+ </TableBody>
257
+ </Table>
258
+ </TableContainer>
259
+ {rows.length === 0 ? (
260
+ <div className={styles.tileContainer}>
261
+ <Tile className={styles.tile}>
262
+ <div className={styles.tileContent}>
263
+ <p className={styles.content}>
264
+ {t(
265
+ "noMatchingFormsToDisplay",
266
+ "No matching forms to display"
267
+ )}
268
+ </p>
269
+ <p className={styles.helper}>
270
+ {t("checkFilters", "Check the filters above")}
271
+ </p>
272
+ </div>
273
+ </Tile>
274
+ </div>
275
+ ) : null}
276
+ </>
277
+ )}
278
+ </DataTable>
279
+ </>
280
+ );
281
+ }
282
+
283
+ const Dashboard: React.FC = () => {
284
+ const { t } = useTranslation();
285
+ const { error, forms, isLoading, isValidating } = useForms();
286
+
287
+ return (
288
+ <div className={styles.container}>
289
+ <h3 className={styles.heading}>{t("formBuilder", "Form Builder")}</h3>
290
+ {(() => {
291
+ if (error) {
292
+ return <ErrorState error={error} />;
293
+ }
294
+
295
+ if (isLoading) {
296
+ return <DataTableSkeleton role="progressbar" />;
297
+ }
298
+
299
+ if (forms.length === 0) {
300
+ return <EmptyState />;
301
+ }
302
+
303
+ return <FormsList forms={forms} isValidating={isValidating} t={t} />;
304
+ })()}
305
+ </div>
306
+ );
307
+ };
308
+
309
+ export default Dashboard;
@@ -0,0 +1,112 @@
1
+ @use '@carbon/styles/scss/type';
2
+ @use '@carbon/styles/scss/spacing';
3
+ @import '~@openmrs/esm-styleguide/src/vars';
4
+
5
+ .container {
6
+ padding: 2rem;
7
+ background-color: $ui-01;
8
+ }
9
+
10
+ .backgroundDataFetchingIndicator {
11
+ flex: 1;
12
+ }
13
+
14
+ .flexContainer {
15
+ display: flex;
16
+ justify-content: space-between;
17
+ }
18
+
19
+ .toolbarWrapper {
20
+ position: relative;
21
+ display: flex;
22
+ height: spacing.$spacing-09;
23
+ justify-content: flex-end;
24
+ }
25
+
26
+ .tableToolbar {
27
+ width: 20%;
28
+ min-width: 12.5rem;
29
+ }
30
+
31
+ .table tr:last-of-type {
32
+ td {
33
+ border-bottom: none;
34
+ }
35
+ }
36
+
37
+ .heading {
38
+ margin-bottom: 1.5rem;
39
+ }
40
+
41
+ .toolbar {
42
+ position: relative;
43
+ display: flex;
44
+ width: 100%;
45
+ min-height: 3rem;
46
+ margin-bottom: 0.5rem;
47
+ }
48
+
49
+ .emptyContainer {
50
+ justify-content: center;
51
+ align-items: center;
52
+ width: 100%;
53
+ padding: 1rem;
54
+ }
55
+
56
+ .tableContainer {
57
+ padding: 0;
58
+
59
+ :global(.cds--data-table-content) {
60
+ border: 1px solid $ui-03;
61
+ border-bottom: none;
62
+ overflow: visible;
63
+ }
64
+
65
+ :global(.cds--table-toolbar) {
66
+ position: relative;
67
+ height: 2rem;
68
+ min-height: 0rem;
69
+ overflow: visible;
70
+ top: 0;
71
+ }
72
+
73
+ &:global(.cds--data-table-container) {
74
+ padding-top: 0rem;
75
+ }
76
+ }
77
+
78
+ .filterContainer {
79
+ flex: 1;
80
+
81
+ :global(.cds--dropdown__wrapper--inline) {
82
+ gap: 0;
83
+ }
84
+
85
+ :global(.cds--list-box__menu-icon) {
86
+ height: 1rem;
87
+ }
88
+ }
89
+
90
+ .content {
91
+ @include type.type-style('heading-compact-02');
92
+ color: $text-02;
93
+ margin-bottom: 0.5rem;
94
+ }
95
+
96
+ .tileContainer {
97
+ background-color: $ui-02;
98
+ border-top: 1px solid $ui-03;
99
+ padding: 5rem 0;
100
+ }
101
+
102
+ .tile {
103
+ margin: auto;
104
+ width: fit-content;
105
+ }
106
+
107
+ .tileContent {
108
+ display: flex;
109
+ flex-direction: column;
110
+ align-items: center;
111
+ }
112
+