Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) O3-3797: Add the Encounter List Table and Tabs to Patient Chart #1987

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/esm-patient-chart-app/src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ export const esmPatientChartSchema = {
_default: '/etl-latest/etl/patient/',
_description: 'Custom URL to load resources required for showing recommended visit types',
},
trueConceptUuid: {
_type: Type.String,
_description: 'Default concept uuid for true in forms',
_default: 'cf82933b-3f3f-45e7-a5ab-5d31aaee3da3',
},
};

export interface ChartConfig {
Expand Down Expand Up @@ -161,4 +166,5 @@ export interface ChartConfig {
uuid: string;
}>;
visitDiagnosisConceptUuid: string;
trueConceptUuid: string;
}
2 changes: 2 additions & 0 deletions packages/esm-patient-chart-app/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const clinicalFormsWorkspace = 'clinical-forms-workspace';
export const formEntryWorkspace = 'patient-form-entry-workspace';
export const spaRoot = window['getOpenmrsSpaBase']();
export const basePath = '/patient/:patientUuid/chart';
export const dashboardPath = `${basePath}/:view/*`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import { useConfig, usePatient, useVisit } from '@openmrs/esm-framework';
import { useTranslation } from 'react-i18next';
import { Tabs, Tab, TabList, TabPanels, TabPanel } from '@carbon/react';
import { EncounterList } from './encounter-list.component';
import { getMenuItemTabsConfiguration } from '../utils/encounter-list-config-builder';
import styles from './encounter-list-tabs.scss';
import { filter } from '../utils/helpers';

interface EncounterListTabsComponentProps {
patientUuid: string;
}

const EncounterListTabsComponent: React.FC<EncounterListTabsComponentProps> = ({ patientUuid }) => {
const config = useConfig();
const { tabDefinitions = [] } = config;
const { t } = useTranslation();
const tabsConfig = getMenuItemTabsConfiguration(tabDefinitions);
const patient = usePatient(patientUuid);
const { currentVisit } = useVisit(patientUuid);

return (
<div className={styles.tabContainer}>
<Tabs>
<TabList contained>
{tabsConfig.map((tab) => (
<Tab key={tab.name}>{t(tab.name)}</Tab>
))}
</TabList>
<TabPanels>
{tabsConfig.map((tab) => (
<TabPanel key={tab.name}>
<EncounterList
filter={tab.hasFilter ? (encounter) => filter(encounter, tab.formList[0].uuid) : null}
patientUuid={patientUuid}
formList={tab.formList}
columns={tab.columns}
encounterType={tab.encounterType}
launchOptions={tab.launchOptions}
headerTitle={tab.headerTitle}
description={tab.description}
currentVisit={currentVisit}
deathStatus={patient?.patient?.deceasedBoolean}
/>
</TabPanel>
))}
</TabPanels>
</Tabs>
</div>
);
};

export default EncounterListTabsComponent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.tabContainer div[role='tabpanel'] {
padding: 0 !important;
}

.tabContainer li button {
width: 100% !important;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import React, { useCallback, useMemo, useState } from 'react';
import { navigate, showModal, showSnackbar, Visit } from '@openmrs/esm-framework';
import { EmptyState } from '@openmrs/esm-patient-common-lib';
import { useTranslation } from 'react-i18next';
import { EncounterListDataTable } from './table.component';
import { Button, Link, OverflowMenu, OverflowMenuItem, DataTableSkeleton, Pagination } from '@carbon/react';
import { Add } from '@carbon/react/icons';
import { launchEncounterForm } from '../utils/helpers';
import { deleteEncounter } from '../encounter-list.resource';
import { useEncounterRows, useFormsJson } from '../hooks';

import styles from './encounter-list.scss';
import { type TableRow, type Encounter, type Mode } from '../types';

export interface EncounterListColumn {
key: string;
header: string;
getValue: (encounter: Encounter) => string;
link?: any;
}

export interface EncounterListProps {
patientUuid: string;
encounterType: string;
columns: Array<any>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be explicitly typed.

headerTitle: string;
description: string;
formList?: Array<{
name?: string;
uuid: string;
excludedIntents?: Array<string>;
fixedIntent?: string;
isDefault?: boolean;
}>;
launchOptions: {
hideFormLauncher?: boolean;
displayText?: string;
workspaceWindowSize?: 'minimized' | 'maximized';
};
filter?: (encounter: Encounter) => boolean;
afterFormSaveAction?: () => void;
deathStatus?: boolean;
currentVisit: Visit;
}

export const EncounterList: React.FC<EncounterListProps> = ({
patientUuid,
encounterType,
columns,
headerTitle,
description,
formList,
filter,
launchOptions,
afterFormSaveAction,
currentVisit,
deathStatus,
}) => {
const { t } = useTranslation();

const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);

const { formsJson, isLoading: isLoadingFormsJson, error: errorFormJson } = useFormsJson(formList[0].uuid);
const { encounters, total, isLoading, onFormSave, mutate } = useEncounterRows(
patientUuid,
encounterType,
filter,
afterFormSaveAction,
pageSize,
currentPage,
);

const { workspaceWindowSize, displayText, hideFormLauncher } = launchOptions;

const defaultActions = useMemo(
() => [
{
label: t('viewEncounter', 'View'),
form: {
name: formsJson?.name,
},
mode: 'view',
intent: '*',
},
{
label: t('editEncounter', 'Edit'),
form: {
name: formsJson?.name,
},
mode: 'edit',
intent: '*',
},
{
label: t('deleteEncounter', 'Delete'),
form: {
name: formsJson?.name,
},
mode: 'delete',
intent: '*',
},
],
[formsJson, t],
);

const createLaunchFormAction = useCallback(
(encounter: Encounter, mode: Mode) => () => {
launchEncounterForm(formsJson, currentVisit, mode, onFormSave, encounter.uuid, null, patientUuid);
},
[formsJson, onFormSave, patientUuid, currentVisit],
);

const handleDeleteEncounter = useCallback(
(encounterUuid, encounterTypeName) => {
const close = showModal('delete-encounter-modal', {
close: () => close(),
encounterTypeName: encounterTypeName || '',
onConfirmation: () => {
const abortController = new AbortController();
deleteEncounter(encounterUuid, abortController)
.then(() => {
onFormSave();
mutate();
showSnackbar({
isLowContrast: true,
title: t('encounterDeleted', 'Encounter deleted'),
subtitle: `Encounter ${t('successfullyDeleted', 'successfully deleted')}`,
kind: 'success',
});
})
.catch(() => {
showSnackbar({
isLowContrast: false,
title: t('error', 'Error'),
subtitle: `Encounter ${t('failedDeleting', "couldn't be deleted")}`,
kind: 'error',
});
})
.finally(() => {
close();
});
},
});
},
[onFormSave, t, mutate],
);

const tableRows = useMemo(() => {
return encounters.map((encounter: Encounter) => {
const tableRow: TableRow = { id: encounter.uuid, actions: null };

encounter['launchFormActions'] = {
editEncounter: createLaunchFormAction(encounter, 'edit'),
viewEncounter: createLaunchFormAction(encounter, 'view'),
};

columns.forEach((column) => {
let val = column?.getValue(encounter);
if (column.link) {
val = (
<Link
onClick={(e) => {
e.preventDefault();
if (column.link.handleNavigate) {
column.link.handleNavigate(encounter);
} else {
column.link?.getUrl && navigate({ to: column.link.getUrl() });
}
}}
>
{val}
</Link>
);
}
tableRow[column.key] = val;
});

const actions =
Array.isArray(tableRow.actions) && tableRow.actions.length > 0 ? tableRow.actions : defaultActions;

tableRow['actions'] = (
<OverflowMenu flipped className={styles.flippedOverflowMenu} data-testid="actions-id">
{actions.map((actionItem, index) => {
const form = formsJson && actionItem?.form?.name ? formsJson.name === actionItem.form.name : null;

return (
form && (
<OverflowMenuItem
key={index}
index={index}
itemText={t(actionItem.label)}
onClick={(e) => {
e.preventDefault();
actionItem.mode === 'delete'
? handleDeleteEncounter(encounter.uuid, encounter.encounterType.name)
: launchEncounterForm(
formsJson,
currentVisit,
actionItem.mode === 'enter' ? 'add' : actionItem.mode,
onFormSave,
encounter.uuid,
actionItem.intent,
patientUuid,
);
}}
/>
)
);
})}
</OverflowMenu>
);

return tableRow;
});
}, [
encounters,
createLaunchFormAction,
columns,
defaultActions,
formsJson,
t,
handleDeleteEncounter,
onFormSave,
patientUuid,
currentVisit,
]);

const headers = useMemo(() => {
if (columns) {
return columns.map((column) => {
return { key: column.key, header: t(column.header) };
});
}
return [];
}, [columns, t]);

const formLauncher = useMemo(() => {
if (formsJson && !formsJson['availableIntents']?.length) {
return (
<Button
kind="ghost"
renderIcon={Add}
iconDescription="Add"
onClick={(e) => {
e.preventDefault();
launchEncounterForm(formsJson, currentVisit, 'add', onFormSave, '', '*', patientUuid);
}}
>
{t(displayText)}
</Button>
);
}
return null;
}, [formsJson, displayText, onFormSave, patientUuid, t, currentVisit]);

if (isLoading === true || isLoadingFormsJson === true) {
return <DataTableSkeleton rowCount={10} />;
}

return (
<>
{tableRows?.length > 0 || encounters.length > 0 ? (
<>
<div className={styles.widgetContainer}>
<div className={styles.widgetHeaderContainer}>
<h4 className={`${styles.productiveHeading03} ${styles.text02}`}>{t(headerTitle)}</h4>
{/* @ts-ignore */}
{!(hideFormLauncher ?? deathStatus) && <div className={styles.toggleButtons}>{formLauncher}</div>}
</div>
<EncounterListDataTable tableHeaders={headers} tableRows={tableRows} />
<Pagination
page={currentPage}
pageSizes={[10, 20, 30, 40, 50]}
onChange={({ page, pageSize }) => {
setCurrentPage(page);
setPageSize(pageSize);
}}
pageSize={pageSize}
totalItems={total}
/>
</div>
</>
) : (
<EmptyState
displayText={description}
headerTitle={t(headerTitle)}
launchForm={
hideFormLauncher || deathStatus
? null
: () => launchEncounterForm(formsJson, currentVisit, 'add', onFormSave, '', '*', patientUuid)
}
/>
)}
</>
);
};
Loading