diff --git a/__mocks__/programs.mock.ts b/__mocks__/programs.mock.ts index 1fb5702e5c..ffb7bcea02 100644 --- a/__mocks__/programs.mock.ts +++ b/__mocks__/programs.mock.ts @@ -47,6 +47,7 @@ export const mockEnrolledProgramsResponse = [ { uuid: '8ba6c08f-66d9-4a18-a233-5f658b1755bf', program: { + display: 'Human immunodeficiency virus (HIV) disease', uuid: '64f950e6-1b07-4ac0-8e7e-f3e148f3463f', name: 'HIV Care and Treatment', allWorkflows: [], @@ -62,6 +63,7 @@ export const mockEnrolledProgramsResponse = [ }, dateEnrolled: '2020-01-16T00:00:00.000+0000', dateCompleted: null, + states: [], }, ]; @@ -84,6 +86,7 @@ export const mockEnrolledInAllProgramsResponse = [ }, dateEnrolled: '2020-01-16T00:00:00.000+0000', dateCompleted: null, + states: [], }, { uuid: '700b7914-9dc9-4569-8fe3-6db6c80af4c5', @@ -99,6 +102,7 @@ export const mockEnrolledInAllProgramsResponse = [ }, dateEnrolled: '2021-03-16T00:00:00.000+0000', dateCompleted: null, + states: [], }, { uuid: '874e5326-faa0-4d4b-a891-9a0e3a16f30f', @@ -114,11 +118,13 @@ export const mockEnrolledInAllProgramsResponse = [ }, dateEnrolled: '2021-02-16T00:00:00.000+0000', dateCompleted: null, + states: [], }, ]; export const mockCareProgramsResponse = [ { + name: 'HIV TREATMENT', uuid: '64f950e6-1b07-4ac0-8e7e-f3e148f3463f', display: 'HIV Care and Treatment', allWorkflows: [], @@ -128,6 +134,7 @@ export const mockCareProgramsResponse = [ }, }, { + name: 'ONCOLOGY SCREENING AND DIAGNOSIS PROGRAM', uuid: '11b129ca-a5e7-4025-84bf-b92a173e20de', display: 'Oncology Screening and Diagnosis', allWorkflows: [], @@ -137,6 +144,7 @@ export const mockCareProgramsResponse = [ }, }, { + name: 'HIV DIFFERENTIATED CARE PROGRAM', uuid: 'b2f65a51-2f87-4faa-a8c6-327a0c1d2e17', display: 'HIV Differentiated Care', allWorkflows: [], diff --git a/packages/esm-patient-programs-app/src/config-schema.ts b/packages/esm-patient-programs-app/src/config-schema.ts index e27467ce21..e8a9d82ca5 100644 --- a/packages/esm-patient-programs-app/src/config-schema.ts +++ b/packages/esm-patient-programs-app/src/config-schema.ts @@ -5,8 +5,15 @@ export const configSchema = { _type: Type.Boolean, _default: false, }, + showProgramStatusField: { + _type: Type.Boolean, + _description: + 'Whether to show the Program status field in the Record program enrollment and Edit program enrollment forms. If set to true, the `Program status` field is displayed in the Programs datatable', + _default: false, + }, }; export interface ConfigObject { hideAddProgramButton: boolean; + showProgramStatusField: boolean; } diff --git a/packages/esm-patient-programs-app/src/programs/programs-detailed-summary.component.tsx b/packages/esm-patient-programs-app/src/programs/programs-detailed-summary.component.tsx index a0b4f10648..3365f4c4d5 100644 --- a/packages/esm-patient-programs-app/src/programs/programs-detailed-summary.component.tsx +++ b/packages/esm-patient-programs-app/src/programs/programs-detailed-summary.component.tsx @@ -26,7 +26,7 @@ import { isDesktop as desktopLayout, } from '@openmrs/esm-framework'; import { CardHeader, EmptyState, ErrorState, launchPatientWorkspace } from '@openmrs/esm-patient-common-lib'; -import { usePrograms } from './programs.resource'; +import { findLastState, usePrograms } from './programs.resource'; import styles from './programs-detailed-summary.scss'; interface ProgramsDetailedSummaryProps { @@ -40,7 +40,7 @@ interface ProgramEditButtonProps { const ProgramsDetailedSummary: React.FC = ({ patientUuid }) => { const { t } = useTranslation(); - const { hideAddProgramButton } = useConfig(); + const { hideAddProgramButton, showProgramStatusField } = useConfig(); const layout = useLayoutType(); const isTablet = layout === 'tablet'; const isDesktop = desktopLayout(layout); @@ -49,8 +49,8 @@ const ProgramsDetailedSummary: React.FC = ({ patie const { enrollments, isLoading, error, isValidating, availablePrograms } = usePrograms(patientUuid); - const tableHeaders: Array = useMemo( - () => [ + const tableHeaders: Array = useMemo(() => { + const headers = [ { key: 'display', header: t('activePrograms', 'Active programs'), @@ -67,21 +67,31 @@ const ProgramsDetailedSummary: React.FC = ({ patie key: 'status', header: t('status', 'Status'), }, - ], - [t], - ); + ]; + if (showProgramStatusField) { + headers.push({ + key: 'state', + header: t('programStatus', 'Program status'), + }); + } + return headers; + }, [t, showProgramStatusField]); const tableRows = useMemo( () => - enrollments?.map((program) => ({ - id: program.uuid, - display: program.display, - location: program.location?.display ?? '--', - dateEnrolled: formatDatetime(new Date(program.dateEnrolled)), - status: program.dateCompleted - ? `${t('completedOn', 'Completed On')} ${formatDate(new Date(program.dateCompleted))}` - : t('active', 'Active'), - })), + enrollments?.map((program) => { + const state = program ? findLastState(program.states) : null; + return { + id: program.uuid, + display: program.display, + location: program.location?.display ?? '--', + dateEnrolled: formatDatetime(new Date(program.dateEnrolled)), + status: program.dateCompleted + ? `${t('completedOn', 'Completed On')} ${formatDate(new Date(program.dateCompleted))}` + : t('active', 'Active'), + state: state ? state.state.concept.display : '--', + }; + }), [enrollments, t], ); diff --git a/packages/esm-patient-programs-app/src/programs/programs-detailed-summary.test.tsx b/packages/esm-patient-programs-app/src/programs/programs-detailed-summary.test.tsx index e0759130e2..1f80aef587 100644 --- a/packages/esm-patient-programs-app/src/programs/programs-detailed-summary.test.tsx +++ b/packages/esm-patient-programs-app/src/programs/programs-detailed-summary.test.tsx @@ -1,13 +1,15 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { screen, within } from '@testing-library/react'; -import { openmrsFetch } from '@openmrs/esm-framework'; +import { getDefaultsFromConfigSchema, openmrsFetch, useConfig } from '@openmrs/esm-framework'; import { launchPatientWorkspace } from '@openmrs/esm-patient-common-lib'; import { mockCareProgramsResponse, mockEnrolledInAllProgramsResponse, mockEnrolledProgramsResponse } from '__mocks__'; import { mockPatient, renderWithSwr, waitForLoadingToFinish } from 'tools'; +import { type ConfigObject, configSchema } from '../config-schema'; import ProgramsDetailedSummary from './programs-detailed-summary.component'; const mockOpenmrsFetch = openmrsFetch as jest.Mock; +const mockUseConfig = jest.mocked(useConfig); jest.mock('@openmrs/esm-patient-common-lib', () => { const originalModule = jest.requireActual('@openmrs/esm-patient-common-lib'); @@ -107,4 +109,21 @@ describe('ProgramsDetailedSummary', () => { expect(screen.getByText(/enrolled in all programs/i)).toBeInTheDocument(); expect(screen.getByText(/there are no more programs left to enroll this patient in/i)).toBeInTheDocument(); }); + + it('renders the programs status field', async () => { + const user = userEvent.setup(); + + mockOpenmrsFetch.mockReturnValueOnce({ data: { results: mockEnrolledProgramsResponse } }); + + mockUseConfig.mockReturnValue({ + ...getDefaultsFromConfigSchema(configSchema), + showProgramStatusField: true, + }); + + renderWithSwr(); + + await waitForLoadingToFinish(); + + expect(screen.getByRole('columnheader', { name: /program status/i })).toBeInTheDocument(); + }); }); diff --git a/packages/esm-patient-programs-app/src/programs/programs-form.test.tsx b/packages/esm-patient-programs-app/src/programs/programs-form.test.tsx index 96b2f98a14..8adf872d03 100644 --- a/packages/esm-patient-programs-app/src/programs/programs-form.test.tsx +++ b/packages/esm-patient-programs-app/src/programs/programs-form.test.tsx @@ -1,7 +1,13 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen } from '@testing-library/react'; -import { type FetchResponse, showSnackbar, useLocations } from '@openmrs/esm-framework'; +import { + type FetchResponse, + showSnackbar, + useLocations, + useConfig, + getDefaultsFromConfigSchema, +} from '@openmrs/esm-framework'; import { mockCareProgramsResponse, mockEnrolledProgramsResponse, mockLocationsResponse } from '__mocks__'; import { mockPatient } from 'tools'; import { @@ -11,6 +17,7 @@ import { useEnrollments, } from './programs.resource'; import ProgramsForm from './programs-form.workspace'; +import { type ConfigObject, configSchema } from '../config-schema'; const mockUseAvailablePrograms = jest.mocked(useAvailablePrograms); const mockUseEnrollments = jest.mocked(useEnrollments); @@ -21,6 +28,7 @@ const mockUseLocations = jest.mocked(useLocations); const mockCloseWorkspace = jest.fn(); const mockCloseWorkspaceWithSavedChanges = jest.fn(); const mockPromptBeforeClosing = jest.fn(); +const mockUseConfig = jest.mocked(useConfig); const testProps = { closeWorkspace: mockCloseWorkspace, @@ -38,6 +46,7 @@ jest.mock('./programs.resource', () => ({ updateProgramEnrollment: jest.fn(), useAvailablePrograms: jest.fn(), useEnrollments: jest.fn(), + findLastState: jest.fn(), })); mockUseLocations.mockReturnValue(mockLocationsResponse); @@ -145,6 +154,17 @@ describe('ProgramsForm', () => { }), ); }); + + it('renders the programs status field if the config property is set to true', async () => { + mockUseConfig.mockReturnValue({ + ...getDefaultsFromConfigSchema(configSchema), + showProgramStatusField: true, + }); + + renderProgramsForm(); + + expect(screen.getByLabelText(/program status/i)).toBeInTheDocument(); + }); }); function renderProgramsForm(programEnrollmentUuidToEdit?: string) { diff --git a/packages/esm-patient-programs-app/src/programs/programs-form.workspace.tsx b/packages/esm-patient-programs-app/src/programs/programs-form.workspace.tsx index f9e3faf4c2..926f3e74c9 100644 --- a/packages/esm-patient-programs-app/src/programs/programs-form.workspace.tsx +++ b/packages/esm-patient-programs-app/src/programs/programs-form.workspace.tsx @@ -16,15 +16,16 @@ import { Stack, } from '@carbon/react'; import { z } from 'zod'; -import { useForm, Controller } from 'react-hook-form'; +import { useForm, Controller, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { parseDate, showSnackbar, useLayoutType, useLocations, useSession } from '@openmrs/esm-framework'; +import { parseDate, showSnackbar, useConfig, useLayoutType, useLocations, useSession } from '@openmrs/esm-framework'; import { type DefaultPatientWorkspaceProps } from '@openmrs/esm-patient-common-lib'; import { createProgramEnrollment, useAvailablePrograms, useEnrollments, updateProgramEnrollment, + findLastState, } from './programs.resource'; import styles from './programs-form.scss'; @@ -38,6 +39,7 @@ const createProgramsFormSchema = (t: TFunction) => enrollmentDate: z.date(), completionDate: z.date().nullable(), enrollmentLocation: z.string(), + selectedProgramStatus: z.string(), }); export type ProgramsFormData = z.infer>; @@ -56,6 +58,7 @@ const ProgramsForm: React.FC = ({ const { data: availablePrograms } = useAvailablePrograms(); const { data: enrollments, mutateEnrollments } = useEnrollments(patientUuid); const [isSubmittingForm, setIsSubmittingForm] = useState(false); + const { showProgramStatusField } = useConfig(); const programsFormSchema = useMemo(() => createProgramsFormSchema(t), [t]); @@ -81,6 +84,8 @@ const ProgramsForm: React.FC = ({ return currentEnrollment?.location.uuid ?? null; }; + const currentState = currentEnrollment ? findLastState(currentEnrollment.states) : null; + const { control, handleSubmit, @@ -94,16 +99,19 @@ const ProgramsForm: React.FC = ({ enrollmentDate: currentEnrollment?.dateEnrolled ? parseDate(currentEnrollment.dateEnrolled) : new Date(), completionDate: currentEnrollment?.dateCompleted ? parseDate(currentEnrollment.dateCompleted) : null, enrollmentLocation: getLocationUuid() ?? '', + selectedProgramStatus: currentState?.state.uuid ?? '', }, }); + const selectedProgram = useWatch({ control, name: 'selectedProgram' }); + useEffect(() => { promptBeforeClosing(() => isDirty); }, [isDirty, promptBeforeClosing]); const onSubmit = useCallback( async (data: ProgramsFormData) => { - const { selectedProgram, enrollmentDate, completionDate, enrollmentLocation } = data; + const { selectedProgram, enrollmentDate, completionDate, enrollmentLocation, selectedProgramStatus } = data; const payload = { patient: patientUuid, @@ -111,9 +119,15 @@ const ProgramsForm: React.FC = ({ dateEnrolled: enrollmentDate ? dayjs(enrollmentDate).format() : null, dateCompleted: completionDate ? dayjs(completionDate).format() : null, location: enrollmentLocation, + states: + !!selectedProgramStatus && selectedProgramStatus != currentState?.state.uuid + ? [{ state: { uuid: selectedProgramStatus } }] + : [], }; try { + setIsSubmittingForm(true); + const abortController = new AbortController(); if (currentEnrollment) { @@ -144,7 +158,7 @@ const ProgramsForm: React.FC = ({ setIsSubmittingForm(false); }, - [closeWorkspaceWithSavedChanges, currentEnrollment, mutateEnrollments, patientUuid, t], + [closeWorkspaceWithSavedChanges, currentEnrollment, currentState, mutateEnrollments, patientUuid, t], ); const programSelect = ( @@ -241,6 +255,41 @@ const ProgramsForm: React.FC = ({ /> ); + let workflowStates = []; + if (!currentProgram && !!selectedProgram) { + const program = eligiblePrograms.find((p) => p.uuid === selectedProgram); + if (program?.allWorkflows.length > 0) workflowStates = program.allWorkflows[0].states; + } else if (currentProgram?.allWorkflows.length > 0) { + workflowStates = currentProgram.allWorkflows[0].states; + } + + const programStatusDropdown = ( + ( + <> + +

{fieldState?.error?.message}

+ + )} + /> + ); + const formGroups = [ { style: { maxWidth: isTablet && '50%' }, @@ -264,6 +313,14 @@ const ProgramsForm: React.FC = ({ }, ]; + if (showProgramStatusField) { + formGroups.push({ + style: { width: '50%' }, + legendText: '', + value: programStatusDropdown, + }); + } + return (
diff --git a/packages/esm-patient-programs-app/src/programs/programs-overview.component.tsx b/packages/esm-patient-programs-app/src/programs/programs-overview.component.tsx index 100dcb8200..a7d0798e4c 100644 --- a/packages/esm-patient-programs-app/src/programs/programs-overview.component.tsx +++ b/packages/esm-patient-programs-app/src/programs/programs-overview.component.tsx @@ -32,7 +32,7 @@ import { usePagination, isDesktop as desktopLayout, } from '@openmrs/esm-framework'; -import { usePrograms } from './programs.resource'; +import { findLastState, usePrograms } from './programs.resource'; import { type ConfigurableProgram } from '../types'; import styles from './programs-overview.scss'; @@ -77,6 +77,10 @@ const ProgramsOverview: React.FC = ({ basePath, patientUu key: 'status', header: t('status', 'Status'), }, + { + key: 'state', + header: t('state', 'State'), + }, { key: 'actions', header: t('actions', 'Actions'), @@ -84,15 +88,19 @@ const ProgramsOverview: React.FC = ({ basePath, patientUu ]; const tableRows = React.useMemo(() => { - return paginatedEnrollments?.map((enrollment: ConfigurableProgram) => ({ - id: enrollment.uuid, - display: enrollment.display, - location: enrollment.location?.display ?? '--', - dateEnrolled: enrollment.dateEnrolled ? formatDatetime(new Date(enrollment.dateEnrolled)) : '--', - status: enrollment.dateCompleted - ? `${t('completedOn', 'Completed On')} ${formatDate(new Date(enrollment.dateCompleted))}` - : t('active', 'Active'), - })); + return paginatedEnrollments?.map((enrollment: ConfigurableProgram) => { + const state = enrollment ? findLastState(enrollment.states) : null; + return { + id: enrollment.uuid, + display: enrollment.display, + location: enrollment.location?.display ?? '--', + dateEnrolled: enrollment.dateEnrolled ? formatDatetime(new Date(enrollment.dateEnrolled)) : '--', + status: enrollment.dateCompleted + ? `${t('completedOn', 'Completed On')} ${formatDate(new Date(enrollment.dateCompleted))}` + : t('active', 'Active'), + state: state ? state.state.concept.display : '--', + }; + }); }, [paginatedEnrollments, t]); if (isLoading) return ; diff --git a/packages/esm-patient-programs-app/src/programs/programs.resource.tsx b/packages/esm-patient-programs-app/src/programs/programs.resource.tsx index 705b06d0cd..4ac9f7d69f 100644 --- a/packages/esm-patient-programs-app/src/programs/programs.resource.tsx +++ b/packages/esm-patient-programs-app/src/programs/programs.resource.tsx @@ -1,12 +1,12 @@ import useSWR from 'swr'; import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; -import { type PatientProgram, type Program, type ProgramsFetchResponse } from '../types'; +import { type ProgramWorkflowState, type PatientProgram, type Program, type ProgramsFetchResponse } from '../types'; import uniqBy from 'lodash-es/uniqBy'; import filter from 'lodash-es/filter'; import includes from 'lodash-es/includes'; import map from 'lodash-es/map'; -export const customRepresentation = `custom:(uuid,display,program,dateEnrolled,dateCompleted,location:(uuid,display))`; +export const customRepresentation = `custom:(uuid,display,program,dateEnrolled,dateCompleted,location:(uuid,display),states:(startDate,endDate,voided,state:(uuid,concept:(display))))`; export function useEnrollments(patientUuid: string) { const enrollmentsUrl = `${restBaseUrl}/programenrollment?patient=${patientUuid}&v=${customRepresentation}`; @@ -57,13 +57,13 @@ export function createProgramEnrollment(payload, abortController) { if (!payload) { return null; } - const { program, patient, dateEnrolled, dateCompleted, location } = payload; + const { program, patient, dateEnrolled, dateCompleted, location, states } = payload; return openmrsFetch(`${restBaseUrl}/programenrollment`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: { program, patient, dateEnrolled, dateCompleted, location }, + body: { program, patient, dateEnrolled, dateCompleted, location, states }, signal: abortController.signal, }); } @@ -72,13 +72,13 @@ export function updateProgramEnrollment(programEnrollmentUuid: string, payload, if (!payload && !payload.program) { return null; } - const { dateEnrolled, dateCompleted, location } = payload; + const { dateEnrolled, dateCompleted, location, states } = payload; return openmrsFetch(`${restBaseUrl}/programenrollment/${programEnrollmentUuid}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: { dateEnrolled, dateCompleted, location }, + body: { dateEnrolled, dateCompleted, location, states }, signal: abortController.signal, }); } @@ -103,3 +103,14 @@ export const usePrograms = (patientUuid: string) => { eligiblePrograms, }; }; + +export const findLastState = (states: ProgramWorkflowState[]): ProgramWorkflowState => { + const activeStates = states.filter((state) => !state.voided); + const ongoingState = activeStates.find((state) => !state.endDate); + + if (ongoingState) { + return ongoingState; + } + + return activeStates.sort((a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime())[0]; +}; diff --git a/packages/esm-patient-programs-app/src/types/index.ts b/packages/esm-patient-programs-app/src/types/index.ts index 2e0832167a..9692f07ba5 100644 --- a/packages/esm-patient-programs-app/src/types/index.ts +++ b/packages/esm-patient-programs-app/src/types/index.ts @@ -5,22 +5,7 @@ export interface ProgramsFetchResponse { export interface PatientProgram { uuid: string; patient?: DisplayMetadata; - program: { - uuid: string; - name: string; - allWorkflows: Array<{ - uuid: string; - concept: DisplayMetadata; - retired: boolean; - states: Array<{}>; - links?: Links; - }>; - concept?: { - display: string; - uuid: string; - }; - links?: Links; - }; + program: Program; display: string; dateEnrolled: string; dateCompleted: string | null; @@ -31,11 +16,21 @@ export interface PatientProgram { }; voided?: boolean; outcome?: null; - states?: []; + states?: ProgramWorkflowState[]; links?: Links; resourceVersion?: string; } +export interface ProgramWorkflowState { + state: { + uuid: string; + concept: DisplayMetadata; + }; + startDate: string; + endDate: string; + voided: boolean; +} + export type Links = Array<{ rel: string; uri: string; @@ -56,13 +51,22 @@ export interface DataCaptureComponentProps { export interface Program { uuid: string; display: string; + name: string; allWorkflows: Array<{ + uuid: string; + concept: DisplayMetadata; + retired: boolean; + states: Array<{ + uuid: string; + concept: DisplayMetadata; + }>; links?: Links; }>; concept: { uuid: string; display: string; }; + links?: Links; } export interface LocationData { diff --git a/packages/esm-patient-programs-app/translations/en.json b/packages/esm-patient-programs-app/translations/en.json index e37efaec13..40df7b82b6 100644 --- a/packages/esm-patient-programs-app/translations/en.json +++ b/packages/esm-patient-programs-app/translations/en.json @@ -7,6 +7,7 @@ "cancel": "Cancel", "carePrograms": "Care Programs", "chooseProgram": "Choose a program", + "chooseStatus": "Choose a program status", "completedOn": "Completed On", "configurePrograms": "Please configure programs to continue.", "dateCompleted": "Date completed", @@ -28,8 +29,10 @@ "programRequired": "Program is required", "programs": "Program enrollments", "Programs": "Programs", + "programStatus": "Program status", "saveAndClose": "Save and close", "saving": "Saving", "seeAll": "See all", + "state": "State", "status": "Status" }