From e97c3cac832111786c7515e3485c675ba499ac4c Mon Sep 17 00:00:00 2001 From: blestab Date: Thu, 9 Jul 2020 01:03:34 +0200 Subject: [PATCH 01/16] fix: add Relates Person search crash if matched person has no DoB fix #2195 --- src/patients/related-persons/AddRelatedPersonModal.tsx | 6 +++--- src/shared/hooks/useTranslator.ts | 7 +------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/patients/related-persons/AddRelatedPersonModal.tsx b/src/patients/related-persons/AddRelatedPersonModal.tsx index 7b57e6d3bb..c7a30bd8e0 100644 --- a/src/patients/related-persons/AddRelatedPersonModal.tsx +++ b/src/patients/related-persons/AddRelatedPersonModal.tsx @@ -48,6 +48,8 @@ const AddRelatedPersonModal = (props: Props) => { return patients.filter((p: Patient) => p.id !== patient.id) } + const formattedDate = (date: string) => (date ? format(new Date(date), 'yyyy-MM-dd') : '') + const body = (
{relatedPersonError?.message && ( @@ -65,9 +67,7 @@ const AddRelatedPersonModal = (props: Props) => { isInvalid={!!relatedPersonError?.relatedPerson} onSearch={onSearch} renderMenuItemChildren={(p: Patient) => ( -
- {`${p.fullName} - ${format(new Date(p.dateOfBirth), 'yyyy-MM-dd')} (${p.code})`} -
+
{`${p.fullName} - ${formattedDate(p.dateOfBirth)} (${p.code})`}
)} /> {relatedPersonError?.relatedPerson && ( diff --git a/src/shared/hooks/useTranslator.ts b/src/shared/hooks/useTranslator.ts index 7a55183aaf..353c81cf18 100644 --- a/src/shared/hooks/useTranslator.ts +++ b/src/shared/hooks/useTranslator.ts @@ -4,12 +4,7 @@ import { useTranslation } from 'react-i18next' export default function useTranslator() { const { t } = useTranslation() - const translate = useCallback( - (key: any): any => { - return key !== undefined ? t(key) : undefined - }, - [t], - ) + const translate = useCallback((key: any): any => (key !== undefined ? t(key) : undefined), [t]) return { t: translate, From 5a34b8f40e0cead8cb2a660e6ea6dc41faf71303 Mon Sep 17 00:00:00 2001 From: blestab Date: Fri, 10 Jul 2020 02:12:57 +0200 Subject: [PATCH 02/16] feat(incidents): add ability to resolve an incident re #2078 --- src/incidents/IncidentFilter.ts | 1 + src/incidents/incident-slice.ts | 22 +++++++ src/incidents/view/ViewIncident.tsx | 63 ++++++++++++++++++- .../enUs/translations/incidents/index.ts | 3 + src/shared/model/Incident.ts | 3 +- src/shared/model/Permissions.ts | 1 + src/user/user-slice.ts | 1 + 7 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/incidents/IncidentFilter.ts b/src/incidents/IncidentFilter.ts index d141730d05..492e28545e 100644 --- a/src/incidents/IncidentFilter.ts +++ b/src/incidents/IncidentFilter.ts @@ -1,5 +1,6 @@ enum IncidentFilter { reported = 'reported', + completed = 'completed', all = 'all', } diff --git a/src/incidents/incident-slice.ts b/src/incidents/incident-slice.ts index 11a319ebe9..9390815b91 100644 --- a/src/incidents/incident-slice.ts +++ b/src/incidents/incident-slice.ts @@ -51,6 +51,8 @@ const incidentSlice = createSlice({ reportIncidentStart: start, reportIncidentSuccess: finish, reportIncidentError: error, + completeIncidentStart: start, + completeIncidentSuccess: finish, }, }) @@ -60,6 +62,8 @@ export const { reportIncidentStart, reportIncidentSuccess, reportIncidentError, + completeIncidentStart, + completeIncidentSuccess, } = incidentSlice.actions export const fetchIncident = (id: string): AppThunk => async (dispatch) => { @@ -120,4 +124,22 @@ export const reportIncident = ( } } +export const completeIncident = ( + incidentToComplete: Incident, + onSuccess?: (incidentToComplete: Incident) => void, +): AppThunk => async (dispatch) => { + dispatch(completeIncidentStart()) + + incidentToComplete.completedOn = new Date(Date.now().valueOf()).toISOString() + const completedIncident = await IncidentRepository.saveOrUpdate({ + ...incidentToComplete, + status: 'completed', + }) + dispatch(completeIncidentSuccess(completedIncident)) + + if (onSuccess) { + onSuccess(completedIncident) + } +} + export default incidentSlice.reducer diff --git a/src/incidents/view/ViewIncident.tsx b/src/incidents/view/ViewIncident.tsx index 17b6fc620d..ed6bdbd9cf 100644 --- a/src/incidents/view/ViewIncident.tsx +++ b/src/incidents/view/ViewIncident.tsx @@ -1,22 +1,26 @@ -import { Column, Row, Spinner } from '@hospitalrun/components' +import { Button, Column, Row, Spinner } from '@hospitalrun/components' import format from 'date-fns/format' import React, { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { useParams } from 'react-router-dom' +import { useParams, useHistory } from 'react-router-dom' import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' import useTitle from '../../page-header/title/useTitle' import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' import useTranslator from '../../shared/hooks/useTranslator' +import Permissions from '../../shared/model/Permissions' import { RootState } from '../../shared/store' -import { fetchIncident } from '../incident-slice' +import { fetchIncident, completeIncident } from '../incident-slice' const ViewIncident = () => { const dispatch = useDispatch() const { t } = useTranslator() + const history = useHistory() const { id } = useParams() const { incident } = useSelector((state: RootState) => state.incident) + const { permissions } = useSelector((state: RootState) => state.user) + const isIncomplete = incident?.status !== 'completed' useTitle(incident ? incident.code : '') const breadcrumbs = [ { @@ -31,7 +35,54 @@ const ViewIncident = () => { dispatch(fetchIncident(id)) } }, [dispatch, id]) + + const onComplete = async () => { + const onSuccess = () => { + history.push('/incidents') + } + + if (incident) { + dispatch(completeIncident(incident, onSuccess)) + } + } + + const getButtons = () => { + const buttons: React.ReactNode[] = [] + if (incident?.status === 'completed') { + return buttons + } + + if (permissions.includes(Permissions.CompleteIncident)) { + buttons.push( + , + ) + } + + return buttons + } + if (incident) { + const getCompletedOnDate = () => { + if (incident.status === 'completed' && incident.completedOn) { + return ( + +
+

{t('incidents.reports.completedOn')}

+
{format(new Date(incident.completedOn), 'yyyy-MM-dd hh:mm a')}
+
+
+ ) + } + return <> + } + return ( <> @@ -59,6 +110,7 @@ const ViewIncident = () => {
{format(new Date(incident.reportedOn || ''), 'yyyy-MM-dd hh:mm a')}
+ {getCompletedOnDate()}
@@ -95,6 +147,11 @@ const ViewIncident = () => { /> + {isIncomplete && ( +
+
{getButtons()}
+
+ )} ) } diff --git a/src/shared/locales/enUs/translations/incidents/index.ts b/src/shared/locales/enUs/translations/incidents/index.ts index 8b71520a52..dc9aa43df3 100644 --- a/src/shared/locales/enUs/translations/incidents/index.ts +++ b/src/shared/locales/enUs/translations/incidents/index.ts @@ -7,12 +7,14 @@ export default { }, status: { reported: 'reported', + completed: 'completed', all: 'all', }, reports: { label: 'Reported Incidents', new: 'Report Incident', view: 'View Incident', + complete: 'Complete Incident', dateOfIncident: 'Date of Incident', department: 'Department', category: 'Category', @@ -21,6 +23,7 @@ export default { code: 'Code', reportedBy: 'Reported By', reportedOn: 'Reported On', + completedOn: 'Completed On', status: 'Status', error: { dateRequired: 'Date is required.', diff --git a/src/shared/model/Incident.ts b/src/shared/model/Incident.ts index ecc8f66cdc..c36ae8d5db 100644 --- a/src/shared/model/Incident.ts +++ b/src/shared/model/Incident.ts @@ -9,5 +9,6 @@ export default interface Incident extends AbstractDBModel { category: string categoryItem: string description: string - status: 'reported' + status: 'reported' | 'completed' + completedOn: string } diff --git a/src/shared/model/Permissions.ts b/src/shared/model/Permissions.ts index 2adda626ac..b4b2f5f2d5 100644 --- a/src/shared/model/Permissions.ts +++ b/src/shared/model/Permissions.ts @@ -14,6 +14,7 @@ enum Permissions { ViewIncidents = 'read:incidents', ViewIncident = 'read:incident', ReportIncident = 'write:incident', + CompleteIncident = 'complete:incident', AddCarePlan = 'write:care_plan', ReadCarePlan = 'read:care_plan', } diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index c18fff557c..755d07ed6a 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -35,6 +35,7 @@ const initialState: UserState = { Permissions.ViewIncident, Permissions.ViewIncidents, Permissions.ReportIncident, + Permissions.CompleteIncident, Permissions.AddCarePlan, Permissions.ReadCarePlan, ], From a7e415c6ed15ddc6cf0e99c06b5b0ac7d902d774 Mon Sep 17 00:00:00 2001 From: blestab Date: Thu, 23 Jul 2020 22:57:00 +0200 Subject: [PATCH 03/16] feat(medications): implement basic medication module re #2229 --- src/HospitalRun.tsx | 2 + src/medications/MedicationOptionFields.tsx | 47 +++ src/medications/Medications.tsx | 47 +++ src/medications/ViewMedication.tsx | 360 ++++++++++++++++++ src/medications/ViewMedications.tsx | 130 +++++++ src/medications/medication-slice.ts | 190 +++++++++ src/medications/medications-slice.ts | 75 ++++ .../requests/NewMedicationRequest.tsx | 239 ++++++++++++ src/shared/components/Sidebar.tsx | 53 +++ src/shared/components/navbar/pageMap.tsx | 12 + src/shared/config/pouchdb.ts | 8 + src/shared/db/MedicationRepository.ts | 66 ++++ src/shared/locales/enUs/translations/index.ts | 2 + .../enUs/translations/medications/index.ts | 65 ++++ src/shared/model/Medication.ts | 31 ++ src/shared/model/Permissions.ts | 5 + src/shared/store/index.ts | 4 + src/user/user-slice.ts | 5 + 18 files changed, 1341 insertions(+) create mode 100644 src/medications/MedicationOptionFields.tsx create mode 100644 src/medications/Medications.tsx create mode 100644 src/medications/ViewMedication.tsx create mode 100644 src/medications/ViewMedications.tsx create mode 100644 src/medications/medication-slice.ts create mode 100644 src/medications/medications-slice.ts create mode 100644 src/medications/requests/NewMedicationRequest.tsx create mode 100644 src/shared/db/MedicationRepository.ts create mode 100644 src/shared/locales/enUs/translations/medications/index.ts create mode 100644 src/shared/model/Medication.ts diff --git a/src/HospitalRun.tsx b/src/HospitalRun.tsx index 6e9a9b406c..2939fce2e0 100644 --- a/src/HospitalRun.tsx +++ b/src/HospitalRun.tsx @@ -6,6 +6,7 @@ import { Redirect, Route, Switch } from 'react-router-dom' import Dashboard from './dashboard/Dashboard' import Incidents from './incidents/Incidents' import Labs from './labs/Labs' +import Medications from './medications/Medications' import Breadcrumbs from './page-header/breadcrumbs/Breadcrumbs' import { ButtonBarProvider } from './page-header/button-toolbar/ButtonBarProvider' import ButtonToolBar from './page-header/button-toolbar/ButtonToolBar' @@ -51,6 +52,7 @@ const HospitalRun = () => { + diff --git a/src/medications/MedicationOptionFields.tsx b/src/medications/MedicationOptionFields.tsx new file mode 100644 index 0000000000..fbd2e9e70b --- /dev/null +++ b/src/medications/MedicationOptionFields.tsx @@ -0,0 +1,47 @@ +import { Option } from '../shared/components/input/SelectWithLableFormGroup' +import useTranslator from '../shared/hooks/useTranslator' + +const MedicationFieldsOptions = () => { + const { t } = useTranslator() + + const statusOptionsNew: Option[] = [ + { label: t('medications.status.draft'), value: 'draft' }, + { label: t('medications.status.active'), value: 'active' }, + ] + + const statusOptionsEdit: Option[] = [ + { label: t('medications.status.active'), value: 'active' }, + { label: t('medications.status.onHold'), value: 'on hold' }, + { label: t('medications.status.completed'), value: 'completed' }, + { label: t('medications.status.enteredInError'), value: 'entered in error' }, + { label: t('medications.status.canceled'), value: 'canceled' }, + { label: t('medications.status.unknown'), value: 'unknown' }, + ] + + const intentOptions: Option[] = [ + { label: t('medications.intent.proposal'), value: 'proposal' }, + { label: t('medications.intent.plan'), value: 'plan' }, + { label: t('medications.intent.order'), value: 'order' }, + { label: t('medications.intent.originalOrder'), value: 'original order' }, + { label: t('medications.intent.reflexOrder'), value: 'reflex order' }, + { label: t('medications.intent.fillerOrder'), value: 'filler order' }, + { label: t('medications.intent.instanceOrder'), value: 'instance order' }, + { label: t('medications.intent.option'), value: 'option' }, + ] + + const priorityOptions: Option[] = [ + { label: t('medications.priority.routine'), value: 'routine' }, + { label: t('medications.priority.urgent'), value: 'urgent' }, + { label: t('medications.priority.asap'), value: 'asap' }, + { label: t('medications.priority.stat'), value: 'stat' }, + ] + + return { + statusNew: statusOptionsNew, + statusEdit: statusOptionsEdit, + intent: intentOptions, + priority: priorityOptions, + } +} + +export default MedicationFieldsOptions diff --git a/src/medications/Medications.tsx b/src/medications/Medications.tsx new file mode 100644 index 0000000000..c2f7ba5fcb --- /dev/null +++ b/src/medications/Medications.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { Switch } from 'react-router-dom' + +import useAddBreadcrumbs from '../page-header/breadcrumbs/useAddBreadcrumbs' +import PrivateRoute from '../shared/components/PrivateRoute' +import Permissions from '../shared/model/Permissions' +import { RootState } from '../shared/store' +import NewMedicationRequest from './requests/NewMedicationRequest' +import ViewMedication from './ViewMedication' +import MedicationRequests from './ViewMedications' + +const Medications = () => { + const { permissions } = useSelector((state: RootState) => state.user) + const breadcrumbs = [ + { + i18nKey: 'medications.label', + location: `/medications`, + }, + ] + useAddBreadcrumbs(breadcrumbs, true) + + return ( + + + + + + ) +} + +export default Medications diff --git a/src/medications/ViewMedication.tsx b/src/medications/ViewMedication.tsx new file mode 100644 index 0000000000..4aa91ec2ee --- /dev/null +++ b/src/medications/ViewMedication.tsx @@ -0,0 +1,360 @@ +import { Row, Column, Badge, Button, Alert } from '@hospitalrun/components' +import format from 'date-fns/format' +import React, { useEffect, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useParams, useHistory } from 'react-router-dom' + +import useAddBreadcrumbs from '../page-header/breadcrumbs/useAddBreadcrumbs' +import useTitle from '../page-header/title/useTitle' +import SelectWithLabelFormGroup, { + Option, +} from '../shared/components/input/SelectWithLableFormGroup' +import TextFieldWithLabelFormGroup from '../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../shared/components/input/TextInputWithLabelFormGroup' +import useTranslator from '../shared/hooks/useTranslator' +import Medication from '../shared/model/Medication' +import Patient from '../shared/model/Patient' +import Permissions from '../shared/model/Permissions' +import { RootState } from '../shared/store' +import { + cancelMedication, + completeMedication, + updateMedication, + fetchMedication, +} from './medication-slice' + +const getTitle = (patient: Patient | undefined, medication: Medication | undefined) => + patient && medication ? `${medication.medication} for ${patient.fullName}` : '' + +const ViewMedication = () => { + const { id } = useParams() + const { t } = useTranslator() + const history = useHistory() + const dispatch = useDispatch() + const { permissions } = useSelector((state: RootState) => state.user) + const { medication, patient, status, error } = useSelector((state: RootState) => state.medication) + + const [medicationToView, setMedicationToView] = useState() + const [isEditable, setIsEditable] = useState(true) + + useTitle(getTitle(patient, medicationToView)) + + const breadcrumbs = [ + { + i18nKey: 'medications.requests.view', + location: `/medications/${medicationToView?.id}`, + }, + ] + useAddBreadcrumbs(breadcrumbs) + + useEffect(() => { + if (id) { + dispatch(fetchMedication(id)) + } + }, [id, dispatch]) + + useEffect(() => { + if (medication) { + setMedicationToView({ ...medication }) + setIsEditable(medication.status !== 'completed') + } + }, [medication]) + + const statusOptionsEdit: Option[] = [ + { label: t('medications.status.draft'), value: 'draft' }, + { label: t('medications.status.active'), value: 'active' }, + { label: t('medications.status.onHold'), value: 'on hold' }, + { label: t('medications.status.completed'), value: 'completed' }, + { label: t('medications.status.enteredInError'), value: 'entered in error' }, + { label: t('medications.status.canceled'), value: 'canceled' }, + { label: t('medications.status.unknown'), value: 'unknown' }, + ] + + const intentOptions: Option[] = [ + { label: t('medications.intent.proposal'), value: 'proposal' }, + { label: t('medications.intent.plan'), value: 'plan' }, + { label: t('medications.intent.order'), value: 'order' }, + { label: t('medications.intent.originalOrder'), value: 'original order' }, + { label: t('medications.intent.reflexOrder'), value: 'reflex order' }, + { label: t('medications.intent.fillerOrder'), value: 'filler order' }, + { label: t('medications.intent.instanceOrder'), value: 'instance order' }, + { label: t('medications.intent.option'), value: 'option' }, + ] + + const priorityOptions: Option[] = [ + { label: t('medications.priority.routine'), value: 'routine' }, + { label: t('medications.priority.urgent'), value: 'urgent' }, + { label: t('medications.priority.asap'), value: 'asap' }, + { label: t('medications.priority.stat'), value: 'stat' }, + ] + + const onQuantityChange = (text: string, name: string) => { + const newMedication = medicationToView as Medication + setMedicationToView({ ...newMedication, quantity: { ...newMedication.quantity, [name]: text } }) + } + + const onFieldChange = (key: string, value: string | boolean) => { + const newMedication = medicationToView as Medication + setMedicationToView({ ...newMedication, [key]: value }) + } + + const onNotesChange = (event: React.ChangeEvent) => { + const notes = event.currentTarget.value + onFieldChange('notes', notes) + } + + const onUpdate = async () => { + const onSuccess = () => { + history.push('/medications') + } + if (medicationToView) { + dispatch(updateMedication(medicationToView, onSuccess)) + } + } + + const onComplete = async () => { + const onSuccess = () => { + history.push('/medications') + } + + if (medicationToView) { + dispatch(completeMedication(medicationToView, onSuccess)) + } + } + + const onCancel = async () => { + const onSuccess = () => { + history.push('/medications') + } + + if (medicationToView) { + dispatch(cancelMedication(medicationToView, onSuccess)) + } + } + + const getButtons = () => { + const buttons: React.ReactNode[] = [] + if (medicationToView?.status === 'completed' || medicationToView?.status === 'canceled') { + return buttons + } + + buttons.push( + , + ) + + if (permissions.includes(Permissions.CompleteMedication)) { + buttons.push( + , + ) + } + + if (permissions.includes(Permissions.CancelMedication)) { + buttons.push( + , + ) + } + + return buttons + } + + if (medicationToView && patient) { + const getBadgeColor = () => { + if (medicationToView.status === 'completed') { + return 'primary' + } + if (medicationToView.status === 'canceled') { + return 'danger' + } + return 'warning' + } + + const getCanceledOnOrCompletedOnDate = () => { + if (medicationToView.status === 'completed' && medicationToView.completedOn) { + return ( + +
+

{t('medications.medication.completedOn')}

+
{format(new Date(medicationToView.completedOn), 'yyyy-MM-dd hh:mm a')}
+
+
+ ) + } + if (medicationToView.status === 'canceled' && medicationToView.canceledOn) { + return ( + +
+

{t('medications.medication.canceledOn')}

+
{format(new Date(medicationToView.canceledOn), 'yyyy-MM-dd hh:mm a')}
+
+
+ ) + } + return <> + } + + return ( + <> + {status === 'error' && ( + + )} + + +
+

{t('medications.medication.status')}

+ +
{medicationToView.status}
+
+
+
+ +
+

{t('medications.medication.medication')}

+
{medicationToView.medication}
+
+
+ +
+

{t('medications.medication.quantity')}

+
{`${medicationToView.quantity.value} x ${medicationToView.quantity.unit}`}
+
+
+ +
+

{t('medications.medication.for')}

+
{patient.fullName}
+
+
+ +
+

{t('medications.medication.requestedOn')}

+
{format(new Date(medicationToView.requestedOn), 'yyyy-MM-dd hh:mm a')}
+
+
+ {getCanceledOnOrCompletedOnDate()} +
+ + +
+

{t('medications.medication.intent')}

+ +
{medicationToView.intent}
+
+
+
+ +
+

{t('medications.medication.priority')}

+ +
{medicationToView.priority}
+
+
+
+
+
+ + + value === medicationToView.status, + )} + onChange={(values) => onFieldChange && onFieldChange('status', values[0])} + isEditable={isEditable} + /> + + + value === medicationToView.intent, + )} + onChange={(values) => onFieldChange && onFieldChange('intent', values[0])} + isEditable={isEditable} + /> + + + value === medicationToView.priority, + )} + onChange={(values) => onFieldChange && onFieldChange('priority', values[0])} + isEditable={isEditable} + /> + + + + + onQuantityChange(event.currentTarget.value, 'value')} + isInvalid={!!error?.quantityValue} + feedback={t(error?.quantityValue as string)} + /> + + + onQuantityChange(event.currentTarget.value, 'unit')} + isInvalid={!!error?.quantityUnit} + feedback={t(error?.quantityUnit as string)} + /> + + + + + + + + + {isEditable && ( +
+
{getButtons()}
+
+ )} + + + ) + } + return

Loading...

+} + +export default ViewMedication diff --git a/src/medications/ViewMedications.tsx b/src/medications/ViewMedications.tsx new file mode 100644 index 0000000000..35a0f1c95c --- /dev/null +++ b/src/medications/ViewMedications.tsx @@ -0,0 +1,130 @@ +import { Button, Table } from '@hospitalrun/components' +import format from 'date-fns/format' +import React, { useState, useEffect, useCallback } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useHistory } from 'react-router-dom' + +import { useButtonToolbarSetter } from '../page-header/button-toolbar/ButtonBarProvider' +import useTitle from '../page-header/title/useTitle' +import SelectWithLabelFormGroup, { + Option, +} from '../shared/components/input/SelectWithLableFormGroup' +import TextInputWithLabelFormGroup from '../shared/components/input/TextInputWithLabelFormGroup' +import useDebounce from '../shared/hooks/useDebounce' +import useTranslator from '../shared/hooks/useTranslator' +import Medication from '../shared/model/Medication' +import Permissions from '../shared/model/Permissions' +import { RootState } from '../shared/store' +import { searchMedications } from './medications-slice' + +type MedicationFilter = 'draft' | 'completed' | 'canceled' | 'all' + +const ViewMedications = () => { + const { t } = useTranslator() + const history = useHistory() + const setButtons = useButtonToolbarSetter() + useTitle(t('medications.label')) + + const { permissions } = useSelector((state: RootState) => state.user) + const dispatch = useDispatch() + const { medications } = useSelector((state: RootState) => state.medications) + const [searchFilter, setSearchFilter] = useState('all') + const [searchText, setSearchText] = useState('') + const debouncedSearchText = useDebounce(searchText, 500) + + const getButtons = useCallback(() => { + const buttons: React.ReactNode[] = [] + + if (permissions.includes(Permissions.RequestMedication)) { + buttons.push( + , + ) + } + + return buttons + }, [permissions, history, t]) + + useEffect(() => { + dispatch(searchMedications(debouncedSearchText, searchFilter)) + }, [dispatch, debouncedSearchText, searchFilter]) + + useEffect(() => { + setButtons(getButtons()) + return () => { + setButtons([]) + } + }, [dispatch, getButtons, setButtons]) + + const onViewClick = (medication: Medication) => { + history.push(`/medications/${medication.id}`) + } + + const onSearchBoxChange = (event: React.ChangeEvent) => { + setSearchText(event.target.value) + } + + const filterOptions: Option[] = [ + // TODO: add other statuses + { label: t('medications.status.draft'), value: 'draft' }, + { label: t('medications.status.completed'), value: 'completed' }, + { label: t('medications.status.canceled'), value: 'canceled' }, + { label: t('medications.filter.all'), value: 'all' }, + ] + + return ( + <> +
+
+ value === searchFilter)} + onChange={(values) => setSearchFilter(values[0] as MedicationFilter)} + isEditable + /> +
+
+ +
+
+
+ row.id} + columns={[ + { label: t('medications.medication.medication'), key: 'medication' }, + { label: t('medications.medication.priority'), key: 'priority' }, + { label: t('medications.medication.intent'), key: 'intent' }, + { + label: t('medications.medication.requestedOn'), + key: 'requestedOn', + formatter: (row) => + row.requestedOn ? format(new Date(row.requestedOn), 'yyyy-MM-dd hh:mm a') : '', + }, + { label: t('medications.medication.status'), key: 'status' }, + ]} + data={medications} + actionsHeaderText={t('actions.label')} + actions={[{ label: t('actions.view'), action: (row) => onViewClick(row as Medication) }]} + /> + + + ) +} + +export default ViewMedications diff --git a/src/medications/medication-slice.ts b/src/medications/medication-slice.ts new file mode 100644 index 0000000000..775f0eaa07 --- /dev/null +++ b/src/medications/medication-slice.ts @@ -0,0 +1,190 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import MedicationRepository from '../shared/db/MedicationRepository' +import PatientRepository from '../shared/db/PatientRepository' +import Medication from '../shared/model/Medication' +import Patient from '../shared/model/Patient' +import { AppThunk } from '../shared/store' + +interface Error { + medication?: string + patient?: string + quantity?: string + quantityValue?: string + quantityUnit?: string + message?: string +} + +interface MedicationState { + error: Error + medication?: Medication + patient?: Patient + status: 'loading' | 'error' | 'completed' +} + +const initialState: MedicationState = { + error: {}, + medication: undefined, + patient: undefined, + status: 'loading', +} + +function start(state: MedicationState) { + state.status = 'loading' +} + +function finish(state: MedicationState, { payload }: PayloadAction) { + state.status = 'completed' + state.medication = payload + state.error = {} +} + +function error(state: MedicationState, { payload }: PayloadAction) { + state.status = 'error' + state.error = payload +} + +const medicationSlice = createSlice({ + name: 'medication', + initialState, + reducers: { + fetchMedicationStart: start, + fetchMedicationSuccess: ( + state: MedicationState, + { payload }: PayloadAction<{ medication: Medication; patient: Patient }>, + ) => { + state.status = 'completed' + state.medication = payload.medication + state.patient = payload.patient + }, + updateMedicationStart: start, + updateMedicationSuccess: finish, + requestMedicationStart: start, + requestMedicationSuccess: finish, + requestMedicationError: error, + cancelMedicationStart: start, + cancelMedicationSuccess: finish, + completeMedicationStart: start, + completeMedicationSuccess: finish, + completeMedicationError: error, + }, +}) + +export const { + fetchMedicationStart, + fetchMedicationSuccess, + updateMedicationStart, + updateMedicationSuccess, + requestMedicationStart, + requestMedicationSuccess, + requestMedicationError, + cancelMedicationStart, + cancelMedicationSuccess, + completeMedicationStart, + completeMedicationSuccess, + completeMedicationError, +} = medicationSlice.actions + +export const fetchMedication = (medicationId: string): AppThunk => async (dispatch) => { + dispatch(fetchMedicationStart()) + const fetchedMedication = await MedicationRepository.find(medicationId) + const fetchedPatient = await PatientRepository.find(fetchedMedication.patient) + dispatch(fetchMedicationSuccess({ medication: fetchedMedication, patient: fetchedPatient })) +} + +const validateMedicationRequest = (newMedication: Medication): Error => { + const medicationRequestError: Error = {} + if (!newMedication.patient) { + medicationRequestError.patient = 'medications.requests.error.patientRequired' + } + + if (!newMedication.quantity) { + medicationRequestError.quantity = 'medications.requests.error.quantityRequired' + } + + return medicationRequestError +} + +export const requestMedication = ( + newMedication: Medication, + onSuccess?: (medication: Medication) => void, +): AppThunk => async (dispatch, getState) => { + dispatch(requestMedicationStart()) + + const medicationRequestError = validateMedicationRequest(newMedication) + if (Object.keys(medicationRequestError).length > 0) { + medicationRequestError.message = 'medications.requests.error.unableToRequest' + dispatch(requestMedicationError(medicationRequestError)) + } else { + newMedication.status = 'draft' + newMedication.requestedOn = new Date(Date.now().valueOf()).toISOString() + newMedication.requestedBy = getState().user.user?.id || '' + const requestedMedication = await MedicationRepository.save(newMedication) + dispatch(requestMedicationSuccess(requestedMedication)) + + if (onSuccess) { + onSuccess(requestedMedication) + } + } +} + +export const cancelMedication = ( + medicationToCancel: Medication, + onSuccess?: (medication: Medication) => void, +): AppThunk => async (dispatch) => { + dispatch(cancelMedicationStart()) + + medicationToCancel.status = 'canceled' + const canceledMedication = await MedicationRepository.saveOrUpdate(medicationToCancel) + dispatch(cancelMedicationSuccess(canceledMedication)) + + if (onSuccess) { + onSuccess(canceledMedication) + } +} + +const validateCompleteMedication = (medicationToComplete: Medication): Error => { + const completeError: Error = {} + + if (!medicationToComplete.quantity) { + completeError.quantity = 'medications.requests.error.quantityRequiredToComplete' + } + + return completeError +} + +export const completeMedication = ( + medicationToComplete: Medication, + onSuccess?: (medication: Medication) => void, +): AppThunk => async (dispatch) => { + dispatch(completeMedicationStart()) + + const completeMedicationErrors = validateCompleteMedication(medicationToComplete) + if (Object.keys(completeMedicationErrors).length > 0) { + completeMedicationErrors.message = 'medications.requests.error.unableToComplete' + dispatch(completeMedicationError(completeMedicationErrors)) + } else { + medicationToComplete.status = 'completed' + const completedMedication = await MedicationRepository.saveOrUpdate(medicationToComplete) + dispatch(completeMedicationSuccess(completedMedication)) + + if (onSuccess) { + onSuccess(completedMedication) + } + } +} + +export const updateMedication = ( + medicationToUpdate: Medication, + onSuccess?: (medication: Medication) => void, +): AppThunk => async (dispatch) => { + dispatch(updateMedicationStart()) + const updatedMedication = await MedicationRepository.saveOrUpdate(medicationToUpdate) + dispatch(updateMedicationSuccess(updatedMedication)) + + if (onSuccess) { + onSuccess(updatedMedication) + } +} + +export default medicationSlice.reducer diff --git a/src/medications/medications-slice.ts b/src/medications/medications-slice.ts new file mode 100644 index 0000000000..40a2fba226 --- /dev/null +++ b/src/medications/medications-slice.ts @@ -0,0 +1,75 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import MedicationRepository from '../shared/db/MedicationRepository' +import SortRequest from '../shared/db/SortRequest' +import Medication from '../shared/model/Medication' +import { AppThunk } from '../shared/store' + +interface MedicationsState { + isLoading: boolean + medications: Medication[] + statusFilter: status +} + +type status = + | 'draft' + | 'active' + | 'on hold' + | 'canceled' + | 'completed' + | 'entered in error' + | 'stopped' + | 'unknown' + | 'all' + +const defaultSortRequest: SortRequest = { + sorts: [ + { + field: 'requestedOn', + direction: 'desc', + }, + ], +} + +const initialState: MedicationsState = { + isLoading: false, + medications: [], + statusFilter: 'all', +} + +const startLoading = (state: MedicationsState) => { + state.isLoading = true +} + +const medicationsSlice = createSlice({ + name: 'medications', + initialState, + reducers: { + fetchMedicationsStart: startLoading, + fetchMedicationsSuccess(state, { payload }: PayloadAction) { + state.isLoading = false + state.medications = payload + }, + }, +}) +export const { fetchMedicationsStart, fetchMedicationsSuccess } = medicationsSlice.actions + +export const searchMedications = (text: string, status: status): AppThunk => async (dispatch) => { + dispatch(fetchMedicationsStart()) + + let medications + + if (text.trim() === '' && status === initialState.statusFilter) { + medications = await MedicationRepository.findAll(defaultSortRequest) + } else { + medications = await MedicationRepository.search({ + text, + status, + defaultSortRequest, + }) + } + + dispatch(fetchMedicationsSuccess(medications)) +} + +export default medicationsSlice.reducer diff --git a/src/medications/requests/NewMedicationRequest.tsx b/src/medications/requests/NewMedicationRequest.tsx new file mode 100644 index 0000000000..e2726bb3f8 --- /dev/null +++ b/src/medications/requests/NewMedicationRequest.tsx @@ -0,0 +1,239 @@ +import { Typeahead, Label, Button, Alert, Column, Row } from '@hospitalrun/components' +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory } from 'react-router-dom' + +import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' +import useTitle from '../../page-header/title/useTitle' +import SelectWithLabelFormGroup, { + Option, +} from '../../shared/components/input/SelectWithLableFormGroup' +import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' +import PatientRepository from '../../shared/db/PatientRepository' +import useTranslator from '../../shared/hooks/useTranslator' +import Medication from '../../shared/model/Medication' +import Patient from '../../shared/model/Patient' +import { RootState } from '../../shared/store' +import { requestMedication } from '../medication-slice' + +const NewMedicationRequest = () => { + const { t } = useTranslator() + const dispatch = useDispatch() + const history = useHistory() + useTitle(t('medications.requests.new')) + const { status, error } = useSelector((state: RootState) => state.medication) + + const [newMedicationRequest, setNewMedicationRequest] = useState(({ + patient: '', + medication: '', + notes: '', + status: '', + intent: 'order', + priority: '', + quantity: { value: ('' as unknown) as number, unit: '' }, + } as unknown) as Medication) + + const statusOptionsNew: Option[] = [ + { label: t('medications.status.draft'), value: 'draft' }, + { label: t('medications.status.active'), value: 'active' }, + ] + + const intentOptions: Option[] = [ + { label: t('medications.intent.proposal'), value: 'proposal' }, + { label: t('medications.intent.plan'), value: 'plan' }, + { label: t('medications.intent.order'), value: 'order' }, + { label: t('medications.intent.originalOrder'), value: 'original order' }, + { label: t('medications.intent.reflexOrder'), value: 'reflex order' }, + { label: t('medications.intent.fillerOrder'), value: 'filler order' }, + { label: t('medications.intent.instanceOrder'), value: 'instance order' }, + { label: t('medications.intent.option'), value: 'option' }, + ] + + const priorityOptions: Option[] = [ + { label: t('medications.priority.routine'), value: 'routine' }, + { label: t('medications.priority.urgent'), value: 'urgent' }, + { label: t('medications.priority.asap'), value: 'asap' }, + { label: t('medications.priority.stat'), value: 'stat' }, + ] + + const breadcrumbs = [ + { + i18nKey: 'medications.requests.new', + location: `/medications/new`, + }, + ] + useAddBreadcrumbs(breadcrumbs) + + const onPatientChange = (patient: Patient) => { + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + patient: patient.id, + })) + } + + const onMedicationChange = (event: React.ChangeEvent) => { + const medication = event.currentTarget.value + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + medication, + })) + } + + const onNoteChange = (event: React.ChangeEvent) => { + const notes = event.currentTarget.value + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + notes, + })) + } + + const onFieldChange = (key: string, value: string | boolean) => { + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + [key]: value, + })) + } + + const onTextInputChange = (text: string, name: string) => { + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + [name]: text, + })) + } + + const onSave = async () => { + const newMedication = newMedicationRequest as Medication + const onSuccess = (createdMedication: Medication) => { + history.push(`/medications/${createdMedication.id}`) + } + + dispatch(requestMedication(newMedication, onSuccess)) + } + + const onCancel = () => { + history.push('/medications') + } + + return ( + <> + {status === 'error' && ( + + )} +
+
+
+ +
+ value === newMedicationRequest.status, + )} + onChange={(values) => onFieldChange && onFieldChange('status', values[0])} + isEditable + /> +
+
+ value === newMedicationRequest.intent, + )} + onChange={(values) => onFieldChange && onFieldChange('intent', values[0])} + isEditable + /> +
+
+ value === newMedicationRequest.priority, + )} + onChange={(values) => onFieldChange && onFieldChange('priority', values[0])} + isEditable + /> +
+ + + onTextInputChange(event.currentTarget.value, 'quantity.value')} + isInvalid={!!error?.quantityValue} + feedback={t(error?.quantityValue as string)} + /> + + + onTextInputChange(event.currentTarget.value, 'quantity.unit')} + isInvalid={!!error?.quantityUnit} + feedback={t(error?.quantityUnit as string)} + /> + + +
+ +
+
+
+ + +
+
+ + + ) +} + +export default NewMedicationRequest diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index cc48668548..4d90e9c955 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -42,6 +42,8 @@ const Sidebar = () => { ? 'appointment' : splittedPath[1].includes('labs') ? 'labs' + : splittedPath[1].includes('medications') + ? 'medications' : splittedPath[1].includes('incidents') ? 'incidents' : 'none', @@ -247,6 +249,56 @@ const Sidebar = () => { ) + const getMedicationLinks = () => ( + <> + { + navigateTo('/medications') + setExpansion('medications') + }} + className="nav-item" + style={listItemStyle} + > + + {!sidebarCollapsed && t('medications.label')} + + {splittedPath[1].includes('medications') && expandedItem === 'medications' && ( + + {permissions.includes(Permissions.RequestMedication) && ( + navigateTo('/medications/new')} + active={splittedPath[1].includes('medications') && splittedPath.length > 2} + > + + {!sidebarCollapsed && t('medications.requests.new')} + + )} + {permissions.includes(Permissions.ViewMedications) && ( + navigateTo('/medications')} + active={splittedPath[1].includes('medications') && splittedPath.length < 3} + > + + {!sidebarCollapsed && t('medications.requests.label')} + + )} + + )} + + ) + const getIncidentLinks = () => ( <> { {getAppointmentLinks()} {getLabLinks()} {getIncidentLinks()} + {getMedicationLinks()} diff --git a/src/shared/components/navbar/pageMap.tsx b/src/shared/components/navbar/pageMap.tsx index 0c5564eafc..5dc9f2d3e1 100644 --- a/src/shared/components/navbar/pageMap.tsx +++ b/src/shared/components/navbar/pageMap.tsx @@ -47,6 +47,18 @@ const pageMap: { path: '/labs', icon: 'lab', }, + newMedication: { + permission: Permissions.RequestMedication, + label: 'medications.requests.new', + path: '/medications/new', + icon: 'add', + }, + viewMedications: { + permission: Permissions.ViewMedications, + label: 'medications.requests.label', + path: '/medications', + icon: 'lab', + }, newIncident: { permission: Permissions.ReportIncident, label: 'incidents.reports.new', diff --git a/src/shared/config/pouchdb.ts b/src/shared/config/pouchdb.ts index 848f237c44..cb25ade41a 100644 --- a/src/shared/config/pouchdb.ts +++ b/src/shared/config/pouchdb.ts @@ -45,6 +45,9 @@ export const schema = [ hasMany: { type: 'appointment', options: { queryInverse: 'patient', async: true } }, }, labs: { hasMany: { type: 'lab', options: { queryInverse: 'patient', async: true } } }, + medications: { + hasMany: { type: 'medication', options: { queryInverse: 'patient', async: true } }, + }, }, }, { @@ -61,6 +64,11 @@ export const schema = [ plural: 'labs', relations: { patient: { belongsTo: 'patient' } }, }, + { + singular: 'medication', + plural: 'medications', + relations: { patient: { belongsTo: 'patient' } }, + }, ] export const relationalDb = localDb.setSchema(schema) export const remoteDb = serverDb as PouchDB.Database diff --git a/src/shared/db/MedicationRepository.ts b/src/shared/db/MedicationRepository.ts new file mode 100644 index 0000000000..7d9a09573a --- /dev/null +++ b/src/shared/db/MedicationRepository.ts @@ -0,0 +1,66 @@ +import { relationalDb } from '../config/pouchdb' +import Medication from '../model/Medication' +import Repository from './Repository' +import SortRequest from './SortRequest' + +interface SearchContainer { + text: string + status: + | 'draft' + | 'active' + | 'on hold' + | 'canceled' + | 'completed' + | 'entered in error' + | 'stopped' + | 'unknown' + | 'all' + defaultSortRequest: SortRequest +} +class MedicationRepository extends Repository { + constructor() { + super('medication', relationalDb) + } + + async search(container: SearchContainer): Promise { + const searchValue = { $regex: RegExp(container.text, 'i') } + const selector = { + $and: [ + { + $or: [ + { + 'data.type': searchValue, + }, + { + 'data.code': searchValue, + }, + ], + }, + ...(container.status !== 'all' ? [{ 'data.status': container.status }] : [undefined]), + ].filter((x) => x !== undefined), + sorts: container.defaultSortRequest, + } + + return super.search({ + selector, + }) + } + + async save(entity: Medication): Promise { + return super.save(entity) + } + + async findAllByPatientId(patientId: string): Promise { + return super.search({ + selector: { + $and: [ + { + patientId, + }, + ], + }, + }) + } +} + +export default new MedicationRepository() diff --git a/src/shared/locales/enUs/translations/index.ts b/src/shared/locales/enUs/translations/index.ts index ec5eee0dd3..38ad45b82c 100644 --- a/src/shared/locales/enUs/translations/index.ts +++ b/src/shared/locales/enUs/translations/index.ts @@ -3,6 +3,7 @@ import bloodType from './blood-type' import dashboard from './dashboard' import incidents from './incidents' import labs from './labs' +import medications from './medications' import networkStatus from './network-status' import patient from './patient' import patients from './patients' @@ -22,6 +23,7 @@ export default { ...states, ...sex, ...labs, + ...medications, ...incidents, ...settings, ...user, diff --git a/src/shared/locales/enUs/translations/medications/index.ts b/src/shared/locales/enUs/translations/medications/index.ts new file mode 100644 index 0000000000..884a2732e4 --- /dev/null +++ b/src/shared/locales/enUs/translations/medications/index.ts @@ -0,0 +1,65 @@ +export default { + medications: { + label: 'Medications', + filterTitle: 'Filter by status', + search: 'Search Medications', + status: { + draft: 'Draft', + active: 'Active', + onHold: 'On Hold', + cancelled: 'Cancelled', + completed: 'Completed', + enteredInError: 'Entered In Error', + stopped: 'Stopped', + unknown: 'Unknown', + }, + intent: { + proposal: 'Proposal', + plan: 'Plan', + order: 'Order', + originalOrder: 'Original Order', + reflexOrder: 'Reflex Order', + fillerOrder: 'Filler Order', + instanceOrder: 'Instance Order', + option: 'Option', + }, + priority: { + routine: 'Routine', + urgent: 'Urgent', + asap: 'Asap', + stat: 'Stat', + }, + filter: { + all: 'All statuses', + }, + requests: { + label: 'Medication Requests', + new: 'Request Medication', + view: 'View Medication', + cancel: 'Cancel Medication', + complete: 'Complete Medication', + error: { + unableToRequest: 'Unable to create Medication request.', + unableToComplete: 'Unable to complete Medication request.', + quantityRequired: 'Quantity is required.', + unitRequired: 'Unit is required.', + }, + }, + medication: { + medication: 'Medication', + for: 'For', + status: 'Status', + intent: 'Intent', + priority: 'Priority', + notes: 'Notes', + quantity: 'Quantity', + quantityValue: 'Value', + quantityUnit: 'Unit', + requestedOn: 'Requested On', + requestedBy: 'Requested By', + completedOn: 'Completed On', + canceledOn: 'Canceled On', + patient: 'Patient', + }, + }, +} diff --git a/src/shared/model/Medication.ts b/src/shared/model/Medication.ts new file mode 100644 index 0000000000..d9dcb0ab1f --- /dev/null +++ b/src/shared/model/Medication.ts @@ -0,0 +1,31 @@ +import AbstractDBModel from './AbstractDBModel' + +export default interface Medication extends AbstractDBModel { + requestedBy: string + requestedOn: string + completedOn: string + canceledOn: string + medication: string + status: + | 'draft' + | 'active' + | 'on hold' + | 'canceled' + | 'completed' + | 'entered in error' + | 'stopped' + | 'unknown' + intent: + | 'proposal' + | 'plan' + | 'order' + | 'original order' + | 'reflex order' + | 'filler order' + | 'instance order' + | 'option' + priority: 'routine' | 'urgent' | 'asap' | 'stat' + patient: string + notes: string + quantity: { value: number; unit: string } +} diff --git a/src/shared/model/Permissions.ts b/src/shared/model/Permissions.ts index f9c6d2ac1f..2366d1f5dc 100644 --- a/src/shared/model/Permissions.ts +++ b/src/shared/model/Permissions.ts @@ -17,6 +17,11 @@ enum Permissions { ResolveIncident = 'resolve:incident', AddCarePlan = 'write:care_plan', ReadCarePlan = 'read:care_plan', + RequestMedication = 'write:medications', + CancelMedication = 'cancel:medication', + CompleteMedication = 'complete:medication', + ViewMedication = 'read:medication', + ViewMedications = 'read:medications', } export default Permissions diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index 4ac0fa9576..e768516203 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -5,6 +5,8 @@ import incident from '../../incidents/incident-slice' import incidents from '../../incidents/incidents-slice' import lab from '../../labs/lab-slice' import labs from '../../labs/labs-slice' +import medication from '../../medications/medication-slice' +import medications from '../../medications/medications-slice' import breadcrumbs from '../../page-header/breadcrumbs/breadcrumbs-slice' import title from '../../page-header/title/title-slice' import patient from '../../patients/patient-slice' @@ -27,6 +29,8 @@ const reducer = combineReducers({ incident, incidents, labs, + medication, + medications, }) const store = configureStore({ diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index 660df6262a..4734a344bf 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -38,6 +38,11 @@ const initialState: UserState = { Permissions.ResolveIncident, Permissions.AddCarePlan, Permissions.ReadCarePlan, + Permissions.RequestMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + Permissions.ViewMedications, + Permissions.ViewMedication, ], } From 6944fb314756510fb354927d30e303183c2366b6 Mon Sep 17 00:00:00 2001 From: blestab Date: Thu, 23 Jul 2020 22:57:00 +0200 Subject: [PATCH 04/16] feat(medications): implement basic medication module re #2229 --- src/HospitalRun.tsx | 2 + .../medications/Medications.test.tsx | 135 ++++++ .../medications/ViewMedication.test.tsx | 380 ++++++++++++++++ .../medications/ViewMedications.test.tsx | 287 ++++++++++++ .../medications/medication-slice.test.ts | 424 ++++++++++++++++++ .../medications/medications-slice.test.ts | 146 ++++++ .../requests/NewMedicationRequest.test.tsx | 219 +++++++++ src/medications/MedicationOptionFields.tsx | 47 ++ src/medications/Medications.tsx | 47 ++ src/medications/ViewMedication.tsx | 360 +++++++++++++++ src/medications/ViewMedications.tsx | 130 ++++++ src/medications/medication-slice.ts | 192 ++++++++ src/medications/medications-slice.ts | 75 ++++ .../requests/NewMedicationRequest.tsx | 239 ++++++++++ src/shared/components/Sidebar.tsx | 53 +++ src/shared/components/navbar/pageMap.tsx | 12 + src/shared/config/pouchdb.ts | 8 + src/shared/db/MedicationRepository.ts | 66 +++ src/shared/locales/enUs/translations/index.ts | 2 + .../enUs/translations/medications/index.ts | 65 +++ src/shared/model/Medication.ts | 31 ++ src/shared/model/Permissions.ts | 5 + src/shared/store/index.ts | 4 + src/user/user-slice.ts | 5 + 24 files changed, 2934 insertions(+) create mode 100644 src/__tests__/medications/Medications.test.tsx create mode 100644 src/__tests__/medications/ViewMedication.test.tsx create mode 100644 src/__tests__/medications/ViewMedications.test.tsx create mode 100644 src/__tests__/medications/medication-slice.test.ts create mode 100644 src/__tests__/medications/medications-slice.test.ts create mode 100644 src/__tests__/medications/requests/NewMedicationRequest.test.tsx create mode 100644 src/medications/MedicationOptionFields.tsx create mode 100644 src/medications/Medications.tsx create mode 100644 src/medications/ViewMedication.tsx create mode 100644 src/medications/ViewMedications.tsx create mode 100644 src/medications/medication-slice.ts create mode 100644 src/medications/medications-slice.ts create mode 100644 src/medications/requests/NewMedicationRequest.tsx create mode 100644 src/shared/db/MedicationRepository.ts create mode 100644 src/shared/locales/enUs/translations/medications/index.ts create mode 100644 src/shared/model/Medication.ts diff --git a/src/HospitalRun.tsx b/src/HospitalRun.tsx index 6e9a9b406c..2939fce2e0 100644 --- a/src/HospitalRun.tsx +++ b/src/HospitalRun.tsx @@ -6,6 +6,7 @@ import { Redirect, Route, Switch } from 'react-router-dom' import Dashboard from './dashboard/Dashboard' import Incidents from './incidents/Incidents' import Labs from './labs/Labs' +import Medications from './medications/Medications' import Breadcrumbs from './page-header/breadcrumbs/Breadcrumbs' import { ButtonBarProvider } from './page-header/button-toolbar/ButtonBarProvider' import ButtonToolBar from './page-header/button-toolbar/ButtonToolBar' @@ -51,6 +52,7 @@ const HospitalRun = () => { + diff --git a/src/__tests__/medications/Medications.test.tsx b/src/__tests__/medications/Medications.test.tsx new file mode 100644 index 0000000000..48e5335e6b --- /dev/null +++ b/src/__tests__/medications/Medications.test.tsx @@ -0,0 +1,135 @@ +import { act } from '@testing-library/react' +import { mount } from 'enzyme' +import React from 'react' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import Medications from '../../medications/Medications' +import NewMedicationRequest from '../../medications/requests/NewMedicationRequest' +import ViewMedication from '../../medications/ViewMedication' +import MedicationRepository from '../../shared/db/MedicationRepository' +import PatientRepository from '../../shared/db/PatientRepository' +import Medication from '../../shared/model/Medication' +import Patient from '../../shared/model/Patient' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('Medications', () => { + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) + jest + .spyOn(MedicationRepository, 'find') + .mockResolvedValue({ id: '1234', requestedOn: new Date().toISOString() } as Medication) + jest + .spyOn(PatientRepository, 'find') + .mockResolvedValue({ id: '12345', fullName: 'test test' } as Patient) + + describe('routing', () => { + describe('/medications/new', () => { + it('should render the new medication request screen when /medications/new is accessed', () => { + const store = mockStore({ + title: 'test', + user: { permissions: [Permissions.RequestMedication] }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + medication: { + medication: ({ id: 'medicationId', patientId: 'patientId' } as unknown) as Medication, + patient: { id: 'patientId', fullName: 'some name' }, + error: {}, + }, + } as any) + + const wrapper = mount( + + + + + , + ) + + expect(wrapper.find(NewMedicationRequest)).toHaveLength(1) + }) + + it('should not navigate to /medications/new if the user does not have RequestMedication permissions', () => { + const store = mockStore({ + title: 'test', + user: { permissions: [] }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + } as any) + + const wrapper = mount( + + + + + , + ) + + expect(wrapper.find(NewMedicationRequest)).toHaveLength(0) + }) + }) + + describe('/medications/:id', () => { + it('should render the view medication screen when /medications/:id is accessed', async () => { + const store = mockStore({ + title: 'test', + user: { permissions: [Permissions.ViewMedication] }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + medication: { + medication: ({ + id: 'medicationId', + patientId: 'patientId', + requestedOn: new Date().toISOString(), + medication: 'medication', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + notes: 'medication notes', + } as unknown) as Medication, + patient: { id: 'patientId', fullName: 'some name' }, + error: {}, + }, + } as any) + + let wrapper: any + + await act(async () => { + wrapper = await mount( + + + + + , + ) + + expect(wrapper.find(ViewMedication)).toHaveLength(1) + }) + }) + + it('should not navigate to /medications/:id if the user does not have ViewMedication permissions', async () => { + const store = mockStore({ + title: 'test', + user: { permissions: [] }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + } as any) + + const wrapper = await mount( + + + + + , + ) + + expect(wrapper.find(ViewMedication)).toHaveLength(0) + }) + }) + }) +}) diff --git a/src/__tests__/medications/ViewMedication.test.tsx b/src/__tests__/medications/ViewMedication.test.tsx new file mode 100644 index 0000000000..525d956fcc --- /dev/null +++ b/src/__tests__/medications/ViewMedication.test.tsx @@ -0,0 +1,380 @@ +import { Badge, Button } from '@hospitalrun/components' +import { act } from '@testing-library/react' +import format from 'date-fns/format' +import { mount } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { Provider } from 'react-redux' +import { Router, Route } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import ViewMedication from '../../medications/ViewMedication' +import * as ButtonBarProvider from '../../page-header/button-toolbar/ButtonBarProvider' +import * as titleUtil from '../../page-header/title/useTitle' +import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' +import MedicationRepository from '../../shared/db/MedicationRepository' +import PatientRepository from '../../shared/db/PatientRepository' +import Medication from '../../shared/model/Medication' +import Patient from '../../shared/model/Patient' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('View Medication', () => { + let history: any + const mockPatient = { fullName: 'test' } + const mockMedication = { + id: '12456', + status: 'draft', + patient: '1234', + medication: 'medication', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + notes: 'medication notes', + requestedOn: '2020-03-30T04:43:20.102Z', + } as Medication + + let setButtonToolBarSpy: any + let titleSpy: any + let medicationRepositorySaveSpy: any + const expectedDate = new Date() + const setup = async (medication: Medication, permissions: Permissions[], error = {}) => { + jest.resetAllMocks() + Date.now = jest.fn(() => expectedDate.valueOf()) + setButtonToolBarSpy = jest.fn() + titleSpy = jest.spyOn(titleUtil, 'default') + jest.spyOn(ButtonBarProvider, 'useButtonToolbarSetter').mockReturnValue(setButtonToolBarSpy) + jest.spyOn(MedicationRepository, 'find').mockResolvedValue(medication) + medicationRepositorySaveSpy = jest + .spyOn(MedicationRepository, 'saveOrUpdate') + .mockResolvedValue(mockMedication) + jest.spyOn(PatientRepository, 'find').mockResolvedValue(mockPatient as Patient) + + history = createMemoryHistory() + history.push(`medications/${medication.id}`) + const store = mockStore({ + title: '', + user: { + permissions, + }, + medication: { + medication, + patient: mockPatient, + error, + status: Object.keys(error).length > 0 ? 'error' : 'completed', + }, + } as any) + + let wrapper: any + await act(async () => { + wrapper = await mount( + + + + + + + + + , + ) + }) + wrapper.update() + return wrapper + } + + it('should set the title', async () => { + await setup(mockMedication, [Permissions.ViewMedication]) + + expect(titleSpy).toHaveBeenCalledWith( + `${mockMedication.medication} for ${mockPatient.fullName}`, + ) + }) + + describe('page content', () => { + it('should display the patient full name for the for', async () => { + const expectedMedication = { ...mockMedication } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const forPatientDiv = wrapper.find('.for-patient') + expect(forPatientDiv.find('h4').text().trim()).toEqual('medications.medication.for') + + expect(forPatientDiv.find('h5').text().trim()).toEqual(mockPatient.fullName) + }) + + it('should display the medication ', async () => { + const expectedMedication = { + ...mockMedication, + medication: 'expected medication', + } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const medicationTypeDiv = wrapper.find('.medication-medication') + expect(medicationTypeDiv.find('h4').text().trim()).toEqual( + 'medications.medication.medication', + ) + + expect(medicationTypeDiv.find('h5').text().trim()).toEqual(expectedMedication.medication) + }) + + it('should display the requested on date', async () => { + const expectedMedication = { + ...mockMedication, + requestedOn: '2020-03-30T04:43:20.102Z', + } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const requestedOnDiv = wrapper.find('.requested-on') + expect(requestedOnDiv.find('h4').text().trim()).toEqual('medications.medication.requestedOn') + + expect(requestedOnDiv.find('h5').text().trim()).toEqual( + format(new Date(expectedMedication.requestedOn), 'yyyy-MM-dd hh:mm a'), + ) + }) + + it('should not display the completed date if the medication is not completed', async () => { + const expectedMedication = { ...mockMedication } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const completedOnDiv = wrapper.find('.completed-on') + + expect(completedOnDiv).toHaveLength(0) + }) + + it('should not display the canceled date if the medication is not canceled', async () => { + const expectedMedication = { ...mockMedication } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const completedOnDiv = wrapper.find('.canceled-on') + + expect(completedOnDiv).toHaveLength(0) + }) + + it('should display the notes in the notes text field', async () => { + const expectedMedication = { ...mockMedication, notes: 'expected notes' } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + + const notesTextField = wrapper.find(TextFieldWithLabelFormGroup).at(0) + + expect(notesTextField).toBeDefined() + + expect(notesTextField.prop('label')).toEqual('medications.medication.notes') + expect(notesTextField.prop('value')).toEqual(expectedMedication.notes) + }) + + describe('draft medication request', () => { + it('should display a warning badge if the status is draft', async () => { + const expectedMedication = ({ + ...mockMedication, + status: 'draft', + } as unknown) as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const medicationStatusDiv = wrapper.find('.medication-status') + const badge = medicationStatusDiv.find(Badge) + expect(medicationStatusDiv.find('h4').text().trim()).toEqual( + 'medications.medication.status', + ) + + expect(badge.prop('color')).toEqual('warning') + expect(badge.text().trim()).toEqual(expectedMedication.status) + }) + + it('should display a update medication, complete medication, and cancel medication button if the medication is in a draft state', async () => { + const expectedMedication = { ...mockMedication, notes: 'expected notes' } as Medication + + const wrapper = await setup(expectedMedication, [ + Permissions.ViewMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + ]) + + const buttons = wrapper.find(Button) + expect(buttons.at(0).text().trim()).toEqual('actions.update') + + expect(buttons.at(1).text().trim()).toEqual('medications.requests.complete') + + expect(buttons.at(2).text().trim()).toEqual('medications.requests.cancel') + }) + }) + + describe('canceled medication request', () => { + it('should display a danger badge if the status is canceled', async () => { + const expectedMedication = { ...mockMedication, status: 'canceled' } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + + const medicationStatusDiv = wrapper.find('.medication-status') + const badge = medicationStatusDiv.find(Badge) + expect(medicationStatusDiv.find('h4').text().trim()).toEqual( + 'medications.medication.status', + ) + + expect(badge.prop('color')).toEqual('danger') + expect(badge.text().trim()).toEqual(expectedMedication.status) + }) + + it('should display the canceled on date if the medication request has been canceled', async () => { + const expectedMedication = { + ...mockMedication, + status: 'canceled', + canceledOn: '2020-03-30T04:45:20.102Z', + } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const canceledOnDiv = wrapper.find('.canceled-on') + + expect(canceledOnDiv.find('h4').text().trim()).toEqual('medications.medication.canceledOn') + + expect(canceledOnDiv.find('h5').text().trim()).toEqual( + format(new Date(expectedMedication.canceledOn as string), 'yyyy-MM-dd hh:mm a'), + ) + }) + + it('should not display update, complete, and cancel button if the medication is canceled', async () => { + const expectedMedication = { ...mockMedication, status: 'canceled' } as Medication + + const wrapper = await setup(expectedMedication, [ + Permissions.ViewMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + ]) + + const buttons = wrapper.find(Button) + expect(buttons).toHaveLength(0) + }) + + it('should not display an update button if the medication is canceled', async () => { + const expectedMedication = { ...mockMedication, status: 'canceled' } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + + const updateButton = wrapper.find(Button) + expect(updateButton).toHaveLength(0) + }) + }) + + describe('completed medication request', () => { + it('should display a primary badge if the status is completed', async () => { + jest.resetAllMocks() + const expectedMedication = { ...mockMedication, status: 'completed' } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const medicationStatusDiv = wrapper.find('.medication-status') + const badge = medicationStatusDiv.find(Badge) + expect(medicationStatusDiv.find('h4').text().trim()).toEqual( + 'medications.medication.status', + ) + + expect(badge.prop('color')).toEqual('primary') + expect(badge.text().trim()).toEqual(expectedMedication.status) + }) + + it('should display the completed on date if the medication request has been completed', async () => { + const expectedMedication = { + ...mockMedication, + status: 'completed', + completedOn: '2020-03-30T04:44:20.102Z', + } as Medication + const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const completedOnDiv = wrapper.find('.completed-on') + + expect(completedOnDiv.find('h4').text().trim()).toEqual( + 'medications.medication.completedOn', + ) + + expect(completedOnDiv.find('h5').text().trim()).toEqual( + format(new Date(expectedMedication.completedOn as string), 'yyyy-MM-dd hh:mm a'), + ) + }) + + it('should not display update, complete, and cancel buttons if the medication is completed', async () => { + const expectedMedication = { ...mockMedication, status: 'completed' } as Medication + + const wrapper = await setup(expectedMedication, [ + Permissions.ViewMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + ]) + + const buttons = wrapper.find(Button) + expect(buttons).toHaveLength(0) + }) + }) + }) + + describe('on update', () => { + it('should update the medication with the new information', async () => { + const wrapper = await setup(mockMedication, [Permissions.ViewMedication]) + const expectedNotes = 'expected notes' + + const notesTextField = wrapper.find(TextFieldWithLabelFormGroup).at(0) + act(() => { + const onChange = notesTextField.prop('onChange') + onChange({ currentTarget: { value: expectedNotes } }) + }) + wrapper.update() + const updateButton = wrapper.find(Button) + await act(async () => { + const onClick = updateButton.prop('onClick') + onClick() + }) + + expect(medicationRepositorySaveSpy).toHaveBeenCalledTimes(1) + expect(medicationRepositorySaveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + ...mockMedication, + notes: expectedNotes, + }), + ) + expect(history.location.pathname).toEqual('/medications') + }) + }) + + describe('on complete', () => { + it('should mark the status as completed and fill in the completed date with the current time', async () => { + const wrapper = await setup(mockMedication, [ + Permissions.ViewMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + ]) + + const completeButton = wrapper.find(Button).at(1) + await act(async () => { + const onClick = completeButton.prop('onClick') + await onClick() + }) + wrapper.update() + + expect(medicationRepositorySaveSpy).toHaveBeenCalledTimes(1) + expect(medicationRepositorySaveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + ...mockMedication, + status: 'completed', + completedOn: expectedDate.toISOString(), + }), + ) + expect(history.location.pathname).toEqual('/medications') + }) + }) + + describe('on cancel', () => { + it('should mark the status as canceled and fill in the cancelled on date with the current time', async () => { + const wrapper = await setup(mockMedication, [ + Permissions.ViewMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + ]) + + const cancelButton = wrapper.find(Button).at(2) + await act(async () => { + const onClick = cancelButton.prop('onClick') + await onClick() + }) + wrapper.update() + + expect(medicationRepositorySaveSpy).toHaveBeenCalledTimes(1) + expect(medicationRepositorySaveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + ...mockMedication, + status: 'canceled', + canceledOn: expectedDate.toISOString(), + }), + ) + expect(history.location.pathname).toEqual('/medications') + }) + }) +}) diff --git a/src/__tests__/medications/ViewMedications.test.tsx b/src/__tests__/medications/ViewMedications.test.tsx new file mode 100644 index 0000000000..4fb67d6977 --- /dev/null +++ b/src/__tests__/medications/ViewMedications.test.tsx @@ -0,0 +1,287 @@ +import { TextInput, Select, Table } from '@hospitalrun/components' +import { act } from '@testing-library/react' +import { mount, ReactWrapper } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { Provider } from 'react-redux' +import { Router } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import * as medicationsSlice from '../../medications/medications-slice' +import ViewMedications from '../../medications/ViewMedications' +import * as ButtonBarProvider from '../../page-header/button-toolbar/ButtonBarProvider' +import * as titleUtil from '../../page-header/title/useTitle' +import MedicationRepository from '../../shared/db/MedicationRepository' +import Medication from '../../shared/model/Medication' +import Permissions from '../../shared/model/Permissions' +import { RootState } from '../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('View Medications', () => { + describe('title', () => { + let titleSpy: any + beforeEach(async () => { + const store = mockStore({ + title: '', + user: { permissions: [Permissions.ViewMedications, Permissions.RequestMedication] }, + medications: { medications: [] }, + } as any) + titleSpy = jest.spyOn(titleUtil, 'default') + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) + await act(async () => { + await mount( + + + + + , + ) + }) + }) + + it('should have the title', () => { + expect(titleSpy).toHaveBeenCalledWith('medications.label') + }) + }) + + describe('button bar', () => { + it('should display button to add new medication request', async () => { + const store = mockStore({ + title: '', + user: { permissions: [Permissions.ViewMedications, Permissions.RequestMedication] }, + medications: { medications: [] }, + } as any) + const setButtonToolBarSpy = jest.fn() + jest.spyOn(ButtonBarProvider, 'useButtonToolbarSetter').mockReturnValue(setButtonToolBarSpy) + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) + await act(async () => { + await mount( + + + + + , + ) + }) + + const actualButtons: React.ReactNode[] = setButtonToolBarSpy.mock.calls[0][0] + expect((actualButtons[0] as any).props.children).toEqual('medications.requests.new') + }) + + it('should not display button to add new medication request if the user does not have permissions', async () => { + const store = mockStore({ + title: '', + user: { permissions: [Permissions.ViewMedications] }, + medications: { medications: [] }, + } as any) + const setButtonToolBarSpy = jest.fn() + jest.spyOn(ButtonBarProvider, 'useButtonToolbarSetter').mockReturnValue(setButtonToolBarSpy) + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) + await act(async () => { + await mount( + + + + + , + ) + }) + + const actualButtons: React.ReactNode[] = setButtonToolBarSpy.mock.calls[0][0] + expect(actualButtons).toEqual([]) + }) + }) + + describe('table', () => { + let wrapper: ReactWrapper + let history: any + const expectedMedication = ({ + id: '1234', + medication: 'medication', + patient: 'patientId', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + requestedOn: '2020-03-30T04:43:20.102Z', + } as unknown) as Medication + + beforeEach(async () => { + const store = mockStore({ + title: '', + user: { permissions: [Permissions.ViewMedications, Permissions.RequestMedication] }, + medications: { medications: [expectedMedication] }, + } as any) + history = createMemoryHistory() + + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([expectedMedication]) + await act(async () => { + wrapper = await mount( + + + + + , + ) + }) + + wrapper.update() + }) + + it('should render a table with data', () => { + const table = wrapper.find(Table) + const columns = table.prop('columns') + const actions = table.prop('actions') as any + expect(columns[0]).toEqual( + expect.objectContaining({ label: 'medications.medication.medication', key: 'medication' }), + ) + expect(columns[1]).toEqual( + expect.objectContaining({ label: 'medications.medication.priority', key: 'priority' }), + ) + expect(columns[2]).toEqual( + expect.objectContaining({ label: 'medications.medication.intent', key: 'intent' }), + ) + expect(columns[3]).toEqual( + expect.objectContaining({ + label: 'medications.medication.requestedOn', + key: 'requestedOn', + }), + ) + expect(columns[4]).toEqual( + expect.objectContaining({ label: 'medications.medication.status', key: 'status' }), + ) + + expect(actions[0]).toEqual(expect.objectContaining({ label: 'actions.view' })) + expect(table.prop('actionsHeaderText')).toEqual('actions.label') + expect(table.prop('data')).toEqual([expectedMedication]) + }) + + it('should navigate to the medication when the view button is clicked', () => { + const tr = wrapper.find('tr').at(1) + + act(() => { + const onClick = tr.find('button').prop('onClick') as any + onClick({ stopPropagation: jest.fn() }) + }) + expect(history.location.pathname).toEqual(`/medications/${expectedMedication.id}`) + }) + }) + + describe('dropdown', () => { + it('should search for medications when dropdown changes', () => { + const searchMedicationsSpy = jest.spyOn(medicationsSlice, 'searchMedications') + let wrapper: ReactWrapper + let history: any + const expectedMedication = ({ + id: '1234', + medication: 'medication', + patient: 'patientId', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + requestedOn: '2020-03-30T04:43:20.102Z', + } as unknown) as Medication + + beforeEach(async () => { + const store = mockStore({ + title: '', + user: { permissions: [Permissions.ViewMedications, Permissions.RequestMedication] }, + medications: { medications: [expectedMedication] }, + } as any) + history = createMemoryHistory() + + await act(async () => { + wrapper = await mount( + + + + + , + ) + }) + + searchMedicationsSpy.mockClear() + + act(() => { + const onChange = wrapper.find(Select).prop('onChange') as any + onChange({ + target: { + value: 'draft', + }, + preventDefault: jest.fn(), + }) + }) + + wrapper.update() + expect(searchMedicationsSpy).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('search functionality', () => { + beforeEach(() => jest.useFakeTimers()) + + afterEach(() => jest.useRealTimers()) + + it('should search for medications after the search text has not changed for 500 milliseconds', () => { + const searchMedicationsSpy = jest.spyOn(medicationsSlice, 'searchMedications') + let wrapper: ReactWrapper + let history: any + const expectedMedication = ({ + id: '1234', + medication: 'medication', + patient: 'patientId', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + requestedOn: '2020-03-30T04:43:20.102Z', + } as unknown) as Medication + + beforeEach(async () => { + const store = mockStore({ + title: '', + user: { permissions: [Permissions.ViewMedications, Permissions.RequestMedication] }, + medications: { medications: [expectedMedication] }, + } as any) + history = createMemoryHistory() + + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([expectedMedication]) + await act(async () => { + wrapper = await mount( + + + + + , + ) + }) + + searchMedicationsSpy.mockClear() + const expectedSearchText = 'search text' + + act(() => { + const onClick = wrapper.find(TextInput).prop('onChange') as any + onClick({ + target: { + value: expectedSearchText, + }, + preventDefault: jest.fn(), + }) + }) + + act(() => { + jest.advanceTimersByTime(500) + }) + + wrapper.update() + + expect(searchMedicationsSpy).toHaveBeenCalledTimes(1) + expect(searchMedicationsSpy).toHaveBeenLastCalledWith(expectedSearchText) + }) + }) + }) +}) diff --git a/src/__tests__/medications/medication-slice.test.ts b/src/__tests__/medications/medication-slice.test.ts new file mode 100644 index 0000000000..181930908a --- /dev/null +++ b/src/__tests__/medications/medication-slice.test.ts @@ -0,0 +1,424 @@ +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import medicationSlice, { + requestMedication, + fetchMedicationStart, + fetchMedicationSuccess, + updateMedicationStart, + updateMedicationSuccess, + requestMedicationStart, + requestMedicationSuccess, + completeMedicationStart, + completeMedicationSuccess, + cancelMedicationStart, + cancelMedicationSuccess, + fetchMedication, + cancelMedication, + completeMedication, + completeMedicationError, + requestMedicationError, + updateMedication, +} from '../../medications/medication-slice' +import MedicationRepository from '../../shared/db/MedicationRepository' +import PatientRepository from '../../shared/db/PatientRepository' +import Medication from '../../shared/model/Medication' +import Patient from '../../shared/model/Patient' +import { RootState } from '../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('medication slice', () => { + describe('reducers', () => { + describe('fetchMedicationStart', () => { + it('should set status to loading', async () => { + const medicationStore = medicationSlice(undefined, fetchMedicationStart()) + + expect(medicationStore.status).toEqual('loading') + }) + }) + + describe('fetchMedicationSuccess', () => { + it('should set the medication, patient, and status to success', () => { + const expectedMedication = { id: 'medicationId' } as Medication + const expectedPatient = { id: 'patient' } as Patient + + const medicationStore = medicationSlice( + undefined, + fetchMedicationSuccess({ medication: expectedMedication, patient: expectedPatient }), + ) + + expect(medicationStore.status).toEqual('completed') + expect(medicationStore.medication).toEqual(expectedMedication) + expect(medicationStore.patient).toEqual(expectedPatient) + }) + }) + + describe('updateMedicationStart', () => { + it('should set status to loading', async () => { + const medicationStore = medicationSlice(undefined, updateMedicationStart()) + + expect(medicationStore.status).toEqual('loading') + }) + }) + + describe('updateMedicationSuccess', () => { + it('should set the medication and status to success', () => { + const expectedMedication = { id: 'medicationId' } as Medication + + const medicationStore = medicationSlice( + undefined, + updateMedicationSuccess(expectedMedication), + ) + + expect(medicationStore.status).toEqual('completed') + expect(medicationStore.medication).toEqual(expectedMedication) + }) + }) + + describe('requestMedicationStart', () => { + it('should set status to loading', async () => { + const medicationStore = medicationSlice(undefined, requestMedicationStart()) + + expect(medicationStore.status).toEqual('loading') + }) + }) + + describe('requestMedicationSuccess', () => { + it('should set the medication and status to success', () => { + const expectedMedication = { id: 'medicationId' } as Medication + + const medicationStore = medicationSlice( + undefined, + requestMedicationSuccess(expectedMedication), + ) + + expect(medicationStore.status).toEqual('completed') + expect(medicationStore.medication).toEqual(expectedMedication) + }) + }) + + describe('requestMedicationError', () => { + const expectedError = { message: 'some message', result: 'some result error' } + + const medicationStore = medicationSlice(undefined, requestMedicationError(expectedError)) + + expect(medicationStore.status).toEqual('error') + expect(medicationStore.error).toEqual(expectedError) + }) + + describe('completeMedicationStart', () => { + it('should set status to loading', async () => { + const medicationStore = medicationSlice(undefined, completeMedicationStart()) + + expect(medicationStore.status).toEqual('loading') + }) + }) + + describe('completeMedicationSuccess', () => { + it('should set the medication and status to success', () => { + const expectedMedication = { id: 'medicationId' } as Medication + + const medicationStore = medicationSlice( + undefined, + completeMedicationSuccess(expectedMedication), + ) + + expect(medicationStore.status).toEqual('completed') + expect(medicationStore.medication).toEqual(expectedMedication) + }) + }) + + describe('completeMedicationError', () => { + const expectedError = { message: 'some message', result: 'some result error' } + + const medicationStore = medicationSlice(undefined, completeMedicationError(expectedError)) + + expect(medicationStore.status).toEqual('error') + expect(medicationStore.error).toEqual(expectedError) + }) + + describe('cancelMedicationStart', () => { + it('should set status to loading', async () => { + const medicationStore = medicationSlice(undefined, cancelMedicationStart()) + + expect(medicationStore.status).toEqual('loading') + }) + }) + + describe('cancelMedicationSuccess', () => { + it('should set the medication and status to success', () => { + const expectedMedication = { id: 'medicationId' } as Medication + + const medicationStore = medicationSlice( + undefined, + cancelMedicationSuccess(expectedMedication), + ) + + expect(medicationStore.status).toEqual('completed') + expect(medicationStore.medication).toEqual(expectedMedication) + }) + }) + }) + + describe('fetch medication', () => { + let patientRepositorySpy: any + let medicationRepositoryFindSpy: any + + const mockMedication = { + id: 'medicationId', + patient: 'patient', + medication: 'medication', + medication: 'medication', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + notes: 'medication notes', + } as Medication + + const mockPatient = { + id: 'patient', + } as Patient + + beforeEach(() => { + patientRepositorySpy = jest.spyOn(PatientRepository, 'find').mockResolvedValue(mockPatient) + medicationRepositoryFindSpy = jest + .spyOn(MedicationRepository, 'find') + .mockResolvedValue(mockMedication) + }) + + it('should fetch the medication and patient', async () => { + const store = mockStore() + + await store.dispatch(fetchMedication(mockMedication.id)) + const actions = store.getActions() + + expect(actions[0]).toEqual(fetchMedicationStart()) + expect(medicationRepositoryFindSpy).toHaveBeenCalledWith(mockMedication.id) + expect(patientRepositorySpy).toHaveBeenCalledWith(mockMedication.patient) + expect(actions[1]).toEqual( + fetchMedicationSuccess({ medication: mockMedication, patient: mockPatient }), + ) + }) + }) + + describe('cancel medication', () => { + const mockMedication = { + id: 'medicationId', + patient: 'patient', + } as Medication + let medicationRepositorySaveOrUpdateSpy: any + + beforeEach(() => { + Date.now = jest.fn().mockReturnValue(new Date().valueOf()) + medicationRepositorySaveOrUpdateSpy = jest + .spyOn(MedicationRepository, 'saveOrUpdate') + .mockResolvedValue(mockMedication) + }) + + it('should cancel a medication', async () => { + const expectedCanceledMedication = { + ...mockMedication, + canceledOn: new Date(Date.now()).toISOString(), + status: 'canceled', + } as Medication + + const store = mockStore() + + await store.dispatch(cancelMedication(mockMedication)) + const actions = store.getActions() + + expect(actions[0]).toEqual(cancelMedicationStart()) + expect(medicationRepositorySaveOrUpdateSpy).toHaveBeenCalledWith(expectedCanceledMedication) + expect(actions[1]).toEqual(cancelMedicationSuccess(expectedCanceledMedication)) + }) + + it('should call on success callback if provided', async () => { + const expectedCanceledMedication = { + ...mockMedication, + canceledOn: new Date(Date.now()).toISOString(), + status: 'canceled', + } as Medication + + const store = mockStore() + const onSuccessSpy = jest.fn() + await store.dispatch(cancelMedication(mockMedication, onSuccessSpy)) + + expect(onSuccessSpy).toHaveBeenCalledWith(expectedCanceledMedication) + }) + }) + + describe('complete medication', () => { + const mockMedication = { + id: 'medicationId', + patient: 'patient', + medication: 'medication', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + notes: 'notes', + } as Medication + let medicationRepositorySaveOrUpdateSpy: any + + beforeEach(() => { + Date.now = jest.fn().mockReturnValue(new Date().valueOf()) + medicationRepositorySaveOrUpdateSpy = jest + .spyOn(MedicationRepository, 'saveOrUpdate') + .mockResolvedValue(mockMedication) + }) + + it('should complete a medication', async () => { + const expectedCompletedMedication = { + ...mockMedication, + completedOn: new Date(Date.now()).toISOString(), + status: 'completed', + } as Medication + + const store = mockStore() + + await store.dispatch(completeMedication(mockMedication)) + const actions = store.getActions() + + expect(actions[0]).toEqual(completeMedicationStart()) + expect(medicationRepositorySaveOrUpdateSpy).toHaveBeenCalledWith(expectedCompletedMedication) + expect(actions[1]).toEqual(completeMedicationSuccess(expectedCompletedMedication)) + }) + + it('should call on success callback if provided', async () => { + const expectedCompletedMedication = { + ...mockMedication, + completedOn: new Date(Date.now()).toISOString(), + status: 'completed', + } as Medication + + const store = mockStore() + const onSuccessSpy = jest.fn() + await store.dispatch(completeMedication(mockMedication, onSuccessSpy)) + + expect(onSuccessSpy).toHaveBeenCalledWith(expectedCompletedMedication) + }) + + it('should validate that the medication can be completed', async () => { + const store = mockStore() + const onSuccessSpy = jest.fn() + const medicationToComplete = mockMedication + await store.dispatch( + completeMedication({ ...medicationToComplete } as Medication, onSuccessSpy), + ) + const actions = store.getActions() + + expect(actions[1]).not.toEqual( + completeMedicationError({ + message: 'medications.requests.error.unableToComplete', + }), + ) + expect(onSuccessSpy).toHaveBeenCalled() + }) + }) + + describe('request medication', () => { + const mockMedication = { + id: 'medicationId', + medication: 'medication', + patient: 'patient', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + notes: 'notes', + } as Medication + let medicationRepositorySaveSpy: any + + beforeEach(() => { + jest.restoreAllMocks() + Date.now = jest.fn().mockReturnValue(new Date().valueOf()) + medicationRepositorySaveSpy = jest + .spyOn(MedicationRepository, 'save') + .mockResolvedValue(mockMedication) + }) + + it('should request a new medication', async () => { + const store = mockStore({ + user: { + user: { + id: 'fake id', + }, + }, + } as any) + + const expectedRequestedMedication = { + ...mockMedication, + requestedOn: new Date(Date.now()).toISOString(), + status: 'draft', + requestedBy: store.getState().user.user?.id, + } as Medication + + await store.dispatch(requestMedication(mockMedication)) + + const actions = store.getActions() + + expect(actions[0]).toEqual(requestMedicationStart()) + expect(medicationRepositorySaveSpy).toHaveBeenCalledWith(expectedRequestedMedication) + expect(actions[1]).toEqual(requestMedicationSuccess(expectedRequestedMedication)) + }) + + it('should execute the onSuccess callback if provided', async () => { + const store = mockStore({ + user: { + user: { + id: 'fake id', + }, + }, + } as any) + const onSuccessSpy = jest.fn() + + await store.dispatch(requestMedication(mockMedication, onSuccessSpy)) + + expect(onSuccessSpy).toHaveBeenCalledWith(mockMedication) + }) + }) + + describe('update medication', () => { + const mockMedication = ({ + id: 'medicationId', + patient: 'patient', + medication: 'medication', + status: 'some status', + } as unknown) as Medication + let medicationRepositorySaveOrUpdateSpy: any + + const expectedUpdatedMedication = ({ + ...mockMedication, + status: 'some other status', + } as unknown) as Medication + + beforeEach(() => { + Date.now = jest.fn().mockReturnValue(new Date().valueOf()) + medicationRepositorySaveOrUpdateSpy = jest + .spyOn(MedicationRepository, 'saveOrUpdate') + .mockResolvedValue(expectedUpdatedMedication) + }) + + it('should update the medication', async () => { + const store = mockStore() + + await store.dispatch(updateMedication(expectedUpdatedMedication)) + const actions = store.getActions() + + expect(actions[0]).toEqual(updateMedicationStart()) + expect(medicationRepositorySaveOrUpdateSpy).toHaveBeenCalledWith(expectedUpdatedMedication) + expect(actions[1]).toEqual(updateMedicationSuccess(expectedUpdatedMedication)) + }) + + it('should call the onSuccess callback if successful', async () => { + const store = mockStore() + const onSuccessSpy = jest.fn() + + await store.dispatch(updateMedication(expectedUpdatedMedication, onSuccessSpy)) + + expect(onSuccessSpy).toHaveBeenCalledWith(expectedUpdatedMedication) + }) + }) +}) diff --git a/src/__tests__/medications/medications-slice.test.ts b/src/__tests__/medications/medications-slice.test.ts new file mode 100644 index 0000000000..a40568cac9 --- /dev/null +++ b/src/__tests__/medications/medications-slice.test.ts @@ -0,0 +1,146 @@ +import { AnyAction } from 'redux' +import { mocked } from 'ts-jest/utils' + +import medications, { + fetchMedicationsStart, + fetchMedicationsSuccess, + searchMedications, +} from '../../medications/medications-slice' +import MedicationRepository from '../../shared/db/MedicationRepository' +import SortRequest from '../../shared/db/SortRequest' +import Medication from '../../shared/model/Medication' + +interface SearchContainer { + text: string + status: 'draft' | 'active' | 'completed' | 'canceled' | 'all' + defaultSortRequest: SortRequest +} + +const defaultSortRequest: SortRequest = { + sorts: [ + { + field: 'requestedOn', + direction: 'desc', + }, + ], +} + +const expectedSearchObject: SearchContainer = { + text: 'search string', + status: 'all', + defaultSortRequest, +} + +describe('medications slice', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + describe('medications reducer', () => { + it('should create the proper initial state with empty medications array', () => { + const medicationsStore = medications(undefined, {} as AnyAction) + expect(medicationsStore.isLoading).toBeFalsy() + expect(medicationsStore.medications).toHaveLength(0) + expect(medicationsStore.statusFilter).toEqual('all') + }) + + it('it should handle the FETCH_MEDICATIONS_SUCCESS action', () => { + const expectedMedications = [{ id: '1234' }] + const medicationsStore = medications(undefined, { + type: fetchMedicationsSuccess.type, + payload: expectedMedications, + }) + + expect(medicationsStore.isLoading).toBeFalsy() + expect(medicationsStore.medications).toEqual(expectedMedications) + }) + }) + + describe('searchMedications', () => { + it('should dispatch the FETCH_MEDICATIONS_START action', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + + await searchMedications('search string', 'all')(dispatch, getState, null) + + expect(dispatch).toHaveBeenCalledWith({ type: fetchMedicationsStart.type }) + }) + + it('should call the MedicationRepository search method with the correct search criteria', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + jest.spyOn(MedicationRepository, 'search') + + await searchMedications(expectedSearchObject.text, expectedSearchObject.status)( + dispatch, + getState, + null, + ) + + expect(MedicationRepository.search).toHaveBeenCalledWith(expectedSearchObject) + }) + + it('should call the MedicationRepository findAll method if there is no string text and status is set to all', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + jest.spyOn(MedicationRepository, 'findAll') + + await searchMedications('', expectedSearchObject.status)(dispatch, getState, null) + + expect(MedicationRepository.findAll).toHaveBeenCalledTimes(1) + }) + + it('should dispatch the FETCH_MEDICATIONS_SUCCESS action', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + + const expectedMedications = [ + { + type: 'text', + }, + ] as Medication[] + + const mockedMedicationRepository = mocked(MedicationRepository, true) + mockedMedicationRepository.search.mockResolvedValue(expectedMedications) + + await searchMedications(expectedSearchObject.text, expectedSearchObject.status)( + dispatch, + getState, + null, + ) + + expect(dispatch).toHaveBeenLastCalledWith({ + type: fetchMedicationsSuccess.type, + payload: expectedMedications, + }) + }) + }) + + describe('sort Request', () => { + it('should have called findAll with sort request in searchMedications method', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + jest.spyOn(MedicationRepository, 'findAll') + + await searchMedications('', expectedSearchObject.status)(dispatch, getState, null) + + expect(MedicationRepository.findAll).toHaveBeenCalledWith( + expectedSearchObject.defaultSortRequest, + ) + }) + + it('should include sorts in the search criteria', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + jest.spyOn(MedicationRepository, 'search') + + await searchMedications(expectedSearchObject.text, expectedSearchObject.status)( + dispatch, + getState, + null, + ) + + expect(MedicationRepository.search).toHaveBeenCalledWith(expectedSearchObject) + }) + }) +}) diff --git a/src/__tests__/medications/requests/NewMedicationRequest.test.tsx b/src/__tests__/medications/requests/NewMedicationRequest.test.tsx new file mode 100644 index 0000000000..a4067013ea --- /dev/null +++ b/src/__tests__/medications/requests/NewMedicationRequest.test.tsx @@ -0,0 +1,219 @@ +import { Button, Typeahead, Label } from '@hospitalrun/components' +import { mount, ReactWrapper } from 'enzyme' +import { createMemoryHistory } from 'history' +import React from 'react' +import { act } from 'react-dom/test-utils' +import { Provider } from 'react-redux' +import { Router } from 'react-router-dom' +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import NewMedicationRequest from '../../../medications/requests/NewMedicationRequest' +import * as titleUtil from '../../../page-header/title/useTitle' +import TextFieldWithLabelFormGroup from '../../../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../../../shared/components/input/TextInputWithLabelFormGroup' +import MedicationRepository from '../../../shared/db/MedicationRepository' +import PatientRepository from '../../../shared/db/PatientRepository' +import Medication from '../../../shared/model/Medication' +import Patient from '../../../shared/model/Patient' +import { RootState } from '../../../shared/store' + +const mockStore = createMockStore([thunk]) + +describe('New Medication Request', () => { + describe('title and breadcrumbs', () => { + let titleSpy: any + const history = createMemoryHistory() + + beforeEach(() => { + const store = mockStore({ title: '', medication: { status: 'loading', error: {} } } as any) + titleSpy = jest.spyOn(titleUtil, 'default') + history.push('/medications/new') + + mount( + + + + + , + ) + }) + + it('should have New Medication Request as the title', () => { + expect(titleSpy).toHaveBeenCalledWith('medications.requests.new') + }) + }) + + describe('form layout', () => { + let wrapper: ReactWrapper + const history = createMemoryHistory() + + beforeEach(() => { + const store = mockStore({ title: '', medication: { status: 'loading', error: {} } } as any) + history.push('/medications/new') + + wrapper = mount( + + + + + , + ) + }) + + it('should render a patient typeahead', () => { + const typeaheadDiv = wrapper.find('.patient-typeahead') + + expect(typeaheadDiv).toBeDefined() + + const label = typeaheadDiv.find(Label) + const typeahead = typeaheadDiv.find(Typeahead) + + expect(label).toBeDefined() + expect(label.prop('text')).toEqual('medications.medication.patient') + expect(typeahead).toBeDefined() + expect(typeahead.prop('placeholder')).toEqual('medications.medication.patient') + expect(typeahead.prop('searchAccessor')).toEqual('fullName') + }) + + it('should render a medication input box', () => { + const typeInputBox = wrapper.find(TextInputWithLabelFormGroup).at(0) + + expect(typeInputBox).toBeDefined() + expect(typeInputBox.prop('label')).toEqual('medications.medication.medication') + expect(typeInputBox.prop('isRequired')).toBeTruthy() + expect(typeInputBox.prop('isEditable')).toBeTruthy() + }) + + it('should render a notes text field', () => { + const notesTextField = wrapper.find(TextFieldWithLabelFormGroup) + + expect(notesTextField).toBeDefined() + expect(notesTextField.prop('label')).toEqual('medications.medication.notes') + expect(notesTextField.prop('isRequired')).toBeFalsy() + expect(notesTextField.prop('isEditable')).toBeTruthy() + }) + + it('should render a save button', () => { + const saveButton = wrapper.find(Button).at(0) + expect(saveButton).toBeDefined() + expect(saveButton.text().trim()).toEqual('actions.save') + }) + + it('should render a cancel button', () => { + const cancelButton = wrapper.find(Button).at(1) + expect(cancelButton).toBeDefined() + expect(cancelButton.text().trim()).toEqual('actions.cancel') + }) + }) + + describe('on cancel', () => { + let wrapper: ReactWrapper + const history = createMemoryHistory() + + beforeEach(() => { + history.push('/medications/new') + const store = mockStore({ title: '', medication: { status: 'loading', error: {} } } as any) + wrapper = mount( + + + + + , + ) + }) + + it('should navigate back to /medications', () => { + const cancelButton = wrapper.find(Button).at(1) + + act(() => { + const onClick = cancelButton.prop('onClick') as any + onClick({} as React.MouseEvent) + }) + + expect(history.location.pathname).toEqual('/medications') + }) + }) + + describe('on save', () => { + let wrapper: ReactWrapper + const history = createMemoryHistory() + let medicationRepositorySaveSpy: any + const expectedDate = new Date() + const expectedMedication = { + patient: '12345', + medication: 'expected medication', + status: 'draft', + notes: 'expected notes', + id: '1234', + requestedOn: expectedDate.toISOString(), + } as Medication + + beforeEach(() => { + jest.resetAllMocks() + Date.now = jest.fn(() => expectedDate.valueOf()) + medicationRepositorySaveSpy = jest + .spyOn(MedicationRepository, 'save') + .mockResolvedValue(expectedMedication as Medication) + + jest + .spyOn(PatientRepository, 'search') + .mockResolvedValue([ + { id: expectedMedication.patient, fullName: 'some full name' }, + ] as Patient[]) + + history.push('/medications/new') + const store = mockStore({ + title: '', + medication: { status: 'loading', error: {} }, + user: { user: { id: 'fake id' } }, + } as any) + wrapper = mount( + + + + + , + ) + }) + + it('should save the medication request and navigate to "/medications/:id"', async () => { + const patientTypeahead = wrapper.find(Typeahead) + await act(async () => { + const onChange = patientTypeahead.prop('onChange') + await onChange([{ id: expectedMedication.patient }] as Patient[]) + }) + + const medicationInput = wrapper.find(TextInputWithLabelFormGroup).at(0) + act(() => { + const onChange = medicationInput.prop('onChange') as any + onChange({ currentTarget: { value: expectedMedication.medication } }) + }) + + const notesTextField = wrapper.find(TextFieldWithLabelFormGroup) + act(() => { + const onChange = notesTextField.prop('onChange') as any + onChange({ currentTarget: { value: expectedMedication.notes } }) + }) + wrapper.update() + + const saveButton = wrapper.find(Button).at(0) + await act(async () => { + const onClick = saveButton.prop('onClick') as any + await onClick() + }) + + expect(medicationRepositorySaveSpy).toHaveBeenCalledTimes(1) + expect(medicationRepositorySaveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + patient: expectedMedication.patient, + medication: expectedMedication.medication, + notes: expectedMedication.notes, + status: 'draft', + requestedOn: expectedDate.toISOString(), + }), + ) + expect(history.location.pathname).toEqual(`/medications/${expectedMedication.id}`) + }) + }) +}) diff --git a/src/medications/MedicationOptionFields.tsx b/src/medications/MedicationOptionFields.tsx new file mode 100644 index 0000000000..fbd2e9e70b --- /dev/null +++ b/src/medications/MedicationOptionFields.tsx @@ -0,0 +1,47 @@ +import { Option } from '../shared/components/input/SelectWithLableFormGroup' +import useTranslator from '../shared/hooks/useTranslator' + +const MedicationFieldsOptions = () => { + const { t } = useTranslator() + + const statusOptionsNew: Option[] = [ + { label: t('medications.status.draft'), value: 'draft' }, + { label: t('medications.status.active'), value: 'active' }, + ] + + const statusOptionsEdit: Option[] = [ + { label: t('medications.status.active'), value: 'active' }, + { label: t('medications.status.onHold'), value: 'on hold' }, + { label: t('medications.status.completed'), value: 'completed' }, + { label: t('medications.status.enteredInError'), value: 'entered in error' }, + { label: t('medications.status.canceled'), value: 'canceled' }, + { label: t('medications.status.unknown'), value: 'unknown' }, + ] + + const intentOptions: Option[] = [ + { label: t('medications.intent.proposal'), value: 'proposal' }, + { label: t('medications.intent.plan'), value: 'plan' }, + { label: t('medications.intent.order'), value: 'order' }, + { label: t('medications.intent.originalOrder'), value: 'original order' }, + { label: t('medications.intent.reflexOrder'), value: 'reflex order' }, + { label: t('medications.intent.fillerOrder'), value: 'filler order' }, + { label: t('medications.intent.instanceOrder'), value: 'instance order' }, + { label: t('medications.intent.option'), value: 'option' }, + ] + + const priorityOptions: Option[] = [ + { label: t('medications.priority.routine'), value: 'routine' }, + { label: t('medications.priority.urgent'), value: 'urgent' }, + { label: t('medications.priority.asap'), value: 'asap' }, + { label: t('medications.priority.stat'), value: 'stat' }, + ] + + return { + statusNew: statusOptionsNew, + statusEdit: statusOptionsEdit, + intent: intentOptions, + priority: priorityOptions, + } +} + +export default MedicationFieldsOptions diff --git a/src/medications/Medications.tsx b/src/medications/Medications.tsx new file mode 100644 index 0000000000..c2f7ba5fcb --- /dev/null +++ b/src/medications/Medications.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { Switch } from 'react-router-dom' + +import useAddBreadcrumbs from '../page-header/breadcrumbs/useAddBreadcrumbs' +import PrivateRoute from '../shared/components/PrivateRoute' +import Permissions from '../shared/model/Permissions' +import { RootState } from '../shared/store' +import NewMedicationRequest from './requests/NewMedicationRequest' +import ViewMedication from './ViewMedication' +import MedicationRequests from './ViewMedications' + +const Medications = () => { + const { permissions } = useSelector((state: RootState) => state.user) + const breadcrumbs = [ + { + i18nKey: 'medications.label', + location: `/medications`, + }, + ] + useAddBreadcrumbs(breadcrumbs, true) + + return ( + + + + + + ) +} + +export default Medications diff --git a/src/medications/ViewMedication.tsx b/src/medications/ViewMedication.tsx new file mode 100644 index 0000000000..4aa91ec2ee --- /dev/null +++ b/src/medications/ViewMedication.tsx @@ -0,0 +1,360 @@ +import { Row, Column, Badge, Button, Alert } from '@hospitalrun/components' +import format from 'date-fns/format' +import React, { useEffect, useState } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useParams, useHistory } from 'react-router-dom' + +import useAddBreadcrumbs from '../page-header/breadcrumbs/useAddBreadcrumbs' +import useTitle from '../page-header/title/useTitle' +import SelectWithLabelFormGroup, { + Option, +} from '../shared/components/input/SelectWithLableFormGroup' +import TextFieldWithLabelFormGroup from '../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../shared/components/input/TextInputWithLabelFormGroup' +import useTranslator from '../shared/hooks/useTranslator' +import Medication from '../shared/model/Medication' +import Patient from '../shared/model/Patient' +import Permissions from '../shared/model/Permissions' +import { RootState } from '../shared/store' +import { + cancelMedication, + completeMedication, + updateMedication, + fetchMedication, +} from './medication-slice' + +const getTitle = (patient: Patient | undefined, medication: Medication | undefined) => + patient && medication ? `${medication.medication} for ${patient.fullName}` : '' + +const ViewMedication = () => { + const { id } = useParams() + const { t } = useTranslator() + const history = useHistory() + const dispatch = useDispatch() + const { permissions } = useSelector((state: RootState) => state.user) + const { medication, patient, status, error } = useSelector((state: RootState) => state.medication) + + const [medicationToView, setMedicationToView] = useState() + const [isEditable, setIsEditable] = useState(true) + + useTitle(getTitle(patient, medicationToView)) + + const breadcrumbs = [ + { + i18nKey: 'medications.requests.view', + location: `/medications/${medicationToView?.id}`, + }, + ] + useAddBreadcrumbs(breadcrumbs) + + useEffect(() => { + if (id) { + dispatch(fetchMedication(id)) + } + }, [id, dispatch]) + + useEffect(() => { + if (medication) { + setMedicationToView({ ...medication }) + setIsEditable(medication.status !== 'completed') + } + }, [medication]) + + const statusOptionsEdit: Option[] = [ + { label: t('medications.status.draft'), value: 'draft' }, + { label: t('medications.status.active'), value: 'active' }, + { label: t('medications.status.onHold'), value: 'on hold' }, + { label: t('medications.status.completed'), value: 'completed' }, + { label: t('medications.status.enteredInError'), value: 'entered in error' }, + { label: t('medications.status.canceled'), value: 'canceled' }, + { label: t('medications.status.unknown'), value: 'unknown' }, + ] + + const intentOptions: Option[] = [ + { label: t('medications.intent.proposal'), value: 'proposal' }, + { label: t('medications.intent.plan'), value: 'plan' }, + { label: t('medications.intent.order'), value: 'order' }, + { label: t('medications.intent.originalOrder'), value: 'original order' }, + { label: t('medications.intent.reflexOrder'), value: 'reflex order' }, + { label: t('medications.intent.fillerOrder'), value: 'filler order' }, + { label: t('medications.intent.instanceOrder'), value: 'instance order' }, + { label: t('medications.intent.option'), value: 'option' }, + ] + + const priorityOptions: Option[] = [ + { label: t('medications.priority.routine'), value: 'routine' }, + { label: t('medications.priority.urgent'), value: 'urgent' }, + { label: t('medications.priority.asap'), value: 'asap' }, + { label: t('medications.priority.stat'), value: 'stat' }, + ] + + const onQuantityChange = (text: string, name: string) => { + const newMedication = medicationToView as Medication + setMedicationToView({ ...newMedication, quantity: { ...newMedication.quantity, [name]: text } }) + } + + const onFieldChange = (key: string, value: string | boolean) => { + const newMedication = medicationToView as Medication + setMedicationToView({ ...newMedication, [key]: value }) + } + + const onNotesChange = (event: React.ChangeEvent) => { + const notes = event.currentTarget.value + onFieldChange('notes', notes) + } + + const onUpdate = async () => { + const onSuccess = () => { + history.push('/medications') + } + if (medicationToView) { + dispatch(updateMedication(medicationToView, onSuccess)) + } + } + + const onComplete = async () => { + const onSuccess = () => { + history.push('/medications') + } + + if (medicationToView) { + dispatch(completeMedication(medicationToView, onSuccess)) + } + } + + const onCancel = async () => { + const onSuccess = () => { + history.push('/medications') + } + + if (medicationToView) { + dispatch(cancelMedication(medicationToView, onSuccess)) + } + } + + const getButtons = () => { + const buttons: React.ReactNode[] = [] + if (medicationToView?.status === 'completed' || medicationToView?.status === 'canceled') { + return buttons + } + + buttons.push( + , + ) + + if (permissions.includes(Permissions.CompleteMedication)) { + buttons.push( + , + ) + } + + if (permissions.includes(Permissions.CancelMedication)) { + buttons.push( + , + ) + } + + return buttons + } + + if (medicationToView && patient) { + const getBadgeColor = () => { + if (medicationToView.status === 'completed') { + return 'primary' + } + if (medicationToView.status === 'canceled') { + return 'danger' + } + return 'warning' + } + + const getCanceledOnOrCompletedOnDate = () => { + if (medicationToView.status === 'completed' && medicationToView.completedOn) { + return ( + +
+

{t('medications.medication.completedOn')}

+
{format(new Date(medicationToView.completedOn), 'yyyy-MM-dd hh:mm a')}
+
+
+ ) + } + if (medicationToView.status === 'canceled' && medicationToView.canceledOn) { + return ( + +
+

{t('medications.medication.canceledOn')}

+
{format(new Date(medicationToView.canceledOn), 'yyyy-MM-dd hh:mm a')}
+
+
+ ) + } + return <> + } + + return ( + <> + {status === 'error' && ( + + )} + + +
+

{t('medications.medication.status')}

+ +
{medicationToView.status}
+
+
+
+ +
+

{t('medications.medication.medication')}

+
{medicationToView.medication}
+
+
+ +
+

{t('medications.medication.quantity')}

+
{`${medicationToView.quantity.value} x ${medicationToView.quantity.unit}`}
+
+
+ +
+

{t('medications.medication.for')}

+
{patient.fullName}
+
+
+ +
+

{t('medications.medication.requestedOn')}

+
{format(new Date(medicationToView.requestedOn), 'yyyy-MM-dd hh:mm a')}
+
+
+ {getCanceledOnOrCompletedOnDate()} +
+ + +
+

{t('medications.medication.intent')}

+ +
{medicationToView.intent}
+
+
+
+ +
+

{t('medications.medication.priority')}

+ +
{medicationToView.priority}
+
+
+
+
+
+ + + value === medicationToView.status, + )} + onChange={(values) => onFieldChange && onFieldChange('status', values[0])} + isEditable={isEditable} + /> + + + value === medicationToView.intent, + )} + onChange={(values) => onFieldChange && onFieldChange('intent', values[0])} + isEditable={isEditable} + /> + + + value === medicationToView.priority, + )} + onChange={(values) => onFieldChange && onFieldChange('priority', values[0])} + isEditable={isEditable} + /> + + + + + onQuantityChange(event.currentTarget.value, 'value')} + isInvalid={!!error?.quantityValue} + feedback={t(error?.quantityValue as string)} + /> + + + onQuantityChange(event.currentTarget.value, 'unit')} + isInvalid={!!error?.quantityUnit} + feedback={t(error?.quantityUnit as string)} + /> + + +
+ + + + + + {isEditable && ( +
+
{getButtons()}
+
+ )} + + + ) + } + return

Loading...

+} + +export default ViewMedication diff --git a/src/medications/ViewMedications.tsx b/src/medications/ViewMedications.tsx new file mode 100644 index 0000000000..35a0f1c95c --- /dev/null +++ b/src/medications/ViewMedications.tsx @@ -0,0 +1,130 @@ +import { Button, Table } from '@hospitalrun/components' +import format from 'date-fns/format' +import React, { useState, useEffect, useCallback } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { useHistory } from 'react-router-dom' + +import { useButtonToolbarSetter } from '../page-header/button-toolbar/ButtonBarProvider' +import useTitle from '../page-header/title/useTitle' +import SelectWithLabelFormGroup, { + Option, +} from '../shared/components/input/SelectWithLableFormGroup' +import TextInputWithLabelFormGroup from '../shared/components/input/TextInputWithLabelFormGroup' +import useDebounce from '../shared/hooks/useDebounce' +import useTranslator from '../shared/hooks/useTranslator' +import Medication from '../shared/model/Medication' +import Permissions from '../shared/model/Permissions' +import { RootState } from '../shared/store' +import { searchMedications } from './medications-slice' + +type MedicationFilter = 'draft' | 'completed' | 'canceled' | 'all' + +const ViewMedications = () => { + const { t } = useTranslator() + const history = useHistory() + const setButtons = useButtonToolbarSetter() + useTitle(t('medications.label')) + + const { permissions } = useSelector((state: RootState) => state.user) + const dispatch = useDispatch() + const { medications } = useSelector((state: RootState) => state.medications) + const [searchFilter, setSearchFilter] = useState('all') + const [searchText, setSearchText] = useState('') + const debouncedSearchText = useDebounce(searchText, 500) + + const getButtons = useCallback(() => { + const buttons: React.ReactNode[] = [] + + if (permissions.includes(Permissions.RequestMedication)) { + buttons.push( + , + ) + } + + return buttons + }, [permissions, history, t]) + + useEffect(() => { + dispatch(searchMedications(debouncedSearchText, searchFilter)) + }, [dispatch, debouncedSearchText, searchFilter]) + + useEffect(() => { + setButtons(getButtons()) + return () => { + setButtons([]) + } + }, [dispatch, getButtons, setButtons]) + + const onViewClick = (medication: Medication) => { + history.push(`/medications/${medication.id}`) + } + + const onSearchBoxChange = (event: React.ChangeEvent) => { + setSearchText(event.target.value) + } + + const filterOptions: Option[] = [ + // TODO: add other statuses + { label: t('medications.status.draft'), value: 'draft' }, + { label: t('medications.status.completed'), value: 'completed' }, + { label: t('medications.status.canceled'), value: 'canceled' }, + { label: t('medications.filter.all'), value: 'all' }, + ] + + return ( + <> +
+
+ value === searchFilter)} + onChange={(values) => setSearchFilter(values[0] as MedicationFilter)} + isEditable + /> +
+
+ +
+
+
+
row.id} + columns={[ + { label: t('medications.medication.medication'), key: 'medication' }, + { label: t('medications.medication.priority'), key: 'priority' }, + { label: t('medications.medication.intent'), key: 'intent' }, + { + label: t('medications.medication.requestedOn'), + key: 'requestedOn', + formatter: (row) => + row.requestedOn ? format(new Date(row.requestedOn), 'yyyy-MM-dd hh:mm a') : '', + }, + { label: t('medications.medication.status'), key: 'status' }, + ]} + data={medications} + actionsHeaderText={t('actions.label')} + actions={[{ label: t('actions.view'), action: (row) => onViewClick(row as Medication) }]} + /> + + + ) +} + +export default ViewMedications diff --git a/src/medications/medication-slice.ts b/src/medications/medication-slice.ts new file mode 100644 index 0000000000..6d4c7749df --- /dev/null +++ b/src/medications/medication-slice.ts @@ -0,0 +1,192 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import MedicationRepository from '../shared/db/MedicationRepository' +import PatientRepository from '../shared/db/PatientRepository' +import Medication from '../shared/model/Medication' +import Patient from '../shared/model/Patient' +import { AppThunk } from '../shared/store' + +interface Error { + medication?: string + patient?: string + quantity?: string + quantityValue?: string + quantityUnit?: string + message?: string +} + +interface MedicationState { + error: Error + medication?: Medication + patient?: Patient + status: 'loading' | 'error' | 'completed' +} + +const initialState: MedicationState = { + error: {}, + medication: undefined, + patient: undefined, + status: 'loading', +} + +function start(state: MedicationState) { + state.status = 'loading' +} + +function finish(state: MedicationState, { payload }: PayloadAction) { + state.status = 'completed' + state.medication = payload + state.error = {} +} + +function error(state: MedicationState, { payload }: PayloadAction) { + state.status = 'error' + state.error = payload +} + +const medicationSlice = createSlice({ + name: 'medication', + initialState, + reducers: { + fetchMedicationStart: start, + fetchMedicationSuccess: ( + state: MedicationState, + { payload }: PayloadAction<{ medication: Medication; patient: Patient }>, + ) => { + state.status = 'completed' + state.medication = payload.medication + state.patient = payload.patient + }, + updateMedicationStart: start, + updateMedicationSuccess: finish, + requestMedicationStart: start, + requestMedicationSuccess: finish, + requestMedicationError: error, + cancelMedicationStart: start, + cancelMedicationSuccess: finish, + completeMedicationStart: start, + completeMedicationSuccess: finish, + completeMedicationError: error, + }, +}) + +export const { + fetchMedicationStart, + fetchMedicationSuccess, + updateMedicationStart, + updateMedicationSuccess, + requestMedicationStart, + requestMedicationSuccess, + requestMedicationError, + cancelMedicationStart, + cancelMedicationSuccess, + completeMedicationStart, + completeMedicationSuccess, + completeMedicationError, +} = medicationSlice.actions + +export const fetchMedication = (medicationId: string): AppThunk => async (dispatch) => { + dispatch(fetchMedicationStart()) + const fetchedMedication = await MedicationRepository.find(medicationId) + const fetchedPatient = await PatientRepository.find(fetchedMedication.patient) + dispatch(fetchMedicationSuccess({ medication: fetchedMedication, patient: fetchedPatient })) +} + +const validateMedicationRequest = (newMedication: Medication): Error => { + const medicationRequestError: Error = {} + if (!newMedication.patient) { + medicationRequestError.patient = 'medications.requests.error.patientRequired' + } + + if (!newMedication.quantity) { + medicationRequestError.quantity = 'medications.requests.error.quantityRequired' + } + + return medicationRequestError +} + +export const requestMedication = ( + newMedication: Medication, + onSuccess?: (medication: Medication) => void, +): AppThunk => async (dispatch, getState) => { + dispatch(requestMedicationStart()) + + const medicationRequestError = validateMedicationRequest(newMedication) + if (Object.keys(medicationRequestError).length > 0) { + medicationRequestError.message = 'medications.requests.error.unableToRequest' + dispatch(requestMedicationError(medicationRequestError)) + } else { + newMedication.status = 'draft' + newMedication.requestedOn = new Date(Date.now().valueOf()).toISOString() + newMedication.requestedBy = getState().user?.user?.id || '' + const requestedMedication = await MedicationRepository.save(newMedication) + dispatch(requestMedicationSuccess(requestedMedication)) + + if (onSuccess) { + onSuccess(requestedMedication) + } + } +} + +export const cancelMedication = ( + medicationToCancel: Medication, + onSuccess?: (medication: Medication) => void, +): AppThunk => async (dispatch) => { + dispatch(cancelMedicationStart()) + + medicationToCancel.canceledOn = new Date(Date.now().valueOf()).toISOString() + medicationToCancel.status = 'canceled' + const canceledMedication = await MedicationRepository.saveOrUpdate(medicationToCancel) + dispatch(cancelMedicationSuccess(canceledMedication)) + + if (onSuccess) { + onSuccess(canceledMedication) + } +} + +const validateCompleteMedication = (medicationToComplete: Medication): Error => { + const completeError: Error = {} + + if (!medicationToComplete.quantity) { + completeError.quantity = 'medications.requests.error.quantityRequiredToComplete' + } + + return completeError +} + +export const completeMedication = ( + medicationToComplete: Medication, + onSuccess?: (medication: Medication) => void, +): AppThunk => async (dispatch) => { + dispatch(completeMedicationStart()) + + const completeMedicationErrors = validateCompleteMedication(medicationToComplete) + if (Object.keys(completeMedicationErrors).length > 0) { + completeMedicationErrors.message = 'medications.requests.error.unableToComplete' + dispatch(completeMedicationError(completeMedicationErrors)) + } else { + medicationToComplete.completedOn = new Date(Date.now().valueOf()).toISOString() + medicationToComplete.status = 'completed' + const completedMedication = await MedicationRepository.saveOrUpdate(medicationToComplete) + dispatch(completeMedicationSuccess(completedMedication)) + + if (onSuccess) { + onSuccess(completedMedication) + } + } +} + +export const updateMedication = ( + medicationToUpdate: Medication, + onSuccess?: (medication: Medication) => void, +): AppThunk => async (dispatch) => { + dispatch(updateMedicationStart()) + const updatedMedication = await MedicationRepository.saveOrUpdate(medicationToUpdate) + dispatch(updateMedicationSuccess(updatedMedication)) + + if (onSuccess) { + onSuccess(updatedMedication) + } +} + +export default medicationSlice.reducer diff --git a/src/medications/medications-slice.ts b/src/medications/medications-slice.ts new file mode 100644 index 0000000000..40a2fba226 --- /dev/null +++ b/src/medications/medications-slice.ts @@ -0,0 +1,75 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import MedicationRepository from '../shared/db/MedicationRepository' +import SortRequest from '../shared/db/SortRequest' +import Medication from '../shared/model/Medication' +import { AppThunk } from '../shared/store' + +interface MedicationsState { + isLoading: boolean + medications: Medication[] + statusFilter: status +} + +type status = + | 'draft' + | 'active' + | 'on hold' + | 'canceled' + | 'completed' + | 'entered in error' + | 'stopped' + | 'unknown' + | 'all' + +const defaultSortRequest: SortRequest = { + sorts: [ + { + field: 'requestedOn', + direction: 'desc', + }, + ], +} + +const initialState: MedicationsState = { + isLoading: false, + medications: [], + statusFilter: 'all', +} + +const startLoading = (state: MedicationsState) => { + state.isLoading = true +} + +const medicationsSlice = createSlice({ + name: 'medications', + initialState, + reducers: { + fetchMedicationsStart: startLoading, + fetchMedicationsSuccess(state, { payload }: PayloadAction) { + state.isLoading = false + state.medications = payload + }, + }, +}) +export const { fetchMedicationsStart, fetchMedicationsSuccess } = medicationsSlice.actions + +export const searchMedications = (text: string, status: status): AppThunk => async (dispatch) => { + dispatch(fetchMedicationsStart()) + + let medications + + if (text.trim() === '' && status === initialState.statusFilter) { + medications = await MedicationRepository.findAll(defaultSortRequest) + } else { + medications = await MedicationRepository.search({ + text, + status, + defaultSortRequest, + }) + } + + dispatch(fetchMedicationsSuccess(medications)) +} + +export default medicationsSlice.reducer diff --git a/src/medications/requests/NewMedicationRequest.tsx b/src/medications/requests/NewMedicationRequest.tsx new file mode 100644 index 0000000000..e2726bb3f8 --- /dev/null +++ b/src/medications/requests/NewMedicationRequest.tsx @@ -0,0 +1,239 @@ +import { Typeahead, Label, Button, Alert, Column, Row } from '@hospitalrun/components' +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory } from 'react-router-dom' + +import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' +import useTitle from '../../page-header/title/useTitle' +import SelectWithLabelFormGroup, { + Option, +} from '../../shared/components/input/SelectWithLableFormGroup' +import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' +import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' +import PatientRepository from '../../shared/db/PatientRepository' +import useTranslator from '../../shared/hooks/useTranslator' +import Medication from '../../shared/model/Medication' +import Patient from '../../shared/model/Patient' +import { RootState } from '../../shared/store' +import { requestMedication } from '../medication-slice' + +const NewMedicationRequest = () => { + const { t } = useTranslator() + const dispatch = useDispatch() + const history = useHistory() + useTitle(t('medications.requests.new')) + const { status, error } = useSelector((state: RootState) => state.medication) + + const [newMedicationRequest, setNewMedicationRequest] = useState(({ + patient: '', + medication: '', + notes: '', + status: '', + intent: 'order', + priority: '', + quantity: { value: ('' as unknown) as number, unit: '' }, + } as unknown) as Medication) + + const statusOptionsNew: Option[] = [ + { label: t('medications.status.draft'), value: 'draft' }, + { label: t('medications.status.active'), value: 'active' }, + ] + + const intentOptions: Option[] = [ + { label: t('medications.intent.proposal'), value: 'proposal' }, + { label: t('medications.intent.plan'), value: 'plan' }, + { label: t('medications.intent.order'), value: 'order' }, + { label: t('medications.intent.originalOrder'), value: 'original order' }, + { label: t('medications.intent.reflexOrder'), value: 'reflex order' }, + { label: t('medications.intent.fillerOrder'), value: 'filler order' }, + { label: t('medications.intent.instanceOrder'), value: 'instance order' }, + { label: t('medications.intent.option'), value: 'option' }, + ] + + const priorityOptions: Option[] = [ + { label: t('medications.priority.routine'), value: 'routine' }, + { label: t('medications.priority.urgent'), value: 'urgent' }, + { label: t('medications.priority.asap'), value: 'asap' }, + { label: t('medications.priority.stat'), value: 'stat' }, + ] + + const breadcrumbs = [ + { + i18nKey: 'medications.requests.new', + location: `/medications/new`, + }, + ] + useAddBreadcrumbs(breadcrumbs) + + const onPatientChange = (patient: Patient) => { + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + patient: patient.id, + })) + } + + const onMedicationChange = (event: React.ChangeEvent) => { + const medication = event.currentTarget.value + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + medication, + })) + } + + const onNoteChange = (event: React.ChangeEvent) => { + const notes = event.currentTarget.value + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + notes, + })) + } + + const onFieldChange = (key: string, value: string | boolean) => { + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + [key]: value, + })) + } + + const onTextInputChange = (text: string, name: string) => { + setNewMedicationRequest((previousNewMedicationRequest) => ({ + ...previousNewMedicationRequest, + [name]: text, + })) + } + + const onSave = async () => { + const newMedication = newMedicationRequest as Medication + const onSuccess = (createdMedication: Medication) => { + history.push(`/medications/${createdMedication.id}`) + } + + dispatch(requestMedication(newMedication, onSuccess)) + } + + const onCancel = () => { + history.push('/medications') + } + + return ( + <> + {status === 'error' && ( + + )} +
+
+
+ +
+ value === newMedicationRequest.status, + )} + onChange={(values) => onFieldChange && onFieldChange('status', values[0])} + isEditable + /> +
+
+ value === newMedicationRequest.intent, + )} + onChange={(values) => onFieldChange && onFieldChange('intent', values[0])} + isEditable + /> +
+
+ value === newMedicationRequest.priority, + )} + onChange={(values) => onFieldChange && onFieldChange('priority', values[0])} + isEditable + /> +
+ + + onTextInputChange(event.currentTarget.value, 'quantity.value')} + isInvalid={!!error?.quantityValue} + feedback={t(error?.quantityValue as string)} + /> + + + onTextInputChange(event.currentTarget.value, 'quantity.unit')} + isInvalid={!!error?.quantityUnit} + feedback={t(error?.quantityUnit as string)} + /> + + +
+ +
+
+
+ + +
+
+ + + ) +} + +export default NewMedicationRequest diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index cc48668548..4d90e9c955 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -42,6 +42,8 @@ const Sidebar = () => { ? 'appointment' : splittedPath[1].includes('labs') ? 'labs' + : splittedPath[1].includes('medications') + ? 'medications' : splittedPath[1].includes('incidents') ? 'incidents' : 'none', @@ -247,6 +249,56 @@ const Sidebar = () => { ) + const getMedicationLinks = () => ( + <> + { + navigateTo('/medications') + setExpansion('medications') + }} + className="nav-item" + style={listItemStyle} + > + + {!sidebarCollapsed && t('medications.label')} + + {splittedPath[1].includes('medications') && expandedItem === 'medications' && ( + + {permissions.includes(Permissions.RequestMedication) && ( + navigateTo('/medications/new')} + active={splittedPath[1].includes('medications') && splittedPath.length > 2} + > + + {!sidebarCollapsed && t('medications.requests.new')} + + )} + {permissions.includes(Permissions.ViewMedications) && ( + navigateTo('/medications')} + active={splittedPath[1].includes('medications') && splittedPath.length < 3} + > + + {!sidebarCollapsed && t('medications.requests.label')} + + )} + + )} + + ) + const getIncidentLinks = () => ( <> { {getAppointmentLinks()} {getLabLinks()} {getIncidentLinks()} + {getMedicationLinks()} diff --git a/src/shared/components/navbar/pageMap.tsx b/src/shared/components/navbar/pageMap.tsx index 0c5564eafc..5dc9f2d3e1 100644 --- a/src/shared/components/navbar/pageMap.tsx +++ b/src/shared/components/navbar/pageMap.tsx @@ -47,6 +47,18 @@ const pageMap: { path: '/labs', icon: 'lab', }, + newMedication: { + permission: Permissions.RequestMedication, + label: 'medications.requests.new', + path: '/medications/new', + icon: 'add', + }, + viewMedications: { + permission: Permissions.ViewMedications, + label: 'medications.requests.label', + path: '/medications', + icon: 'lab', + }, newIncident: { permission: Permissions.ReportIncident, label: 'incidents.reports.new', diff --git a/src/shared/config/pouchdb.ts b/src/shared/config/pouchdb.ts index 848f237c44..cb25ade41a 100644 --- a/src/shared/config/pouchdb.ts +++ b/src/shared/config/pouchdb.ts @@ -45,6 +45,9 @@ export const schema = [ hasMany: { type: 'appointment', options: { queryInverse: 'patient', async: true } }, }, labs: { hasMany: { type: 'lab', options: { queryInverse: 'patient', async: true } } }, + medications: { + hasMany: { type: 'medication', options: { queryInverse: 'patient', async: true } }, + }, }, }, { @@ -61,6 +64,11 @@ export const schema = [ plural: 'labs', relations: { patient: { belongsTo: 'patient' } }, }, + { + singular: 'medication', + plural: 'medications', + relations: { patient: { belongsTo: 'patient' } }, + }, ] export const relationalDb = localDb.setSchema(schema) export const remoteDb = serverDb as PouchDB.Database diff --git a/src/shared/db/MedicationRepository.ts b/src/shared/db/MedicationRepository.ts new file mode 100644 index 0000000000..7d9a09573a --- /dev/null +++ b/src/shared/db/MedicationRepository.ts @@ -0,0 +1,66 @@ +import { relationalDb } from '../config/pouchdb' +import Medication from '../model/Medication' +import Repository from './Repository' +import SortRequest from './SortRequest' + +interface SearchContainer { + text: string + status: + | 'draft' + | 'active' + | 'on hold' + | 'canceled' + | 'completed' + | 'entered in error' + | 'stopped' + | 'unknown' + | 'all' + defaultSortRequest: SortRequest +} +class MedicationRepository extends Repository { + constructor() { + super('medication', relationalDb) + } + + async search(container: SearchContainer): Promise { + const searchValue = { $regex: RegExp(container.text, 'i') } + const selector = { + $and: [ + { + $or: [ + { + 'data.type': searchValue, + }, + { + 'data.code': searchValue, + }, + ], + }, + ...(container.status !== 'all' ? [{ 'data.status': container.status }] : [undefined]), + ].filter((x) => x !== undefined), + sorts: container.defaultSortRequest, + } + + return super.search({ + selector, + }) + } + + async save(entity: Medication): Promise { + return super.save(entity) + } + + async findAllByPatientId(patientId: string): Promise { + return super.search({ + selector: { + $and: [ + { + patientId, + }, + ], + }, + }) + } +} + +export default new MedicationRepository() diff --git a/src/shared/locales/enUs/translations/index.ts b/src/shared/locales/enUs/translations/index.ts index ec5eee0dd3..38ad45b82c 100644 --- a/src/shared/locales/enUs/translations/index.ts +++ b/src/shared/locales/enUs/translations/index.ts @@ -3,6 +3,7 @@ import bloodType from './blood-type' import dashboard from './dashboard' import incidents from './incidents' import labs from './labs' +import medications from './medications' import networkStatus from './network-status' import patient from './patient' import patients from './patients' @@ -22,6 +23,7 @@ export default { ...states, ...sex, ...labs, + ...medications, ...incidents, ...settings, ...user, diff --git a/src/shared/locales/enUs/translations/medications/index.ts b/src/shared/locales/enUs/translations/medications/index.ts new file mode 100644 index 0000000000..884a2732e4 --- /dev/null +++ b/src/shared/locales/enUs/translations/medications/index.ts @@ -0,0 +1,65 @@ +export default { + medications: { + label: 'Medications', + filterTitle: 'Filter by status', + search: 'Search Medications', + status: { + draft: 'Draft', + active: 'Active', + onHold: 'On Hold', + cancelled: 'Cancelled', + completed: 'Completed', + enteredInError: 'Entered In Error', + stopped: 'Stopped', + unknown: 'Unknown', + }, + intent: { + proposal: 'Proposal', + plan: 'Plan', + order: 'Order', + originalOrder: 'Original Order', + reflexOrder: 'Reflex Order', + fillerOrder: 'Filler Order', + instanceOrder: 'Instance Order', + option: 'Option', + }, + priority: { + routine: 'Routine', + urgent: 'Urgent', + asap: 'Asap', + stat: 'Stat', + }, + filter: { + all: 'All statuses', + }, + requests: { + label: 'Medication Requests', + new: 'Request Medication', + view: 'View Medication', + cancel: 'Cancel Medication', + complete: 'Complete Medication', + error: { + unableToRequest: 'Unable to create Medication request.', + unableToComplete: 'Unable to complete Medication request.', + quantityRequired: 'Quantity is required.', + unitRequired: 'Unit is required.', + }, + }, + medication: { + medication: 'Medication', + for: 'For', + status: 'Status', + intent: 'Intent', + priority: 'Priority', + notes: 'Notes', + quantity: 'Quantity', + quantityValue: 'Value', + quantityUnit: 'Unit', + requestedOn: 'Requested On', + requestedBy: 'Requested By', + completedOn: 'Completed On', + canceledOn: 'Canceled On', + patient: 'Patient', + }, + }, +} diff --git a/src/shared/model/Medication.ts b/src/shared/model/Medication.ts new file mode 100644 index 0000000000..d9dcb0ab1f --- /dev/null +++ b/src/shared/model/Medication.ts @@ -0,0 +1,31 @@ +import AbstractDBModel from './AbstractDBModel' + +export default interface Medication extends AbstractDBModel { + requestedBy: string + requestedOn: string + completedOn: string + canceledOn: string + medication: string + status: + | 'draft' + | 'active' + | 'on hold' + | 'canceled' + | 'completed' + | 'entered in error' + | 'stopped' + | 'unknown' + intent: + | 'proposal' + | 'plan' + | 'order' + | 'original order' + | 'reflex order' + | 'filler order' + | 'instance order' + | 'option' + priority: 'routine' | 'urgent' | 'asap' | 'stat' + patient: string + notes: string + quantity: { value: number; unit: string } +} diff --git a/src/shared/model/Permissions.ts b/src/shared/model/Permissions.ts index f9c6d2ac1f..2366d1f5dc 100644 --- a/src/shared/model/Permissions.ts +++ b/src/shared/model/Permissions.ts @@ -17,6 +17,11 @@ enum Permissions { ResolveIncident = 'resolve:incident', AddCarePlan = 'write:care_plan', ReadCarePlan = 'read:care_plan', + RequestMedication = 'write:medications', + CancelMedication = 'cancel:medication', + CompleteMedication = 'complete:medication', + ViewMedication = 'read:medication', + ViewMedications = 'read:medications', } export default Permissions diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index 4ac0fa9576..e768516203 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -5,6 +5,8 @@ import incident from '../../incidents/incident-slice' import incidents from '../../incidents/incidents-slice' import lab from '../../labs/lab-slice' import labs from '../../labs/labs-slice' +import medication from '../../medications/medication-slice' +import medications from '../../medications/medications-slice' import breadcrumbs from '../../page-header/breadcrumbs/breadcrumbs-slice' import title from '../../page-header/title/title-slice' import patient from '../../patients/patient-slice' @@ -27,6 +29,8 @@ const reducer = combineReducers({ incident, incidents, labs, + medication, + medications, }) const store = configureStore({ diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index 660df6262a..4734a344bf 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -38,6 +38,11 @@ const initialState: UserState = { Permissions.ResolveIncident, Permissions.AddCarePlan, Permissions.ReadCarePlan, + Permissions.RequestMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + Permissions.ViewMedications, + Permissions.ViewMedication, ], } From d950e504bc0fecd5d58d79e8780e0dcb05454dcd Mon Sep 17 00:00:00 2001 From: blestab Date: Sun, 26 Jul 2020 22:55:24 +0200 Subject: [PATCH 05/16] feat(medications): implement basic medication module --- .../shared/components/Sidebar.test.tsx | 114 ++++++++++++++++++ src/shared/components/Sidebar.tsx | 2 +- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/__tests__/shared/components/Sidebar.test.tsx b/src/__tests__/shared/components/Sidebar.test.tsx index 34e78818fd..dc58e46da4 100644 --- a/src/__tests__/shared/components/Sidebar.test.tsx +++ b/src/__tests__/shared/components/Sidebar.test.tsx @@ -29,6 +29,11 @@ describe('Sidebar', () => { Permissions.CompleteLab, Permissions.ViewLab, Permissions.ViewLabs, + Permissions.RequestMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + Permissions.ViewMedications, + Permissions.ViewMedication, Permissions.ViewIncidents, Permissions.ViewIncident, Permissions.ReportIncident, @@ -532,4 +537,113 @@ describe('Sidebar', () => { expect(history.location.pathname).toEqual('/incidents') }) }) + + describe('medications links', () => { + it('should render the main medications link', () => { + const wrapper = setup('/medications') + + const listItems = wrapper.find(ListItem) + + expect(listItems.at(6).text().trim()).toEqual('medications.label') + }) + + it('should render the new medications request link', () => { + const wrapper = setup('/medications') + + const listItems = wrapper.find(ListItem) + + expect(listItems.at(7).text().trim()).toEqual('medications.requests.new') + }) + + it('should not render the new medications request link when user does not have request medications privileges', () => { + const wrapper = setupNoPermissions('/medications') + + const listItems = wrapper.find(ListItem) + + listItems.forEach((_, i) => { + expect(listItems.at(i).text().trim()).not.toEqual('medications.requests.new') + }) + }) + + it('should render the medications list link', () => { + const wrapper = setup('/medications') + + const listItems = wrapper.find(ListItem) + + expect(listItems.at(7).text().trim()).toEqual('medications.requests.new') + }) + + it('should not render the medications list link when user does not have view medications privileges', () => { + const wrapper = setupNoPermissions('/medications') + + const listItems = wrapper.find(ListItem) + + listItems.forEach((_, i) => { + expect(listItems.at(i).text().trim()).not.toEqual('medications.requests.new') + }) + }) + + it('main medications link should be active when the current path is /medications', () => { + const wrapper = setup('/medications') + + const listItems = wrapper.find(ListItem) + + expect(listItems.at(6).prop('active')).toBeTruthy() + }) + + it('should navigate to /medications when the main lab link is clicked', () => { + const wrapper = setup('/') + + const listItems = wrapper.find(ListItem) + + act(() => { + const onClick = listItems.at(6).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/medications') + }) + + it('new lab request link should be active when the current path is /medications/new', () => { + const wrapper = setup('/medications/new') + + const listItems = wrapper.find(ListItem) + + expect(listItems.at(7).prop('active')).toBeTruthy() + }) + + it('should navigate to /medications/new when the new medications link is clicked', () => { + const wrapper = setup('/medications') + + const listItems = wrapper.find(ListItem) + + act(() => { + const onClick = listItems.at(7).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/medications/new') + }) + + it('medications list link should be active when the current path is /medications', () => { + const wrapper = setup('/medications') + + const listItems = wrapper.find(ListItem) + + expect(listItems.at(8).prop('active')).toBeTruthy() + }) + + it('should navigate to /medications when the medications list link is clicked', () => { + const wrapper = setup('/medications/new') + + const listItems = wrapper.find(ListItem) + + act(() => { + const onClick = listItems.at(8).prop('onClick') as any + onClick() + }) + + expect(history.location.pathname).toEqual('/medications') + }) + }) }) diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 4d90e9c955..423bd271ec 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -268,7 +268,7 @@ const Sidebar = () => { } style={expandibleArrow} /> - {!sidebarCollapsed && t('medications.label')} + {!sidebarCollapsed && t('medications.label')} {splittedPath[1].includes('medications') && expandedItem === 'medications' && ( From cae8481093948038613a631f3ac64e3501f5fb7f Mon Sep 17 00:00:00 2001 From: blestab Date: Wed, 29 Jul 2020 01:35:40 +0200 Subject: [PATCH 06/16] feat(navbar): add "Request Medication" to quick create button in navbar re #2257 --- src/__tests__/shared/components/navbar/Navbar.test.tsx | 7 +++++++ src/shared/components/navbar/Navbar.tsx | 9 ++++++++- src/shared/components/navbar/pageMap.tsx | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/__tests__/shared/components/navbar/Navbar.test.tsx b/src/__tests__/shared/components/navbar/Navbar.test.tsx index d0884a7883..542cf477a5 100644 --- a/src/__tests__/shared/components/navbar/Navbar.test.tsx +++ b/src/__tests__/shared/components/navbar/Navbar.test.tsx @@ -53,6 +53,11 @@ describe('Navbar', () => { Permissions.CompleteLab, Permissions.ViewLab, Permissions.ViewLabs, + Permissions.RequestMedication, + Permissions.CancelMedication, + Permissions.CompleteMedication, + Permissions.ViewMedication, + Permissions.ViewMedications, Permissions.ViewIncidents, Permissions.ViewIncident, Permissions.ReportIncident, @@ -89,6 +94,8 @@ describe('Navbar', () => { 'labs.requests.label', 'incidents.reports.new', 'incidents.reports.label', + 'medications.requests.new', + 'medications.requests.label', ] children.forEach((option: any) => { diff --git a/src/shared/components/navbar/Navbar.tsx b/src/shared/components/navbar/Navbar.tsx index d51390b192..9da4cf4dcb 100644 --- a/src/shared/components/navbar/Navbar.tsx +++ b/src/shared/components/navbar/Navbar.tsx @@ -21,6 +21,7 @@ const Navbar = () => { const dividerAboveLabels = [ 'scheduling.appointments.new', 'labs.requests.new', + 'medications.requests.new', 'incidents.reports.new', 'settings.label', ] @@ -43,7 +44,13 @@ const Navbar = () => { const hambergerPages = Object.keys(pageMap).map((key) => pageMap[key]) // For Desktop, add shortcuts menu - const addPages = [pageMap.newPatient, pageMap.newAppointment, pageMap.newLab, pageMap.newIncident] + const addPages = [ + pageMap.newPatient, + pageMap.newAppointment, + pageMap.newLab, + pageMap.newMedication, + pageMap.newIncident, + ] return ( Date: Sun, 2 Aug 2020 16:29:07 +0200 Subject: [PATCH 07/16] feat(medications): PR Feedback (tests, remove 'complete' feature) --- .../medications/Medications.test.tsx | 141 ++++------- .../medications/ViewMedication.test.tsx | 232 ++++++------------ .../medications/medication-slice.test.ts | 105 -------- src/medications/ViewMedication.tsx | 49 +--- src/medications/medication-slice.ts | 38 --- 5 files changed, 132 insertions(+), 433 deletions(-) diff --git a/src/__tests__/medications/Medications.test.tsx b/src/__tests__/medications/Medications.test.tsx index 48e5335e6b..eb52b6ce2b 100644 --- a/src/__tests__/medications/Medications.test.tsx +++ b/src/__tests__/medications/Medications.test.tsx @@ -1,5 +1,5 @@ import { act } from '@testing-library/react' -import { mount } from 'enzyme' +import { mount, ReactWrapper } from 'enzyme' import React from 'react' import { Provider } from 'react-redux' import { MemoryRouter } from 'react-router-dom' @@ -19,114 +19,81 @@ import { RootState } from '../../shared/store' const mockStore = createMockStore([thunk]) describe('Medications', () => { - jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) - jest - .spyOn(MedicationRepository, 'find') - .mockResolvedValue({ id: '1234', requestedOn: new Date().toISOString() } as Medication) - jest - .spyOn(PatientRepository, 'find') - .mockResolvedValue({ id: '12345', fullName: 'test test' } as Patient) + const setup = (route: string, permissions: Array) => { + jest.resetAllMocks() + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) + jest + .spyOn(MedicationRepository, 'find') + .mockResolvedValue({ id: '1234', requestedOn: new Date().toISOString() } as Medication) + jest + .spyOn(PatientRepository, 'find') + .mockResolvedValue({ id: '12345', fullName: 'test test' } as Patient) + + const store = mockStore({ + title: 'test', + user: { permissions }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + medication: { + medication: ({ + id: 'medicationId', + patientId: 'patientId', + requestedOn: new Date().toISOString(), + medication: 'medication', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + notes: 'medication notes', + } as unknown) as Medication, + patient: { id: 'patientId', fullName: 'some name' }, + error: {}, + }, + } as any) + + const wrapper = mount( + + + + + , + ) + return wrapper as ReactWrapper + } describe('routing', () => { describe('/medications/new', () => { it('should render the new medication request screen when /medications/new is accessed', () => { - const store = mockStore({ - title: 'test', - user: { permissions: [Permissions.RequestMedication] }, - breadcrumbs: { breadcrumbs: [] }, - components: { sidebarCollapsed: false }, - medication: { - medication: ({ id: 'medicationId', patientId: 'patientId' } as unknown) as Medication, - patient: { id: 'patientId', fullName: 'some name' }, - error: {}, - }, - } as any) - - const wrapper = mount( - - - - - , - ) - + const route = '/medications/new' + const permissions = [Permissions.RequestMedication] + const wrapper = setup(route, permissions) expect(wrapper.find(NewMedicationRequest)).toHaveLength(1) }) it('should not navigate to /medications/new if the user does not have RequestMedication permissions', () => { - const store = mockStore({ - title: 'test', - user: { permissions: [] }, - breadcrumbs: { breadcrumbs: [] }, - components: { sidebarCollapsed: false }, - } as any) - - const wrapper = mount( - - - - - , - ) - + const route = '/medications/new' + const permissions: never[] = [] + const wrapper = setup(route, permissions) expect(wrapper.find(NewMedicationRequest)).toHaveLength(0) }) }) describe('/medications/:id', () => { it('should render the view medication screen when /medications/:id is accessed', async () => { - const store = mockStore({ - title: 'test', - user: { permissions: [Permissions.ViewMedication] }, - breadcrumbs: { breadcrumbs: [] }, - components: { sidebarCollapsed: false }, - medication: { - medication: ({ - id: 'medicationId', - patientId: 'patientId', - requestedOn: new Date().toISOString(), - medication: 'medication', - status: 'draft', - intent: 'order', - priority: 'routine', - quantity: { value: 1, unit: 'unit' }, - notes: 'medication notes', - } as unknown) as Medication, - patient: { id: 'patientId', fullName: 'some name' }, - error: {}, - }, - } as any) - + const route = '/medications/1234' + const permissions = [Permissions.ViewMedication] let wrapper: any - await act(async () => { - wrapper = await mount( - - - - - , - ) + wrapper = setup(route, permissions) expect(wrapper.find(ViewMedication)).toHaveLength(1) }) }) it('should not navigate to /medications/:id if the user does not have ViewMedication permissions', async () => { - const store = mockStore({ - title: 'test', - user: { permissions: [] }, - breadcrumbs: { breadcrumbs: [] }, - components: { sidebarCollapsed: false }, - } as any) - - const wrapper = await mount( - - - - - , - ) + const route = '/medications/1234' + const permissions: never[] = [] + const wrapper = setup(route, permissions) expect(wrapper.find(ViewMedication)).toHaveLength(0) }) diff --git a/src/__tests__/medications/ViewMedication.test.tsx b/src/__tests__/medications/ViewMedication.test.tsx index 525d956fcc..399d51ef8a 100644 --- a/src/__tests__/medications/ViewMedication.test.tsx +++ b/src/__tests__/medications/ViewMedication.test.tsx @@ -21,39 +21,36 @@ import Permissions from '../../shared/model/Permissions' import { RootState } from '../../shared/store' const mockStore = createMockStore([thunk]) - +let expectedDate: any describe('View Medication', () => { - let history: any - const mockPatient = { fullName: 'test' } - const mockMedication = { - id: '12456', - status: 'draft', - patient: '1234', - medication: 'medication', - intent: 'order', - priority: 'routine', - quantity: { value: 1, unit: 'unit' }, - notes: 'medication notes', - requestedOn: '2020-03-30T04:43:20.102Z', - } as Medication - - let setButtonToolBarSpy: any - let titleSpy: any - let medicationRepositorySaveSpy: any - const expectedDate = new Date() const setup = async (medication: Medication, permissions: Permissions[], error = {}) => { + const mockPatient = { fullName: 'test' } + const mockMedication = { + id: '12456', + status: 'draft', + patient: '1234', + medication: 'medication', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + notes: 'medication notes', + requestedOn: '2020-03-30T04:43:20.102Z', + } as Medication + + expectedDate = new Date() + jest.resetAllMocks() Date.now = jest.fn(() => expectedDate.valueOf()) - setButtonToolBarSpy = jest.fn() - titleSpy = jest.spyOn(titleUtil, 'default') + const setButtonToolBarSpy = jest.fn() + const titleSpy = jest.spyOn(titleUtil, 'default') jest.spyOn(ButtonBarProvider, 'useButtonToolbarSetter').mockReturnValue(setButtonToolBarSpy) jest.spyOn(MedicationRepository, 'find').mockResolvedValue(medication) - medicationRepositorySaveSpy = jest + const medicationRepositorySaveSpy = jest .spyOn(MedicationRepository, 'saveOrUpdate') .mockResolvedValue(mockMedication) jest.spyOn(PatientRepository, 'find').mockResolvedValue(mockPatient as Patient) - history = createMemoryHistory() + const history = createMemoryHistory() history.push(`medications/${medication.id}`) const store = mockStore({ title: '', @@ -61,7 +58,7 @@ describe('View Medication', () => { permissions, }, medication: { - medication, + medication: { ...mockMedication, ...medication }, patient: mockPatient, error, status: Object.keys(error).length > 0 ? 'error' : 'completed', @@ -83,11 +80,20 @@ describe('View Medication', () => { ) }) wrapper.update() - return wrapper + return [ + wrapper, + mockPatient, + { ...mockMedication, ...medication }, + titleSpy, + medicationRepositorySaveSpy, + history, + ] } it('should set the title', async () => { - await setup(mockMedication, [Permissions.ViewMedication]) + const [, mockPatient, mockMedication, titleSpy] = await setup({} as Medication, [ + Permissions.ViewMedication, + ]) expect(titleSpy).toHaveBeenCalledWith( `${mockMedication.medication} for ${mockPatient.fullName}`, @@ -96,8 +102,7 @@ describe('View Medication', () => { describe('page content', () => { it('should display the patient full name for the for', async () => { - const expectedMedication = { ...mockMedication } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const [wrapper, mockPatient] = await setup({} as Medication, [Permissions.ViewMedication]) const forPatientDiv = wrapper.find('.for-patient') expect(forPatientDiv.find('h4').text().trim()).toEqual('medications.medication.for') @@ -105,11 +110,9 @@ describe('View Medication', () => { }) it('should display the medication ', async () => { - const expectedMedication = { - ...mockMedication, - medication: 'expected medication', - } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const [wrapper, , expectedMedication] = await setup({} as Medication, [ + Permissions.ViewMedication, + ]) const medicationTypeDiv = wrapper.find('.medication-medication') expect(medicationTypeDiv.find('h4').text().trim()).toEqual( 'medications.medication.medication', @@ -119,11 +122,9 @@ describe('View Medication', () => { }) it('should display the requested on date', async () => { - const expectedMedication = { - ...mockMedication, - requestedOn: '2020-03-30T04:43:20.102Z', - } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const [wrapper, , expectedMedication] = await setup({} as Medication, [ + Permissions.ViewMedication, + ]) const requestedOnDiv = wrapper.find('.requested-on') expect(requestedOnDiv.find('h4').text().trim()).toEqual('medications.medication.requestedOn') @@ -132,41 +133,30 @@ describe('View Medication', () => { ) }) - it('should not display the completed date if the medication is not completed', async () => { - const expectedMedication = { ...mockMedication } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) - const completedOnDiv = wrapper.find('.completed-on') - - expect(completedOnDiv).toHaveLength(0) - }) - it('should not display the canceled date if the medication is not canceled', async () => { - const expectedMedication = { ...mockMedication } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const [wrapper] = await setup({} as Medication, [Permissions.ViewMedication]) const completedOnDiv = wrapper.find('.canceled-on') expect(completedOnDiv).toHaveLength(0) }) it('should display the notes in the notes text field', async () => { - const expectedMedication = { ...mockMedication, notes: 'expected notes' } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const [wrapper, , expectedMedication] = await setup({} as Medication, [ + Permissions.ViewMedication, + ]) const notesTextField = wrapper.find(TextFieldWithLabelFormGroup).at(0) expect(notesTextField).toBeDefined() - expect(notesTextField.prop('label')).toEqual('medications.medication.notes') expect(notesTextField.prop('value')).toEqual(expectedMedication.notes) }) describe('draft medication request', () => { it('should display a warning badge if the status is draft', async () => { - const expectedMedication = ({ - ...mockMedication, - status: 'draft', - } as unknown) as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const [wrapper, , expectedMedication] = await setup({} as Medication, [ + Permissions.ViewMedication, + ]) const medicationStatusDiv = wrapper.find('.medication-status') const badge = medicationStatusDiv.find(Badge) expect(medicationStatusDiv.find('h4').text().trim()).toEqual( @@ -177,10 +167,8 @@ describe('View Medication', () => { expect(badge.text().trim()).toEqual(expectedMedication.status) }) - it('should display a update medication, complete medication, and cancel medication button if the medication is in a draft state', async () => { - const expectedMedication = { ...mockMedication, notes: 'expected notes' } as Medication - - const wrapper = await setup(expectedMedication, [ + it('should display a update medication and cancel medication button if the medication is in a draft state', async () => { + const [wrapper] = await setup({} as Medication, [ Permissions.ViewMedication, Permissions.CompleteMedication, Permissions.CancelMedication, @@ -189,16 +177,15 @@ describe('View Medication', () => { const buttons = wrapper.find(Button) expect(buttons.at(0).text().trim()).toEqual('actions.update') - expect(buttons.at(1).text().trim()).toEqual('medications.requests.complete') - - expect(buttons.at(2).text().trim()).toEqual('medications.requests.cancel') + expect(buttons.at(1).text().trim()).toEqual('medications.requests.cancel') }) }) describe('canceled medication request', () => { it('should display a danger badge if the status is canceled', async () => { - const expectedMedication = { ...mockMedication, status: 'canceled' } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const [wrapper, , expectedMedication] = await setup({ status: 'canceled' } as Medication, [ + Permissions.ViewMedication, + ]) const medicationStatusDiv = wrapper.find('.medication-status') const badge = medicationStatusDiv.find(Badge) @@ -211,12 +198,13 @@ describe('View Medication', () => { }) it('should display the canceled on date if the medication request has been canceled', async () => { - const expectedMedication = { - ...mockMedication, - status: 'canceled', - canceledOn: '2020-03-30T04:45:20.102Z', - } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) + const [wrapper, , expectedMedication] = await setup( + { + status: 'canceled', + canceledOn: '2020-03-30T04:45:20.102Z', + } as Medication, + [Permissions.ViewMedication], + ) const canceledOnDiv = wrapper.find('.canceled-on') expect(canceledOnDiv.find('h4').text().trim()).toEqual('medications.medication.canceledOn') @@ -226,79 +214,34 @@ describe('View Medication', () => { ) }) - it('should not display update, complete, and cancel button if the medication is canceled', async () => { - const expectedMedication = { ...mockMedication, status: 'canceled' } as Medication - - const wrapper = await setup(expectedMedication, [ - Permissions.ViewMedication, - Permissions.CompleteMedication, - Permissions.CancelMedication, - ]) + it('should not display update and cancel button if the medication is canceled', async () => { + const [wrapper] = await setup( + { + status: 'canceled', + } as Medication, + [Permissions.ViewMedication, Permissions.CancelMedication], + ) const buttons = wrapper.find(Button) expect(buttons).toHaveLength(0) }) it('should not display an update button if the medication is canceled', async () => { - const expectedMedication = { ...mockMedication, status: 'canceled' } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) - - const updateButton = wrapper.find(Button) - expect(updateButton).toHaveLength(0) - }) - }) - - describe('completed medication request', () => { - it('should display a primary badge if the status is completed', async () => { - jest.resetAllMocks() - const expectedMedication = { ...mockMedication, status: 'completed' } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) - const medicationStatusDiv = wrapper.find('.medication-status') - const badge = medicationStatusDiv.find(Badge) - expect(medicationStatusDiv.find('h4').text().trim()).toEqual( - 'medications.medication.status', - ) - - expect(badge.prop('color')).toEqual('primary') - expect(badge.text().trim()).toEqual(expectedMedication.status) - }) - - it('should display the completed on date if the medication request has been completed', async () => { - const expectedMedication = { - ...mockMedication, - status: 'completed', - completedOn: '2020-03-30T04:44:20.102Z', - } as Medication - const wrapper = await setup(expectedMedication, [Permissions.ViewMedication]) - const completedOnDiv = wrapper.find('.completed-on') - - expect(completedOnDiv.find('h4').text().trim()).toEqual( - 'medications.medication.completedOn', - ) - - expect(completedOnDiv.find('h5').text().trim()).toEqual( - format(new Date(expectedMedication.completedOn as string), 'yyyy-MM-dd hh:mm a'), - ) - }) - - it('should not display update, complete, and cancel buttons if the medication is completed', async () => { - const expectedMedication = { ...mockMedication, status: 'completed' } as Medication - - const wrapper = await setup(expectedMedication, [ + const [wrapper] = await setup({ status: 'canceled' } as Medication, [ Permissions.ViewMedication, - Permissions.CompleteMedication, - Permissions.CancelMedication, ]) - const buttons = wrapper.find(Button) - expect(buttons).toHaveLength(0) + const updateButton = wrapper.find(Button) + expect(updateButton).toHaveLength(0) }) }) }) describe('on update', () => { it('should update the medication with the new information', async () => { - const wrapper = await setup(mockMedication, [Permissions.ViewMedication]) + const [wrapper, , mockMedication, , medicationRepositorySaveSpy, history] = await setup({}, [ + Permissions.ViewMedication, + ]) const expectedNotes = 'expected notes' const notesTextField = wrapper.find(TextFieldWithLabelFormGroup).at(0) @@ -324,42 +267,15 @@ describe('View Medication', () => { }) }) - describe('on complete', () => { - it('should mark the status as completed and fill in the completed date with the current time', async () => { - const wrapper = await setup(mockMedication, [ - Permissions.ViewMedication, - Permissions.CompleteMedication, - Permissions.CancelMedication, - ]) - - const completeButton = wrapper.find(Button).at(1) - await act(async () => { - const onClick = completeButton.prop('onClick') - await onClick() - }) - wrapper.update() - - expect(medicationRepositorySaveSpy).toHaveBeenCalledTimes(1) - expect(medicationRepositorySaveSpy).toHaveBeenCalledWith( - expect.objectContaining({ - ...mockMedication, - status: 'completed', - completedOn: expectedDate.toISOString(), - }), - ) - expect(history.location.pathname).toEqual('/medications') - }) - }) - describe('on cancel', () => { it('should mark the status as canceled and fill in the cancelled on date with the current time', async () => { - const wrapper = await setup(mockMedication, [ + const [wrapper, , mockMedication, , medicationRepositorySaveSpy, history] = await setup({}, [ Permissions.ViewMedication, Permissions.CompleteMedication, Permissions.CancelMedication, ]) - const cancelButton = wrapper.find(Button).at(2) + const cancelButton = wrapper.find(Button).at(1) await act(async () => { const onClick = cancelButton.prop('onClick') await onClick() diff --git a/src/__tests__/medications/medication-slice.test.ts b/src/__tests__/medications/medication-slice.test.ts index 181930908a..5b7eec4ac2 100644 --- a/src/__tests__/medications/medication-slice.test.ts +++ b/src/__tests__/medications/medication-slice.test.ts @@ -9,14 +9,10 @@ import medicationSlice, { updateMedicationSuccess, requestMedicationStart, requestMedicationSuccess, - completeMedicationStart, - completeMedicationSuccess, cancelMedicationStart, cancelMedicationSuccess, fetchMedication, cancelMedication, - completeMedication, - completeMedicationError, requestMedicationError, updateMedication, } from '../../medications/medication-slice' @@ -107,37 +103,6 @@ describe('medication slice', () => { expect(medicationStore.error).toEqual(expectedError) }) - describe('completeMedicationStart', () => { - it('should set status to loading', async () => { - const medicationStore = medicationSlice(undefined, completeMedicationStart()) - - expect(medicationStore.status).toEqual('loading') - }) - }) - - describe('completeMedicationSuccess', () => { - it('should set the medication and status to success', () => { - const expectedMedication = { id: 'medicationId' } as Medication - - const medicationStore = medicationSlice( - undefined, - completeMedicationSuccess(expectedMedication), - ) - - expect(medicationStore.status).toEqual('completed') - expect(medicationStore.medication).toEqual(expectedMedication) - }) - }) - - describe('completeMedicationError', () => { - const expectedError = { message: 'some message', result: 'some result error' } - - const medicationStore = medicationSlice(undefined, completeMedicationError(expectedError)) - - expect(medicationStore.status).toEqual('error') - expect(medicationStore.error).toEqual(expectedError) - }) - describe('cancelMedicationStart', () => { it('should set status to loading', async () => { const medicationStore = medicationSlice(undefined, cancelMedicationStart()) @@ -169,7 +134,6 @@ describe('medication slice', () => { id: 'medicationId', patient: 'patient', medication: 'medication', - medication: 'medication', status: 'draft', intent: 'order', priority: 'routine', @@ -249,75 +213,6 @@ describe('medication slice', () => { }) }) - describe('complete medication', () => { - const mockMedication = { - id: 'medicationId', - patient: 'patient', - medication: 'medication', - status: 'draft', - intent: 'order', - priority: 'routine', - quantity: { value: 1, unit: 'unit' }, - notes: 'notes', - } as Medication - let medicationRepositorySaveOrUpdateSpy: any - - beforeEach(() => { - Date.now = jest.fn().mockReturnValue(new Date().valueOf()) - medicationRepositorySaveOrUpdateSpy = jest - .spyOn(MedicationRepository, 'saveOrUpdate') - .mockResolvedValue(mockMedication) - }) - - it('should complete a medication', async () => { - const expectedCompletedMedication = { - ...mockMedication, - completedOn: new Date(Date.now()).toISOString(), - status: 'completed', - } as Medication - - const store = mockStore() - - await store.dispatch(completeMedication(mockMedication)) - const actions = store.getActions() - - expect(actions[0]).toEqual(completeMedicationStart()) - expect(medicationRepositorySaveOrUpdateSpy).toHaveBeenCalledWith(expectedCompletedMedication) - expect(actions[1]).toEqual(completeMedicationSuccess(expectedCompletedMedication)) - }) - - it('should call on success callback if provided', async () => { - const expectedCompletedMedication = { - ...mockMedication, - completedOn: new Date(Date.now()).toISOString(), - status: 'completed', - } as Medication - - const store = mockStore() - const onSuccessSpy = jest.fn() - await store.dispatch(completeMedication(mockMedication, onSuccessSpy)) - - expect(onSuccessSpy).toHaveBeenCalledWith(expectedCompletedMedication) - }) - - it('should validate that the medication can be completed', async () => { - const store = mockStore() - const onSuccessSpy = jest.fn() - const medicationToComplete = mockMedication - await store.dispatch( - completeMedication({ ...medicationToComplete } as Medication, onSuccessSpy), - ) - const actions = store.getActions() - - expect(actions[1]).not.toEqual( - completeMedicationError({ - message: 'medications.requests.error.unableToComplete', - }), - ) - expect(onSuccessSpy).toHaveBeenCalled() - }) - }) - describe('request medication', () => { const mockMedication = { id: 'medicationId', diff --git a/src/medications/ViewMedication.tsx b/src/medications/ViewMedication.tsx index 4aa91ec2ee..1f20cc5404 100644 --- a/src/medications/ViewMedication.tsx +++ b/src/medications/ViewMedication.tsx @@ -16,12 +16,7 @@ import Medication from '../shared/model/Medication' import Patient from '../shared/model/Patient' import Permissions from '../shared/model/Permissions' import { RootState } from '../shared/store' -import { - cancelMedication, - completeMedication, - updateMedication, - fetchMedication, -} from './medication-slice' +import { cancelMedication, updateMedication, fetchMedication } from './medication-slice' const getTitle = (patient: Patient | undefined, medication: Medication | undefined) => patient && medication ? `${medication.medication} for ${patient.fullName}` : '' @@ -112,16 +107,6 @@ const ViewMedication = () => { } } - const onComplete = async () => { - const onSuccess = () => { - history.push('/medications') - } - - if (medicationToView) { - dispatch(completeMedication(medicationToView, onSuccess)) - } - } - const onCancel = async () => { const onSuccess = () => { history.push('/medications') @@ -134,7 +119,7 @@ const ViewMedication = () => { const getButtons = () => { const buttons: React.ReactNode[] = [] - if (medicationToView?.status === 'completed' || medicationToView?.status === 'canceled') { + if (medicationToView?.status === 'canceled') { return buttons } @@ -144,19 +129,6 @@ const ViewMedication = () => { , ) - if (permissions.includes(Permissions.CompleteMedication)) { - buttons.push( - , - ) - } - if (permissions.includes(Permissions.CancelMedication)) { buttons.push(