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-3773 Support for Patient Program Summary in Patient Chart #1969

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

kajambiya
Copy link
Contributor

@kajambiya kajambiya commented Aug 22, 2024

Requirements

  • This PR has a title that briefly describes the work done including the ticket number. If there is a ticket, make sure your PR title includes a conventional commit label. See existing PR titles for inspiration.
  • My work conforms to the OpenMRS 3.0 Styleguide and design documentation.
  • My work includes tests or is validated by existing tests.

Summary

This PR introduces summary program summary to patient chart.

Screenshots

Screenshot 2024-09-06 at 10 03 33

Related Issue

Other

@kajambiya kajambiya force-pushed the O3-3773 branch 2 times, most recently from eaf5653 to 6b763ca Compare September 4, 2024 05:28
@kajambiya kajambiya changed the title (feat) O3-3773 Support Summary Tiles in Patient Chart (feat) O3-3773 Support for Patient Program Summary in Patient Chart Sep 4, 2024
@kajambiya kajambiya marked this pull request as ready for review September 4, 2024 06:36
Copy link
Contributor

@hadijahkyampeire hadijahkyampeire left a 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 {
Copy link
Contributor

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);
Copy link
Contributor

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');
Copy link
Member

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);
Copy link
Member

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> {
Copy link
Member

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') ||
Copy link
Member

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;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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
Copy link
Member

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?

Copy link
Collaborator

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 ?

Copy link
Member

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, {
Copy link
Member

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.

Copy link
Collaborator

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.

Copy link
Member

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?

Copy link
Collaborator

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

Copy link
Member

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.

@@ -112,6 +113,11 @@ export const stopVisitPatientSearchActionButton = getSyncLifecycle(stopVisitActi
moduleName,
});

export const clinicalViewsSummary = getSyncLifecycle(ClinicalViewsSummary, {
Copy link
Member

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, {
Copy link
Member

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) => ({
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 correctly typed.

Suggested change
tilesDefinitions?.map((tile: any) => ({
tilesDefinitions?.map((tile=> ({

const tilesData = useMemo(
() =>
tilesDefinitions?.map((tile: any) => ({
title: tile.tileHeader,
Copy link
Member

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.

Copy link
Collaborator

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

Comment on lines 19 to 15
const config = useConfig();
const tilesDefinitions = config.tilesDefinitions;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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';
Copy link
Member

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);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
export const MemoizedEncounterTile = React.memo(EncounterTile);
export const EncounterTile = React.memo(EncounterTileInternal);

Copy link
Member

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>
Copy link
Member

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) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
export function useLastEncounter(patientUuid: string, encounterType: string) {
export function useMostRecentEncounter(patientUuid: string, encounterType: string) {

Comment on lines 59 to 61
font-size: 16px;
line-height: 1.33;
letter-spacing: 0.32px;
Copy link
Member

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?

Copy link
Member

@ibacher ibacher left a 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:

Screenshot

  • 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:

Screenshot2

}

const ClinicalViewsSummary: React.FC<OverviewListProps> = ({ patientUuid }) => {
const config = useConfig() as ConfigObject;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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,
Copy link
Member

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,
Copy link
Member

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;
Copy link
Member

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.

@brandones
Copy link
Contributor

Ping @ibacher

Copy link
Member

@ibacher ibacher left a 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!

Comment on lines 1 to 9
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';
Copy link
Member

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>
Copy link
Member

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.

Copy link
Collaborator

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);
Copy link
Member

Choose a reason for hiding this comment

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

Why create two separate consts 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[]}
Copy link
Member

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:

  1. The types in question
  2. The thing that's being cast to a type
  3. The function returning the thing that needs to be cast

Comment on lines 72 to 78
<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>
Copy link
Member

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,
Copy link
Member

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,
Copy link
Member

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.

Copy link
Collaborator

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.

Copy link
Collaborator

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')) {
Copy link
Member

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);
Copy link
Member

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';
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
import { OpenmrsResource } from '@openmrs/esm-framework';
import { type OpenmrsResource } from '@openmrs/esm-framework';

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants