@kenyaemr/esm-pcs-app 5.4.5-pre.1092

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 (286) hide show
  1. package/.turbo/turbo-build.log +6 -0
  2. package/dist/1072.js +1 -0
  3. package/dist/1072.js.map +1 -0
  4. package/dist/1102.js +1 -0
  5. package/dist/1102.js.map +1 -0
  6. package/dist/1105.js +1 -0
  7. package/dist/1105.js.map +1 -0
  8. package/dist/1148.js +1 -0
  9. package/dist/1148.js.map +1 -0
  10. package/dist/1278.js +1 -0
  11. package/dist/1278.js.map +1 -0
  12. package/dist/1442.js +1 -0
  13. package/dist/1442.js.map +1 -0
  14. package/dist/15.js +1 -0
  15. package/dist/15.js.map +1 -0
  16. package/dist/1620.js +1 -0
  17. package/dist/1620.js.map +1 -0
  18. package/dist/1789.js +1 -0
  19. package/dist/1789.js.map +1 -0
  20. package/dist/1818.js +1 -0
  21. package/dist/1818.js.map +1 -0
  22. package/dist/1831.js +1 -0
  23. package/dist/1831.js.map +1 -0
  24. package/dist/1844.js +1 -0
  25. package/dist/1844.js.map +1 -0
  26. package/dist/1854.js +1 -0
  27. package/dist/1854.js.map +1 -0
  28. package/dist/1946.js +1 -0
  29. package/dist/1946.js.map +1 -0
  30. package/dist/2007.js +1 -0
  31. package/dist/2007.js.map +1 -0
  32. package/dist/2115.js +1 -0
  33. package/dist/2115.js.map +1 -0
  34. package/dist/2152.js +1 -0
  35. package/dist/2152.js.map +1 -0
  36. package/dist/2202.js +1 -0
  37. package/dist/2202.js.map +1 -0
  38. package/dist/2394.js +1 -0
  39. package/dist/2394.js.map +1 -0
  40. package/dist/2530.js +1 -0
  41. package/dist/2530.js.map +1 -0
  42. package/dist/2534.js +1 -0
  43. package/dist/2534.js.map +1 -0
  44. package/dist/2537.js +1 -0
  45. package/dist/2537.js.map +1 -0
  46. package/dist/2556.js +1 -0
  47. package/dist/2556.js.map +1 -0
  48. package/dist/2648.js +1 -0
  49. package/dist/2648.js.map +1 -0
  50. package/dist/2795.js +1 -0
  51. package/dist/2795.js.map +1 -0
  52. package/dist/2827.js +1 -0
  53. package/dist/2827.js.map +1 -0
  54. package/dist/2916.js +1 -0
  55. package/dist/2916.js.map +1 -0
  56. package/dist/2924.js +1 -0
  57. package/dist/2924.js.map +1 -0
  58. package/dist/2947.js +1 -0
  59. package/dist/2947.js.map +1 -0
  60. package/dist/3022.js +1 -0
  61. package/dist/3022.js.map +1 -0
  62. package/dist/3285.js +1 -0
  63. package/dist/3285.js.map +1 -0
  64. package/dist/3335.js +1 -0
  65. package/dist/3335.js.map +1 -0
  66. package/dist/3378.js +1 -0
  67. package/dist/3378.js.map +1 -0
  68. package/dist/3381.js +1 -0
  69. package/dist/3381.js.map +1 -0
  70. package/dist/3504.js +1 -0
  71. package/dist/3504.js.map +1 -0
  72. package/dist/3530.js +1 -0
  73. package/dist/3530.js.map +1 -0
  74. package/dist/356.js +1 -0
  75. package/dist/356.js.map +1 -0
  76. package/dist/3569.js +1 -0
  77. package/dist/3569.js.map +1 -0
  78. package/dist/3723.js +1 -0
  79. package/dist/3723.js.map +1 -0
  80. package/dist/3745.js +1 -0
  81. package/dist/3745.js.map +1 -0
  82. package/dist/3813.js +1 -0
  83. package/dist/3813.js.map +1 -0
  84. package/dist/393.js +6 -0
  85. package/dist/393.js.map +1 -0
  86. package/dist/3953.js +1 -0
  87. package/dist/3953.js.map +1 -0
  88. package/dist/3989.js +1 -0
  89. package/dist/3989.js.map +1 -0
  90. package/dist/412.js +1 -0
  91. package/dist/412.js.map +1 -0
  92. package/dist/4178.js +1 -0
  93. package/dist/4178.js.map +1 -0
  94. package/dist/4413.js +1 -0
  95. package/dist/4413.js.map +1 -0
  96. package/dist/4487.js +11 -0
  97. package/dist/4487.js.map +1 -0
  98. package/dist/4537.js +1 -0
  99. package/dist/4537.js.map +1 -0
  100. package/dist/4588.js +1 -0
  101. package/dist/4588.js.map +1 -0
  102. package/dist/4637.js +1 -0
  103. package/dist/4637.js.map +1 -0
  104. package/dist/4650.js +1 -0
  105. package/dist/4650.js.map +1 -0
  106. package/dist/466.js +1 -0
  107. package/dist/466.js.map +1 -0
  108. package/dist/4817.js +1 -0
  109. package/dist/4817.js.map +1 -0
  110. package/dist/4818.js +1 -0
  111. package/dist/4818.js.map +1 -0
  112. package/dist/500.js +1 -0
  113. package/dist/500.js.map +1 -0
  114. package/dist/5086.js +1 -0
  115. package/dist/5086.js.map +1 -0
  116. package/dist/5117.js +1 -0
  117. package/dist/5117.js.map +1 -0
  118. package/dist/5279.js +1 -0
  119. package/dist/5279.js.map +1 -0
  120. package/dist/5366.js +1 -0
  121. package/dist/5366.js.map +1 -0
  122. package/dist/5598.js +1 -0
  123. package/dist/5598.js.map +1 -0
  124. package/dist/5676.js +1 -0
  125. package/dist/5676.js.map +1 -0
  126. package/dist/5731.js +5 -0
  127. package/dist/5731.js.map +1 -0
  128. package/dist/5792.js +1 -0
  129. package/dist/5792.js.map +1 -0
  130. package/dist/5825.js +1 -0
  131. package/dist/5825.js.map +1 -0
  132. package/dist/5932.js +1 -0
  133. package/dist/5932.js.map +1 -0
  134. package/dist/5941.js +1 -0
  135. package/dist/5941.js.map +1 -0
  136. package/dist/5967.js +1 -0
  137. package/dist/5967.js.map +1 -0
  138. package/dist/6104.js +1 -0
  139. package/dist/6104.js.map +1 -0
  140. package/dist/624.js +1 -0
  141. package/dist/624.js.map +1 -0
  142. package/dist/6259.js +1 -0
  143. package/dist/6259.js.map +1 -0
  144. package/dist/6273.js +1 -0
  145. package/dist/6273.js.map +1 -0
  146. package/dist/6337.js +1 -0
  147. package/dist/6337.js.map +1 -0
  148. package/dist/6427.js +1 -0
  149. package/dist/6427.js.map +1 -0
  150. package/dist/6478.js +7 -0
  151. package/dist/6478.js.map +1 -0
  152. package/dist/6508.js +1 -0
  153. package/dist/6508.js.map +1 -0
  154. package/dist/6561.js +1 -0
  155. package/dist/6561.js.map +1 -0
  156. package/dist/6566.js +1 -0
  157. package/dist/6566.js.map +1 -0
  158. package/dist/6567.js +1 -0
  159. package/dist/6567.js.map +1 -0
  160. package/dist/6634.js +1 -0
  161. package/dist/6634.js.map +1 -0
  162. package/dist/7022.js +1 -0
  163. package/dist/7022.js.map +1 -0
  164. package/dist/7085.js +1 -0
  165. package/dist/7085.js.map +1 -0
  166. package/dist/7209.js +1 -0
  167. package/dist/7209.js.map +1 -0
  168. package/dist/7291.js +1 -0
  169. package/dist/7291.js.map +1 -0
  170. package/dist/7360.js +1 -0
  171. package/dist/7360.js.map +1 -0
  172. package/dist/7453.js +1 -0
  173. package/dist/7453.js.map +1 -0
  174. package/dist/7454.js +1 -0
  175. package/dist/7454.js.map +1 -0
  176. package/dist/7478.js +1 -0
  177. package/dist/7478.js.map +1 -0
  178. package/dist/7778.js +1 -0
  179. package/dist/7778.js.map +1 -0
  180. package/dist/7782.js +1 -0
  181. package/dist/7782.js.map +1 -0
  182. package/dist/8028.js +1 -0
  183. package/dist/8028.js.map +1 -0
  184. package/dist/8031.js +1 -0
  185. package/dist/8031.js.map +1 -0
  186. package/dist/8196.js +1 -0
  187. package/dist/8196.js.map +1 -0
  188. package/dist/820.js +1 -0
  189. package/dist/820.js.map +1 -0
  190. package/dist/8240.js +1 -0
  191. package/dist/8240.js.map +1 -0
  192. package/dist/8472.js +1 -0
  193. package/dist/8472.js.map +1 -0
  194. package/dist/8607.js +1 -0
  195. package/dist/8607.js.map +1 -0
  196. package/dist/8793.js +1 -0
  197. package/dist/8793.js.map +1 -0
  198. package/dist/8924.js +1 -0
  199. package/dist/8924.js.map +1 -0
  200. package/dist/8990.js +1 -0
  201. package/dist/8990.js.map +1 -0
  202. package/dist/9060.js +1 -0
  203. package/dist/9060.js.map +1 -0
  204. package/dist/9061.js +1 -0
  205. package/dist/9061.js.map +1 -0
  206. package/dist/9087.js +1 -0
  207. package/dist/9087.js.map +1 -0
  208. package/dist/9165.js +1 -0
  209. package/dist/9165.js.map +1 -0
  210. package/dist/9274.js +1 -0
  211. package/dist/9274.js.map +1 -0
  212. package/dist/9425.js +1 -0
  213. package/dist/9520.js +1 -0
  214. package/dist/9520.js.map +1 -0
  215. package/dist/9547.js +1 -0
  216. package/dist/9547.js.map +1 -0
  217. package/dist/9612.js +1 -0
  218. package/dist/9612.js.map +1 -0
  219. package/dist/9771.js +1 -0
  220. package/dist/9771.js.map +1 -0
  221. package/dist/9774.js +1 -0
  222. package/dist/9774.js.map +1 -0
  223. package/dist/9806.js +1 -0
  224. package/dist/9806.js.map +1 -0
  225. package/dist/9885.js +1 -0
  226. package/dist/9885.js.map +1 -0
  227. package/dist/992.js +1 -0
  228. package/dist/992.js.map +1 -0
  229. package/dist/9941.js +1 -0
  230. package/dist/9941.js.map +1 -0
  231. package/dist/997.js +1 -0
  232. package/dist/997.js.map +1 -0
  233. package/dist/kenyaemr-esm-pcs-app.js +3 -0
  234. package/dist/kenyaemr-esm-pcs-app.js.buildmanifest.json +3173 -0
  235. package/dist/kenyaemr-esm-pcs-app.js.map +1 -0
  236. package/dist/main.js +3 -0
  237. package/dist/main.js.map +1 -0
  238. package/dist/routes.json +1 -0
  239. package/package.json +50 -0
  240. package/rspack.config.js +1 -0
  241. package/src/add-dependant/add-dependant.scss +29 -0
  242. package/src/add-dependant/add-dependant.workspace.test.ts +65 -0
  243. package/src/add-dependant/add-dependant.workspace.tsx +249 -0
  244. package/src/config-schema.ts +67 -0
  245. package/src/declarations.d.ts +2 -0
  246. package/src/empty-svg.component.tsx +565 -0
  247. package/src/index.ts +34 -0
  248. package/src/link-hie-dependant/link-hie-dependant.scss +55 -0
  249. package/src/link-hie-dependant/link-hie-dependant.workspace.tsx +207 -0
  250. package/src/linked/dependant-row.component.test.tsx +134 -0
  251. package/src/linked/dependant-row.component.tsx +122 -0
  252. package/src/linked/dependants-list.component.tsx +60 -0
  253. package/src/linked/linked-participant-body.component.tsx +82 -0
  254. package/src/linked/linked-participant.component.test.tsx +134 -0
  255. package/src/linked/linked-participant.component.tsx +142 -0
  256. package/src/linked/participant-details.component.tsx +89 -0
  257. package/src/linked/use-study-sync-snackbars.ts +41 -0
  258. package/src/modals/delink-participant.modal.tsx +96 -0
  259. package/src/modals/link-dependant.modal.tsx +244 -0
  260. package/src/modals/link-participant.modal.tsx +143 -0
  261. package/src/modals/link-participant.scss +48 -0
  262. package/src/pcs.scss +241 -0
  263. package/src/resources/hie-contacts.ts +61 -0
  264. package/src/resources/identifiers.ts +52 -0
  265. package/src/resources/link-dependant.resource.test.ts +445 -0
  266. package/src/resources/link-dependant.resource.ts +268 -0
  267. package/src/resources/link-participant.resource.test.ts +336 -0
  268. package/src/resources/link-participant.resource.ts +452 -0
  269. package/src/resources/patient-create.resource.ts +296 -0
  270. package/src/resources/patient-search.resource.ts +106 -0
  271. package/src/resources/pcs.resource.test.ts +36 -0
  272. package/src/resources/pcs.resource.ts +182 -0
  273. package/src/resources/use-identifier-type-uuids.ts +38 -0
  274. package/src/routes.json +54 -0
  275. package/src/search/participant-results.component.tsx +96 -0
  276. package/src/search/participant-tile.component.tsx +133 -0
  277. package/src/search/pcs-empty-state.component.tsx +31 -0
  278. package/src/search/pcs-results-skeleton.component.tsx +21 -0
  279. package/src/search/pcs-search.component.test.tsx +169 -0
  280. package/src/search/pcs-search.component.tsx +228 -0
  281. package/src/types/index.ts +140 -0
  282. package/translations/am.json +120 -0
  283. package/translations/en.json +120 -0
  284. package/translations/fr.json +122 -0
  285. package/translations/sw.json +120 -0
  286. package/tsconfig.json +5 -0
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://json.openmrs.org/routes.schema.json",
3
+ "backendDependencies": {
4
+ "webservices.rest": "^2.24.0"
5
+ },
6
+ "extensions": [
7
+ {
8
+ "name": "pcs-search-results",
9
+ "component": "pcsSearchResults",
10
+ "slot": "ewf-registration-patient-extras-slot",
11
+ "featureFlag": "pcsSite",
12
+ "online": true,
13
+ "offline": false
14
+ }
15
+ ],
16
+ "modals": [
17
+ {
18
+ "name": "pcs-link-participant-modal",
19
+ "component": "pcsLinkParticipantModal"
20
+ },
21
+ {
22
+ "name": "pcs-delink-participant-modal",
23
+ "component": "pcsDelinkParticipantModal"
24
+ },
25
+ {
26
+ "name": "pcs-link-dependant-modal",
27
+ "component": "pcsLinkDependantModal"
28
+ }
29
+ ],
30
+ "workspaces2": [
31
+ {
32
+ "name": "pcs-add-dependant-workspace-form",
33
+ "component": "pcsAddDependantWorkspace",
34
+ "window": "pcs-dependant"
35
+ },
36
+ {
37
+ "name": "pcs-link-hie-dependant-workspace-form",
38
+ "component": "pcsLinkHieDependantWorkspace",
39
+ "window": "pcs-dependant"
40
+ }
41
+ ],
42
+ "workspaceWindows2": [
43
+ {
44
+ "name": "pcs-dependant",
45
+ "group": "pcs-registration",
46
+ "width": "narrow"
47
+ }
48
+ ],
49
+ "workspaceGroups2": [
50
+ {
51
+ "name": "pcs-registration"
52
+ }
53
+ ]
54
+ }
@@ -0,0 +1,96 @@
1
+ import React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { ErrorState } from '@openmrs/esm-framework';
4
+ import styles from '../pcs.scss';
5
+ import { getPcsErrorMessage, isPcsUnavailable } from '../resources/pcs.resource';
6
+ import { type PcsParticipant, type PcsSearchSubject } from '../types';
7
+ import PCSParticipantTile from './participant-tile.component';
8
+ import PcsEmptyState from './pcs-empty-state.component';
9
+ import PcsResultsSkeleton from './pcs-results-skeleton.component';
10
+
11
+ interface ParticipantResultsProps {
12
+ subject: PcsSearchSubject;
13
+ participants: Array<PcsParticipant>;
14
+ totalCount: number;
15
+ /** False until a filter has actually been submitted — no search has been made yet. */
16
+ hasCommittedFilters: boolean;
17
+ isLoading: boolean;
18
+ error: unknown;
19
+ onReset: () => void;
20
+ onLinked: () => void;
21
+ }
22
+
23
+ /** The body of the search pane: nothing asked yet, loading, failed, empty, or results. */
24
+ const ParticipantResults: React.FC<ParticipantResultsProps> = ({
25
+ subject,
26
+ participants,
27
+ totalCount,
28
+ hasCommittedFilters,
29
+ isLoading,
30
+ error,
31
+ onReset,
32
+ onLinked,
33
+ }) => {
34
+ const { t } = useTranslation();
35
+
36
+ if (!hasCommittedFilters) {
37
+ return (
38
+ <PcsEmptyState
39
+ title={t('noFilters', 'No filters')}
40
+ subtitle={t('pcsAddFilterToSearch', 'Add a filter to search the PCS registry')}
41
+ onReset={onReset}
42
+ />
43
+ );
44
+ }
45
+
46
+ if (isLoading) {
47
+ return <PcsResultsSkeleton />;
48
+ }
49
+
50
+ if (error) {
51
+ return (
52
+ <ErrorState
53
+ error={error}
54
+ headerTitle={
55
+ // A downed VPN to the SQL Server reads very differently to a bug, so it is named.
56
+ isPcsUnavailable(error)
57
+ ? t('pcsRegistryUnavailable', 'PCS registry unavailable')
58
+ : getPcsErrorMessage(error) ?? t('errorLoadingPcsRecords', 'Error loading PCS records')
59
+ }
60
+ />
61
+ );
62
+ }
63
+
64
+ if (participants.length === 0) {
65
+ return (
66
+ <PcsEmptyState
67
+ title={t('noPcsRecordsFound', 'No PCS records found')}
68
+ subtitle={t('noPcsRecordsForFilters', 'No participant matched these filters')}
69
+ onReset={onReset}
70
+ />
71
+ );
72
+ }
73
+
74
+ return (
75
+ <div className={styles.pcsResults}>
76
+ <span className={styles.pcsResultsCount}>
77
+ {totalCount > participants.length
78
+ ? t('pcsShowingOf', 'Showing {{shown}} of {{total}} PCS records', {
79
+ shown: participants.length,
80
+ total: totalCount,
81
+ })
82
+ : t('pcsRecordsFound', 'PCS record(s) found ({{count}})', { count: participants.length })}
83
+ </span>
84
+ {participants.map((participant) => (
85
+ <PCSParticipantTile
86
+ key={participant.individualId}
87
+ participant={participant}
88
+ subject={subject}
89
+ onLinked={onLinked}
90
+ />
91
+ ))}
92
+ </div>
93
+ );
94
+ };
95
+
96
+ export default ParticipantResults;
@@ -0,0 +1,133 @@
1
+ import React from 'react';
2
+ import { Button, Tag } from '@carbon/react';
3
+ import { GenderFemale, GenderMale, Link as LinkIcon } from '@carbon/react/icons';
4
+ import { age, formatDate, parseDate, showModal } from '@openmrs/esm-framework';
5
+ import { useTranslation } from 'react-i18next';
6
+ import styles from '../pcs.scss';
7
+ import {
8
+ formatCompoundHeadName,
9
+ formatMotherName,
10
+ formatParticipantName,
11
+ getPrimaryContact,
12
+ } from '../resources/pcs.resource';
13
+ import { type PcsMatchType, type PcsMatchedOn, type PcsParticipant, type PcsSearchSubject } from '../types';
14
+
15
+ interface PCSParticipantTileProps {
16
+ participant: PcsParticipant;
17
+ subject: PcsSearchSubject;
18
+ /** Lets the pane re-read the patient's study link once one has been written. */
19
+ onLinked?: () => void;
20
+ }
21
+
22
+ const GENDER_ICONS = {
23
+ F: <GenderFemale />,
24
+ M: <GenderMale />,
25
+ } as const;
26
+
27
+ const SEX_LABELS: Record<PcsParticipant['sex'], string> = { F: 'Female', M: 'Male' };
28
+
29
+ const PCSParticipantTile: React.FC<PCSParticipantTileProps> = ({ participant, subject, onLinked }) => {
30
+ const { t } = useTranslation();
31
+
32
+ const name = formatParticipantName(participant);
33
+ const contact = getPrimaryContact(participant);
34
+
35
+ // The registry scores the name itself, tiered and per field, which beats comparing
36
+ // strings here. Both are null when `name` was not one of the filters.
37
+ const matchLabel = (matchType: PcsMatchType, matchedOn: PcsMatchedOn): string => {
38
+ const field = {
39
+ name: t('pcsFieldName', 'name'),
40
+ motherName: t('pcsFieldMotherName', "mother's name"),
41
+ compoundName: t('pcsFieldCompoundName', 'compound head'),
42
+ }[matchedOn];
43
+
44
+ switch (matchType) {
45
+ case 'EXACT':
46
+ return t('pcsMatchExact', 'Exact match on {{field}}', { field });
47
+ case 'CONTAINS':
48
+ return t('pcsMatchContains', 'Partial match on {{field}}', { field });
49
+ case 'SOUNDEX':
50
+ return t('pcsMatchSoundex', 'Sounds like {{field}}', { field });
51
+ }
52
+ };
53
+
54
+ const matchTagType = participant.matchType === 'EXACT' ? 'green' : 'teal';
55
+
56
+ // Linking creates the patient if needed and writes to their record, so it confirms first.
57
+ const openLinkModal = () => {
58
+ const dispose = showModal('pcs-link-participant-modal', {
59
+ closeModal: () => dispose(),
60
+ subject,
61
+ participant,
62
+ onLinked,
63
+ });
64
+ };
65
+
66
+ return (
67
+ <div className={styles.pcsTile}>
68
+ <div className={styles.pcsRow}>
69
+ <span className={styles.pcsName}>{name}</span>
70
+ </div>
71
+
72
+ {participant.matchType && participant.matchedOn && (
73
+ <Tag className={styles.matchTag} type={matchTagType} size="sm">
74
+ {matchLabel(participant.matchType, participant.matchedOn)}
75
+ </Tag>
76
+ )}
77
+
78
+ <div className={styles.pcsRow}>
79
+ <span className={styles.genderIcon}>
80
+ {GENDER_ICONS[participant.sex]}
81
+ <span>{SEX_LABELS[participant.sex]}</span>
82
+ </span>
83
+ {participant.dateOfBirth ? (
84
+ <>
85
+ <span className={styles.separator}>&middot;</span>
86
+ <span>{age(participant.dateOfBirth)}</span>
87
+ <span className={styles.separator}>&middot;</span>
88
+ <span>{formatDate(parseDate(participant.dateOfBirth))}</span>
89
+ <span className={styles.separator}>&middot;</span>
90
+ <span>{contact?.phone || '--'}</span>
91
+ </>
92
+ ) : (
93
+ <>
94
+ <span className={styles.separator}>&middot;</span>
95
+ <span>{t('unknownDateOfBirth', 'Unknown date of birth')}</span>
96
+ </>
97
+ )}
98
+ </div>
99
+
100
+ <div className={styles.pcsRow}>
101
+ <span className={styles.pcsFieldLabel}>{t('individualId', 'Individual ID')}:</span>
102
+ <span>{participant.individualId}</span>
103
+ <span className={styles.separator}>&middot;</span>
104
+ <span className={styles.pcsFieldLabel}>{t('nationalId', 'National ID')}:</span>
105
+ <span>{contact?.nationalId || '--'}</span>
106
+ </div>
107
+
108
+ {participant.mother && (
109
+ <div className={styles.pcsRow}>
110
+ <span className={styles.pcsFieldLabel}>{t('mother', 'Mother')}:</span>
111
+ <span>{formatMotherName(participant.mother)}</span>
112
+ <span className={styles.separator}>&middot;</span>
113
+ <span>{participant.mother.individualId}</span>
114
+ </div>
115
+ )}
116
+
117
+ <div className={styles.pcsHousehold}>
118
+ {t('pcsHousehold', '{{village}} village | Head: {{compoundHead}}', {
119
+ village: participant.village?.name || '--',
120
+ compoundHead: formatCompoundHeadName(participant.compound) || '--',
121
+ })}
122
+ </div>
123
+
124
+ <div className={styles.pcsTileActions}>
125
+ <Button kind="ghost" size="sm" renderIcon={LinkIcon} onClick={openLinkModal}>
126
+ {t('linkRecords', 'Link records')}
127
+ </Button>
128
+ </div>
129
+ </div>
130
+ );
131
+ };
132
+
133
+ export default PCSParticipantTile;
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { Button } from '@carbon/react';
3
+ import { Reset } from '@carbon/react/icons';
4
+ import { useTranslation } from 'react-i18next';
5
+ import styles from '../pcs.scss';
6
+ import { EmptySvg } from '../empty-svg.component';
7
+
8
+ interface PcsEmptyStateProps {
9
+ title: string;
10
+ subtitle: string;
11
+ /** Puts the patient's own details back into the filters. */
12
+ onReset: () => void;
13
+ }
14
+
15
+ /** Shown both before a search has been made and when one returned nothing. */
16
+ const PcsEmptyState: React.FC<PcsEmptyStateProps> = ({ title, subtitle, onReset }) => {
17
+ const { t } = useTranslation();
18
+
19
+ return (
20
+ <div className={styles.pcsEmptyState}>
21
+ <EmptySvg />
22
+ <p className={styles.pcsEmptyTitle}>{title}</p>
23
+ <p className={styles.pcsEmptySubtitle}>{subtitle}</p>
24
+ <Button kind="ghost" size="sm" renderIcon={Reset} onClick={onReset}>
25
+ {t('resetToPatient', 'Reset to patient details')}
26
+ </Button>
27
+ </div>
28
+ );
29
+ };
30
+
31
+ export default PcsEmptyState;
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import { SkeletonText } from '@carbon/react';
3
+ import styles from '../pcs.scss';
4
+
5
+ interface PcsResultsSkeletonProps {
6
+ /** How many placeholder tiles to show. */
7
+ count?: number;
8
+ }
9
+
10
+ const PcsResultsSkeleton: React.FC<PcsResultsSkeletonProps> = ({ count = 3 }) => (
11
+ <div className={styles.pcsLoading}>
12
+ {Array.from({ length: count }).map((_, index) => (
13
+ <div className={styles.pcsSkeletonTile} key={`pcs-skeleton-${index}`}>
14
+ <SkeletonText heading width="60%" />
15
+ <SkeletonText paragraph lineCount={3} />
16
+ </div>
17
+ ))}
18
+ </div>
19
+ );
20
+
21
+ export default PcsResultsSkeleton;
@@ -0,0 +1,169 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import '@testing-library/jest-dom';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import PCSSearchResults from './pcs-search.component';
7
+ import { usePcsParticipantSearch } from '../resources/pcs.resource';
8
+ import { usePatientStudyLink } from '../resources/link-participant.resource';
9
+ import { type PcsSearchSubject } from '../types';
10
+
11
+ vi.mock('@openmrs/esm-framework', async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import('@openmrs/esm-framework')>()),
13
+ ErrorState: () => null,
14
+ useConfig: () => ({
15
+ pcsIdentifiers: { studyParticipantID: 'study-id-type-uuid', studyTemporaryParticipantID: '' },
16
+ pcsAttributeTypes: { pbidsEnrollmentStatus: 'pbids-uuid', cardseEnrollmentStatus: 'cardse-uuid' },
17
+ }),
18
+ }));
19
+
20
+ // The pane checks whether the patient is already linked before deciding what to render.
21
+ vi.mock('../resources/link-participant.resource', () => ({ usePatientStudyLink: vi.fn() }));
22
+
23
+ vi.mock('../linked/linked-participant.component', () => ({
24
+ default: ({ studyParticipantId }: any) => <span>{`Linked view for ${studyParticipantId}`}</span>,
25
+ }));
26
+
27
+ // Interpolates, unlike the bare fallback mock used elsewhere — the tag labels under test
28
+ // are built from `{{value}}`.
29
+ vi.mock('react-i18next', () => ({
30
+ useTranslation: () => ({
31
+ t: (_key: string, fallback: string, options?: Record<string, unknown>) =>
32
+ fallback.replace(/{{(\w+)}}/g, (_match, name) => String(options?.[name] ?? '')),
33
+ }),
34
+ }));
35
+
36
+ // The boundary that receives the committed filters. Keeping the rest of the module real
37
+ // means `toPcsParticipantFilters` and `hasAnyFilter` still drive the component.
38
+ vi.mock('../resources/pcs.resource', async (importOriginal) => ({
39
+ ...(await importOriginal<typeof import('../resources/pcs.resource')>()),
40
+ usePcsParticipantSearch: vi.fn(),
41
+ }));
42
+
43
+ vi.mock('./participant-tile.component', () => ({ default: () => null }));
44
+
45
+ const mockUsePcsParticipantSearch = vi.mocked(usePcsParticipantSearch);
46
+ const mockUsePatientStudyLink = vi.mocked(usePatientStudyLink);
47
+
48
+ const subject: PcsSearchSubject = {
49
+ id: 'patient-uuid',
50
+ source: 'local',
51
+ patient: { resourceType: 'Patient', id: 'patient-uuid' },
52
+ name: 'Mary Akinyi Odongo',
53
+ gender: 'female',
54
+ birthDate: '1991-04-12',
55
+ nationalId: '12345678',
56
+ phoneNumber: '0712345678',
57
+ };
58
+
59
+ const committedFilters = () => mockUsePcsParticipantSearch.mock.calls.at(-1)?.[0];
60
+
61
+ beforeEach(() => {
62
+ vi.clearAllMocks();
63
+ mockUsePatientStudyLink.mockReturnValue({
64
+ localPatient: null,
65
+ studyParticipantId: undefined,
66
+ isLoading: false,
67
+ error: undefined,
68
+ mutate: vi.fn(),
69
+ } as any);
70
+ mockUsePcsParticipantSearch.mockReturnValue({
71
+ participants: [],
72
+ totalCount: 0,
73
+ isLoading: false,
74
+ error: undefined,
75
+ });
76
+ });
77
+
78
+ describe('PCS filter bar', () => {
79
+ it('searches on the patient demographics, tagging what was derived for you', () => {
80
+ render(<PCSSearchResults subject={subject} />);
81
+
82
+ expect(committedFilters()).toEqual({ name: 'Mary Akinyi Odongo', village: '', phone: '0712345678' });
83
+ expect(screen.getByText('Name: Mary Akinyi Odongo')).toBeInTheDocument();
84
+ expect(screen.getByText('Phone: 0712345678')).toBeInTheDocument();
85
+ // Village is the search box, so it never appears as a tag — and a patient carries no
86
+ // village, so the box starts empty.
87
+ expect(screen.getByPlaceholderText('Village name')).toHaveValue('');
88
+ expect(screen.queryByText(/^Village:/)).not.toBeInTheDocument();
89
+ });
90
+
91
+ it('dismissing the name tag drops that filter and re-searches immediately', async () => {
92
+ const user = userEvent.setup();
93
+ render(<PCSSearchResults subject={subject} />);
94
+
95
+ await user.click(screen.getByRole('button', { name: 'Remove Name: Mary Akinyi Odongo' }));
96
+
97
+ expect(committedFilters()).toEqual({ name: '', village: '', phone: '0712345678' });
98
+ expect(screen.queryByText('Name: Mary Akinyi Odongo')).not.toBeInTheDocument();
99
+ expect(screen.getByText('Phone: 0712345678')).toBeInTheDocument();
100
+ });
101
+
102
+ it('searches the village typed into the box without tagging it', async () => {
103
+ const user = userEvent.setup();
104
+ render(<PCSSearchResults subject={subject} />);
105
+
106
+ await user.type(screen.getByPlaceholderText('Village name'), 'KAMBI{Enter}');
107
+
108
+ expect(committedFilters()).toEqual({ name: 'Mary Akinyi Odongo', village: 'KAMBI', phone: '0712345678' });
109
+ expect(screen.queryByText('Village: KAMBI')).not.toBeInTheDocument();
110
+ });
111
+
112
+ it('discards an uncommitted name edit when the panel is collapsed', async () => {
113
+ const user = userEvent.setup();
114
+ render(<PCSSearchResults subject={subject} />);
115
+
116
+ await user.click(screen.getByRole('button', { name: 'Edit filters' }));
117
+ await user.clear(screen.getByLabelText('Name'));
118
+ await user.type(screen.getByLabelText('Name'), 'Someone Else');
119
+ await user.click(screen.getByRole('button', { name: 'Hide filters' }));
120
+
121
+ // Never searched, so it must not appear as a tag describing the results.
122
+ expect(screen.queryByText('Name: Someone Else')).not.toBeInTheDocument();
123
+ expect(screen.getByText('Name: Mary Akinyi Odongo')).toBeInTheDocument();
124
+ expect(committedFilters()).toEqual({ name: 'Mary Akinyi Odongo', village: '', phone: '0712345678' });
125
+ });
126
+
127
+ it('commits an edited name on submit and tags it', async () => {
128
+ const user = userEvent.setup();
129
+ render(<PCSSearchResults subject={subject} />);
130
+
131
+ await user.click(screen.getByRole('button', { name: 'Edit filters' }));
132
+ await user.clear(screen.getByLabelText('Name'));
133
+ await user.type(screen.getByLabelText('Name'), 'Someone Else');
134
+ await user.click(screen.getByRole('button', { name: 'Search' }));
135
+
136
+ expect(committedFilters()).toEqual({ name: 'Someone Else', village: '', phone: '0712345678' });
137
+
138
+ await user.click(screen.getByRole('button', { name: 'Hide filters' }));
139
+ expect(screen.getByText('Name: Someone Else')).toBeInTheDocument();
140
+ });
141
+
142
+ it('opens the panel and stops searching once every filter is gone', async () => {
143
+ const user = userEvent.setup();
144
+ render(<PCSSearchResults subject={{ ...subject, phoneNumber: null }} />);
145
+
146
+ await user.click(screen.getByRole('button', { name: 'Remove Name: Mary Akinyi Odongo' }));
147
+
148
+ expect(committedFilters()).toEqual({ name: '', village: '', phone: '' });
149
+ expect(screen.getByText('Add a filter to search the PCS registry')).toBeInTheDocument();
150
+ // The panel opens itself so there are fields to type into.
151
+ expect(screen.getByLabelText('Name')).toBeInTheDocument();
152
+ });
153
+
154
+ it('reports on the linked participant instead of searching when the patient has a study ID', () => {
155
+ mockUsePatientStudyLink.mockReturnValue({
156
+ localPatient: { uuid: 'local-uuid' },
157
+ studyParticipantId: '901-1-1-3',
158
+ isLoading: false,
159
+ error: undefined,
160
+ mutate: vi.fn(),
161
+ } as any);
162
+
163
+ render(<PCSSearchResults subject={subject} />);
164
+
165
+ expect(screen.getByText('Linked view for 901-1-1-3')).toBeInTheDocument();
166
+ // The filter bar is gone entirely — the pane is reporting, not searching.
167
+ expect(screen.queryByPlaceholderText('Village name')).not.toBeInTheDocument();
168
+ });
169
+ });