Skip to content

Commit

Permalink
Merge branch 'main' into identifier-input-test
Browse files Browse the repository at this point in the history
  • Loading branch information
denniskigen authored Oct 2, 2024
2 parents 78cafc2 + 2e13624 commit ac163a0
Show file tree
Hide file tree
Showing 10 changed files with 367 additions and 167 deletions.
4 changes: 2 additions & 2 deletions e2e/commands/cohort-operations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { APIRequestContext, expect } from '@playwright/test';
import { Patient } from './patient-operations';
import { type APIRequestContext, expect } from '@playwright/test';
import { type Patient } from './patient-operations';

export interface CohortType {
uuid: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const AddPatientToPatientListMenuItem: React.FC<AddPastVisitOverflowMenuItemProp
const dispose = showModal('add-patient-to-patient-list-modal', {
closeModal: () => dispose(),
patientUuid,
size: 'sm',
});
closeOverflowMenu();
}, [patientUuid]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ describe('AddPatientToPatientListMenuItem', () => {

await user.click(button);

expect(mockShowModal).toHaveBeenCalledWith('add-patient-to-patient-list-modal', expect.any(Object));
expect(mockShowModal).toHaveBeenCalledWith('add-patient-to-patient-list-modal', {
closeModal: expect.any(Function),
size: 'sm',
patientUuid: mockPatient.uuid,
});
});
});
Original file line number Diff line number Diff line change
@@ -1,48 +1,31 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import classNames from 'classnames';
import { mutate } from 'swr';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import useSWR from 'swr';
import { Button, Checkbox, Pagination, Search, CheckboxSkeleton } from '@carbon/react';
import {
getDynamicOfflineDataEntries,
putDynamicOfflineData,
syncDynamicOfflineData,
showSnackbar,
toOmrsIsoString,
usePagination,
navigate,
useConfig,
} from '@openmrs/esm-framework';
import { addPatientToList, getAllPatientLists, getPatientListIdsForPatient } from '../api/api-remote';
import { type ConfigSchema } from '../config-schema';
import { Button, Checkbox, CheckboxSkeleton, Pagination, Search, Tile } from '@carbon/react';
import { navigate, restBaseUrl, showSnackbar, usePagination } from '@openmrs/esm-framework';
import { type AddablePatientListViewModel } from '../api/types';
import { useAddablePatientLists } from '../api/api-remote';
import styles from './add-patient.scss';

interface AddPatientProps {
closeModal: () => void;
patientUuid: string;
}

interface AddablePatientListViewModel {
addPatient(): Promise<void>;
displayName: string;
checked?: boolean;
id: string;
}

const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
const { t } = useTranslation();
const { data, isLoading } = useAddablePatientLists(patientUuid);
const [searchValue, setSearchValue] = useState('');
const [selected, setSelected] = useState<Array<string>>([]);
const { data, isLoading } = useAddablePatientLists(patientUuid);

const handleCreateNewList = () => {
const handleCreateNewList = useCallback(() => {
navigate({
to: window.getOpenmrsSpaBase() + 'home/patient-lists?new_cohort=true',
});

closeModal();
};
}, [closeModal]);

const handleSelectionChanged = useCallback((patientListId: string, listSelected: boolean) => {
if (listSelected) {
Expand All @@ -52,34 +35,39 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
}
}, []);

const handleSubmit = useCallback(() => {
for (const selectedId of selected) {
const patientList = data.find((list) => list.id === selectedId);
if (!patientList) {
continue;
}
const mutateCohortMembers = useCallback(() => {
const key = `${restBaseUrl}/cohortm/cohortmember?patient=${patientUuid}&v=custom:(uuid,patient:ref,cohort:(uuid,name,startDate,endDate))`;

patientList
.addPatient()
.then(() =>
showSnackbar({
title: t('successfullyAdded', 'Successfully added'),
kind: 'success',
isLowContrast: true,
subtitle: `${t('successAddPatientToList', 'Patient added to list')}: ${patientList.displayName}`,
}),
)
.catch(() =>
showSnackbar({
title: t('error', 'Error'),
kind: 'error',
subtitle: `${t('errorAddPatientToList', 'Patient not added to list')}: ${patientList.displayName}`,
}),
);
}
return mutate((k) => typeof k === 'string' && k === key);
}, []);

closeModal();
}, [data, selected, closeModal, t]);
const handleSubmit = useCallback(() => {
Promise.all(
selected.map((selectedId) => {
const patientList = data.find((list) => list.id === selectedId);
if (!patientList) return Promise.resolve();

return patientList
.addPatient()
.then(async () => {
await mutateCohortMembers();
showSnackbar({
title: t('successfullyAdded', 'Successfully added'),
kind: 'success',
isLowContrast: true,
subtitle: `${t('successAddPatientToList', 'Patient added to list')}: ${patientList.displayName}`,
});
})
.catch(() => {
showSnackbar({
title: t('error', 'Error'),
kind: 'error',
subtitle: `${t('errorAddPatientToList', 'Patient not added to list')}: ${patientList.displayName}`,
});
});
}),
).finally(closeModal);
}, [data, selected, closeModal, t, patientUuid]);

const searchResults = useMemo(() => {
if (!data) {
Expand All @@ -94,7 +82,7 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
return data;
}, [searchValue, data]);

const { results, goTo, currentPage, paginated } = usePagination(searchResults, 5);
const { results, goTo, currentPage, paginated } = usePagination<AddablePatientListViewModel>(searchResults, 5);

useEffect(() => {
if (currentPage !== 1) {
Expand All @@ -105,41 +93,54 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
return (
<div className={styles.modalContent}>
<div className={styles.modalHeader}>
<h1 className={styles.productiveHeading03}>{t('addPatientToList', 'Add patient to list')}</h1>
<h3 className={styles.bodyLong01} style={{ margin: '1rem 0' }}>
<h1 className={styles.header}>{t('addPatientToList', 'Add patient to list')}</h1>
<h3 className={styles.subheader}>
{t('searchForAListToAddThisPatientTo', 'Search for a list to add this patient to.')}
</h3>
</div>
<div style={{ marginBottom: '0.875rem' }}>
<Search
style={{ backgroundColor: 'white', borderBottom: '1px solid #e0e0e0' }}
labelText={t('searchForList', 'Search for a list')}
placeholder="Filter list"
onChange={({ target }) => {
setSearchValue(target.value);
}}
value={searchValue}
/>
</div>
<Search
className={styles.search}
labelText={t('searchForList', 'Search for a list')}
placeholder={t('searchForList', 'Search for a list')}
onChange={({ target }) => {
setSearchValue(target.value);
}}
value={searchValue}
/>
<div className={styles.patientListList}>
<fieldset className="cds--fieldset">
<p className="cds--label">{t('patientLists', 'Patient lists')}</p>
{!isLoading && results ? (
results.length > 0 ? (
results.map((patientList) => (
<div key={patientList.id} className={styles.checkbox}>
<Checkbox
key={patientList.id}
onChange={(e) => handleSelectionChanged(patientList.id, e.target.checked)}
checked={patientList.checked || selected.includes(patientList.id)}
disabled={patientList.checked}
labelText={patientList.displayName}
id={patientList.id}
/>
</div>
))
<>
<p className="cds--label">{t('patientLists', 'Patient lists')}</p>
{results.map((patientList) => (
<div key={patientList.id} className={styles.checkbox}>
<Checkbox
key={patientList.id}
onChange={(e) => handleSelectionChanged(patientList.id, e.target.checked)}
checked={patientList.checked || selected.includes(patientList.id)}
disabled={patientList.checked}
labelText={patientList.displayName}
id={patientList.id}
/>
</div>
))}
</>
) : (
<p className={styles.bodyLong01}>{t('noPatientListFound', 'No patient list found')}</p>
<div className={styles.tileContainer}>
<Tile className={styles.tile}>
<div className={styles.tileContent}>
<p className={styles.content}>{t('noMatchingListsFound', 'No matching lists found')}</p>
<p className={styles.actionText}>
<span>{t('trySearchingForADifferentList', 'Try searching for a different list')}</span>
<span>&mdash; or &mdash;</span>
<Button kind="ghost" size="sm" onClick={handleCreateNewList}>
{t('createNewPatientList', 'Create new patient list')}
</Button>
</p>
</div>
</Tile>
</div>
)
) : (
<>
Expand Down Expand Up @@ -180,7 +181,7 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
</div>
)}
<div className={styles.buttonSet}>
<Button kind="ghost" size="xl" onClick={handleCreateNewList}>
<Button className={styles.createButton} kind="ghost" size="xl" onClick={handleCreateNewList}>
{t('createNewPatientList', 'Create new patient list')}
</Button>
<div>
Expand All @@ -196,76 +197,4 @@ const AddPatient: React.FC<AddPatientProps> = ({ closeModal, patientUuid }) => {
);
};

// This entire modal is a little bit special since it not only displays the "real" patient lists (i.e. data from
// the cohorts/backend), but also a fake patient list which doesn't really exist in the backend:
// The offline patient list.
// When a patient is added to the offline list, that patient should become available offline, i.e.
// a dynamic offline data entry must be created.
// This is why the following abstracts away the differences between the real and the fake patient lists.
// The component doesn't really care about which is which - the only thing that matters is that the
// data can be fetched and that there is an "add patient" function.

export function useAddablePatientLists(patientUuid: string) {
const { t } = useTranslation();
const config = useConfig() as ConfigSchema;
return useSWR(['addablePatientLists', patientUuid], async () => {
// Using Promise.allSettled instead of Promise.all here because some distros might not have the
// cohort module installed, leading to the real patient list call failing.
// In that case we still want to show fake lists and *not* error out here.
const [fakeLists, realLists] = await Promise.allSettled([
findFakePatientListsWithoutPatient(patientUuid, t),
findRealPatientListsWithoutPatient(patientUuid, config.myListCohortTypeUUID, config.systemListCohortTypeUUID),
]);

return [
...(fakeLists.status === 'fulfilled' ? fakeLists.value : []),
...(realLists.status === 'fulfilled' ? realLists.value : []),
];
});
}

async function findRealPatientListsWithoutPatient(
patientUuid: string,
myListCohortUUID,
systemListCohortType,
): Promise<Array<AddablePatientListViewModel>> {
const [allLists, listsIdsOfThisPatient] = await Promise.all([
getAllPatientLists({}, myListCohortUUID, systemListCohortType),
getPatientListIdsForPatient(patientUuid),
]);

return allLists.map((list) => ({
id: list.id,
displayName: list.display,
checked: listsIdsOfThisPatient.includes(list.id),
async addPatient() {
await addPatientToList({
cohort: list.id,
patient: patientUuid,
startDate: toOmrsIsoString(new Date()),
});
},
}));
}

async function findFakePatientListsWithoutPatient(
patientUuid: string,
t: TFunction,
): Promise<Array<AddablePatientListViewModel>> {
const offlinePatients = await getDynamicOfflineDataEntries('patient');
const isPatientOnOfflineList = offlinePatients.some((x) => x.identifier === patientUuid);
return isPatientOnOfflineList
? []
: [
{
id: 'fake-offline-patient-list',
displayName: t('offlinePatients', 'Offline patients'),
async addPatient() {
await putDynamicOfflineData('patient', patientUuid);
await syncDynamicOfflineData('patient', patientUuid);
},
},
];
}

export default AddPatient;
Loading

0 comments on commit ac163a0

Please sign in to comment.