diff --git a/packages/esm-patient-chart-app/src/visit/visit-form/visit-date-time.component.tsx b/packages/esm-patient-chart-app/src/visit/visit-form/visit-date-time.component.tsx index a4df9cd2b2..543cbccc66 100644 --- a/packages/esm-patient-chart-app/src/visit/visit-form/visit-date-time.component.tsx +++ b/packages/esm-patient-chart-app/src/visit/visit-form/visit-date-time.component.tsx @@ -54,18 +54,18 @@ const VisitDateTimeField: React.FC = ({ dateFormat="d/m/Y" datePickerType="single" id={dateFieldName} - minDate={minDateObj} maxDate={maxDateObj} + minDate={minDateObj} onChange={([date]) => onChange(date)} value={value ? dayjs(value).format('DD/MM/YYYY') : null} > @@ -78,26 +78,24 @@ const VisitDateTimeField: React.FC = ({ render={({ field: { onBlur, onChange, value } }) => ( onChange(event.target.value as amPm)} pattern="^(1[0-2]|0?[1-9]):([0-5]?[0-9])$" style={{ marginLeft: '0.125rem', flex: 'none' }} value={value} - onBlur={onBlur} - invalid={errors[timeFieldName]?.message} - invalidText={errors[timeFieldName]?.message} > ( onChange(event.target.value as amPm)} value={value} - aria-label={t('timeFormat ', 'Time Format')} - invalid={errors[timeFormatFieldName]?.message} - invalidText={errors[timeFormatFieldName]?.message} > diff --git a/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.component.tsx b/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.component.tsx index bcadb96d50..d6e343524b 100644 --- a/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.component.tsx +++ b/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.component.tsx @@ -121,58 +121,73 @@ const StartVisitForm: React.FC = ({ }); const displayVisitStopDateTimeFields = useMemo( - () => visitToEdit?.stopDatetime || showVisitEndDateTimeFields, + () => Boolean(visitToEdit?.stopDatetime || showVisitEndDateTimeFields), [visitToEdit?.stopDatetime, showVisitEndDateTimeFields], ); const visitFormSchema = useMemo(() => { + const createVisitAttributeSchema = (required: boolean) => + required + ? z + .string({ + required_error: t('fieldRequired', 'This field is required'), + }) + .refine((value) => !!value, t('fieldRequired', 'This field is required')) + : z.string().optional(); + const visitAttributes = (config.visitAttributeTypes ?? [])?.reduce( (acc, { uuid, required }) => ({ ...acc, - [uuid]: required - ? z - .string({ - required_error: t('fieldRequired', 'This field is required'), - }) - .refine((value) => !!value, t('fieldRequired', 'This field is required')) - : z.string().optional(), + [uuid]: createVisitAttributeSchema(required), }), {}, ); - return z.object({ - visitStartDate: z.date().refine( - (value) => { - const today = dayjs(); - const startDate = dayjs(value); - return displayVisitStopDateTimeFields ? true : startDate.isSameOrBefore(today, 'day'); - }, - t('invalidVisitStartDate', 'Start date needs to be on or before {{firstEncounterDatetime}}', { - firstEncounterDatetime: formatDatetime(new Date()), - interpolation: { - escapeValue: false, + // Validates that the start time is not in the future + const validateStartTime = (data: z.infer) => { + const [visitStartHours, visitStartMinutes] = convertTime12to24(data.visitStartTime, data.visitStartTimeFormat); + const visitStartDatetime = new Date(data.visitStartDate).setHours(visitStartHours, visitStartMinutes); + return new Date(visitStartDatetime) <= new Date(); + }; + + return z + .object({ + visitStartDate: z.date().refine( + (value) => { + const today = dayjs(); + const startDate = dayjs(value); + return displayVisitStopDateTimeFields ? true : startDate.isSameOrBefore(today, 'day'); }, + t('invalidVisitStartDate', 'Start date needs to be on or before {{firstEncounterDatetime}}', { + firstEncounterDatetime: formatDatetime(new Date()), + interpolation: { + escapeValue: false, + }, + }), + ), + visitStartTime: z + .string() + .refine((value) => value.match(time12HourFormatRegex), t('invalidTimeFormat', 'Invalid time format')), + visitStartTimeFormat: z.enum(['PM', 'AM']), + visitStopDate: displayVisitStopDateTimeFields ? z.date() : z.date().optional(), + visitStopTime: displayVisitStopDateTimeFields + ? z + .string() + .refine((value) => value.match(time12HourFormatRegex), t('invalidTimeFormat', 'Invalid time format')) + : z.string().optional(), + visitStopTimeFormat: displayVisitStopDateTimeFields ? z.enum(['PM', 'AM']) : z.enum(['PM', 'AM']).optional(), + programType: z.string().optional(), + visitType: z.string().refine((value) => !!value, t('visitTypeRequired', 'Visit type is required')), + visitLocation: z.object({ + display: z.string(), + uuid: z.string(), }), - ), - visitStartTime: z - .string() - .refine((value) => value.match(time12HourFormatRegex), t('invalidTimeFormat', 'Invalid time format')), - visitStartTimeFormat: z.enum(['PM', 'AM']), - visitStopDate: displayVisitStopDateTimeFields ? z.date() : z.date().optional(), - visitStopTime: displayVisitStopDateTimeFields - ? z - .string() - .refine((value) => value.match(time12HourFormatRegex), t('invalidTimeFormat', 'Invalid time format')) - : z.string().optional(), - visitStopTimeFormat: displayVisitStopDateTimeFields ? z.enum(['PM', 'AM']) : z.enum(['PM', 'AM']).optional(), - programType: z.string().optional(), - visitType: z.string().refine((value) => !!value, t('visitTypeRequired', 'Visit type is required')), - visitLocation: z.object({ - display: z.string(), - uuid: z.string(), - }), - visitAttributes: z.object(visitAttributes), - }); + visitAttributes: z.object(visitAttributes), + }) + .refine((data) => validateStartTime(data), { + message: t('futureStartTime', 'Visit start time cannot be in the future'), + path: ['visitStartTime'], + }); }, [t, config, displayVisitStopDateTimeFields]); const defaultValues = useMemo(() => { @@ -386,7 +401,7 @@ const StartVisitForm: React.FC = ({ ); const onSubmit = useCallback( - (data: VisitFormData, event) => { + (data: VisitFormData) => { if (visitToEdit && !validateVisitStartStopDatetime()) { return; } @@ -464,45 +479,41 @@ const StartVisitForm: React.FC = ({ .pipe(first()) .subscribe({ next: (response) => { - if (response.status === 201) { - if (config.showServiceQueueFields && queueLocation && service && priority) { - // retrieve values from the queue extension - setVisitUuid(response.data.uuid); - - saveQueueEntry( - response.data.uuid, - service, - patientUuid, - priority, - status, - sortWeight, - queueLocation, - visitQueueNumberAttributeUuid, - abortController, - ).then( - ({ status }) => { - if (status === 201) { - mutateCurrentVisit(); - mutateVisits(); - mutateInfiniteVisits(); - mutateQueueEntry(); - showSnackbar({ - kind: 'success', - title: t('visitStarted', 'Visit started'), - subtitle: t('queueAddedSuccessfully', `Patient added to the queue successfully.`), - }); - } - }, - (error) => { - showSnackbar({ - title: t('queueEntryError', 'Error adding patient to the queue'), - kind: 'error', - isLowContrast: false, - subtitle: error?.message, - }); - }, - ); - } + if (config.showServiceQueueFields && queueLocation && service && priority) { + // retrieve values from the queue extension + setVisitUuid(response.data.uuid); + + saveQueueEntry( + response.data.uuid, + service, + patientUuid, + priority, + status, + sortWeight, + queueLocation, + visitQueueNumberAttributeUuid, + abortController, + ).then( + ({ status }) => { + mutateCurrentVisit(); + mutateVisits(); + mutateInfiniteVisits(); + mutateQueueEntry(); + showSnackbar({ + kind: 'success', + title: t('visitStarted', 'Visit started'), + subtitle: t('queueAddedSuccessfully', `Patient added to the queue successfully.`), + }); + }, + (error) => { + showSnackbar({ + title: t('queueEntryError', 'Error adding patient to the queue'), + kind: 'error', + isLowContrast: false, + subtitle: error?.message, + }); + }, + ); if (config.showUpcomingAppointments && upcomingAppointment) { updateAppointmentStatus('CheckedIn', upcomingAppointment.uuid, abortController).then( diff --git a/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.test.tsx b/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.test.tsx index 19e0cc736d..c76723ef7e 100644 --- a/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.test.tsx +++ b/packages/esm-patient-chart-app/src/visit/visit-form/visit-form.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import dayjs from 'dayjs'; import { of, throwError } from 'rxjs'; import { render, screen } from '@testing-library/react'; import { esmPatientChartSchema, type ChartConfig } from '../../config-schema'; @@ -174,6 +175,7 @@ describe('Visit form', () => { }); mockUseVisitTypes.mockReturnValue(mockVisitTypes); }); + it('renders the Start Visit form with all the relevant fields and values', async () => { renderVisitForm(); @@ -197,24 +199,59 @@ describe('Visit form', () => { expect(screen.getByText(/Inpatient Ward/i)).toBeInTheDocument(); }); - it('renders an error message when a visit type has not been selected', async () => { + it('renders a validation error when required fields are not filled', async () => { const user = userEvent.setup(); renderVisitForm(); const saveButton = screen.getByRole('button', { name: /start visit/i }); - const locationPicker = screen.getByRole('combobox', { name: /Select a location/i }); + const locationPicker = screen.getByRole('combobox', { name: /select a location/i }); await user.click(locationPicker); await user.click(screen.getByText('Inpatient Ward')); - await user.click(saveButton); - expect(screen.getByText(/Missing visit type/i)).toBeInTheDocument(); - expect(screen.getByText(/Please select a visit type/i)).toBeInTheDocument(); + expect(screen.getByText(/missing visit type/i)).toBeInTheDocument(); + expect(screen.getByText(/please select a visit type/i)).toBeInTheDocument(); await user.click(screen.getByLabelText(/Outpatient visit/i)); }); + it('displays an error message when the visit start date is in the future', async () => { + const user = userEvent.setup(); + + renderVisitForm(); + + const dateInput = screen.getByRole('textbox', { name: /date/i }); + const futureDate = dayjs().add(1, 'month').format('DD/MM/YYYY'); + + await user.clear(dateInput); + await user.type(dateInput, futureDate); + await user.tab(); + + expect(screen.getByText(/start date needs to be on or before/i)).toBeInTheDocument(); + }); + + // TODO: Figure out why this test is failing + xit('displays an error message when the visit start time is in the future', async () => { + const user = userEvent.setup(); + + renderVisitForm(); + + const dateInput = screen.getByRole('textbox', { name: /date/i }); + const timeInput = screen.getByRole('textbox', { name: /time/i }); + const amPmSelect = screen.getByRole('combobox', { name: /time format/i }); + const futureTime = dayjs().add(1, 'hour'); + + await user.clear(dateInput); + await user.type(dateInput, futureTime.format('DD/MM/YYYY')); + await user.clear(timeInput); + await user.type(timeInput, futureTime.format('hh:mm')); + await user.selectOptions(amPmSelect, futureTime.format('A')); + await user.tab(); + + expect(screen.getByText(/start time cannot be in the future/i)).toBeInTheDocument(); + }); + it('starts a new visit upon successful submission of the form', async () => { const user = userEvent.setup(); @@ -510,7 +547,7 @@ describe('Visit form', () => { expect(mockCloseWorkspace).toHaveBeenCalled(); }); - it('should show an inline error notification if an optional visit attribute type field fails to load', async () => { + it('renders an inline error notification if an optional visit attribute type field fails to load', async () => { mockUseVisitAttributeType.mockReturnValue({ isLoading: false, error: new Error('failed to load'), @@ -524,7 +561,7 @@ describe('Visit form', () => { expect(screen.getByRole('button', { name: /Start visit/i })).toBeEnabled(); }); - it('should show an error if a required visit attribute type is not provided', async () => { + it('renders an error if a required visit attribute type is not provided', async () => { const user = userEvent.setup(); mockUseConfig.mockReturnValue({ diff --git a/packages/esm-patient-chart-app/translations/en.json b/packages/esm-patient-chart-app/translations/en.json index d456ec9928..1832051798 100644 --- a/packages/esm-patient-chart-app/translations/en.json +++ b/packages/esm-patient-chart-app/translations/en.json @@ -71,6 +71,7 @@ "fieldRequired": "This field is required", "filterByEncounterType": "Filter by encounter type", "form": "Form name", + "futureStartTime": "Visit start time cannot be in the future", "goToThisEncounter": "Go to this encounter", "indication": "Indication", "invalidTimeFormat": "Invalid time format",