@kenyaemr/esm-admin-app 5.4.4-pre.34 → 5.4.4-pre.340

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 (332) hide show
  1. package/.turbo/turbo-build.log +5 -12
  2. package/dist/100.js +1 -0
  3. package/dist/100.js.map +1 -0
  4. package/dist/1074.js +1 -0
  5. package/dist/1074.js.map +1 -0
  6. package/dist/1242.js +1 -0
  7. package/dist/1242.js.map +1 -0
  8. package/dist/1311.js +1 -0
  9. package/dist/1311.js.map +1 -0
  10. package/dist/1442.js +1 -0
  11. package/dist/1442.js.map +1 -0
  12. package/dist/1462.js +1 -0
  13. package/dist/1462.js.map +1 -0
  14. package/dist/1469.js +1 -0
  15. package/dist/1469.js.map +1 -0
  16. package/dist/1506.js +13 -0
  17. package/dist/1506.js.map +1 -0
  18. package/dist/1718.js +1 -0
  19. package/dist/1718.js.map +1 -0
  20. package/dist/1722.js +1 -0
  21. package/dist/1722.js.map +1 -0
  22. package/dist/1772.js +1 -0
  23. package/dist/1772.js.map +1 -0
  24. package/dist/1889.js +1 -0
  25. package/dist/1889.js.map +1 -0
  26. package/dist/1972.js +1 -0
  27. package/dist/1972.js.map +1 -0
  28. package/dist/1990.js +1 -0
  29. package/dist/1990.js.map +1 -0
  30. package/dist/2016.js +1 -0
  31. package/dist/2016.js.map +1 -0
  32. package/dist/2080.js +1 -0
  33. package/dist/2080.js.map +1 -0
  34. package/dist/2096.js +1 -0
  35. package/dist/2096.js.map +1 -0
  36. package/dist/2153.js +1 -0
  37. package/dist/2153.js.map +1 -0
  38. package/dist/216.js +1 -0
  39. package/dist/216.js.map +1 -0
  40. package/dist/2270.js +1 -0
  41. package/dist/2270.js.map +1 -0
  42. package/dist/2294.js +1 -0
  43. package/dist/2294.js.map +1 -0
  44. package/dist/2345.js +1 -0
  45. package/dist/2345.js.map +1 -0
  46. package/dist/2467.js +1 -0
  47. package/dist/2467.js.map +1 -0
  48. package/dist/2500.js +1 -0
  49. package/dist/2500.js.map +1 -0
  50. package/dist/251.js +1 -0
  51. package/dist/251.js.map +1 -0
  52. package/dist/257.js +1 -0
  53. package/dist/257.js.map +1 -0
  54. package/dist/2586.js +1 -0
  55. package/dist/2586.js.map +1 -0
  56. package/dist/2625.js +1 -0
  57. package/dist/2625.js.map +1 -0
  58. package/dist/2652.js +1 -0
  59. package/dist/2652.js.map +1 -0
  60. package/dist/2685.js +1 -0
  61. package/dist/2685.js.map +1 -0
  62. package/dist/2948.js +1 -0
  63. package/dist/2948.js.map +1 -0
  64. package/dist/3089.js +1 -0
  65. package/dist/3089.js.map +1 -0
  66. package/dist/3190.js +1 -0
  67. package/dist/3190.js.map +1 -0
  68. package/dist/3224.js +1 -0
  69. package/dist/3224.js.map +1 -0
  70. package/dist/3380.js +1 -0
  71. package/dist/3380.js.map +1 -0
  72. package/dist/3548.js +1 -0
  73. package/dist/3548.js.map +1 -0
  74. package/dist/3571.js +1 -0
  75. package/dist/3571.js.map +1 -0
  76. package/dist/3691.js +1 -0
  77. package/dist/3691.js.map +1 -0
  78. package/dist/3775.js +1 -0
  79. package/dist/3775.js.map +1 -0
  80. package/dist/3816.js +1 -0
  81. package/dist/3816.js.map +1 -0
  82. package/dist/3906.js +1 -0
  83. package/dist/3906.js.map +1 -0
  84. package/dist/3963.js +1 -0
  85. package/dist/3963.js.map +1 -0
  86. package/dist/405.js +1 -0
  87. package/dist/405.js.map +1 -0
  88. package/dist/4296.js +1 -0
  89. package/dist/4296.js.map +1 -0
  90. package/dist/4337.js +1 -0
  91. package/dist/4337.js.map +1 -0
  92. package/dist/4584.js +1 -0
  93. package/dist/4584.js.map +1 -0
  94. package/dist/4687.js +1 -0
  95. package/dist/4687.js.map +1 -0
  96. package/dist/4735.js +1 -0
  97. package/dist/4735.js.map +1 -0
  98. package/dist/4744.js +1 -0
  99. package/dist/4744.js.map +1 -0
  100. package/dist/4813.js +2 -0
  101. package/dist/4813.js.map +1 -0
  102. package/dist/4858.js +1 -0
  103. package/dist/4858.js.map +1 -0
  104. package/dist/487.js +1 -0
  105. package/dist/487.js.map +1 -0
  106. package/dist/4970.js +1 -0
  107. package/dist/4970.js.map +1 -0
  108. package/dist/5202.js +1 -0
  109. package/dist/5202.js.map +1 -0
  110. package/dist/5294.js +1 -0
  111. package/dist/5294.js.map +1 -0
  112. package/dist/5297.js +1 -0
  113. package/dist/5297.js.map +1 -0
  114. package/dist/545.js +1 -0
  115. package/dist/545.js.map +1 -0
  116. package/dist/5592.js +1 -0
  117. package/dist/5592.js.map +1 -0
  118. package/dist/5669.js +1 -0
  119. package/dist/5669.js.map +1 -0
  120. package/dist/5884.js +1 -0
  121. package/dist/5884.js.map +1 -0
  122. package/dist/5910.js +1 -0
  123. package/dist/5910.js.map +1 -0
  124. package/dist/5940.js +1 -0
  125. package/dist/5940.js.map +1 -0
  126. package/dist/6155.js +1 -0
  127. package/dist/6155.js.map +1 -0
  128. package/dist/6178.js +1 -0
  129. package/dist/6178.js.map +1 -0
  130. package/dist/6253.js +1 -0
  131. package/dist/6253.js.map +1 -0
  132. package/dist/6455.js +1 -0
  133. package/dist/6455.js.map +1 -0
  134. package/dist/6456.js +1 -0
  135. package/dist/6466.js +3 -0
  136. package/dist/6466.js.map +1 -0
  137. package/dist/6492.js +1 -0
  138. package/dist/6492.js.map +1 -0
  139. package/dist/6800.js +1 -0
  140. package/dist/6800.js.map +1 -0
  141. package/dist/6925.js +1 -0
  142. package/dist/6925.js.map +1 -0
  143. package/dist/7005.js +1 -0
  144. package/dist/7005.js.map +1 -0
  145. package/dist/7201.js +1 -0
  146. package/dist/7201.js.map +1 -0
  147. package/dist/7210.js +1 -0
  148. package/dist/7210.js.map +1 -0
  149. package/dist/7234.js +1 -0
  150. package/dist/7234.js.map +1 -0
  151. package/dist/7261.js +1 -0
  152. package/dist/7261.js.map +1 -0
  153. package/dist/7326.js +1 -0
  154. package/dist/7463.js +1 -0
  155. package/dist/7463.js.map +1 -0
  156. package/dist/7528.js +1 -0
  157. package/dist/7528.js.map +1 -0
  158. package/dist/7607.js +1 -0
  159. package/dist/7717.js +1 -0
  160. package/dist/7717.js.map +1 -0
  161. package/dist/7737.js +1 -0
  162. package/dist/7737.js.map +1 -0
  163. package/dist/7739.js +1 -0
  164. package/dist/7739.js.map +1 -0
  165. package/dist/7765.js +1 -0
  166. package/dist/7765.js.map +1 -0
  167. package/dist/7820.js +1 -0
  168. package/dist/7820.js.map +1 -0
  169. package/dist/7844.js +1 -0
  170. package/dist/7844.js.map +1 -0
  171. package/dist/7866.js +1 -0
  172. package/dist/7866.js.map +1 -0
  173. package/dist/7971.js +1 -0
  174. package/dist/7971.js.map +1 -0
  175. package/dist/8159.js +7 -0
  176. package/dist/8159.js.map +1 -0
  177. package/dist/8206.js +1 -0
  178. package/dist/8206.js.map +1 -0
  179. package/dist/8244.js +1 -0
  180. package/dist/8244.js.map +1 -0
  181. package/dist/8262.js +1 -0
  182. package/dist/8262.js.map +1 -0
  183. package/dist/8376.js +1 -0
  184. package/dist/8376.js.map +1 -0
  185. package/dist/845.js +1 -0
  186. package/dist/845.js.map +1 -0
  187. package/dist/846.js +17 -0
  188. package/dist/846.js.map +1 -0
  189. package/dist/8487.js +1 -0
  190. package/dist/8487.js.map +1 -0
  191. package/dist/8528.js +1 -0
  192. package/dist/8528.js.map +1 -0
  193. package/dist/8570.js +1 -0
  194. package/dist/8570.js.map +1 -0
  195. package/dist/87.js +1 -0
  196. package/dist/87.js.map +1 -0
  197. package/dist/8727.js +1 -0
  198. package/dist/8828.js +1 -0
  199. package/dist/8828.js.map +1 -0
  200. package/dist/8860.js +1 -0
  201. package/dist/8860.js.map +1 -0
  202. package/dist/9036.js +1 -0
  203. package/dist/9036.js.map +1 -0
  204. package/dist/9124.js +1 -0
  205. package/dist/9124.js.map +1 -0
  206. package/dist/9182.js +1 -0
  207. package/dist/921.js +1 -0
  208. package/dist/921.js.map +1 -0
  209. package/dist/9404.js +1 -0
  210. package/dist/9404.js.map +1 -0
  211. package/dist/9446.js +1 -0
  212. package/dist/9446.js.map +1 -0
  213. package/dist/9449.js +1 -0
  214. package/dist/9449.js.map +1 -0
  215. package/dist/9566.js +5 -0
  216. package/dist/9566.js.map +1 -0
  217. package/dist/9585.js +1 -0
  218. package/dist/9585.js.map +1 -0
  219. package/dist/9641.js +1 -0
  220. package/dist/9641.js.map +1 -0
  221. package/dist/9647.js +1 -0
  222. package/dist/9647.js.map +1 -0
  223. package/dist/9801.js +1 -0
  224. package/dist/9801.js.map +1 -0
  225. package/dist/9835.js +11 -0
  226. package/dist/9835.js.map +1 -0
  227. package/dist/kenyaemr-esm-admin-app.js +5 -5
  228. package/dist/kenyaemr-esm-admin-app.js.buildmanifest.json +2672 -154
  229. package/dist/kenyaemr-esm-admin-app.js.map +1 -1
  230. package/dist/main.js +5 -31
  231. package/dist/main.js.map +1 -1
  232. package/dist/routes.json +1 -1
  233. package/package.json +5 -7
  234. package/rspack.config.js +1 -1
  235. package/src/components/confirm-modal/confirmation-operation.test.tsx +8 -19
  236. package/src/components/dashboard/dashboard.component.tsx +4 -1
  237. package/src/components/empty-state/empty-state-log.test.tsx +3 -4
  238. package/src/components/facility-setup/constant/index.ts +3 -0
  239. package/src/components/facility-setup/facility-info.component.tsx +247 -108
  240. package/src/components/facility-setup/facility-info.scss +136 -55
  241. package/src/components/facility-setup/facility-setup.component.tsx +2 -2
  242. package/src/components/facility-setup/header/header.component.tsx +4 -10
  243. package/src/components/facility-setup/header/header.scss +3 -9
  244. package/src/components/facility-setup/shared/custom-info.component.tsx +9 -0
  245. package/src/components/facility-setup/shared/custom-section-card.component.tsx +10 -0
  246. package/src/components/facility-setup/shared/custom-status-tag.component.tsx +22 -0
  247. package/src/components/facility-setup/type/index.ts +61 -0
  248. package/src/components/facility-setup/useFacilityRegistry.ts +29 -0
  249. package/src/components/global-property/dashboard/global-property-dashboard.component.tsx +23 -0
  250. package/src/components/global-property/dashboard/global-property-dashboard.scss +6 -0
  251. package/src/components/global-property/hooks/useGlobalProperty.ts +64 -0
  252. package/src/components/global-property/index.ts +14 -0
  253. package/src/components/global-property/modal/delete-global-property-modal.component.tsx +71 -0
  254. package/src/components/global-property/modal/delete-global-property-modal.test.tsx +131 -0
  255. package/src/components/global-property/table/global-property-table.component.tsx +249 -0
  256. package/src/components/global-property/table/global-property-table.scss +34 -0
  257. package/src/components/global-property/table/global-property-table.test.tsx +198 -0
  258. package/src/components/global-property/workspace/global-property-form-schema.ts +32 -0
  259. package/src/components/global-property/workspace/global-property.workspace.scss +40 -0
  260. package/src/components/global-property/workspace/global-property.workspace.test.tsx +172 -0
  261. package/src/components/global-property/workspace/global-property.workspace.tsx +260 -0
  262. package/src/components/hook/healthWorkerRegistry.ts +78 -0
  263. package/src/components/hook/useProfessionalRegistryEnums.ts +59 -0
  264. package/src/components/locations/forms/add-location/add-location.workspace.tsx +96 -95
  265. package/src/components/locations/forms/search-location/search-location.workspace.tsx +90 -85
  266. package/src/components/locations/tables/locations-table.component.tsx +117 -121
  267. package/src/components/logs-table/operation-log-table.component.tsx +87 -75
  268. package/src/components/logs-table/operation-log.test.tsx +134 -28
  269. package/src/components/modal/hwr-confirmation.modal.scss +80 -4
  270. package/src/components/modal/hwr-confirmation.modal.tsx +118 -128
  271. package/src/components/modal/hwr-sync.modal.tsx +194 -106
  272. package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-workspace/user-role-scope.workspace.tsx +13 -13
  273. package/src/components/users/manage-users/user-details/user-detail.scss +167 -39
  274. package/src/components/users/manage-users/user-details/user-details.component.tsx +130 -122
  275. package/src/components/users/manage-users/user-list/user-list.component.tsx +22 -9
  276. package/src/components/users/manage-users/user-management.workspace.scss +233 -95
  277. package/src/components/users/manage-users/user-management.workspace.tsx +800 -687
  278. package/src/components/users/userManagementFormSchema.tsx +17 -8
  279. package/src/config-schema.ts +48 -68
  280. package/src/index.ts +64 -31
  281. package/src/left-pannel-link.component.tsx +5 -3
  282. package/src/root.component.tsx +13 -13
  283. package/src/routes.json +57 -38
  284. package/src/types/index.ts +40 -3
  285. package/translations/am.json +196 -13
  286. package/translations/en.json +207 -24
  287. package/translations/fr.json +243 -58
  288. package/translations/sw.json +312 -129
  289. package/tsconfig.json +1 -1
  290. package/dist/127.js +0 -1
  291. package/dist/267.js +0 -1
  292. package/dist/267.js.map +0 -1
  293. package/dist/281.js +0 -15
  294. package/dist/281.js.map +0 -1
  295. package/dist/329.js +0 -1
  296. package/dist/329.js.map +0 -1
  297. package/dist/40.js +0 -1
  298. package/dist/466.js +0 -1
  299. package/dist/466.js.map +0 -1
  300. package/dist/472.js +0 -1
  301. package/dist/472.js.map +0 -1
  302. package/dist/478.js +0 -1
  303. package/dist/478.js.map +0 -1
  304. package/dist/585.js +0 -1
  305. package/dist/585.js.map +0 -1
  306. package/dist/630.js +0 -1
  307. package/dist/630.js.map +0 -1
  308. package/dist/675.js +0 -1
  309. package/dist/675.js.map +0 -1
  310. package/dist/689.js +0 -1
  311. package/dist/689.js.map +0 -1
  312. package/dist/706.js +0 -27
  313. package/dist/706.js.map +0 -1
  314. package/dist/729.js +0 -17
  315. package/dist/729.js.map +0 -1
  316. package/dist/774.js +0 -1
  317. package/dist/774.js.map +0 -1
  318. package/dist/847.js +0 -1
  319. package/dist/847.js.map +0 -1
  320. package/dist/85.js +0 -1
  321. package/dist/85.js.map +0 -1
  322. package/dist/882.js +0 -1
  323. package/dist/91.js +0 -1
  324. package/dist/91.js.map +0 -1
  325. package/dist/916.js +0 -1
  326. package/dist/998.js +0 -1
  327. package/dist/998.js.map +0 -1
  328. package/jest.config.js +0 -8
  329. package/src/components/facility-setup/card.component.tsx +0 -16
  330. package/src/components/facility-setup/facility-setup.resource.tsx +0 -7
  331. package/src/components/hook/healthWorkerAdapter.ts +0 -213
  332. package/src/components/hook/useFacilityInfo.tsx +0 -37
@@ -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,249 @@
1
+ import React, { useCallback, useMemo, useState } from 'react';
2
+ import {
3
+ Button,
4
+ DataTable,
5
+ DataTableSkeleton,
6
+ Pagination,
7
+ Search,
8
+ Table,
9
+ TableBody,
10
+ TableCell,
11
+ TableHead,
12
+ TableHeader,
13
+ TableRow,
14
+ type DataTableHeader,
15
+ } from '@carbon/react';
16
+ import { Add, Edit, TrashCan } from '@carbon/react/icons';
17
+ import { useTranslation } from 'react-i18next';
18
+ import {
19
+ ErrorCard,
20
+ isDesktop,
21
+ launchWorkspace2,
22
+ showModal,
23
+ useDebounce,
24
+ useLayoutType,
25
+ usePaginationInfo,
26
+ } from '@openmrs/esm-framework';
27
+
28
+ import { useGlobalProperties } from '../hooks/useGlobalProperty';
29
+ import styles from './global-property-table.scss';
30
+
31
+ const DEFAULT_PAGE_SIZE = 10;
32
+ const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
33
+
34
+ type GlobalPropertyRow = {
35
+ id: string;
36
+ property: string;
37
+ value: string;
38
+ };
39
+
40
+ const GlobalPropertyTable: React.FC = () => {
41
+ const { t } = useTranslation();
42
+ const layoutType = useLayoutType();
43
+ const desktop = isDesktop(layoutType);
44
+
45
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
46
+ const [searchTerm, setSearchTerm] = useState('');
47
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
48
+
49
+ const {
50
+ isLoading,
51
+ data: globalProperties = [],
52
+ error,
53
+ goTo,
54
+ currentPage,
55
+ totalCount,
56
+ mutate,
57
+ } = useGlobalProperties(pageSize, debouncedSearchTerm);
58
+
59
+ const { pageSizes } = usePaginationInfo(DEFAULT_PAGE_SIZE, totalCount, currentPage, globalProperties.length);
60
+
61
+ const headers: DataTableHeader[] = useMemo(
62
+ () => [
63
+ { key: 'property', header: t('property', 'Property') },
64
+ { key: 'value', header: t('value', 'Value') },
65
+ ],
66
+ [t],
67
+ );
68
+
69
+ const rows: Array<GlobalPropertyRow> = useMemo(
70
+ () =>
71
+ globalProperties.map((gp, idx) => ({
72
+ id: gp?.uuid ?? `gp-${idx}`,
73
+ property: gp?.property ?? '',
74
+ value: gp?.value ?? '',
75
+ })),
76
+ [globalProperties],
77
+ );
78
+
79
+ const handleSearchChange = useCallback(
80
+ (event: React.ChangeEvent<HTMLInputElement>) => {
81
+ setSearchTerm(event.target.value);
82
+ if (currentPage !== 1) {
83
+ goTo(1);
84
+ }
85
+ },
86
+ [currentPage, goTo],
87
+ );
88
+
89
+ const handlePaginationChange = useCallback(
90
+ ({ page, pageSize: newSize }: { page: number; pageSize: number }) => {
91
+ if (newSize !== pageSize) {
92
+ setPageSize(newSize);
93
+ }
94
+ goTo(page);
95
+ },
96
+ [pageSize, goTo],
97
+ );
98
+
99
+ const openWorkspace = useCallback(
100
+ (systemSetting?: (typeof globalProperties)[number]) => {
101
+ launchWorkspace2('global-property-workspace', {
102
+ systemSetting,
103
+ mutateGlobalProperty: mutate,
104
+ });
105
+ },
106
+ [globalProperties, mutate],
107
+ );
108
+
109
+ const handleEdit = useCallback(
110
+ (row: GlobalPropertyRow) => {
111
+ const systemSetting = globalProperties.find((gp) => gp.uuid === row.id);
112
+ openWorkspace(systemSetting);
113
+ },
114
+ [globalProperties, openWorkspace],
115
+ );
116
+
117
+ const handleDelete = useCallback(
118
+ (row: GlobalPropertyRow) => {
119
+ const dispose = showModal('delete-global-property-modal', {
120
+ close: () => dispose(),
121
+ property: row.property,
122
+ uuid: row.id,
123
+ onDeleted: () => mutate(),
124
+ });
125
+ },
126
+ [mutate],
127
+ );
128
+
129
+ const renderContent = () => {
130
+ if (isLoading) {
131
+ return (
132
+ <DataTableSkeleton
133
+ aria-label={t('globalProperties', 'Global properties')}
134
+ headers={headers}
135
+ showHeader
136
+ showToolbar
137
+ />
138
+ );
139
+ }
140
+
141
+ if (rows.length === 0) {
142
+ return (
143
+ <p className={styles.emptyState}>
144
+ {debouncedSearchTerm
145
+ ? t('noMatchingGlobalProperties', 'No global properties match your search')
146
+ : t('noGlobalProperties', 'No global properties to display')}
147
+ </p>
148
+ );
149
+ }
150
+
151
+ return (
152
+ <>
153
+ <DataTable useZebraStyles size={desktop ? 'sm' : 'md'} rows={rows} headers={headers}>
154
+ {({ rows: renderRows, headers: renderHeaders, getTableProps, getHeaderProps, getRowProps, getCellProps }) => (
155
+ <Table {...getTableProps()}>
156
+ <TableHead>
157
+ <TableRow>
158
+ {renderHeaders.map((header) => (
159
+ <TableHeader {...getHeaderProps({ header })}>{header.header}</TableHeader>
160
+ ))}
161
+ <TableHeader>
162
+ <span className={styles.visuallyHidden}>{t('actions', 'Actions')}</span>
163
+ </TableHeader>
164
+ </TableRow>
165
+ </TableHead>
166
+ <TableBody>
167
+ {renderRows.map((row) => {
168
+ const sourceRow = rows.find((r) => r.id === row.id)!;
169
+ return (
170
+ <TableRow {...getRowProps({ row })}>
171
+ {row.cells.map((cell) => (
172
+ <TableCell {...getCellProps({ cell })}>{cell.value}</TableCell>
173
+ ))}
174
+ <TableCell className={styles.actionsCell}>
175
+ <Button
176
+ kind="ghost"
177
+ size="sm"
178
+ renderIcon={Edit}
179
+ iconDescription={t('edit', 'Edit')}
180
+ hasIconOnly
181
+ onClick={() => handleEdit(sourceRow)}
182
+ />
183
+ <Button
184
+ kind="danger--ghost"
185
+ size="sm"
186
+ renderIcon={TrashCan}
187
+ iconDescription={t('delete', 'Delete')}
188
+ hasIconOnly
189
+ onClick={() => handleDelete(sourceRow)}
190
+ />
191
+ </TableCell>
192
+ </TableRow>
193
+ );
194
+ })}
195
+ </TableBody>
196
+ </Table>
197
+ )}
198
+ </DataTable>
199
+
200
+ <Pagination
201
+ itemsPerPageText={t('itemsPerPage', 'Items per page:')}
202
+ forwardText={t('nextPage', 'Next page')}
203
+ backwardText={t('previousPage', 'Previous page')}
204
+ itemRangeText={(min, max, total) =>
205
+ t('minMaxItems', '{{min}}-{{max}} of {{total}} items', { min, max, total })
206
+ }
207
+ pageRangeText={(_current, total) => t('pageRangeText', 'of {{count}} pages', { count: total })}
208
+ page={currentPage}
209
+ pageSize={pageSize}
210
+ pageSizes={pageSizes?.length > 0 ? pageSizes : PAGE_SIZE_OPTIONS}
211
+ totalItems={totalCount ?? 0}
212
+ onChange={handlePaginationChange}
213
+ />
214
+ </>
215
+ );
216
+ };
217
+
218
+ if (error) {
219
+ return (
220
+ <div className={styles.dataTableContainer}>
221
+ <ErrorCard error={error} headerTitle={t('globalPropertyError', 'Global property')} />
222
+ </div>
223
+ );
224
+ }
225
+
226
+ return (
227
+ <div className={styles.dataTableContainer}>
228
+ <div className={styles.tableHeaderSection}>
229
+ <Search
230
+ id="global-property-search"
231
+ labelText=""
232
+ placeholder={t('searchGlobalPropertiesByName', 'Search global property by name')}
233
+ closeButtonLabelText={t('clearSearchButton', 'Clear search button')}
234
+ size={desktop ? 'md' : 'lg'}
235
+ value={searchTerm}
236
+ onChange={handleSearchChange}
237
+ type="search"
238
+ />
239
+ <Button size={desktop ? 'md' : 'lg'} kind="ghost" renderIcon={Add} onClick={() => openWorkspace()}>
240
+ {t('addGlobalProperty', 'Add new global property')}
241
+ </Button>
242
+ </div>
243
+
244
+ {renderContent()}
245
+ </div>
246
+ );
247
+ };
248
+
249
+ export default GlobalPropertyTable;
@@ -0,0 +1,34 @@
1
+ @use '@carbon/layout';
2
+
3
+ .dataTableContainer {
4
+ height: 100%;
5
+ margin: layout.$layout-01;
6
+ }
7
+
8
+ .tableHeaderSection {
9
+ display: flex;
10
+ align-items: center;
11
+ }
12
+
13
+ .actionsCell {
14
+ display: flex;
15
+ justify-content: flex-end;
16
+ }
17
+
18
+ .visuallyHidden {
19
+ position: absolute;
20
+ width: 1px;
21
+ height: 1px;
22
+ padding: 0;
23
+ margin: -1px;
24
+ overflow: hidden;
25
+ clip: rect(0, 0, 0, 0);
26
+ white-space: nowrap;
27
+ border: 0;
28
+ }
29
+
30
+ .emptyState {
31
+ padding: 2rem;
32
+ text-align: center;
33
+ color: var(--cds-text-secondary);
34
+ }
@@ -0,0 +1,198 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
5
+ import GlobalPropertyTable from './global-property-table.component';
6
+
7
+ const mockGoTo = vi.fn();
8
+ const mockMutate = vi.fn();
9
+ const mockLaunchWorkspace2 = vi.fn();
10
+ const mockShowModal = vi.fn(() => vi.fn());
11
+
12
+ vi.mock('react-i18next', () => ({
13
+ useTranslation: () => ({ t: (_key: string, fallback: string, opts?: Record<string, unknown>) => fallback }),
14
+ }));
15
+
16
+ vi.mock('@openmrs/esm-framework', () => ({
17
+ useLayoutType: vi.fn(() => 'desktop'),
18
+ isDesktop: vi.fn(() => true),
19
+ launchWorkspace2: (...args: unknown[]) => mockLaunchWorkspace2(...args),
20
+ showModal: (...args: unknown[]) => mockShowModal(...args),
21
+ useDebounce: vi.fn((val: string) => val),
22
+ usePaginationInfo: vi.fn(() => ({ pageSizes: [10, 20, 50] })),
23
+ ErrorCard: ({ headerTitle }: { headerTitle: string }) => <div data-testid="error-card">{headerTitle}</div>,
24
+ }));
25
+
26
+ vi.mock('@carbon/react', async (importOriginal) => {
27
+ const original = await importOriginal<typeof import('@carbon/react')>();
28
+ return {
29
+ ...original,
30
+ DataTable: ({
31
+ children,
32
+ rows,
33
+ headers,
34
+ }: {
35
+ children: (props: any) => React.ReactNode;
36
+ rows: any[];
37
+ headers: any[];
38
+ }) => {
39
+ if (typeof children === 'function') {
40
+ const renderRows = rows.map((row) => ({
41
+ ...row,
42
+ cells: headers.map((h) => ({
43
+ id: `${row.id}:${h.key}`,
44
+ value: row[h.key],
45
+ info: { header: h.key },
46
+ })),
47
+ }));
48
+ return children({
49
+ rows: renderRows,
50
+ headers,
51
+ getTableProps: () => ({}),
52
+ getHeaderProps: ({ header }: any) => ({ key: header.key }),
53
+ getRowProps: ({ row }: any) => ({ key: row.id }),
54
+ getCellProps: ({ cell }: any) => ({ key: cell.id }),
55
+ });
56
+ }
57
+ return children;
58
+ },
59
+ Button: ({ children, onClick, disabled, iconDescription, hasIconOnly, type }: any) => (
60
+ <button
61
+ onClick={onClick}
62
+ disabled={disabled}
63
+ aria-label={hasIconOnly ? iconDescription : undefined}
64
+ type={type || 'button'}>
65
+ {hasIconOnly ? null : children}
66
+ </button>
67
+ ),
68
+ };
69
+ });
70
+
71
+ vi.mock('../hooks/useGlobalProperty', () => ({
72
+ useGlobalProperties: vi.fn(),
73
+ }));
74
+
75
+ import { useGlobalProperties } from '../hooks/useGlobalProperty';
76
+
77
+ const mockProperties = [
78
+ { uuid: 'uuid-1', property: 'setting.one', value: 'value1', description: 'First setting' },
79
+ { uuid: 'uuid-2', property: 'setting.two', value: 'value2', description: 'Second setting' },
80
+ ];
81
+
82
+ function setupMock(overrides = {}) {
83
+ (useGlobalProperties as ReturnType<typeof vi.fn>).mockReturnValue({
84
+ isLoading: false,
85
+ data: mockProperties,
86
+ error: null,
87
+ goTo: mockGoTo,
88
+ currentPage: 1,
89
+ totalCount: 2,
90
+ mutate: mockMutate,
91
+ ...overrides,
92
+ });
93
+ }
94
+
95
+ describe('GlobalPropertyTable', () => {
96
+ beforeEach(() => {
97
+ vi.clearAllMocks();
98
+ });
99
+
100
+ it('shows a loading skeleton while data is loading', () => {
101
+ setupMock({ isLoading: true, data: [] });
102
+ render(<GlobalPropertyTable />);
103
+ expect(screen.getByRole('table')).toBeInTheDocument();
104
+ });
105
+
106
+ it('shows empty state message when no properties exist', () => {
107
+ setupMock({ data: [] });
108
+ render(<GlobalPropertyTable />);
109
+ expect(screen.getByText('No global properties to display')).toBeInTheDocument();
110
+ });
111
+
112
+ it('renders the table with property and value columns', () => {
113
+ setupMock();
114
+ render(<GlobalPropertyTable />);
115
+ expect(screen.getByText('Property')).toBeInTheDocument();
116
+ expect(screen.getByText('Value')).toBeInTheDocument();
117
+ });
118
+
119
+ it('renders all global property rows', () => {
120
+ setupMock();
121
+ render(<GlobalPropertyTable />);
122
+ expect(screen.getByText('setting.one')).toBeInTheDocument();
123
+ expect(screen.getByText('value1')).toBeInTheDocument();
124
+ expect(screen.getByText('setting.two')).toBeInTheDocument();
125
+ expect(screen.getByText('value2')).toBeInTheDocument();
126
+ });
127
+
128
+ it('opens the add workspace when "Add new global property" button is clicked', () => {
129
+ setupMock();
130
+ render(<GlobalPropertyTable />);
131
+ fireEvent.click(screen.getByText('Add new global property'));
132
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith(
133
+ 'global-property-workspace',
134
+ expect.objectContaining({
135
+ systemSetting: undefined,
136
+ mutateGlobalProperty: mockMutate,
137
+ }),
138
+ );
139
+ });
140
+
141
+ it('opens the edit workspace with the correct property when edit is clicked', () => {
142
+ setupMock();
143
+ render(<GlobalPropertyTable />);
144
+ const editButtons = screen.getAllByRole('button', { name: 'Edit' });
145
+ fireEvent.click(editButtons[0]);
146
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith(
147
+ 'global-property-workspace',
148
+ expect.objectContaining({
149
+ systemSetting: mockProperties[0],
150
+ }),
151
+ );
152
+ });
153
+
154
+ it('opens the delete modal with the correct property when delete is clicked', () => {
155
+ setupMock();
156
+ render(<GlobalPropertyTable />);
157
+ const deleteButtons = screen.getAllByRole('button', { name: 'Delete' });
158
+ fireEvent.click(deleteButtons[0]);
159
+ expect(mockShowModal).toHaveBeenCalledWith(
160
+ 'delete-global-property-modal',
161
+ expect.objectContaining({
162
+ property: 'setting.one',
163
+ uuid: 'uuid-1',
164
+ }),
165
+ );
166
+ });
167
+
168
+ it('filters results and resets to page 1 when user types in the search box', async () => {
169
+ setupMock({ currentPage: 2 });
170
+ render(<GlobalPropertyTable />);
171
+ const searchInput = screen.getByPlaceholderText('Search global property by name');
172
+ fireEvent.change(searchInput, { target: { value: 'setting' } });
173
+ await waitFor(() => {
174
+ expect(mockGoTo).toHaveBeenCalledWith(1);
175
+ });
176
+ });
177
+
178
+ it('shows "no matching" message when search yields no results', () => {
179
+ setupMock({ data: [] });
180
+ render(<GlobalPropertyTable />);
181
+ const searchInput = screen.getByPlaceholderText('Search global property by name');
182
+ fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
183
+ expect(screen.getByText('No global properties match your search')).toBeInTheDocument();
184
+ });
185
+
186
+ it('renders an error card when the hook returns an error', () => {
187
+ setupMock({ error: new Error('Network error'), data: [] });
188
+ render(<GlobalPropertyTable />);
189
+ expect(screen.getByTestId('error-card')).toBeInTheDocument();
190
+ expect(screen.getByText('Global property')).toBeInTheDocument();
191
+ });
192
+
193
+ it('renders a search input for filtering properties', () => {
194
+ setupMock();
195
+ render(<GlobalPropertyTable />);
196
+ expect(screen.getByPlaceholderText('Search global property by name')).toBeInTheDocument();
197
+ });
198
+ });
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import type { TFunction } from 'i18next';
3
+
4
+ export const createGlobalPropertyFormSchema = (t: TFunction) =>
5
+ z.object({
6
+ property: z.string().min(1, { message: t('gpPropertyNameRequired', 'Property name is required') }),
7
+ description: z.string().optional(),
8
+ datatypeClassname: z.string().optional(),
9
+ datatypeConfig: z.string().optional(),
10
+ preferredHandlerClassname: z.string().optional(),
11
+ handlerConfig: z.string().nullable().optional(),
12
+ value: z.string().min(1, { message: t('gpValueRequired', 'Value is required') }),
13
+ });
14
+
15
+ export type GlobalPropertyFormType = z.infer<ReturnType<typeof createGlobalPropertyFormSchema>>;
16
+
17
+ export const openmrsCustomDatatypes = [
18
+ 'org.openmrs.customdatatype.datatype.BooleanDatatype',
19
+ 'org.openmrs.customdatatype.datatype.DateDatatype',
20
+ 'org.openmrs.customdatatype.datatype.DateTimeDatatype',
21
+ 'org.openmrs.customdatatype.datatype.FloatDatatype',
22
+ 'org.openmrs.customdatatype.datatype.FreeTextDatatype',
23
+ 'org.openmrs.customdatatype.datatype.LongFreeTextDatatype',
24
+ 'org.openmrs.customdatatype.datatype.RegexValidatedTextDatatype',
25
+ 'org.openmrs.customdatatype.datatype.SpecifiedTextOptionsDatatype',
26
+ 'org.openmrs.customdatatype.datatype.ConceptDatatype',
27
+ 'org.openmrs.customdatatype.datatype.LocationDatatype',
28
+ 'org.openmrs.customdatatype.datatype.ProgramDatatype',
29
+ 'org.openmrs.customdatatype.datatype.ProviderDatatype',
30
+ ];
31
+
32
+ export type OpenmrsCustomDatatype = (typeof openmrsCustomDatatypes)[number];
@@ -0,0 +1,40 @@
1
+ @use '@carbon/colors';
2
+ @use '@carbon/layout';
3
+
4
+ .form {
5
+ display: flex;
6
+ flex-direction: column;
7
+ justify-content: space-between;
8
+ height: 100%;
9
+ }
10
+
11
+ .formContainer {
12
+ margin: layout.$spacing-05;
13
+ display: flex;
14
+ flex-direction: column;
15
+ gap: layout.$spacing-05;
16
+ overflow-y: auto;
17
+ }
18
+
19
+ .tablet {
20
+ padding: layout.$spacing-06 layout.$spacing-05;
21
+ background-color: colors.$white;
22
+ }
23
+
24
+ .desktop {
25
+ padding: 0;
26
+
27
+ & button {
28
+ max-width: 50% !important;
29
+ }
30
+ }
31
+
32
+ .buttonContainer {
33
+ max-width: 50%;
34
+ }
35
+
36
+ .inlineLoading {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: layout.$spacing-03;
40
+ }