Skip to content

Commit

Permalink
feat: add patient notes history
Browse files Browse the repository at this point in the history
  • Loading branch information
usamaidrsk committed Aug 7, 2024
1 parent ac24702 commit f4e486f
Show file tree
Hide file tree
Showing 13 changed files with 560 additions and 150 deletions.
13 changes: 2 additions & 11 deletions packages/esm-ward-app/src/hooks/useEmrConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import { type FetchResponse, openmrsFetch, type OpenmrsResource, restBaseUrl } from '@openmrs/esm-framework';
import { type FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { useMemo } from 'react';
import useSWRImmutable from 'swr/immutable';

interface EmrApiConfigurationResponse {
admissionEncounterType: OpenmrsResource;
clinicianEncounterRole: OpenmrsResource;
consultFreeTextCommentsConcept: OpenmrsResource;
visitNoteEncounterType: OpenmrsResource;
transferWithinHospitalEncounterType: OpenmrsResource;
// There are many more keys to this object, but we only need these for now
// Add more keys as needed
}
import { type EmrApiConfigurationResponse } from '../types';

export default function useEmrConfiguration() {
const swrData = useSWRImmutable<FetchResponse<EmrApiConfigurationResponse>>(
Expand Down
10 changes: 10 additions & 0 deletions packages/esm-ward-app/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,13 @@ export interface ObsPayload {
value: string;
}>;
}

export interface EmrApiConfigurationResponse {
admissionEncounterType: OpenmrsResource;
clinicianEncounterRole: OpenmrsResource;
consultFreeTextCommentsConcept: OpenmrsResource;
visitNoteEncounterType: OpenmrsResource;
transferWithinHospitalEncounterType: OpenmrsResource;
// There are many more keys to this object, but we only need these for now
// Add more keys as needed
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { type Encounter, type Bed } from '../types';
import { type Bed, type Encounter } from '../types';
import { WardPatientCardElement } from './ward-patient-card-element.component';
import { useCurrentWardCardConfig } from '../hooks/useCurrentWardCardConfig';
import styles from './ward-patient-card.scss';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import {
translateFrom,
useSession,
} from '@openmrs/esm-framework';
import { savePatientNote } from './notes-form.resource';
import styles from './notes-form.scss';
import { moduleName } from '../../../constant';
import useEmrConfiguration from '../../../hooks/useEmrConfiguration';
import { savePatientNote } from '../notes.resource';
import { type EmrApiConfigurationResponse } from '../../../types';

type NotesFormData = z.infer<typeof noteFormSchema>;

Expand All @@ -29,14 +29,19 @@ const noteFormSchema = z.object({

interface PatientNotesFormProps extends DefaultWorkspaceProps {
patientUuid: PatientUuid;
emrConfiguration: EmrApiConfigurationResponse;
isLoadingEmrConfiguration: boolean;
errorFetchingEmrConfiguration: Error;
}

const PatientNotesForm: React.FC<PatientNotesFormProps> = ({
closeWorkspaceWithSavedChanges,
patientUuid,
promptBeforeClosing,
emrConfiguration,
isLoadingEmrConfiguration,
errorFetchingEmrConfiguration,
}) => {
const { emrConfiguration, isLoadingEmrConfiguration, errorFetchingEmrConfiguration } = useEmrConfiguration();
const { t } = useTranslation();
const session = useSession();
const [isSubmitting, setIsSubmitting] = useState(false);
Expand Down Expand Up @@ -120,18 +125,16 @@ const PatientNotesForm: React.FC<PatientNotesFormProps> = ({
return (
<Form className={styles.form} onSubmit={handleSubmit(onSubmit, onError)}>
{errorFetchingEmrConfiguration && (
<div className={styles.formError}>
<InlineNotification
kind="error"
title={t('somePartsOfTheFormDidntLoad', "Some parts of the form didn't load")}
subtitle={t(
'fetchingEmrConfigurationFailed',
'Fetching EMR configuration failed. Try refreshing the page or contact your system administrator.',
)}
lowContrast
hideCloseButton
/>
</div>
<InlineNotification
kind="error"
title={t('somePartsOfTheFormDidntLoad', "Some parts of the form didn't load")}
subtitle={t(
'fetchingEmrConfigurationFailed',
'Fetching EMR configuration failed. Try refreshing the page or contact your system administrator.',
)}
lowContrast
hideCloseButton
/>
)}
<Stack className={styles.formContainer} gap={2}>
<Row className={styles.row}>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { type PatientNote } from '../types';
import { SkeletonText, Tag, Tile } from '@carbon/react';
import dayjs from 'dayjs';
import styles from './styles.scss';

export const InPatientNoteSkeleton: React.FC = () => {
return (
<Tile className={styles.noteTile}>
<div className={styles.noteHeader}>
<SkeletonText heading width="30%" />
<SkeletonText width="20%" />
</div>
<SkeletonText width="15%" />
<SkeletonText width="100%" />
<SkeletonText width="80%" />
</Tile>
);
};

interface InPatientNoteProps {
note: PatientNote;
}

const InPatientNote: React.FC<InPatientNoteProps> = ({ note }) => {
const formattedDate = note.encounterNoteRecordedAt
? dayjs(note.encounterNoteRecordedAt).format('dddd, D MMM YYYY')
: '';
const formattedTime = note.encounterNoteRecordedAt ? dayjs(note.encounterNoteRecordedAt).format('HH:mm') : '';

return (
<Tile className={styles.noteTile}>
<div className={styles.noteHeader}>
<span className={styles.noteProviderRole}>Nurse’s note</span>
<span className={styles.noteDateAndTime}>
{formattedDate}, {formattedTime}
</span>
</div>
{note.diagnoses &&
note.diagnoses.split(',').map((diagnosis, index) => (
<Tag key={index} type="red">
{diagnosis.trim()}
</Tag>
))}
<div className={styles.noteBody}>{note.encounterNote}</div>
<div className={styles.noteProviderName}>{note.encounterProvider}</div>
</Tile>
);
};

export default InPatientNote;
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isDesktop, type PatientUuid, useLayoutType } from '@openmrs/esm-framework';
import { usePatientNotes } from '../notes.resource';
import InPatientNote, { InPatientNoteSkeleton } from './note.component';
import { type EmrApiConfigurationResponse } from '../../../types';
import styles from './styles.scss';
import { Dropdown } from '@carbon/react';

interface PatientNotesHistoryProps {
patientUuid: PatientUuid;
emrConfiguration: EmrApiConfigurationResponse;
isLoadingEmrConfiguration: boolean;
}

const PatientNotesHistory: React.FC<PatientNotesHistoryProps> = ({
patientUuid,
emrConfiguration,
isLoadingEmrConfiguration,
}) => {
const { t } = useTranslation();
const desktopLayout = isDesktop(useLayoutType());
const [filter, setFilter] = useState('');
const { patientNotes, isLoadingPatientNotes } = usePatientNotes(
patientUuid,
emrConfiguration?.visitNoteEncounterType?.uuid,
emrConfiguration?.consultFreeTextCommentsConcept.uuid,
);

const handleEncounterTypeChange = ({ selectedItem }) => setFilter(selectedItem);

const isLoading = isLoadingPatientNotes || isLoadingEmrConfiguration;

return (
<div className={styles.notesContainer}>
<div className={styles.notesContainerHeader}>
<div className={styles.notesContainerTitle}>History</div>
<div>
<Dropdown
autoAlign
id="providerFilter"
initialSelectedItem={t('all', 'All')}
label=""
titleText={t('show', 'Show')}
type="inline"
items={[t('all', 'All')]}
onChange={handleEncounterTypeChange}
size={desktopLayout ? 'sm' : 'lg'}
/>
</div>
</div>
{isLoading ? [1, 2, 3, 4].map((item, index) => <InPatientNoteSkeleton key={index} />) : null}
{patientNotes.map((patientNote) => (
<InPatientNote key={patientNote.id} note={patientNote} />
))}
</div>
);
};

export default PatientNotesHistory;
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import { createErrorHandler, ResponsiveWrapper, showSnackbar, translateFrom, useSession } from '@openmrs/esm-framework';
import { savePatientNote } from './notes-form.resource';
import PatientNotesForm from './notes-form.component';
import { emrConfigurationMock, mockPatient, mockSession } from '__mocks__';
import useEmrConfiguration from '../../../hooks/useEmrConfiguration';

const testProps = {
patientUuid: mockPatient.uuid,
closeWorkspace: jest.fn(),
closeWorkspaceWithSavedChanges: jest.fn(),
promptBeforeClosing: jest.fn(),
setTitle: jest.fn(),
onWorkspaceClose: jest.fn(),
setOnCloseCallback: jest.fn(),
};

const mockSavePatientNote = savePatientNote as jest.Mock;
const mockedShowSnackbar = jest.mocked(showSnackbar);
const mockedCreateErrorHandler = jest.mocked(createErrorHandler);
const mockedTranslateFrom = jest.mocked(translateFrom);
const mockedResponsiveWrapper = jest.mocked(ResponsiveWrapper);
const mockedUseSession = jest.mocked(useSession);

jest.mock('./notes-form.resource', () => ({
savePatientNote: jest.fn(),
}));

jest.mock('../../../hooks/useEmrConfiguration', () => jest.fn());

const mockedUseEmrConfiguration = jest.mocked(useEmrConfiguration);

mockedUseEmrConfiguration.mockReturnValue({
emrConfiguration: emrConfigurationMock,
mutateEmrConfiguration: jest.fn(),
isLoadingEmrConfiguration: false,
errorFetchingEmrConfiguration: null,
});

test('renders the visit notes form with all the relevant fields and values', () => {
renderWardPatientNotesForm();

expect(screen.getByRole('textbox', { name: /Write your notes/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
});

test('renders a success snackbar upon successfully recording a visit note', async () => {
const successPayload = {
encounterType: emrConfigurationMock.visitNoteEncounterType.uuid,
location: undefined,
obs: expect.arrayContaining([
{
concept: { display: '', uuid: '162169AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' },
value: 'Sample clinical note',
},
]),
patient: mockPatient.uuid,
};

mockSavePatientNote.mockResolvedValue({ status: 201, body: 'Condition created' });

renderWardPatientNotesForm();

const note = screen.getByRole('textbox', { name: /Write your notes/i });
await userEvent.clear(note);
await userEvent.type(note, 'Sample clinical note');
expect(note).toHaveValue('Sample clinical note');

const submitButton = screen.getByRole('button', { name: /Save/i });
await userEvent.click(submitButton);

expect(mockSavePatientNote).toHaveBeenCalledTimes(1);
expect(mockSavePatientNote).toHaveBeenCalledWith(expect.objectContaining(successPayload), new AbortController());
});

test('renders an error snackbar if there was a problem recording a visit note', async () => {
const error = {
message: 'Internal Server Error',
response: {
status: 500,
statusText: 'Internal Server Error',
},
};

mockSavePatientNote.mockRejectedValueOnce(error);
renderWardPatientNotesForm();

const note = screen.getByRole('textbox', { name: /Write your notes/i });
await userEvent.clear(note);
await userEvent.type(note, 'Sample clinical note');
expect(note).toHaveValue('Sample clinical note');

const submitButton = screen.getByRole('button', { name: /Save/i });

await userEvent.click(submitButton);

expect(mockedShowSnackbar).toHaveBeenCalledWith({
isLowContrast: false,
kind: 'error',
subtitle: 'Internal Server Error',
title: 'Error saving patient note',
});
});

function renderWardPatientNotesForm() {
mockedUseSession.mockReturnValue(mockSession);
render(<PatientNotesForm {...testProps} />);
}
Loading

0 comments on commit f4e486f

Please sign in to comment.