From dcb46b89acb562e982a99531ca006e976f922d17 Mon Sep 17 00:00:00 2001 From: Jack Meyer Date: Sun, 23 Feb 2020 00:25:57 -0600 Subject: [PATCH] feat(delete appointment): adds ability to delete appointment --- .../appointments/appointment-slice.test.ts | 103 ++++++++++++++- .../view/ViewAppointment.test.tsx | 125 +++++++++++++++++- src/locales/en-US/translation.json | 11 +- src/model/Permissions.ts | 1 + .../appointments/appointment-slice.ts | 29 +++- .../appointments/view/ViewAppointment.tsx | 56 +++++++- src/user/user-slice.ts | 1 + 7 files changed, 311 insertions(+), 15 deletions(-) diff --git a/src/__tests__/scheduling/appointments/appointment-slice.test.ts b/src/__tests__/scheduling/appointments/appointment-slice.test.ts index 7dada1d5a5..9dafe22139 100644 --- a/src/__tests__/scheduling/appointments/appointment-slice.test.ts +++ b/src/__tests__/scheduling/appointments/appointment-slice.test.ts @@ -1,13 +1,19 @@ +import '../../../__mocks__/matchMediaMock' import { AnyAction } from 'redux' import Appointment from 'model/Appointment' import AppointmentRepository from 'clients/db/AppointmentsRepository' +import * as components from '@hospitalrun/components' import { mocked } from 'ts-jest/utils' import PatientRepository from 'clients/db/PatientRepository' import Patient from 'model/Patient' +import { createMemoryHistory } from 'history' import appointment, { fetchAppointmentStart, fetchAppointmentSuccess, fetchAppointment, + deleteAppointment, + deleteAppointmentStart, + deleteAppointmentSuccess, } from '../../../scheduling/appointments/appointment-slice' describe('appointment slice', () => { @@ -46,6 +52,22 @@ describe('appointment slice', () => { expect(appointmentStore.appointment).toEqual(expectedAppointment) expect(appointmentStore.patient).toEqual(expectedPatient) }) + + it('should handle the DELETE_APPOINTMENT_START action', () => { + const appointmentStore = appointment(undefined, { + type: deleteAppointmentStart.type, + }) + + expect(appointmentStore.isLoading).toBeTruthy() + }) + + it('should handle the DELETE_APPOINTMENT_SUCCESS action', () => { + const appointmentStore = appointment(undefined, { + type: deleteAppointmentSuccess.type, + }) + + expect(appointmentStore.isLoading).toBeFalsy() + }) }) describe('fetchAppointment()', () => { @@ -107,11 +129,84 @@ describe('appointment slice', () => { const dispatch = jest.fn() const getState = jest.fn() await fetchAppointment('id')(dispatch, getState, null) + }) + }) - expect(dispatch).toHaveBeenCalledWith({ - type: fetchAppointmentSuccess.type, - payload: { appointment: expectedAppointment, patient: expectedPatient }, - }) + describe('deleteAppointment()', () => { + let deleteAppointmentSpy = jest.spyOn(AppointmentRepository, 'delete') + let toastSpy = jest.spyOn(components, 'Toast') + beforeEach(() => { + jest.resetAllMocks() + deleteAppointmentSpy = jest.spyOn(AppointmentRepository, 'delete') + toastSpy = jest.spyOn(components, 'Toast') + }) + + it('should dispatch the DELETE_APPOINTMENT_START action', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + + await deleteAppointment({ id: 'test1' } as Appointment, createMemoryHistory())( + dispatch, + getState, + null, + ) + + expect(dispatch).toHaveBeenCalledWith({ type: deleteAppointmentStart.type }) + }) + + it('should call the AppointmentRepository delete function with the appointment', async () => { + const expectedAppointment = { id: 'appointmentId1' } as Appointment + + const dispatch = jest.fn() + const getState = jest.fn() + + await deleteAppointment(expectedAppointment, createMemoryHistory())(dispatch, getState, null) + + expect(deleteAppointmentSpy).toHaveBeenCalledTimes(1) + expect(deleteAppointmentSpy).toHaveBeenCalledWith(expectedAppointment) + }) + + it('should navigate to /appointments after deleting', async () => { + const history = createMemoryHistory() + const expectedAppointment = { id: 'appointmentId1' } as Appointment + + const dispatch = jest.fn() + const getState = jest.fn() + + await deleteAppointment(expectedAppointment, history)(dispatch, getState, null) + + expect(history.location.pathname).toEqual('/appointments') + }) + + it('should create a toast with a success message', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + + await deleteAppointment({ id: 'test1' } as Appointment, createMemoryHistory())( + dispatch, + getState, + null, + ) + + expect(toastSpy).toHaveBeenCalledTimes(1) + expect(toastSpy).toHaveBeenLastCalledWith( + 'success', + 'states.success', + 'scheduling.appointments.successfullyDeleted', + ) + }) + + it('should dispatch the DELETE_APPOINTMENT_SUCCESS action', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + + await deleteAppointment({ id: 'test1' } as Appointment, createMemoryHistory())( + dispatch, + getState, + null, + ) + + expect(dispatch).toHaveBeenCalledWith({ type: deleteAppointmentSuccess.type }) }) }) }) diff --git a/src/__tests__/scheduling/appointments/view/ViewAppointment.test.tsx b/src/__tests__/scheduling/appointments/view/ViewAppointment.test.tsx index 45f3f3545e..66d64e4b85 100644 --- a/src/__tests__/scheduling/appointments/view/ViewAppointment.test.tsx +++ b/src/__tests__/scheduling/appointments/view/ViewAppointment.test.tsx @@ -11,10 +11,12 @@ import { createMemoryHistory } from 'history' import AppointmentRepository from 'clients/db/AppointmentsRepository' import { mocked } from 'ts-jest/utils' import { act } from 'react-dom/test-utils' -import { Spinner } from '@hospitalrun/components' +import { Spinner, Modal } from '@hospitalrun/components' import AppointmentDetailForm from 'scheduling/appointments/AppointmentDetailForm' import PatientRepository from 'clients/db/PatientRepository' import Patient from 'model/Patient' +import * as ButtonBarProvider from 'page-header/ButtonBarProvider' +import Permissions from 'model/Permissions' import * as titleUtil from '../../../../page-header/useTitle' import * as appointmentSlice from '../../../../scheduling/appointments/appointment-slice' @@ -37,7 +39,7 @@ describe('View Appointment', () => { let history: any let store: MockStore - const setup = (isLoading: boolean) => { + const setup = (isLoading: boolean, permissions = [Permissions.DeleteAppointment]) => { jest.spyOn(AppointmentRepository, 'find') const mockedAppointmentRepository = mocked(AppointmentRepository, true) mockedAppointmentRepository.find.mockResolvedValue(appointment) @@ -50,6 +52,9 @@ describe('View Appointment', () => { history.push('/appointments/123') store = mockStore({ + user: { + permissions, + }, appointment: { appointment, isLoading, @@ -115,4 +120,120 @@ describe('View Appointment', () => { expect(appointmentDetailForm.prop('appointment')).toEqual(appointment) expect(appointmentDetailForm.prop('isEditable')).toBeFalsy() }) + + it('should render a modal for delete confirmation', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup(false) + }) + + const deleteAppointmentConfirmationModal = wrapper.find(Modal) + expect(deleteAppointmentConfirmationModal).toHaveLength(1) + expect(deleteAppointmentConfirmationModal.prop('closeButton').children).toEqual( + 'actions.delete', + ) + expect(deleteAppointmentConfirmationModal.prop('body')).toEqual( + 'scheduling.appointment.deleteConfirmationMessage', + ) + expect(deleteAppointmentConfirmationModal.prop('title')).toEqual('actions.confirmDelete') + }) + + describe('delete appointment', () => { + let setButtonToolBarSpy = jest.fn() + let deleteAppointmentSpy = jest.spyOn(AppointmentRepository, 'delete') + beforeEach(() => { + jest.resetAllMocks() + jest.spyOn(ButtonBarProvider, 'useButtonToolbarSetter') + deleteAppointmentSpy = jest.spyOn(AppointmentRepository, 'delete') + setButtonToolBarSpy = jest.fn() + mocked(ButtonBarProvider).useButtonToolbarSetter.mockReturnValue(setButtonToolBarSpy) + }) + + it('should render a delete appointment button in the button toolbar', async () => { + await act(async () => { + await setup(false) + }) + + expect(setButtonToolBarSpy).toHaveBeenCalledTimes(1) + const actualButtons: React.ReactNode[] = setButtonToolBarSpy.mock.calls[0][0] + expect((actualButtons[0] as any).props.children).toEqual('scheduling.appointment.delete') + }) + + it('should pop up the modal when on delete appointment click', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup(false) + }) + + expect(setButtonToolBarSpy).toHaveBeenCalledTimes(1) + const actualButtons: React.ReactNode[] = setButtonToolBarSpy.mock.calls[0][0] + + act(() => { + const { onClick } = (actualButtons[0] as any).props + onClick({ preventDefault: jest.fn() }) + }) + wrapper.update() + + const deleteConfirmationModal = wrapper.find(Modal) + expect(deleteConfirmationModal.prop('show')).toEqual(true) + }) + + it('should close the modal when the toggle button is clicked', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup(false) + }) + + expect(setButtonToolBarSpy).toHaveBeenCalledTimes(1) + const actualButtons: React.ReactNode[] = setButtonToolBarSpy.mock.calls[0][0] + + act(() => { + const { onClick } = (actualButtons[0] as any).props + onClick({ preventDefault: jest.fn() }) + }) + wrapper.update() + + act(() => { + const deleteConfirmationModal = wrapper.find(Modal) + deleteConfirmationModal.prop('toggle')() + }) + wrapper.update() + + const deleteConfirmationModal = wrapper.find(Modal) + expect(deleteConfirmationModal.prop('show')).toEqual(false) + }) + + it('should dispatch DELETE_APPOINTMENT action when modal confirmation button is clicked', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup(false) + }) + + const deleteConfirmationModal = wrapper.find(Modal) + + await act(async () => { + await deleteConfirmationModal.prop('closeButton').onClick() + }) + wrapper.update() + + expect(deleteAppointmentSpy).toHaveBeenCalledTimes(1) + expect(deleteAppointmentSpy).toHaveBeenCalledWith(appointment) + + expect(store.getActions()).toContainEqual(appointmentSlice.deleteAppointmentStart()) + expect(store.getActions()).toContainEqual(appointmentSlice.deleteAppointmentSuccess()) + }) + + it('should not add delete appointment button to toolbar if the user does not have delete appointment permissions', async () => { + await act(async () => { + await setup(false, []) + }) + + expect(setButtonToolBarSpy).toHaveBeenCalledTimes(1) + const actualButtons: React.ReactNode[] = setButtonToolBarSpy.mock.calls[0][0] + + expect( + actualButtons.filter((b: any) => b.props.children === 'scheduling.appointment.delete'), + ).toHaveLength(0) + }) + }) }) diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index a6632e20c6..59dd7f1e29 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -66,7 +66,9 @@ "cancel": "Cancel", "new": "New", "list": "List", - "search": "Search" + "search": "Search", + "delete": "Delete", + "confirmDelete": "Confirm Delete" }, "states": { "success": "Success!", @@ -77,7 +79,8 @@ "appointments": { "label": "Appointments", "new": "New Appointment", - "view": "View Appointment" + "view": "View Appointment", + "successfullyDeleted": "Successfully deleted appointment!" }, "appointment": { "startDate": "Start Date", @@ -97,7 +100,9 @@ "startDateMustBeBeforeEndDate": "Start Time must be before End Time." }, "reason": "Reason", - "patient": "Patient" + "patient": "Patient", + "delete": "Delete Appointment", + "deleteConfirmationMessage": "Are you sure you want to delete this appointment?" } } } diff --git a/src/model/Permissions.ts b/src/model/Permissions.ts index 5640a99292..fdb910cc63 100644 --- a/src/model/Permissions.ts +++ b/src/model/Permissions.ts @@ -3,6 +3,7 @@ enum Permissions { WritePatients = 'write:patients', ReadAppointments = 'read:appointments', WriteAppointments = 'write:appointments', + DeleteAppointment = 'delete:appointment', } export default Permissions diff --git a/src/scheduling/appointments/appointment-slice.ts b/src/scheduling/appointments/appointment-slice.ts index aa710d76d4..a4ecced154 100644 --- a/src/scheduling/appointments/appointment-slice.ts +++ b/src/scheduling/appointments/appointment-slice.ts @@ -1,9 +1,11 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import Appointment from 'model/Appointment' import { AppThunk } from 'store' +import { Toast } from '@hospitalrun/components' import AppointmentRepository from 'clients/db/AppointmentsRepository' import Patient from 'model/Patient' import PatientRepository from 'clients/db/PatientRepository' +import il8n from '../../i18n' interface AppointmentState { appointment: Appointment @@ -24,6 +26,12 @@ const appointmentSlice = createSlice({ fetchAppointmentStart: (state: AppointmentState) => { state.isLoading = true }, + deleteAppointmentStart: (state: AppointmentState) => { + state.isLoading = true + }, + deleteAppointmentSuccess: (state: AppointmentState) => { + state.isLoading = false + }, fetchAppointmentSuccess: ( state, { payload }: PayloadAction<{ appointment: Appointment; patient: Patient }>, @@ -35,7 +43,12 @@ const appointmentSlice = createSlice({ }, }) -export const { fetchAppointmentStart, fetchAppointmentSuccess } = appointmentSlice.actions +export const { + fetchAppointmentStart, + fetchAppointmentSuccess, + deleteAppointmentStart, + deleteAppointmentSuccess, +} = appointmentSlice.actions export const fetchAppointment = (id: string): AppThunk => async (dispatch) => { dispatch(fetchAppointmentStart()) @@ -45,4 +58,18 @@ export const fetchAppointment = (id: string): AppThunk => async (dispatch) => { dispatch(fetchAppointmentSuccess({ appointment, patient })) } +export const deleteAppointment = (appointment: Appointment, history: any): AppThunk => async ( + dispatch, +) => { + dispatch(deleteAppointmentStart()) + await AppointmentRepository.delete(appointment) + history.push('/appointments') + Toast( + 'success', + il8n.t('states.success'), + `${il8n.t('scheduling.appointments.successfullyDeleted')}`, + ) + dispatch(deleteAppointmentSuccess()) +} + export default appointmentSlice.reducer diff --git a/src/scheduling/appointments/view/ViewAppointment.tsx b/src/scheduling/appointments/view/ViewAppointment.tsx index c2e78947ac..bc837b9500 100644 --- a/src/scheduling/appointments/view/ViewAppointment.tsx +++ b/src/scheduling/appointments/view/ViewAppointment.tsx @@ -1,11 +1,13 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import useTitle from 'page-header/useTitle' import { useSelector, useDispatch } from 'react-redux' import { RootState } from 'store' -import { useParams } from 'react-router' -import { Spinner } from '@hospitalrun/components' +import { useParams, useHistory } from 'react-router' +import { Spinner, Button, Modal } from '@hospitalrun/components' import { useTranslation } from 'react-i18next' -import { fetchAppointment } from '../appointment-slice' +import { useButtonToolbarSetter } from 'page-header/ButtonBarProvider' +import Permissions from 'model/Permissions' +import { fetchAppointment, deleteAppointment } from '../appointment-slice' import AppointmentDetailForm from '../AppointmentDetailForm' const ViewAppointment = () => { @@ -13,13 +15,45 @@ const ViewAppointment = () => { useTitle(t('scheduling.appointments.view')) const dispatch = useDispatch() const { id } = useParams() + const history = useHistory() const { appointment, patient, isLoading } = useSelector((state: RootState) => state.appointment) + const { permissions } = useSelector((state: RootState) => state.user) + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false) + + const setButtons = useButtonToolbarSetter() + + const onAppointmentDeleteButtonClick = (event: React.MouseEvent) => { + event.preventDefault() + setShowDeleteConfirmation(true) + } + + const onDeleteConfirmationButtonClick = () => { + dispatch(deleteAppointment(appointment, history)) + setShowDeleteConfirmation(false) + } + + const buttons = [] + if (permissions.includes(Permissions.DeleteAppointment)) { + buttons.push( + , + ) + } + + setButtons(buttons) useEffect(() => { if (id) { dispatch(fetchAppointment(id)) } - }, [dispatch, id]) + return () => setButtons([]) + }, [dispatch, id, setButtons]) if (!appointment.id || isLoading) { return @@ -35,6 +69,18 @@ const ViewAppointment = () => { // not editable }} /> + setShowDeleteConfirmation(false)} + /> ) } diff --git a/src/user/user-slice.ts b/src/user/user-slice.ts index dce26fc088..ea24efd27a 100644 --- a/src/user/user-slice.ts +++ b/src/user/user-slice.ts @@ -11,6 +11,7 @@ const initialState: UserState = { Permissions.WritePatients, Permissions.ReadAppointments, Permissions.WriteAppointments, + Permissions.DeleteAppointment, ], }