@openmrs/esm-sms-app 1.0.1-pre.22

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 (108) hide show
  1. package/.turbo/turbo-build.log +32 -0
  2. package/dist/123.js +2 -0
  3. package/dist/123.js.LICENSE.txt +9 -0
  4. package/dist/123.js.map +1 -0
  5. package/dist/144.js +2 -0
  6. package/dist/144.js.LICENSE.txt +19 -0
  7. package/dist/144.js.map +1 -0
  8. package/dist/160.js +1 -0
  9. package/dist/160.js.map +1 -0
  10. package/dist/173.js +1 -0
  11. package/dist/173.js.map +1 -0
  12. package/dist/197.js +1 -0
  13. package/dist/260.js +1 -0
  14. package/dist/260.js.map +1 -0
  15. package/dist/285.js +1 -0
  16. package/dist/285.js.map +1 -0
  17. package/dist/300.js +2 -0
  18. package/dist/300.js.LICENSE.txt +5 -0
  19. package/dist/300.js.map +1 -0
  20. package/dist/335.js +1 -0
  21. package/dist/356.js +1 -0
  22. package/dist/356.js.map +1 -0
  23. package/dist/372.js +1 -0
  24. package/dist/372.js.map +1 -0
  25. package/dist/378.js +1 -0
  26. package/dist/378.js.map +1 -0
  27. package/dist/41.js +2 -0
  28. package/dist/41.js.LICENSE.txt +9 -0
  29. package/dist/41.js.map +1 -0
  30. package/dist/449.js +1 -0
  31. package/dist/449.js.map +1 -0
  32. package/dist/465.js +1 -0
  33. package/dist/465.js.map +1 -0
  34. package/dist/470.js +2 -0
  35. package/dist/470.js.LICENSE.txt +9 -0
  36. package/dist/470.js.map +1 -0
  37. package/dist/495.js +1 -0
  38. package/dist/495.js.map +1 -0
  39. package/dist/55.js +1 -0
  40. package/dist/613.js +2 -0
  41. package/dist/613.js.LICENSE.txt +5 -0
  42. package/dist/613.js.map +1 -0
  43. package/dist/628.js +1 -0
  44. package/dist/628.js.map +1 -0
  45. package/dist/652.js +1 -0
  46. package/dist/733.js +2 -0
  47. package/dist/733.js.LICENSE.txt +30 -0
  48. package/dist/733.js.map +1 -0
  49. package/dist/830.js +1 -0
  50. package/dist/830.js.map +1 -0
  51. package/dist/831.js +2 -0
  52. package/dist/831.js.LICENSE.txt +5 -0
  53. package/dist/831.js.map +1 -0
  54. package/dist/876.js +2 -0
  55. package/dist/876.js.LICENSE.txt +9 -0
  56. package/dist/876.js.map +1 -0
  57. package/dist/919.js +1 -0
  58. package/dist/99.js +1 -0
  59. package/dist/main.js +1 -0
  60. package/dist/main.js.map +1 -0
  61. package/dist/openmrs-esm-sms-app.js +1 -0
  62. package/dist/openmrs-esm-sms-app.js.buildmanifest.json +807 -0
  63. package/dist/openmrs-esm-sms-app.js.map +1 -0
  64. package/dist/routes.json +1 -0
  65. package/jest.config.js +3 -0
  66. package/package.json +55 -0
  67. package/src/api/providers.resource.ts +45 -0
  68. package/src/config-schema.ts +34 -0
  69. package/src/declarations.d.ts +4 -0
  70. package/src/hooks/useLogs.ts +28 -0
  71. package/src/hooks/useProviderConfigTemplates.ts +16 -0
  72. package/src/hooks/useProviderConfigurations.ts +32 -0
  73. package/src/index.ts +35 -0
  74. package/src/providers/add-config-form/provider-config-form.scss +118 -0
  75. package/src/providers/add-config-form/provider-config-form.test.tsx +252 -0
  76. package/src/providers/add-config-form/provider-config-form.workspace.tsx +347 -0
  77. package/src/providers/empty-state/empty-data-illustration.component.tsx +39 -0
  78. package/src/providers/empty-state/empty-state.component.tsx +41 -0
  79. package/src/providers/empty-state/empty-state.scss +55 -0
  80. package/src/providers/modals/config-upload.modal.tsx +170 -0
  81. package/src/providers/modals/config-upload.scss +33 -0
  82. package/src/providers/modals/remove-config.modal.tsx +69 -0
  83. package/src/providers/providers-dashboard.component.tsx +63 -0
  84. package/src/providers/providers-dashboard.scss +69 -0
  85. package/src/providers/providers-dashboard.test.tsx +63 -0
  86. package/src/providers/providers-overview/providers-action-menu.component.tsx +102 -0
  87. package/src/providers/providers-overview/providers-action-menu.scss +11 -0
  88. package/src/providers/providers-overview/providers-overview.component.tsx +306 -0
  89. package/src/providers/providers-overview/providers-overview.scss +114 -0
  90. package/src/providers/sms-logs-table/sms-logs-table.component.tsx +217 -0
  91. package/src/providers/sms-logs-table/sms-logs-table.scss +63 -0
  92. package/src/providers/sms-logs-table/sms-logs-table.test.tsx +103 -0
  93. package/src/providers/test-form/provider-config-test-form.scss +105 -0
  94. package/src/providers/test-form/provider-config-test-form.test.tsx +122 -0
  95. package/src/providers/test-form/provider-config-test-form.workspace.tsx +233 -0
  96. package/src/providers/utils/index.ts +57 -0
  97. package/src/root.scss +39 -0
  98. package/src/routes.json +44 -0
  99. package/src/sms-admin-card-link.component.tsx +24 -0
  100. package/src/types/index.ts +68 -0
  101. package/translations/am.json +4 -0
  102. package/translations/en.json +47 -0
  103. package/translations/es.json +4 -0
  104. package/translations/fr.json +4 -0
  105. package/translations/he.json +4 -0
  106. package/translations/km.json +4 -0
  107. package/tsconfig.json +5 -0
  108. package/webpack.config.js +1 -0
@@ -0,0 +1,347 @@
1
+ import classnames from 'classnames';
2
+ import {
3
+ Stack,
4
+ Column,
5
+ Form,
6
+ TextInput,
7
+ Row,
8
+ ButtonSet,
9
+ Button,
10
+ AccordionItem,
11
+ TextArea,
12
+ SelectSkeleton,
13
+ Select,
14
+ SelectItem,
15
+ NumberInput,
16
+ Checkbox,
17
+ InlineLoading,
18
+ Accordion,
19
+ } from '@carbon/react';
20
+ import React, { useEffect } from 'react';
21
+ import { useTranslation } from 'react-i18next';
22
+ import { ResponsiveWrapper, closeWorkspace, showSnackbar, useLayoutType } from '@openmrs/esm-framework';
23
+ import { useProviderConfigTemplates } from '../../hooks/useProviderConfigTemplates';
24
+ import { z } from 'zod';
25
+ import { zodResolver } from '@hookform/resolvers/zod';
26
+ import { Controller, useForm } from 'react-hook-form';
27
+ import { type Prop, type ProviderConfiguration } from '../../types';
28
+ import { saveConfig } from '../../api/providers.resource';
29
+ import { useProviderConfigurations } from '../../hooks/useProviderConfigurations';
30
+ import styles from './provider-config-form.scss';
31
+
32
+ interface AddProviderConfigProps {
33
+ providerName?: string;
34
+ templateName?: string;
35
+ maxRetries?: number;
36
+ splitHeader?: string;
37
+ splitFooter?: string;
38
+ excludeLastFooter?: boolean;
39
+ autoScript?: string;
40
+ props?: Array<Prop>;
41
+ }
42
+
43
+ const addProviderConfigFormSchema = z.object({
44
+ providerName: z.string(),
45
+ templateName: z.string(),
46
+ maxRetries: z.number(),
47
+ splitHeader: z.string(),
48
+ splitFooter: z.string(),
49
+ excludeLastFooter: z.boolean(),
50
+ autoScript: z.string().optional(),
51
+ dynamicFields: z.record(z.string(), z.string()),
52
+ });
53
+
54
+ type AddProviderConfigFormData = z.infer<typeof addProviderConfigFormSchema>;
55
+
56
+ const AddProviderConfigForm: React.FC<AddProviderConfigProps> = ({
57
+ providerName,
58
+ templateName,
59
+ excludeLastFooter,
60
+ splitFooter,
61
+ splitHeader,
62
+ maxRetries,
63
+ autoScript,
64
+ props,
65
+ }) => {
66
+ const { t } = useTranslation();
67
+ const isTablet = useLayoutType() === 'tablet';
68
+ const { mutateConfigs, providerConfigurations } = useProviderConfigurations();
69
+ const { templates, isLoadingTemplates } = useProviderConfigTemplates();
70
+
71
+ const {
72
+ handleSubmit,
73
+ control,
74
+ watch,
75
+ setValue,
76
+ formState: { isSubmitting, isDirty },
77
+ } = useForm<AddProviderConfigFormData>({
78
+ mode: 'all',
79
+ resolver: zodResolver(addProviderConfigFormSchema),
80
+ defaultValues: {
81
+ providerName: providerName ? providerName : undefined,
82
+ templateName: templateName ? templateName : undefined,
83
+ maxRetries: maxRetries ? maxRetries : 0,
84
+ splitHeader: splitHeader ? splitHeader : undefined,
85
+ splitFooter: splitFooter ? splitFooter : undefined,
86
+ excludeLastFooter: excludeLastFooter ? excludeLastFooter : false,
87
+ autoScript: autoScript ? autoScript : undefined,
88
+ },
89
+ });
90
+
91
+ const onSubmit = async (data: AddProviderConfigFormData) => {
92
+ if (!isDirty) {
93
+ return closeForm();
94
+ }
95
+ const props = Object.entries(data.dynamicFields).map(([key, value]) => ({ name: key, value: value }));
96
+ const { providerName, dynamicFields, ...rest } = data;
97
+ const payload = {
98
+ ...rest,
99
+ props,
100
+ name: providerName,
101
+ };
102
+
103
+ await saveConfig([...providerConfigurations, payload as ProviderConfiguration])
104
+ .then(() => {
105
+ showSnackbar({
106
+ title: t('configSaved', 'Configuration saved'),
107
+ kind: 'success',
108
+ });
109
+ mutateConfigs();
110
+ closeForm();
111
+ })
112
+ .catch(() =>
113
+ showSnackbar({
114
+ title: t('configNotSaved', 'Configuration not saved'),
115
+ kind: 'error',
116
+ }),
117
+ );
118
+ };
119
+
120
+ const selectedTemplate = templates?.[watch('templateName')] ?? null;
121
+
122
+ const closeForm = () => closeWorkspace('add-provider-config-form');
123
+
124
+ useEffect(() => {
125
+ if (props) {
126
+ props.forEach(({ name, value }) => {
127
+ setValue(`dynamicFields.${name}`, value);
128
+ });
129
+ }
130
+ }, [props, setValue]);
131
+
132
+ return (
133
+ <Form data-testid="add-provider-form" className={styles.form} onSubmit={handleSubmit(onSubmit)}>
134
+ <Stack gap={2} className={styles.formContainer}>
135
+ <Row className={styles.row}>
136
+ <Column sm={1}>
137
+ <span className={styles.columnLabel}>{t('providerName', ' Provider Name')}</span>
138
+ </Column>
139
+ <Column sm={3}>
140
+ <ResponsiveWrapper>
141
+ <Controller
142
+ name="providerName"
143
+ control={control}
144
+ render={({ field, fieldState: { error } }) => (
145
+ <TextInput
146
+ {...field}
147
+ data-testid="provider-name"
148
+ invalid={!!error?.message}
149
+ invalidText={error?.message}
150
+ labelText={t('nameOfProvider', 'Name of the provider')}
151
+ placeholder={t('namePlaceHolder', 'Enter provider name e.g Twilio')}
152
+ />
153
+ )}
154
+ />
155
+ </ResponsiveWrapper>
156
+ </Column>
157
+ </Row>
158
+ <Row className={styles.row}>
159
+ <Column sm={1}>
160
+ <span className={styles.columnLabel}>{t('template', 'Template')}</span>
161
+ </Column>
162
+ <Column sm={3}>
163
+ <ResponsiveWrapper>
164
+ {isLoadingTemplates ? (
165
+ <SelectSkeleton />
166
+ ) : (
167
+ <Controller
168
+ name="templateName"
169
+ control={control}
170
+ render={({ field, fieldState: { error } }) => (
171
+ <Select
172
+ {...field}
173
+ data-testid="select-template"
174
+ labelText={t('selectTemplate', 'Select a template')}
175
+ invalid={!!error?.message}
176
+ invalidText={error?.message}
177
+ >
178
+ <SelectItem value={''} text={t('chooseTemplate', 'Choose a template')} />
179
+ {Object.keys(templates).map((value) => (
180
+ <SelectItem value={value} text={value} />
181
+ ))}
182
+ </Select>
183
+ )}
184
+ />
185
+ )}
186
+ </ResponsiveWrapper>
187
+ </Column>
188
+ </Row>
189
+ <Row className={styles.row}>
190
+ <Column sm={1}>
191
+ <span className={styles.columnLabel}>{t('maximumRetries', 'Maximum retries')}</span>
192
+ </Column>
193
+ <Column sm={3}>
194
+ <ResponsiveWrapper>
195
+ <Controller
196
+ name="maxRetries"
197
+ control={control}
198
+ render={({ field, fieldState: { error } }) => (
199
+ <NumberInput
200
+ {...field}
201
+ data-testid="numberOfRetries"
202
+ invalid={!!error?.message}
203
+ invalidText={error?.message}
204
+ onChange={(_, { value }) => field.onChange(Number(value))}
205
+ labelText={t('numberOfRetries', 'Number of retries')}
206
+ />
207
+ )}
208
+ />
209
+ </ResponsiveWrapper>
210
+ </Column>
211
+ </Row>
212
+ <Row className={styles.row}>
213
+ <Column sm={1}>
214
+ <span className={styles.columnLabel}>{t('headerSplitMessage', 'Header split message')}</span>
215
+ </Column>
216
+ <Column sm={3}>
217
+ <ResponsiveWrapper>
218
+ <Controller
219
+ name="splitHeader"
220
+ control={control}
221
+ render={({ field, fieldState: { error } }) => (
222
+ <TextInput
223
+ data-testid="split-header"
224
+ invalid={!!error?.message}
225
+ invalidText={error?.message}
226
+ {...field}
227
+ labelText={t('headerSplitText', 'Header split text')}
228
+ />
229
+ )}
230
+ />
231
+ </ResponsiveWrapper>
232
+ </Column>
233
+ </Row>
234
+ <Row className={styles.row}>
235
+ <Column sm={1}>
236
+ <span className={styles.columnLabel}>{t('footerSplitMessage', 'Footer split message')}</span>
237
+ </Column>
238
+ <Column sm={3}>
239
+ <ResponsiveWrapper>
240
+ <Controller
241
+ name="splitFooter"
242
+ control={control}
243
+ render={({ field, fieldState: { error } }) => (
244
+ <TextInput
245
+ {...field}
246
+ data-testid="split-footer"
247
+ invalid={!!error?.message}
248
+ invalidText={error?.message}
249
+ labelText={t('footerSplitText', 'Footers split text')}
250
+ />
251
+ )}
252
+ />
253
+ </ResponsiveWrapper>
254
+ </Column>
255
+ </Row>
256
+ <Row className={styles.row}>
257
+ <Column sm={3}>
258
+ <ResponsiveWrapper>
259
+ <Controller
260
+ name="excludeLastFooter"
261
+ control={control}
262
+ render={({ field }) => (
263
+ <Checkbox
264
+ data-testid="exclude-last-footer"
265
+ id={field.name}
266
+ name={field.name}
267
+ checked={field.value}
268
+ onChange={(_, { checked }) => setValue('excludeLastFooter', checked)}
269
+ onBlur={field.onBlur}
270
+ className={styles.checkBox}
271
+ labelText={t('excludeLastFooter', 'Exclude footer from last split message')}
272
+ />
273
+ )}
274
+ />
275
+ </ResponsiveWrapper>
276
+ </Column>
277
+ </Row>
278
+ <Row className={styles.row}>
279
+ <Accordion>
280
+ <AccordionItem title={t('advanced', 'Advanced')}>
281
+ <Column sm={1}>
282
+ <span className={styles.columnLabel}>
283
+ {t('automaticResponseScript', 'Automatic response script (Optional)')}
284
+ </span>
285
+ </Column>
286
+ <Column sm={3}>
287
+ <ResponsiveWrapper>
288
+ <Controller
289
+ name="autoScript"
290
+ control={control}
291
+ render={({ field, fieldState: { error } }) => (
292
+ <TextArea
293
+ {...field}
294
+ data-testid="auto-script"
295
+ invalid={!!error?.message}
296
+ invalidText={error?.message}
297
+ labelText={t('writeYourScript', 'Write your script')}
298
+ placeholder={t('enterAutomaticScript', 'Enter your script')}
299
+ />
300
+ )}
301
+ />
302
+ </ResponsiveWrapper>
303
+ </Column>
304
+ </AccordionItem>
305
+ </Accordion>
306
+ </Row>
307
+ {(() => {
308
+ if (selectedTemplate) {
309
+ return selectedTemplate.configurables.map((dataField, index) => (
310
+ <Row className={styles.dynamicRow} key={index}>
311
+ <ResponsiveWrapper>
312
+ <Controller
313
+ name={`dynamicFields.${dataField}`}
314
+ control={control}
315
+ render={({ field, fieldState: { error } }) => (
316
+ <TextInput
317
+ {...field}
318
+ invalid={!!error?.message}
319
+ invalidText={error?.message}
320
+ labelText={dataField}
321
+ data-testid={dataField}
322
+ />
323
+ )}
324
+ />
325
+ </ResponsiveWrapper>
326
+ </Row>
327
+ ));
328
+ }
329
+ })()}
330
+ </Stack>
331
+ <ButtonSet className={classnames({ [styles.tablet]: isTablet, [styles.desktop]: !isTablet })}>
332
+ <Button className={styles.button} kind="secondary" onClick={closeForm}>
333
+ {t('discard', 'Discard')}
334
+ </Button>
335
+ <Button className={styles.button} kind="primary" disabled={isSubmitting} type="submit">
336
+ {isSubmitting ? (
337
+ <InlineLoading description={t('sending', 'Sending') + '...'} />
338
+ ) : (
339
+ <span>{t('saveAndClose', 'Save and close')}</span>
340
+ )}
341
+ </Button>
342
+ </ButtonSet>
343
+ </Form>
344
+ );
345
+ };
346
+
347
+ export default AddProviderConfigForm;
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+
3
+ export const EmptyDataIllustration = ({ width = '64', height = '64' }) => {
4
+ return (
5
+ <svg width={width} height={height} viewBox="0 0 64 64">
6
+ <title>Empty data illustration</title>
7
+ <g fill="none" fillRule="evenodd">
8
+ <path
9
+ d="M38.133 13.186H21.947c-.768.001-1.39.623-1.39 1.391V50.55l-.186.057-3.97 1.216a.743.743 0 01-.927-.493L3.664 12.751a.742.742 0 01.492-.926l6.118-1.874 17.738-5.43 6.119-1.873a.741.741 0 01.926.492L38.076 13l.057.186z"
10
+ fill="#F4F4F4"
11
+ />
12
+ <path
13
+ d="M41.664 13L38.026 1.117A1.576 1.576 0 0036.056.07l-8.601 2.633-17.737 5.43-8.603 2.634a1.578 1.578 0 00-1.046 1.97l12.436 40.616a1.58 1.58 0 001.969 1.046l5.897-1.805.185-.057v-.194l-.185.057-5.952 1.822a1.393 1.393 0 01-1.737-.923L.247 12.682a1.39 1.39 0 01.923-1.738L9.772 8.31 27.51 2.881 36.112.247a1.393 1.393 0 011.737.923L41.47 13l.057.186h.193l-.057-.185z"
14
+ fill="#8D8D8D"
15
+ />
16
+ <path
17
+ d="M11.378 11.855a.836.836 0 01-.798-.59L9.385 7.361a.835.835 0 01.554-1.042l16.318-4.996a.836.836 0 011.042.554l1.195 3.902a.836.836 0 01-.554 1.043l-16.318 4.995a.831.831 0 01-.244.037z"
18
+ fill="#C6C6C6"
19
+ />
20
+ <circle fill="#C6C6C6" cx={17.636} cy={2.314} r={1.855} />
21
+ <circle fill="#FFF" fillRule="nonzero" cx={17.636} cy={2.314} r={1.175} />
22
+ <path
23
+ d="M55.893 53.995H24.544a.79.79 0 01-.788-.789V15.644a.79.79 0 01.788-.788h31.349a.79.79 0 01.788.788v37.562a.79.79 0 01-.788.789z"
24
+ fill="#F4F4F4"
25
+ />
26
+ <path
27
+ d="M41.47 13H21.948a1.579 1.579 0 00-1.576 1.577V52.4l.185-.057V14.577c.001-.768.623-1.39 1.391-1.39h19.581L41.471 13zm17.02 0H21.947a1.579 1.579 0 00-1.576 1.577v42.478c0 .87.706 1.576 1.576 1.577H58.49a1.579 1.579 0 001.576-1.577V14.577a1.579 1.579 0 00-1.576-1.576zm1.39 44.055c0 .768-.622 1.39-1.39 1.392H21.947c-.768-.001-1.39-.624-1.39-1.392V14.577c0-.768.622-1.39 1.39-1.39H58.49c.768 0 1.39.622 1.39 1.39v42.478z"
28
+ fill="#8D8D8D"
29
+ />
30
+ <path
31
+ d="M48.751 17.082H31.686a.836.836 0 01-.835-.835v-4.081c0-.46.374-.834.835-.835H48.75c.461 0 .834.374.835.835v4.08c0 .462-.374.835-.835.836z"
32
+ fill="#C6C6C6"
33
+ />
34
+ <circle fill="#C6C6C6" cx={40.218} cy={9.755} r={1.855} />
35
+ <circle fill="#FFF" fillRule="nonzero" cx={40.218} cy={9.755} r={1.13} />
36
+ </g>
37
+ </svg>
38
+ );
39
+ };
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ import { Layer, Link, Tile } from '@carbon/react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { EmptyDataIllustration } from './empty-data-illustration.component';
5
+ import { useLayoutType } from '@openmrs/esm-framework';
6
+ import styles from './empty-state.scss';
7
+ import { Button } from '@carbon/react';
8
+
9
+ export interface EmptyStateProps {
10
+ displayText: string;
11
+ headerTitle: string;
12
+ launchForm?(): void;
13
+ }
14
+
15
+ export const EmptyState: React.FC<EmptyStateProps> = (props) => {
16
+ const { t } = useTranslation();
17
+ const isTablet = useLayoutType() === 'tablet';
18
+
19
+ return (
20
+ <Layer>
21
+ <Tile className={styles.tile}>
22
+ <div className={isTablet ? styles.tabletHeading : styles.desktopHeading}>
23
+ <h4>{props.headerTitle}</h4>
24
+ </div>
25
+ <EmptyDataIllustration />
26
+ <p className={styles.content}>
27
+ {t('emptyTableStateText', 'There are no {{displayText}} to display', {
28
+ displayText: props.displayText.toLowerCase(),
29
+ })}
30
+ </p>
31
+ <p className={styles.action}>
32
+ {props.launchForm && (
33
+ <Button onClick={() => props.launchForm()} kind="ghost" size={isTablet ? 'lg' : 'sm'}>
34
+ {t('record', 'Record')} {props.displayText.toLowerCase()}
35
+ </Button>
36
+ )}
37
+ </p>
38
+ </Tile>
39
+ </Layer>
40
+ );
41
+ };
@@ -0,0 +1,55 @@
1
+ @use '@carbon/styles/scss/spacing';
2
+ @use '@carbon/styles/scss/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .action {
6
+ margin-bottom: spacing.$spacing-03;
7
+ }
8
+
9
+ .content {
10
+ @include type.type-style("heading-compact-01");
11
+ color: $text-02;
12
+ margin-top: spacing.$spacing-05;
13
+ margin-bottom: spacing.$spacing-03;
14
+ }
15
+
16
+ .desktopHeading {
17
+ h4 {
18
+ @include type.type-style('heading-compact-02');
19
+ color: $text-02;
20
+ }
21
+ }
22
+
23
+ .tabletHeading {
24
+ h4 {
25
+ @include type.type-style('heading-03');
26
+ color: $text-02;
27
+ }
28
+ }
29
+
30
+ .desktopHeading, .tabletHeading {
31
+ text-align: left;
32
+ text-transform: capitalize;
33
+ margin-bottom: spacing.$spacing-05;
34
+
35
+ h4:after {
36
+ content: "";
37
+ display: block;
38
+ width: 2rem;
39
+ padding-top: 0.188rem;
40
+ border-bottom: 0.375rem solid var(--brand-03);
41
+ }
42
+ }
43
+
44
+ .heading:after {
45
+ content: "";
46
+ display: block;
47
+ width: 2rem;
48
+ padding-top: 0.188rem;
49
+ border-bottom: 0.375rem solid var(--brand-03);
50
+ }
51
+
52
+ .tile {
53
+ text-align: center;
54
+ border: 1px solid $ui-03;
55
+ }
@@ -0,0 +1,170 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ FileUploaderDropContainer,
4
+ InlineNotification,
5
+ ModalHeader,
6
+ ModalBody,
7
+ ModalFooter,
8
+ Button,
9
+ Form,
10
+ Stack,
11
+ TextArea,
12
+ } from '@carbon/react';
13
+ import { useTranslation } from 'react-i18next';
14
+ import { DocumentUnknown } from '@carbon/react/icons';
15
+ import { uploadConfigTemplate } from '../../api/providers.resource';
16
+ import { showSnackbar } from '@openmrs/esm-framework';
17
+ import styles from './config-upload.scss';
18
+
19
+ interface ErrorNotification {
20
+ title: string;
21
+ subtitle: string;
22
+ }
23
+
24
+ interface ConfigUploadModalProps {
25
+ closeModal: () => {};
26
+ mutateTemplates: () => void;
27
+ }
28
+
29
+ const ConfigUploadModal: React.FC<ConfigUploadModalProps> = ({ closeModal, mutateTemplates }) => {
30
+ const { t } = useTranslation();
31
+
32
+ // Maximm config file size. Hard coding since we don't expect the file size to go beyond
33
+ // 1MB. This value should be updated to match the actual maximum file size once needed in the future.
34
+ const maxFileSize = 1;
35
+
36
+ const [errorNotification, setErrorNotification] = useState<ErrorNotification>(null);
37
+ const [fileToUpload, setFileToUpload] = useState<File>(null);
38
+
39
+ const [fileContent, setFileContent] = useState<string | null>(null);
40
+
41
+ const upload = useCallback(
42
+ (files: Array<File>) => {
43
+ const config = files[0];
44
+
45
+ if (config.size > maxFileSize * 1024 * 1024) {
46
+ return setErrorNotification({
47
+ title: t('fileSizeLimitExceededText', 'File size limit exceeded'),
48
+ subtitle: `The file "${config.name}" ${t(
49
+ 'fileSizeLimitExceeded',
50
+ 'exceeds the size limit of',
51
+ )} ${maxFileSize} MB.`,
52
+ });
53
+ } else {
54
+ setFileToUpload(config);
55
+ }
56
+ },
57
+ [maxFileSize, t],
58
+ );
59
+ const onCancel = useCallback(() => {
60
+ setFileToUpload(null);
61
+ setFileContent(null);
62
+ closeModal();
63
+ }, [closeModal]);
64
+
65
+ useEffect(() => {
66
+ const reader = new FileReader();
67
+ reader.onload = (event) => {
68
+ const result = event.target?.result as string;
69
+ try {
70
+ const json = JSON.parse(result);
71
+ setFileContent(JSON.stringify(json, null, 2));
72
+ } catch (e) {
73
+ setFileContent('Invalid JSON file');
74
+ }
75
+ };
76
+ if (fileToUpload) {
77
+ reader.readAsText(fileToUpload);
78
+ }
79
+ }, [fileToUpload]);
80
+
81
+ const handleSubmit = useCallback(
82
+ (e) => {
83
+ e.preventDefault();
84
+ if (fileToUpload) {
85
+ return uploadConfigTemplate(fileToUpload)
86
+ .then(() => {
87
+ mutateTemplates?.();
88
+ showSnackbar({
89
+ title: t('uploadSuccess', 'Upload successful'),
90
+ kind: 'success',
91
+ }),
92
+ closeModal();
93
+ })
94
+ .catch(() =>
95
+ showSnackbar({
96
+ title: t('uploadNotSuccess', 'Upload unsuccessful'),
97
+ kind: 'error',
98
+ }),
99
+ );
100
+ }
101
+ },
102
+ [closeModal, fileToUpload, mutateTemplates, t],
103
+ );
104
+
105
+ return (
106
+ <Form onSubmit={handleSubmit}>
107
+ <ModalHeader closeModal={onCancel} title={t('uploadConfig', 'Upload configuration template')} />
108
+ <ModalBody className={styles.modalBody}>
109
+ {fileToUpload ? (
110
+ <>
111
+ <div className={styles.filePlaceholder}>
112
+ <DocumentUnknown size={16} />
113
+ </div>
114
+ <div className={styles.imageDetails}>
115
+ <Stack gap={5}>
116
+ <div className="cds--label">{t('fileName', 'File name')}</div>
117
+ <h6>{fileToUpload.name}</h6>
118
+ {fileContent && (
119
+ <div className={styles.jsonPreviesw}>
120
+ <TextArea labelText={t('fileContent', 'File Content')} value={fileContent} readOnly rows={10} />
121
+ </div>
122
+ )}
123
+ </Stack>
124
+ </div>
125
+ </>
126
+ ) : (
127
+ <div className="cds--file__container">
128
+ {errorNotification && (
129
+ <div className={styles.errorContainer}>
130
+ <InlineNotification
131
+ aria-label="Upload error notification"
132
+ kind="error"
133
+ onClose={() => setErrorNotification(null)}
134
+ subtitle={errorNotification.subtitle}
135
+ title={errorNotification.title}
136
+ />
137
+ </div>
138
+ )}
139
+ <p className="cds--label-description">
140
+ {t('fileUploadSizeConstraints', 'Size limit is {{fileSize}}MB', {
141
+ fileSize: maxFileSize,
142
+ })}
143
+ .
144
+ </p>
145
+ <div className={styles.uploadFile}>
146
+ <FileUploaderDropContainer
147
+ accept={['.json']}
148
+ labelText={t('fileSizeInstructions', 'Drag and drop files here or click to upload')}
149
+ tabIndex={0}
150
+ onAddFiles={(evt, { addedFiles }) => {
151
+ upload(addedFiles);
152
+ }}
153
+ />
154
+ </div>
155
+ </div>
156
+ )}
157
+ </ModalBody>
158
+ <ModalFooter>
159
+ <Button kind="secondary" size="lg" onClick={onCancel}>
160
+ {t('cancel', 'Cancel')}
161
+ </Button>
162
+ <Button type="submit" size="lg" disabled={!fileToUpload}>
163
+ {t('uploadConfig', 'Upload config')}
164
+ </Button>
165
+ </ModalFooter>
166
+ </Form>
167
+ );
168
+ };
169
+
170
+ export default ConfigUploadModal;
@@ -0,0 +1,33 @@
1
+ @use '@carbon/styles/scss/spacing';
2
+ @use '@carbon/styles/scss/type';
3
+ @use '@openmrs/esm-styleguide/src/vars' as *;
4
+
5
+ .errorContainer {
6
+ margin: spacing.$spacing-05 0;
7
+ }
8
+
9
+ .uploadFile {
10
+ color: $ui-02;
11
+ width: 100%;
12
+ height: 25rem;
13
+
14
+ div > button {
15
+ max-width: 100%;
16
+ display: flex;
17
+ flex-flow: column;
18
+ justify-content: center;
19
+ align-items: center;
20
+ height: 25rem;
21
+ position: relative;
22
+ }
23
+
24
+ label {
25
+ max-width: unset;
26
+ }
27
+
28
+ label > div {
29
+ height: 25rem;
30
+ justify-content: center;
31
+ align-items: center;
32
+ }
33
+ }