Skip to content

Commit 5df7adf

Browse files
authored
feat: Delete unit [FC-0083] (#1773)
* Adds the delete menu item in unit cards. * Delete a unit with a confirmation modal. * Restore a component
1 parent 04faf54 commit 5df7adf

File tree

11 files changed

+481
-26
lines changed

11 files changed

+481
-26
lines changed

src/generic/delete-modal/DeleteModal.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const DeleteModal = ({
5151
e.stopPropagation();
5252
await onDeleteSubmit();
5353
}}
54+
variant="brand"
5455
label={defaultBtnLabel}
5556
/>
5657
</ActionRow>

src/library-authoring/components/ContainerCard.test.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import userEvent from '@testing-library/user-event';
2+
import type MockAdapter from 'axios-mock-adapter';
23

34
import {
45
initializeMocks, render as baseRender, screen, waitFor,
6+
fireEvent,
57
} from '../../testUtils';
68
import { LibraryProvider } from '../common/context/LibraryContext';
79
import { mockContentLibrary, mockGetContainerChildren } from '../data/api.mocks';
810
import { type ContainerHit, PublishStatus } from '../../search-manager';
911
import ContainerCard from './ContainerCard';
12+
import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api';
1013

1114
const containerHitSample: ContainerHit = {
1215
id: 'lctorg1democourse-unit-display-name-123',
@@ -33,6 +36,8 @@ const containerHitSample: ContainerHit = {
3336
tags: {},
3437
publishStatus: PublishStatus.Published,
3538
};
39+
let axiosMock: MockAdapter;
40+
let mockShowToast;
3641

3742
mockContentLibrary.applyMock();
3843
mockGetContainerChildren.applyMock();
@@ -50,7 +55,7 @@ const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => b
5055

5156
describe('<ContainerCard />', () => {
5257
beforeEach(() => {
53-
initializeMocks();
58+
({ axiosMock, mockShowToast } = initializeMocks());
5459
});
5560

5661
it('should render the card with title', () => {
@@ -85,6 +90,68 @@ describe('<ContainerCard />', () => {
8590
// );
8691
});
8792

93+
it('should delete the container from the menu & restore the container', async () => {
94+
axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(200);
95+
96+
render(<ContainerCard hit={containerHitSample} />);
97+
98+
// Open menu
99+
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
100+
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
101+
102+
// Click on Delete Item
103+
const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
104+
expect(deleteMenuItem).toBeInTheDocument();
105+
fireEvent.click(deleteMenuItem);
106+
107+
// Confirm delete Modal is open
108+
expect(screen.getByText('Delete Unit'));
109+
const deleteButton = screen.getByRole('button', { name: /delete/i });
110+
fireEvent.click(deleteButton);
111+
112+
await waitFor(() => {
113+
expect(axiosMock.history.delete.length).toBe(1);
114+
});
115+
expect(mockShowToast).toHaveBeenCalled();
116+
117+
// Get restore / undo func from the toast
118+
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
119+
120+
const restoreUrl = getLibraryContainerRestoreApiUrl(containerHitSample.usageKey);
121+
axiosMock.onPost(restoreUrl).reply(200);
122+
// restore collection
123+
restoreFn();
124+
await waitFor(() => {
125+
expect(axiosMock.history.post.length).toEqual(1);
126+
});
127+
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
128+
});
129+
130+
it('should show error on delete the container from the menu', async () => {
131+
axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(400);
132+
133+
render(<ContainerCard hit={containerHitSample} />);
134+
135+
// Open menu
136+
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
137+
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
138+
139+
// Click on Delete Item
140+
const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
141+
expect(deleteMenuItem).toBeInTheDocument();
142+
fireEvent.click(deleteMenuItem);
143+
144+
// Confirm delete Modal is open
145+
expect(screen.getByText('Delete Unit'));
146+
const deleteButton = screen.getByRole('button', { name: /delete/i });
147+
fireEvent.click(deleteButton);
148+
149+
await waitFor(() => {
150+
expect(axiosMock.history.delete.length).toBe(1);
151+
});
152+
expect(mockShowToast).toHaveBeenCalledWith('Failed to delete unit');
153+
});
154+
88155
it('should render no child blocks in card preview', async () => {
89156
render(<ContainerCard hit={containerHitSample} />);
90157

src/library-authoring/components/ContainerCard.tsx

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { ReactNode, useCallback } from 'react';
1+
import { useCallback, ReactNode } from 'react';
22
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
33
import {
44
ActionRow,
55
Dropdown,
66
Icon,
77
IconButton,
8+
useToggle,
89
Stack,
910
} from '@openedx/paragon';
1011
import { MoreVert } from '@openedx/paragon/icons';
@@ -16,39 +17,58 @@ import { useComponentPickerContext } from '../common/context/ComponentPickerCont
1617
import { useLibraryContext } from '../common/context/LibraryContext';
1718
import { useSidebarContext } from '../common/context/SidebarContext';
1819
import BaseCard from './BaseCard';
19-
import { useContainerChildren } from '../data/apiHooks';
2020
import { useLibraryRoutes } from '../routes';
2121
import messages from './messages';
22+
import { useContainerChildren } from '../data/apiHooks';
23+
import ContainerDeleter from './ContainerDeleter';
2224

2325
type ContainerMenuProps = {
2426
hit: ContainerHit,
2527
};
2628

2729
const ContainerMenu = ({ hit } : ContainerMenuProps) => {
2830
const intl = useIntl();
29-
const { contextKey, blockId } = hit;
31+
const {
32+
contextKey,
33+
blockId,
34+
usageKey: containerId,
35+
displayName,
36+
} = hit;
37+
38+
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
3039

3140
return (
32-
<Dropdown id="container-card-dropdown">
33-
<Dropdown.Toggle
34-
id="container-card-menu-toggle"
35-
as={IconButton}
36-
src={MoreVert}
37-
iconAs={Icon}
38-
variant="primary"
39-
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
40-
data-testid="container-card-menu-toggle"
41+
<>
42+
<Dropdown id="container-card-dropdown">
43+
<Dropdown.Toggle
44+
id="container-card-menu-toggle"
45+
as={IconButton}
46+
src={MoreVert}
47+
iconAs={Icon}
48+
variant="primary"
49+
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
50+
data-testid="container-card-menu-toggle"
51+
/>
52+
<Dropdown.Menu>
53+
<Dropdown.Item
54+
as={Link}
55+
to={`/library/${contextKey}/container/${blockId}`}
56+
disabled
57+
>
58+
<FormattedMessage {...messages.menuOpen} />
59+
</Dropdown.Item>
60+
<Dropdown.Item onClick={confirmDelete}>
61+
<FormattedMessage {...messages.menuDeleteContainer} />
62+
</Dropdown.Item>
63+
</Dropdown.Menu>
64+
</Dropdown>
65+
<ContainerDeleter
66+
isOpen={isConfirmingDelete}
67+
close={cancelDelete}
68+
containerId={containerId}
69+
displayName={displayName}
4170
/>
42-
<Dropdown.Menu>
43-
<Dropdown.Item
44-
as={Link}
45-
to={`/library/${contextKey}/container/${blockId}`}
46-
disabled
47-
>
48-
<FormattedMessage {...messages.menuOpen} />
49-
</Dropdown.Item>
50-
</Dropdown.Menu>
51-
</Dropdown>
71+
</>
5272
);
5373
};
5474

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { ReactNode, useCallback, useContext } from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { Icon } from '@openedx/paragon';
4+
import { Warning, School, Widgets } from '@openedx/paragon/icons';
5+
6+
import DeleteModal from '../../generic/delete-modal/DeleteModal';
7+
import { useSidebarContext } from '../common/context/SidebarContext';
8+
import { ToastContext } from '../../generic/toast-context';
9+
import { useDeleteContainer, useRestoreContainer } from '../data/apiHooks';
10+
import messages from './messages';
11+
12+
type ContainerDeleterProps = {
13+
isOpen: boolean,
14+
close: () => void,
15+
containerId: string,
16+
displayName: string,
17+
};
18+
19+
const ContainerDeleter = ({
20+
isOpen,
21+
close,
22+
containerId,
23+
displayName,
24+
}: ContainerDeleterProps) => {
25+
const intl = useIntl();
26+
const {
27+
sidebarComponentInfo,
28+
closeLibrarySidebar,
29+
} = useSidebarContext();
30+
const deleteContainerMutation = useDeleteContainer(containerId);
31+
const restoreContainerMutation = useRestoreContainer(containerId);
32+
const { showToast } = useContext(ToastContext);
33+
34+
// TODO: support other container types besides 'unit'
35+
const deleteWarningTitle = intl.formatMessage(messages.deleteUnitWarningTitle);
36+
const deleteText = intl.formatMessage(messages.deleteUnitConfirm, {
37+
unitName: <b>{displayName}</b>,
38+
message: (
39+
<>
40+
<div className="d-flex mt-2">
41+
<Icon className="mr-2" src={School} />
42+
{intl.formatMessage(messages.deleteUnitConfirmMsg1)}
43+
</div>
44+
<div className="d-flex mt-2">
45+
<Icon className="mr-2" src={Widgets} />
46+
{intl.formatMessage(messages.deleteUnitConfirmMsg2)}
47+
</div>
48+
</>
49+
),
50+
}) as ReactNode as string;
51+
const deleteSuccess = intl.formatMessage(messages.deleteUnitSuccess);
52+
const deleteError = intl.formatMessage(messages.deleteUnitFailed);
53+
const undoDeleteError = messages.undoDeleteUnitToastFailed;
54+
55+
const restoreComponent = useCallback(async () => {
56+
try {
57+
await restoreContainerMutation.mutateAsync();
58+
showToast(intl.formatMessage(messages.undoDeleteContainerToastMessage));
59+
} catch (e) {
60+
showToast(intl.formatMessage(undoDeleteError));
61+
}
62+
}, []);
63+
64+
const onDelete = useCallback(async () => {
65+
await deleteContainerMutation.mutateAsync().then(() => {
66+
if (sidebarComponentInfo?.id === containerId) {
67+
closeLibrarySidebar();
68+
}
69+
showToast(
70+
deleteSuccess,
71+
{
72+
label: intl.formatMessage(messages.undoDeleteContainerToastAction),
73+
onClick: restoreComponent,
74+
},
75+
);
76+
}).catch(() => {
77+
showToast(deleteError);
78+
}).finally(() => {
79+
close();
80+
});
81+
}, [sidebarComponentInfo, showToast, deleteContainerMutation]);
82+
83+
return (
84+
<DeleteModal
85+
isOpen={isOpen}
86+
close={close}
87+
variant="warning"
88+
title={deleteWarningTitle}
89+
icon={Warning}
90+
description={deleteText}
91+
onDeleteSubmit={onDelete}
92+
/>
93+
);
94+
};
95+
96+
export default ContainerDeleter;

src/library-authoring/components/messages.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,56 @@ const messages = defineMessages({
176176
defaultMessage: 'This component can be synced in courses after publish.',
177177
description: 'Alert text of the modal to confirm publish a component in a library.',
178178
},
179+
menuDeleteContainer: {
180+
id: 'course-authoring.library-authoring.container.delete-menu-text',
181+
defaultMessage: 'Delete',
182+
description: 'Menu item to delete a container.',
183+
},
184+
deleteUnitWarningTitle: {
185+
id: 'course-authoring.library-authoring.unit.delete-confirmation-title',
186+
defaultMessage: 'Delete Unit',
187+
description: 'Title text for the warning displayed before deleting a Unit',
188+
},
189+
deleteUnitConfirm: {
190+
id: 'course-authoring.library-authoring.unit.delete-confirmation-text',
191+
defaultMessage: 'Delete {unitName}? {message}',
192+
description: 'Confirmation text to display before deleting a unit',
193+
},
194+
deleteUnitConfirmMsg1: {
195+
id: 'course-authoring.library-authoring.unit.delete-confirmation-msg-1',
196+
defaultMessage: 'Any course instances will stop receiving updates.',
197+
description: 'First part of confirmation message to display before deleting a unit',
198+
},
199+
deleteUnitConfirmMsg2: {
200+
id: 'course-authoring.library-authoring.unit.delete-confirmation-msg-2',
201+
defaultMessage: 'Any components will remain in the library.',
202+
description: 'Second part of confirmation message to display before deleting a unit',
203+
},
204+
deleteUnitSuccess: {
205+
id: 'course-authoring.library-authoring.unit.delete.success',
206+
defaultMessage: 'Unit deleted',
207+
description: 'Message to display on delete unit success',
208+
},
209+
deleteUnitFailed: {
210+
id: 'course-authoring.library-authoring.unit.delete-failed-error',
211+
defaultMessage: 'Failed to delete unit',
212+
description: 'Message to display on failure to delete a unit',
213+
},
214+
undoDeleteContainerToastAction: {
215+
id: 'course-authoring.library-authoring.container.undo-delete-container-toast-button',
216+
defaultMessage: 'Undo',
217+
description: 'Toast message to undo deletion of container',
218+
},
219+
undoDeleteContainerToastMessage: {
220+
id: 'course-authoring.library-authoring.container.undo-delete-container-toast-text',
221+
defaultMessage: 'Undo successful',
222+
description: 'Message to display on undo delete container success',
223+
},
224+
undoDeleteUnitToastFailed: {
225+
id: 'course-authoring.library-authoring.unit.undo-delete-unit-failed',
226+
defaultMessage: 'Failed to undo delete Unit operation',
227+
description: 'Message to display on failure to undo delete unit',
228+
},
179229
containerPreviewMoreBlocks: {
180230
id: 'course-authoring.library-authoring.component.container-card-preview.more-blocks',
181231
defaultMessage: '+{count}',

0 commit comments

Comments
 (0)