diff --git a/src/__tests__/patients/allergies/Allergies.test.tsx b/src/__tests__/patients/allergies/Allergies.test.tsx new file mode 100644 index 0000000000..c2d0ec7392 --- /dev/null +++ b/src/__tests__/patients/allergies/Allergies.test.tsx @@ -0,0 +1,132 @@ +import '../../../__mocks__/matchMediaMock' +import React from 'react' +import { mount } from 'enzyme' +import Allergies from 'patients/allergies/Allergies' +import Permissions from 'model/Permissions' +import configureMockStore from 'redux-mock-store' +import { createMemoryHistory } from 'history' +import thunk from 'redux-thunk' +import { Router } from 'react-router' +import { Provider } from 'react-redux' +import Patient from 'model/Patient' +import User from 'model/User' +import { Button, Modal, List, ListItem, Alert } from '@hospitalrun/components' +import { act } from '@testing-library/react' +import { mocked } from 'ts-jest/utils' +import PatientRepository from 'clients/db/PatientRepository' +import Allergy from 'model/Allergy' +import NewAllergyModal from 'patients/allergies/NewAllergyModal' +import * as patientSlice from '../../../patients/patient-slice' + +const mockStore = configureMockStore([thunk]) +const history = createMemoryHistory() +const expectedPatient = { + id: '123', + rev: '123', + allergies: [ + { id: '1', name: 'allergy1' }, + { id: '2', name: 'allergy2' }, + ], +} as Patient + +let user: any +let store: any + +const setup = (patient = expectedPatient, permissions = [Permissions.AddAllergy]) => { + user = { permissions } as User + store = mockStore({ patient, user }) + const wrapper = mount( + + + + + , + ) + + return wrapper +} + +describe('Allergies', () => { + beforeEach(() => { + jest.resetAllMocks() + jest.spyOn(PatientRepository, 'saveOrUpdate') + }) + + describe('add new allergy button', () => { + it('should render a button to add new allergies', () => { + const wrapper = setup() + + const addAllergyButton = wrapper.find(Button) + expect(addAllergyButton).toHaveLength(1) + expect(addAllergyButton.text().trim()).toEqual('patient.allergies.new') + }) + + it('should not render a button to add new allergies if the user does not have permissions', () => { + const wrapper = setup(expectedPatient, []) + + const addAllergyButton = wrapper.find(Button) + expect(addAllergyButton).toHaveLength(0) + }) + + it('should open the New Allergy Modal when clicked', () => { + const wrapper = setup() + + act(() => { + const addAllergyButton = wrapper.find(Button) + const onClick = addAllergyButton.prop('onClick') as any + onClick({} as React.MouseEvent) + }) + + wrapper.update() + + expect(wrapper.find(Modal).prop('show')).toBeTruthy() + }) + + it('should update the patient with the new allergy when the save button is clicked', async () => { + const expectedAllergy = { name: 'name' } as Allergy + const expectedUpdatedPatient = { + ...expectedPatient, + allergies: [...(expectedPatient.allergies as any), expectedAllergy], + } as Patient + + const mockedPatientRepository = mocked(PatientRepository, true) + mockedPatientRepository.saveOrUpdate.mockResolvedValue(expectedUpdatedPatient) + + const wrapper = setup() + + await act(async () => { + const modal = wrapper.find(NewAllergyModal) + await modal.prop('onSave')(expectedAllergy) + }) + + expect(mockedPatientRepository.saveOrUpdate).toHaveBeenCalledWith(expectedUpdatedPatient) + expect(store.getActions()).toContainEqual(patientSlice.updatePatientStart()) + expect(store.getActions()).toContainEqual( + patientSlice.updatePatientSuccess(expectedUpdatedPatient), + ) + }) + }) + + describe('allergy list', () => { + it('should list the patients allergies', () => { + const allergies = expectedPatient.allergies as Allergy[] + const wrapper = setup() + + const list = wrapper.find(List) + const listItems = wrapper.find(ListItem) + + expect(list).toHaveLength(1) + expect(listItems).toHaveLength(allergies.length) + }) + + it('should render a warning message if the patient does not have any allergies', () => { + const wrapper = setup({ ...expectedPatient, allergies: [] }) + + const alert = wrapper.find(Alert) + + expect(alert).toHaveLength(1) + expect(alert.prop('title')).toEqual('patient.allergies.warning.noAllergies') + expect(alert.prop('message')).toEqual('patient.allergies.addAllergyAbove') + }) + }) +}) diff --git a/src/__tests__/patients/allergies/NewAllergyModal.test.tsx b/src/__tests__/patients/allergies/NewAllergyModal.test.tsx new file mode 100644 index 0000000000..e4601bc4f5 --- /dev/null +++ b/src/__tests__/patients/allergies/NewAllergyModal.test.tsx @@ -0,0 +1,90 @@ +import '../../../__mocks__/matchMediaMock' +import React from 'react' +import NewAllergyModal from 'patients/allergies/NewAllergyModal' +import { shallow, mount } from 'enzyme' +import { Modal, Alert } from '@hospitalrun/components' +import { act } from '@testing-library/react' +import Allergy from 'model/Allergy' + +describe('New Allergy Modal', () => { + it('should render a modal with the correct labels', () => { + const wrapper = shallow( + , + ) + + const modal = wrapper.find(Modal) + expect(modal).toHaveLength(1) + expect(modal.prop('title')).toEqual('patient.allergies.new') + expect(modal.prop('closeButton')?.children).toEqual('actions.cancel') + expect(modal.prop('closeButton')?.color).toEqual('danger') + expect(modal.prop('successButton')?.children).toEqual('patient.allergies.new') + expect(modal.prop('successButton')?.color).toEqual('success') + expect(modal.prop('successButton')?.icon).toEqual('add') + }) + + describe('cancel', () => { + it('should call the onCloseButtonClick function when the close button is clicked', () => { + const onCloseButtonClickSpy = jest.fn() + const wrapper = shallow( + , + ) + + act(() => { + const modal = wrapper.find(Modal) + const { onClick } = modal.prop('closeButton') as any + onClick() + }) + + expect(onCloseButtonClickSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('save', () => { + it('should call the onSave function with the correct data when the save button is clicked', () => { + const expectedName = 'expected name' + const onSaveSpy = jest.fn() + const wrapper = mount( + , + ) + + act(() => { + const input = wrapper.findWhere((c) => c.prop('name') === 'name') + const onChange = input.prop('onChange') + onChange({ target: { value: expectedName } }) + }) + + wrapper.update() + + act(() => { + const modal = wrapper.find(Modal) + const onSave = (modal.prop('successButton') as any).onClick + onSave({} as React.MouseEvent) + }) + + expect(onSaveSpy).toHaveBeenCalledTimes(1) + expect(onSaveSpy).toHaveBeenCalledWith({ name: expectedName } as Allergy) + }) + + it('should display an error message if the name field is not filled out', () => { + const wrapper = mount( + , + ) + + act(() => { + const modal = wrapper.find(Modal) + const onSave = (modal.prop('successButton') as any).onClick + onSave({} as React.MouseEvent) + }) + wrapper.update() + + expect(wrapper.find(Alert)).toHaveLength(1) + expect(wrapper.find(Alert).prop('title')).toEqual('states.error') + expect(wrapper.find(Alert).prop('message')).toContain('patient.allergies.error.nameRequired') + }) + }) +}) diff --git a/src/__tests__/patients/view/ViewPatient.test.tsx b/src/__tests__/patients/view/ViewPatient.test.tsx index 15bc7f1810..c0cd44ee45 100644 --- a/src/__tests__/patients/view/ViewPatient.test.tsx +++ b/src/__tests__/patients/view/ViewPatient.test.tsx @@ -12,6 +12,7 @@ import GeneralInformation from 'patients/GeneralInformation' import { createMemoryHistory } from 'history' import RelatedPersonTab from 'patients/related-persons/RelatedPersonTab' import * as ButtonBarProvider from 'page-header/ButtonBarProvider' +import Allergies from 'patients/allergies/Allergies' import Patient from '../../../model/Patient' import PatientRepository from '../../../clients/db/PatientRepository' import * as titleUtil from '../../../page-header/useTitle' @@ -113,10 +114,11 @@ describe('ViewPatient', () => { const tabs = tabsHeader.find(Tab) expect(tabsHeader).toHaveLength(1) - expect(tabs).toHaveLength(3) + expect(tabs).toHaveLength(4) expect(tabs.at(0).prop('label')).toEqual('patient.generalInformation') expect(tabs.at(1).prop('label')).toEqual('patient.relatedPersons.label') expect(tabs.at(2).prop('label')).toEqual('scheduling.appointments.label') + expect(tabs.at(3).prop('label')).toEqual('patient.allergies.label') }) it('should mark the general information tab as active and render the general information component when route is /patients/:id', async () => { @@ -173,4 +175,28 @@ describe('ViewPatient', () => { expect(relatedPersonTab).toHaveLength(1) expect(relatedPersonTab.prop('patient')).toEqual(patient) }) + + it('should mark the rallergies tab as active when it is clicked and render the allergies component when route is /patients/:id/allergies', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + + await act(async () => { + const tabsHeader = wrapper.find(TabsHeader) + const tabs = tabsHeader.find(Tab) + tabs.at(3).prop('onClick')() + }) + + wrapper.update() + + const tabsHeader = wrapper.find(TabsHeader) + const tabs = tabsHeader.find(Tab) + const allergiesTab = wrapper.find(Allergies) + + expect(history.location.pathname).toEqual(`/patients/${patient.id}/allergies`) + expect(tabs.at(3).prop('active')).toBeTruthy() + expect(allergiesTab).toHaveLength(1) + expect(allergiesTab.prop('patient')).toEqual(patient) + }) }) diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 59dd7f1e29..8d08fbe681 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -52,6 +52,17 @@ }, "errors": { "patientGivenNameRequired": "Patient Given Name is required." + }, + "allergies": { + "label": "Allergies", + "new": "Add Allergy", + "error": { + "nameRequired": "Name is required." + }, + "warning": { + "noAllergies": "No Allergies" + }, + "addAllergyAbove": "Add an allergy using the button above." } }, "sex": { diff --git a/src/model/Allergy.ts b/src/model/Allergy.ts new file mode 100644 index 0000000000..e4bb1e4c30 --- /dev/null +++ b/src/model/Allergy.ts @@ -0,0 +1,4 @@ +export default interface Allergy { + id: string + name: string +} diff --git a/src/model/Patient.ts b/src/model/Patient.ts index 9a8757cbe5..52db49648f 100644 --- a/src/model/Patient.ts +++ b/src/model/Patient.ts @@ -2,6 +2,7 @@ import AbstractDBModel from './AbstractDBModel' import Name from './Name' import ContactInformation from './ContactInformation' import RelatedPerson from './RelatedPerson' +import Allergy from './Allergy' export default interface Patient extends AbstractDBModel, Name, ContactInformation { sex: string @@ -12,4 +13,5 @@ export default interface Patient extends AbstractDBModel, Name, ContactInformati type?: string friendlyId: string relatedPersons?: RelatedPerson[] + allergies?: Allergy[] } diff --git a/src/model/Permissions.ts b/src/model/Permissions.ts index fdb910cc63..b7403fb69e 100644 --- a/src/model/Permissions.ts +++ b/src/model/Permissions.ts @@ -4,6 +4,7 @@ enum Permissions { ReadAppointments = 'read:appointments', WriteAppointments = 'write:appointments', DeleteAppointment = 'delete:appointment', + AddAllergy = 'write:allergy', } export default Permissions diff --git a/src/patients/allergies/Allergies.tsx b/src/patients/allergies/Allergies.tsx new file mode 100644 index 0000000000..2f800fabbe --- /dev/null +++ b/src/patients/allergies/Allergies.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import useAddBreadcrumbs from 'breadcrumbs/useAddBreadcrumbs' +import Patient from 'model/Patient' +import { Button, List, ListItem, Alert } from '@hospitalrun/components' +import { useSelector, useDispatch } from 'react-redux' +import { RootState } from 'store' +import Permissions from 'model/Permissions' +import { useTranslation } from 'react-i18next' +import Allergy from 'model/Allergy' +import { useHistory } from 'react-router' +import { updatePatient } from 'patients/patient-slice' +import { getTimestampId } from 'patients/util/timestamp-id-generator' +import NewAllergyModal from './NewAllergyModal' + +interface AllergiesProps { + patient: Patient +} + +const Allergies = (props: AllergiesProps) => { + const { t } = useTranslation() + const history = useHistory() + const dispatch = useDispatch() + const { patient } = props + const { permissions } = useSelector((state: RootState) => state.user) + const [showNewAllergyModal, setShowNewAllergyModal] = useState(false) + + const breadcrumbs = [ + { + i18nKey: 'patient.allergies.label', + location: `/patients/${patient.id}/allergies`, + }, + ] + useAddBreadcrumbs(breadcrumbs) + + const onAddAllergy = (allergy: Allergy) => { + allergy.id = getTimestampId() + const allergies = [] + if (patient.allergies) { + allergies.push(...patient.allergies) + } + + allergies.push(allergy) + const patientToUpdate = { ...patient, allergies } + dispatch(updatePatient(patientToUpdate, history)) + } + + return ( + <> +
+
+ {permissions.includes(Permissions.AddAllergy) && ( + + )} +
+
+
+ {(!patient.allergies || patient.allergies.length === 0) && ( + + )} + + {patient.allergies?.map((a: Allergy) => ( + {a.name} + ))} + + setShowNewAllergyModal(false)} + onSave={(allergy) => onAddAllergy(allergy)} + /> + + ) +} + +export default Allergies diff --git a/src/patients/allergies/NewAllergyModal.tsx b/src/patients/allergies/NewAllergyModal.tsx new file mode 100644 index 0000000000..531f9e4207 --- /dev/null +++ b/src/patients/allergies/NewAllergyModal.tsx @@ -0,0 +1,85 @@ +import React, { useState, useEffect } from 'react' +import { Modal, Alert } from '@hospitalrun/components' +import { useTranslation } from 'react-i18next' +import Allergy from 'model/Allergy' +import TextInputWithLabelFormGroup from 'components/input/TextInputWithLabelFormGroup' + +interface NewAllergyModalProps { + show: boolean + onCloseButtonClick: () => void + onSave: (allergy: Allergy) => void +} + +const NewAllergyModal = (props: NewAllergyModalProps) => { + const { show, onCloseButtonClick, onSave } = props + const [allergy, setAllergy] = useState({ name: '' }) + const [errorMessage, setErrorMessage] = useState('') + const { t } = useTranslation() + + useEffect(() => { + setErrorMessage('') + setAllergy({ name: '' }) + }, [show]) + + const onNameChange = (event: React.ChangeEvent) => { + const name = event.target.value + setAllergy((prevAllergy) => ({ ...prevAllergy, name })) + } + + const onSaveButtonClick = () => { + let newErrorMessage = '' + if (!allergy.name) { + newErrorMessage += `${t('patient.allergies.error.nameRequired')} ` + } + + if (newErrorMessage) { + setErrorMessage(newErrorMessage.trim()) + return + } + + onSave(allergy as Allergy) + } + + const onClose = () => { + onCloseButtonClick() + } + + const body = ( + <> + {errorMessage && } +
+ + + + ) + + return ( + + ) +} + +export default NewAllergyModal diff --git a/src/patients/util/timestamp-id-generator.ts b/src/patients/util/timestamp-id-generator.ts new file mode 100644 index 0000000000..ae9198054a --- /dev/null +++ b/src/patients/util/timestamp-id-generator.ts @@ -0,0 +1,5 @@ +import { getTime } from 'date-fns' + +export function getTimestampId() { + return getTime(new Date()).toString() +} diff --git a/src/patients/view/ViewPatient.tsx b/src/patients/view/ViewPatient.tsx index 80358d8562..3658ba2207 100644 --- a/src/patients/view/ViewPatient.tsx +++ b/src/patients/view/ViewPatient.tsx @@ -5,6 +5,7 @@ import { Panel, Spinner, TabsHeader, Tab, Button } from '@hospitalrun/components import { useTranslation } from 'react-i18next' import { useButtonToolbarSetter } from 'page-header/ButtonBarProvider' +import Allergies from 'patients/allergies/Allergies' import useTitle from '../../page-header/useTitle' import { fetchPatient } from '../patient-slice' import { RootState } from '../../store' @@ -87,6 +88,11 @@ const ViewPatient = () => { label={t('scheduling.appointments.label')} onClick={() => history.push(`/patients/${patient.id}/appointments`)} /> + history.push(`/patients/${patient.id}/allergies`)} + /> @@ -98,6 +104,9 @@ const ViewPatient = () => { + + + ) diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index ea24efd27a..80089b0b02 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -12,6 +12,7 @@ const initialState: UserState = { Permissions.ReadAppointments, Permissions.WriteAppointments, Permissions.DeleteAppointment, + Permissions.AddAllergy, ], }