-
Notifications
You must be signed in to change notification settings - Fork 230
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-3773 Support for Patient Program Summary in Patient Chart #1969
base: main
Are you sure you want to change the base?
Conversation
eaf5653
to
6b763ca
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly looks good, thanks @kajambiya , just a few comments
else return encounter[param] ? encounter[param] : '--'; | ||
} | ||
|
||
export function formatDateTime(dateString: string): any { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use the already existing Date formatters from the framework
} | ||
|
||
export function obsArrayDateComparator(left, right) { | ||
return formatDateTime(right.obsDatetime) - formatDateTime(left.obsDatetime); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here, we can leverage the framework functions, check on my PR, I got a way around this
import { formatDate, parseDate } from '@openmrs/esm-framework'; | ||
|
||
export function getEncounterValues(encounter, param: string, isDate?: Boolean) { | ||
if (isDate) return dayjs(encounter[param]).format('DD-MMM-YYYY'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please use the framework rather than dayjs
for formatting dates and never hard-code the date format.
|
||
export function getObsFromEncounters(encounters, obsConcept) { | ||
const filteredEnc = encounters?.find((enc) => enc.obs.find((obs) => obs.concept.uuid === obsConcept)); | ||
return getObsFromEncounter(filteredEnc, obsConcept); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need this filtering step? This just seems to add complexity for no real gain.
return formatDateTime(right.obsDatetime) - formatDateTime(left.obsDatetime); | ||
} | ||
|
||
export function findObs(encounter, obsConcept): Record<string, any> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please don't use any
when a better type can be used (as is the case here).
|
||
if (isTrueFalseConcept) { | ||
if ( | ||
(obs?.value?.uuid != 'cf82933b-3f3f-45e7-a5ab-5d31aaee3da3' && obs?.value?.name?.name !== 'Unknown') || |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please don't hard-code the meaning of concepts to specific UUIDs like this. It is inherently not-sharrable.
} | ||
|
||
if (type === 'location') { | ||
return encounter.location.name; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return encounter.location.name; | |
return encounter.location.display; |
|
||
if (typeof obs.value === 'object' && obs.value?.names) { | ||
return ( | ||
obs.value?.names?.find((conceptName) => conceptName.conceptNameType === 'SHORT')?.name || obs.value.name.name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we using the SHORT name in preference to the preferred name? Is there a good reason for this choice?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do you mean concept.display
or concept.preferredName
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
display
is always the preferred name in the user's locale.
@@ -112,6 +113,11 @@ export const stopVisitPatientSearchActionButton = getSyncLifecycle(stopVisitActi | |||
moduleName, | |||
}); | |||
|
|||
export const patientProgramSummary = getSyncLifecycle(PatientSummaryOverviewList, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like it should be part of the programs app and not the patient-chart-app.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The data is related to a program but some people may classify it differently.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, that doesn't explain to me why it belongs here though. If it's program-specific, surely it's part of the programs app?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is not program specific, I think the name might be misleading , it’s more of a clinical view summary. I have updated the name
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Part of my point is that this is a public interface intended to be consumed by multiple configurations written by implementations that may or may not understand what the original purpose of a widget is; thus naming things so that they are consistent, discoverable, and not confusing is of vital importance here.
cb49d63
to
cab9b91
Compare
@@ -112,6 +113,11 @@ export const stopVisitPatientSearchActionButton = getSyncLifecycle(stopVisitActi | |||
moduleName, | |||
}); | |||
|
|||
export const clinicalViewsSummary = getSyncLifecycle(ClinicalViewsSummary, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please use getAsyncLifecycle()
here.
@@ -112,6 +113,11 @@ export const stopVisitPatientSearchActionButton = getSyncLifecycle(stopVisitActi | |||
moduleName, | |||
}); | |||
|
|||
export const patientProgramSummary = getSyncLifecycle(PatientSummaryOverviewList, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Part of my point is that this is a public interface intended to be consumed by multiple configurations written by implementations that may or may not understand what the original purpose of a widget is; thus naming things so that they are consistent, discoverable, and not confusing is of vital importance here.
|
||
const tilesData = useMemo( | ||
() => | ||
tilesDefinitions?.map((tile: any) => ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be correctly typed.
tilesDefinitions?.map((tile: any) => ({ | |
tilesDefinitions?.map((tile=> ({ |
const tilesData = useMemo( | ||
() => | ||
tilesDefinitions?.map((tile: any) => ({ | ||
title: tile.tileHeader, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is coming straight from config, it should be translatable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added the Tile
type instead
const config = useConfig(); | ||
const tilesDefinitions = config.tilesDefinitions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const config = useConfig(); | |
const tilesDefinitions = config.tilesDefinitions; | |
const { tileDefinitions } = useConfig</* ConfigType goes here */>(); |
@@ -0,0 +1,46 @@ | |||
import React, { useMemo } from 'react'; | |||
import { useConfig } from '@openmrs/esm-framework'; | |||
import { getEncounterTileColumns } from '../../encounter-tile/utils/encounter-tile-config-builder'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Organization-wise here, the encounter-tile is part of the clinical views, so surely it should be under the components for that?
); | ||
}; | ||
|
||
export const MemoizedEncounterTile = React.memo(EncounterTile); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export const MemoizedEncounterTile = React.memo(EncounterTile); | |
export const EncounterTile = React.memo(EncounterTileInternal); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's better to reserve weird names for the non-exported bits of an API.
return ( | ||
<div className={styles.tileBox}> | ||
<div className={styles.tileBoxColumn}> | ||
<span className={styles.tileTitle}> {column.header} </span> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spaces here seem wrong?
'obs:(uuid,obsDatetime,voided,groupMembers,concept:(uuid,name:(uuid,name)),value:(uuid,name:(uuid,name),' + | ||
'names:(uuid,conceptNameType,name))),form:(uuid,name))'; | ||
|
||
export function useLastEncounter(patientUuid: string, encounterType: string) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export function useLastEncounter(patientUuid: string, encounterType: string) { | |
export function useMostRecentEncounter(patientUuid: string, encounterType: string) { |
font-size: 16px; | ||
line-height: 1.33; | ||
letter-spacing: 0.32px; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't we be using Carbon's typography defaults here and everywhere else in this file?
d5c1461
to
b9b0d00
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you! This widget is getting much closer. A few more nit-picking comments on the code.
Now looking at the visual of this widget, we should bring it in line with the existing RefApp chart tiles specifically:
- The h4 header font rendering is inconsistent with other tiles in the chart. Both the font-size (20px vs 16px) is off and the font-weight (400 vs 600).
- The header ::after element is not correctly sized (border-bottom-width is 2px too large and padding-top is off by 1px — 2px in this component vs 3px in others)
Oddly, when located in the top-of-all-patient-dashboards-slot, as shown above the box appears too wide, but when located in the same slot as the other elements, the width is too small:
} | ||
|
||
const ClinicalViewsSummary: React.FC<OverviewListProps> = ({ patientUuid }) => { | ||
const config = useConfig() as ConfigObject; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const config = useConfig() as ConfigObject; | |
const config = useConfig<ConfigObject>(); |
export const getEncounterTileColumns = (tileDefinition: MenuCardProps, t?: TFunction) => { | ||
const columns: Array<FormattedCardColumn> = tileDefinition.columns?.map((column: ColumnDefinition) => ({ | ||
key: column.id, | ||
header: column.title, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Header should be translatable.
|
||
export const getEncounterTileColumns = (tileDefinition: MenuCardProps, t?: TFunction) => { | ||
const columns: Array<FormattedCardColumn> = tileDefinition.columns?.map((column: ColumnDefinition) => ({ | ||
key: column.id, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like bad practice to require users to supply the key. This could presumably be constructed from the title
property, right?
const ClinicalViewsSummary: React.FC<OverviewListProps> = ({ patientUuid }) => { | ||
const config = useConfig() as ConfigObject; | ||
const { t } = useTranslation(); | ||
const tilesDefinitions = config.tilesDefinitions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be better to name this "tileDefinitions". The double plural is weird.
Ping @ibacher |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure I've covered everything, but here are some things that need to be cleaned-up before this is mergeable. Goal here is to make sure that the code is in a format that the community can support over the long term. Thanks!
import { CodeSnippetSkeleton, Tile, Column, Layer } from '@carbon/react'; | ||
import React, { useMemo } from 'react'; | ||
import styles from './tile.scss'; | ||
import { groupColumnsByEncounterType } from '../../utils/helpers'; | ||
import { useLastEncounter } from '../../hooks/useLastEncounter'; | ||
import { LazyCell } from '../../../lazy-cell/lazy-cell.component'; | ||
import { useTranslation } from 'react-i18next'; | ||
import { type FormattedCardColumn } from '../../utils/encounter-tile-config-builder'; | ||
import { useLayoutType } from '@openmrs/esm-framework'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please review our Coding Conventions on code organization and how imports should be ordered.
<Layer className={styles.layer}> | ||
<Tile className={styles.tile}> | ||
<div className={isTablet ? styles.tabletHeading : styles.desktopHeading}> | ||
<h4 className={styles.title}>{headerTitle}</h4> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any string displayed to the user needs to be translatable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The strings from the config are translated already
); | ||
}; | ||
|
||
export const EncounterTile = React.memo(EncounterTileInternal); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why create two separate const
s for this?
export const EncounterTile = React.memo(({ patientUuid, columns, headerTitle }: EncounterTileProps) => {
// code goes here
});
Also works.
key={encounterType} | ||
patientUuid={patientUuid} | ||
encounterType={encounterType} | ||
columns={columns as FormattedCardColumn[]} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need to cast the column types here? This seems like an unnecessary escape hatch when we have full control over:
- The types in question
- The thing that's being cast to a type
- The function returning the thing that needs to be cast
<div className={styles.tileBox}> | ||
{columns.map((column, ind) => ( | ||
<div key={ind} className={styles.tileBoxColumn}> | ||
<span className={styles.tileTitle}>{t(column.title)}</span> | ||
<span className={styles.tileValue}>--</span> | ||
</div> | ||
))} | ||
</div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, here, an error happens and instead of reporting that to the user we just render a blank set of cells? That doesn't seem right... I'd expect us to render a message to the user explaining that the encounter data could not be loaded.
obsConcept: string, | ||
isDate?: Boolean, | ||
isTrueFalseConcept?: Boolean, | ||
type?: string, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We only support a fixed set of types, so the type here should probably reflect that.
isTrueFalseConcept?: Boolean, | ||
type?: string, | ||
fallbackConcepts?: Array<string>, | ||
secondaryConcept?: string, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is a "secondaryConcept"? How does this differ from "fallbackConcepts"? These are the things that we need to explain via comments.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a secondary concept is used in a question wit the option other and a user is required to add their response in another question, which in his case is captured in the secondary concept.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A fallback concept is used in instances where a tile has an alternative value, for example, enrollment date and re-enrollment date. If there is no enrollment date, then the value of the re-enrollment date can be displayed instead
cc : @eudson
|
||
if (secondaryConcept && typeof obs.value === 'object' && obs.value.names) { | ||
const primaryValue = obs.value.name.display; | ||
if (primaryValue === t('Other non-coded')) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is, again, not what the translation system should be used for. Capture the UUID of the concept via the configuration system.
|
||
export function getObsFromEncounters(encounters: Encounter, obsConcept: string) { | ||
const filteredEnc = encounters?.find((enc) => enc.obs.find((obs) => obs.concept.uuid === obsConcept)); | ||
return getObsFromEncounter(filteredEnc, obsConcept); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is one of the many reasons that I think getObsFromEncounter
needs refactoring. It's doing too many things with too many concerns.
@@ -1,3 +1,5 @@ | |||
import { OpenmrsResource } from '@openmrs/esm-framework'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
import { OpenmrsResource } from '@openmrs/esm-framework'; | |
import { type OpenmrsResource } from '@openmrs/esm-framework'; |
72c35b9
to
8dcedbb
Compare
8dcedbb
to
bd4e5dc
Compare
Requirements
Summary
This PR introduces summary program summary to patient chart.
Screenshots
Related Issue
Other