@kenyaemr/esm-admin-app 5.4.4-pre.98 → 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,29 @@
1
+ import useSWR from 'swr';
2
+ import { openmrsFetch, restBaseUrl, type FetchResponse } from '@openmrs/esm-framework';
3
+ import { FacilityRegistryRecord, FacilityRegistrySyncResult } from './type';
4
+ import { facilityRegistryUrl } from './constant';
5
+
6
+ export const useFacilityRegistry = () => {
7
+ const { data, error, isLoading, mutate } = useSWR<FetchResponse<FacilityRegistryRecord>>(
8
+ facilityRegistryUrl,
9
+ openmrsFetch,
10
+ {
11
+ revalidateOnFocus: false,
12
+ },
13
+ );
14
+
15
+ const notYetSynced = error?.response?.status === 404;
16
+
17
+ return {
18
+ facility: data?.data,
19
+ isLoading,
20
+ error: notYetSynced ? null : error,
21
+ notYetSynced,
22
+ mutate,
23
+ };
24
+ };
25
+
26
+ export const syncFacilityRegistry = async (): Promise<FacilityRegistrySyncResult> => {
27
+ const response = await openmrsFetch<FacilityRegistrySyncResult>(facilityRegistryUrl + '/sync', { method: 'POST' });
28
+ return response.data;
29
+ };
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { PageHeader, FacilityPictogram } from '@openmrs/esm-framework';
3
+ import { useTranslation } from 'react-i18next';
4
+
5
+ import GlobalPropertyTable from '../table/global-property-table.component';
6
+ import styles from './global-property-dashboard.scss';
7
+
8
+ const GlobalPropertyDashboard: React.FC = () => {
9
+ const { t } = useTranslation();
10
+
11
+ return (
12
+ <div>
13
+ <PageHeader
14
+ className={styles.pageHeader}
15
+ title={t('globalProperty', 'Global Property')}
16
+ illustration={<FacilityPictogram />}
17
+ />
18
+ <GlobalPropertyTable />
19
+ </div>
20
+ );
21
+ };
22
+
23
+ export default GlobalPropertyDashboard;
@@ -0,0 +1,6 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/colors';
3
+
4
+ .pageHeader {
5
+ border-bottom: 1px solid colors.$gray-10;
6
+ }
@@ -0,0 +1,64 @@
1
+ import { openmrsFetch, restBaseUrl, useOpenmrsPagination } from '@openmrs/esm-framework';
2
+
3
+ import { type SystemSettings } from '../../../types';
4
+
5
+ export type GlobalPropertyPayload = {
6
+ property: string;
7
+ value: string;
8
+ description?: string | null;
9
+ datatypeClassname?: string | null;
10
+ datatypeConfig?: string | null;
11
+ preferredHandlerClassname?: string | null;
12
+ handlerConfig?: string | null;
13
+ };
14
+
15
+ export async function saveOrUpdateGlobalProperty(payload: GlobalPropertyPayload, uuid?: string): Promise<void> {
16
+ const url = uuid ? `${restBaseUrl}/systemsetting/${uuid}` : `${restBaseUrl}/systemsetting`;
17
+ const body: Record<string, unknown> = {
18
+ property: payload.property,
19
+ value: payload.value,
20
+ };
21
+ if (payload.description) {
22
+ body.description = payload.description;
23
+ }
24
+ if (payload.datatypeClassname) {
25
+ body.datatypeClassname = payload.datatypeClassname;
26
+ }
27
+ if (payload.datatypeConfig) {
28
+ body.datatypeConfig = payload.datatypeConfig;
29
+ }
30
+ if (payload.preferredHandlerClassname) {
31
+ body.preferredHandlerClassname = payload.preferredHandlerClassname;
32
+ }
33
+ if (payload.handlerConfig != null) {
34
+ body.handlerConfig = payload.handlerConfig;
35
+ }
36
+
37
+ const response = await openmrsFetch(url, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify(body),
41
+ });
42
+
43
+ if (!response.ok) {
44
+ const errorData = await response.json().catch(() => ({}));
45
+ throw new Error(errorData?.error?.message ?? 'Failed to save global property');
46
+ }
47
+ }
48
+
49
+ export async function deleteGlobalProperty(uuid: string): Promise<void> {
50
+ const response = await openmrsFetch(`${restBaseUrl}/systemsetting/${uuid}?purge=true`, {
51
+ method: 'DELETE',
52
+ });
53
+ if (!response.ok) {
54
+ const errorData = await response.json().catch(() => ({}));
55
+ throw new Error(errorData?.error?.message ?? 'Failed to delete global property');
56
+ }
57
+ }
58
+
59
+ export function useGlobalProperties(pageSize: number = 10, searchTerm?: string) {
60
+ const searchQuery = searchTerm ? `&q=${encodeURIComponent(searchTerm)}` : '';
61
+ const baseUrl = `${restBaseUrl}/systemsetting?v=default`;
62
+ const url = searchTerm ? `${baseUrl}${searchQuery}` : baseUrl;
63
+ return useOpenmrsPagination<SystemSettings>(url, pageSize);
64
+ }
@@ -0,0 +1,28 @@
1
+ import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2
+
3
+ export const ALLOWED_LOGO_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'];
4
+ export const MAX_LOGO_SIZE_BYTES = 2 * 1024 * 1024; // 2 MB
5
+
6
+ export type LogoTarget = 'prescription' | 'receipt';
7
+
8
+ export type UploadLogoResponse = {
9
+ savedPath: string;
10
+ message: string;
11
+ };
12
+
13
+ export async function uploadLogo(file: File, target: LogoTarget = 'prescription'): Promise<UploadLogoResponse> {
14
+ const formData = new FormData();
15
+ formData.append('file', file);
16
+
17
+ const response = await openmrsFetch(`${restBaseUrl}/palladiumemr/logo?target=${target}`, {
18
+ method: 'POST',
19
+ body: formData,
20
+ });
21
+
22
+ if (!response.ok) {
23
+ const errorData = await response.json().catch(() => ({}));
24
+ throw new Error(errorData?.error ?? errorData?.error?.message ?? 'Failed to upload image');
25
+ }
26
+
27
+ return response.json();
28
+ }
@@ -0,0 +1,14 @@
1
+ import { getAsyncLifecycle, getSyncLifecycle } from '@openmrs/esm-framework';
2
+ import { createLeftPanelLink } from '../../left-pannel-link.component';
3
+ import { moduleName } from '../../constants';
4
+
5
+ // t('globalProperty','Global property')
6
+ export const globalPropertyLink = getSyncLifecycle(
7
+ createLeftPanelLink({ title: 'globalProperty', name: 'global-property' }),
8
+ { moduleName, featureName: 'global-property' },
9
+ );
10
+
11
+ export const globalPropertyDashboard = getAsyncLifecycle(
12
+ () => import('./dashboard/global-property-dashboard.component'),
13
+ { moduleName, featureName: 'global-property' },
14
+ );
@@ -0,0 +1,71 @@
1
+ import React, { useState } from 'react';
2
+ import { Button, InlineLoading } from '@carbon/react';
3
+ import { showSnackbar } from '@openmrs/esm-framework';
4
+ import { useTranslation } from 'react-i18next';
5
+
6
+ import { deleteGlobalProperty } from '../hooks/useGlobalProperty';
7
+
8
+ interface DeleteGlobalPropertyModalProps {
9
+ close: () => void;
10
+ property: string;
11
+ uuid: string;
12
+ onDeleted: () => void;
13
+ }
14
+
15
+ const DeleteGlobalPropertyModal: React.FC<DeleteGlobalPropertyModalProps> = ({ close, property, uuid, onDeleted }) => {
16
+ const { t } = useTranslation();
17
+ const [isDeleting, setIsDeleting] = useState(false);
18
+
19
+ const handleDelete = async () => {
20
+ setIsDeleting(true);
21
+ try {
22
+ await deleteGlobalProperty(uuid);
23
+ showSnackbar({
24
+ isLowContrast: true,
25
+ kind: 'success',
26
+ title: t('gpDeleted', 'Global property deleted'),
27
+ subtitle: t('gpDeletedSubtitle', '{{property}} was deleted successfully.', { property }),
28
+ });
29
+ onDeleted();
30
+ close();
31
+ } catch (error) {
32
+ showSnackbar({
33
+ kind: 'error',
34
+ title: t('gpDeleteError', 'Error deleting global property'),
35
+ subtitle: error?.message,
36
+ });
37
+ } finally {
38
+ setIsDeleting(false);
39
+ }
40
+ };
41
+
42
+ return (
43
+ <>
44
+ <div className="cds--modal-header">
45
+ <h3 className="cds--modal-header__heading">{t('deleteGlobalProperty', 'Delete global property')}</h3>
46
+ </div>
47
+ <div className="cds--modal-content">
48
+ <p>
49
+ {t(
50
+ 'deleteGlobalPropertyConfirmation',
51
+ 'Are you sure you want to delete the global property "{{property}}"?',
52
+ {
53
+ property,
54
+ },
55
+ )}
56
+ </p>
57
+ <p>{t('deleteGlobalPropertyWarning', 'This action cannot be undone.')}</p>
58
+ </div>
59
+ <div className="cds--modal-footer">
60
+ <Button kind="secondary" onClick={close} disabled={isDeleting}>
61
+ {t('cancel', 'Cancel')}
62
+ </Button>
63
+ <Button kind="danger" onClick={handleDelete} disabled={isDeleting}>
64
+ {isDeleting ? <InlineLoading description={t('deleting', 'Deleting...')} /> : t('delete', 'Delete')}
65
+ </Button>
66
+ </div>
67
+ </>
68
+ );
69
+ };
70
+
71
+ export default DeleteGlobalPropertyModal;
@@ -0,0 +1,131 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
5
+ import DeleteGlobalPropertyModal from './delete-global-property-modal.component';
6
+
7
+ const mockShowSnackbar = vi.fn();
8
+ const mockDeleteGlobalProperty = vi.fn();
9
+
10
+ vi.mock('react-i18next', () => ({
11
+ useTranslation: () => ({ t: (_key: string, fallback: string, opts?: Record<string, unknown>) => fallback }),
12
+ }));
13
+
14
+ vi.mock('@openmrs/esm-framework', () => ({
15
+ showSnackbar: (...args: unknown[]) => mockShowSnackbar(...args),
16
+ }));
17
+
18
+ vi.mock('../hooks/useGlobalProperty', () => ({
19
+ deleteGlobalProperty: (...args: unknown[]) => mockDeleteGlobalProperty(...args),
20
+ }));
21
+
22
+ describe('DeleteGlobalPropertyModal', () => {
23
+ const defaultProps = {
24
+ close: vi.fn(),
25
+ property: 'setting.example',
26
+ uuid: 'uuid-abc',
27
+ onDeleted: vi.fn(),
28
+ };
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+ });
33
+
34
+ it('displays the modal heading', () => {
35
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
36
+ expect(screen.getByText('Delete global property')).toBeInTheDocument();
37
+ });
38
+
39
+ it('shows the property name in the confirmation message', () => {
40
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
41
+ expect(screen.getByText('Are you sure you want to delete the global property "{{property}}"?')).toBeInTheDocument();
42
+ });
43
+
44
+ it('shows a warning that the action cannot be undone', () => {
45
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
46
+ expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument();
47
+ });
48
+
49
+ it('calls close when the Cancel button is clicked', () => {
50
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
51
+ fireEvent.click(screen.getByText('Cancel'));
52
+ expect(defaultProps.close).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it('does not call onDeleted when Cancel is clicked', () => {
56
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
57
+ fireEvent.click(screen.getByText('Cancel'));
58
+ expect(defaultProps.onDeleted).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('calls deleteGlobalProperty with the correct uuid when Delete is clicked', async () => {
62
+ mockDeleteGlobalProperty.mockResolvedValue(undefined);
63
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
64
+ fireEvent.click(screen.getByText('Delete'));
65
+ await waitFor(() => {
66
+ expect(mockDeleteGlobalProperty).toHaveBeenCalledWith('uuid-abc');
67
+ });
68
+ });
69
+
70
+ it('shows a success snackbar and closes the modal after successful deletion', async () => {
71
+ mockDeleteGlobalProperty.mockResolvedValue(undefined);
72
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
73
+ fireEvent.click(screen.getByText('Delete'));
74
+ await waitFor(() => {
75
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
76
+ expect(defaultProps.onDeleted).toHaveBeenCalledTimes(1);
77
+ expect(defaultProps.close).toHaveBeenCalledTimes(1);
78
+ });
79
+ });
80
+
81
+ it('shows an error snackbar when deletion fails', async () => {
82
+ mockDeleteGlobalProperty.mockRejectedValue(new Error('Server error'));
83
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
84
+ fireEvent.click(screen.getByText('Delete'));
85
+ await waitFor(() => {
86
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
87
+ expect(defaultProps.close).not.toHaveBeenCalled();
88
+ expect(defaultProps.onDeleted).not.toHaveBeenCalled();
89
+ });
90
+ });
91
+
92
+ it('disables both buttons while deletion is in progress', async () => {
93
+ let resolveDelete: () => void;
94
+ mockDeleteGlobalProperty.mockReturnValue(
95
+ new Promise<void>((res) => {
96
+ resolveDelete = res;
97
+ }),
98
+ );
99
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
100
+
101
+ fireEvent.click(screen.getByText('Delete'));
102
+
103
+ await waitFor(() => {
104
+ expect(screen.getByText('Cancel')).toBeDisabled();
105
+ });
106
+
107
+ await act(async () => {
108
+ resolveDelete!();
109
+ });
110
+ });
111
+
112
+ it('shows a loading indicator while deletion is in progress', async () => {
113
+ let resolveDelete: () => void;
114
+ mockDeleteGlobalProperty.mockReturnValue(
115
+ new Promise<void>((res) => {
116
+ resolveDelete = res;
117
+ }),
118
+ );
119
+ render(<DeleteGlobalPropertyModal {...defaultProps} />);
120
+
121
+ fireEvent.click(screen.getByText('Delete'));
122
+
123
+ await waitFor(() => {
124
+ expect(screen.getByText('Deleting...')).toBeInTheDocument();
125
+ });
126
+
127
+ await act(async () => {
128
+ resolveDelete!();
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,241 @@
1
+ import React, { useCallback, useMemo, useState } from 'react';
2
+ import {
3
+ Button,
4
+ DataTable,
5
+ OverflowMenu,
6
+ OverflowMenuItem,
7
+ Pagination,
8
+ Table,
9
+ TableBody,
10
+ TableCell,
11
+ TableContainer,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ TableToolbar,
16
+ TableToolbarContent,
17
+ TableToolbarSearch,
18
+ Tag,
19
+ type DataTableHeader,
20
+ } from '@carbon/react';
21
+ import { Add, Upload } from '@carbon/react/icons';
22
+ import { useTranslation } from 'react-i18next';
23
+ import { ErrorCard, launchWorkspace2, showModal, useDebounce, usePaginationInfo } from '@openmrs/esm-framework';
24
+
25
+ import { useGlobalProperties } from '../hooks/useGlobalProperty';
26
+ import styles from './global-property-table.scss';
27
+
28
+ const DEFAULT_PAGE_SIZE = 10;
29
+ const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
30
+
31
+ type GlobalPropertyRow = {
32
+ id: string;
33
+ property: string;
34
+ value: string;
35
+ };
36
+
37
+ const GlobalPropertyTable: React.FC = () => {
38
+ const { t } = useTranslation();
39
+
40
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
41
+ const [searchTerm, setSearchTerm] = useState('');
42
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
43
+
44
+ const {
45
+ data: globalProperties = [],
46
+ error,
47
+ goTo,
48
+ currentPage,
49
+ totalCount,
50
+ mutate,
51
+ } = useGlobalProperties(pageSize, debouncedSearchTerm);
52
+
53
+ const { pageSizes } = usePaginationInfo(DEFAULT_PAGE_SIZE, totalCount, currentPage, globalProperties.length);
54
+
55
+ const headers: Array<DataTableHeader> = useMemo(
56
+ () => [
57
+ { key: 'property', header: t('property', 'Property') },
58
+ { key: 'value', header: t('value', 'Value') },
59
+ ],
60
+ [t],
61
+ );
62
+
63
+ const tableRows: Array<GlobalPropertyRow> = useMemo(
64
+ () =>
65
+ globalProperties.map((gp, idx) => ({
66
+ id: gp?.uuid ?? `gp-${idx}`,
67
+ property: gp?.property ?? '',
68
+ value: gp?.value ?? '',
69
+ })),
70
+ [globalProperties],
71
+ );
72
+
73
+ const handleSearchChange = useCallback(
74
+ (event: React.ChangeEvent<HTMLInputElement> | '', value?: string) => {
75
+ const searchValue = typeof event === 'string' ? value ?? '' : event.target.value;
76
+ setSearchTerm(searchValue);
77
+ if (currentPage !== 1) {
78
+ goTo(1);
79
+ }
80
+ },
81
+ [currentPage, goTo],
82
+ );
83
+
84
+ const handlePaginationChange = useCallback(
85
+ ({ page, pageSize: newSize }: { page: number; pageSize: number }) => {
86
+ if (newSize !== pageSize) {
87
+ setPageSize(newSize);
88
+ }
89
+ goTo(page);
90
+ },
91
+ [pageSize, goTo],
92
+ );
93
+
94
+ const openWorkspace = useCallback(
95
+ (systemSetting?: (typeof globalProperties)[number]) => {
96
+ launchWorkspace2('global-property-workspace', {
97
+ systemSetting,
98
+ mutateGlobalProperty: mutate,
99
+ });
100
+ },
101
+ [globalProperties, mutate],
102
+ );
103
+
104
+ const openUploadImageWorkspace = useCallback(() => {
105
+ launchWorkspace2('upload-logo-workspace', {
106
+ mutateGlobalProperty: mutate,
107
+ });
108
+ }, [mutate]);
109
+
110
+ const handleEdit = useCallback(
111
+ (row: GlobalPropertyRow) => {
112
+ const systemSetting = globalProperties.find((gp) => gp.uuid === row.id);
113
+ openWorkspace(systemSetting);
114
+ },
115
+ [globalProperties, openWorkspace],
116
+ );
117
+
118
+ const handleDelete = useCallback(
119
+ (row: GlobalPropertyRow) => {
120
+ const dispose = showModal('delete-global-property-modal', {
121
+ close: () => dispose(),
122
+ property: row.property,
123
+ uuid: row.id,
124
+ onDeleted: () => mutate(),
125
+ });
126
+ },
127
+ [mutate],
128
+ );
129
+
130
+ if (error) {
131
+ return (
132
+ <div className={styles.dataTableContainer}>
133
+ <ErrorCard error={error} headerTitle={t('globalPropertyError', 'Global property')} />
134
+ </div>
135
+ );
136
+ }
137
+
138
+ return (
139
+ <div className={styles.container}>
140
+ <DataTable size="sm" rows={tableRows} headers={headers} isSortable useZebraStyles>
141
+ {({
142
+ rows,
143
+ headers,
144
+ getHeaderProps,
145
+ getRowProps,
146
+ getBatchActionProps,
147
+ getToolbarProps,
148
+ getTableProps,
149
+ getTableContainerProps,
150
+ }) => {
151
+ const batchActionProps = getBatchActionProps();
152
+
153
+ return (
154
+ <TableContainer
155
+ title={t('globalProperty', 'Global Property')}
156
+ description={t(
157
+ 'globalPropertyDescription',
158
+ 'A list of all global properties for the system. Filter by search term to find specific properties.',
159
+ )}
160
+ {...getTableContainerProps()}>
161
+ <TableToolbar {...getToolbarProps()}>
162
+ <TableToolbarContent aria-hidden={batchActionProps.shouldShowBatchActions}>
163
+ <TableToolbarSearch
164
+ persistent
165
+ onChange={handleSearchChange}
166
+ placeholder={t('searchForGlobalProperties', 'Search for global properties')}
167
+ />
168
+ <Button renderIcon={Add} onClick={() => openWorkspace()} size="sm" kind="primary">
169
+ {t('addGlobalProperty', 'Add global property')}
170
+ </Button>
171
+ <Button renderIcon={Upload} onClick={openUploadImageWorkspace} size="sm" kind="tertiary">
172
+ {t('uploadImage', 'Upload image')}
173
+ </Button>
174
+ </TableToolbarContent>
175
+ </TableToolbar>
176
+
177
+ <Table {...getTableProps()} aria-label="Global properties">
178
+ <TableHead>
179
+ <TableRow>
180
+ {headers.map((header) => (
181
+ <TableHeader {...getHeaderProps({ header })}>{header.header}</TableHeader>
182
+ ))}
183
+ <TableHeader aria-label={t('rowActions', 'Row actions')} />
184
+ </TableRow>
185
+ </TableHead>
186
+
187
+ <TableBody>
188
+ {rows.map((row, index) => {
189
+ return (
190
+ <React.Fragment key={row.id}>
191
+ <TableRow {...getRowProps({ row })}>
192
+ {row.cells.map((cell) => {
193
+ if (cell.info.header === 'status') {
194
+ return (
195
+ <TableCell key={cell.id}>
196
+ <Tag size="sm">{cell.value}</Tag>
197
+ </TableCell>
198
+ );
199
+ }
200
+ return <TableCell key={cell.id}>{cell.value}</TableCell>;
201
+ })}
202
+ <TableCell className="cds--table-column-menu">
203
+ <OverflowMenu flipped aria-label="overflow-menu">
204
+ <OverflowMenuItem
205
+ onClick={() => handleEdit(tableRows[index])}
206
+ itemText={t('edit', 'Edit')}
207
+ />
208
+ <OverflowMenuItem
209
+ isDelete
210
+ onClick={() => handleDelete(tableRows[index])}
211
+ itemText={t('delete', 'Delete')}
212
+ />
213
+ </OverflowMenu>
214
+ </TableCell>
215
+ </TableRow>
216
+ </React.Fragment>
217
+ );
218
+ })}
219
+ </TableBody>
220
+ </Table>
221
+ </TableContainer>
222
+ );
223
+ }}
224
+ </DataTable>
225
+ <Pagination
226
+ itemsPerPageText={t('itemsPerPage', 'Items per page:')}
227
+ forwardText={t('nextPage', 'Next page')}
228
+ backwardText={t('previousPage', 'Previous page')}
229
+ itemRangeText={(min, max, total) => t('minMaxItems', '{{min}}-{{max}} of {{total}} items', { min, max, total })}
230
+ pageRangeText={(_current, total) => t('pageRangeText', 'of {{count}} pages', { count: total })}
231
+ page={currentPage}
232
+ pageSize={pageSize}
233
+ pageSizes={pageSizes?.length > 0 ? pageSizes : PAGE_SIZE_OPTIONS}
234
+ totalItems={totalCount ?? 0}
235
+ onChange={handlePaginationChange}
236
+ />
237
+ </div>
238
+ );
239
+ };
240
+
241
+ export default GlobalPropertyTable;
@@ -0,0 +1,44 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/colors';
3
+
4
+ .dataTableContainer {
5
+ height: 100%;
6
+ margin: layout.$layout-01;
7
+ }
8
+
9
+ .tableHeaderSection {
10
+ display: flex;
11
+ align-items: center;
12
+ }
13
+
14
+ .actionsCell {
15
+ display: flex;
16
+ justify-content: flex-end;
17
+ }
18
+
19
+ .visuallyHidden {
20
+ position: absolute;
21
+ width: 1px;
22
+ height: 1px;
23
+ padding: 0;
24
+ margin: -1px;
25
+ overflow: hidden;
26
+ clip: rect(0, 0, 0, 0);
27
+ white-space: nowrap;
28
+ border: 0;
29
+ }
30
+
31
+ .emptyState {
32
+ padding: 2rem;
33
+ text-align: center;
34
+ color: var(--cds-text-secondary);
35
+ }
36
+
37
+ .container {
38
+ margin: layout.$layout-01;
39
+ border: 1px solid colors.$gray-20;
40
+
41
+ :global(.cds--table-toolbar) {
42
+ position: relative !important;
43
+ }
44
+ }