diff --git a/src/HospitalRun.tsx b/src/HospitalRun.tsx index a67923d623..118bafdeaf 100644 --- a/src/HospitalRun.tsx +++ b/src/HospitalRun.tsx @@ -7,6 +7,7 @@ import Dashboard from './dashboard/Dashboard' import Imagings from './imagings/Imagings' 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' @@ -52,6 +53,7 @@ const HospitalRun = () => { + diff --git a/src/__tests__/HospitalRun.test.tsx b/src/__tests__/HospitalRun.test.tsx index 6ed65a83fb..072611fc1f 100644 --- a/src/__tests__/HospitalRun.test.tsx +++ b/src/__tests__/HospitalRun.test.tsx @@ -12,12 +12,14 @@ import HospitalRun from '../HospitalRun' import ViewImagings from '../imagings/ViewImagings' import Incidents from '../incidents/Incidents' import ViewLabs from '../labs/ViewLabs' +import ViewMedications from '../medications/ViewMedications' import { addBreadcrumbs } from '../page-header/breadcrumbs/breadcrumbs-slice' import Appointments from '../scheduling/appointments/Appointments' import Settings from '../settings/Settings' import ImagingRepository from '../shared/db/ImagingRepository' import IncidentRepository from '../shared/db/IncidentRepository' import LabRepository from '../shared/db/LabRepository' +import MedicationRepository from '../shared/db/MedicationRepository' import Permissions from '../shared/model/Permissions' import { RootState } from '../shared/store' @@ -125,6 +127,54 @@ describe('HospitalRun', () => { }) }) + describe('/medications', () => { + it('should render the Medications component when /medications is accessed', async () => { + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) + const store = mockStore({ + title: 'test', + user: { user: { id: '123' }, permissions: [Permissions.ViewMedications] }, + medications: { medications: [] }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + } as any) + + let wrapper: any + await act(async () => { + wrapper = await mount( + + + + + , + ) + }) + wrapper.update() + + expect(wrapper.find(ViewMedications)).toHaveLength(1) + }) + + it('should render the dashboard if the user does not have permissions to view medications', () => { + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) + const store = mockStore({ + title: 'test', + user: { user: { id: '123' }, permissions: [] }, + breadcrumbs: { breadcrumbs: [] }, + components: { sidebarCollapsed: false }, + } as any) + + const wrapper = mount( + + + + + , + ) + + expect(wrapper.find(ViewMedications)).toHaveLength(0) + expect(wrapper.find(Dashboard)).toHaveLength(1) + }) + }) + describe('/incidents', () => { it('should render the Incidents component when /incidents is accessed', async () => { jest.spyOn(IncidentRepository, 'search').mockResolvedValue([]) diff --git a/src/__tests__/medications/Medications.test.tsx b/src/__tests__/medications/Medications.test.tsx new file mode 100644 index 0000000000..eb52b6ce2b --- /dev/null +++ b/src/__tests__/medications/Medications.test.tsx @@ -0,0 +1,102 @@ +import { act } from '@testing-library/react' +import { mount, ReactWrapper } 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', () => { + 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 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 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 route = '/medications/1234' + const permissions = [Permissions.ViewMedication] + let wrapper: any + await act(async () => { + 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 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 new file mode 100644 index 0000000000..399d51ef8a --- /dev/null +++ b/src/__tests__/medications/ViewMedication.test.tsx @@ -0,0 +1,296 @@ +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]) +let expectedDate: any +describe('View Medication', () => { + 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()) + const setButtonToolBarSpy = jest.fn() + const titleSpy = jest.spyOn(titleUtil, 'default') + jest.spyOn(ButtonBarProvider, 'useButtonToolbarSetter').mockReturnValue(setButtonToolBarSpy) + jest.spyOn(MedicationRepository, 'find').mockResolvedValue(medication) + const medicationRepositorySaveSpy = jest + .spyOn(MedicationRepository, 'saveOrUpdate') + .mockResolvedValue(mockMedication) + jest.spyOn(PatientRepository, 'find').mockResolvedValue(mockPatient as Patient) + + const history = createMemoryHistory() + history.push(`medications/${medication.id}`) + const store = mockStore({ + title: '', + user: { + permissions, + }, + medication: { + medication: { ...mockMedication, ...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, + mockPatient, + { ...mockMedication, ...medication }, + titleSpy, + medicationRepositorySaveSpy, + history, + ] + } + + it('should set the title', async () => { + const [, mockPatient, mockMedication, titleSpy] = await setup({} as Medication, [ + 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 [wrapper, mockPatient] = await setup({} as Medication, [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 [wrapper, , expectedMedication] = await setup({} as Medication, [ + 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 [wrapper, , expectedMedication] = await setup({} as Medication, [ + 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 canceled date if the medication is not canceled', async () => { + 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 [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 [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( + 'medications.medication.status', + ) + + expect(badge.prop('color')).toEqual('warning') + expect(badge.text().trim()).toEqual(expectedMedication.status) + }) + + 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, + ]) + + const buttons = wrapper.find(Button) + expect(buttons.at(0).text().trim()).toEqual('actions.update') + + 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 [wrapper, , expectedMedication] = await setup({ status: 'canceled' } as Medication, [ + 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 [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') + + 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 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 [wrapper] = await setup({ status: 'canceled' } as Medication, [ + Permissions.ViewMedication, + ]) + + const updateButton = wrapper.find(Button) + expect(updateButton).toHaveLength(0) + }) + }) + }) + + describe('on update', () => { + it('should update the medication with the new information', async () => { + const [wrapper, , mockMedication, , medicationRepositorySaveSpy, history] = await setup({}, [ + 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 cancel', () => { + it('should mark the status as canceled and fill in the cancelled on date with the current time', async () => { + const [wrapper, , mockMedication, , medicationRepositorySaveSpy, history] = await setup({}, [ + Permissions.ViewMedication, + Permissions.CompleteMedication, + Permissions.CancelMedication, + ]) + + const cancelButton = wrapper.find(Button).at(1) + 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..96c9c7d524 --- /dev/null +++ b/src/__tests__/medications/ViewMedications.test.tsx @@ -0,0 +1,190 @@ +import { TextInput, Select, Table } from '@hospitalrun/components' +import { act } from '@testing-library/react' +import { mount } 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', () => { + const setup = async (medication: Medication, permissions: Permissions[]) => { + let wrapper: 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 + const history = createMemoryHistory() + const store = mockStore({ + title: '', + user: { permissions }, + medications: { medications: [{ ...expectedMedication, ...medication }] }, + } as any) + const titleSpy = jest.spyOn(titleUtil, 'default') + const setButtonToolBarSpy = jest.fn() + const searchMedicationsSpy = jest.spyOn(medicationsSlice, 'searchMedications') + jest.spyOn(ButtonBarProvider, 'useButtonToolbarSetter').mockReturnValue(setButtonToolBarSpy) + jest.spyOn(MedicationRepository, 'findAll').mockResolvedValue([]) + + await act(async () => { + wrapper = await mount( + + + + + , + ) + }) + wrapper.update() + return [ + wrapper, + titleSpy, + setButtonToolBarSpy, + { ...expectedMedication, ...medication }, + history, + searchMedicationsSpy, + ] + } + + describe('title', () => { + it('should have the title', async () => { + const permissions: never[] = [] + const [, titleSpy] = await setup({} as Medication, permissions) + expect(titleSpy).toHaveBeenCalledWith('medications.label') + }) + }) + + describe('button bar', () => { + it('should display button to add new medication request', async () => { + const permissions = [Permissions.ViewMedications, Permissions.RequestMedication] + const [, , setButtonToolBarSpy] = await setup({} as Medication, permissions) + + 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 permissions: never[] = [] + const [, , setButtonToolBarSpy] = await setup({} as Medication, permissions) + + const actualButtons: React.ReactNode[] = setButtonToolBarSpy.mock.calls[0][0] + expect(actualButtons).toEqual([]) + }) + }) + + describe('table', () => { + it('should render a table with data', async () => { + const permissions = [Permissions.ViewMedications] + const [wrapper, , , expectedMedication] = await setup({} as Medication, permissions) + 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', async () => { + const permissions = [Permissions.ViewMedications] + const [wrapper, , , expectedMedication, history] = await setup({} as Medication, permissions) + 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', async () => { + const permissions = [Permissions.ViewMedications] + const [wrapper, , , , , searchMedicationsSpy] = await setup({} as Medication, permissions) + + 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', async () => { + const permissions = [Permissions.ViewMedications] + const [wrapper, , , , , searchMedicationsSpy] = await setup({} as Medication, permissions) + + searchMedicationsSpy.mockClear() + const expectedSearchText = 'search text' + + act(() => { + const onClick = wrapper.find(TextInput).at(0).prop('onChange') as any + onClick({ + target: { + value: expectedSearchText, + }, + preventDefault: jest.fn(), + }) + }) + + act(() => { + jest.advanceTimersByTime(500) + }) + + wrapper.update() + + expect(searchMedicationsSpy).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/__tests__/medications/medication-slice.test.ts b/src/__tests__/medications/medication-slice.test.ts new file mode 100644 index 0000000000..b052a3a35e --- /dev/null +++ b/src/__tests__/medications/medication-slice.test.ts @@ -0,0 +1,312 @@ +import createMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import medicationSlice, { + requestMedication, + fetchMedicationStart, + fetchMedicationSuccess, + updateMedicationStart, + updateMedicationSuccess, + requestMedicationStart, + requestMedicationSuccess, + cancelMedicationStart, + cancelMedicationSuccess, + fetchMedication, + cancelMedication, + 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', () => { + const setup = () => { + const mockMedication = { + id: 'medicationId', + patient: 'patient', + medication: 'medication', + status: 'draft', + intent: 'order', + priority: 'routine', + quantity: { value: 1, unit: 'unit' }, + notes: 'medication notes', + } as Medication + + const mockPatient = { + id: 'patient', + } as Patient + return [mockMedication, mockPatient] + } + 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, expectedPatient] = setup() + const medicationStore = medicationSlice( + undefined, + fetchMedicationSuccess({ + medication: expectedMedication as Medication, + patient: expectedPatient as Patient, + }), + ) + + 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] = setup() + + const medicationStore = medicationSlice( + undefined, + updateMedicationSuccess(expectedMedication as Medication), + ) + + 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] = setup() + + const medicationStore = medicationSlice( + undefined, + requestMedicationSuccess(expectedMedication as Medication), + ) + + 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('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] = setup() + + const medicationStore = medicationSlice( + undefined, + cancelMedicationSuccess(expectedMedication as Medication), + ) + + expect(medicationStore.status).toEqual('completed') + expect(medicationStore.medication).toEqual(expectedMedication) + }) + }) + }) + + describe('fetch medication', () => { + let patientRepositorySpy: any + let medicationRepositoryFindSpy: any + + const [mockMedication, mockPatient] = setup() + + beforeEach(() => { + patientRepositorySpy = jest + .spyOn(PatientRepository, 'find') + .mockResolvedValue(mockPatient as Patient) + medicationRepositoryFindSpy = jest + .spyOn(MedicationRepository, 'find') + .mockResolvedValue(mockMedication as Medication) + }) + + 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 as Medication, + patient: mockPatient as Patient, + }), + ) + }) + }) + + describe('cancel medication', () => { + const [mockMedication] = setup() + let medicationRepositorySaveOrUpdateSpy: any + + beforeEach(() => { + Date.now = jest.fn().mockReturnValue(new Date().valueOf()) + medicationRepositorySaveOrUpdateSpy = jest + .spyOn(MedicationRepository, 'saveOrUpdate') + .mockResolvedValue(mockMedication as Medication) + }) + + 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 as Medication)) + 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 as Medication, onSuccessSpy)) + + expect(onSuccessSpy).toHaveBeenCalledWith(expectedCanceledMedication) + }) + }) + + describe('request medication', () => { + const [mockMedication] = setup() + let medicationRepositorySaveSpy: any + + beforeEach(() => { + jest.restoreAllMocks() + Date.now = jest.fn().mockReturnValue(new Date().valueOf()) + medicationRepositorySaveSpy = jest + .spyOn(MedicationRepository, 'save') + .mockResolvedValue(mockMedication as Medication) + }) + + 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 as Medication)) + + 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 as Medication, onSuccessSpy)) + + expect(onSuccessSpy).toHaveBeenCalledWith(mockMedication) + }) + }) + + describe('update medication', () => { + const [mockMedication] = setup() + 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..9e7359c673 --- /dev/null +++ b/src/__tests__/medications/medications-slice.test.ts @@ -0,0 +1,144 @@ +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', () => { + const setup = (medicationSpyOn: string) => { + const dispatch = jest.fn() + const getState = jest.fn() + jest.spyOn(MedicationRepository, medicationSpyOn as any) + return [dispatch, getState] + } + + 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, getState] = setup('search') + + 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, getState] = setup('search') + 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, getState] = setup('findAll') + + await searchMedications('', expectedSearchObject.status)(dispatch, getState, null) + + expect(MedicationRepository.findAll).toHaveBeenCalledTimes(1) + }) + + it('should dispatch the FETCH_MEDICATIONS_SUCCESS action', async () => { + const [dispatch, getState] = setup('findAll') + + const expectedMedications = [ + { + medication: '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, getState] = setup('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, getState] = setup('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/__tests__/shared/components/Sidebar.test.tsx b/src/__tests__/shared/components/Sidebar.test.tsx index 5a141f838e..62ae168f23 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, @@ -543,7 +548,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) - expect(listItems.at(6).text().trim()).toEqual('imagings.label') + expect(listItems.at(7).text().trim()).toEqual('imagings.label') }) it('should render the new imaging request link', () => { @@ -551,7 +556,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) - expect(listItems.at(7).text().trim()).toEqual('imagings.requests.new') + expect(listItems.at(8).text().trim()).toEqual('imagings.requests.new') }) it('should not render the new imaging request link when user does not have the request imaging privileges', () => { @@ -569,7 +574,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) - expect(listItems.at(8).text().trim()).toEqual('imagings.requests.label') + expect(listItems.at(9).text().trim()).toEqual('imagings.requests.label') }) it('should not render the imagings list link when user does not have the view imagings privileges', () => { @@ -587,7 +592,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) - expect(listItems.at(6).prop('active')).toBeTruthy() + expect(listItems.at(7).prop('active')).toBeTruthy() }) it('should navigate to /imaging when the main imagings link is clicked', () => { @@ -596,7 +601,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) act(() => { - const onClick = listItems.at(6).prop('onClick') as any + const onClick = listItems.at(7).prop('onClick') as any onClick() }) @@ -608,7 +613,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) - expect(listItems.at(7).prop('active')).toBeTruthy() + expect(listItems.at(8).prop('active')).toBeTruthy() }) it('should navigate to /imaging/new when the new imaging link is clicked', () => { @@ -617,7 +622,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) act(() => { - const onClick = listItems.at(7).prop('onClick') as any + const onClick = listItems.at(8).prop('onClick') as any onClick() }) @@ -629,7 +634,7 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) - expect(listItems.at(8).prop('active')).toBeTruthy() + expect(listItems.at(7).prop('active')).toBeTruthy() }) it('should navigate to /imaging when the imagings list link is clicked', () => { @@ -638,11 +643,120 @@ describe('Sidebar', () => { const listItems = wrapper.find(ListItem) act(() => { - const onClick = listItems.at(8).prop('onClick') as any + const onClick = listItems.at(7).prop('onClick') as any onClick() }) expect(history.location.pathname).toEqual('/imaging') }) }) + + 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/__tests__/shared/components/navbar/Navbar.test.tsx b/src/__tests__/shared/components/navbar/Navbar.test.tsx index 99b72f06a3..f91d76b4fe 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, @@ -93,6 +98,8 @@ describe('Navbar', () => { 'labs.requests.label', 'incidents.reports.new', 'incidents.reports.label', + 'medications.requests.new', + 'medications.requests.label', 'imagings.requests.new', 'imagings.requests.label', ] 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..1f20cc5404 --- /dev/null +++ b/src/medications/ViewMedication.tsx @@ -0,0 +1,319 @@ +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, 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 onCancel = async () => { + const onSuccess = () => { + history.push('/medications') + } + + if (medicationToView) { + dispatch(cancelMedication(medicationToView, onSuccess)) + } + } + + const getButtons = () => { + const buttons: React.ReactNode[] = [] + if (medicationToView?.status === 'canceled') { + return buttons + } + + buttons.push( + , + ) + + if (permissions.includes(Permissions.CancelMedication)) { + buttons.push( + , + ) + } + + return buttons + } + + if (medicationToView && patient) { + const getBadgeColor = () => { + if (medicationToView.status === 'canceled') { + return 'danger' + } + return 'warning' + } + + const getCancelledOnDate = () => { + 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')}
+
+
+ {getCancelledOnDate()} +
+ + +
+

{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..9623938066 --- /dev/null +++ b/src/medications/ViewMedications.tsx @@ -0,0 +1,133 @@ +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[] = [ + { label: t('medications.filter.all'), value: 'all' }, + { 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' }, + ] + + 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..93f1322a55 --- /dev/null +++ b/src/medications/medication-slice.ts @@ -0,0 +1,154 @@ +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, + }, +}) + +export const { + fetchMedicationStart, + fetchMedicationSuccess, + updateMedicationStart, + updateMedicationSuccess, + requestMedicationStart, + requestMedicationSuccess, + requestMedicationError, + cancelMedicationStart, + cancelMedicationSuccess, +} = 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) + } +} + +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 204fc28961..35c4a73cba 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' : splittedPath[1].includes('imagings') @@ -249,6 +251,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()} {getImagingLinks()} diff --git a/src/shared/components/navbar/Navbar.tsx b/src/shared/components/navbar/Navbar.tsx index e9c5d79c28..4a699a0d8e 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', 'imagings.requests.new', 'settings.label', @@ -48,6 +49,8 @@ const Navbar = () => { pageMap.newPatient, pageMap.newAppointment, pageMap.newLab, + pageMap.newMedication, + pageMap.newIncident, pageMap.newIncident, pageMap.newImaging, ] diff --git a/src/shared/components/navbar/pageMap.tsx b/src/shared/components/navbar/pageMap.tsx index bab446729b..03dceda6c1 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: 'medication', + }, newIncident: { permission: Permissions.ReportIncident, label: 'incidents.reports.new', diff --git a/src/shared/config/pouchdb.ts b/src/shared/config/pouchdb.ts index 8d965f43bf..cb4c3fdb90 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 } }, + }, imagings: { hasMany: { type: 'imaging', options: { queryInverse: 'patient', async: true } } }, }, }, @@ -67,6 +70,11 @@ export const schema = [ plural: 'imagings', 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 3039d0c06d..5995498562 100644 --- a/src/shared/locales/enUs/translations/index.ts +++ b/src/shared/locales/enUs/translations/index.ts @@ -4,6 +4,7 @@ import dashboard from './dashboard' import imagings from './imagings' 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' @@ -23,6 +24,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 e11b06cf8f..875839861c 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', AddVisit = 'write:visit', ReadVisits = 'read:visit', RequestImaging = 'write:imaging', diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index 1ade0f7e25..7a7f8e19f8 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -5,6 +5,8 @@ import imaging from '../../imagings/imaging-slice' import imagings from '../../imagings/imagings-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' @@ -25,6 +27,8 @@ const reducer = combineReducers({ components, lab, labs, + medication, + medications, imagings, imaging, }) diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index 84af456645..ac461b627b 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, Permissions.AddVisit, Permissions.ReadVisits, Permissions.ViewImagings,