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

O3-3210 ward app - configuration system for ward patient cards #1184

Merged
merged 24 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { SkeletonIcon } from '@carbon/react';
import React from 'react';
import styles from './empty-bed.scss';
import { SkeletonIcon, SkeletonText } from '@carbon/react';

const EmptyBedSkeleton = () => {
return (
Expand Down
24 changes: 24 additions & 0 deletions packages/esm-ward-app/src/beds/empty-bed.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import styles from './empty-bed.scss';
import wardPatientCardStyles from '../ward-patient-card/ward-patient-card.scss';
import { type Bed } from '../types';
import { useTranslation } from 'react-i18next';

interface EmptyBedProps {
bed: Bed;
}

const EmptyBed: React.FC<EmptyBedProps> = ({ bed }) => {
const { t } = useTranslation();

return (
<div className={styles.container}>
<span className={`${wardPatientCardStyles.wardPatientBedNumber} ${wardPatientCardStyles.empty}`}>
{bed.bedNumber}
</span>
<p className={styles.emptyBed}>{t('emptyBed', 'Empty bed')}</p>
</div>
);
};

export default EmptyBed;
40 changes: 40 additions & 0 deletions packages/esm-ward-app/src/beds/occupied-bed.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { type Patient } from '@openmrs/esm-framework';
import { type Bed } from '../types';
import styles from './occupied-bed.scss';
import { Tag } from '@carbon/react';
import { useTranslation } from 'react-i18next';
import WardPatientCard from '../ward-patient-card/ward-patient-card';

export interface OccupiedBedProps {
patients: Patient[];
bed: Bed;
}
const OccupiedBed: React.FC<OccupiedBedProps> = ({ patients, bed }) => {
return (
<div className={styles.occupiedBed}>
{patients.map((patient, index: number) => {
const last = index === patients.length - 1;
return (
<div key={'occupied-bed-pt-' + patient.uuid}>
<WardPatientCard patient={patient} bed={bed} />
{!last && <BedShareDivider />}
</div>
);
})}
</div>
);
};

const BedShareDivider = () => {
const { t } = useTranslation();
return (
<div className={styles.bedDivider}>
<div className={styles.bedDividerLine}></div>
<Tag>{t('bedShare', 'Bed share')}</Tag>
<div className={styles.bedDividerLine}></div>
</div>
);
};

export default OccupiedBed;
24 changes: 24 additions & 0 deletions packages/esm-ward-app/src/beds/occupied-bed.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@use '@carbon/styles/scss/spacing';
@use '@carbon/styles/scss/type';
@use '@openmrs/esm-styleguide/src/vars';

.occupiedBed {
display: flex;
flex-direction: column;
background-color: vars.$ui-02;
}

.bedDivider {
background-color: vars.$ui-02;
color: vars.$text-02;
padding: spacing.$spacing-01;
display: flex;
align-items: center;
justify-content: space-between;
}

.bedDividerLine {
height: 1px;
background-color: vars.$ui-03;
width: 30%;
}
45 changes: 45 additions & 0 deletions packages/esm-ward-app/src/beds/occupied-bed.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import OccupiedBed from './occupied-bed.component';
import React from 'react';
import { mockAdmissionLocation } from '../../../../__mocks__/wards.mock';
import { bedLayoutToBed, filterBeds } from '../ward-view/ward-view.resource';
import { getDefaultsFromConfigSchema, useConfig } from '@openmrs/esm-framework';
import { configSchema, defaultPatientCardElementConfig } from '../config-schema';

const defaultConfigSchema = getDefaultsFromConfigSchema(configSchema);

jest.mocked(useConfig).mockReturnValue({
...defaultConfigSchema,
});
chibongho marked this conversation as resolved.
Show resolved Hide resolved

const mockBedLayouts = filterBeds(mockAdmissionLocation);

const mockBedToUse = mockBedLayouts[0];
jest.replaceProperty(mockBedToUse.patient.person, 'preferredName', {
uuid: '',
givenName: 'Alice',
familyName: 'Johnson',
});
const mockPatient = mockBedToUse.patient;
const mockBed = bedLayoutToBed(mockBedToUse);

describe('Occupied bed: ', () => {
it('renders a single bed with patient details', () => {
render(<OccupiedBed patients={[mockPatient]} bed={mockBed} />);
const patientName = screen.getByText('Alice Johnson');
expect(patientName).toBeInTheDocument();
const patientAge = `${mockPatient.person.age} yrs`;
expect(screen.getByText(patientAge)).toBeInTheDocument();
const defaultAddressFields = defaultPatientCardElementConfig.addressFields;
defaultAddressFields.forEach((addressField) => {
const addressFieldValue = mockPatient.person.preferredAddress[addressField] as string;
expect(screen.getByText(addressFieldValue)).toBeInTheDocument();
});
});

it('renders a divider for shared patients', () => {
render(<OccupiedBed patients={[mockPatient, mockPatient]} bed={mockBed} />);
const bedShareText = screen.getByTitle('Bed share');
expect(bedShareText).toBeInTheDocument();
});
});
137 changes: 134 additions & 3 deletions packages/esm-ward-app/src/config-schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,136 @@
import { type ConfigSchema } from '@openmrs/esm-framework';
import { Type, validators, type ConfigSchema, type PersonAddress } from '@openmrs/esm-framework';
import { patientCardElementTypes, type PatientCardElementType } from './types';

export const configSchema: ConfigSchema = {};
const defaultWardPatientCard: WardPatientCardDefinition = {
id: 'default-card',
rows: [
{
rowType: 'header',
elements: ['bed-number', 'patient-name', 'patient-age', 'patient-address'],
},
],
appliedTo: null,
};

export interface ConfigObject {}
const defaultPatientAddressFields: Array<keyof PersonAddress> = ['cityVillage', 'country'];

export const defaultPatientCardElementConfig: PatientCardElementConfig = {
addressFields: defaultPatientAddressFields,
};

export const builtInPatientCardElements: PatientCardElementType[] = [
'bed-number',
'patient-name',
'patient-age',
'patient-address',
'admission-time',
];

export const configSchema: ConfigSchema = {
wardPatientCards: {
_description: 'Configure the display of ward patient cards',
patientCardElementDefinitions: {
_type: Type.Array,
_default: [],
_elements: {
id: {
_type: Type.String,
_description: 'The unique identifier for this custom patient card element',
},
elementType: {
_type: Type.String,
_description: 'The patient card element type',
_validators: [validators.oneOf(patientCardElementTypes)],
},
config: {
addressFields: {
_type: Type.Array,
Copy link
Member

Choose a reason for hiding this comment

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

I really don't like this here. A specific type of patientCardElement should have a configuration that takes in an address field. Just like other types of patientCardElement (like Gravity) might take in a Concept. This should not be baked into a generic config schema.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A similar change was made to the queues app. I believe it was to facilitate adding validators on the fields. I'm not sure if it's possible to have a validator work by defining specific configs per element type, but I can play around with it.
@brandones what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

@mseaton This is the way we handle this kind of thing in registration and service queues; it's the best pattern we've figured out for this.

@chibongho You will definitely want to add a _default value for addressFields so that it is optional, since indeed it would be very bad if we required implementers to configure addressFields for an element of type bed-number. We want implementers to be able to provide config like

{
  "patientCardElementDefinitions": [
    { "id": "bednum", "elementType": "bed-number" },
    { "id": "addr", "elementType": "patient-address", "config": { "addressFields": ["cityVillage"] } } 
  ]
}

and then we can add validators that ensure that implementers don't provide an element config that doesn't make sense with the element type.

@mseaton The only way I can imagine supporting what you're talking about would be to have a separate definitions array for each type:

{
  "patientCardBedNumberElementDefinitions": [
    { "id": "bednum" }
  ],
  "patientCardPatientAddressElementDefinitions": [
    { "id": "addr", "addressFields": ["cityVillage"] }
  ]
}

So that would be our other option here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Given the frequency with which this pattern appears, I'm definitely open to thinking about whether we can add a schema feature that supports it better. It would be really nice if the config system had some way of knowing what element config schema should be used based on the element type.

Copy link
Member

Choose a reason for hiding this comment

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

Quick first pass. This usually comes up in the context of arrays of elements right? Maybe for arrays we can support something like the following:

someKey: {
  _type: Type.Array,
  _default: [],
  _key: {
    id: {
      _type: Type.String,
      _description: 'The config-internal identifier for this element',
    },
  }
  _elements: [{
    id: 'address',  // applies to element whose key matches this
    elementType: {
      _type: Type.String,
      _description: 'The bento element type',
      _validators: [validators.oneOf(bentoElementTypes)],
    },
    config: {
        _type: Type.Array,
        _description: 'For bentoElementType "patient-address", defining which address fields to show',
    }
  }, {
    elementType: {
      _type: Type.String,
      _description: 'The bento element type',
      _validators: [validators.oneOf(bentoElementTypes)],
    },
  }]
}

That could be typed like:

type ConfigElement = {
  id: string;
  elementType: string;
}

type AddressConfigElement = ConfigElement & {
  id: 'address';
  config: Array<string>;
}

someKey: Array<ConfigElement>;

_description: 'For patientCardElementType "patient-address", defining which address fields to show',
_default: defaultPatientAddressFields,
},
},
},
},
cardDefinitions: {
_type: Type.Array,
_default: [defaultWardPatientCard],
_description: `An array of card configuration. A card configuration can be applied to different ward locations.
If multiple card configurations apply to a location, only the first one is chosen.`,
_elements: {
id: {
_type: Type.String,
_description: 'The unique identifier for this card definition. Currently unused, but that might change.',
},
rows: {
_type: Type.Array,
_elements: {
id: {
_type: Type.String,
_description: 'The unique identifier for this card row. Currently unused, but that might change.',
},
elements: {
_type: Type.Array,
_element: {
_type: Type.String,
_description: 'The ID of the (bulit-in or custom) patient card elements to appear in this card row',
_validators: [validators.oneOf(patientCardElementTypes)],
},
},
},
},
appliedTo: {
_type: Type.Array,
_elements: {
location: {
_type: Type.UUID,
_description: 'The UUID of the location. If not provided, applies to all queues.',
_default: null,
},
},
},
},
},
},
};

export interface WardConfigObject {
wardPatientCards: WardPatientCardsConfig;
}

export interface WardPatientCardsConfig {
patientCardElementDefinitions: Array<PatientCardElementDefinition>;
cardDefinitions: Array<WardPatientCardDefinition>;
}

export interface WardPatientCardDefinition {
id: string;
rows: Array<{
/**
* The type of row. Currently, only "header" is supported
*/
rowType: 'header';

/**
* an array of (either built-in or custom) patient card element ids
*/
elements: Array<string>;
}>;
appliedTo?: Array<{
/**
* locationUuid. If given, only applies to patients at the specified ward locations. (If not provided, applies to all locations)
*/
location: string;
}>;
}

export type PatientCardElementDefinition = {
id: string;
elementType: PatientCardElementType;
config?: PatientCardElementConfig;
};

export interface PatientAddressElementConfig {
addressFields: Array<keyof PersonAddress>;
}

export type PatientCardElementConfig = {} & PatientAddressElementConfig;
19 changes: 0 additions & 19 deletions packages/esm-ward-app/src/empty-beds/empty-bed.component.tsx

This file was deleted.

8 changes: 6 additions & 2 deletions packages/esm-ward-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { defineConfigSchema, getSyncLifecycle, registerBreadcrumbs } from '@openmrs/esm-framework';
import rootComponent from './root.component';
import {
defineConfigSchema,
getSyncLifecycle,
registerBreadcrumbs
} from '@openmrs/esm-framework';
import { configSchema } from './config-schema';
import rootComponent from './root.component';

export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');

Expand Down
Loading
Loading