Skip to content

Commit b110b6b

Browse files
authored
feat: undo component delete [FC-0076] (#1556)
Allows library authors to undo component deletion by displaying a toast message with an undo button for some duration after deletion.
1 parent 69bbeda commit b110b6b

8 files changed

Lines changed: 126 additions & 27 deletions

File tree

src/generic/delete-modal/DeleteModal.jsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const DeleteModal = ({
1818
description,
1919
variant,
2020
btnLabel,
21+
icon,
2122
}) => {
2223
const intl = useIntl();
2324

@@ -31,6 +32,7 @@ const DeleteModal = ({
3132
isOpen={isOpen}
3233
onClose={close}
3334
variant={variant}
35+
icon={icon}
3436
footerNode={(
3537
<ActionRow>
3638
<Button
@@ -65,6 +67,7 @@ DeleteModal.defaultProps = {
6567
description: '',
6668
variant: 'default',
6769
btnLabel: '',
70+
icon: null,
6871
};
6972

7073
DeleteModal.propTypes = {
@@ -73,9 +76,13 @@ DeleteModal.propTypes = {
7376
category: PropTypes.string,
7477
onDeleteSubmit: PropTypes.func.isRequired,
7578
title: PropTypes.string,
76-
description: PropTypes.string,
79+
description: PropTypes.oneOfType([
80+
PropTypes.element,
81+
PropTypes.string,
82+
]),
7783
variant: PropTypes.string,
7884
btnLabel: PropTypes.string,
85+
icon: PropTypes.elementType,
7986
};
8087

8188
export default DeleteModal;

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ToastActionData } from '../../generic/toast-context';
12
import {
23
fireEvent,
34
render,
@@ -6,22 +7,28 @@ import {
67
waitFor,
78
} from '../../testUtils';
89
import { SidebarProvider } from '../common/context/SidebarContext';
9-
import { mockContentLibrary, mockDeleteLibraryBlock, mockLibraryBlockMetadata } from '../data/api.mocks';
10+
import {
11+
mockContentLibrary, mockDeleteLibraryBlock, mockLibraryBlockMetadata, mockRestoreLibraryBlock,
12+
} from '../data/api.mocks';
1013
import ComponentDeleter from './ComponentDeleter';
1114

1215
mockContentLibrary.applyMock(); // Not required, but avoids 404 errors in the logs when <LibraryProvider> loads data
1316
mockLibraryBlockMetadata.applyMock();
1417
const mockDelete = mockDeleteLibraryBlock.applyMock();
18+
const mockRestore = mockRestoreLibraryBlock.applyMock();
1519

1620
const usageKey = mockLibraryBlockMetadata.usageKeyPublished;
1721

1822
const renderArgs = {
1923
extraWrapper: SidebarProvider,
2024
};
2125

26+
let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; };
27+
2228
describe('<ComponentDeleter />', () => {
2329
beforeEach(() => {
24-
initializeMocks();
30+
const mocks = initializeMocks();
31+
mockShowToast = mocks.mockShowToast;
2532
});
2633

2734
it('is invisible when isConfirmingDelete is false', async () => {
@@ -48,7 +55,7 @@ describe('<ComponentDeleter />', () => {
4855
expect(mockCancel).toHaveBeenCalled();
4956
});
5057

51-
it('deletes the block when confirmed', async () => {
58+
it('deletes the block when confirmed, shows a toast with undo option and restores block on undo', async () => {
5259
const mockCancel = jest.fn();
5360
render(<ComponentDeleter usageKey={usageKey} isConfirmingDelete cancelDelete={mockCancel} />, renderArgs);
5461

@@ -61,5 +68,13 @@ describe('<ComponentDeleter />', () => {
6168
expect(mockDelete).toHaveBeenCalled();
6269
});
6370
expect(mockCancel).toHaveBeenCalled(); // In order to close the modal, this also gets called.
71+
expect(mockShowToast).toHaveBeenCalled();
72+
// Get restore / undo func from the toast
73+
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
74+
restoreFn();
75+
await waitFor(() => {
76+
expect(mockRestore).toHaveBeenCalled();
77+
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
78+
});
6479
});
6580
});

src/library-authoring/components/ComponentDeleter.tsx

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import React from 'react';
1+
import React, { useCallback, useContext } from 'react';
22
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
3-
import {
4-
ActionRow,
5-
AlertModal,
6-
Button,
7-
} from '@openedx/paragon';
83
import { Warning } from '@openedx/paragon/icons';
94

105
import { useSidebarContext } from '../common/context/SidebarContext';
11-
import { useDeleteLibraryBlock, useLibraryBlockMetadata } from '../data/apiHooks';
6+
import { useDeleteLibraryBlock, useLibraryBlockMetadata, useRestoreLibraryBlock } from '../data/apiHooks';
127
import messages from './messages';
8+
import { ToastContext } from '../../generic/toast-context';
9+
import DeleteModal from '../../generic/delete-modal/DeleteModal';
1310

1411
/**
1512
* Helper component to load and display the name of the block.
@@ -35,11 +32,29 @@ interface Props {
3532
const ComponentDeleter = ({ usageKey, ...props }: Props) => {
3633
const intl = useIntl();
3734
const { sidebarComponentInfo, closeLibrarySidebar } = useSidebarContext();
35+
const { showToast } = useContext(ToastContext);
3836
const sidebarComponentUsageKey = sidebarComponentInfo?.id;
3937

38+
const restoreComponentMutation = useRestoreLibraryBlock();
39+
const restoreComponent = useCallback(async () => {
40+
try {
41+
await restoreComponentMutation.mutateAsync({ usageKey });
42+
showToast(intl.formatMessage(messages.undoDeleteComponentToastSuccess));
43+
} catch (e) {
44+
showToast(intl.formatMessage(messages.undoDeleteComponentToastFailed));
45+
}
46+
}, []);
47+
4048
const deleteComponentMutation = useDeleteLibraryBlock();
41-
const doDelete = React.useCallback(() => {
42-
deleteComponentMutation.mutateAsync({ usageKey });
49+
const doDelete = React.useCallback(async () => {
50+
await deleteComponentMutation.mutateAsync({ usageKey });
51+
showToast(
52+
intl.formatMessage(messages.deleteComponentSuccess),
53+
{
54+
label: intl.formatMessage(messages.undoDeleteCollectionToastAction),
55+
onClick: restoreComponent,
56+
},
57+
);
4358
props.cancelDelete();
4459
// Close the sidebar if it's still open showing the deleted component:
4560
if (usageKey === sidebarComponentUsageKey) {
@@ -52,20 +67,13 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => {
5267
}
5368

5469
return (
55-
<AlertModal
56-
title={intl.formatMessage(messages.deleteComponentWarningTitle)}
70+
<DeleteModal
5771
isOpen
58-
onClose={props.cancelDelete}
72+
close={props.cancelDelete}
5973
variant="warning"
74+
title={intl.formatMessage(messages.deleteComponentWarningTitle)}
6075
icon={Warning}
61-
footerNode={(
62-
<ActionRow>
63-
<Button variant="tertiary" onClick={props.cancelDelete}><FormattedMessage {...messages.deleteComponentCancelButton} /></Button>
64-
<Button variant="danger" onClick={doDelete}><FormattedMessage {...messages.deleteComponentButton} /></Button>
65-
</ActionRow>
66-
)}
67-
>
68-
<p>
76+
description={(
6977
<FormattedMessage
7078
{...messages.deleteComponentConfirm}
7179
values={{
@@ -74,8 +82,9 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => {
7482
),
7583
}}
7684
/>
77-
</p>
78-
</AlertModal>
85+
)}
86+
onDeleteSubmit={doDelete}
87+
/>
7988
);
8089
};
8190

src/library-authoring/components/messages.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const messages = defineMessages({
7373
},
7474
deleteComponentConfirm: {
7575
id: 'course-authoring.library-authoring.component.delete-confirmation-text',
76-
defaultMessage: 'Delete {componentName} permanently? If this component has been used in a course, those copies won\'t be deleted, but they will no longer receive updates from the library.',
76+
defaultMessage: 'Delete {componentName}? If this component has been used in a course, those copies won\'t be deleted, but they will no longer receive updates from the library.',
7777
description: 'Confirmation text to display before deleting a component',
7878
},
7979
deleteComponentCancelButton: {
@@ -86,6 +86,26 @@ const messages = defineMessages({
8686
defaultMessage: 'Delete',
8787
description: 'Button to confirm deletion of a component',
8888
},
89+
deleteComponentSuccess: {
90+
id: 'course-authoring.library-authoring.component.delete-error-success',
91+
defaultMessage: 'Component deleted',
92+
description: 'Message to display on delete component success',
93+
},
94+
undoDeleteComponentToastAction: {
95+
id: 'course-authoring.library-authoring.component.undo-delete-component-toast-button',
96+
defaultMessage: 'Undo',
97+
description: 'Toast message to undo deletion of component',
98+
},
99+
undoDeleteComponentToastSuccess: {
100+
id: 'course-authoring.library-authoring.component.undo-delete-component-toast-text',
101+
defaultMessage: 'Undo successful',
102+
description: 'Message to display on undo delete component success',
103+
},
104+
undoDeleteComponentToastFailed: {
105+
id: 'course-authoring.library-authoring.component.undo-delete-component-failed',
106+
defaultMessage: 'Failed to undo delete component operation',
107+
description: 'Message to display on failure to undo delete component',
108+
},
89109
deleteCollection: {
90110
id: 'course-authoring.library-authoring.collection.delete-menu-text',
91111
defaultMessage: 'Delete',

src/library-authoring/data/api.mocks.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,17 @@ mockDeleteLibraryBlock.applyMock = () => (
243243
jest.spyOn(api, 'deleteLibraryBlock').mockImplementation(mockDeleteLibraryBlock)
244244
);
245245

246+
/**
247+
* Mock for `restoreLibraryBlock()`
248+
*/
249+
export async function mockRestoreLibraryBlock(): ReturnType<typeof api.restoreLibraryBlock> {
250+
// no-op
251+
}
252+
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
253+
mockRestoreLibraryBlock.applyMock = () => (
254+
jest.spyOn(api, 'restoreLibraryBlock').mockImplementation(mockRestoreLibraryBlock)
255+
);
256+
246257
/**
247258
* Mock for `getXBlockFields()`
248259
*

src/library-authoring/data/api.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ describe('library data API', () => {
2929
});
3030
});
3131

32+
describe('restoreLibraryBlock', () => {
33+
it('should restore a soft-deleted library block', async () => {
34+
const { axiosMock } = initializeMocks();
35+
const usageKey = 'lib:org:1';
36+
const url = api.getLibraryBlockRestoreUrl(usageKey);
37+
axiosMock.onPost(url).reply(200);
38+
await api.restoreLibraryBlock({ usageKey });
39+
expect(axiosMock.history.post[0].url).toEqual(url);
40+
});
41+
});
42+
3243
describe('commitLibraryChanges', () => {
3344
it('should commit library changes', async () => {
3445
const { axiosMock } = initializeMocks();

src/library-authoring/data/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export const getLibraryTeamMemberApiUrl = (libraryId: string, username: string)
2929
*/
3030
export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/`;
3131

32+
/**
33+
* Get the URL for restoring deleted library block.
34+
*/
35+
export const getLibraryBlockRestoreUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}restore/`;
36+
3237
/**
3338
* Get the URL for library block metadata.
3439
*/
@@ -281,6 +286,11 @@ export async function deleteLibraryBlock({ usageKey }: DeleteBlockDataRequest):
281286
await client.delete(getLibraryBlockMetadataUrl(usageKey));
282287
}
283288

289+
export async function restoreLibraryBlock({ usageKey }: DeleteBlockDataRequest): Promise<void> {
290+
const client = getAuthenticatedHttpClient();
291+
await client.post(getLibraryBlockRestoreUrl(usageKey));
292+
}
293+
284294
/**
285295
* Update library metadata.
286296
*/

src/library-authoring/data/apiHooks.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
removeComponentsFromCollection,
4545
publishXBlock,
4646
deleteXBlockAsset,
47+
restoreLibraryBlock,
4748
} from './api';
4849
import { VersionSpec } from '../LibraryBlock';
4950

@@ -115,6 +116,7 @@ export function invalidateComponentData(queryClient: QueryClient, contentLibrary
115116
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.xblockFields(usageKey) });
116117
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(usageKey) });
117118
// The description and display name etc. may have changed, so refresh everything in the library too:
119+
// This might fail in case this helper is called after deleting the block.
118120
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(contentLibraryId) });
119121
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
120122
}
@@ -158,6 +160,20 @@ export const useDeleteLibraryBlock = () => {
158160
});
159161
};
160162

163+
/**
164+
* Use this mutation to restore a deleted block in a library
165+
*/
166+
export const useRestoreLibraryBlock = () => {
167+
const queryClient = useQueryClient();
168+
return useMutation({
169+
mutationFn: restoreLibraryBlock,
170+
onSettled: (_data, _error, variables) => {
171+
const libraryId = getLibraryId(variables.usageKey);
172+
invalidateComponentData(queryClient, libraryId, variables.usageKey);
173+
},
174+
});
175+
};
176+
161177
export const useUpdateLibraryMetadata = () => {
162178
const queryClient = useQueryClient();
163179
return useMutation({

0 commit comments

Comments
 (0)