Skip to content

Commit

Permalink
(feat) O3-4040: Enable deleting program enrollments
Browse files Browse the repository at this point in the history
This PR makes it possible to delete program enrollments. It adds a delete button to the program overview and detailed summary components' overflow menus. When clicked, a modal is opened to confirm the deletion.
  • Loading branch information
denniskigen committed Jan 27, 2025
1 parent bad2f7a commit c8f8885
Show file tree
Hide file tree
Showing 16 changed files with 393 additions and 52 deletions.
3 changes: 2 additions & 1 deletion e2e/pages/program-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export class ProgramsPage {
constructor(readonly page: Page) {}

readonly programsTable = () => this.page.getByRole('table', { name: /program enrollments/i });
readonly editProgramButton = () => this.page.getByRole('button', { name: /edit program/i });
readonly overflowButton = () => this.page.getByRole('button', { name: /options/i });
readonly editProgramButton = () => this.page.getByRole('menuitem', { name: /edit/i });

async goTo(patientUuid: string) {
await this.page.goto(`patient/${patientUuid}/chart/Programs`);
Expand Down
1 change: 1 addition & 0 deletions e2e/specs/program-enrollment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ test('Add and edit a program enrollment', async ({ page }) => {
});

await test.step('When I click on the `Edit` button of the created program', async () => {
await programsPage.overflowButton().click();
await programsPage.editProgramButton().click();
});

Expand Down
5 changes: 5 additions & 0 deletions packages/esm-patient-programs-app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ export const programsDashboardLink =

// t('programEnrollmentWorkspaceTitle', 'Record program enrollment')
export const programsFormWorkspace = getAsyncLifecycle(() => import('./programs/programs-form.workspace'), options);

export const deleteProgramConfirmationModal = getAsyncLifecycle(
() => import('./programs/delete-program.modal'),
options,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { useCallback, useState } from 'react';
import { Button, InlineLoading, ModalBody, ModalFooter, ModalHeader } from '@carbon/react';
import { useTranslation } from 'react-i18next';
import { showSnackbar, getCoreTranslation } from '@openmrs/esm-framework';
import { deleteProgramEnrollment, useEnrollments } from './programs.resource';
import styles from './delete-program.scss';

interface DeleteProgramProps {
closeDeleteModal: () => void;
programEnrollmentId: string;
patientUuid: string;
}

const DeleteProgramModal: React.FC<DeleteProgramProps> = ({ closeDeleteModal, programEnrollmentId, patientUuid }) => {
const { t } = useTranslation();
const { mutateEnrollments } = useEnrollments(patientUuid);
const [isDeleting, setIsDeleting] = useState(false);

const handleDelete = useCallback(async () => {
setIsDeleting(true);
try {
await deleteProgramEnrollment(programEnrollmentId);
await mutateEnrollments();
closeDeleteModal();
showSnackbar({
isLowContrast: true,
kind: 'success',
title: t('programEnrollmentDeleted', 'Program enrollment deleted'),
});
} catch (error) {
showSnackbar({
isLowContrast: false,
kind: 'error',
title: t('errorDeletingProgram', 'Error deleting program enrollment'),
subtitle: error?.responseBody?.message ?? error?.message,
});
} finally {
setIsDeleting(false);
}
}, [closeDeleteModal, programEnrollmentId, t, mutateEnrollments]);

return (
<div>
<ModalHeader
closeModal={closeDeleteModal}
title={t('deletePatientProgramEnrollment', 'Delete program enrollment')}
/>
<ModalBody>
<p>{t('deleteModalConfirmationText', 'Are you sure you want to delete this program enrollment?')}</p>
</ModalBody>
<ModalFooter>
<Button kind="secondary" onClick={closeDeleteModal}>
{getCoreTranslation('cancel', 'Cancel')}
</Button>
<Button className={styles.deleteButton} kind="danger" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? (
<InlineLoading description={t('deleting', 'Deleting') + '...'} />
) : (
<span>{getCoreTranslation('confirm', 'Confirm')}</span>
)}
</Button>
</ModalFooter>
</div>
);
};

export default DeleteProgramModal;
12 changes: 12 additions & 0 deletions packages/esm-patient-programs-app/src/programs/delete-program.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@use '@carbon/layout';
@use '@carbon/type';

.deleteButton {
:global(.cds--inline-loading) {
min-height: layout.$spacing-05;
}

:global(.cds--inline-loading__text) {
@include type.type-style('body-01');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { type FetchResponse, showSnackbar } from '@openmrs/esm-framework';
import { mockPatient } from 'tools';
import { deleteProgramEnrollment, useEnrollments } from './programs.resource';
import DeleteProgramModal from './delete-program.modal';

jest.mock('./programs.resource', () => ({
deleteProgramEnrollment: jest.fn(),
useEnrollments: jest.fn(),
}));

const mockDeleteProgramEnrollment = jest.mocked(deleteProgramEnrollment);
const mockShowSnackbar = jest.mocked(showSnackbar);
const mockUseEnrollments = jest.mocked(useEnrollments);
const mockMutateEnrollments = jest.fn();

mockUseEnrollments.mockImplementation(
() =>
({
mutateEnrollments: mockMutateEnrollments,
}) as unknown as ReturnType<typeof useEnrollments>,
);

const testProps = {
programEnrollmentId: '123',
patientUuid: mockPatient.id,
};

const closeDeleteModalMock = jest.fn();

const renderDeleteProgramModal = () => {
return render(
<DeleteProgramModal
closeDeleteModal={closeDeleteModalMock}
programEnrollmentId={testProps.programEnrollmentId}
patientUuid={testProps.patientUuid}
/>,
);
};

describe('DeleteProgramModal', () => {
it('renders modal with delete confirmation text ', () => {
renderDeleteProgramModal();

expect(screen.getByRole('heading', { name: /delete program enrollment/i })).toBeInTheDocument();
expect(screen.getByText(/are you sure you want to delete this program enrollment?/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
});

it('Calls closeDeleteModal when cancel button is clicked', async () => {
const user = userEvent.setup();

renderDeleteProgramModal();

await user.click(screen.getByRole('button', { name: /cancel/i }));

expect(closeDeleteModalMock).toHaveBeenCalled();
});

it('clicking the delete button deletes the program enrollment', async () => {
const user = userEvent.setup();
mockDeleteProgramEnrollment.mockResolvedValue({ ok: true } as unknown as FetchResponse);

renderDeleteProgramModal();

await user.click(screen.getByRole('button', { name: /confirm/i }));

expect(mockDeleteProgramEnrollment).toHaveBeenCalledTimes(1);
expect(mockDeleteProgramEnrollment).toHaveBeenCalledWith(testProps.programEnrollmentId);
expect(mockShowSnackbar).toHaveBeenCalledWith({
isLowContrast: true,
kind: 'success',
title: expect.stringMatching(/program enrollment deleted/i),
});
});

it('renders an error notification when the delete action fails', async () => {
const user = userEvent.setup();
mockDeleteProgramEnrollment.mockRejectedValue(new Error('Internal server error'));

renderDeleteProgramModal();

await user.click(screen.getByRole('button', { name: /confirm/i }));

expect(mockDeleteProgramEnrollment).toHaveBeenCalledTimes(1);
expect(mockDeleteProgramEnrollment).toHaveBeenCalledWith(testProps.programEnrollmentId);
expect(mockMutateEnrollments).not.toHaveBeenCalled();
expect(mockShowSnackbar).toHaveBeenCalledWith({
isLowContrast: false,
kind: 'error',
title: expect.stringMatching(/error deleting program enrollment/i),
subtitle: 'Internal server error',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Layer, OverflowMenu, OverflowMenuItem } from '@carbon/react';
import { launchPatientWorkspace } from '@openmrs/esm-patient-common-lib';
import { showModal, useLayoutType } from '@openmrs/esm-framework';
import styles from './programs-action-menu.scss';

interface ProgramActionsProps {
patientUuid: string;
programEnrollmentId: string;
}

export const ProgramsActionMenu = ({ patientUuid, programEnrollmentId }: ProgramActionsProps) => {
const { t } = useTranslation();
const isTablet = useLayoutType() === 'tablet';

const launchEditProgramsForm = useCallback(
() => launchPatientWorkspace('programs-form-workspace', { programEnrollmentId }),
[programEnrollmentId],
);

const launchDeleteProgramDialog = useCallback(() => {
const dispose = showModal('program-delete-confirmation-modal', {
closeDeleteModal: () => dispose(),
programEnrollmentId,
patientUuid,
size: 'sm',
});
}, [programEnrollmentId, patientUuid]);

return (
<Layer className={styles.layer}>
<OverflowMenu
align="left"
aria-label={t('editOrDeleteProgram', 'Edit or delete program')}
flipped
size={isTablet ? 'lg' : 'sm'}
>
<OverflowMenuItem
className={styles.menuItem}
id="editProgram"
itemText={t('edit', 'Edit')}
onClick={launchEditProgramsForm}
/>
<OverflowMenuItem
className={styles.menuItem}
id="deleteProgam"
hasDivider
isDelete
itemText={t('delete', 'Delete')}
onClick={launchDeleteProgramDialog}
/>
</OverflowMenu>
</Layer>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.layer {
height: 100%;

:global(.cds--overflow-menu) {
min-height: unset;
}
}

.menuItem {
max-width: none;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '@testing-library/react';
import { launchPatientWorkspace } from '@openmrs/esm-patient-common-lib';
import { showModal, useLayoutType } from '@openmrs/esm-framework';
import { mockPatient } from 'tools';
import { ProgramsActionMenu } from './programs-action-menu.component';

const mockShowModal = jest.mocked(showModal);
const mockUseLayoutType = jest.mocked(useLayoutType);

jest.mock('@openmrs/esm-patient-common-lib', () => ({
launchPatientWorkspace: jest.fn(),
}));

const testProps = {
programEnrollmentId: '123',
patientUuid: mockPatient.id,
};

const renderProgramActionsMenu = () => {
return render(
<ProgramsActionMenu patientUuid={testProps.patientUuid} programEnrollmentId={testProps.programEnrollmentId} />,
);
};

describe('ProgramActionsMenu', () => {
beforeEach(() => {
mockUseLayoutType.mockReturnValue('small-desktop'); // or 'large-desktop' or 'tablet'
});

it('renders an overflow menu with edit and delete actions', async () => {
const user = userEvent.setup();
renderProgramActionsMenu();

const overflowMenuButton = screen.getByRole('button', { name: /options/i });
await user.click(overflowMenuButton);

await waitFor(() => {
expect(screen.getByText('Edit')).toBeInTheDocument();
});

await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
});

it('launches edit program form when edit button is clicked', async () => {
const user = userEvent.setup();
renderProgramActionsMenu();
await user.click(screen.getByRole('button'));
await user.click(screen.getByText('Edit'));

expect(launchPatientWorkspace).toHaveBeenCalledWith('programs-form-workspace', {
programEnrollmentId: testProps.programEnrollmentId,
});
});

it('launches delete program dialog when delete option is clicked', async () => {
const user = userEvent.setup();
renderProgramActionsMenu();

await user.click(screen.getByRole('button'));
await user.click(screen.getByText('Delete'));

expect(mockShowModal).toHaveBeenCalledWith('program-delete-confirmation-modal', {
closeDeleteModal: expect.any(Function),
patientUuid: testProps.patientUuid,
programEnrollmentId: testProps.programEnrollmentId,
size: 'sm',
});
});
});
Loading

0 comments on commit c8f8885

Please sign in to comment.