@kenyaemr/esm-admin-app 5.4.4-pre.5 → 5.4.4-pre.501

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 (406) hide show
  1. package/.turbo/turbo-build.log +5 -12
  2. package/dist/1102.js +1 -0
  3. package/dist/1102.js.map +1 -0
  4. package/dist/1278.js +1 -0
  5. package/dist/1278.js.map +1 -0
  6. package/dist/1442.js +1 -0
  7. package/dist/1442.js.map +1 -0
  8. package/dist/15.js +1 -0
  9. package/dist/15.js.map +1 -0
  10. package/dist/162.js +1 -0
  11. package/dist/162.js.map +1 -0
  12. package/dist/1620.js +1 -0
  13. package/dist/1620.js.map +1 -0
  14. package/dist/1789.js +1 -0
  15. package/dist/1789.js.map +1 -0
  16. package/dist/1831.js +1 -0
  17. package/dist/1831.js.map +1 -0
  18. package/dist/1844.js +1 -0
  19. package/dist/1844.js.map +1 -0
  20. package/dist/1854.js +1 -0
  21. package/dist/1854.js.map +1 -0
  22. package/dist/1919.js +1 -0
  23. package/dist/1919.js.map +1 -0
  24. package/dist/193.js +1 -0
  25. package/dist/193.js.map +1 -0
  26. package/dist/1946.js +1 -0
  27. package/dist/1946.js.map +1 -0
  28. package/dist/2007.js +1 -0
  29. package/dist/2007.js.map +1 -0
  30. package/dist/2043.js +1 -0
  31. package/dist/2043.js.map +1 -0
  32. package/dist/2127.js +1 -0
  33. package/dist/2127.js.map +1 -0
  34. package/dist/2146.js +1 -0
  35. package/dist/2146.js.map +1 -0
  36. package/dist/2152.js +1 -0
  37. package/dist/2152.js.map +1 -0
  38. package/dist/2202.js +1 -0
  39. package/dist/2202.js.map +1 -0
  40. package/dist/2258.js +1 -0
  41. package/dist/2258.js.map +1 -0
  42. package/dist/2394.js +1 -0
  43. package/dist/2394.js.map +1 -0
  44. package/dist/2468.js +1 -0
  45. package/dist/2468.js.map +1 -0
  46. package/dist/2534.js +1 -0
  47. package/dist/2534.js.map +1 -0
  48. package/dist/2556.js +1 -0
  49. package/dist/2556.js.map +1 -0
  50. package/dist/26.js +1 -0
  51. package/dist/26.js.map +1 -0
  52. package/dist/2638.js +1 -0
  53. package/dist/2638.js.map +1 -0
  54. package/dist/2648.js +1 -0
  55. package/dist/2648.js.map +1 -0
  56. package/dist/2709.js +1 -0
  57. package/dist/2709.js.map +1 -0
  58. package/dist/2823.js +1 -0
  59. package/dist/2823.js.map +1 -0
  60. package/dist/2840.js +1 -0
  61. package/dist/2840.js.map +1 -0
  62. package/dist/2842.js +1 -0
  63. package/dist/2842.js.map +1 -0
  64. package/dist/2916.js +1 -0
  65. package/dist/2916.js.map +1 -0
  66. package/dist/2924.js +1 -0
  67. package/dist/2924.js.map +1 -0
  68. package/dist/3137.js +1 -0
  69. package/dist/3137.js.map +1 -0
  70. package/dist/3248.js +1 -0
  71. package/dist/3248.js.map +1 -0
  72. package/dist/3282.js +1 -0
  73. package/dist/3282.js.map +1 -0
  74. package/dist/3285.js +1 -0
  75. package/dist/3285.js.map +1 -0
  76. package/dist/3302.js +1 -0
  77. package/dist/3302.js.map +1 -0
  78. package/dist/3335.js +1 -0
  79. package/dist/3335.js.map +1 -0
  80. package/dist/3378.js +1 -0
  81. package/dist/3378.js.map +1 -0
  82. package/dist/3504.js +1 -0
  83. package/dist/3504.js.map +1 -0
  84. package/dist/356.js +1 -0
  85. package/dist/356.js.map +1 -0
  86. package/dist/3569.js +1 -0
  87. package/dist/3569.js.map +1 -0
  88. package/dist/3580.js +1 -0
  89. package/dist/3580.js.map +1 -0
  90. package/dist/3690.js +1 -0
  91. package/dist/3690.js.map +1 -0
  92. package/dist/3723.js +1 -0
  93. package/dist/3723.js.map +1 -0
  94. package/dist/3745.js +1 -0
  95. package/dist/3745.js.map +1 -0
  96. package/dist/3796.js +1 -0
  97. package/dist/3796.js.map +1 -0
  98. package/dist/3813.js +1 -0
  99. package/dist/3813.js.map +1 -0
  100. package/dist/393.js +6 -0
  101. package/dist/393.js.map +1 -0
  102. package/dist/3953.js +1 -0
  103. package/dist/3953.js.map +1 -0
  104. package/dist/3989.js +1 -0
  105. package/dist/3989.js.map +1 -0
  106. package/dist/4124.js +1 -0
  107. package/dist/4124.js.map +1 -0
  108. package/dist/4163.js +1 -0
  109. package/dist/4163.js.map +1 -0
  110. package/dist/4178.js +1 -0
  111. package/dist/4178.js.map +1 -0
  112. package/dist/4308.js +1 -0
  113. package/dist/4308.js.map +1 -0
  114. package/dist/4487.js +11 -0
  115. package/dist/4487.js.map +1 -0
  116. package/dist/4588.js +1 -0
  117. package/dist/4588.js.map +1 -0
  118. package/dist/4637.js +1 -0
  119. package/dist/4637.js.map +1 -0
  120. package/dist/466.js +1 -1
  121. package/dist/466.js.map +1 -1
  122. package/dist/4711.js +1 -0
  123. package/dist/4711.js.map +1 -0
  124. package/dist/4759.js +1 -0
  125. package/dist/4759.js.map +1 -0
  126. package/dist/4817.js +1 -0
  127. package/dist/4817.js.map +1 -0
  128. package/dist/4818.js +1 -0
  129. package/dist/4818.js.map +1 -0
  130. package/dist/4902.js +1 -0
  131. package/dist/4902.js.map +1 -0
  132. package/dist/4973.js +1 -0
  133. package/dist/4973.js.map +1 -0
  134. package/dist/500.js +1 -0
  135. package/dist/500.js.map +1 -0
  136. package/dist/5086.js +1 -0
  137. package/dist/5086.js.map +1 -0
  138. package/dist/5117.js +1 -0
  139. package/dist/5117.js.map +1 -0
  140. package/dist/5203.js +1 -0
  141. package/dist/5203.js.map +1 -0
  142. package/dist/5361.js +1 -0
  143. package/dist/5361.js.map +1 -0
  144. package/dist/5362.js +1 -0
  145. package/dist/5362.js.map +1 -0
  146. package/dist/5366.js +1 -0
  147. package/dist/5366.js.map +1 -0
  148. package/dist/5536.js +1 -0
  149. package/dist/5536.js.map +1 -0
  150. package/dist/5567.js +1 -0
  151. package/dist/5567.js.map +1 -0
  152. package/dist/5598.js +1 -0
  153. package/dist/5598.js.map +1 -0
  154. package/dist/5676.js +1 -0
  155. package/dist/5676.js.map +1 -0
  156. package/dist/5693.js +1 -0
  157. package/dist/5693.js.map +1 -0
  158. package/dist/5731.js +5 -0
  159. package/dist/5731.js.map +1 -0
  160. package/dist/5767.js +1 -0
  161. package/dist/5767.js.map +1 -0
  162. package/dist/5809.js +1 -0
  163. package/dist/5809.js.map +1 -0
  164. package/dist/5825.js +1 -0
  165. package/dist/5825.js.map +1 -0
  166. package/dist/5932.js +1 -0
  167. package/dist/5932.js.map +1 -0
  168. package/dist/5941.js +1 -0
  169. package/dist/5941.js.map +1 -0
  170. package/dist/6104.js +1 -0
  171. package/dist/6104.js.map +1 -0
  172. package/dist/6273.js +1 -0
  173. package/dist/6273.js.map +1 -0
  174. package/dist/6337.js +1 -0
  175. package/dist/6337.js.map +1 -0
  176. package/dist/6355.js +1 -0
  177. package/dist/6355.js.map +1 -0
  178. package/dist/6357.js +1 -0
  179. package/dist/6357.js.map +1 -0
  180. package/dist/6427.js +1 -0
  181. package/dist/6427.js.map +1 -0
  182. package/dist/6478.js +7 -0
  183. package/dist/6478.js.map +1 -0
  184. package/dist/6508.js +1 -0
  185. package/dist/6508.js.map +1 -0
  186. package/dist/6567.js +1 -0
  187. package/dist/6567.js.map +1 -0
  188. package/dist/6634.js +1 -0
  189. package/dist/6634.js.map +1 -0
  190. package/dist/6698.js +1 -0
  191. package/dist/6698.js.map +1 -0
  192. package/dist/682.js +1 -0
  193. package/dist/682.js.map +1 -0
  194. package/dist/6848.js +1 -0
  195. package/dist/6848.js.map +1 -0
  196. package/dist/689.js +1 -1
  197. package/dist/689.js.map +1 -1
  198. package/dist/6978.js +1 -0
  199. package/dist/6978.js.map +1 -0
  200. package/dist/7085.js +1 -0
  201. package/dist/7085.js.map +1 -0
  202. package/dist/7187.js +1 -0
  203. package/dist/7187.js.map +1 -0
  204. package/dist/7209.js +1 -0
  205. package/dist/7209.js.map +1 -0
  206. package/dist/740.js +1 -0
  207. package/dist/740.js.map +1 -0
  208. package/dist/7419.js +1 -0
  209. package/dist/7419.js.map +1 -0
  210. package/dist/7478.js +1 -0
  211. package/dist/7478.js.map +1 -0
  212. package/dist/7686.js +1 -0
  213. package/dist/7686.js.map +1 -0
  214. package/dist/770.js +1 -0
  215. package/dist/770.js.map +1 -0
  216. package/dist/7782.js +1 -0
  217. package/dist/7782.js.map +1 -0
  218. package/dist/7783.js +1 -0
  219. package/dist/7783.js.map +1 -0
  220. package/dist/7808.js +1 -0
  221. package/dist/7808.js.map +1 -0
  222. package/dist/781.js +1 -0
  223. package/dist/781.js.map +1 -0
  224. package/dist/8183.js +1 -0
  225. package/dist/8183.js.map +1 -0
  226. package/dist/8196.js +1 -0
  227. package/dist/8196.js.map +1 -0
  228. package/dist/820.js +1 -0
  229. package/dist/820.js.map +1 -0
  230. package/dist/8240.js +1 -0
  231. package/dist/8240.js.map +1 -0
  232. package/dist/8267.js +1 -0
  233. package/dist/8267.js.map +1 -0
  234. package/dist/8472.js +1 -0
  235. package/dist/8472.js.map +1 -0
  236. package/dist/8521.js +1 -0
  237. package/dist/8521.js.map +1 -0
  238. package/dist/8602.js +1 -0
  239. package/dist/8602.js.map +1 -0
  240. package/dist/8607.js +1 -0
  241. package/dist/8607.js.map +1 -0
  242. package/dist/8743.js +1 -0
  243. package/dist/8743.js.map +1 -0
  244. package/dist/8762.js +1 -0
  245. package/dist/8762.js.map +1 -0
  246. package/dist/8781.js +1 -0
  247. package/dist/8781.js.map +1 -0
  248. package/dist/8812.js +1 -0
  249. package/dist/8812.js.map +1 -0
  250. package/dist/8952.js +1 -0
  251. package/dist/8952.js.map +1 -0
  252. package/dist/8985.js +1 -0
  253. package/dist/8985.js.map +1 -0
  254. package/dist/8996.js +1 -0
  255. package/dist/8996.js.map +1 -0
  256. package/dist/9011.js +1 -0
  257. package/dist/9011.js.map +1 -0
  258. package/dist/9049.js +1 -0
  259. package/dist/9049.js.map +1 -0
  260. package/dist/9050.js +1 -0
  261. package/dist/9050.js.map +1 -0
  262. package/dist/9061.js +1 -0
  263. package/dist/9061.js.map +1 -0
  264. package/dist/9087.js +1 -0
  265. package/dist/9087.js.map +1 -0
  266. package/dist/9148.js +1 -0
  267. package/dist/9148.js.map +1 -0
  268. package/dist/9168.js +1 -0
  269. package/dist/9168.js.map +1 -0
  270. package/dist/935.js +1 -0
  271. package/dist/935.js.map +1 -0
  272. package/dist/9425.js +1 -0
  273. package/dist/944.js +1 -0
  274. package/dist/944.js.map +1 -0
  275. package/dist/9443.js +1 -0
  276. package/dist/9443.js.map +1 -0
  277. package/dist/9520.js +1 -0
  278. package/dist/9520.js.map +1 -0
  279. package/dist/9547.js +1 -0
  280. package/dist/9547.js.map +1 -0
  281. package/dist/9549.js +1 -0
  282. package/dist/9549.js.map +1 -0
  283. package/dist/9603.js +1 -0
  284. package/dist/9603.js.map +1 -0
  285. package/dist/9612.js +1 -0
  286. package/dist/9612.js.map +1 -0
  287. package/dist/9621.js +1 -0
  288. package/dist/9621.js.map +1 -0
  289. package/dist/9662.js +1 -0
  290. package/dist/9662.js.map +1 -0
  291. package/dist/9771.js +1 -0
  292. package/dist/9771.js.map +1 -0
  293. package/dist/9774.js +1 -0
  294. package/dist/9774.js.map +1 -0
  295. package/dist/9800.js +1 -0
  296. package/dist/9800.js.map +1 -0
  297. package/dist/9806.js +1 -0
  298. package/dist/9806.js.map +1 -0
  299. package/dist/992.js +1 -0
  300. package/dist/992.js.map +1 -0
  301. package/dist/kenyaemr-esm-admin-app.js +6 -5
  302. package/dist/kenyaemr-esm-admin-app.js.buildmanifest.json +3594 -151
  303. package/dist/kenyaemr-esm-admin-app.js.map +1 -1
  304. package/dist/main.js +6 -31
  305. package/dist/main.js.map +1 -1
  306. package/dist/routes.json +1 -1
  307. package/package.json +5 -7
  308. package/rspack.config.js +1 -1
  309. package/src/components/confirm-modal/confirmation-operation.test.tsx +8 -19
  310. package/src/components/dashboard/dashboard.component.tsx +4 -1
  311. package/src/components/empty-state/empty-state-log.test.tsx +3 -4
  312. package/src/components/facility-setup/constant/index.ts +3 -0
  313. package/src/components/facility-setup/facility-info.component.tsx +247 -108
  314. package/src/components/facility-setup/facility-info.scss +136 -55
  315. package/src/components/facility-setup/facility-setup.component.tsx +2 -2
  316. package/src/components/facility-setup/header/header.component.tsx +4 -10
  317. package/src/components/facility-setup/header/header.scss +3 -9
  318. package/src/components/facility-setup/shared/custom-info.component.tsx +9 -0
  319. package/src/components/facility-setup/shared/custom-section-card.component.tsx +10 -0
  320. package/src/components/facility-setup/shared/custom-status-tag.component.tsx +22 -0
  321. package/src/components/facility-setup/type/index.ts +61 -0
  322. package/src/components/facility-setup/useFacilityRegistry.ts +29 -0
  323. package/src/components/global-property/dashboard/global-property-dashboard.component.tsx +23 -0
  324. package/src/components/global-property/dashboard/global-property-dashboard.scss +6 -0
  325. package/src/components/global-property/hooks/useGlobalProperty.ts +64 -0
  326. package/src/components/global-property/hooks/useLogoUpload.ts +28 -0
  327. package/src/components/global-property/index.ts +14 -0
  328. package/src/components/global-property/modal/delete-global-property-modal.component.tsx +71 -0
  329. package/src/components/global-property/modal/delete-global-property-modal.test.tsx +131 -0
  330. package/src/components/global-property/table/global-property-table.component.tsx +241 -0
  331. package/src/components/global-property/table/global-property-table.scss +44 -0
  332. package/src/components/global-property/table/global-property-table.test.tsx +202 -0
  333. package/src/components/global-property/workspace/global-property-form-schema.ts +32 -0
  334. package/src/components/global-property/workspace/global-property.workspace.scss +40 -0
  335. package/src/components/global-property/workspace/global-property.workspace.test.tsx +172 -0
  336. package/src/components/global-property/workspace/global-property.workspace.tsx +260 -0
  337. package/src/components/global-property/workspace/upload-logo.workspace.scss +61 -0
  338. package/src/components/global-property/workspace/upload-logo.workspace.test.tsx +180 -0
  339. package/src/components/global-property/workspace/upload-logo.workspace.tsx +191 -0
  340. package/src/components/hook/healthWorkerRegistry.ts +78 -0
  341. package/src/components/hook/useProfessionalRegistryEnums.ts +59 -0
  342. package/src/components/locations/forms/add-location/add-location.workspace.tsx +96 -95
  343. package/src/components/locations/forms/search-location/search-location.workspace.tsx +90 -85
  344. package/src/components/locations/tables/locations-table.component.tsx +117 -121
  345. package/src/components/logs-table/operation-log-table.component.tsx +87 -75
  346. package/src/components/logs-table/operation-log.test.tsx +134 -28
  347. package/src/components/modal/hwr-confirmation.modal.scss +80 -4
  348. package/src/components/modal/hwr-confirmation.modal.tsx +118 -128
  349. package/src/components/modal/hwr-sync.modal.tsx +194 -106
  350. package/src/components/users/manage-users/manage-user-role-scope/user-role-scope-workspace/user-role-scope.workspace.tsx +14 -14
  351. package/src/components/users/manage-users/user-details/user-detail.scss +167 -39
  352. package/src/components/users/manage-users/user-details/user-details.component.tsx +130 -122
  353. package/src/components/users/manage-users/user-list/user-list.component.tsx +22 -9
  354. package/src/components/users/manage-users/user-management.workspace.scss +233 -95
  355. package/src/components/users/manage-users/user-management.workspace.tsx +800 -687
  356. package/src/components/users/userManagementFormSchema.tsx +17 -8
  357. package/src/config-schema.ts +48 -68
  358. package/src/index.ts +68 -31
  359. package/src/left-pannel-link.component.tsx +5 -3
  360. package/src/root.component.tsx +13 -13
  361. package/src/routes.json +60 -36
  362. package/src/types/index.ts +40 -3
  363. package/translations/am.json +213 -13
  364. package/translations/en.json +225 -25
  365. package/translations/fr.json +260 -58
  366. package/translations/sw.json +329 -129
  367. package/tsconfig.json +1 -1
  368. package/dist/127.js +0 -1
  369. package/dist/267.js +0 -1
  370. package/dist/267.js.map +0 -1
  371. package/dist/281.js +0 -15
  372. package/dist/281.js.map +0 -1
  373. package/dist/329.js +0 -1
  374. package/dist/329.js.map +0 -1
  375. package/dist/40.js +0 -1
  376. package/dist/472.js +0 -1
  377. package/dist/472.js.map +0 -1
  378. package/dist/478.js +0 -1
  379. package/dist/478.js.map +0 -1
  380. package/dist/585.js +0 -1
  381. package/dist/585.js.map +0 -1
  382. package/dist/630.js +0 -1
  383. package/dist/630.js.map +0 -1
  384. package/dist/675.js +0 -1
  385. package/dist/675.js.map +0 -1
  386. package/dist/706.js +0 -27
  387. package/dist/706.js.map +0 -1
  388. package/dist/729.js +0 -17
  389. package/dist/729.js.map +0 -1
  390. package/dist/774.js +0 -1
  391. package/dist/774.js.map +0 -1
  392. package/dist/847.js +0 -1
  393. package/dist/847.js.map +0 -1
  394. package/dist/85.js +0 -1
  395. package/dist/85.js.map +0 -1
  396. package/dist/882.js +0 -1
  397. package/dist/91.js +0 -1
  398. package/dist/91.js.map +0 -1
  399. package/dist/916.js +0 -1
  400. package/dist/998.js +0 -1
  401. package/dist/998.js.map +0 -1
  402. package/jest.config.js +0 -8
  403. package/src/components/facility-setup/card.component.tsx +0 -16
  404. package/src/components/facility-setup/facility-setup.resource.tsx +0 -7
  405. package/src/components/hook/healthWorkerAdapter.ts +0 -213
  406. package/src/components/hook/useFacilityInfo.tsx +0 -37
@@ -0,0 +1,172 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, 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 GlobalPropertyWorkspace from './global-property.workspace';
7
+
8
+ const mockShowSnackbar = vi.fn();
9
+ const mockSaveOrUpdateGlobalProperty = 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
+ ResponsiveWrapper: ({ children }: { children: React.ReactNode }) => <>{children}</>,
21
+ Workspace2: ({ children, title }: { children: React.ReactNode; title: string }) => (
22
+ <div>
23
+ <h2>{title}</h2>
24
+ {children}
25
+ </div>
26
+ ),
27
+ }));
28
+
29
+ vi.mock('../hooks/useGlobalProperty', () => ({
30
+ saveOrUpdateGlobalProperty: (...args: unknown[]) => mockSaveOrUpdateGlobalProperty(...args),
31
+ }));
32
+
33
+ vi.mock('@hookform/resolvers/zod', () => ({
34
+ zodResolver: () => async (values: unknown) => ({ values, errors: {} }),
35
+ }));
36
+
37
+ const baseProps = {
38
+ closeWorkspace: mockCloseWorkspace,
39
+ workspaceProps: {
40
+ systemSetting: null,
41
+ mutateGlobalProperty: mockMutateGlobalProperty,
42
+ },
43
+ };
44
+
45
+ describe('GlobalPropertyWorkspace', () => {
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ });
49
+
50
+ it('shows "Add global property" title in add mode', () => {
51
+ render(<GlobalPropertyWorkspace {...baseProps} />);
52
+ expect(screen.getByText('Add global property')).toBeInTheDocument();
53
+ });
54
+
55
+ it('shows "Edit global property" title when editing an existing property', () => {
56
+ const props = {
57
+ ...baseProps,
58
+ workspaceProps: {
59
+ systemSetting: { uuid: 'uuid-1', property: 'setting.one', value: 'value1' },
60
+ mutateGlobalProperty: mockMutateGlobalProperty,
61
+ },
62
+ };
63
+ render(<GlobalPropertyWorkspace {...props} />);
64
+ expect(screen.getByText('Edit global property')).toBeInTheDocument();
65
+ });
66
+
67
+ it('pre-fills form fields when editing an existing property', () => {
68
+ const props = {
69
+ ...baseProps,
70
+ workspaceProps: {
71
+ systemSetting: { uuid: 'uuid-1', property: 'my.setting', value: 'my-value' },
72
+ mutateGlobalProperty: mockMutateGlobalProperty,
73
+ },
74
+ };
75
+ render(<GlobalPropertyWorkspace {...props} />);
76
+ expect(screen.getByDisplayValue('my.setting')).toBeInTheDocument();
77
+ expect(screen.getByDisplayValue('my-value')).toBeInTheDocument();
78
+ });
79
+
80
+ it('disables the property field when editing an existing property', () => {
81
+ const props = {
82
+ ...baseProps,
83
+ workspaceProps: {
84
+ systemSetting: { uuid: 'uuid-1', property: 'setting.one', value: 'value1' },
85
+ mutateGlobalProperty: mockMutateGlobalProperty,
86
+ },
87
+ };
88
+ render(<GlobalPropertyWorkspace {...props} />);
89
+ expect(screen.getByLabelText(/Property/i)).toBeDisabled();
90
+ });
91
+
92
+ it('enables the property field in add mode', () => {
93
+ render(<GlobalPropertyWorkspace {...baseProps} />);
94
+ expect(screen.getByLabelText(/Property/i)).not.toBeDisabled();
95
+ });
96
+
97
+ it('renders the Cancel and Save & close buttons', () => {
98
+ render(<GlobalPropertyWorkspace {...baseProps} />);
99
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
100
+ expect(screen.getByText('Save & close')).toBeInTheDocument();
101
+ });
102
+
103
+ it('calls closeWorkspace when Cancel is clicked', () => {
104
+ render(<GlobalPropertyWorkspace {...baseProps} />);
105
+ fireEvent.click(screen.getByText('Cancel'));
106
+ expect(mockCloseWorkspace).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it('shows validation error when form is submitted with empty required fields', async () => {
110
+ render(<GlobalPropertyWorkspace {...baseProps} />);
111
+ const saveButton = screen.getByText('Save & close');
112
+ fireEvent.click(saveButton);
113
+ await waitFor(() => {
114
+ expect(mockSaveOrUpdateGlobalProperty).not.toHaveBeenCalled();
115
+ });
116
+ });
117
+
118
+ it('saves a new property with the entered values', async () => {
119
+ mockSaveOrUpdateGlobalProperty.mockResolvedValue(undefined);
120
+ render(<GlobalPropertyWorkspace {...baseProps} />);
121
+
122
+ await userEvent.type(screen.getByLabelText(/Property/i), 'new.setting');
123
+ await userEvent.type(screen.getByLabelText(/Value/i), 'new-value');
124
+
125
+ fireEvent.submit(screen.getByLabelText(/Property/i).closest('form')!);
126
+
127
+ await waitFor(() => {
128
+ expect(mockSaveOrUpdateGlobalProperty).toHaveBeenCalledWith(
129
+ expect.objectContaining({ property: 'new.setting', value: 'new-value' }),
130
+ undefined,
131
+ );
132
+ });
133
+ });
134
+
135
+ it('shows a success snackbar and closes workspace after saving', async () => {
136
+ mockSaveOrUpdateGlobalProperty.mockResolvedValue(undefined);
137
+ render(<GlobalPropertyWorkspace {...baseProps} />);
138
+
139
+ await userEvent.type(screen.getByLabelText(/Property/i), 'new.setting');
140
+ await userEvent.type(screen.getByLabelText(/Value/i), 'new-value');
141
+
142
+ fireEvent.submit(screen.getByLabelText(/Property/i).closest('form')!);
143
+
144
+ await waitFor(() => {
145
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
146
+ expect(mockMutateGlobalProperty).toHaveBeenCalled();
147
+ expect(mockCloseWorkspace).toHaveBeenCalled();
148
+ });
149
+ });
150
+
151
+ it('shows an error snackbar when saving fails', async () => {
152
+ mockSaveOrUpdateGlobalProperty.mockRejectedValue(new Error('Failed to save'));
153
+ render(<GlobalPropertyWorkspace {...baseProps} />);
154
+
155
+ await userEvent.type(screen.getByLabelText(/Property/i), 'new.setting');
156
+ await userEvent.type(screen.getByLabelText(/Value/i), 'new-value');
157
+
158
+ fireEvent.submit(screen.getByLabelText(/Property/i).closest('form')!);
159
+
160
+ await waitFor(() => {
161
+ expect(mockShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
162
+ expect(mockCloseWorkspace).not.toHaveBeenCalled();
163
+ });
164
+ });
165
+
166
+ it('renders optional fields: description, datatype config, handler config', () => {
167
+ render(<GlobalPropertyWorkspace {...baseProps} />);
168
+ expect(screen.getByLabelText(/Description/i)).toBeInTheDocument();
169
+ expect(screen.getByLabelText(/Datatype config/i)).toBeInTheDocument();
170
+ expect(screen.getByLabelText(/Handler config/i)).toBeInTheDocument();
171
+ });
172
+ });
@@ -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
+ });