@kenyaemr/esm-admin-app 5.4.4-pre.96 → 5.4.5-pre.16

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 (285) hide show
  1. package/package.json +3 -3
  2. package/src/components/confirm-modal/confirmation-operation.test.tsx +8 -19
  3. package/src/components/dashboard/dashboard.component.tsx +4 -1
  4. package/src/components/empty-state/empty-state-log.test.tsx +3 -4
  5. package/src/components/facility-setup/constant/index.ts +3 -0
  6. package/src/components/facility-setup/facility-info.component.tsx +177 -107
  7. package/src/components/facility-setup/facility-info.scss +39 -1
  8. package/src/components/facility-setup/facility-setup.component.tsx +2 -2
  9. package/src/components/facility-setup/type/index.ts +61 -0
  10. package/src/components/facility-setup/useFacilityRegistry.ts +29 -0
  11. package/src/components/global-property/dashboard/global-property-dashboard.component.tsx +23 -0
  12. package/src/components/global-property/dashboard/global-property-dashboard.scss +6 -0
  13. package/src/components/global-property/hooks/useGlobalProperty.ts +64 -0
  14. package/src/components/global-property/hooks/useLogoUpload.ts +28 -0
  15. package/src/components/global-property/index.ts +14 -0
  16. package/src/components/global-property/modal/delete-global-property-modal.component.tsx +71 -0
  17. package/src/components/global-property/modal/delete-global-property-modal.test.tsx +131 -0
  18. package/src/components/global-property/table/global-property-table.component.tsx +241 -0
  19. package/src/components/global-property/table/global-property-table.scss +44 -0
  20. package/src/components/global-property/table/global-property-table.test.tsx +202 -0
  21. package/src/components/global-property/workspace/global-property-form-schema.ts +32 -0
  22. package/src/components/global-property/workspace/global-property.workspace.scss +40 -0
  23. package/src/components/global-property/workspace/global-property.workspace.test.tsx +172 -0
  24. package/src/components/global-property/workspace/global-property.workspace.tsx +260 -0
  25. package/src/components/global-property/workspace/upload-logo.workspace.scss +61 -0
  26. package/src/components/global-property/workspace/upload-logo.workspace.test.tsx +180 -0
  27. package/src/components/global-property/workspace/upload-logo.workspace.tsx +191 -0
  28. package/src/components/hook/healthWorkerRegistry.ts +78 -0
  29. package/src/components/hook/useProfessionalRegistryEnums.ts +59 -0
  30. package/src/components/logs-table/operation-log-table.component.tsx +87 -75
  31. package/src/components/logs-table/operation-log.test.tsx +134 -28
  32. package/src/components/modal/hwr-confirmation.modal.scss +80 -4
  33. package/src/components/modal/hwr-confirmation.modal.tsx +118 -128
  34. package/src/components/modal/hwr-sync.modal.tsx +194 -106
  35. package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-workspace/user-role-scope.workspace.tsx +1 -1
  36. package/src/components/users/manage-users/user-details/user-detail.scss +167 -39
  37. package/src/components/users/manage-users/user-details/user-details.component.tsx +130 -122
  38. package/src/components/users/manage-users/user-management.workspace.scss +233 -95
  39. package/src/components/users/manage-users/user-management.workspace.tsx +481 -375
  40. package/src/components/users/userManagementFormSchema.tsx +17 -8
  41. package/src/config-schema.ts +48 -68
  42. package/src/index.ts +21 -7
  43. package/src/left-pannel-link.component.tsx +5 -3
  44. package/src/root.component.tsx +2 -0
  45. package/src/routes.json +25 -1
  46. package/src/types/index.ts +11 -2
  47. package/translations/am.json +192 -10
  48. package/translations/en.json +205 -23
  49. package/translations/fr.json +260 -58
  50. package/translations/sw.json +329 -147
  51. package/.turbo/turbo-build.log +0 -6
  52. package/dist/1074.js +0 -1
  53. package/dist/1074.js.map +0 -1
  54. package/dist/12.js +0 -17
  55. package/dist/12.js.map +0 -1
  56. package/dist/1201.js +0 -1
  57. package/dist/1201.js.map +0 -1
  58. package/dist/1242.js +0 -1
  59. package/dist/1242.js.map +0 -1
  60. package/dist/1311.js +0 -1
  61. package/dist/1311.js.map +0 -1
  62. package/dist/1462.js +0 -1
  63. package/dist/1462.js.map +0 -1
  64. package/dist/1469.js +0 -1
  65. package/dist/1469.js.map +0 -1
  66. package/dist/1506.js +0 -13
  67. package/dist/1506.js.map +0 -1
  68. package/dist/1718.js +0 -1
  69. package/dist/1718.js.map +0 -1
  70. package/dist/1722.js +0 -1
  71. package/dist/1722.js.map +0 -1
  72. package/dist/1772.js +0 -1
  73. package/dist/1772.js.map +0 -1
  74. package/dist/1889.js +0 -1
  75. package/dist/1889.js.map +0 -1
  76. package/dist/1972.js +0 -1
  77. package/dist/1972.js.map +0 -1
  78. package/dist/1990.js +0 -1
  79. package/dist/1990.js.map +0 -1
  80. package/dist/2016.js +0 -1
  81. package/dist/2016.js.map +0 -1
  82. package/dist/2080.js +0 -1
  83. package/dist/2080.js.map +0 -1
  84. package/dist/2096.js +0 -1
  85. package/dist/2096.js.map +0 -1
  86. package/dist/2153.js +0 -1
  87. package/dist/2153.js.map +0 -1
  88. package/dist/216.js +0 -1
  89. package/dist/216.js.map +0 -1
  90. package/dist/2270.js +0 -1
  91. package/dist/2270.js.map +0 -1
  92. package/dist/2294.js +0 -1
  93. package/dist/2294.js.map +0 -1
  94. package/dist/2345.js +0 -1
  95. package/dist/2345.js.map +0 -1
  96. package/dist/2402.js +0 -1
  97. package/dist/2402.js.map +0 -1
  98. package/dist/2500.js +0 -1
  99. package/dist/2500.js.map +0 -1
  100. package/dist/251.js +0 -1
  101. package/dist/251.js.map +0 -1
  102. package/dist/257.js +0 -1
  103. package/dist/257.js.map +0 -1
  104. package/dist/2586.js +0 -1
  105. package/dist/2586.js.map +0 -1
  106. package/dist/2625.js +0 -1
  107. package/dist/2625.js.map +0 -1
  108. package/dist/2652.js +0 -1
  109. package/dist/2652.js.map +0 -1
  110. package/dist/2685.js +0 -1
  111. package/dist/2685.js.map +0 -1
  112. package/dist/2948.js +0 -1
  113. package/dist/2948.js.map +0 -1
  114. package/dist/3089.js +0 -1
  115. package/dist/3089.js.map +0 -1
  116. package/dist/3190.js +0 -1
  117. package/dist/3190.js.map +0 -1
  118. package/dist/3224.js +0 -1
  119. package/dist/3224.js.map +0 -1
  120. package/dist/3366.js +0 -1
  121. package/dist/3366.js.map +0 -1
  122. package/dist/349.js +0 -1
  123. package/dist/349.js.map +0 -1
  124. package/dist/3548.js +0 -1
  125. package/dist/3548.js.map +0 -1
  126. package/dist/3571.js +0 -1
  127. package/dist/3571.js.map +0 -1
  128. package/dist/3691.js +0 -1
  129. package/dist/3691.js.map +0 -1
  130. package/dist/3775.js +0 -1
  131. package/dist/3775.js.map +0 -1
  132. package/dist/3816.js +0 -1
  133. package/dist/3816.js.map +0 -1
  134. package/dist/3852.js +0 -1
  135. package/dist/3852.js.map +0 -1
  136. package/dist/3906.js +0 -1
  137. package/dist/3906.js.map +0 -1
  138. package/dist/3963.js +0 -1
  139. package/dist/3963.js.map +0 -1
  140. package/dist/405.js +0 -1
  141. package/dist/405.js.map +0 -1
  142. package/dist/4296.js +0 -1
  143. package/dist/4296.js.map +0 -1
  144. package/dist/4337.js +0 -1
  145. package/dist/4337.js.map +0 -1
  146. package/dist/4735.js +0 -1
  147. package/dist/4735.js.map +0 -1
  148. package/dist/4744.js +0 -1
  149. package/dist/4744.js.map +0 -1
  150. package/dist/4813.js +0 -2
  151. package/dist/4813.js.map +0 -1
  152. package/dist/4858.js +0 -1
  153. package/dist/4858.js.map +0 -1
  154. package/dist/487.js +0 -1
  155. package/dist/487.js.map +0 -1
  156. package/dist/4970.js +0 -1
  157. package/dist/4970.js.map +0 -1
  158. package/dist/5202.js +0 -1
  159. package/dist/5202.js.map +0 -1
  160. package/dist/5294.js +0 -1
  161. package/dist/5294.js.map +0 -1
  162. package/dist/545.js +0 -1
  163. package/dist/545.js.map +0 -1
  164. package/dist/552.js +0 -1
  165. package/dist/552.js.map +0 -1
  166. package/dist/5592.js +0 -1
  167. package/dist/5592.js.map +0 -1
  168. package/dist/5669.js +0 -1
  169. package/dist/5669.js.map +0 -1
  170. package/dist/5884.js +0 -1
  171. package/dist/5884.js.map +0 -1
  172. package/dist/5940.js +0 -1
  173. package/dist/5940.js.map +0 -1
  174. package/dist/6092.js +0 -1
  175. package/dist/6092.js.map +0 -1
  176. package/dist/6155.js +0 -1
  177. package/dist/6155.js.map +0 -1
  178. package/dist/6178.js +0 -1
  179. package/dist/6178.js.map +0 -1
  180. package/dist/6399.js +0 -1
  181. package/dist/6399.js.map +0 -1
  182. package/dist/6456.js +0 -1
  183. package/dist/6466.js +0 -3
  184. package/dist/6466.js.map +0 -1
  185. package/dist/6492.js +0 -1
  186. package/dist/6492.js.map +0 -1
  187. package/dist/6676.js +0 -1
  188. package/dist/6676.js.map +0 -1
  189. package/dist/6800.js +0 -1
  190. package/dist/6800.js.map +0 -1
  191. package/dist/6976.js +0 -1
  192. package/dist/6976.js.map +0 -1
  193. package/dist/7005.js +0 -1
  194. package/dist/7005.js.map +0 -1
  195. package/dist/7201.js +0 -1
  196. package/dist/7201.js.map +0 -1
  197. package/dist/7210.js +0 -1
  198. package/dist/7210.js.map +0 -1
  199. package/dist/7234.js +0 -1
  200. package/dist/7234.js.map +0 -1
  201. package/dist/7261.js +0 -1
  202. package/dist/7261.js.map +0 -1
  203. package/dist/7326.js +0 -1
  204. package/dist/7463.js +0 -1
  205. package/dist/7463.js.map +0 -1
  206. package/dist/7528.js +0 -1
  207. package/dist/7528.js.map +0 -1
  208. package/dist/7584.js +0 -1
  209. package/dist/7584.js.map +0 -1
  210. package/dist/7607.js +0 -1
  211. package/dist/7717.js +0 -1
  212. package/dist/7717.js.map +0 -1
  213. package/dist/7737.js +0 -1
  214. package/dist/7737.js.map +0 -1
  215. package/dist/7739.js +0 -1
  216. package/dist/7739.js.map +0 -1
  217. package/dist/7765.js +0 -1
  218. package/dist/7765.js.map +0 -1
  219. package/dist/7820.js +0 -1
  220. package/dist/7820.js.map +0 -1
  221. package/dist/7844.js +0 -1
  222. package/dist/7844.js.map +0 -1
  223. package/dist/7866.js +0 -1
  224. package/dist/7866.js.map +0 -1
  225. package/dist/7916.js +0 -1
  226. package/dist/7916.js.map +0 -1
  227. package/dist/7971.js +0 -1
  228. package/dist/7971.js.map +0 -1
  229. package/dist/8159.js +0 -7
  230. package/dist/8159.js.map +0 -1
  231. package/dist/8244.js +0 -1
  232. package/dist/8244.js.map +0 -1
  233. package/dist/8262.js +0 -1
  234. package/dist/8262.js.map +0 -1
  235. package/dist/834.js +0 -1
  236. package/dist/834.js.map +0 -1
  237. package/dist/8376.js +0 -1
  238. package/dist/8376.js.map +0 -1
  239. package/dist/845.js +0 -1
  240. package/dist/845.js.map +0 -1
  241. package/dist/8546.js +0 -1
  242. package/dist/8546.js.map +0 -1
  243. package/dist/8570.js +0 -1
  244. package/dist/8570.js.map +0 -1
  245. package/dist/87.js +0 -1
  246. package/dist/87.js.map +0 -1
  247. package/dist/8727.js +0 -1
  248. package/dist/8828.js +0 -1
  249. package/dist/8828.js.map +0 -1
  250. package/dist/8860.js +0 -1
  251. package/dist/8860.js.map +0 -1
  252. package/dist/9036.js +0 -1
  253. package/dist/9036.js.map +0 -1
  254. package/dist/9124.js +0 -1
  255. package/dist/9124.js.map +0 -1
  256. package/dist/9182.js +0 -1
  257. package/dist/921.js +0 -1
  258. package/dist/921.js.map +0 -1
  259. package/dist/9404.js +0 -1
  260. package/dist/9404.js.map +0 -1
  261. package/dist/9406.js +0 -1
  262. package/dist/9406.js.map +0 -1
  263. package/dist/9446.js +0 -1
  264. package/dist/9446.js.map +0 -1
  265. package/dist/9449.js +0 -1
  266. package/dist/9449.js.map +0 -1
  267. package/dist/9566.js +0 -5
  268. package/dist/9566.js.map +0 -1
  269. package/dist/9641.js +0 -1
  270. package/dist/9641.js.map +0 -1
  271. package/dist/9711.js +0 -1
  272. package/dist/9711.js.map +0 -1
  273. package/dist/9801.js +0 -1
  274. package/dist/9801.js.map +0 -1
  275. package/dist/9835.js +0 -11
  276. package/dist/9835.js.map +0 -1
  277. package/dist/kenyaemr-esm-admin-app.js +0 -5
  278. package/dist/kenyaemr-esm-admin-app.js.buildmanifest.json +0 -3173
  279. package/dist/kenyaemr-esm-admin-app.js.map +0 -1
  280. package/dist/main.js +0 -5
  281. package/dist/main.js.map +0 -1
  282. package/dist/routes.json +0 -1
  283. package/src/components/facility-setup/facility-setup.resource.tsx +0 -7
  284. package/src/components/hook/healthWorkerAdapter.ts +0 -213
  285. package/src/components/hook/useFacilityInfo.tsx +0 -37
@@ -0,0 +1,260 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import {
3
+ ResponsiveWrapper,
4
+ Workspace2,
5
+ showSnackbar,
6
+ useLayoutType,
7
+ type Workspace2DefinitionProps,
8
+ } from '@openmrs/esm-framework';
9
+ import { useTranslation } from 'react-i18next';
10
+ import { Controller, useForm } from 'react-hook-form';
11
+ import { Button, ButtonSet, ComboBox, Form, FormGroup, InlineLoading, Stack, TextArea, TextInput } from '@carbon/react';
12
+ import { zodResolver } from '@hookform/resolvers/zod';
13
+ import classNames from 'classnames';
14
+
15
+ import styles from './global-property.workspace.scss';
16
+ import { type SystemSettings } from '../../../types';
17
+ import { saveOrUpdateGlobalProperty } from '../hooks/useGlobalProperty';
18
+ import {
19
+ createGlobalPropertyFormSchema,
20
+ openmrsCustomDatatypes,
21
+ type GlobalPropertyFormType,
22
+ } from './global-property-form-schema';
23
+
24
+ type GlobalPropertyWorkspaceProps = {
25
+ systemSetting?: SystemSettings | null;
26
+ mutateGlobalProperty: () => void;
27
+ };
28
+
29
+ const GlobalPropertyWorkspace: React.FC<Workspace2DefinitionProps<GlobalPropertyWorkspaceProps, {}, {}>> = ({
30
+ closeWorkspace,
31
+ workspaceProps: { systemSetting, mutateGlobalProperty },
32
+ }) => {
33
+ const { t } = useTranslation();
34
+ const isTablet = useLayoutType() === 'tablet';
35
+ const isEditMode = Boolean(systemSetting?.uuid);
36
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
37
+
38
+ const {
39
+ handleSubmit,
40
+ control,
41
+ formState: { isSubmitting, isDirty, errors },
42
+ } = useForm<GlobalPropertyFormType>({
43
+ resolver: zodResolver(createGlobalPropertyFormSchema(t)),
44
+ defaultValues: {
45
+ property: systemSetting?.property ?? '',
46
+ description: systemSetting?.description ?? '',
47
+ datatypeClassname: systemSetting?.datatypeClassname ?? '',
48
+ datatypeConfig: systemSetting?.datatypeConfig ?? '',
49
+ preferredHandlerClassname: systemSetting?.preferredHandlerClassname ?? '',
50
+ handlerConfig: systemSetting?.handlerConfig ?? '',
51
+ value: systemSetting?.value ?? '',
52
+ },
53
+ });
54
+
55
+ useEffect(() => {
56
+ setHasUnsavedChanges(isDirty);
57
+ }, [isDirty]);
58
+
59
+ const onSubmit = async (data: GlobalPropertyFormType) => {
60
+ const payload = {
61
+ property: data.property,
62
+ value: data.value,
63
+ description: data.description,
64
+ datatypeClassname: data.datatypeClassname,
65
+ datatypeConfig: data.datatypeConfig,
66
+ preferredHandlerClassname: data.preferredHandlerClassname,
67
+ handlerConfig: data.handlerConfig,
68
+ };
69
+ try {
70
+ await saveOrUpdateGlobalProperty(payload, systemSetting?.uuid);
71
+
72
+ showSnackbar({
73
+ title: t('success', 'Success'),
74
+ kind: 'success',
75
+ subtitle: isEditMode
76
+ ? t('gpUpdated', 'Global property {{property}} was updated successfully.', { property: data.property })
77
+ : t('gpCreated', 'Global property {{property}} was created successfully.', { property: data.property }),
78
+ });
79
+ mutateGlobalProperty();
80
+ closeWorkspace({ discardUnsavedChanges: true });
81
+ } catch (error: any) {
82
+ showSnackbar({
83
+ title: t('error', 'Error'),
84
+ kind: 'error',
85
+ subtitle: error?.message ?? t('gpSaveError', 'Error saving global property'),
86
+ });
87
+ }
88
+ };
89
+
90
+ const title = isEditMode
91
+ ? t('editGlobalProperty', 'Edit global property')
92
+ : t('addGlobalProperty', 'Add global property');
93
+
94
+ return (
95
+ <Workspace2 title={title} hasUnsavedChanges={hasUnsavedChanges}>
96
+ <Form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
97
+ <div className={styles.formContainer}>
98
+ <Stack gap={4}>
99
+ <ResponsiveWrapper>
100
+ <FormGroup legendText="">
101
+ <Controller
102
+ control={control}
103
+ name="property"
104
+ render={({ field }) => (
105
+ <TextInput
106
+ id="gpProperty"
107
+ labelText={t('property', 'Property (required)')}
108
+ placeholder={t('gpPropertyPlaceholder', 'e.g. setting.name')}
109
+ value={field.value}
110
+ onChange={field.onChange}
111
+ invalid={!!errors.property}
112
+ invalidText={errors.property?.message}
113
+ disabled={isEditMode}
114
+ />
115
+ )}
116
+ />
117
+ </FormGroup>
118
+ </ResponsiveWrapper>
119
+
120
+ <ResponsiveWrapper>
121
+ <FormGroup legendText="">
122
+ <Controller
123
+ control={control}
124
+ name="value"
125
+ render={({ field }) => (
126
+ <TextArea
127
+ id="gpValue"
128
+ labelText={t('value', 'Value (required)')}
129
+ placeholder={t('gpValuePlaceholder', 'Enter value')}
130
+ value={field.value}
131
+ onChange={field.onChange}
132
+ invalid={!!errors.value}
133
+ invalidText={errors.value?.message}
134
+ rows={3}
135
+ />
136
+ )}
137
+ />
138
+ </FormGroup>
139
+ </ResponsiveWrapper>
140
+
141
+ <ResponsiveWrapper>
142
+ <FormGroup legendText="">
143
+ <Controller
144
+ control={control}
145
+ name="description"
146
+ render={({ field }) => (
147
+ <TextArea
148
+ id="gpDescription"
149
+ labelText={t('description', 'Description')}
150
+ placeholder={t('gpDescriptionPlaceholder', 'Optional description')}
151
+ value={field.value ?? ''}
152
+ onChange={field.onChange}
153
+ rows={2}
154
+ />
155
+ )}
156
+ />
157
+ </FormGroup>
158
+ </ResponsiveWrapper>
159
+
160
+ <ResponsiveWrapper>
161
+ <FormGroup legendText="">
162
+ <Controller
163
+ control={control}
164
+ name="datatypeClassname"
165
+ render={({ field }) => (
166
+ <ComboBox
167
+ id="datatypeClassname"
168
+ invalidText={errors.datatypeClassname?.message}
169
+ items={openmrsCustomDatatypes}
170
+ onChange={function Zye() {}}
171
+ titleText={t('datatypeClassname', 'Datatype classname')}
172
+ typeahead
173
+ placeholder={t('selectDatatypeClassname', 'Select datatype classname')}
174
+ />
175
+ )}
176
+ />
177
+ </FormGroup>
178
+ </ResponsiveWrapper>
179
+
180
+ <ResponsiveWrapper>
181
+ <FormGroup legendText="">
182
+ <Controller
183
+ control={control}
184
+ name="datatypeConfig"
185
+ render={({ field }) => (
186
+ <TextInput
187
+ id="gpDatatypeConfig"
188
+ labelText={t('datatypeConfig', 'Datatype config')}
189
+ placeholder={t('gpDatatypeConfigPlaceholder', 'Optional datatype configuration')}
190
+ value={field.value ?? ''}
191
+ onChange={field.onChange}
192
+ />
193
+ )}
194
+ />
195
+ </FormGroup>
196
+ </ResponsiveWrapper>
197
+
198
+ <ResponsiveWrapper>
199
+ <FormGroup legendText="">
200
+ <Controller
201
+ control={control}
202
+ name="preferredHandlerClassname"
203
+ render={({ field }) => (
204
+ <TextInput
205
+ id="gpPreferredHandlerClassname"
206
+ labelText={t('preferredHandlerClassname', 'Preferred handler classname')}
207
+ placeholder={t('gpPreferredHandlerPlaceholder', 'Optional preferred handler classname')}
208
+ value={field.value ?? ''}
209
+ onChange={field.onChange}
210
+ />
211
+ )}
212
+ />
213
+ </FormGroup>
214
+ </ResponsiveWrapper>
215
+
216
+ <ResponsiveWrapper>
217
+ <FormGroup legendText="">
218
+ <Controller
219
+ control={control}
220
+ name="handlerConfig"
221
+ render={({ field }) => (
222
+ <TextInput
223
+ id="gpHandlerConfig"
224
+ labelText={t('handlerConfig', 'Handler config')}
225
+ placeholder={t('gpHandlerConfigPlaceholder', 'Optional handler configuration')}
226
+ value={field.value ?? ''}
227
+ onChange={field.onChange}
228
+ />
229
+ )}
230
+ />
231
+ </FormGroup>
232
+ </ResponsiveWrapper>
233
+ </Stack>
234
+ </div>
235
+
236
+ <ButtonSet
237
+ className={classNames({
238
+ [styles.tablet]: isTablet,
239
+ [styles.desktop]: !isTablet,
240
+ })}>
241
+ <Button className={styles.buttonContainer} kind="secondary" onClick={() => closeWorkspace()}>
242
+ {t('cancel', 'Cancel')}
243
+ </Button>
244
+ <Button className={styles.buttonContainer} disabled={isSubmitting || !isDirty} kind="primary" type="submit">
245
+ {isSubmitting ? (
246
+ <span className={styles.inlineLoading}>
247
+ {t('submitting', 'Submitting...')}
248
+ <InlineLoading status="active" iconDescription="Loading" />
249
+ </span>
250
+ ) : (
251
+ t('saveAndClose', 'Save & close')
252
+ )}
253
+ </Button>
254
+ </ButtonSet>
255
+ </Form>
256
+ </Workspace2>
257
+ );
258
+ };
259
+
260
+ export default GlobalPropertyWorkspace;
@@ -0,0 +1,61 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+ @use '@carbon/type';
4
+
5
+ .form {
6
+ display: flex;
7
+ flex-direction: column;
8
+ justify-content: space-between;
9
+ height: 100%;
10
+ }
11
+
12
+ .formContainer {
13
+ margin: layout.$spacing-05;
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: layout.$spacing-05;
17
+ overflow-y: auto;
18
+ }
19
+
20
+ .previewContainer {
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: layout.$spacing-03;
24
+ }
25
+
26
+ .previewLabel {
27
+ @include type.type-style('label-01');
28
+ color: colors.$gray-70;
29
+ }
30
+
31
+ .preview {
32
+ max-width: 100%;
33
+ max-height: 240px;
34
+ object-fit: contain;
35
+ border: 1px solid colors.$gray-20;
36
+ padding: layout.$spacing-03;
37
+ background-color: colors.$white;
38
+ }
39
+
40
+ .tablet {
41
+ padding: layout.$spacing-06 layout.$spacing-05;
42
+ background-color: colors.$white;
43
+ }
44
+
45
+ .desktop {
46
+ padding: 0;
47
+
48
+ & button {
49
+ max-width: 50% !important;
50
+ }
51
+ }
52
+
53
+ .buttonContainer {
54
+ max-width: 50%;
55
+ }
56
+
57
+ .inlineLoading {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: layout.$spacing-03;
61
+ }
@@ -0,0 +1,180 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import '@testing-library/jest-dom';
5
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
6
+ import UploadLogoWorkspace from './upload-logo.workspace';
7
+
8
+ const mockShowSnackbar = vi.fn();
9
+ const mockUploadLogo = vi.fn();
10
+ const mockCloseWorkspace = vi.fn();
11
+ const mockMutateGlobalProperty = vi.fn();
12
+
13
+ vi.mock('react-i18next', () => ({
14
+ useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
15
+ }));
16
+
17
+ vi.mock('@openmrs/esm-framework', () => ({
18
+ useLayoutType: vi.fn(() => 'desktop'),
19
+ showSnackbar: (...args: unknown[]) => mockShowSnackbar(...args),
20
+ Workspace2: ({ children, title }: { children: React.ReactNode; title: string }) => (
21
+ <div>
22
+ <h2>{title}</h2>
23
+ {children}
24
+ </div>
25
+ ),
26
+ }));
27
+
28
+ vi.mock('../hooks/useLogoUpload', async (importOriginal) => {
29
+ const original = await importOriginal<typeof import('../hooks/useLogoUpload')>();
30
+ return {
31
+ ...original,
32
+ uploadLogo: (...args: unknown[]) => mockUploadLogo(...args),
33
+ };
34
+ });
35
+
36
+ vi.mock('@carbon/react', async (importOriginal) => {
37
+ const original = await importOriginal<typeof import('@carbon/react')>();
38
+ return {
39
+ ...original,
40
+ Button: ({ children, onClick, disabled, type }: any) => (
41
+ <button onClick={onClick} disabled={disabled} type={type || 'button'}>
42
+ {children}
43
+ </button>
44
+ ),
45
+ FileUploader: ({ onChange, onDelete, disabled }: any) => (
46
+ <div>
47
+ <input
48
+ data-testid="file-input"
49
+ type="file"
50
+ disabled={disabled}
51
+ onChange={(e) => onChange({ target: e.target, addedFiles: Array.from(e.target.files ?? []) })}
52
+ />
53
+ <button type="button" onClick={() => onDelete?.()}>
54
+ remove-file
55
+ </button>
56
+ </div>
57
+ ),
58
+ };
59
+ });
60
+
61
+ const baseProps = {
62
+ closeWorkspace: mockCloseWorkspace,
63
+ workspaceProps: {
64
+ mutateGlobalProperty: mockMutateGlobalProperty,
65
+ },
66
+ } as any;
67
+
68
+ function makeFile(name: string, type: string, size?: number) {
69
+ const file = new File(['logo-bytes'], name, { type });
70
+ if (size != null) {
71
+ Object.defineProperty(file, 'size', { value: size });
72
+ }
73
+ return file;
74
+ }
75
+
76
+ describe('UploadLogoWorkspace', () => {
77
+ beforeEach(() => {
78
+ vi.clearAllMocks();
79
+ global.URL.createObjectURL = vi.fn(() => 'blob:preview');
80
+ global.URL.revokeObjectURL = vi.fn();
81
+ });
82
+
83
+ it('renders the workspace with the Upload button disabled until a file is chosen', () => {
84
+ render(<UploadLogoWorkspace {...baseProps} />);
85
+ expect(screen.getByText('Upload image')).toBeInTheDocument();
86
+ expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled();
87
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
88
+ });
89
+
90
+ it('shows a preview and enables Upload after selecting a valid image', async () => {
91
+ render(<UploadLogoWorkspace {...baseProps} />);
92
+
93
+ await userEvent.upload(screen.getByTestId('file-input'), makeFile('logo.png', 'image/png'));
94
+
95
+ expect(screen.getByRole('img', { name: 'Image preview' })).toBeInTheDocument();
96
+ expect(screen.getByRole('button', { name: 'Upload' })).toBeEnabled();
97
+ });
98
+
99
+ it('rejects an unsupported file type and keeps Upload disabled', async () => {
100
+ render(<UploadLogoWorkspace {...baseProps} />);
101
+
102
+ await userEvent.upload(screen.getByTestId('file-input'), makeFile('notes.pdf', 'application/pdf'));
103
+
104
+ expect(screen.getByText(/Unsupported file type/i)).toBeInTheDocument();
105
+ expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled();
106
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
107
+ });
108
+
109
+ it('rejects a file larger than the 2 MB limit', async () => {
110
+ render(<UploadLogoWorkspace {...baseProps} />);
111
+
112
+ await userEvent.upload(screen.getByTestId('file-input'), makeFile('huge.png', 'image/png', 2 * 1024 * 1024 + 1));
113
+
114
+ expect(screen.getByText(/exceeds the 2 MB maximum/i)).toBeInTheDocument();
115
+ expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled();
116
+ });
117
+
118
+ it('uploads the selected image, shows a success snackbar, refreshes and closes', async () => {
119
+ mockUploadLogo.mockResolvedValue({ savedPath: '/srv/openmrs/prescription-logo.png', message: 'ok' });
120
+ render(<UploadLogoWorkspace {...baseProps} />);
121
+
122
+ const file = makeFile('logo.png', 'image/png');
123
+ await userEvent.upload(screen.getByTestId('file-input'), file);
124
+ await userEvent.click(screen.getByRole('button', { name: 'Upload' }));
125
+
126
+ await waitFor(() => {
127
+ expect(mockUploadLogo).toHaveBeenCalledWith(file, 'prescription');
128
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
129
+ expect(mockMutateGlobalProperty).toHaveBeenCalled();
130
+ expect(mockCloseWorkspace).toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ it('uploads to the receipt destination when the receipt option is selected', async () => {
135
+ mockUploadLogo.mockResolvedValue({ savedPath: '/srv/openmrs/receipt-logo.png', message: 'ok' });
136
+ render(<UploadLogoWorkspace {...baseProps} />);
137
+
138
+ await userEvent.click(screen.getByRole('radio', { name: 'Receipt logo' }));
139
+ const file = makeFile('mtrh-logo.png', 'image/png');
140
+ await userEvent.upload(screen.getByTestId('file-input'), file);
141
+ await userEvent.click(screen.getByRole('button', { name: 'Upload' }));
142
+
143
+ await waitFor(() => {
144
+ expect(mockUploadLogo).toHaveBeenCalledWith(file, 'receipt');
145
+ });
146
+ });
147
+
148
+ it('shows an error snackbar and keeps the workspace open when upload fails', async () => {
149
+ mockUploadLogo.mockRejectedValue(new Error('Upload failed'));
150
+ render(<UploadLogoWorkspace {...baseProps} />);
151
+
152
+ await userEvent.upload(screen.getByTestId('file-input'), makeFile('logo.png', 'image/png'));
153
+ await userEvent.click(screen.getByRole('button', { name: 'Upload' }));
154
+
155
+ await waitFor(() => {
156
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
157
+ });
158
+ expect(mockMutateGlobalProperty).not.toHaveBeenCalled();
159
+ expect(mockCloseWorkspace).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('closes the workspace when Cancel is clicked', async () => {
163
+ render(<UploadLogoWorkspace {...baseProps} />);
164
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
165
+ expect(mockCloseWorkspace).toHaveBeenCalledTimes(1);
166
+ expect(mockUploadLogo).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('clears the preview and disables Upload when the chosen file is removed', async () => {
170
+ render(<UploadLogoWorkspace {...baseProps} />);
171
+
172
+ await userEvent.upload(screen.getByTestId('file-input'), makeFile('logo.png', 'image/png'));
173
+ expect(screen.getByRole('img', { name: 'Image preview' })).toBeInTheDocument();
174
+
175
+ await userEvent.click(screen.getByRole('button', { name: 'remove-file' }));
176
+
177
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
178
+ expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled();
179
+ });
180
+ });
@@ -0,0 +1,191 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { showSnackbar, useLayoutType, Workspace2, type Workspace2DefinitionProps } from '@openmrs/esm-framework';
3
+ import {
4
+ Button,
5
+ ButtonSet,
6
+ FileUploader,
7
+ Form,
8
+ FormGroup,
9
+ InlineLoading,
10
+ InlineNotification,
11
+ RadioButton,
12
+ RadioButtonGroup,
13
+ Stack,
14
+ } from '@carbon/react';
15
+ import { useTranslation } from 'react-i18next';
16
+ import classNames from 'classnames';
17
+
18
+ import styles from './upload-logo.workspace.scss';
19
+ import { ALLOWED_LOGO_TYPES, MAX_LOGO_SIZE_BYTES, type LogoTarget, uploadLogo } from '../hooks/useLogoUpload';
20
+
21
+ type UploadLogoWorkspaceProps = {
22
+ mutateGlobalProperty?: () => void;
23
+ };
24
+
25
+ const UploadLogoWorkspace: React.FC<Workspace2DefinitionProps<UploadLogoWorkspaceProps, {}, {}>> = ({
26
+ closeWorkspace,
27
+ workspaceProps,
28
+ }) => {
29
+ const { t } = useTranslation();
30
+ const isTablet = useLayoutType() === 'tablet';
31
+ const mutateGlobalProperty = workspaceProps?.mutateGlobalProperty;
32
+
33
+ const [target, setTarget] = useState<LogoTarget>('prescription');
34
+ const [file, setFile] = useState<File | null>(null);
35
+ const [previewUrl, setPreviewUrl] = useState<string | null>(null);
36
+ const [validationError, setValidationError] = useState<string | null>(null);
37
+ const [isSubmitting, setIsSubmitting] = useState(false);
38
+
39
+ useEffect(() => {
40
+ if (!file) {
41
+ setPreviewUrl(null);
42
+ return;
43
+ }
44
+ const url = URL.createObjectURL(file);
45
+ setPreviewUrl(url);
46
+ return () => URL.revokeObjectURL(url);
47
+ }, [file]);
48
+
49
+ const handleFileChange = (selected: File | null) => {
50
+ setValidationError(null);
51
+ if (!selected) {
52
+ setFile(null);
53
+ return;
54
+ }
55
+ if (!ALLOWED_LOGO_TYPES.includes(selected.type)) {
56
+ setValidationError(t('unsupportedImageType', 'Unsupported file type. Accepted: JPEG, PNG, GIF, BMP or WebP.'));
57
+ setFile(null);
58
+ return;
59
+ }
60
+ if (selected.size > MAX_LOGO_SIZE_BYTES) {
61
+ setValidationError(t('imageTooLarge', 'File exceeds the 2 MB maximum size.'));
62
+ setFile(null);
63
+ return;
64
+ }
65
+ setFile(selected);
66
+ };
67
+
68
+ const onSubmit = async (event: React.FormEvent) => {
69
+ event.preventDefault();
70
+ if (!file) {
71
+ setValidationError(t('selectImageFirst', 'Please select an image to upload.'));
72
+ return;
73
+ }
74
+ setIsSubmitting(true);
75
+ try {
76
+ const { savedPath } = await uploadLogo(file, target);
77
+ const fileName = savedPath?.split(/[\\/]/).pop() ?? file.name;
78
+ showSnackbar({
79
+ title: t('success', 'Success'),
80
+ kind: 'success',
81
+ subtitle: t('logoUploaded', 'Image "{{fileName}}" uploaded successfully', { fileName }),
82
+ timeoutInMs: 5000,
83
+ });
84
+ mutateGlobalProperty?.();
85
+ closeWorkspace({ discardUnsavedChanges: true });
86
+ } catch (error: any) {
87
+ showSnackbar({
88
+ title: t('error', 'Error'),
89
+ kind: 'error',
90
+ subtitle: error?.message ?? t('logoUploadError', 'Error uploading image'),
91
+ });
92
+ } finally {
93
+ setIsSubmitting(false);
94
+ }
95
+ };
96
+
97
+ return (
98
+ <Workspace2 title={t('uploadImage', 'Upload image')} hasUnsavedChanges={Boolean(file)}>
99
+ <Form onSubmit={onSubmit} className={styles.form}>
100
+ <div className={styles.formContainer}>
101
+ <Stack gap={5}>
102
+ <InlineNotification
103
+ kind="info"
104
+ lowContrast
105
+ hideCloseButton
106
+ title={t('logoUploadTitle', 'Printed documents logo')}
107
+ subtitle={t(
108
+ 'logoUploadDesc',
109
+ 'Upload a logo used in printed documents. JPEG, PNG, GIF, BMP or WebP, up to 2 MB.',
110
+ )}
111
+ />
112
+
113
+ <FormGroup legendText={t('logoTarget', 'Logo destination')}>
114
+ <RadioButtonGroup
115
+ name="logo-target"
116
+ legendText=""
117
+ valueSelected={target}
118
+ onChange={(value: string) => setTarget(value as LogoTarget)}
119
+ disabled={isSubmitting}>
120
+ <RadioButton
121
+ labelText={t('prescriptionLogo', 'Prescription logo')}
122
+ value="prescription"
123
+ id="logo-target-prescription"
124
+ />
125
+ <RadioButton labelText={t('receiptLogo', 'Receipt logo')} value="receipt" id="logo-target-receipt" />
126
+ </RadioButtonGroup>
127
+ </FormGroup>
128
+
129
+ <FormGroup legendText={t('image', 'Image')}>
130
+ <FileUploader
131
+ labelTitle=""
132
+ labelDescription={t('logoUploaderHint', 'Max file size is 2 MB. Supported: JPEG, PNG, GIF, BMP, WebP.')}
133
+ buttonLabel={t('addFile', 'Add file')}
134
+ buttonKind="tertiary"
135
+ size="md"
136
+ filenameStatus="edit"
137
+ accept={ALLOWED_LOGO_TYPES}
138
+ multiple={false}
139
+ disabled={isSubmitting}
140
+ onChange={(e: { target: HTMLInputElement; addedFiles?: File[] }) => {
141
+ const selected = e.addedFiles?.[0] ?? e.target?.files?.[0] ?? null;
142
+ handleFileChange(selected);
143
+ }}
144
+ onDelete={() => handleFileChange(null)}
145
+ />
146
+ </FormGroup>
147
+
148
+ {validationError && (
149
+ <InlineNotification
150
+ kind="error"
151
+ lowContrast
152
+ hideCloseButton
153
+ title={t('invalidImage', 'Invalid image')}
154
+ subtitle={validationError}
155
+ />
156
+ )}
157
+
158
+ {previewUrl && (
159
+ <div className={styles.previewContainer}>
160
+ <span className={styles.previewLabel}>{t('preview', 'Preview')}</span>
161
+ <img src={previewUrl} alt={t('imagePreview', 'Image preview')} className={styles.preview} />
162
+ </div>
163
+ )}
164
+ </Stack>
165
+ </div>
166
+
167
+ <ButtonSet
168
+ className={classNames({
169
+ [styles.tablet]: isTablet,
170
+ [styles.desktop]: !isTablet,
171
+ })}>
172
+ <Button className={styles.buttonContainer} kind="secondary" onClick={() => closeWorkspace()}>
173
+ {t('cancel', 'Cancel')}
174
+ </Button>
175
+ <Button className={styles.buttonContainer} disabled={!file || isSubmitting} kind="primary" type="submit">
176
+ {isSubmitting ? (
177
+ <span className={styles.inlineLoading}>
178
+ {t('uploading', 'Uploading...')}
179
+ <InlineLoading status="active" iconDescription="Loading" />
180
+ </span>
181
+ ) : (
182
+ t('upload', 'Upload')
183
+ )}
184
+ </Button>
185
+ </ButtonSet>
186
+ </Form>
187
+ </Workspace2>
188
+ );
189
+ };
190
+
191
+ export default UploadLogoWorkspace;