Skip to content

Commit

Permalink
Merge branch 'main' into fix/O3-2825
Browse files Browse the repository at this point in the history
  • Loading branch information
denniskigen authored Oct 2, 2024
2 parents 9015601 + d4f6f05 commit 616be61
Show file tree
Hide file tree
Showing 18 changed files with 230 additions and 150 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={Boolean(errors[dateFieldName])}
invalidText={errors[dateFieldName]?.message}
labelText={t('date', 'Date')}
placeholder="dd/mm/yyyy"
style={{ width: '100%' }}
invalid={!!errors[dateFieldName]}
invalidText={errors[dateFieldName]?.message}
/>
</DatePicker>
</ResponsiveWrapper>
Expand All @@ -78,26 +78,26 @@ 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]}
invalidText={errors[timeFieldName]?.message}
>
<Controller
name={timeFormatFieldName}
control={control}
render={({ field: { onChange, value } }) => (
<TimePickerSelect
aria-label={t('timeFormat ', 'Time Format')}
id={`${timeFormatFieldName}Input`}
invalid={Boolean(errors[timeFormatFieldName])}
invalidText={errors[timeFormatFieldName]?.message}
onChange={(event) => onChange(event.target.value as amPm)}
value={value}
aria-label={t('timeFormat ', 'Time Format')}
invalid={!!errors[timeFormatFieldName]}
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,71 @@ 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'),
})
: 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 +399,7 @@ const StartVisitForm: React.FC<StartVisitFormProps> = ({
);

const onSubmit = useCallback(
(data: VisitFormData, event) => {
(data: VisitFormData) => {
if (visitToEdit && !validateVisitStartStopDatetime()) {
return;
}
Expand Down Expand Up @@ -464,45 +477,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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useContext, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { Accordion, AccordionItem, Button, Checkbox } from '@carbon/react';
import { useConfig, useLayoutType } from '@openmrs/esm-framework';
import { type ConfigObject } from '../../config-schema';
Expand Down Expand Up @@ -81,7 +82,7 @@ const FilterNodeParent = ({ root, itemNumber }: filterNodeParentProps) => {

return (
<div>
<div className={`${styles.treeNodeHeader} ${tablet ? styles.treeNodeHeaderTablet : ''}`}>
<div className={classNames(styles.treeNodeHeader, { [styles.treeNodeHeaderTablet]: tablet })}>
<h5>{t(root.display)}</h5>
<Button
className={styles.button}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
@use '@carbon/layout';
@use '@carbon/styles/scss/type';
@use '@carbon/colors';
@import '@openmrs/esm-styleguide/src/vars';
@use '@carbon/layout';
@use '@carbon/type';
@use '@openmrs/esm-styleguide/src/vars' as *;

.filterSetContent {
max-height: calc(100vh - 9.5rem);
overflow-y: auto;

.treeNodeHeader:first-of-type {
padding-top: 0;
}

.nestedAccordion:first-of-type {
margin-top: 0;
}
}

// background of filter, and spacing between containers
Expand Down
Loading

0 comments on commit 616be61

Please sign in to comment.