Skip to content

(feat) Retrieve scheduled appointments in clinical forms workspace #471

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fhirBaseUrl, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { encounterRepresentation } from '../constants';
import type { FHIRObsResource, OpenmrsForm, PatientIdentifier, PatientProgramPayload } from '../types';
import type { Appointment, AppointmentsPayload, FHIRObsResource, OpenmrsForm, PatientIdentifier, PatientProgramPayload } from '../types';
import { isUuid } from '../utils/boolean-utils';

export function saveEncounter(abortController: AbortController, payload, encounterUuid?: string) {
Expand All @@ -18,6 +18,65 @@ export function saveEncounter(abortController: AbortController, payload, encount
});
}

export function addFulfillingEncounters(
abortController: AbortController,
appointments: Array<Appointment>,
encounterUuid?: string,
) {
const url = `${restBaseUrl}/appointment`;
const filteredAppointments = appointments.filter((appointment) => {
return !appointment.fulfillingEncounters.includes(encounterUuid);
});
return filteredAppointments.map((appointment) => {
return updateAppointment(url, appointment, encounterUuid, abortController);
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This code would be more concise and easier to read without the explicit return statements

}

function updateAppointment(
url: string,
appointment: Appointment,
encounterUuid: string | undefined,
abortController: AbortController
) {

const updatedFulfillingEncounters = [...(appointment.fulfillingEncounters ?? []), encounterUuid];

const updatedAppointment: AppointmentsPayload = {
fulfillingEncounters: updatedFulfillingEncounters,
serviceUuid: appointment.service.uuid,
locationUuid: appointment.location.uuid,
patientUuid: appointment.patient.uuid,
dateAppointmentScheduled: appointment.startDateTime,
appointmentKind: appointment.appointmentKind,
status: appointment.status,
startDateTime: appointment.startDateTime,
endDateTime: appointment.endDateTime.toString(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need toString() here? If it's a timestamp, isn't it preferable to convert it to a date string using something like toIsoString or a dayjs utility function instead?

providers: [{ uuid: appointment.providers[0]?.uuid }],
comments: appointment.comments,
uuid: appointment.uuid
};

return openmrsFetch(`${url}`, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return openmrsFetch(`${url}`, {
return openmrsFetch(url, {

method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: updatedAppointment,
signal: abortController.signal,
})
}

export const getPatientAppointment = (appointmentUuid: string) => {
return openmrsFetch(
`${restBaseUrl}/appointments/${appointmentUuid}`,
).then(({ data }) => {
if (data) {
return data;
}
return null;
});
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be useful to have some kind of error handling here? Something along the lines of:

Suggested change
export const getPatientAppointment = (appointmentUuid: string) => {
return openmrsFetch(
`${restBaseUrl}/appointments/${appointmentUuid}`,
).then(({ data }) => {
if (data) {
return data;
}
return null;
});
};
export const getPatientAppointment = async (appointmentUuid: string) => {
try {
const response = await openmrsFetch(`${restBaseUrl}/appointments/${appointmentUuid}`);
return response.data || null;
} catch (error) {
console.error('Error fetching appointment:', error);
throw error; // Re-throw to allow caller to handle the error
}
};


export function saveAttachment(patientUuid, field, conceptUuid, date, encounterUUID, abortController) {
const url = `${restBaseUrl}/attachment`;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { showSnackbar } from '@openmrs/esm-framework';
import { formatDatetime, parseDate, showSnackbar } from '@openmrs/esm-framework';
import { useLaunchWorkspaceRequiringVisit } from '@openmrs/esm-patient-common-lib';
import { Button } from '@carbon/react';
import { type FormFieldInputProps } from '../../../types';
import { type Appointment, type FormFieldInputProps } from '../../../types';
import { isTrue } from '../../../utils/boolean-utils';
import styles from './workspace-launcher.scss';
import { useFormFactory } from '../../../provider/form-factory-provider';
import { getPatientAppointment } from '../../../api';
import { DataTable, Table, TableHead, TableRow, TableHeader, TableBody, TableCell } from '@carbon/react';

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we clean up the unused imports here? Also, can we consolidate the @carbon/react imports into one line?

const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => {
const { t } = useTranslation();
const { appointments, addAppointmentForCurrentEncounter } = useFormFactory();
const launchWorkspace = useLaunchWorkspaceRequiringVisit(field.questionOptions?.workspaceName);

const handleAfterCreateAppointment = async (appointmentUuid: string) => {
addAppointmentForCurrentEncounter(appointmentUuid);
};

const handleLaunchWorkspace = () => {
if (!launchWorkspace) {
showSnackbar({
Expand All @@ -20,7 +28,57 @@ const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => {
isLowContrast: true,
});
}
launchWorkspace();
if (field.questionOptions?.workspaceName === 'appointments-form-workspace') {
launchWorkspace({ handleAfterCreateAppointment });
} else {
launchWorkspace();
}
};

const AppointmentsTable = ({ appointments }) => {
const headers = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this component have empty and loading states? We're fetching appointments from the backend, right? So it's not always guaranteed that we'll have something to render from the get-go.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jnsereko , never create a component inside another component.

{ key: 'startDateTime', header: 'Date & Time' },
{ key: 'location', header: 'Location' },
{ key: 'service', header: 'Service' },
{ key: 'status', header: 'Status' },
];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These header titles should be translated.


const rows = appointments.map((appointment) => ({
id: `${appointment.uuid}`,
startDateTime: formatDatetime(parseDate(appointment.startDateTime)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
id: `${appointment.uuid}`,
id: appointment.uuid,

Isn't appointment.uuid a string?

location: appointment?.location?.name ? appointment?.location?.name : '——',
service: appointment.service.name,
status: appointment.status,
}));

return (
<DataTable rows={rows} headers={headers}>
{({ rows, headers, getTableProps, getHeaderProps, getRowProps, getCellProps }) => (
<Table {...getTableProps()}>
<TableHead>
<TableRow>
{headers.map((header) => (
<TableHeader key={header.key} {...getHeaderProps({ header })}>
{header.header}
</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} {...getRowProps({ row })}>
{row.cells.map((cell) => (
<TableCell key={cell.id} {...getCellProps({ cell })}>
{cell.value}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</DataTable>
);
};

return (
Expand All @@ -32,6 +90,11 @@ const WorkspaceLauncher: React.FC<FormFieldInputProps> = ({ field }) => {
{t(field.questionOptions?.buttonLabel) ?? t('launchWorkspace', 'Launch Workspace')}
</Button>
</div>
{appointments.length !== 0 && (
<div>
<AppointmentsTable appointments={appointments} />
</div>
)}
</div>
)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const FormProcessorFactory = ({
isSubForm = false,
setIsLoadingFormDependencies,
}: FormProcessorFactoryProps) => {
const { patient, sessionMode, formProcessors, layoutType, location, provider, sessionDate, visit } = useFormFactory();
const { patient, sessionMode, formProcessors, layoutType, location, provider, sessionDate, visit, appointments } = useFormFactory();

const processor = useMemo(() => {
const ProcessorClass = formProcessors[formJson.processor];
Expand All @@ -48,10 +48,12 @@ const FormProcessorFactory = ({
processor,
sessionDate,
visit,
appointments,
formFields: [],
formFieldAdapters: {},
formFieldValidators: {},
});

const { t } = useTranslation();
const { formFields: rawFormFields, conceptReferences } = useFormFields(formJson);
const { concepts: formFieldsConcepts, isLoading: isLoadingConcepts } = useConcepts(conceptReferences);
Expand Down
21 changes: 19 additions & 2 deletions src/form-engine.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import { useFormCollapse } from './hooks/useFormCollapse';
import { useFormWorkspaceSize } from './hooks/useFormWorkspaceSize';
import { usePageObserver } from './components/sidebar/usePageObserver';
import { usePatientData } from './hooks/usePatientData';
import type { FormField, FormSchema, SessionMode } from './types';
import type { Appointment, FormField, FormSchema, SessionMode } from './types';
import FormProcessorFactory from './components/processor-factory/form-processor-factory.component';
import Loader from './components/loaders/loader.component';
import MarkdownWrapper from './components/inputs/markdown/markdown-wrapper.component';
import PatientBanner from './components/patient-banner/patient-banner.component';
import Sidebar from './components/sidebar/sidebar.component';
import styles from './form-engine.scss';
import { usePatientAppointments } from './hooks/usePatientCheckedInAppointments';

interface FormEngineProps {
patientUUID: string;
Expand All @@ -34,7 +35,6 @@ interface FormEngineProps {
handleConfirmQuestionDeletion?: (question: Readonly<FormField>) => Promise<void>;
markFormAsDirty?: (isDirty: boolean) => void;
}

const FormEngine = ({
formJson,
patientUUID,
Expand All @@ -57,6 +57,10 @@ const FormEngine = ({
}, []);
const workspaceSize = useFormWorkspaceSize(ref);
const { patient, isLoadingPatient } = usePatientData(patientUUID);
const { appointments, addAppointmentForCurrentEncounter } = usePatientAppointments(
patientUUID,
encounterUUID || null,
);
const [isLoadingDependencies, setIsLoadingDependencies] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isFormDirty, setIsFormDirty] = useState(false);
Expand Down Expand Up @@ -92,6 +96,17 @@ const FormEngine = ({
return !isFormWorkspaceTooNarrow && hasMultiplePages;
}, [isFormWorkspaceTooNarrow, isLoadingDependencies, hasMultiplePages]);

// useEffect(() => {
// if (initialPatientAppointments) {
// setAppointments((prevAppointments) => {
// const newAppointments = initialPatientAppointments.filter(
// (newAppt) => !prevAppointments.some((appt) => appt.uuid === newAppt.uuid),
// );
// return [...prevAppointments, ...newAppointments];
// });
// }
// }, [initialPatientAppointments]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this if it's not being used anywhere.


useEffect(() => {
reportError(formError, t('errorLoadingFormSchema', 'Error loading form schema'));
}, [formError]);
Expand Down Expand Up @@ -126,6 +141,8 @@ const FormEngine = ({
location={session?.sessionLocation}
provider={session?.currentProvider}
visit={visit}
appointments={appointments}
addAppointmentForCurrentEncounter={addAppointmentForCurrentEncounter}
handleConfirmQuestionDeletion={handleConfirmQuestionDeletion}
isFormExpanded={isFormExpanded}
formSubmissionProps={{
Expand Down
52 changes: 52 additions & 0 deletions src/hooks/usePatientCheckedInAppointments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { openmrsFetch, restBaseUrl, useOpenmrsSWR } from '@openmrs/esm-framework';
import dayjs from 'dayjs';
import useSWR, { mutate, SWRResponse } from 'swr';
import { type AppointmentsResponse } from '../types';
import { useCallback, useMemo, useState } from 'react';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we're not using openmrsFetch and the SWR imports here. Can we remove them?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes @denniskigen, working on it!
Thanks for the catch!


export function usePatientAppointments(patientUuid: string, encounterUUID: string) {
const [appointmentUuids, setAppointmentUuids] = useState<Array<string>>([]);

const startDate = useMemo(() => dayjs().subtract(6, 'month').toISOString(), []);

// We need to fetch the appointments with the specified fulfilling encounter
const appointmentsSearchUrl =
encounterUUID || appointmentUuids.length > 0 ? `${restBaseUrl}/appointments/search` : null;

const swrResult = useOpenmrsSWR<AppointmentsResponse, Error>(appointmentsSearchUrl, {
fetchInit: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: {
patientUuid: patientUuid,
startDate: startDate,
},
},
});

const addAppointmentForCurrentEncounter = useCallback(
(appointmentUuid: string) => {
setAppointmentUuids((prev) => (!prev.includes(appointmentUuid) ? [...prev, appointmentUuid] : prev));
swrResult.mutate();
},
[swrResult.mutate, setAppointmentUuids],
);

const results = useMemo(
() => ({
appointments: (swrResult.data?.data ?? [])?.filter(
(appointment) =>
appointment.fulfillingEncounters?.includes(encounterUUID) || appointmentUuids.includes(appointment.uuid),
),
mutateAppointments: swrResult.mutate,
isLoading: swrResult.isLoading,
error: swrResult.error,
addAppointmentForCurrentEncounter,
}),
[swrResult, addAppointmentForCurrentEncounter, appointmentUuids, encounterUUID],
);

return results;
}
25 changes: 23 additions & 2 deletions src/processors/encounter/encounter-form-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
savePatientPrograms,
} from './encounter-processor-helper';
import {
type Appointment,
type FormField,
type FormPage,
type FormProcessorContextProps,
Expand All @@ -23,7 +24,7 @@ import { evaluateAsyncExpression, type FormNode } from '../../utils/expression-r
import { extractErrorMessagesFromResponse } from '../../utils/error-utils';
import { extractObsValueAndDisplay } from '../../utils/form-helper';
import { FormProcessor } from '../form-processor';
import { getPreviousEncounter, saveEncounter } from '../../api';
import { addFulfillingEncounters, getPreviousEncounter, saveEncounter } from '../../api';
import { hasRendering } from '../../utils/common-utils';
import { isEmpty } from '../../validators/form-validator';
import { formEngineAppName } from '../../globals';
Expand Down Expand Up @@ -108,7 +109,7 @@ export class EncounterFormProcessor extends FormProcessor {
return schema;
}

async processSubmission(context: FormContextProps, abortController: AbortController) {
async processSubmission(context: FormContextProps, appointments: Array<Appointment>, abortController: AbortController) {
const { encounterRole, encounterProvider, encounterDate, encounterLocation } = getMutableSessionProps(context);
const translateFn = (key, defaultValue?) => translateFrom(formEngineAppName, key, defaultValue);
const patientIdentifiers = preparePatientIdentifiers(context.formFields, encounterLocation);
Expand Down Expand Up @@ -202,6 +203,26 @@ export class EncounterFormProcessor extends FormProcessor {
critical: true,
});
}
// handle appointments
try {
const {appointments: myAppointments} = context
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we're not using this code

const appointmentsResponse = await Promise.all(addFulfillingEncounters(abortController, appointments, savedEncounter.uuid));
if (appointmentsResponse?.length) {
showSnackbar({
title: translateFn('appointmentsSaved', 'Appointment(s) saved successfully'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does translateFrom handle pluralization? If not, I think we could just default it to plural:

Suggested change
title: translateFn('appointmentsSaved', 'Appointment(s) saved successfully'),
title: translateFn('appointmentsSaved', 'Appointments saved successfully'),

kind: 'success',
isLowContrast: true,
});
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we modify this so that we only proceed if there are appointments?

if (appointments && appointments.length > 0) {

   // ...
 }

} catch (error) {
const errorMessages = Array.isArray(error) ? error.map((err) => err.message) : [error.message];
return Promise.reject({
title: translateFn('errorSavingAppointments', 'Error saving appointment(s)'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: translateFn('errorSavingAppointments', 'Error saving appointment(s)'),
title: translateFn('errorSavingAppointments', 'Error saving appointments'),

description: errorMessages.join(', '),
kind: 'error',
critical: true,
});
}
return savedEncounter;
} catch (error) {
const errorMessages = extractErrorMessagesFromResponse(error);
Expand Down
4 changes: 2 additions & 2 deletions src/processors/form-processor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type OpenmrsResource } from '@openmrs/esm-framework';
import { type FormContextProps } from '../provider/form-provider';
import { type ValueAndDisplay, type FormField, type FormSchema } from '../types';
import { type ValueAndDisplay, type FormField, type FormSchema, type Appointment } from '../types';
import { type FormProcessorContextProps } from '../types';

export type FormProcessorConstructor = new (...args: ConstructorParameters<typeof FormProcessor>) => FormProcessor;
Expand Down Expand Up @@ -34,7 +34,7 @@ export abstract class FormProcessor {
}

abstract getHistoricalValue(field: FormField, context: FormContextProps): Promise<ValueAndDisplay>;
abstract processSubmission(context: FormContextProps, abortController: AbortController): Promise<OpenmrsResource>;
abstract processSubmission(context: FormContextProps, appointments: Array<Appointment>, abortController: AbortController): Promise<OpenmrsResource>;
abstract getInitialValues(context: FormProcessorContextProps): Promise<Record<string, any>>;
abstract getCustomHooks(): GetCustomHooksResponse;
abstract prepareFormSchema(schema: FormSchema): FormSchema;
Expand Down
Loading
Loading