From 723bec3b05184631fd611f0a9c65b495974f289f Mon Sep 17 00:00:00 2001 From: Jack Meyer Date: Sun, 26 Jan 2020 17:29:54 -0600 Subject: [PATCH] feat(appointments): add create appointment functionality --- .../clients/db/AppointmentRepository.test.ts | 14 + .../DateTimePickerWithLabelFormGroup.test.tsx | 101 +++++++ .../scheduling/appointments-slice.test.ts | 89 ++++++ .../appointments/new/NewAppointment.test.tsx | 265 +++++++++++++++++- .../DateTimePickerWithLabelFormGroup.tsx | 39 +++ src/locales/en-US/translation.json | 20 ++ src/model/Appointment.ts | 4 +- .../appointments/appointments-slice.ts | 36 +++ .../appointments/new/NewAppointment.tsx | 165 ++++++++++- 9 files changed, 721 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/components/input/DateTimePickerWithLabelFormGroup.test.tsx create mode 100644 src/__tests__/scheduling/appointments-slice.test.ts create mode 100644 src/components/input/DateTimePickerWithLabelFormGroup.tsx create mode 100644 src/scheduling/appointments/appointments-slice.ts diff --git a/src/__tests__/clients/db/AppointmentRepository.test.ts b/src/__tests__/clients/db/AppointmentRepository.test.ts index f151d59240..c249e4a817 100644 --- a/src/__tests__/clients/db/AppointmentRepository.test.ts +++ b/src/__tests__/clients/db/AppointmentRepository.test.ts @@ -1,8 +1,22 @@ import AppointmentRepository from 'clients/db/AppointmentsRepository' import { appointments } from 'config/pouchdb' +import Appointment from 'model/Appointment' +import { fromUnixTime } from 'date-fns' describe('Appointment Repository', () => { it('should create a repository with the database set to the appointments database', () => { expect(AppointmentRepository.db).toEqual(appointments) }) + + describe('save', () => { + it('should create an id that is a timestamp', async () => { + const newAppointment = await AppointmentRepository.save({ + patientId: 'id', + } as Appointment) + + expect(fromUnixTime(parseInt(newAppointment.id, 10)).getTime() > 0).toBeTruthy() + + await appointments.remove(await appointments.get(newAppointment.id)) + }) + }) }) diff --git a/src/__tests__/components/input/DateTimePickerWithLabelFormGroup.test.tsx b/src/__tests__/components/input/DateTimePickerWithLabelFormGroup.test.tsx new file mode 100644 index 0000000000..cba26f9131 --- /dev/null +++ b/src/__tests__/components/input/DateTimePickerWithLabelFormGroup.test.tsx @@ -0,0 +1,101 @@ +import '../../../__mocks__/matchMediaMock' +import React, { ChangeEvent } from 'react' +import { DateTimePicker, Label } from '@hospitalrun/components' +import { shallow } from 'enzyme' +import DateTimePickerWithLabelFormGroup from '../../../components/input/DateTimePickerWithLabelFormGroup' + +describe('date picker with label form group', () => { + describe('layout', () => { + it('should render a label', () => { + const expectedName = 'test' + const wrapper = shallow( + , + ) + + const label = wrapper.find(Label) + expect(label).toHaveLength(1) + expect(label.prop('htmlFor')).toEqual(`${expectedName}DateTimePicker`) + expect(label.prop('text')).toEqual(expectedName) + }) + + it('should render and date time picker', () => { + const expectedName = 'test' + const wrapper = shallow( + , + ) + + const input = wrapper.find(DateTimePicker) + expect(input).toHaveLength(1) + }) + + it('should render disabled is isDisable disabled is true', () => { + const expectedName = 'test' + const wrapper = shallow( + , + ) + + const input = wrapper.find(DateTimePicker) + expect(input).toHaveLength(1) + expect(input.prop('disabled')).toBeTruthy() + }) + + it('should render the proper value', () => { + const expectedName = 'test' + const expectedValue = new Date() + const wrapper = shallow( + , + ) + + const input = wrapper.find(DateTimePicker) + expect(input).toHaveLength(1) + expect(input.prop('selected')).toEqual(expectedValue) + }) + }) + + describe('change handler', () => { + it('should call the change handler on change', () => { + const expectedName = 'test' + const expectedValue = new Date() + const handler = jest.fn() + const wrapper = shallow( + , + ) + + const input = wrapper.find(DateTimePicker) + input.prop('onChange')(new Date(), { + target: { value: new Date().toISOString() }, + } as ChangeEvent) + expect(handler).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/__tests__/scheduling/appointments-slice.test.ts b/src/__tests__/scheduling/appointments-slice.test.ts new file mode 100644 index 0000000000..09410d3e6d --- /dev/null +++ b/src/__tests__/scheduling/appointments-slice.test.ts @@ -0,0 +1,89 @@ +import { AnyAction } from 'redux' +import Appointment from 'model/Appointment' +import { createMemoryHistory } from 'history' +import AppointmentRepository from 'clients/db/AppointmentsRepository' +import appointments, { + createAppointmentStart, + createAppointment, +} from '../../scheduling/appointments/appointments-slice' +import { mocked } from 'ts-jest/utils' + +describe('appointments slice', () => { + describe('appointments reducer', () => { + it('should create the initial state properly', () => { + const appointmentsStore = appointments(undefined, {} as AnyAction) + + expect(appointmentsStore.isLoading).toBeFalsy() + }) + it('should handle the CREATE_APPOINTMENT_START action', () => { + const appointmentsStore = appointments(undefined, { + type: createAppointmentStart.type, + }) + + expect(appointmentsStore.isLoading).toBeTruthy() + }) + }) + + 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/new/NewAppointment.test.tsx b/src/__tests__/scheduling/appointments/new/NewAppointment.test.tsx index b0d2cc6130..3d50f419b0 100644 --- a/src/__tests__/scheduling/appointments/new/NewAppointment.test.tsx +++ b/src/__tests__/scheduling/appointments/new/NewAppointment.test.tsx @@ -1,23 +1,272 @@ import '../../../../__mocks__/matchMediaMock' import React from 'react' import NewAppointment from 'scheduling/appointments/new/NewAppointment' -import { MemoryRouter } from 'react-router' +import { MemoryRouter, Router } from 'react-router' import store from 'store' import { Provider } from 'react-redux' -import { mount } from 'enzyme' +import { mount, ReactWrapper } from 'enzyme' +import { Typeahead, Button, Alert } from '@hospitalrun/components' +import { roundToNearestMinutes, addMinutes } from 'date-fns' +import { createMemoryHistory } from 'history' +import { act } from '@testing-library/react' +import subDays from 'date-fns/subDays' +import Patient from 'model/Patient' +import PatientRepository from 'clients/db/PatientRepository' +import AppointmentRepository from 'clients/db/AppointmentsRepository' +import { mocked } from 'ts-jest/utils' +import Appointment from 'model/Appointment' import * as titleUtil from '../../../../page-header/useTitle' +import * as appointmentsSlice from '../../../../scheduling/appointments/appointments-slice' describe('New Appointment', () => { - it('should use "New Appointment" as the header', () => { - jest.spyOn(titleUtil, 'default') - mount( + let wrapper: ReactWrapper + let history = createMemoryHistory() + jest.spyOn(AppointmentRepository, 'save') + mocked(AppointmentRepository, true).save.mockResolvedValue({ id: '123' } as Appointment) + + beforeEach(() => { + history = createMemoryHistory() + wrapper = mount( - + - + , ) + }) + + describe('header', () => { + it('should use "New Appointment" as the header', () => { + jest.spyOn(titleUtil, 'default') + mount( + + + + + , + ) + + expect(titleUtil.default).toHaveBeenCalledWith('scheduling.appointments.new') + }) + }) + + describe('layout', () => { + it('should render a typeahead for patients', () => { + const patientTypeahead = wrapper.find(Typeahead) + + expect(patientTypeahead).toHaveLength(1) + expect(patientTypeahead.prop('placeholder')).toEqual('scheduling.appointment.patient') + }) + + it('should render as start date date time picker', () => { + const startDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'startDate') + + expect(startDateTimePicker).toHaveLength(1) + expect(startDateTimePicker.prop('label')).toEqual('scheduling.appointment.startDate') + expect(startDateTimePicker.prop('value')).toEqual( + roundToNearestMinutes(new Date(), { nearestTo: 15 }), + ) + }) + + it('should render an end date time picker', () => { + const endDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'endDate') + + expect(endDateTimePicker).toHaveLength(1) + expect(endDateTimePicker.prop('label')).toEqual('scheduling.appointment.endDate') + expect(endDateTimePicker.prop('value')).toEqual( + addMinutes(roundToNearestMinutes(new Date(), { nearestTo: 15 }), 60), + ) + }) + + it('should render a location text input box', () => { + const locationTextInputBox = wrapper.findWhere((w) => w.prop('name') === 'location') + + expect(locationTextInputBox).toHaveLength(1) + expect(locationTextInputBox.prop('label')).toEqual('scheduling.appointment.location') + }) + + it('should render a type select box', () => { + const typeSelect = wrapper.findWhere((w) => w.prop('name') === 'type') + + expect(typeSelect).toHaveLength(1) + expect(typeSelect.prop('label')).toEqual('scheduling.appointment.type') + expect(typeSelect.prop('options')[0].label).toEqual('scheduling.appointment.types.checkup') + expect(typeSelect.prop('options')[0].value).toEqual('checkup') + expect(typeSelect.prop('options')[1].label).toEqual('scheduling.appointment.types.emergency') + expect(typeSelect.prop('options')[1].value).toEqual('emergency') + expect(typeSelect.prop('options')[2].label).toEqual('scheduling.appointment.types.followUp') + expect(typeSelect.prop('options')[2].value).toEqual('follow up') + expect(typeSelect.prop('options')[3].label).toEqual('scheduling.appointment.types.routine') + expect(typeSelect.prop('options')[3].value).toEqual('routine') + expect(typeSelect.prop('options')[4].label).toEqual('scheduling.appointment.types.walkUp') + expect(typeSelect.prop('options')[4].value).toEqual('walk up') + }) + + it('should render a reason text field input', () => { + const reasonTextField = wrapper.findWhere((w) => w.prop('name') === 'reason') + + expect(reasonTextField).toHaveLength(1) + expect(reasonTextField.prop('label')).toEqual('scheduling.appointment.reason') + }) + + it('should render a save button', () => { + const saveButton = wrapper.find(Button).at(0) + + expect(saveButton).toHaveLength(1) + expect(saveButton.text().trim()).toEqual('actions.save') + }) + + it('should render a cancel button', () => { + const cancelButton = wrapper.find(Button).at(1) + + expect(cancelButton).toHaveLength(1) + expect(cancelButton.text().trim()).toEqual('actions.cancel') + }) + }) + + describe('typeahead search', () => { + it('should call the PatientRepository search when typeahead changes', () => { + const patientTypeahead = wrapper.find(Typeahead) + const patientRepositorySearch = jest.spyOn(PatientRepository, 'search') + const expectedSearchString = 'search' + + act(() => { + patientTypeahead.prop('onSearch')(expectedSearchString) + }) + + expect(patientRepositorySearch).toHaveBeenCalledWith(expectedSearchString) + }) + }) + + describe('on save click', () => { + it('should call createAppointment with the proper date', () => { + const createAppointmentSpy = jest.spyOn(appointmentsSlice, 'createAppointment') + const expectedPatientId = '123' + const expectedStartDateTime = roundToNearestMinutes(new Date(), { nearestTo: 15 }) + const expectedEndDateTime = addMinutes(expectedStartDateTime, 30) + const expectedLocation = 'location' + const expectedType = 'follow up' + const expectedReason = 'reason' + + act(() => { + const patientTypeahead = wrapper.find(Typeahead) + patientTypeahead.prop('onChange')([{ id: expectedPatientId }] as Patient[]) + }) + wrapper.update() + + act(() => { + const startDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'startDate') + startDateTimePicker.prop('onChange')(expectedStartDateTime) + }) + wrapper.update() + + act(() => { + const endDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'endDate') + endDateTimePicker.prop('onChange')(expectedEndDateTime) + }) + wrapper.update() + + act(() => { + const locationTextInputBox = wrapper.findWhere((w) => w.prop('name') === 'location') + locationTextInputBox.prop('onChange')({ target: { value: expectedLocation } }) + }) + wrapper.update() + + act(() => { + const typeSelect = wrapper.findWhere((w) => w.prop('name') === 'type') + typeSelect.prop('onChange')({ currentTarget: { value: expectedType } }) + }) + wrapper.update() + + act(() => { + const reasonTextField = wrapper.findWhere((w) => w.prop('name') === 'reason') + reasonTextField.prop('onChange')({ target: { value: expectedReason } }) + }) + wrapper.update() + + act(() => { + const saveButton = wrapper.find(Button).at(0) + const onClick = saveButton.prop('onClick') as any + onClick() + }) + + expect(createAppointmentSpy).toHaveBeenCalledTimes(1) + expect(createAppointmentSpy).toHaveBeenCalledWith( + { + patientId: expectedPatientId, + startDateTime: expectedStartDateTime.toISOString(), + endDateTime: expectedEndDateTime.toISOString(), + location: expectedLocation, + reason: expectedReason, + type: expectedType, + }, + expect.anything(), + ) + }) + + it('should display an error if there is no patient id', () => { + 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.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) + + act(() => { + const patientTypeahead = wrapper.find(Typeahead) + patientTypeahead.prop('onChange')([{ id: expectedPatientId }] as Patient[]) + }) + wrapper.update() + + act(() => { + const startDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'startDate') + startDateTimePicker.prop('onChange')(expectedStartDateTime) + }) + wrapper.update() + + act(() => { + const endDateTimePicker = wrapper.findWhere((w) => w.prop('name') === 'endDate') + endDateTimePicker.prop('onChange')(expectedEndDateTime) + }) + 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', + ) + expect(alert.prop('title')).toEqual('scheduling.appointment.errors.errorCreatingAppointment') + }) + }) + + describe('on cancel click', () => { + it('should navigate back to /appointments', () => { + const cancelButton = wrapper.find(Button).at(1) + + act(() => { + const onClick = cancelButton.prop('onClick') as any + onClick() + }) - expect(titleUtil.default).toHaveBeenCalledWith('scheduling.appointments.new') + expect(history.location.pathname).toEqual('/appointments') + }) }) }) diff --git a/src/components/input/DateTimePickerWithLabelFormGroup.tsx b/src/components/input/DateTimePickerWithLabelFormGroup.tsx new file mode 100644 index 0000000000..72e6cdc241 --- /dev/null +++ b/src/components/input/DateTimePickerWithLabelFormGroup.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { Label, DateTimePicker } from '@hospitalrun/components' + +interface Props { + name: string + label: string + value: Date | undefined + isEditable: boolean + onChange?: (date: Date) => void +} + +const DateTimePickerWithLabelFormGroup = (props: Props) => { + const { onChange, label, name, isEditable, value } = props + const id = `${name}DateTimePicker` + return ( +
+
+ ) +} + +export default DateTimePickerWithLabelFormGroup diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 9cb4a0b156..36d04ec35d 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -68,6 +68,26 @@ "appointments": { "label": "Appointments", "new": "New Appointment" + }, + "appointment": { + "startDate": "Start Date", + "endDate": "End Date", + "location": "Location", + "type": "Type", + "types": { + "checkup": "Checkup", + "emergency": "Emergency", + "followUp": "Follow Up", + "routine": "Routine", + "walkUp": "Walk Up" + }, + "errors": { + "patientRequired": "Patient is required.", + "errorCreatingAppointment": "Error Creating Appointment!", + "startDateMustBeBeforeEndDate": "Start Time must be before End Time." + }, + "reason": "Reason", + "patient": "Patient" } } } diff --git a/src/model/Appointment.ts b/src/model/Appointment.ts index 5ca58cbd1f..a82dc4d590 100644 --- a/src/model/Appointment.ts +++ b/src/model/Appointment.ts @@ -4,7 +4,7 @@ export default interface Appointment extends AbstractDBModel { startDateTime: string endDateTime: string patientId: string - title: string location: string - notes: string + reason: string + type: string } diff --git a/src/scheduling/appointments/appointments-slice.ts b/src/scheduling/appointments/appointments-slice.ts new file mode 100644 index 0000000000..a7a12e8125 --- /dev/null +++ b/src/scheduling/appointments/appointments-slice.ts @@ -0,0 +1,36 @@ +import { createSlice } from '@reduxjs/toolkit' +import Appointment from 'model/Appointment' +import { AppThunk } from 'store' +import AppointmentRepository from 'clients/db/AppointmentsRepository' + +interface AppointmentsState { + isLoading: boolean +} + +const initialState: AppointmentsState = { + isLoading: false, +} + +function startLoading(state: AppointmentsState) { + state.isLoading = true +} + +const appointmentsSlice = createSlice({ + name: 'appointments', + initialState, + reducers: { + createAppointmentStart: startLoading, + }, +}) + +export const { createAppointmentStart } = appointmentsSlice.actions + +export const createAppointment = (appointment: Appointment, history: any): AppThunk => async ( + dispatch, +) => { + dispatch(createAppointmentStart()) + await AppointmentRepository.save(appointment) + history.push('/appointments') +} + +export default appointmentsSlice.reducer diff --git a/src/scheduling/appointments/new/NewAppointment.tsx b/src/scheduling/appointments/new/NewAppointment.tsx index 74b0256168..e434f68d3b 100644 --- a/src/scheduling/appointments/new/NewAppointment.tsx +++ b/src/scheduling/appointments/new/NewAppointment.tsx @@ -1,12 +1,173 @@ -import React from 'react' +import React, { useState } from 'react' import useTitle from 'page-header/useTitle' import { useTranslation } from 'react-i18next' +import DateTimePickerWithLabelFormGroup from 'components/input/DateTimePickerWithLabelFormGroup' +import { Typeahead, Label, Button, Alert } from '@hospitalrun/components' +import Patient from 'model/Patient' +import PatientRepository from 'clients/db/PatientRepository' +import TextInputWithLabelFormGroup from 'components/input/TextInputWithLabelFormGroup' +import TextFieldWithLabelFormGroup from 'components/input/TextFieldWithLabelFormGroup' +import SelectWithLabelFormGroup from 'components/input/SelectWithLableFormGroup' +import roundToNearestMinutes from 'date-fns/roundToNearestMinutes' +import { useHistory } from 'react-router' +import { useDispatch } from 'react-redux' +import Appointment from 'model/Appointment' +import addMinutes from 'date-fns/addMinutes' +import { isBefore } from 'date-fns' +import { createAppointment } from '../appointments-slice' const NewAppointment = () => { const { t } = useTranslation() + const history = useHistory() + const dispatch = useDispatch() useTitle(t('scheduling.appointments.new')) + const startDateTime = roundToNearestMinutes(new Date(), { nearestTo: 15 }) + const endDateTime = addMinutes(startDateTime, 60) - return

{t('scheduling.appointments.new')}

+ const [appointment, setAppointment] = useState({ + patientId: '', + startDateTime: startDateTime.toISOString(), + endDateTime: endDateTime.toISOString(), + location: '', + reason: '', + type: '', + }) + const [errorMessage, setErrorMessage] = useState('') + + const onCancelClick = () => { + history.push('/appointments') + } + + const onSaveClick = () => { + let newErrorMessage = '' + if (!appointment.patientId) { + newErrorMessage += t('scheduling.appointment.errors.patientRequired') + } + if (isBefore(new Date(appointment.endDateTime), new Date(appointment.startDateTime))) { + newErrorMessage += ` ${t('scheduling.appointment.errors.startDateMustBeBeforeEndDate')}` + } + + if (newErrorMessage) { + setErrorMessage(newErrorMessage.trim()) + return + } + + dispatch(createAppointment(appointment as Appointment, history)) + } + + return ( +
+
+ {errorMessage && ( + + )} +
+
+
+
+
+
+
+
+ { + setAppointment({ ...appointment, startDateTime: date.toISOString() }) + }} + /> +
+
+ { + setAppointment({ ...appointment, endDateTime: date.toISOString() }) + }} + /> +
+
+
+
+ { + setAppointment({ ...appointment, location: event?.target.value }) + }} + /> +
+
+
+
+ ) => { + setAppointment({ ...appointment, type: event.currentTarget.value }) + }} + /> +
+
+
+
+
+ { + setAppointment({ ...appointment, reason: event?.target.value }) + }} + /> +
+
+
+
+
+ + +
+
+ +
+ ) } export default NewAppointment