@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.
- package/.turbo/turbo-build.log +6 -0
- package/dist/1072.js +1 -0
- package/dist/1072.js.map +1 -0
- package/dist/1102.js +1 -0
- package/dist/1102.js.map +1 -0
- package/dist/1105.js +1 -0
- package/dist/1105.js.map +1 -0
- package/dist/1148.js +1 -0
- package/dist/1148.js.map +1 -0
- package/dist/1278.js +1 -0
- package/dist/1278.js.map +1 -0
- package/dist/1442.js +1 -0
- package/dist/1442.js.map +1 -0
- package/dist/15.js +1 -0
- package/dist/15.js.map +1 -0
- package/dist/1620.js +1 -0
- package/dist/1620.js.map +1 -0
- package/dist/1789.js +1 -0
- package/dist/1789.js.map +1 -0
- package/dist/1818.js +1 -0
- package/dist/1818.js.map +1 -0
- package/dist/1831.js +1 -0
- package/dist/1831.js.map +1 -0
- package/dist/1844.js +1 -0
- package/dist/1844.js.map +1 -0
- package/dist/1854.js +1 -0
- package/dist/1854.js.map +1 -0
- package/dist/1946.js +1 -0
- package/dist/1946.js.map +1 -0
- package/dist/2007.js +1 -0
- package/dist/2007.js.map +1 -0
- package/dist/2115.js +1 -0
- package/dist/2115.js.map +1 -0
- package/dist/2152.js +1 -0
- package/dist/2152.js.map +1 -0
- package/dist/2202.js +1 -0
- package/dist/2202.js.map +1 -0
- package/dist/2394.js +1 -0
- package/dist/2394.js.map +1 -0
- package/dist/2530.js +1 -0
- package/dist/2530.js.map +1 -0
- package/dist/2534.js +1 -0
- package/dist/2534.js.map +1 -0
- package/dist/2537.js +1 -0
- package/dist/2537.js.map +1 -0
- package/dist/2556.js +1 -0
- package/dist/2556.js.map +1 -0
- package/dist/2648.js +1 -0
- package/dist/2648.js.map +1 -0
- package/dist/2795.js +1 -0
- package/dist/2795.js.map +1 -0
- package/dist/2827.js +1 -0
- package/dist/2827.js.map +1 -0
- package/dist/2916.js +1 -0
- package/dist/2916.js.map +1 -0
- package/dist/2924.js +1 -0
- package/dist/2924.js.map +1 -0
- package/dist/2947.js +1 -0
- package/dist/2947.js.map +1 -0
- package/dist/3022.js +1 -0
- package/dist/3022.js.map +1 -0
- package/dist/3285.js +1 -0
- package/dist/3285.js.map +1 -0
- package/dist/3335.js +1 -0
- package/dist/3335.js.map +1 -0
- package/dist/3378.js +1 -0
- package/dist/3378.js.map +1 -0
- package/dist/3381.js +1 -0
- package/dist/3381.js.map +1 -0
- package/dist/3504.js +1 -0
- package/dist/3504.js.map +1 -0
- package/dist/3530.js +1 -0
- package/dist/3530.js.map +1 -0
- package/dist/356.js +1 -0
- package/dist/356.js.map +1 -0
- package/dist/3569.js +1 -0
- package/dist/3569.js.map +1 -0
- package/dist/3723.js +1 -0
- package/dist/3723.js.map +1 -0
- package/dist/3745.js +1 -0
- package/dist/3745.js.map +1 -0
- package/dist/3813.js +1 -0
- package/dist/3813.js.map +1 -0
- package/dist/393.js +6 -0
- package/dist/393.js.map +1 -0
- package/dist/3953.js +1 -0
- package/dist/3953.js.map +1 -0
- package/dist/3989.js +1 -0
- package/dist/3989.js.map +1 -0
- package/dist/412.js +1 -0
- package/dist/412.js.map +1 -0
- package/dist/4178.js +1 -0
- package/dist/4178.js.map +1 -0
- package/dist/4413.js +1 -0
- package/dist/4413.js.map +1 -0
- package/dist/4487.js +11 -0
- package/dist/4487.js.map +1 -0
- package/dist/4537.js +1 -0
- package/dist/4537.js.map +1 -0
- package/dist/4588.js +1 -0
- package/dist/4588.js.map +1 -0
- package/dist/4637.js +1 -0
- package/dist/4637.js.map +1 -0
- package/dist/4650.js +1 -0
- package/dist/4650.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/4817.js +1 -0
- package/dist/4817.js.map +1 -0
- package/dist/4818.js +1 -0
- package/dist/4818.js.map +1 -0
- package/dist/500.js +1 -0
- package/dist/500.js.map +1 -0
- package/dist/5086.js +1 -0
- package/dist/5086.js.map +1 -0
- package/dist/5117.js +1 -0
- package/dist/5117.js.map +1 -0
- package/dist/5279.js +1 -0
- package/dist/5279.js.map +1 -0
- package/dist/5366.js +1 -0
- package/dist/5366.js.map +1 -0
- package/dist/5598.js +1 -0
- package/dist/5598.js.map +1 -0
- package/dist/5676.js +1 -0
- package/dist/5676.js.map +1 -0
- package/dist/5731.js +5 -0
- package/dist/5731.js.map +1 -0
- package/dist/5792.js +1 -0
- package/dist/5792.js.map +1 -0
- package/dist/5825.js +1 -0
- package/dist/5825.js.map +1 -0
- package/dist/5932.js +1 -0
- package/dist/5932.js.map +1 -0
- package/dist/5941.js +1 -0
- package/dist/5941.js.map +1 -0
- package/dist/5967.js +1 -0
- package/dist/5967.js.map +1 -0
- package/dist/6104.js +1 -0
- package/dist/6104.js.map +1 -0
- package/dist/624.js +1 -0
- package/dist/624.js.map +1 -0
- package/dist/6259.js +1 -0
- package/dist/6259.js.map +1 -0
- package/dist/6273.js +1 -0
- package/dist/6273.js.map +1 -0
- package/dist/6337.js +1 -0
- package/dist/6337.js.map +1 -0
- package/dist/6427.js +1 -0
- package/dist/6427.js.map +1 -0
- package/dist/6478.js +7 -0
- package/dist/6478.js.map +1 -0
- package/dist/6508.js +1 -0
- package/dist/6508.js.map +1 -0
- package/dist/6561.js +1 -0
- package/dist/6561.js.map +1 -0
- package/dist/6566.js +1 -0
- package/dist/6566.js.map +1 -0
- package/dist/6567.js +1 -0
- package/dist/6567.js.map +1 -0
- package/dist/6634.js +1 -0
- package/dist/6634.js.map +1 -0
- package/dist/7022.js +1 -0
- package/dist/7022.js.map +1 -0
- package/dist/7085.js +1 -0
- package/dist/7085.js.map +1 -0
- package/dist/7209.js +1 -0
- package/dist/7209.js.map +1 -0
- package/dist/7291.js +1 -0
- package/dist/7291.js.map +1 -0
- package/dist/7360.js +1 -0
- package/dist/7360.js.map +1 -0
- package/dist/7453.js +1 -0
- package/dist/7453.js.map +1 -0
- package/dist/7454.js +1 -0
- package/dist/7454.js.map +1 -0
- package/dist/7478.js +1 -0
- package/dist/7478.js.map +1 -0
- package/dist/7778.js +1 -0
- package/dist/7778.js.map +1 -0
- package/dist/7782.js +1 -0
- package/dist/7782.js.map +1 -0
- package/dist/8028.js +1 -0
- package/dist/8028.js.map +1 -0
- package/dist/8031.js +1 -0
- package/dist/8031.js.map +1 -0
- package/dist/8196.js +1 -0
- package/dist/8196.js.map +1 -0
- package/dist/820.js +1 -0
- package/dist/820.js.map +1 -0
- package/dist/8240.js +1 -0
- package/dist/8240.js.map +1 -0
- package/dist/8472.js +1 -0
- package/dist/8472.js.map +1 -0
- package/dist/8607.js +1 -0
- package/dist/8607.js.map +1 -0
- package/dist/8793.js +1 -0
- package/dist/8793.js.map +1 -0
- package/dist/8924.js +1 -0
- package/dist/8924.js.map +1 -0
- package/dist/8990.js +1 -0
- package/dist/8990.js.map +1 -0
- package/dist/9060.js +1 -0
- package/dist/9060.js.map +1 -0
- package/dist/9061.js +1 -0
- package/dist/9061.js.map +1 -0
- package/dist/9087.js +1 -0
- package/dist/9087.js.map +1 -0
- package/dist/9165.js +1 -0
- package/dist/9165.js.map +1 -0
- package/dist/9274.js +1 -0
- package/dist/9274.js.map +1 -0
- package/dist/9425.js +1 -0
- package/dist/9520.js +1 -0
- package/dist/9520.js.map +1 -0
- package/dist/9547.js +1 -0
- package/dist/9547.js.map +1 -0
- package/dist/9612.js +1 -0
- package/dist/9612.js.map +1 -0
- package/dist/9771.js +1 -0
- package/dist/9771.js.map +1 -0
- package/dist/9774.js +1 -0
- package/dist/9774.js.map +1 -0
- package/dist/9806.js +1 -0
- package/dist/9806.js.map +1 -0
- package/dist/9885.js +1 -0
- package/dist/9885.js.map +1 -0
- package/dist/992.js +1 -0
- package/dist/992.js.map +1 -0
- package/dist/9941.js +1 -0
- package/dist/9941.js.map +1 -0
- package/dist/997.js +1 -0
- package/dist/997.js.map +1 -0
- package/dist/kenyaemr-esm-pcs-app.js +3 -0
- package/dist/kenyaemr-esm-pcs-app.js.buildmanifest.json +3173 -0
- package/dist/kenyaemr-esm-pcs-app.js.map +1 -0
- package/dist/main.js +3 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/package.json +50 -0
- package/rspack.config.js +1 -0
- package/src/add-dependant/add-dependant.scss +29 -0
- package/src/add-dependant/add-dependant.workspace.test.ts +65 -0
- package/src/add-dependant/add-dependant.workspace.tsx +249 -0
- package/src/config-schema.ts +67 -0
- package/src/declarations.d.ts +2 -0
- package/src/empty-svg.component.tsx +565 -0
- package/src/index.ts +34 -0
- package/src/link-hie-dependant/link-hie-dependant.scss +55 -0
- package/src/link-hie-dependant/link-hie-dependant.workspace.tsx +207 -0
- package/src/linked/dependant-row.component.test.tsx +134 -0
- package/src/linked/dependant-row.component.tsx +122 -0
- package/src/linked/dependants-list.component.tsx +60 -0
- package/src/linked/linked-participant-body.component.tsx +82 -0
- package/src/linked/linked-participant.component.test.tsx +134 -0
- package/src/linked/linked-participant.component.tsx +142 -0
- package/src/linked/participant-details.component.tsx +89 -0
- package/src/linked/use-study-sync-snackbars.ts +41 -0
- package/src/modals/delink-participant.modal.tsx +96 -0
- package/src/modals/link-dependant.modal.tsx +244 -0
- package/src/modals/link-participant.modal.tsx +143 -0
- package/src/modals/link-participant.scss +48 -0
- package/src/pcs.scss +241 -0
- package/src/resources/hie-contacts.ts +61 -0
- package/src/resources/identifiers.ts +52 -0
- package/src/resources/link-dependant.resource.test.ts +445 -0
- package/src/resources/link-dependant.resource.ts +268 -0
- package/src/resources/link-participant.resource.test.ts +336 -0
- package/src/resources/link-participant.resource.ts +452 -0
- package/src/resources/patient-create.resource.ts +296 -0
- package/src/resources/patient-search.resource.ts +106 -0
- package/src/resources/pcs.resource.test.ts +36 -0
- package/src/resources/pcs.resource.ts +182 -0
- package/src/resources/use-identifier-type-uuids.ts +38 -0
- package/src/routes.json +54 -0
- package/src/search/participant-results.component.tsx +96 -0
- package/src/search/participant-tile.component.tsx +133 -0
- package/src/search/pcs-empty-state.component.tsx +31 -0
- package/src/search/pcs-results-skeleton.component.tsx +21 -0
- package/src/search/pcs-search.component.test.tsx +169 -0
- package/src/search/pcs-search.component.tsx +228 -0
- package/src/types/index.ts +140 -0
- package/translations/am.json +120 -0
- package/translations/en.json +120 -0
- package/translations/fr.json +122 -0
- package/translations/sw.json +120 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type PcsHieDependant } from '../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads the dependants the HIE lists on a patient's record. The contact array is already in
|
|
5
|
+
* hand whenever a patient has been pulled from the HIE, so this needs no request.
|
|
6
|
+
*/
|
|
7
|
+
export const getDependentsFromContacts = (patient: any): Array<PcsHieDependant> => {
|
|
8
|
+
if (!patient?.contact) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return patient.contact.map((contact: any, index: number) => {
|
|
13
|
+
const relationship = contact.relationship?.[0]?.coding?.[0]?.display || 'Unknown';
|
|
14
|
+
|
|
15
|
+
const name =
|
|
16
|
+
contact.name?.text?.trim() ||
|
|
17
|
+
`${contact.name?.given?.join(' ') || ''} ${contact.name?.family || ''}`.trim() ||
|
|
18
|
+
'Unknown';
|
|
19
|
+
|
|
20
|
+
const phoneContact = contact.telecom?.find((t) => t.system === 'phone');
|
|
21
|
+
const phoneNumber = phoneContact?.value || 'N/A';
|
|
22
|
+
|
|
23
|
+
const emailContact = contact.telecom?.find((t) => t.system === 'email');
|
|
24
|
+
const email = emailContact?.value || 'N/A';
|
|
25
|
+
|
|
26
|
+
const gender = contact.gender || 'Unknown';
|
|
27
|
+
|
|
28
|
+
const birthDateExtension = contact.extension?.find(
|
|
29
|
+
(ext) => ext.url === 'https://ts.kenya-hie.health/fhir/StructureDefinition/date_of_birth',
|
|
30
|
+
);
|
|
31
|
+
const birthDate = birthDateExtension?.valueString || 'Unknown';
|
|
32
|
+
|
|
33
|
+
const identifierExtensions = contact.extension?.filter((ext) => ext.url === 'identifiers') || [];
|
|
34
|
+
const shaNumber = identifierExtensions.find((ext) => ext.valueIdentifier?.type?.coding?.[0]?.code === 'sha-number')
|
|
35
|
+
?.valueIdentifier?.value;
|
|
36
|
+
const shaIdNumber = identifierExtensions.find(
|
|
37
|
+
(ext) => ext.valueIdentifier?.type?.coding?.[0]?.code === 'sha-id-number',
|
|
38
|
+
)?.valueIdentifier?.value;
|
|
39
|
+
const nationalId = identifierExtensions.find(
|
|
40
|
+
(ext) => ext.valueIdentifier?.type?.coding?.[0]?.code === 'national-id',
|
|
41
|
+
)?.valueIdentifier?.value;
|
|
42
|
+
const birthCertificate = identifierExtensions.find(
|
|
43
|
+
(ext) => ext.valueIdentifier?.type?.coding?.[0]?.code === 'birth-certificate',
|
|
44
|
+
)?.valueIdentifier?.value;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
id: contact.id || `contact-${index}`,
|
|
48
|
+
name,
|
|
49
|
+
relationship,
|
|
50
|
+
phoneNumber,
|
|
51
|
+
email,
|
|
52
|
+
gender,
|
|
53
|
+
birthDate,
|
|
54
|
+
shaNumber,
|
|
55
|
+
nationalId,
|
|
56
|
+
birthCertificate,
|
|
57
|
+
contactData: contact,
|
|
58
|
+
shaIdNumber,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
|
|
3
|
+
/** The OpenMRS ID identifier type and the idgen source that mints values for it. */
|
|
4
|
+
export const openmrsId = 'dfacd928-0370-4315-99d7-6ec1c9f7ae76';
|
|
5
|
+
export const openmrsIdSource = 'fb034aac-2353-4940-abe2-7bc94e7c1e71';
|
|
6
|
+
|
|
7
|
+
/** Mints the next OpenMRS ID from the configured identifier source. */
|
|
8
|
+
export function generateIdentifier(source: string) {
|
|
9
|
+
const abortController = new AbortController();
|
|
10
|
+
|
|
11
|
+
return openmrsFetch<{ identifier: string }>(`${restBaseUrl}/idgen/identifiersource/${source}/identifier`, {
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
method: 'POST',
|
|
14
|
+
body: {},
|
|
15
|
+
signal: abortController.signal,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reads one identifier off a local (REST) patient record by its type uuid. */
|
|
20
|
+
export const getLocalIdentifierValue = (localPatient: any, typeUuid: string): string | undefined =>
|
|
21
|
+
localPatient?.identifiers
|
|
22
|
+
?.find((identifier: any) => identifier?.identifierType?.uuid === typeUuid)
|
|
23
|
+
?.identifier?.trim() || undefined;
|
|
24
|
+
|
|
25
|
+
/** Strips anything OpenMRS will not accept in a person name. */
|
|
26
|
+
export const sanitizeName = (name: string): string => name?.trim().replace(/[^\w\s'\-\.]/g, '') || '';
|
|
27
|
+
|
|
28
|
+
export const getNationalIdFromPatient = (
|
|
29
|
+
patient: fhir.Patient,
|
|
30
|
+
nationalIdIdentifierTypeUuid?: string,
|
|
31
|
+
): string | null => {
|
|
32
|
+
const nationalIdIdentifier = patient.identifier?.find((identifier) => {
|
|
33
|
+
const coding = identifier.type?.coding?.[0];
|
|
34
|
+
if (!coding) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const code = coding.code?.trim();
|
|
38
|
+
const display = coding.display?.trim().toLowerCase();
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
code === 'national-id' ||
|
|
42
|
+
display === 'national id' ||
|
|
43
|
+
(!!nationalIdIdentifierTypeUuid && code === nationalIdIdentifierTypeUuid)
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return nationalIdIdentifier?.value?.trim() || null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const getPhoneFromFhirPatient = (patient?: fhir.Patient): string | undefined =>
|
|
51
|
+
patient?.telecom?.find((contact) => contact.system === 'phone' || contact.use === 'mobile')?.value?.trim() ||
|
|
52
|
+
undefined;
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getSessionLocation, launchWorkspace2, launchWorkspaceGroup2, openmrsFetch } from '@openmrs/esm-framework';
|
|
3
|
+
import { createDependantPatient, createPatientFromHiePatient } from './patient-create.resource';
|
|
4
|
+
import { findExistingLocalPatient } from './patient-search.resource';
|
|
5
|
+
import {
|
|
6
|
+
createAndLinkFromParticipant,
|
|
7
|
+
createDependantWithTemporaryId,
|
|
8
|
+
linkDependantToParticipant,
|
|
9
|
+
linkHieDependantWithTemporaryId,
|
|
10
|
+
useHieDependantLinkState,
|
|
11
|
+
} from './link-dependant.resource';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
14
|
+
import { SWRConfig } from 'swr';
|
|
15
|
+
import { type PcsParticipant } from '../types';
|
|
16
|
+
|
|
17
|
+
vi.mock('@openmrs/esm-framework', async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal<typeof import('@openmrs/esm-framework')>()),
|
|
19
|
+
openmrsFetch: vi.fn(),
|
|
20
|
+
getSessionLocation: vi.fn(),
|
|
21
|
+
launchWorkspace2: vi.fn(),
|
|
22
|
+
launchWorkspaceGroup2: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('./patient-create.resource', async (importOriginal) => ({
|
|
26
|
+
...(await importOriginal<typeof import('./patient-create.resource')>()),
|
|
27
|
+
createDependantPatient: vi.fn(),
|
|
28
|
+
createPatientFromHiePatient: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('./patient-search.resource', () => ({ findExistingLocalPatient: vi.fn() }));
|
|
31
|
+
vi.mock('./identifiers', async (importOriginal) => ({
|
|
32
|
+
...(await importOriginal<typeof import('./identifiers')>()),
|
|
33
|
+
openmrsId: 'openmrs-id-type',
|
|
34
|
+
openmrsIdSource: 'openmrs-id-source',
|
|
35
|
+
generateIdentifier: vi.fn(async () => ({ data: { identifier: 'MGH-0001' } })),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const mockOpenmrsFetch = vi.mocked(openmrsFetch);
|
|
39
|
+
const mockCreateDependantPatient = vi.mocked(createDependantPatient);
|
|
40
|
+
const mockCreatePatientFromHie = vi.mocked(createPatientFromHiePatient);
|
|
41
|
+
const mockFindExistingLocalPatient = vi.mocked(findExistingLocalPatient);
|
|
42
|
+
|
|
43
|
+
const uuids = {
|
|
44
|
+
nationalIdUUID: 'national-id-type',
|
|
45
|
+
shaNumberUUID: 'sha-number-type',
|
|
46
|
+
passportUUID: 'passport-type',
|
|
47
|
+
birthCertificateUUID: 'birth-certificate-type',
|
|
48
|
+
crIdentificationNumberUUID: 'cr-number-type',
|
|
49
|
+
phoneAttributeTypeUUID: 'phone-attr-type',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const STUDY_ID_TYPE = 'study-id-type-uuid';
|
|
53
|
+
const PBIDS_ENROLLMENT_TYPE = 'pbids-enrollment-type-uuid';
|
|
54
|
+
const CARDSE_ENROLLMENT_TYPE = 'cardse-enrollment-type-uuid';
|
|
55
|
+
|
|
56
|
+
const participant = {
|
|
57
|
+
individualId: '901-1-1-3',
|
|
58
|
+
firstName: 'DENNIS',
|
|
59
|
+
lastName: 'ODONGO',
|
|
60
|
+
sex: 'M',
|
|
61
|
+
pbidsEnrolled: true,
|
|
62
|
+
cardse: false,
|
|
63
|
+
mother: null,
|
|
64
|
+
compound: { compoundId: '901-1', headIndividualId: '901-1-1-1', headFirstName: 'JOHN', headLastName: 'ODONGO' },
|
|
65
|
+
village: { code: '901', name: 'TEST ABUYA' },
|
|
66
|
+
contacts: [],
|
|
67
|
+
matchedOn: null,
|
|
68
|
+
matchType: null,
|
|
69
|
+
} as PcsParticipant;
|
|
70
|
+
|
|
71
|
+
const dependant = { id: 'CR-123', name: 'Dennis Odongo', gender: 'M', contactData: { id: 'CR-123' } };
|
|
72
|
+
|
|
73
|
+
const link = () =>
|
|
74
|
+
linkDependantToParticipant({
|
|
75
|
+
dependant,
|
|
76
|
+
parentPhoneNumber: '0712345678',
|
|
77
|
+
participant,
|
|
78
|
+
studyParticipantIdentifierType: STUDY_ID_TYPE,
|
|
79
|
+
pbidsEnrollmentAttributeType: PBIDS_ENROLLMENT_TYPE,
|
|
80
|
+
cardseEnrollmentAttributeType: CARDSE_ENROLLMENT_TYPE,
|
|
81
|
+
uuids,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const writes = () =>
|
|
85
|
+
mockOpenmrsFetch.mock.calls.filter(([, options]) => (options as any)?.method === 'POST').map(([url]) => url);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The by-uuid re-read is tagged so tests can tell the refreshed record apart from the one
|
|
89
|
+
* resolved before the writes — that distinction is the bug this file guards.
|
|
90
|
+
*/
|
|
91
|
+
const rereadFor = (url: string) => ({
|
|
92
|
+
uuid: url.split('/patient/')[1]?.split('?')[0],
|
|
93
|
+
identifiers: [],
|
|
94
|
+
reread: true,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
vi.clearAllMocks();
|
|
99
|
+
mockGetSessionLocationDefault();
|
|
100
|
+
mockOpenmrsFetch.mockImplementation((url: string, options?: any) => {
|
|
101
|
+
if (url.includes('?v=custom')) {
|
|
102
|
+
return Promise.resolve({ data: rereadFor(url) } as any);
|
|
103
|
+
}
|
|
104
|
+
if (url.endsWith('/patient') && options?.method === 'POST') {
|
|
105
|
+
return Promise.resolve({ data: { uuid: 'created-uuid', identifiers: [] } } as any);
|
|
106
|
+
}
|
|
107
|
+
return Promise.resolve({ data: { results: [] } } as any);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function mockGetSessionLocationDefault() {
|
|
112
|
+
vi.mocked(getSessionLocation).mockResolvedValue({ uuid: 'location-uuid' } as any);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('linkDependantToParticipant', () => {
|
|
116
|
+
it('uses the existing local record and never creates one', async () => {
|
|
117
|
+
mockFindExistingLocalPatient.mockResolvedValue({ uuid: 'dependant-uuid', identifiers: [] } as any);
|
|
118
|
+
|
|
119
|
+
await link();
|
|
120
|
+
|
|
121
|
+
expect(mockCreateDependantPatient).not.toHaveBeenCalled();
|
|
122
|
+
// isDependent must be true, or household-number would match the parent's record.
|
|
123
|
+
expect(mockFindExistingLocalPatient).toHaveBeenCalledWith(dependant.contactData, true);
|
|
124
|
+
expect(writes()).toContain('/ws/rest/v1/patient/dependant-uuid/identifier');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('creates the dependant, then stamps, then checks in exactly once', async () => {
|
|
128
|
+
mockFindExistingLocalPatient.mockResolvedValue(null);
|
|
129
|
+
mockCreateDependantPatient.mockResolvedValue({ uuid: 'new-uuid', identifiers: [] } as any);
|
|
130
|
+
|
|
131
|
+
await link();
|
|
132
|
+
|
|
133
|
+
expect(mockCreateDependantPatient).toHaveBeenCalledOnce();
|
|
134
|
+
// The dependant and the parent's number reach the creator; the identifier types come from
|
|
135
|
+
// config rather than being resolved inside it.
|
|
136
|
+
expect(mockCreateDependantPatient).toHaveBeenCalledWith(dependant, uuids, '0712345678');
|
|
137
|
+
expect(writes()).toEqual([
|
|
138
|
+
'/ws/rest/v1/patient/new-uuid/identifier',
|
|
139
|
+
'/ws/rest/v1/person/new-uuid/attribute',
|
|
140
|
+
'/ws/rest/v1/person/new-uuid/attribute',
|
|
141
|
+
]);
|
|
142
|
+
// createDependentPatient would have launched inside itself, before the writes.
|
|
143
|
+
expect(vi.mocked(launchWorkspaceGroup2).mock.invocationCallOrder[0]).toBeGreaterThan(
|
|
144
|
+
Math.max(...mockOpenmrsFetch.mock.invocationCallOrder),
|
|
145
|
+
);
|
|
146
|
+
expect(launchWorkspace2).toHaveBeenCalledOnce();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws without checking in when a write fails', async () => {
|
|
150
|
+
mockFindExistingLocalPatient.mockResolvedValue({ uuid: 'dependant-uuid', identifiers: [] } as any);
|
|
151
|
+
mockOpenmrsFetch.mockImplementation((_url: string, options?: any) =>
|
|
152
|
+
options?.method === 'POST'
|
|
153
|
+
? Promise.reject(new Error('Identifier already in use'))
|
|
154
|
+
: Promise.resolve({ data: { results: [] } } as any),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await expect(link()).rejects.toThrow('Identifier already in use');
|
|
158
|
+
expect(launchWorkspace2).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('createAndLinkFromParticipant', () => {
|
|
163
|
+
const withContacts = {
|
|
164
|
+
...participant,
|
|
165
|
+
contacts: [{ phone: '0712345678', nationalId: '12345678' }],
|
|
166
|
+
} as PcsParticipant;
|
|
167
|
+
|
|
168
|
+
const createAndLink = (subject: PcsParticipant = withContacts) =>
|
|
169
|
+
createAndLinkFromParticipant({
|
|
170
|
+
participant: subject,
|
|
171
|
+
nationalIdUUID: uuids.nationalIdUUID,
|
|
172
|
+
phoneAttributeTypeUUID: uuids.phoneAttributeTypeUUID,
|
|
173
|
+
studyParticipantIdentifierType: STUDY_ID_TYPE,
|
|
174
|
+
pbidsEnrollmentAttributeType: PBIDS_ENROLLMENT_TYPE,
|
|
175
|
+
cardseEnrollmentAttributeType: CARDSE_ENROLLMENT_TYPE,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const createBody = () =>
|
|
179
|
+
(mockOpenmrsFetch.mock.calls.find(([u, o]) => u.endsWith('/patient') && (o as any)?.method === 'POST')![1] as any)
|
|
180
|
+
.body;
|
|
181
|
+
|
|
182
|
+
it('carries the phone and national ID across from the PCS contact', async () => {
|
|
183
|
+
await createAndLink();
|
|
184
|
+
|
|
185
|
+
const body = createBody();
|
|
186
|
+
// The national ID is what lets `findExistingLocalPatient` match this child later, and what
|
|
187
|
+
// makes OpenMRS reject a duplicate rather than silently creating a second patient.
|
|
188
|
+
// Asserted by content rather than position: `preferred` is what designates the primary
|
|
189
|
+
// identifier, so the order within the array carries no meaning.
|
|
190
|
+
expect(body.identifiers).toEqual(
|
|
191
|
+
expect.arrayContaining([
|
|
192
|
+
expect.objectContaining({ identifierType: 'openmrs-id-type', preferred: true }),
|
|
193
|
+
expect.objectContaining({ identifier: '12345678', identifierType: 'national-id-type', preferred: false }),
|
|
194
|
+
]),
|
|
195
|
+
);
|
|
196
|
+
expect(body.identifiers).toHaveLength(2);
|
|
197
|
+
expect(body.person.attributes).toEqual([{ attributeType: 'phone-attr-type', value: '0712345678' }]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('creates cleanly for a participant with no contacts', async () => {
|
|
201
|
+
await createAndLink(participant);
|
|
202
|
+
|
|
203
|
+
const body = createBody();
|
|
204
|
+
// An identifier entry with an empty value would be rejected outright.
|
|
205
|
+
expect(body.identifiers).toHaveLength(1);
|
|
206
|
+
expect(body.person.attributes).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("registers the patient from the participant's own demographics", async () => {
|
|
210
|
+
await createAndLink();
|
|
211
|
+
|
|
212
|
+
const [url, options] = mockOpenmrsFetch.mock.calls.find(
|
|
213
|
+
([u, o]) => u.endsWith('/patient') && (o as any)?.method === 'POST',
|
|
214
|
+
)!;
|
|
215
|
+
expect(url).toBe('/ws/rest/v1/patient');
|
|
216
|
+
expect((options as any).body.person).toMatchObject({
|
|
217
|
+
names: [{ preferred: true, givenName: 'DENNIS', familyName: 'ODONGO' }],
|
|
218
|
+
gender: 'M',
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("stamps the participant's own id rather than minting a temporary one", async () => {
|
|
223
|
+
await createAndLink();
|
|
224
|
+
|
|
225
|
+
const identifierPost = mockOpenmrsFetch.mock.calls.find(
|
|
226
|
+
([url, options]) => url.includes('/identifier') && (options as any)?.method === 'POST',
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Writing a temporary id here was the other reading of the request — this is the guard.
|
|
230
|
+
expect((identifierPost![1] as any).body).toMatchObject({
|
|
231
|
+
identifier: '901-1-1-3',
|
|
232
|
+
identifierType: STUDY_ID_TYPE,
|
|
233
|
+
});
|
|
234
|
+
expect(mockOpenmrsFetch.mock.calls.some(([url]) => url.endsWith('/pbids-participants'))).toBe(false);
|
|
235
|
+
expect(launchWorkspace2).toHaveBeenCalledOnce();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('createDependantWithTemporaryId', () => {
|
|
240
|
+
const demographics = {
|
|
241
|
+
firstName: 'BABY',
|
|
242
|
+
middleName: 'A',
|
|
243
|
+
lastName: 'ODONGO',
|
|
244
|
+
sex: 'F',
|
|
245
|
+
dateOfBirth: '2026-08-01',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const addDependant = () =>
|
|
249
|
+
createDependantWithTemporaryId({
|
|
250
|
+
demographics,
|
|
251
|
+
motherIndividualId: '901-1-1-2',
|
|
252
|
+
nationalIdUUID: uuids.nationalIdUUID,
|
|
253
|
+
phoneAttributeTypeUUID: uuids.phoneAttributeTypeUUID,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
mockOpenmrsFetch.mockImplementation((url: string, options?: any) => {
|
|
258
|
+
if (url.includes('?v=custom')) {
|
|
259
|
+
return Promise.resolve({ data: rereadFor(url) } as any);
|
|
260
|
+
}
|
|
261
|
+
if (url.endsWith('/patient') && options?.method === 'POST') {
|
|
262
|
+
return Promise.resolve({ data: { uuid: 'infant-uuid', identifiers: [] } } as any);
|
|
263
|
+
}
|
|
264
|
+
if (url.endsWith('/pbids-participants') && options?.method === 'POST') {
|
|
265
|
+
return Promise.resolve({ data: { ...participant, individualId: 'TMP-901-1-1-9' } } as any);
|
|
266
|
+
}
|
|
267
|
+
return Promise.resolve({ data: { results: [] } } as any);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('registers the patient from the form values', async () => {
|
|
272
|
+
await addDependant();
|
|
273
|
+
|
|
274
|
+
const [, options] = mockOpenmrsFetch.mock.calls.find(
|
|
275
|
+
([u, o]) => u.endsWith('/patient') && (o as any)?.method === 'POST',
|
|
276
|
+
)!;
|
|
277
|
+
expect((options as any).body.person).toMatchObject({
|
|
278
|
+
names: [{ preferred: true, givenName: 'BABY', middleName: 'A', familyName: 'ODONGO' }],
|
|
279
|
+
gender: 'F',
|
|
280
|
+
birthdate: '2026-08-01',
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("posts the new patient against the mother's individual ID", async () => {
|
|
285
|
+
await addDependant();
|
|
286
|
+
|
|
287
|
+
const [url, options] = mockOpenmrsFetch.mock.calls.find(
|
|
288
|
+
([u, o]) => u.endsWith('/pbids-participants') && (o as any)?.method === 'POST',
|
|
289
|
+
)!;
|
|
290
|
+
expect(url).toBe('/ws/rest/v1/pbids-participants');
|
|
291
|
+
// Both field names are the module's, and neither is guessable from this side.
|
|
292
|
+
expect((options as any).body).toEqual({ patientUuid: 'infant-uuid', motherId: '901-1-1-2' });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('writes no identifier or attribute of its own', async () => {
|
|
296
|
+
await addDependant();
|
|
297
|
+
|
|
298
|
+
// The module sets the temporary ID and both enrolment attributes as one unit with the PCS
|
|
299
|
+
// row. A client write here is exactly what would break that pairing.
|
|
300
|
+
expect(writes()).toEqual(['/ws/rest/v1/patient', '/ws/rest/v1/pbids-participants']);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('checks in once, with the record as the server left it', async () => {
|
|
304
|
+
const { localPatient } = await addDependant();
|
|
305
|
+
|
|
306
|
+
// The patient we created cannot carry what the module wrote; only the re-read can.
|
|
307
|
+
expect(localPatient).toMatchObject({ uuid: 'infant-uuid', reread: true });
|
|
308
|
+
expect(launchWorkspace2).toHaveBeenCalledOnce();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('does not check in when the participant cannot be created', async () => {
|
|
312
|
+
mockOpenmrsFetch.mockImplementation((url: string, options?: any) => {
|
|
313
|
+
if (url.endsWith('/patient') && options?.method === 'POST') {
|
|
314
|
+
return Promise.resolve({ data: { uuid: 'infant-uuid', identifiers: [] } } as any);
|
|
315
|
+
}
|
|
316
|
+
if (url.endsWith('/pbids-participants') && options?.method === 'POST') {
|
|
317
|
+
return Promise.reject(new Error('Patient already has a study participant identifier'));
|
|
318
|
+
}
|
|
319
|
+
return Promise.resolve({ data: { results: [] } } as any);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await expect(addDependant()).rejects.toThrow('Patient already has a study participant identifier');
|
|
323
|
+
expect(launchWorkspace2).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('linkHieDependantWithTemporaryId', () => {
|
|
328
|
+
const addFromHie = () =>
|
|
329
|
+
linkHieDependantWithTemporaryId({
|
|
330
|
+
dependant,
|
|
331
|
+
parentPhoneNumber: '0712345678',
|
|
332
|
+
motherIndividualId: '901-1-1-2',
|
|
333
|
+
uuids,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
beforeEach(() => {
|
|
337
|
+
mockOpenmrsFetch.mockImplementation((url: string, options?: any) => {
|
|
338
|
+
if (url.includes('?v=custom')) {
|
|
339
|
+
return Promise.resolve({ data: rereadFor(url) } as any);
|
|
340
|
+
}
|
|
341
|
+
if (url.endsWith('/pbids-participants') && options?.method === 'POST') {
|
|
342
|
+
return Promise.resolve({ data: { ...participant, individualId: 'TMP-901-1-1-9' } } as any);
|
|
343
|
+
}
|
|
344
|
+
return Promise.resolve({ data: { results: [] } } as any);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('reuses the local record when the child is already registered here', async () => {
|
|
349
|
+
mockFindExistingLocalPatient.mockResolvedValue({ uuid: 'existing-uuid', identifiers: [] } as any);
|
|
350
|
+
|
|
351
|
+
await addFromHie();
|
|
352
|
+
|
|
353
|
+
// Creating here would duplicate a patient the HIE contact already resolves to.
|
|
354
|
+
expect(mockCreateDependantPatient).not.toHaveBeenCalled();
|
|
355
|
+
// isDependent must be true, or household-number would match the mother's own record.
|
|
356
|
+
expect(mockFindExistingLocalPatient).toHaveBeenCalledWith(dependant.contactData, true);
|
|
357
|
+
expect(
|
|
358
|
+
mockOpenmrsFetch.mock.calls.find(
|
|
359
|
+
([u, o]) => u.endsWith('/pbids-participants') && (o as any)?.method === 'POST',
|
|
360
|
+
)![1],
|
|
361
|
+
).toMatchObject({ body: { patientUuid: 'existing-uuid' } });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('creates the child from the HIE contact when she is not registered here', async () => {
|
|
365
|
+
mockFindExistingLocalPatient.mockResolvedValue(null);
|
|
366
|
+
mockCreateDependantPatient.mockResolvedValue({ uuid: 'new-uuid', identifiers: [] } as any);
|
|
367
|
+
|
|
368
|
+
await addFromHie();
|
|
369
|
+
|
|
370
|
+
expect(mockCreateDependantPatient).toHaveBeenCalledOnce();
|
|
371
|
+
expect(mockCreateDependantPatient).toHaveBeenCalledWith(dependant, uuids, '0712345678');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("posts the resolved patient against the mother's individual ID", async () => {
|
|
375
|
+
mockFindExistingLocalPatient.mockResolvedValue({ uuid: 'existing-uuid', identifiers: [] } as any);
|
|
376
|
+
|
|
377
|
+
await addFromHie();
|
|
378
|
+
|
|
379
|
+
const [url, options] = mockOpenmrsFetch.mock.calls.find(
|
|
380
|
+
([u, o]) => u.endsWith('/pbids-participants') && (o as any)?.method === 'POST',
|
|
381
|
+
)!;
|
|
382
|
+
expect(url).toBe('/ws/rest/v1/pbids-participants');
|
|
383
|
+
// Both field names are the module's, and neither is guessable from this side.
|
|
384
|
+
expect((options as any).body).toEqual({ patientUuid: 'existing-uuid', motherId: '901-1-1-2' });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('writes no identifier or attribute of its own', async () => {
|
|
388
|
+
mockFindExistingLocalPatient.mockResolvedValue({ uuid: 'existing-uuid', identifiers: [] } as any);
|
|
389
|
+
|
|
390
|
+
await addFromHie();
|
|
391
|
+
|
|
392
|
+
// The module sets the temporary ID and both enrolment attributes as one unit with the PCS
|
|
393
|
+
// row. A client write here is exactly what would break that pairing.
|
|
394
|
+
expect(writes()).toEqual(['/ws/rest/v1/pbids-participants']);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('starts the visit once, with the record as the server left it', async () => {
|
|
398
|
+
mockFindExistingLocalPatient.mockResolvedValue({ uuid: 'existing-uuid', identifiers: [] } as any);
|
|
399
|
+
|
|
400
|
+
const { localPatient } = await addFromHie();
|
|
401
|
+
|
|
402
|
+
// The record we resolved cannot carry what the module wrote; only the re-read can.
|
|
403
|
+
expect(localPatient).toMatchObject({ uuid: 'existing-uuid', reread: true });
|
|
404
|
+
expect(launchWorkspace2).toHaveBeenCalledOnce();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('does not start a visit when the participant cannot be created', async () => {
|
|
408
|
+
mockFindExistingLocalPatient.mockResolvedValue({ uuid: 'existing-uuid', identifiers: [] } as any);
|
|
409
|
+
mockOpenmrsFetch.mockImplementation((url: string, options?: any) =>
|
|
410
|
+
url.endsWith('/pbids-participants') && options?.method === 'POST'
|
|
411
|
+
? Promise.reject(new Error('Patient already has a study participant identifier'))
|
|
412
|
+
: Promise.resolve({ data: { results: [] } } as any),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
await expect(addFromHie()).rejects.toThrow('Patient already has a study participant identifier');
|
|
416
|
+
expect(launchWorkspace2).not.toHaveBeenCalled();
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe('useHieDependantLinkState', () => {
|
|
421
|
+
const candidates = [
|
|
422
|
+
{ id: 'CR-linked', contactData: { id: 'CR-linked' } },
|
|
423
|
+
{ id: 'CR-free', contactData: { id: 'CR-free' } },
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
it('marks the candidates whose local patient already holds a study ID', async () => {
|
|
427
|
+
mockFindExistingLocalPatient.mockImplementation(async (contactData: any) =>
|
|
428
|
+
contactData.id === 'CR-linked'
|
|
429
|
+
? { uuid: 'p1', identifiers: [{ identifier: '901-1-1-3', identifierType: { uuid: STUDY_ID_TYPE } }] }
|
|
430
|
+
: null,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// Fresh SWR cache per render, so nothing leaks between cases.
|
|
434
|
+
const { result } = renderHook(() => useHieDependantLinkState(candidates, [STUDY_ID_TYPE, 'temp-id-type']), {
|
|
435
|
+
wrapper: ({ children }) => React.createElement(SWRConfig, { value: { provider: () => new Map() } }, children),
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Waiting on the data itself rather than on isChecking, which is briefly false before
|
|
439
|
+
// SWR has begun.
|
|
440
|
+
await waitFor(() => expect(result.current.linkedById['CR-linked']).toBe('901-1-1-3'));
|
|
441
|
+
|
|
442
|
+
// The marked one is what the modal disables; the unmarked one stays selectable.
|
|
443
|
+
expect(result.current.linkedById).toEqual({ 'CR-linked': '901-1-1-3', 'CR-free': undefined });
|
|
444
|
+
});
|
|
445
|
+
});
|