From 5e95a9ccf15d094174be817ce2d18f70cb13a485 Mon Sep 17 00:00:00 2001 From: Blessed Tabvirwa Date: Sun, 12 Jul 2020 03:01:01 +0200 Subject: [PATCH] feat(incidents): add ability to resolve incidents (#2222) --- .../incidents/incident-slice.test.ts | 64 +++++++++++ .../incidents/view/ViewIncident.test.tsx | 102 +++++++++++++++--- .../shared/utils/extractUsername.test.ts | 13 +++ src/incidents/IncidentFilter.ts | 1 + src/incidents/incident-slice.ts | 23 ++++ src/incidents/list/ViewIncidents.tsx | 8 +- src/incidents/view/ViewIncident.tsx | 66 +++++++++++- .../enUs/translations/incidents/index.ts | 3 + src/shared/model/Incident.ts | 3 +- src/shared/model/Permissions.ts | 1 + src/shared/util/extractUsername.ts | 2 + src/user/user-slice.ts | 1 + 12 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/shared/utils/extractUsername.test.ts create mode 100644 src/shared/util/extractUsername.ts diff --git a/src/__tests__/incidents/incident-slice.test.ts b/src/__tests__/incidents/incident-slice.test.ts index 416208e30e..ab4290a6d4 100644 --- a/src/__tests__/incidents/incident-slice.test.ts +++ b/src/__tests__/incidents/incident-slice.test.ts @@ -12,6 +12,9 @@ import incident, { fetchIncidentStart, fetchIncidentSuccess, fetchIncident, + resolveIncident, + resolveIncidentStart, + resolveIncidentSuccess, } from '../../incidents/incident-slice' import IncidentRepository from '../../shared/db/IncidentRepository' import Incident from '../../shared/model/Incident' @@ -70,6 +73,11 @@ describe('incident slice', () => { expect(incidentStore.status).toEqual('loading') }) + it('should handle resolve incident start', () => { + const incidentStore = incident(undefined, resolveIncidentStart()) + expect(incidentStore.status).toEqual('loading') + }) + it('should handle fetch incident success', () => { const expectedIncident = { id: '1234', @@ -80,6 +88,18 @@ describe('incident slice', () => { expect(incidentStore.status).toEqual('completed') expect(incidentStore.incident).toEqual(expectedIncident) }) + + it('should handle resolve incident success', () => { + const expectedIncident = { + id: '1234', + resolvedOn: new Date(Date.now()).toISOString(), + status: 'resolved', + } as Incident + + const incidentStore = incident(undefined, resolveIncidentSuccess(expectedIncident)) + expect(incidentStore.status).toEqual('completed') + expect(incidentStore.incident).toEqual(expectedIncident) + }) }) describe('report incident', () => { @@ -202,4 +222,48 @@ describe('incident slice', () => { expect(store.getActions()[1]).toEqual(fetchIncidentSuccess(expectedIncident)) }) }) + + describe('resolve incident', () => { + const expectedDate = new Date() + const mockIncident = { + id: '123', + description: 'description', + date: expectedDate.toISOString(), + department: 'some department', + category: 'category', + categoryItem: 'categoryItem', + status: 'reported', + } as Incident + const expectedResolvedIncident = { + ...mockIncident, + resolvedOn: expectedDate.toISOString(), + status: 'resolved', + } as Incident + let incidentRepositorySaveOrUpdateSpy: any + + beforeEach(() => { + Date.now = jest.fn().mockReturnValue(expectedDate.valueOf()) + incidentRepositorySaveOrUpdateSpy = jest + .spyOn(IncidentRepository, 'saveOrUpdate') + .mockResolvedValue(expectedResolvedIncident) + }) + + it('should resolve an incident', async () => { + const store = mockStore() + + await store.dispatch(resolveIncident(mockIncident)) + + expect(store.getActions()[0]).toEqual(resolveIncidentStart()) + expect(incidentRepositorySaveOrUpdateSpy).toHaveBeenCalledWith(expectedResolvedIncident) + expect(store.getActions()[1]).toEqual(resolveIncidentSuccess(expectedResolvedIncident)) + }) + + it('should call on success callback if provided', async () => { + const store = mockStore() + const onSuccessSpy = jest.fn() + await store.dispatch(resolveIncident(mockIncident, onSuccessSpy)) + + expect(onSuccessSpy).toHaveBeenCalledWith(expectedResolvedIncident) + }) + }) }) diff --git a/src/__tests__/incidents/view/ViewIncident.test.tsx b/src/__tests__/incidents/view/ViewIncident.test.tsx index 9b2d77798d..1afa26d2bb 100644 --- a/src/__tests__/incidents/view/ViewIncident.test.tsx +++ b/src/__tests__/incidents/view/ViewIncident.test.tsx @@ -1,3 +1,4 @@ +import { Button } from '@hospitalrun/components' import { act } from '@testing-library/react' import { mount } from 'enzyme' import { createMemoryHistory } from 'history' @@ -20,6 +21,8 @@ const mockStore = createMockStore([thunk]) describe('View Incident', () => { const expectedDate = new Date(2020, 5, 1, 19, 48) + const expectedResolveDate = new Date() + let incidentRepositorySaveSpy: any let history: any const expectedIncident = { id: '1234', @@ -34,11 +37,15 @@ describe('View Incident', () => { date: expectedDate.toISOString(), } as Incident - const setup = async (permissions: Permissions[]) => { + const setup = async (mockIncident: Incident, permissions: Permissions[]) => { jest.resetAllMocks() + Date.now = jest.fn(() => expectedResolveDate.valueOf()) jest.spyOn(breadcrumbUtil, 'default') jest.spyOn(titleUtil, 'default') jest.spyOn(IncidentRepository, 'find').mockResolvedValue(expectedIncident) + incidentRepositorySaveSpy = jest + .spyOn(IncidentRepository, 'saveOrUpdate') + .mockResolvedValue(expectedIncident) history = createMemoryHistory() history.push(`/incidents/1234`) @@ -49,7 +56,7 @@ describe('View Incident', () => { permissions, }, incident: { - incident: expectedIncident, + incident: mockIncident, }, } as any) @@ -73,13 +80,13 @@ describe('View Incident', () => { describe('layout', () => { it('should set the title', async () => { - await setup([Permissions.ViewIncident]) + await setup(expectedIncident, [Permissions.ViewIncident]) expect(titleUtil.default).toHaveBeenCalledWith(expectedIncident.code) }) it('should set the breadcrumbs properly', async () => { - await setup([Permissions.ViewIncident]) + await setup(expectedIncident, [Permissions.ViewIncident]) expect(breadcrumbUtil.default).toHaveBeenCalledWith([ { i18nKey: expectedIncident.code, location: '/incidents/1234' }, @@ -87,7 +94,7 @@ describe('View Incident', () => { }) it('should render the date of incident', async () => { - const wrapper = await setup([Permissions.ViewIncident]) + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) const dateOfIncidentFormGroup = wrapper.find('.incident-date') expect(dateOfIncidentFormGroup.find('h4').text()).toEqual('incidents.reports.dateOfIncident') @@ -95,7 +102,7 @@ describe('View Incident', () => { }) it('should render the status', async () => { - const wrapper = await setup([Permissions.ViewIncident]) + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) const dateOfIncidentFormGroup = wrapper.find('.incident-status') expect(dateOfIncidentFormGroup.find('h4').text()).toEqual('incidents.reports.status') @@ -103,7 +110,7 @@ describe('View Incident', () => { }) it('should render the reported by', async () => { - const wrapper = await setup([Permissions.ViewIncident]) + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) const dateOfIncidentFormGroup = wrapper.find('.incident-reported-by') expect(dateOfIncidentFormGroup.find('h4').text()).toEqual('incidents.reports.reportedBy') @@ -111,15 +118,35 @@ describe('View Incident', () => { }) it('should render the reported on', async () => { - const wrapper = await setup([Permissions.ViewIncident]) + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) const dateOfIncidentFormGroup = wrapper.find('.incident-reported-on') expect(dateOfIncidentFormGroup.find('h4').text()).toEqual('incidents.reports.reportedOn') expect(dateOfIncidentFormGroup.find('h5').text()).toEqual('2020-06-01 07:48 PM') }) + it('should render the resolved on if incident status is resolved', async () => { + const mockIncident = { + ...expectedIncident, + status: 'resolved', + resolvedOn: '2020-07-10 06:33 PM', + } as Incident + const wrapper = await setup(mockIncident, [Permissions.ViewIncident]) + + const dateOfResolutionFormGroup = wrapper.find('.incident-resolved-on') + expect(dateOfResolutionFormGroup.find('h4').text()).toEqual('incidents.reports.resolvedOn') + expect(dateOfResolutionFormGroup.find('h5').text()).toEqual('2020-07-10 06:33 PM') + }) + + it('should not render the resolved on if incident status is not resolved', async () => { + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) + + const completedOn = wrapper.find('.incident-resolved-on') + expect(completedOn).toHaveLength(0) + }) + it('should render the department', async () => { - const wrapper = await setup([Permissions.ViewIncident]) + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) const departmentInput = wrapper.findWhere((w: any) => w.prop('name') === 'department') expect(departmentInput.prop('label')).toEqual('incidents.reports.department') @@ -127,7 +154,7 @@ describe('View Incident', () => { }) it('should render the category', async () => { - const wrapper = await setup([Permissions.ViewIncident]) + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) const categoryInput = wrapper.findWhere((w: any) => w.prop('name') === 'category') expect(categoryInput.prop('label')).toEqual('incidents.reports.category') @@ -135,7 +162,7 @@ describe('View Incident', () => { }) it('should render the category item', async () => { - const wrapper = await setup([Permissions.ViewIncident]) + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) const categoryItemInput = wrapper.findWhere((w: any) => w.prop('name') === 'categoryItem') expect(categoryItemInput.prop('label')).toEqual('incidents.reports.categoryItem') @@ -143,11 +170,62 @@ describe('View Incident', () => { }) it('should render the description', async () => { - const wrapper = await setup([Permissions.ViewIncident]) + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) const descriptionTextInput = wrapper.findWhere((w: any) => w.prop('name') === 'description') expect(descriptionTextInput.prop('label')).toEqual('incidents.reports.description') expect(descriptionTextInput.prop('value')).toEqual(expectedIncident.description) }) + + it('should display a resolve incident button if the incident is in a reported state', async () => { + const wrapper = await setup(expectedIncident, [ + Permissions.ViewIncident, + Permissions.ResolveIncident, + ]) + + const buttons = wrapper.find(Button) + expect(buttons.at(0).text().trim()).toEqual('incidents.reports.resolve') + }) + + it('should not display a resolve incident button if the user has no access ResolveIncident access', async () => { + const wrapper = await setup(expectedIncident, [Permissions.ViewIncident]) + + const resolveButton = wrapper.find(Button) + expect(resolveButton).toHaveLength(0) + }) + + it('should not display a resolve incident button if the incident is resolved', async () => { + const mockIncident = { ...expectedIncident, status: 'resolved' } as Incident + const wrapper = await setup(mockIncident, [Permissions.ViewIncident]) + + const resolveButton = wrapper.find(Button) + expect(resolveButton).toHaveLength(0) + }) + }) + + describe('on resolve', () => { + it('should mark the status as resolved and fill in the resolved date with the current time', async () => { + const wrapper = await setup(expectedIncident, [ + Permissions.ViewIncident, + Permissions.ResolveIncident, + ]) + + const resolveButton = wrapper.find(Button).at(0) + await act(async () => { + const onClick = resolveButton.prop('onClick') + await onClick() + }) + wrapper.update() + + expect(incidentRepositorySaveSpy).toHaveBeenCalledTimes(1) + expect(incidentRepositorySaveSpy).toHaveBeenCalledWith( + expect.objectContaining({ + ...expectedIncident, + status: 'resolved', + resolvedOn: expectedResolveDate.toISOString(), + }), + ) + expect(history.location.pathname).toEqual('/incidents') + }) }) }) diff --git a/src/__tests__/shared/utils/extractUsername.test.ts b/src/__tests__/shared/utils/extractUsername.test.ts new file mode 100644 index 0000000000..5e78fd4b3b --- /dev/null +++ b/src/__tests__/shared/utils/extractUsername.test.ts @@ -0,0 +1,13 @@ +import { extractUsername } from '../../../shared/util/extractUsername' + +describe('extract username util', () => { + it('should extract the string after the last : in a given string', () => { + const extractedName = extractUsername('org.couchdb.user:username') + expect(extractedName).toMatch('username') + }) + + it('should return the string if string does not contain a : ', () => { + const extractedName = extractUsername('username') + expect(extractedName).toMatch('username') + }) +}) diff --git a/src/incidents/IncidentFilter.ts b/src/incidents/IncidentFilter.ts index d141730d05..4707a50626 100644 --- a/src/incidents/IncidentFilter.ts +++ b/src/incidents/IncidentFilter.ts @@ -1,5 +1,6 @@ enum IncidentFilter { reported = 'reported', + resolved = 'resolved', all = 'all', } diff --git a/src/incidents/incident-slice.ts b/src/incidents/incident-slice.ts index 11a319ebe9..0f905b4f2f 100644 --- a/src/incidents/incident-slice.ts +++ b/src/incidents/incident-slice.ts @@ -51,6 +51,8 @@ const incidentSlice = createSlice({ reportIncidentStart: start, reportIncidentSuccess: finish, reportIncidentError: error, + resolveIncidentStart: start, + resolveIncidentSuccess: finish, }, }) @@ -60,6 +62,8 @@ export const { reportIncidentStart, reportIncidentSuccess, reportIncidentError, + resolveIncidentStart, + resolveIncidentSuccess, } = incidentSlice.actions export const fetchIncident = (id: string): AppThunk => async (dispatch) => { @@ -120,4 +124,23 @@ export const reportIncident = ( } } +export const resolveIncident = ( + incidentToComplete: Incident, + onSuccess?: (incidentToComplete: Incident) => void, +): AppThunk => async (dispatch) => { + dispatch(resolveIncidentStart()) + + const resolvedIncident = await IncidentRepository.saveOrUpdate({ + ...incidentToComplete, + resolvedOn: new Date(Date.now().valueOf()).toISOString(), + status: 'resolved', + }) + + dispatch(resolveIncidentSuccess(resolvedIncident)) + + if (onSuccess) { + onSuccess(resolvedIncident) + } +} + export default incidentSlice.reducer diff --git a/src/incidents/list/ViewIncidents.tsx b/src/incidents/list/ViewIncidents.tsx index b798381168..fd5fd04a2b 100644 --- a/src/incidents/list/ViewIncidents.tsx +++ b/src/incidents/list/ViewIncidents.tsx @@ -11,6 +11,7 @@ import SelectWithLabelFormGroup, { } from '../../shared/components/input/SelectWithLableFormGroup' import useTranslator from '../../shared/hooks/useTranslator' import { RootState } from '../../shared/store' +import { extractUsername } from '../../shared/util/extractUsername' import IncidentFilter from '../IncidentFilter' import { searchIncidents } from '../incidents-slice' @@ -21,7 +22,10 @@ const ViewIncidents = () => { useTitle(t('incidents.reports.label')) const [searchFilter, setSearchFilter] = useState(IncidentFilter.reported) const { incidents } = useSelector((state: RootState) => state.incidents) - + const viewIncidents = incidents.map((row) => ({ + ...row, + reportedBy: extractUsername(row.reportedBy), + })) const setButtonToolBar = useButtonToolbarSetter() useEffect(() => { setButtonToolBar([ @@ -67,7 +71,7 @@ const ViewIncidents = () => {
row.id} - data={incidents} + data={viewIncidents} columns={[ { label: t('incidents.reports.code'), key: 'code' }, { diff --git a/src/incidents/view/ViewIncident.tsx b/src/incidents/view/ViewIncident.tsx index 17b6fc620d..02cb758740 100644 --- a/src/incidents/view/ViewIncident.tsx +++ b/src/incidents/view/ViewIncident.tsx @@ -1,22 +1,27 @@ -import { Column, Row, Spinner } from '@hospitalrun/components' +import { Button, Column, Row, Spinner } from '@hospitalrun/components' import format from 'date-fns/format' import React, { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { useParams } from 'react-router-dom' +import { useParams, useHistory } from 'react-router-dom' import useAddBreadcrumbs from '../../page-header/breadcrumbs/useAddBreadcrumbs' import useTitle from '../../page-header/title/useTitle' import TextFieldWithLabelFormGroup from '../../shared/components/input/TextFieldWithLabelFormGroup' import TextInputWithLabelFormGroup from '../../shared/components/input/TextInputWithLabelFormGroup' import useTranslator from '../../shared/hooks/useTranslator' +import Permissions from '../../shared/model/Permissions' import { RootState } from '../../shared/store' -import { fetchIncident } from '../incident-slice' +import { extractUsername } from '../../shared/util/extractUsername' +import { fetchIncident, resolveIncident } from '../incident-slice' const ViewIncident = () => { const dispatch = useDispatch() const { t } = useTranslator() + const history = useHistory() const { id } = useParams() const { incident } = useSelector((state: RootState) => state.incident) + const { permissions } = useSelector((state: RootState) => state.user) + const isUnresolved = incident?.status !== 'resolved' useTitle(incident ? incident.code : '') const breadcrumbs = [ { @@ -31,7 +36,54 @@ const ViewIncident = () => { dispatch(fetchIncident(id)) } }, [dispatch, id]) + + const onComplete = async () => { + const onSuccess = () => { + history.push('/incidents') + } + + if (incident) { + dispatch(resolveIncident(incident, onSuccess)) + } + } + + const getButtons = () => { + const buttons: React.ReactNode[] = [] + if (incident?.status === 'resolved') { + return buttons + } + + if (permissions.includes(Permissions.ResolveIncident)) { + buttons.push( + , + ) + } + + return buttons + } + if (incident) { + const getResolvedOnDate = () => { + if (incident.status === 'resolved' && incident.resolvedOn) { + return ( + +
+

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

+
{format(new Date(incident.resolvedOn), 'yyyy-MM-dd hh:mm a')}
+
+
+ ) + } + return <> + } + return ( <> @@ -50,7 +102,7 @@ const ViewIncident = () => {

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

-
{incident.reportedBy}
+
{extractUsername(incident.reportedBy)}
@@ -59,6 +111,7 @@ const ViewIncident = () => {
{format(new Date(incident.reportedOn || ''), 'yyyy-MM-dd hh:mm a')}
+ {getResolvedOnDate()}
@@ -95,6 +148,11 @@ const ViewIncident = () => { /> + {isUnresolved && ( +
+
{getButtons()}
+
+ )} ) } diff --git a/src/shared/locales/enUs/translations/incidents/index.ts b/src/shared/locales/enUs/translations/incidents/index.ts index 8b71520a52..6223bafb29 100644 --- a/src/shared/locales/enUs/translations/incidents/index.ts +++ b/src/shared/locales/enUs/translations/incidents/index.ts @@ -7,12 +7,14 @@ export default { }, status: { reported: 'reported', + resolved: 'resolved', all: 'all', }, reports: { label: 'Reported Incidents', new: 'Report Incident', view: 'View Incident', + resolve: 'Resolve Incident', dateOfIncident: 'Date of Incident', department: 'Department', category: 'Category', @@ -21,6 +23,7 @@ export default { code: 'Code', reportedBy: 'Reported By', reportedOn: 'Reported On', + resolvedOn: 'Resolved On', status: 'Status', error: { dateRequired: 'Date is required.', diff --git a/src/shared/model/Incident.ts b/src/shared/model/Incident.ts index ecc8f66cdc..9487835de2 100644 --- a/src/shared/model/Incident.ts +++ b/src/shared/model/Incident.ts @@ -9,5 +9,6 @@ export default interface Incident extends AbstractDBModel { category: string categoryItem: string description: string - status: 'reported' + status: 'reported' | 'resolved' + resolvedOn: string } diff --git a/src/shared/model/Permissions.ts b/src/shared/model/Permissions.ts index 2adda626ac..f9c6d2ac1f 100644 --- a/src/shared/model/Permissions.ts +++ b/src/shared/model/Permissions.ts @@ -14,6 +14,7 @@ enum Permissions { ViewIncidents = 'read:incidents', ViewIncident = 'read:incident', ReportIncident = 'write:incident', + ResolveIncident = 'resolve:incident', AddCarePlan = 'write:care_plan', ReadCarePlan = 'read:care_plan', } diff --git a/src/shared/util/extractUsername.ts b/src/shared/util/extractUsername.ts new file mode 100644 index 0000000000..572fcb88b3 --- /dev/null +++ b/src/shared/util/extractUsername.ts @@ -0,0 +1,2 @@ +export const extractUsername = (username: string) => + username ? username.slice(username.lastIndexOf(':') + 1) : '' diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index c18fff557c..660df6262a 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -35,6 +35,7 @@ const initialState: UserState = { Permissions.ViewIncident, Permissions.ViewIncidents, Permissions.ReportIncident, + Permissions.ResolveIncident, Permissions.AddCarePlan, Permissions.ReadCarePlan, ],