diff --git a/src/HospitalRun.tsx b/src/HospitalRun.tsx index 2ccf0bccbf..a1b013e718 100644 --- a/src/HospitalRun.tsx +++ b/src/HospitalRun.tsx @@ -4,6 +4,7 @@ import { useSelector } from 'react-redux' import { Toaster } from '@hospitalrun/components' import Appointments from 'scheduling/appointments/Appointments' import NewAppointment from 'scheduling/appointments/new/NewAppointment' +import EditAppointment from 'scheduling/appointments/edit/EditAppointment' import ViewAppointment from 'scheduling/appointments/view/ViewAppointment' import Sidebar from './components/Sidebar' import Permissions from './model/Permissions' @@ -70,6 +71,15 @@ const HospitalRun = () => { path="/appointments/new" component={NewAppointment} /> + { 'scheduling.appointments.label', ) expect(scheduleLinkList.first().props().children[1].props.children).toEqual( - 'scheduling.appointments.new', + 'scheduling.appointments.newAppointment', ) }) diff --git a/src/__tests__/scheduling/appointments/AppointmentDetailForm.test.tsx b/src/__tests__/scheduling/appointments/AppointmentDetailForm.test.tsx index 73cc774bfe..887173c3ba 100644 --- a/src/__tests__/scheduling/appointments/AppointmentDetailForm.test.tsx +++ b/src/__tests__/scheduling/appointments/AppointmentDetailForm.test.tsx @@ -8,7 +8,6 @@ import { Typeahead } from '@hospitalrun/components' import PatientRepository from 'clients/db/PatientRepository' import Patient from 'model/Patient' import { act } from '@testing-library/react' -import TextInputWithLabelFormGroup from 'components/input/TextInputWithLabelFormGroup' describe('AppointmentDetailForm', () => { describe('layout - editable', () => { @@ -26,7 +25,7 @@ describe('AppointmentDetailForm', () => { beforeEach(() => { wrapper = mount( - , + , ) }) @@ -92,6 +91,29 @@ describe('AppointmentDetailForm', () => { }) }) + describe('layout - editable but patient prop passed (Edit Appointment functionality)', () => { + it('should disable patient typeahead if patient prop passed', () => { + const expectedAppointment = { + startDateTime: roundToNearestMinutes(new Date(), { nearestTo: 15 }).toISOString(), + endDateTime: addMinutes( + roundToNearestMinutes(new Date(), { nearestTo: 15 }), + 60, + ).toISOString(), + } as Appointment + + const wrapper = mount( + , + ) + const patientTypeahead = wrapper.find(Typeahead) + expect(patientTypeahead.prop('disabled')).toBeTruthy() + }) + }) + describe('layout - not editable', () => { let wrapper: ReactWrapper const expectedAppointment = { @@ -114,21 +136,21 @@ describe('AppointmentDetailForm', () => { isEditable={false} appointment={expectedAppointment} patient={expectedPatient} - onAppointmentChange={jest.fn()} + onFieldChange={jest.fn()} />, ) }) - it('should disabled fields', () => { - const patientInput = wrapper.findWhere((w) => w.prop('name') === 'patient') + it('should disable fields', () => { + const patientTypeahead = wrapper.find(Typeahead) const startDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'startDate') const endDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'endDate') const locationTextInputBox = wrapper.findWhere((w) => w.prop('name') === 'location') const reasonTextField = wrapper.findWhere((w) => w.prop('name') === 'reason') const typeSelect = wrapper.findWhere((w) => w.prop('name') === 'type') - expect(patientInput).toHaveLength(1) - expect(patientInput.prop('isEditable')).toBeFalsy() - expect(patientInput.prop('value')).toEqual(expectedPatient.fullName) + expect(patientTypeahead).toHaveLength(1) + expect(patientTypeahead.prop('disabled')).toBeTruthy() + expect(patientTypeahead.prop('value')).toEqual(expectedPatient.fullName) expect(startDateTimePicker.prop('isEditable')).toBeFalsy() expect(endDateTimePicker.prop('isEditable')).toBeFalsy() expect(locationTextInputBox.prop('isEditable')).toBeFalsy() @@ -146,18 +168,15 @@ describe('AppointmentDetailForm', () => { 30, ).toISOString(), } as Appointment - const onAppointmentChangeSpy = jest.fn() + const onFieldChange = jest.fn() beforeEach(() => { wrapper = mount( - , + , ) }) - it('should call the onAppointmentChange when patient input changes', () => { + it('should call onFieldChange when patient input changes', () => { const expectedPatientId = '123' act(() => { @@ -166,14 +185,10 @@ describe('AppointmentDetailForm', () => { }) wrapper.update() - expect(onAppointmentChangeSpy).toHaveBeenLastCalledWith({ - patientId: expectedPatientId, - startDateTime: appointment.startDateTime, - endDateTime: appointment.endDateTime, - }) + expect(onFieldChange).toHaveBeenLastCalledWith('patientId', expectedPatientId) }) - it('should call the onAppointmentChange when start date time changes', () => { + it('should call onFieldChange when start date time changes', () => { const expectedStartDateTime = roundToNearestMinutes(new Date(), { nearestTo: 15 }) act(() => { @@ -182,13 +197,13 @@ describe('AppointmentDetailForm', () => { }) wrapper.update() - expect(onAppointmentChangeSpy).toHaveBeenLastCalledWith({ - startDateTime: expectedStartDateTime.toISOString(), - endDateTime: appointment.endDateTime, - }) + expect(onFieldChange).toHaveBeenLastCalledWith( + 'startDateTime', + expectedStartDateTime.toISOString(), + ) }) - it('should call the onAppointmentChange when end date time changes', () => { + it('should call onFieldChange when end date time changes', () => { const expectedStartDateTime = roundToNearestMinutes(new Date(), { nearestTo: 15 }) const expectedEndDateTime = addMinutes(expectedStartDateTime, 30) @@ -198,13 +213,13 @@ describe('AppointmentDetailForm', () => { }) wrapper.update() - expect(onAppointmentChangeSpy).toHaveBeenLastCalledWith({ - startDateTime: appointment.startDateTime, - endDateTime: expectedEndDateTime.toISOString(), - }) + expect(onFieldChange).toHaveBeenLastCalledWith( + 'endDateTime', + expectedEndDateTime.toISOString(), + ) }) - it('should call the onAppointmentChange when location changes', () => { + it('should call onFieldChange when location changes', () => { const expectedLocation = 'location' act(() => { @@ -213,43 +228,31 @@ describe('AppointmentDetailForm', () => { }) wrapper.update() - expect(onAppointmentChangeSpy).toHaveBeenLastCalledWith({ - startDateTime: appointment.startDateTime, - endDateTime: appointment.endDateTime, - location: expectedLocation, - }) + expect(onFieldChange).toHaveBeenLastCalledWith('location', expectedLocation) }) - it('should call the onAppointmentChange when type changes', () => { + it('should call onFieldChange when type changes', () => { const expectedType = 'follow up' act(() => { const typeSelect = wrapper.findWhere((w) => w.prop('name') === 'type') - typeSelect.prop('onChange')({ currentTarget: { value: expectedType } }) + typeSelect.prop('onChange')({ target: { value: expectedType } }) }) wrapper.update() - expect(onAppointmentChangeSpy).toHaveBeenLastCalledWith({ - startDateTime: appointment.startDateTime, - endDateTime: appointment.endDateTime, - type: expectedType, - }) + expect(onFieldChange).toHaveBeenLastCalledWith('type', expectedType) }) - it('should call the onAppointmentChange when reason changes', () => { + it('should call onFieldChange when reason changes', () => { const expectedReason = 'reason' act(() => { const reasonTextField = wrapper.findWhere((w) => w.prop('name') === 'reason') - reasonTextField.prop('onChange')({ target: { value: expectedReason } }) + reasonTextField.prop('onChange')({ currentTarget: { value: expectedReason } }) }) wrapper.update() - expect(onAppointmentChangeSpy).toHaveBeenLastCalledWith({ - startDateTime: appointment.startDateTime, - endDateTime: appointment.endDateTime, - reason: expectedReason, - }) + expect(onFieldChange).toHaveBeenLastCalledWith('reason', expectedReason) }) }) @@ -267,7 +270,7 @@ describe('AppointmentDetailForm', () => { ).toISOString(), } as Appointment } - onAppointmentChange={jest.fn()} + onFieldChange={jest.fn()} />, ) }) diff --git a/src/__tests__/scheduling/appointments/appointment-slice.test.ts b/src/__tests__/scheduling/appointments/appointment-slice.test.ts index 7dada1d5a5..8375c746e9 100644 --- a/src/__tests__/scheduling/appointments/appointment-slice.test.ts +++ b/src/__tests__/scheduling/appointments/appointment-slice.test.ts @@ -1,13 +1,20 @@ import { AnyAction } from 'redux' import Appointment from 'model/Appointment' -import AppointmentRepository from 'clients/db/AppointmentsRepository' +import AppointmentRepository from 'clients/db/AppointmentRepository' import { mocked } from 'ts-jest/utils' +import { createMemoryHistory } from 'history' import PatientRepository from 'clients/db/PatientRepository' import Patient from 'model/Patient' import appointment, { fetchAppointmentStart, fetchAppointmentSuccess, fetchAppointment, + createAppointmentStart, + createAppointmentSuccess, + createAppointment, + updateAppointmentStart, + updateAppointmentSuccess, + updateAppointment, } from '../../../scheduling/appointments/appointment-slice' describe('appointment slice', () => { @@ -18,6 +25,50 @@ describe('appointment slice', () => { expect(appointmentStore.appointment).toEqual({} as Appointment) expect(appointmentStore.isLoading).toBeFalsy() }) + it('should handle the CREATE_APPOINTMENT_START action', () => { + const appointmentStore = appointment(undefined, { + type: createAppointmentStart.type, + }) + + expect(appointmentStore.isLoading).toBeTruthy() + }) + + it('should handle the CREATE_APPOINTMENT_SUCCESS action', () => { + const appointmentStore = appointment(undefined, { + type: createAppointmentSuccess.type, + }) + + expect(appointmentStore.isLoading).toBeFalsy() + }) + + it('should handle the UPDATE_APPOINTMENT_START action', () => { + const appointmentStore = appointment(undefined, { + type: updateAppointmentStart.type, + }) + + expect(appointmentStore.isLoading).toBeTruthy() + }) + + it('should handle the UPDATE_APPOINTMENT_SUCCESS action', () => { + const expectedAppointment = { + patientId: '123', + startDateTime: new Date().toISOString(), + endDateTime: new Date().toISOString(), + location: 'location', + type: 'type', + reason: 'reason', + } as Appointment + const appointmentStore = appointment(undefined, { + type: updateAppointmentSuccess.type, + payload: { + ...expectedAppointment, + }, + }) + + expect(appointmentStore.isLoading).toBeFalsy() + expect(appointmentStore.appointment).toEqual(expectedAppointment) + }) + it('should handle the FETCH_APPOINTMENT_START action', () => { const appointmentStore = appointment(undefined, { type: fetchAppointmentStart.type, @@ -48,6 +99,69 @@ describe('appointment slice', () => { }) }) + describe('createAppointment()', () => { + it('should dispatch the CREATE_APPOINTMENT_START action', async () => { + jest.spyOn(AppointmentRepository, 'save') + mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) + const dispatch = jest.fn() + const getState = jest.fn() + const expectedAppointment = { + patientId: '123', + startDateTime: new Date().toISOString(), + endDateTime: new Date().toISOString(), + location: 'location', + type: 'type', + reason: 'reason', + } as Appointment + + await createAppointment(expectedAppointment, createMemoryHistory())(dispatch, getState, null) + + expect(dispatch).toHaveBeenCalledWith({ type: createAppointmentStart.type }) + }) + + it('should call the the AppointmentRepository save function with the correct data', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + const appointmentRepositorySaveSpy = jest.spyOn(AppointmentRepository, 'save') + mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) + + const expectedAppointment = { + patientId: '123', + startDateTime: new Date().toISOString(), + endDateTime: new Date().toISOString(), + location: 'location', + type: 'type', + reason: 'reason', + } as Appointment + + await createAppointment(expectedAppointment, createMemoryHistory())(dispatch, getState, null) + + expect(appointmentRepositorySaveSpy).toHaveBeenCalled() + expect(appointmentRepositorySaveSpy).toHaveBeenCalledWith(expectedAppointment) + }) + + it('should navigate the /appointments when an appointment is successfully created', async () => { + jest.spyOn(AppointmentRepository, 'save') + mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) + const dispatch = jest.fn() + const getState = jest.fn() + const history = createMemoryHistory() + + const expectedAppointment = { + patientId: '123', + startDateTime: new Date().toISOString(), + endDateTime: new Date().toISOString(), + location: 'location', + type: 'type', + reason: 'reason', + } as Appointment + + await createAppointment(expectedAppointment, history)(dispatch, getState, null) + + expect(history.location.pathname).toEqual('/appointments') + }) + }) + describe('fetchAppointment()', () => { let findSpy = jest.spyOn(AppointmentRepository, 'find') let findPatientSpy = jest.spyOn(PatientRepository, 'find') @@ -114,4 +228,51 @@ describe('appointment slice', () => { }) }) }) + + describe('update appointment', () => { + it('should dispatch the UPDATE_APPOINTMENT_START action', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + jest.spyOn(AppointmentRepository, 'saveOrUpdate') + const expectedAppointmentId = 'sliceId9' + const expectedAppointment = { id: expectedAppointmentId } as Appointment + const mockedAppointmentRepository = mocked(AppointmentRepository, true) + mockedAppointmentRepository.saveOrUpdate.mockResolvedValue(expectedAppointment) + + await updateAppointment(expectedAppointment, createMemoryHistory())(dispatch, getState, null) + + expect(dispatch).toHaveBeenCalledWith({ type: updateAppointmentStart.type }) + }) + + it('should call the AppointmentRepository saveOrUpdate function with the correct data', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + jest.spyOn(AppointmentRepository, 'saveOrUpdate') + const expectedAppointmentId = 'sliceId10' + const expectedAppointment = { id: expectedAppointmentId } as Appointment + const mockedAppointmentRepository = mocked(AppointmentRepository, true) + mockedAppointmentRepository.saveOrUpdate.mockResolvedValue(expectedAppointment) + + await updateAppointment(expectedAppointment, createMemoryHistory())(dispatch, getState, null) + + expect(AppointmentRepository.saveOrUpdate).toHaveBeenCalledWith(expectedAppointment) + }) + + it('should dispatch the UPDATE_APPOINTMENT_SUCCESS action with the correct data', async () => { + const dispatch = jest.fn() + const getState = jest.fn() + jest.spyOn(AppointmentRepository, 'saveOrUpdate') + const expectedAppointmentId = 'sliceId11' + const expectedAppointment = { id: expectedAppointmentId } as Appointment + const mockedAppointmentRepository = mocked(AppointmentRepository, true) + mockedAppointmentRepository.saveOrUpdate.mockResolvedValue(expectedAppointment) + + await updateAppointment(expectedAppointment, createMemoryHistory())(dispatch, getState, null) + + expect(dispatch).toHaveBeenCalledWith({ + type: updateAppointmentSuccess.type, + payload: expectedAppointment, + }) + }) + }) }) diff --git a/src/__tests__/scheduling/appointments/appointments-slice.test.ts b/src/__tests__/scheduling/appointments/appointments-slice.test.ts index 4b7345bbb4..f69a86d79f 100644 --- a/src/__tests__/scheduling/appointments/appointments-slice.test.ts +++ b/src/__tests__/scheduling/appointments/appointments-slice.test.ts @@ -1,12 +1,9 @@ import { AnyAction } from 'redux' import { mocked } from 'ts-jest/utils' import Appointment from 'model/Appointment' -import { createMemoryHistory } from 'history' -import AppointmentRepository from 'clients/db/AppointmentsRepository' +import AppointmentRepository from 'clients/db/AppointmentRepository' import appointments, { - createAppointmentStart, - createAppointment, fetchAppointmentsStart, fetchAppointmentsSuccess, fetchAppointments, @@ -19,13 +16,6 @@ describe('appointments slice', () => { expect(appointmentsStore.isLoading).toBeFalsy() }) - it('should handle the CREATE_APPOINTMENT_START action', () => { - const appointmentsStore = appointments(undefined, { - type: createAppointmentStart.type, - }) - - expect(appointmentsStore.isLoading).toBeTruthy() - }) it('should handle the GET_APPOINTMENTS_START action', () => { const appointmentsStore = appointments(undefined, { @@ -102,67 +92,4 @@ describe('appointments slice', () => { }) }) }) - - describe('createAppointments()', () => { - it('should dispatch the CREATE_APPOINTMENT_START action', async () => { - jest.spyOn(AppointmentRepository, 'save') - mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) - const dispatch = jest.fn() - const getState = jest.fn() - const expectedAppointment = { - patientId: '123', - startDateTime: new Date().toISOString(), - endDateTime: new Date().toISOString(), - location: 'location', - type: 'type', - reason: 'reason', - } as Appointment - - await createAppointment(expectedAppointment, createMemoryHistory())(dispatch, getState, null) - - expect(dispatch).toHaveBeenCalledWith({ type: createAppointmentStart.type }) - }) - - it('should call the the AppointmentRepository save function with the correct data', async () => { - const dispatch = jest.fn() - const getState = jest.fn() - const appointmentRepositorySaveSpy = jest.spyOn(AppointmentRepository, 'save') - mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) - - const expectedAppointment = { - patientId: '123', - startDateTime: new Date().toISOString(), - endDateTime: new Date().toISOString(), - location: 'location', - type: 'type', - reason: 'reason', - } as Appointment - - await createAppointment(expectedAppointment, createMemoryHistory())(dispatch, getState, null) - - expect(appointmentRepositorySaveSpy).toHaveBeenCalled() - expect(appointmentRepositorySaveSpy).toHaveBeenCalledWith(expectedAppointment) - }) - - it('should navigate the /appointments when an appointment is successfully created', async () => { - jest.spyOn(AppointmentRepository, 'save') - mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) - const dispatch = jest.fn() - const getState = jest.fn() - const history = createMemoryHistory() - - const expectedAppointment = { - patientId: '123', - startDateTime: new Date().toISOString(), - endDateTime: new Date().toISOString(), - location: 'location', - type: 'type', - reason: 'reason', - } as Appointment - - await createAppointment(expectedAppointment, history)(dispatch, getState, null) - - expect(history.location.pathname).toEqual('/appointments') - }) - }) }) diff --git a/src/__tests__/scheduling/appointments/edit/EditAppointment.test.tsx b/src/__tests__/scheduling/appointments/edit/EditAppointment.test.tsx new file mode 100644 index 0000000000..ef5d059fbe --- /dev/null +++ b/src/__tests__/scheduling/appointments/edit/EditAppointment.test.tsx @@ -0,0 +1,206 @@ +import '../../../../__mocks__/matchMediaMock' +import React from 'react' +import { mount } from 'enzyme' +import { Router, Route } from 'react-router-dom' +import { Provider } from 'react-redux' +import { mocked } from 'ts-jest/utils' +import { createMemoryHistory } from 'history' +import { act } from 'react-dom/test-utils' +import configureMockStore, { MockStore } from 'redux-mock-store' +import thunk from 'redux-thunk' +import { roundToNearestMinutes, addMinutes, subDays } from 'date-fns' +import { Button, Alert } from '@hospitalrun/components' +import EditAppointment from '../../../../scheduling/appointments/edit/EditAppointment' +import AppointmentDetailForm from '../../../../scheduling/appointments/AppointmentDetailForm' +import Appointment from '../../../../model/Appointment' +import Patient from '../../../../model/Patient' +import * as titleUtil from '../../../../page-header/useTitle' +import * as appointmentSlice from '../../../../scheduling/appointments/appointment-slice' +import AppointmentRepository from '../../../../clients/db/AppointmentRepository' +import PatientRepository from '../../../../clients/db/PatientRepository' + +const mockStore = configureMockStore([thunk]) + +describe('Edit Appointment', () => { + const appointment = { + id: '123', + patientId: '456', + startDateTime: roundToNearestMinutes(new Date(), { nearestTo: 15 }).toISOString(), + endDateTime: addMinutes(roundToNearestMinutes(new Date(), { nearestTo: 15 }), 60).toISOString(), + location: 'location', + reason: 'reason', + type: 'type', + } as Appointment + + const patient = { + id: '456', + prefix: 'prefix', + givenName: 'givenName', + familyName: 'familyName', + suffix: 'suffix', + fullName: 'givenName familyName suffix', + sex: 'male', + type: 'charity', + occupation: 'occupation', + preferredLanguage: 'preferredLanguage', + phoneNumber: 'phoneNumber', + email: 'email@email.com', + address: 'address', + friendlyId: 'P00001', + dateOfBirth: new Date().toISOString(), + } as Patient + + let history: any + let store: MockStore + + const setup = () => { + jest.spyOn(AppointmentRepository, 'saveOrUpdate') + jest.spyOn(AppointmentRepository, 'find') + jest.spyOn(PatientRepository, 'find') + + const mockedAppointmentRepository = mocked(AppointmentRepository, true) + mockedAppointmentRepository.find.mockResolvedValue(appointment) + mockedAppointmentRepository.saveOrUpdate.mockResolvedValue(appointment) + + const mockedPatientRepository = mocked(PatientRepository, true) + mockedPatientRepository.find.mockResolvedValue(patient) + + history = createMemoryHistory() + store = mockStore({ appointment: { appointment, patient } }) + + history.push('/appointments/edit/123') + const wrapper = mount( + + + + + + + , + ) + + wrapper.update() + return wrapper + } + + beforeEach(() => { + jest.restoreAllMocks() + }) + + it('should render an edit appointment form', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + + wrapper.update() + + expect(wrapper.find(AppointmentDetailForm)).toHaveLength(1) + }) + + it('should dispatch fetchAppointment when component loads', async () => { + await act(async () => { + await setup() + }) + + expect(AppointmentRepository.find).toHaveBeenCalledWith(appointment.id) + expect(PatientRepository.find).toHaveBeenCalledWith(appointment.patientId) + expect(store.getActions()).toContainEqual(appointmentSlice.fetchAppointmentStart()) + expect(store.getActions()).toContainEqual( + appointmentSlice.fetchAppointmentSuccess({ appointment, patient }), + ) + }) + + it('should use the correct title', async () => { + jest.spyOn(titleUtil, 'default') + await act(async () => { + await setup() + }) + expect(titleUtil.default).toHaveBeenCalledWith('scheduling.appointments.editAppointment') + }) + + it('should display an error if the end date is before the start date', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + + const startDateTime = roundToNearestMinutes(new Date(), { nearestTo: 15 }) + const endDateTime = subDays(startDateTime, 1) + + wrapper.update() + + act(() => { + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('startDateTime', startDateTime) + }) + + wrapper.update() + + act(() => { + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('endDateTime', endDateTime) + }) + + wrapper.update() + + act(() => { + const saveButton = wrapper.find(Button).at(0) + const onClick = saveButton.prop('onClick') as any + onClick() + }) + + wrapper.update() + + const alert = wrapper.find(Alert) + expect(alert).toHaveLength(1) + expect(alert.prop('message')).toEqual( + 'scheduling.appointment.errors.startDateMustBeBeforeEndDate', + ) + }) + + it('should dispatch updateAppointment when save button is clicked', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + + wrapper.update() + + const saveButton = wrapper.find(Button).at(0) + const onClick = saveButton.prop('onClick') as any + expect(saveButton.text().trim()).toEqual('actions.save') + + await act(async () => { + await onClick() + }) + + expect(AppointmentRepository.saveOrUpdate).toHaveBeenCalledWith(appointment) + expect(store.getActions()).toContainEqual(appointmentSlice.updateAppointmentStart()) + expect(store.getActions()).toContainEqual( + appointmentSlice.updateAppointmentSuccess(appointment), + ) + }) + + it('should navigate to /appointments/:id when cancel is clicked', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + + wrapper.update() + + const cancelButton = wrapper.find(Button).at(1) + const onClick = cancelButton.prop('onClick') as any + expect(cancelButton.text().trim()).toEqual('actions.cancel') + + act(() => { + onClick() + }) + + wrapper.update() + expect(history.location.pathname).toEqual('/appointments/123') + }) +}) diff --git a/src/__tests__/scheduling/appointments/new/NewAppointment.test.tsx b/src/__tests__/scheduling/appointments/new/NewAppointment.test.tsx index 0518c9b43f..8f3c1b8965 100644 --- a/src/__tests__/scheduling/appointments/new/NewAppointment.test.tsx +++ b/src/__tests__/scheduling/appointments/new/NewAppointment.test.tsx @@ -1,63 +1,87 @@ import '../../../../__mocks__/matchMediaMock' import React from 'react' import NewAppointment from 'scheduling/appointments/new/NewAppointment' -import { MemoryRouter, Router } from 'react-router' -import store from 'store' +import { Router, Route } from 'react-router' import { Provider } from 'react-redux' -import { mount, ReactWrapper } from 'enzyme' +import { mount } from 'enzyme' import { Button, Alert } from '@hospitalrun/components' import { roundToNearestMinutes, addMinutes } from 'date-fns' -import { createMemoryHistory } from 'history' +import { createMemoryHistory, MemoryHistory } from 'history' import { act } from '@testing-library/react' import subDays from 'date-fns/subDays' -import AppointmentRepository from 'clients/db/AppointmentsRepository' +import AppointmentRepository from 'clients/db/AppointmentRepository' import { mocked } from 'ts-jest/utils' +import configureMockStore, { MockStore } from 'redux-mock-store' +import thunk from 'redux-thunk' import Appointment from 'model/Appointment' +import Patient from 'model/Patient' import AppointmentDetailForm from 'scheduling/appointments/AppointmentDetailForm' import * as titleUtil from '../../../../page-header/useTitle' -import * as appointmentsSlice from '../../../../scheduling/appointments/appointments-slice' +import * as appointmentSlice from '../../../../scheduling/appointments/appointment-slice' + +const mockStore = configureMockStore([thunk]) describe('New Appointment', () => { - let wrapper: ReactWrapper - let history = createMemoryHistory() - jest.spyOn(AppointmentRepository, 'save') - mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) + let history: MemoryHistory + let store: MockStore + + const setup = () => { + jest.spyOn(AppointmentRepository, 'save') + mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) - beforeEach(() => { history = createMemoryHistory() - wrapper = mount( + store = mockStore({ + appointment: { + appointment: {} as Appointment, + patient: {} as Patient, + }, + }) + + history.push('/appointments/new') + const wrapper = mount( - + + + , ) - }) + + wrapper.update() + return wrapper + } describe('header', () => { - it('should use "New Appointment" as the header', () => { + it('should use "New Appointment" as the header', async () => { jest.spyOn(titleUtil, 'default') - mount( - - - - - , - ) + await act(async () => { + await setup() + }) - expect(titleUtil.default).toHaveBeenCalledWith('scheduling.appointments.new') + expect(titleUtil.default).toHaveBeenCalledWith('scheduling.appointments.newAppointment') }) }) describe('layout', () => { - it('should render a Appointment Detail Component', () => { + it('should render a Appointment Detail Component', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + expect(wrapper.find(AppointmentDetailForm)).toHaveLength(1) }) }) describe('on save click', () => { - it('should call createAppointment with the proper data', () => { - const expectedAppointmentDetails = { + it('should dispatch createAppointment when save button is clicked', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + + const expectedAppointment = { patientId: '123', startDateTime: roundToNearestMinutes(new Date(), { nearestTo: 15 }).toISOString(), endDateTime: addMinutes( @@ -68,30 +92,73 @@ describe('New Appointment', () => { reason: 'reason', type: 'type', } as Appointment - const createAppointmentSpy = jest.spyOn(appointmentsSlice, 'createAppointment') act(() => { const appointmentDetailForm = wrapper.find(AppointmentDetailForm) - const appointmentChangeHandler = appointmentDetailForm.prop('onAppointmentChange') - appointmentChangeHandler(expectedAppointmentDetails) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('patientId', expectedAppointment.patientId) }) + wrapper.update() act(() => { - const saveButton = wrapper.find(Button).at(0) - const onClick = saveButton.prop('onClick') as any - onClick() + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('startDateTime', expectedAppointment.startDateTime) }) + wrapper.update() - expect(createAppointmentSpy).toHaveBeenCalledTimes(1) - expect(createAppointmentSpy).toHaveBeenCalledWith( - expectedAppointmentDetails, - expect.anything(), - ) + act(() => { + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('endDateTime', expectedAppointment.endDateTime) + }) + + wrapper.update() + + act(() => { + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('location', expectedAppointment.location) + }) + + wrapper.update() + + act(() => { + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('reason', expectedAppointment.reason) + }) + + wrapper.update() + + act(() => { + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('type', expectedAppointment.type) + }) + + wrapper.update() + + const saveButton = wrapper.find(Button).at(0) + const onClick = saveButton.prop('onClick') as any + + await act(async () => { + await onClick() + }) + + expect(AppointmentRepository.save).toHaveBeenCalledWith(expectedAppointment) + expect(store.getActions()).toContainEqual(appointmentSlice.createAppointmentStart()) + expect(store.getActions()).toContainEqual(appointmentSlice.createAppointmentSuccess()) }) - it('should display an error if there is no patient id', () => { + it('should display an error if there is no patient id', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + act(() => { const saveButton = wrapper.find(Button).at(0) const onClick = saveButton.prop('onClick') as any @@ -102,22 +169,40 @@ describe('New Appointment', () => { const alert = wrapper.find(Alert) expect(alert).toHaveLength(1) expect(alert.prop('message')).toEqual('scheduling.appointment.errors.patientRequired') - expect(alert.prop('title')).toEqual('scheduling.appointment.errors.errorCreatingAppointment') }) - it('should display an error if the end date is before the start date', () => { - const expectedPatientId = '123' - const expectedStartDateTime = roundToNearestMinutes(new Date(), { nearestTo: 15 }) - const expectedEndDateTime = subDays(expectedStartDateTime, 1) + it('should display an error if the end date is before the start date', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + + const patientId = '123' + const startDateTime = roundToNearestMinutes(new Date(), { nearestTo: 15 }) + const endDateTime = subDays(startDateTime, 1) act(() => { - const onAppointmentChange = wrapper.find(AppointmentDetailForm).prop('onAppointmentChange') - onAppointmentChange({ - patientId: expectedPatientId, - startDateTime: expectedStartDateTime.toISOString(), - endDateTime: expectedEndDateTime.toISOString(), - } as Appointment) + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('patientId', patientId) }) + + wrapper.update() + + act(() => { + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('startDateTime', startDateTime) + }) + + wrapper.update() + + act(() => { + const appointmentDetailForm = wrapper.find(AppointmentDetailForm) + const onFieldChange = appointmentDetailForm.prop('onFieldChange') + onFieldChange('endDateTime', endDateTime) + }) + wrapper.update() act(() => { @@ -125,6 +210,7 @@ describe('New Appointment', () => { const onClick = saveButton.prop('onClick') as any onClick() }) + wrapper.update() const alert = wrapper.find(Alert) @@ -132,12 +218,16 @@ describe('New Appointment', () => { expect(alert.prop('message')).toEqual( 'scheduling.appointment.errors.startDateMustBeBeforeEndDate', ) - expect(alert.prop('title')).toEqual('scheduling.appointment.errors.errorCreatingAppointment') }) }) describe('on cancel click', () => { - it('should navigate back to /appointments', () => { + it('should navigate back to /appointments', async () => { + let wrapper: any + await act(async () => { + wrapper = await setup() + }) + const cancelButton = wrapper.find(Button).at(1) act(() => { diff --git a/src/__tests__/scheduling/appointments/view/ViewAppointment.test.tsx b/src/__tests__/scheduling/appointments/view/ViewAppointment.test.tsx index 45f3f3545e..6316dc2d30 100644 --- a/src/__tests__/scheduling/appointments/view/ViewAppointment.test.tsx +++ b/src/__tests__/scheduling/appointments/view/ViewAppointment.test.tsx @@ -8,7 +8,7 @@ import Appointment from 'model/Appointment' import ViewAppointment from 'scheduling/appointments/view/ViewAppointment' import { Router, Route } from 'react-router' import { createMemoryHistory } from 'history' -import AppointmentRepository from 'clients/db/AppointmentsRepository' +import AppointmentRepository from 'clients/db/AppointmentRepository' import { mocked } from 'ts-jest/utils' import { act } from 'react-dom/test-utils' import { Spinner } from '@hospitalrun/components' @@ -81,7 +81,7 @@ describe('View Appointment', () => { await setup(true) }) - expect(titleUtil.default).toHaveBeenCalledWith('scheduling.appointments.view') + expect(titleUtil.default).toHaveBeenCalledWith('scheduling.appointments.viewAppointment') }) it('should dispatch getAppointment if id is present', async () => { diff --git a/src/clients/db/AppointmentsRepository.ts b/src/clients/db/AppointmentRepository.ts similarity index 100% rename from src/clients/db/AppointmentsRepository.ts rename to src/clients/db/AppointmentRepository.ts diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index ebb8c1b682..ef44e012b4 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -63,7 +63,7 @@ const Navbar = () => { }, { type: 'link', - label: t('scheduling.appointments.new'), + label: t('scheduling.appointments.newAppointment'), onClick: () => { history.push('/appointments/new') }, diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index a6632e20c6..0aada8be38 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -76,8 +76,9 @@ "label": "Scheduling", "appointments": { "label": "Appointments", - "new": "New Appointment", - "view": "View Appointment" + "newAppointment": "New Appointment", + "editAppointment": "Edit Appointment", + "viewAppointment": "View Appointment" }, "appointment": { "startDate": "Start Date", @@ -93,7 +94,6 @@ }, "errors": { "patientRequired": "Patient is required.", - "errorCreatingAppointment": "Error Creating Appointment!", "startDateMustBeBeforeEndDate": "Start Time must be before End Time." }, "reason": "Reason", diff --git a/src/scheduling/appointments/AppointmentDetailForm.tsx b/src/scheduling/appointments/AppointmentDetailForm.tsx index ee55895f3c..0b6c1c64ef 100644 --- a/src/scheduling/appointments/AppointmentDetailForm.tsx +++ b/src/scheduling/appointments/AppointmentDetailForm.tsx @@ -1,7 +1,7 @@ import React from 'react' import Appointment from 'model/Appointment' import DateTimePickerWithLabelFormGroup from 'components/input/DateTimePickerWithLabelFormGroup' -import { Typeahead, Label } from '@hospitalrun/components' +import { Typeahead, Label, Alert } from '@hospitalrun/components' import Patient from 'model/Patient' import PatientRepository from 'clients/db/PatientRepository' import TextInputWithLabelFormGroup from 'components/input/TextInputWithLabelFormGroup' @@ -13,42 +13,42 @@ interface Props { appointment: Appointment patient?: Patient isEditable: boolean - onAppointmentChange: (appointment: Appointment) => void + errorMessage?: string + onFieldChange?: (key: string, value: string | boolean) => void } const AppointmentDetailForm = (props: Props) => { - const { onAppointmentChange, appointment, patient, isEditable } = props + const { onFieldChange, appointment, patient, isEditable, errorMessage } = props const { t } = useTranslation() + + const onSelectChange = (event: React.ChangeEvent, fieldName: string) => + onFieldChange && onFieldChange(fieldName, event.target.value) + + const onDateChange = (date: Date, fieldName: string) => + onFieldChange && onFieldChange(fieldName, date.toISOString()) + + const onInputElementChange = (event: React.ChangeEvent, fieldName: string) => + onFieldChange && onFieldChange(fieldName, event.target.value) + return ( <> + {errorMessage && }
- {isEditable && !patient ? ( - <> -
@@ -59,8 +59,8 @@ const AppointmentDetailForm = (props: Props) => { label={t('scheduling.appointment.startDate')} value={new Date(appointment.startDateTime)} isEditable={isEditable} - onChange={(date) => { - onAppointmentChange({ ...appointment, startDateTime: date.toISOString() }) + onChange={(date: Date) => { + onDateChange(date, 'startDateTime') }} /> @@ -70,8 +70,8 @@ const AppointmentDetailForm = (props: Props) => { label={t('scheduling.appointment.endDate')} value={new Date(appointment.endDateTime)} isEditable={isEditable} - onChange={(date) => { - onAppointmentChange({ ...appointment, endDateTime: date.toISOString() }) + onChange={(date: Date) => { + onDateChange(date, 'endDateTime') }} /> @@ -84,7 +84,7 @@ const AppointmentDetailForm = (props: Props) => { value={appointment.location} isEditable={isEditable} onChange={(event) => { - onAppointmentChange({ ...appointment, location: event?.target.value }) + onInputElementChange(event, 'location') }} /> @@ -104,7 +104,7 @@ const AppointmentDetailForm = (props: Props) => { { label: t('scheduling.appointment.types.walkUp'), value: 'walk up' }, ]} onChange={(event: React.ChangeEvent) => { - onAppointmentChange({ ...appointment, type: event.currentTarget.value }) + onSelectChange(event, 'type') }} /> @@ -117,9 +117,11 @@ const AppointmentDetailForm = (props: Props) => { label={t('scheduling.appointment.reason')} value={appointment.reason} isEditable={isEditable} - onChange={(event) => { - onAppointmentChange({ ...appointment, reason: event?.target.value }) - }} + onChange={ + (event: React.ChangeEvent) => + onFieldChange && onFieldChange('reason', event.currentTarget.value) + // eslint-disable-next-line react/jsx-curly-newline + } /> diff --git a/src/scheduling/appointments/appointment-slice.ts b/src/scheduling/appointments/appointment-slice.ts index aa710d76d4..7966ae3bc8 100644 --- a/src/scheduling/appointments/appointment-slice.ts +++ b/src/scheduling/appointments/appointment-slice.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import Appointment from 'model/Appointment' import { AppThunk } from 'store' -import AppointmentRepository from 'clients/db/AppointmentsRepository' +import AppointmentRepository from 'clients/db/AppointmentRepository' import Patient from 'model/Patient' import PatientRepository from 'clients/db/PatientRepository' @@ -17,13 +17,17 @@ const initialAppointmentState = { isLoading: false, } +function startLoading(state: AppointmentState) { + state.isLoading = true +} + const appointmentSlice = createSlice({ name: 'appointment', initialState: initialAppointmentState, reducers: { - fetchAppointmentStart: (state: AppointmentState) => { - state.isLoading = true - }, + fetchAppointmentStart: startLoading, + createAppointmentStart: startLoading, + updateAppointmentStart: startLoading, fetchAppointmentSuccess: ( state, { payload }: PayloadAction<{ appointment: Appointment; patient: Patient }>, @@ -32,10 +36,24 @@ const appointmentSlice = createSlice({ state.appointment = payload.appointment state.patient = payload.patient }, + createAppointmentSuccess(state) { + state.isLoading = false + }, + updateAppointmentSuccess(state, { payload }: PayloadAction) { + state.isLoading = false + state.appointment = payload + }, }, }) -export const { fetchAppointmentStart, fetchAppointmentSuccess } = appointmentSlice.actions +export const { + createAppointmentStart, + createAppointmentSuccess, + updateAppointmentStart, + updateAppointmentSuccess, + fetchAppointmentStart, + fetchAppointmentSuccess, +} = appointmentSlice.actions export const fetchAppointment = (id: string): AppThunk => async (dispatch) => { dispatch(fetchAppointmentStart()) @@ -45,4 +63,22 @@ export const fetchAppointment = (id: string): AppThunk => async (dispatch) => { dispatch(fetchAppointmentSuccess({ appointment, patient })) } +export const createAppointment = (appointment: Appointment, history: any): AppThunk => async ( + dispatch, +) => { + dispatch(createAppointmentStart()) + await AppointmentRepository.save(appointment) + dispatch(createAppointmentSuccess()) + history.push('/appointments') +} + +export const updateAppointment = (appointment: Appointment, history: any): AppThunk => async ( + dispatch, +) => { + dispatch(updateAppointmentStart()) + const updatedAppointment = await AppointmentRepository.saveOrUpdate(appointment) + dispatch(updateAppointmentSuccess(updatedAppointment)) + history.push(`/appointments/${updatedAppointment.id}`) +} + export default appointmentSlice.reducer diff --git a/src/scheduling/appointments/appointments-slice.ts b/src/scheduling/appointments/appointments-slice.ts index fd90b05537..7290275aac 100644 --- a/src/scheduling/appointments/appointments-slice.ts +++ b/src/scheduling/appointments/appointments-slice.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import Appointment from 'model/Appointment' import { AppThunk } from 'store' -import AppointmentRepository from 'clients/db/AppointmentsRepository' +import AppointmentRepository from 'clients/db/AppointmentRepository' interface AppointmentsState { isLoading: boolean diff --git a/src/scheduling/appointments/edit/EditAppointment.tsx b/src/scheduling/appointments/edit/EditAppointment.tsx new file mode 100644 index 0000000000..1cca80243f --- /dev/null +++ b/src/scheduling/appointments/edit/EditAppointment.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import { Spinner, Button } from '@hospitalrun/components' +import { isBefore } from 'date-fns' + +import AppointmentDetailForm from '../AppointmentDetailForm' +import useTitle from '../../../page-header/useTitle' +import Appointment from '../../../model/Appointment' +import { updateAppointment, fetchAppointment } from '../appointment-slice' +import { RootState } from '../../../store' + +const EditAppointment = () => { + const { t } = useTranslation() + useTitle(t('scheduling.appointments.editAppointment')) + const history = useHistory() + const dispatch = useDispatch() + + const [appointment, setAppointment] = useState({} as Appointment) + const [errorMessage, setErrorMessage] = useState('') + const { appointment: reduxAppointment, patient, isLoading } = useSelector( + (state: RootState) => state.appointment, + ) + + useEffect(() => { + setAppointment(reduxAppointment) + }, [reduxAppointment]) + + const { id } = useParams() + useEffect(() => { + if (id) { + dispatch(fetchAppointment(id)) + } + }, [id, dispatch]) + + const onCancel = () => { + history.push(`/appointments/${appointment.id}`) + } + + const onSave = () => { + let newErrorMessage = '' + if (isBefore(new Date(appointment.endDateTime), new Date(appointment.startDateTime))) { + newErrorMessage += ` ${t('scheduling.appointment.errors.startDateMustBeBeforeEndDate')}` + } + + if (newErrorMessage) { + setErrorMessage(newErrorMessage.trim()) + return + } + + dispatch(updateAppointment(appointment as Appointment, history)) + } + + const onFieldChange = (key: string, value: string | boolean) => { + setAppointment({ + ...appointment, + [key]: value, + }) + } + + if (isLoading || !appointment.id || !patient.id) { + return + } + + return ( +
+ +
+
+ + +
+
+
+ ) +} + +export default EditAppointment diff --git a/src/scheduling/appointments/new/NewAppointment.tsx b/src/scheduling/appointments/new/NewAppointment.tsx index 538ae33f98..4adc7e2982 100644 --- a/src/scheduling/appointments/new/NewAppointment.tsx +++ b/src/scheduling/appointments/new/NewAppointment.tsx @@ -8,15 +8,15 @@ import { useDispatch } from 'react-redux' import Appointment from 'model/Appointment' import addMinutes from 'date-fns/addMinutes' import { isBefore } from 'date-fns' -import { Button, Alert } from '@hospitalrun/components' -import { createAppointment } from '../appointments-slice' +import { Button } from '@hospitalrun/components' +import { createAppointment } from '../appointment-slice' import AppointmentDetailForm from '../AppointmentDetailForm' const NewAppointment = () => { const { t } = useTranslation() const history = useHistory() const dispatch = useDispatch() - useTitle(t('scheduling.appointments.new')) + useTitle(t('scheduling.appointments.newAppointment')) const startDateTime = roundToNearestMinutes(new Date(), { nearestTo: 15 }) const endDateTime = addMinutes(startDateTime, 60) @@ -34,7 +34,7 @@ const NewAppointment = () => { history.push('/appointments') } - const onSaveClick = () => { + const onSave = () => { let newErrorMessage = '' if (!appointment.patientId) { newErrorMessage += t('scheduling.appointment.errors.patientRequired') @@ -51,24 +51,25 @@ const NewAppointment = () => { dispatch(createAppointment(appointment as Appointment, history)) } + const onFieldChange = (key: string, value: string | boolean) => { + setAppointment({ + ...appointment, + [key]: value, + }) + } + return (
- {errorMessage && ( - - )}
- +
+
+
) }