Skip to content

Commit

Permalink
(fix) O3-3982: Validate against starting a visit at a future time
Browse files Browse the repository at this point in the history
This PR adds validation logic that ensures a visit cannot be started at a time in the future. It does so by adding a refinement to the visitFormSchema zod schema that compares the value of the visitStartTime field with the current timestamp. If the visitStartTime is in the future, a validation error message will be shown under the field, and the form will not be submitted. The error message shown reads "Visit start time cannot be in the future".

Additionally, this PR refactors the zod schema, centralizing some of the validation logic into reusable helper functions that are hopefully more easier to maintain. It also adds some test coverage for the new validation logic.
  • Loading branch information
denniskigen committed Sep 27, 2024
1 parent e762129 commit 6ff6226
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 95 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,18 @@ const VisitDateTimeField: React.FC<VisitDateTimeFieldProps> = ({
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}
>
<DatePickerInput
id={`${dateFieldName}Input`}
invalid={errors[dateFieldName]}
invalidText={errors[dateFieldName]?.message}
labelText={t('date', 'Date')}
placeholder="dd/mm/yyyy"
style={{ width: '100%' }}
invalid={errors[dateFieldName]?.message}
invalidText={errors[dateFieldName]?.message}
/>
</DatePicker>
</ResponsiveWrapper>
Expand All @@ -78,26 +78,24 @@ const VisitDateTimeField: React.FC<VisitDateTimeFieldProps> = ({
render={({ field: { onBlur, onChange, value } }) => (
<TimePicker
id={timeFieldName}
invalid={Boolean(errors[timeFieldName])}
invalidText={errors[timeFieldName]?.message}
labelText={t('time', 'Time')}
onBlur={onBlur}
onChange={(event) => 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}
>
<Controller
name={timeFormatFieldName}
control={control}
render={({ field: { onChange, value } }) => (
<TimePickerSelect
aria-label={t('timeFormat ', 'Time Format')}
id={`${timeFormatFieldName}Input`}
onChange={(event) => onChange(event.target.value as amPm)}
value={value}
aria-label={t('timeFormat ', 'Time Format')}
invalid={errors[timeFormatFieldName]?.message}
invalidText={errors[timeFormatFieldName]?.message}
>
<SelectItem value="AM" text="AM" />
<SelectItem value="PM" text="PM" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,58 +121,73 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({
});

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<typeof visitFormSchema>) => {
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(() => {
Expand Down Expand Up @@ -386,7 +401,7 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({
);

const onSubmit = useCallback(
(data: VisitFormData, event) => {
(data: VisitFormData) => {
if (visitToEdit && !validateVisitStartStopDatetime()) {
return;
}
Expand Down Expand Up @@ -464,45 +479,41 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({
.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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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'),
Expand All @@ -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({
Expand Down
1 change: 1 addition & 0 deletions packages/esm-patient-chart-app/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 6ff6226

Please sign in to comment.