From 272b34ccb64f001532602dd17fa8c4f04e74acd2 Mon Sep 17 00:00:00 2001 From: Usama Idriss Kakumba Date: Fri, 29 Nov 2024 08:32:18 +0300 Subject: [PATCH 1/5] fix: move types to one central place the types dir --- .../esm-active-visits-app/src/types/index.ts | 157 +++++++++++++++++- .../visits-summary/visit-detail.component.tsx | 5 +- .../src/visits-summary/visit.resource.ts | 136 +-------------- .../encounter-list.component.tsx | 18 +- .../encounter-observations.component.tsx | 2 +- .../encounter-observations.test.tsx | 2 +- .../medications-summary.component.tsx | 2 +- .../notes-summary.component.tsx | 2 +- .../tests-summary.component.tsx | 2 +- .../visit-summary.component.tsx | 8 +- 10 files changed, 178 insertions(+), 156 deletions(-) diff --git a/packages/esm-active-visits-app/src/types/index.ts b/packages/esm-active-visits-app/src/types/index.ts index 6910c23eb..55fce7015 100644 --- a/packages/esm-active-visits-app/src/types/index.ts +++ b/packages/esm-active-visits-app/src/types/index.ts @@ -1,4 +1,4 @@ -import { type OpenmrsResource } from '@openmrs/esm-framework'; +import { type OpenmrsResource, type Visit } from '@openmrs/esm-framework'; export interface SearchedPatient { patientId: number; @@ -28,3 +28,158 @@ export interface Identifier { preferred: boolean; voided: boolean; } + +export interface Encounter { + uuid: string; + encounterDateTime: string; + encounterProviders: Array<{ + uuid: string; + display: string; + encounterRole: { + uuid: string; + display: string; + }; + provider: { + uuid: string; + person: { + uuid: string; + display: string; + }; + }; + }>; + encounterType: { + uuid: string; + display: string; + }; + obs: Array; + orders: Array; +} + +export interface EncounterProvider { + uuid: string; + display: string; + encounterRole: { + uuid: string; + display: string; + }; + provider: { + uuid: string; + person: { + uuid: string; + display: string; + }; + }; +} + +export interface Observation { + uuid: string; + concept: { + uuid: string; + display: string; + conceptClass: { + uuid: string; + display: string; + }; + }; + display: string; + groupMembers: null | Array<{ + uuid: string; + concept: { + uuid: string; + display: string; + }; + value: { + uuid: string; + display: string; + }; + }>; + value: any; + obsDatetime: string; +} + +export interface Order { + uuid: string; + dateActivated: string; + dateStopped?: Date | null; + dose: number; + dosingInstructions: string | null; + dosingType?: 'org.openmrs.FreeTextDosingInstructions' | 'org.openmrs.SimpleDosingInstructions'; + doseUnits: { + uuid: string; + display: string; + }; + drug: { + uuid: string; + name: string; + strength: string; + display: string; + }; + duration: number; + durationUnits: { + uuid: string; + display: string; + }; + frequency: { + uuid: string; + display: string; + }; + numRefills: number; + orderNumber: string; + orderReason: string | null; + orderReasonNonCoded: string | null; + orderer: { + uuid: string; + person: { + uuid: string; + display: string; + }; + }; + orderType: { + uuid: string; + display: string; + }; + route: { + uuid: string; + display: string; + }; + quantity: number; + quantityUnits: OpenmrsResource; +} + +export interface Note { + note: string; + provider: { + name: string; + role: string; + }; + time: string; +} + +export interface OrderItem { + order: Order; + provider: { + name: string; + role: string; + }; +} + +export interface ActiveVisit { + age: string; + id: string; + idNumber: string; + gender: string; + location: string; + name: string; + patientUuid: string; + visitStartTime: string; + visitType: string; + visitUuid: string; + observations?: Record; + [identifier: string]: string | Record; +} + +export interface VisitResponse { + results: Array; + links: Array<{ rel: 'prev' | 'next' }>; + totalCount: number; +} diff --git a/packages/esm-active-visits-app/src/visits-summary/visit-detail.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visit-detail.component.tsx index 427045e32..27c7f228f 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visit-detail.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visit-detail.component.tsx @@ -2,8 +2,9 @@ import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { ContentSwitcher, DataTableSkeleton, Switch } from '@carbon/react'; -import { type Encounter, useVisit } from './visit.resource'; -import { formatTime, formatDatetime, parseDate } from '@openmrs/esm-framework'; +import { useVisit } from './visit.resource'; +import { type Encounter } from '../types'; +import { formatDatetime, formatTime, parseDate } from '@openmrs/esm-framework'; import EncounterList from './visits-components/encounter-list.component'; import VisitSummary from './visits-components/visit-summary.component'; import styles from './visit-detail-overview.scss'; diff --git a/packages/esm-active-visits-app/src/visits-summary/visit.resource.ts b/packages/esm-active-visits-app/src/visits-summary/visit.resource.ts index 74c76caac..ff98833ea 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visit.resource.ts +++ b/packages/esm-active-visits-app/src/visits-summary/visit.resource.ts @@ -1,139 +1,5 @@ import useSWR from 'swr'; -import { openmrsFetch, restBaseUrl, type OpenmrsResource, type Visit } from '@openmrs/esm-framework'; - -export interface Encounter { - uuid: string; - encounterDateTime: string; - encounterProviders: Array<{ - uuid: string; - display: string; - encounterRole: { - uuid: string; - display: string; - }; - provider: { - uuid: string; - person: { - uuid: string; - display: string; - }; - }; - }>; - encounterType: { - uuid: string; - display: string; - }; - obs: Array; - orders: Array; -} - -export interface EncounterProvider { - uuid: string; - display: string; - encounterRole: { - uuid: string; - display: string; - }; - provider: { - uuid: string; - person: { - uuid: string; - display: string; - }; - }; -} - -export interface Observation { - uuid: string; - concept: { - uuid: string; - display: string; - conceptClass: { - uuid: string; - display: string; - }; - }; - display: string; - groupMembers: null | Array<{ - uuid: string; - concept: { - uuid: string; - display: string; - }; - value: { - uuid: string; - display: string; - }; - }>; - value: any; - obsDatetime: string; -} - -export interface Order { - uuid: string; - dateActivated: string; - dateStopped?: Date | null; - dose: number; - dosingInstructions: string | null; - dosingType?: 'org.openmrs.FreeTextDosingInstructions' | 'org.openmrs.SimpleDosingInstructions'; - doseUnits: { - uuid: string; - display: string; - }; - drug: { - uuid: string; - name: string; - strength: string; - display: string; - }; - duration: number; - durationUnits: { - uuid: string; - display: string; - }; - frequency: { - uuid: string; - display: string; - }; - numRefills: number; - orderNumber: string; - orderReason: string | null; - orderReasonNonCoded: string | null; - orderer: { - uuid: string; - person: { - uuid: string; - display: string; - }; - }; - orderType: { - uuid: string; - display: string; - }; - route: { - uuid: string; - display: string; - }; - quantity: number; - quantityUnits: OpenmrsResource; -} - -export interface Note { - note: string; - provider: { - name: string; - role: string; - }; - time: string; -} - -export interface OrderItem { - order: Order; - provider: { - name: string; - role: string; - }; -} +import { openmrsFetch, restBaseUrl, type Visit } from '@openmrs/esm-framework'; export function useVisit(visitUuid: string) { const customRepresentation = diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-list.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-list.component.tsx index 4d2b42a88..7479eb66f 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-list.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-list.component.tsx @@ -1,21 +1,21 @@ -import React, { useEffect, useState, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { DataTable, - TableContainer, Table, - TableHead, - TableExpandHeader, - TableRow, - TableHeader, TableBody, - TableExpandRow, TableCell, + TableContainer, TableExpandedRow, + TableExpandHeader, + TableExpandRow, + TableHead, + TableHeader, + TableRow, } from '@carbon/react'; -import { useLayoutType, isDesktop } from '@openmrs/esm-framework'; -import { type Observation } from '../visit.resource'; +import { isDesktop, useLayoutType } from '@openmrs/esm-framework'; +import { type Observation } from '../../types'; import EncounterObservations from './encounter-observations.component'; import styles from '../visit-detail-overview.scss'; diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-observations.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-observations.component.tsx index 747951188..efe158eac 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-observations.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-observations.component.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { SkeletonText } from '@carbon/react'; -import { type Observation } from '../visit.resource'; +import { type Observation } from '../../types'; import styles from '../visit-detail-overview.scss'; interface EncounterObservationsProps { diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-observations.test.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-observations.test.tsx index ff58eb842..4cd4a913e 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-observations.test.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/encounter-observations.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { type Observation } from '../visit.resource'; +import { type Observation } from '../../types'; import EncounterObservations from './encounter-observations.component'; describe('EncounterObservations', () => { diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/medications-summary.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/medications-summary.component.tsx index 56c253396..7f96ed284 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/medications-summary.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/medications-summary.component.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import capitalize from 'lodash-es/capitalize'; import { useTranslation } from 'react-i18next'; import { formatDate, formatTime, parseDate } from '@openmrs/esm-framework'; -import { type OrderItem, getDosage } from '../visit.resource'; +import { type OrderItem } from '../../types'; import styles from '../visit-detail-overview.scss'; interface MedicationSummaryProps { diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/notes-summary.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/notes-summary.component.tsx index a0b0b3796..2543bbb5d 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/notes-summary.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/notes-summary.component.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { Layer, Tile } from '@carbon/react'; import { isDesktop, useLayoutType } from '@openmrs/esm-framework'; -import type { Note } from '../visit.resource'; +import type { Note } from '../../types'; import { EmptyDataIllustration } from '../../active-visits-widget/empty-data-illustration.component'; import styles from '../visit-detail-overview.scss'; diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/tests-summary.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/tests-summary.component.tsx index 43434cf3c..70779f655 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/tests-summary.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/tests-summary.component.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { ExtensionSlot } from '@openmrs/esm-framework'; -import { type Encounter } from '../visit.resource'; +import { type Encounter } from '../../types'; import styles from '../visit-detail-overview.scss'; const TestsSummary = ({ patientUuid, encounters }: { patientUuid: string; encounters: Array }) => { diff --git a/packages/esm-active-visits-app/src/visits-summary/visits-components/visit-summary.component.tsx b/packages/esm-active-visits-app/src/visits-summary/visits-components/visit-summary.component.tsx index 0cb59b83c..c5cc7854f 100644 --- a/packages/esm-active-visits-app/src/visits-summary/visits-components/visit-summary.component.tsx +++ b/packages/esm-active-visits-app/src/visits-summary/visits-components/visit-summary.component.tsx @@ -1,12 +1,12 @@ -import React, { useState, useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; -import { Tab, Tabs, TabList, TabPanel, TabPanels, Tag } from '@carbon/react'; -import { type OpenmrsResource, formatTime, parseDate } from '@openmrs/esm-framework'; +import { Tab, TabList, TabPanel, TabPanels, Tabs, Tag } from '@carbon/react'; +import { formatTime, type OpenmrsResource, parseDate } from '@openmrs/esm-framework'; import NotesSummary from './notes-summary.component'; import MedicationSummary from './medications-summary.component'; import TestsSummary from './tests-summary.component'; -import { type Order, type Encounter, type Note, type Observation, type OrderItem } from '../visit.resource'; +import type { Encounter, Note, Observation, Order, OrderItem } from '../../types'; import styles from '../visit-detail-overview.scss'; interface DiagnosisItem { From a7ad3899639c64e626c85cf80f5e37169a9e82b6 Mon Sep 17 00:00:00 2001 From: Usama Idriss Kakumba Date: Fri, 29 Nov 2024 08:34:08 +0300 Subject: [PATCH 2/5] feat: add config for obs on activeVisits --- .../active-visits.component.tsx | 296 ++++++++++-------- .../active-visits.resource.tsx | 153 +++++++-- .../active-visits-widget/active-visits.scss | 18 ++ .../active-visits.test.tsx | 203 +++++++----- .../src/config-schema.ts | 12 +- 5 files changed, 443 insertions(+), 239 deletions(-) diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx index 08e3e53a3..a57f8ce26 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useCallback } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { DataTable, DataTableSkeleton, @@ -20,19 +20,22 @@ import { } from '@carbon/react'; import { useTranslation } from 'react-i18next'; import { - useLayoutType, + ConfigurableLink, + ErrorState, + ExtensionSlot, isDesktop, + type OpenmrsResource, useConfig, + useLayoutType, usePagination, - ExtensionSlot, - ErrorState, - ConfigurableLink, } from '@openmrs/esm-framework'; import { EmptyDataIllustration } from './empty-data-illustration.component'; -import { useActiveVisits } from './active-visits.resource'; +import { useActiveVisits, useActiveVisitsSorting, useObsConcepts } from './active-visits.resource'; import styles from './active-visits.scss'; +import { type ActiveVisitsConfigSchema } from '../config-schema'; +import { type ActiveVisit } from '../types'; -function generateTableHeaders(t, config) { +function generateTableHeaders(t, config, obsConcepts) { let headersIndex = 0; const headers = [ @@ -51,7 +54,7 @@ function generateTableHeaders(t, config) { }); }); - if (!config?.activeVisit?.identifiers) { + if (!config?.activeVisits?.identifiers) { headers.push({ id: headersIndex++, header: t('idNumber', 'ID Number'), @@ -67,6 +70,15 @@ function generateTableHeaders(t, config) { }); }); + // Add headers for obs concepts + obsConcepts?.forEach((concept: OpenmrsResource) => { + headers.push({ + id: headersIndex++, + header: concept.display, + key: `obs-${concept.uuid}`, + }); + }); + headers.push( { id: headersIndex++, @@ -95,13 +107,37 @@ function generateTableHeaders(t, config) { const ActiveVisitsTable = () => { const { t } = useTranslation(); - const config = useConfig(); + const config = useConfig(); const layout = useLayoutType(); const pageSizes = config?.activeVisits?.pageSizes ?? [10, 20, 30, 40, 50]; const [pageSize, setPageSize] = useState(config?.activeVisits?.pageSize ?? 10); + const { obsConcepts, isLoadingObsConcepts } = useObsConcepts(config.activeVisits.obs); const { activeVisits, isLoading, isValidating, error } = useActiveVisits(); const [searchString, setSearchString] = useState(''); - const headerData = useMemo(() => generateTableHeaders(t, config), [config, t]); + const headerData = useMemo(() => generateTableHeaders(t, config, obsConcepts), [config, t, obsConcepts]); + + const transformVisitForDisplay = useCallback( + (visit: ActiveVisit) => { + const displayData = { ...visit }; + + // Add observation values to the display data + obsConcepts?.forEach((concept) => { + const obsValues = visit?.observations?.[concept.uuid] ?? []; + const latestObs = obsValues[0]; + + if (latestObs) { + // Handle both string and object values + displayData[`obs-${concept.uuid}`] = + typeof latestObs.value === 'object' ? latestObs.value.display : latestObs.value; + } else { + displayData[`obs-${concept.uuid}`] = '--'; + } + }); + + return displayData; + }, + [obsConcepts], + ); const searchResults = useMemo(() => { if (activeVisits !== undefined && activeVisits.length > 0) { @@ -118,10 +154,11 @@ const ActiveVisitsTable = () => { } } - return activeVisits; - }, [searchString, activeVisits]); + return activeVisits.map(transformVisitForDisplay); + }, [searchString, activeVisits, transformVisitForDisplay]); - const { paginated, goTo, results, currentPage } = usePagination(searchResults, pageSize); + const { sortedRows, sortRow } = useActiveVisitsSorting(searchResults); + const { paginated, goTo, results, currentPage } = usePagination(sortedRows, pageSize as number); const handleSearch = useCallback( (e) => { @@ -131,7 +168,7 @@ const ActiveVisitsTable = () => { [goTo, setSearchString], ); - if (isLoading) { + if (isLoading || isLoadingObsConcepts) { return (
@@ -187,125 +224,128 @@ const ActiveVisitsTable = () => {
); - } else { - return ( -
-
-
-

{t('activeVisits', 'Active Visits')}

-
-
- {isValidating ? : null} -
+ } + + return ( +
+
+
+

{t('activeVisits', 'Active Visits')}

- - 1 ? true : false}> - {({ rows, headers, getHeaderProps, getTableProps, getRowProps, getExpandHeaderProps }) => ( - - - - - - {headers.map((header) => ( - {header.header} - ))} - - - - {rows.map((row, index) => { - const currentVisit = activeVisits.find((visit) => visit.id === row.id); +
+ {isValidating ? : null} +
+ + + 1}> + {({ rows, headers, getHeaderProps, getTableProps, getRowProps, getExpandHeaderProps }) => ( + +
+ + + + {headers.map((header) => ( + {header.header} + ))} + + + + {rows.map((row, index) => { + const currentVisit = activeVisits.find((visit) => visit.id === row.id); - if (!currentVisit) { - return null; - } + if (!currentVisit) { + return null; + } - const patientChartUrl = '${openmrsSpaBase}/patient/${patientUuid}/chart/Patient%20Summary'; + const patientChartUrl = '${openmrsSpaBase}/patient/${patientUuid}/chart/Patient%20Summary'; - return ( - - - {row.cells.map((cell) => ( - - {cell.info.header === 'name' && currentVisit.patientUuid ? ( - - {cell.value} - - ) : ( - cell.value - )} - - ))} - - {row.isExpanded ? ( - - - - ) : ( - - )} - - ); - })} - -
- -
-
- )} -
- {searchResults?.length === 0 && ( -
- - -

{t('noVisitsToDisplay', 'No visits to display')}

-

{t('checkFilters', 'Check the filters above')}

-
-
-
- )} - {paginated && ( - { - if (newPageSize !== pageSize) { - setPageSize(newPageSize); - } - if (newPage !== currentPage) { - goTo(newPage); - } - }} - /> + return ( + + + {row.cells.map((cell) => ( + + {cell.info.header === 'name' && currentVisit.patientUuid ? ( + + {cell.value} + + ) : ( + cell.value + )} + + ))} + + {row.isExpanded ? ( + + + + + + ) : ( + + )} + + ); + })} + + + )} -
- ); - } + + {searchResults?.length === 0 && ( +
+ + +

{t('noVisitsToDisplay', 'No visits to display')}

+

{t('checkFilters', 'Check the filters above')}

+
+
+
+ )} + {paginated && ( + { + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + } + if (newPage !== currentPage) { + goTo(newPage); + } + }} + /> + )} +
+ ); }; export default ActiveVisitsTable; diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx index 53ffd00c1..0e482cbec 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx @@ -1,39 +1,23 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import useSWRInfinite from 'swr/infinite'; import dayjs from 'dayjs'; import isToday from 'dayjs/plugin/isToday'; import last from 'lodash-es/last'; import { - openmrsFetch, - type Visit, - useSession, type FetchResponse, formatDatetime, + openmrsFetch, + type OpenmrsResource, parseDate, - useConfig, restBaseUrl, + useConfig, + useSession, + type Visit, } from '@openmrs/esm-framework'; -dayjs.extend(isToday); +import useSWR from 'swr'; +import { type ActiveVisit, type VisitResponse } from '../types'; -export interface ActiveVisit { - age: string; - id: string; - idNumber: string; - gender: string; - location: string; - name: string; - patientUuid: string; - visitStartTime: string; - visitType: string; - visitUuid: string; - [identifier: string]: string; -} - -interface VisitResponse { - results: Array; - links: Array<{ rel: 'prev' | 'next' }>; - totalCount: number; -} +dayjs.extend(isToday); export function useActiveVisits() { const session = useSession(); @@ -41,8 +25,10 @@ export function useActiveVisits() { const sessionLocation = session?.sessionLocation?.uuid; const customRepresentation = - 'custom:(uuid,patient:(uuid,identifiers:(identifier,uuid,identifierType:(name,uuid)),person:(age,display,gender,uuid,attributes:(value,attributeType:(uuid,display)))),' + - 'visitType:(uuid,name,display),location:(uuid,name,display),startDatetime,stopDatetime)'; + 'custom:(uuid,patient:(uuid,identifiers:(identifier,uuid,identifierType:(name,uuid)),' + + 'person:(age,display,gender,uuid,attributes:(value,attributeType:(uuid,display)))),' + + 'visitType:(uuid,name,display),location:(uuid,name,display),startDatetime,stopDatetime,' + + 'encounters:(encounterDatetime,obs:(uuid,concept:(uuid,display),value)))'; const getUrl = (pageIndex, previousPageData: FetchResponse) => { if (pageIndex && !previousPageData?.data?.links?.some((link) => link.rel === 'next')) { @@ -126,6 +112,23 @@ export function useActiveVisits() { activeVisits[header?.key] = personAttributes?.value ?? '--'; }); + // Add flattened observations + const allObs = visit.encounters.reduce((accumulator, encounter) => { + return [...accumulator, ...(encounter.obs || [])]; + }, []); + + activeVisits.observations = allObs.reduce((map, obs) => { + const key = obs.concept.uuid; + if (!map[key]) { + map[key] = []; + } + map[key].push({ + value: obs.value, + uuid: obs.uuid, + }); + return map; + }, {}); + return activeVisits; }; @@ -142,6 +145,102 @@ export function useActiveVisits() { }; } +export function useObsConcepts(uuids: Array): { + obsConcepts: Array | undefined; + isLoadingObsConcepts: boolean; +} { + const fetchConcept = async (uuid: string): Promise => { + try { + const response = await openmrsFetch(`${restBaseUrl}/concept/${uuid}?v=custom:(uuid,display)`); + return response?.data; + } catch (error) { + console.error(`Error fetching concept for UUID: ${uuid}`, error); + return null; + } + }; + + const { data, isLoading, error } = useSWR(uuids.length > 0 ? ['obs-concepts', uuids] : null, async () => { + const results = await Promise.all(uuids.map(fetchConcept)); + return results.filter((concept) => concept !== null); + }); + + return useMemo( + () => ({ + obsConcepts: data ?? [], + isLoadingObsConcepts: isLoading, + }), + [data, isLoading], + ); +} + +export function useActiveVisitsSorting(tableRows: Array) { + const [sortParams, setSortParams] = useState<{ + key: string; + sortDirection: 'ASC' | 'DESC' | 'NONE'; + }>({ key: 'visitStartTime', sortDirection: 'DESC' }); + + const sortRow = (cellA, cellB, { key, sortDirection }) => { + setSortParams({ key, sortDirection }); + }; + + const getSortValue = (item: any, key: string) => { + // For observation columns + if (key.startsWith('obs-')) { + const conceptUuid = key.replace('obs-', ''); + const obsValue = item?.observations?.[conceptUuid]?.[0]?.value; + + if (!obsValue) return null; + if (typeof obsValue === 'object' && obsValue.display) { + return obsValue.display.toLowerCase(); + } + return obsValue; + } + + const value = item[key]; + if (value == null) return null; + + if (key === 'visitStartTime') { + return new Date(value).getTime(); + } + + if (key === 'age' && !isNaN(value)) { + return Number(value); + } + + return String(value).toLowerCase(); + }; + + const sortedRows = useMemo(() => { + if (sortParams.sortDirection === 'NONE') { + return tableRows; + } + + return [...tableRows].sort((a, b) => { + const valueA = getSortValue(a, sortParams.key); + const valueB = getSortValue(b, sortParams.key); + + if (valueA === null && valueB === null) return 0; + if (valueA === null) return 1; + if (valueB === null) return -1; + + if (typeof valueA === 'number' && typeof valueB === 'number') { + return sortParams.sortDirection === 'DESC' ? valueB - valueA : valueA - valueB; + } + + const compareResult = String(valueA).localeCompare(String(valueB), undefined, { + numeric: true, + }); + + return sortParams.sortDirection === 'DESC' ? -compareResult : compareResult; + }); + }, [sortParams, tableRows]); + + return { + sortedRows, + sortRow, + }; +} + export const getOriginFromPathName = (pathname = '') => { const from = pathname.split('/'); return last(from); diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.scss b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.scss index 99752b452..92ee6dc8a 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.scss +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.scss @@ -24,6 +24,14 @@ } .activeVisitsTable { + width: 100%; + + th, + td { + white-space: nowrap; + text-align: left; + } + tbody tr[data-parent-row] { // Don't show a bottom border on the last row so we don't end up with a double border from the activeVisitContainer &:nth-last-of-type(2) > td { @@ -154,12 +162,14 @@ html[dir='rtl'] { .activeVisitsDetailHeaderContainer { padding: layout.$spacing-04 layout.$spacing-05 layout.$spacing-04 0; } + .desktopHeading, .tabletHeading { h4 { text-align: right; } } + div[role='search'] { & :first-child { svg { @@ -167,21 +177,29 @@ html[dir='rtl'] { right: layout.$spacing-03; } } + & :last-child { right: unset; left: 0; } } + .tableContainer { + overflow-x: auto; + text-wrap: nowrap; + th > div { text-align: right; } + td { text-align: right; + .serviceColor { margin-right: 0; margin-left: layout.$spacing-03; } + button { text-align: right; } diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.test.tsx b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.test.tsx index 2b5b5fbfd..38977c3bf 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.test.tsx +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.test.tsx @@ -1,41 +1,72 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen } from '@testing-library/react'; -import { getDefaultsFromConfigSchema, useConfig } from '@openmrs/esm-framework'; -import { mockPatient, mockSession } from '__mocks__'; -import { configSchema, type SectionDefinition } from '../config-schema'; -import { useActiveVisits } from './active-visits.resource'; +import { getDefaultsFromConfigSchema, type OpenmrsResource, useConfig } from '@openmrs/esm-framework'; +import { mockSession } from '__mocks__'; +import { type ActiveVisitsConfigSchema, configSchema } from '../config-schema'; +import { useActiveVisits, useObsConcepts } from './active-visits.resource'; import ActiveVisitsTable from './active-visits.component'; +import { type ActiveVisit, type Observation } from '../types'; const mockUseActiveVisits = jest.mocked(useActiveVisits); -const mockUseConfig = jest.mocked(useConfig); +const mockUseObsConcepts = jest.mocked(useObsConcepts); +const mockIsDesktop = jest.mocked(useObsConcepts); +const mockUseConfig = jest.mocked(useConfig); jest.mock('./active-visits.resource', () => ({ ...jest.requireActual('./active-visits.resource'), useActiveVisits: jest.fn(), + useObsConcepts: jest.fn(), })); +const mockObsConcepts: Array = [ + { uuid: '160225AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', display: 'Sickle cell screening test' }, + { uuid: '5484AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', display: 'Nutritional support' }, +]; + +const mockConfig: ActiveVisitsConfigSchema = { + activeVisits: { + ...getDefaultsFromConfigSchema(configSchema).activeVisits, + obs: ['160225AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', '5484AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'], + }, +}; + +const mockActiveVisits: ActiveVisit[] = [ + { + age: '20', + gender: 'male', + id: '1', + idNumber: '000001A', + location: mockSession.data.sessionLocation.uuid, + name: 'John Doe', + patientUuid: 'uuid1', + visitStartTime: '', + visitType: 'Checkup', + visitUuid: 'visit-uuid-1', + }, + { + age: '25', + gender: 'female', + id: '2', + idNumber: '000001B', + location: mockSession.data.sessionLocation.uuid, + name: 'Some One', + patientUuid: 'uuid2', + visitStartTime: '', + visitType: 'Checkup', + visitUuid: 'visit-uuid-2', + }, +]; + describe('ActiveVisitsTable', () => { beforeEach(() => { - mockUseConfig.mockReturnValue({ - ...getDefaultsFromConfigSchema(configSchema), + mockUseConfig.mockReturnValue(mockConfig); + mockUseObsConcepts.mockReturnValue({ + obsConcepts: mockObsConcepts, + isLoadingObsConcepts: false, }); - mockUseActiveVisits.mockReturnValue({ - activeVisits: [ - { - age: '20', - gender: 'male', - id: '1', - idNumber: mockPatient.uuid, - location: mockSession.data.sessionLocation.uuid, - name: 'John Doe', - patientUuid: 'uuid1', - visitStartTime: '', - visitType: 'Checkup', - visitUuid: 'visit-uuid-1', - }, - ], + activeVisits: mockActiveVisits, isLoading: false, isValidating: false, error: undefined, @@ -43,51 +74,82 @@ describe('ActiveVisitsTable', () => { }); }); - it('renders data table with active visits', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders data table with standard and observation columns', () => { + mockUseActiveVisits.mockReturnValue({ + activeVisits: mockActiveVisits.map((visit) => ({ + ...visit, + observations: { + '160225AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA': [ + { + value: { uuid: '1065AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', display: 'Patient is sick' }, + uuid: 'obs-uuid-1', + } as unknown as Observation, + ], + '5484AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA': [ + { + value: 'Not done', + uuid: 'obs-uuid-2', + } as unknown as Observation, + ], + }, + })), + isLoading: false, + isValidating: false, + error: undefined, + totalResults: 0, + }); + render(); - expect(screen.getByText('Visit Time')).toBeInTheDocument(); - expect(screen.getByText('ID Number')).toBeInTheDocument(); - const expectedColumnHeaders = [/Visit Time/, /ID Number/, /Name/, /Gender/, /Age/, /Visit Type/]; - expectedColumnHeaders.forEach((header) => { - expect(screen.getByRole('columnheader', { name: new RegExp(header, 'i') })).toBeInTheDocument(); + const standardColumnHeaders = [/Visit Time/, /ID Number/, /Name/, /Gender/, /Age/, /Visit Type/]; + standardColumnHeaders.forEach((header) => { + expect(screen.getByRole('columnheader', { name: header })).toBeInTheDocument(); }); - const patientNameLink = screen.getByText('John Doe'); - expect(patientNameLink).toBeInTheDocument(); - expect(patientNameLink.tagName).toBe('A'); + expect(screen.getByRole('columnheader', { name: /Sickle cell screening test/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Nutritional support/ })).toBeInTheDocument(); + }); + + it('displays observation values correctly', () => { + mockUseActiveVisits.mockReturnValue({ + activeVisits: mockActiveVisits.map((visit) => ({ + ...visit, + observations: { + '160225AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA': [ + { + value: { uuid: '1065AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', display: 'Patient is sick' }, + uuid: 'obs-uuid-1', + } as unknown as Observation, + ], + '5484AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA': [ + { + value: 'Not done', + uuid: 'obs-uuid-2', + } as unknown as Observation, + ], + }, + })), + isLoading: false, + isValidating: false, + error: undefined, + totalResults: 0, + }); + + render(); + + expect(screen.getAllByRole('cell', { name: /Patient is sick/ }).length).toBe(2); + expect(screen.getAllByRole('cell', { name: /Not done/ }).length).toBe(2); }); it('filters active visits based on search input', async () => { const user = userEvent.setup(); mockUseActiveVisits.mockReturnValue({ - activeVisits: [ - { - age: '20', - gender: 'male', - id: '1', - idNumber: '000001A', - location: mockSession.data.sessionLocation.uuid, - name: 'John Doe', - patientUuid: 'uuid1', - visitStartTime: '', - visitType: 'Checkup', - visitUuid: 'visit-uuid-1', - }, - { - age: '25', - gender: 'female', - id: '2', - idNumber: '000001B', - location: mockSession.data.sessionLocation.uuid, - name: 'Some One', - patientUuid: 'uuid2', - visitStartTime: '', - visitType: 'Checkup', - visitUuid: 'visit-uuid-2', - }, - ], + activeVisits: mockActiveVisits, isLoading: false, isValidating: false, error: undefined, @@ -151,32 +213,7 @@ describe('ActiveVisitsTable', () => { it('should display the pagination when pagination is true', () => { mockUseActiveVisits.mockReturnValue({ - activeVisits: [ - { - age: '20', - gender: 'male', - id: '1', - idNumber: '000001A', - location: mockSession.data.sessionLocation.uuid, - name: 'John Doe', - patientUuid: 'uuid1', - visitStartTime: '', - visitType: 'Checkup', - visitUuid: 'visit-uuid-1', - }, - { - age: '25', - gender: 'female', - id: '2', - idNumber: '000001B', - location: mockSession.data.sessionLocation.uuid, - name: 'Some One', - patientUuid: 'uuid2', - visitStartTime: '', - visitType: 'Checkup', - visitUuid: 'visit-uuid-2', - }, - ], + activeVisits: mockActiveVisits, isLoading: false, isValidating: false, error: undefined, diff --git a/packages/esm-active-visits-app/src/config-schema.ts b/packages/esm-active-visits-app/src/config-schema.ts index fedc9d50e..9f144e10f 100644 --- a/packages/esm-active-visits-app/src/config-schema.ts +++ b/packages/esm-active-visits-app/src/config-schema.ts @@ -1,10 +1,11 @@ import { Type } from '@openmrs/esm-framework'; -export interface SectionDefinition { +export interface ActiveVisitsConfigSchema { activeVisits: { pageSize: Number; pageSizes: Array; identifiers: Array; + obs: Array; }; } @@ -53,5 +54,14 @@ export const configSchema = { _description: 'Customizable page sizes that user can choose', _default: [10, 20, 50], }, + obs: { + _type: Type.Array, + _description: 'Array of observation concept UUIDs to be displayed on the active visits table.', + _elements: { + _type: Type.UUID, + _description: 'UUID of an observation concept.', + }, + _default: [], + }, }, }; From f0bee53daf3e750601e10eadf14e1db193bb956c Mon Sep 17 00:00:00 2001 From: Usama Idriss Kakumba Date: Mon, 2 Dec 2024 11:09:59 +0300 Subject: [PATCH 3/5] fix: move generateTableHeaders to resource --- .../active-visits.component.tsx | 75 +------------------ .../active-visits.resource.tsx | 72 ++++++++++++++++++ 2 files changed, 74 insertions(+), 73 deletions(-) diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx index a57f8ce26..f9e6b8e6b 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx @@ -24,87 +24,16 @@ import { ErrorState, ExtensionSlot, isDesktop, - type OpenmrsResource, useConfig, useLayoutType, usePagination, } from '@openmrs/esm-framework'; import { EmptyDataIllustration } from './empty-data-illustration.component'; -import { useActiveVisits, useActiveVisitsSorting, useObsConcepts } from './active-visits.resource'; +import { useActiveVisits, useActiveVisitsSorting, useObsConcepts, useTableHeaders } from './active-visits.resource'; import styles from './active-visits.scss'; import { type ActiveVisitsConfigSchema } from '../config-schema'; import { type ActiveVisit } from '../types'; -function generateTableHeaders(t, config, obsConcepts) { - let headersIndex = 0; - - const headers = [ - { - id: headersIndex++, - header: t('visitStartTime', 'Visit Time'), - key: 'visitStartTime', - }, - ]; - - config?.activeVisits?.identifiers?.map((identifier) => { - headers.push({ - id: headersIndex++, - header: t(identifier?.header?.key, identifier?.header?.default), - key: identifier?.header?.key, - }); - }); - - if (!config?.activeVisits?.identifiers) { - headers.push({ - id: headersIndex++, - header: t('idNumber', 'ID Number'), - key: 'idNumber', - }); - } - - config?.activeVisits?.attributes?.map((attribute) => { - headers.push({ - id: headersIndex++, - header: t(attribute?.header?.key, attribute?.header?.default), - key: attribute?.header?.key, - }); - }); - - // Add headers for obs concepts - obsConcepts?.forEach((concept: OpenmrsResource) => { - headers.push({ - id: headersIndex++, - header: concept.display, - key: `obs-${concept.uuid}`, - }); - }); - - headers.push( - { - id: headersIndex++, - header: t('name', 'Name'), - key: 'name', - }, - { - id: headersIndex++, - header: t('gender', 'Gender'), - key: 'gender', - }, - { - id: headersIndex++, - header: t('age', 'Age'), - key: 'age', - }, - { - id: headersIndex++, - header: t('visitType', 'Visit Type'), - key: 'visitType', - }, - ); - - return headers; -} - const ActiveVisitsTable = () => { const { t } = useTranslation(); const config = useConfig(); @@ -114,7 +43,7 @@ const ActiveVisitsTable = () => { const { obsConcepts, isLoadingObsConcepts } = useObsConcepts(config.activeVisits.obs); const { activeVisits, isLoading, isValidating, error } = useActiveVisits(); const [searchString, setSearchString] = useState(''); - const headerData = useMemo(() => generateTableHeaders(t, config, obsConcepts), [config, t, obsConcepts]); + const headerData = useTableHeaders(t, config, obsConcepts); const transformVisitForDisplay = useCallback( (visit: ActiveVisit) => { diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx index 0e482cbec..b6361ea0a 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx @@ -241,6 +241,78 @@ export function useActiveVisitsSorting(tableRows: Array) { }; } +export function useTableHeaders(t, config, obsConcepts) { + return useMemo(() => { + let headersIndex = 0; + + const headers = [ + { + id: headersIndex++, + header: t('visitStartTime', 'Visit Time'), + key: 'visitStartTime', + }, + ]; + + config?.activeVisits?.identifiers?.forEach((identifier) => { + headers.push({ + id: headersIndex++, + header: t(identifier?.header?.key, identifier?.header?.default), + key: identifier?.header?.key, + }); + }); + + if (!config?.activeVisits?.identifiers) { + headers.push({ + id: headersIndex++, + header: t('idNumber', 'ID Number'), + key: 'idNumber', + }); + } + + config?.activeVisits?.attributes?.forEach((attribute) => { + headers.push({ + id: headersIndex++, + header: t(attribute?.header?.key, attribute?.header?.default), + key: attribute?.header?.key, + }); + }); + + // Add headers for obs concepts + obsConcepts?.forEach((concept) => { + headers.push({ + id: headersIndex++, + header: concept.display, + key: `obs-${concept.uuid}`, + }); + }); + + headers.push( + { + id: headersIndex++, + header: t('name', 'Name'), + key: 'name', + }, + { + id: headersIndex++, + header: t('gender', 'Gender'), + key: 'gender', + }, + { + id: headersIndex++, + header: t('age', 'Age'), + key: 'age', + }, + { + id: headersIndex++, + header: t('visitType', 'Visit Type'), + key: 'visitType', + }, + ); + + return headers; + }, [t, config, obsConcepts]); +} + export const getOriginFromPathName = (pathname = '') => { const from = pathname.split('/'); return last(from); From 344b5a08d4ffdd07d58d76fa1884d0f8228b2158 Mon Sep 17 00:00:00 2001 From: Usama Idriss Kakumba Date: Tue, 3 Dec 2024 15:11:11 +0300 Subject: [PATCH 4/5] fix: remove t and config as args on useTableHeaders --- .../src/active-visits-widget/active-visits.component.tsx | 4 ++-- .../src/active-visits-widget/active-visits.resource.tsx | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx index f9e6b8e6b..f7ba849fb 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.component.tsx @@ -43,7 +43,7 @@ const ActiveVisitsTable = () => { const { obsConcepts, isLoadingObsConcepts } = useObsConcepts(config.activeVisits.obs); const { activeVisits, isLoading, isValidating, error } = useActiveVisits(); const [searchString, setSearchString] = useState(''); - const headerData = useTableHeaders(t, config, obsConcepts); + const headerData = useTableHeaders(obsConcepts); const transformVisitForDisplay = useCallback( (visit: ActiveVisit) => { @@ -198,7 +198,7 @@ const ActiveVisitsTable = () => { return null; } - const patientChartUrl = '${openmrsSpaBase}/patient/${patientUuid}/chart/Patient%20Summary'; + const patientChartUrl = '${openmrsSpaBase}/patient/${patientUuid}/chart'; return ( diff --git a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx index b6361ea0a..975bfb653 100644 --- a/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx +++ b/packages/esm-active-visits-app/src/active-visits-widget/active-visits.resource.tsx @@ -16,6 +16,7 @@ import { } from '@openmrs/esm-framework'; import useSWR from 'swr'; import { type ActiveVisit, type VisitResponse } from '../types'; +import { useTranslation } from 'react-i18next'; dayjs.extend(isToday); @@ -241,7 +242,9 @@ export function useActiveVisitsSorting(tableRows: Array) { }; } -export function useTableHeaders(t, config, obsConcepts) { +export function useTableHeaders(obsConcepts: OpenmrsResource[]) { + const { t } = useTranslation(); + const config = useConfig(); return useMemo(() => { let headersIndex = 0; From 18f49d7c1475af23c033162d878d05cbed713ffd Mon Sep 17 00:00:00 2001 From: Usama Idriss Kakumba Date: Tue, 3 Dec 2024 15:20:26 +0300 Subject: [PATCH 5/5] fxi: extract translations from the resource file --- packages/esm-active-visits-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/esm-active-visits-app/package.json b/packages/esm-active-visits-app/package.json index 2bbad0817..fd2b237ca 100644 --- a/packages/esm-active-visits-app/package.json +++ b/packages/esm-active-visits-app/package.json @@ -18,7 +18,7 @@ "test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js --color", "coverage": "yarn test --coverage", "typescript": "tsc", - "extract-translations": "i18next 'src/**/*.component.tsx' 'src/**/*.extension.tsx' 'src/**/*modal.tsx' 'src/**/*.workspace.tsx' 'src/index.ts' --config ../../tools/i18next-parser.config.js" + "extract-translations": "i18next 'src/**/*.component.tsx' 'src/**/*.resource.tsx' 'src/**/*.extension.tsx' 'src/**/*modal.tsx' 'src/**/*.workspace.tsx' 'src/index.ts' --config ../../tools/i18next-parser.config.js" }, "browserslist": [ "extends browserslist-config-openmrs"