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.
This commit is contained in:
@@ -18,6 +18,7 @@ const DeleteModal = ({
|
||||
description,
|
||||
variant,
|
||||
btnLabel,
|
||||
icon,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -31,6 +32,7 @@ const DeleteModal = ({
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
variant={variant}
|
||||
icon={icon}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
@@ -65,6 +67,7 @@ DeleteModal.defaultProps = {
|
||||
description: '',
|
||||
variant: 'default',
|
||||
btnLabel: '',
|
||||
icon: null,
|
||||
};
|
||||
|
||||
DeleteModal.propTypes = {
|
||||
@@ -73,9 +76,13 @@ DeleteModal.propTypes = {
|
||||
category: PropTypes.string,
|
||||
onDeleteSubmit: PropTypes.func.isRequired,
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
description: PropTypes.oneOfType([
|
||||
PropTypes.element,
|
||||
PropTypes.string,
|
||||
]),
|
||||
variant: PropTypes.string,
|
||||
btnLabel: PropTypes.string,
|
||||
icon: PropTypes.elementType,
|
||||
};
|
||||
|
||||
export default DeleteModal;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ToastActionData } from '../../generic/toast-context';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -6,12 +7,15 @@ import {
|
||||
waitFor,
|
||||
} from '../../testUtils';
|
||||
import { SidebarProvider } from '../common/context/SidebarContext';
|
||||
import { mockContentLibrary, mockDeleteLibraryBlock, mockLibraryBlockMetadata } from '../data/api.mocks';
|
||||
import {
|
||||
mockContentLibrary, mockDeleteLibraryBlock, mockLibraryBlockMetadata, mockRestoreLibraryBlock,
|
||||
} from '../data/api.mocks';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
|
||||
mockContentLibrary.applyMock(); // Not required, but avoids 404 errors in the logs when <LibraryProvider> loads data
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
const mockDelete = mockDeleteLibraryBlock.applyMock();
|
||||
const mockRestore = mockRestoreLibraryBlock.applyMock();
|
||||
|
||||
const usageKey = mockLibraryBlockMetadata.usageKeyPublished;
|
||||
|
||||
@@ -19,9 +23,12 @@ const renderArgs = {
|
||||
extraWrapper: SidebarProvider,
|
||||
};
|
||||
|
||||
let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; };
|
||||
|
||||
describe('<ComponentDeleter />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
const mocks = initializeMocks();
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
});
|
||||
|
||||
it('is invisible when isConfirmingDelete is false', async () => {
|
||||
@@ -48,7 +55,7 @@ describe('<ComponentDeleter />', () => {
|
||||
expect(mockCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the block when confirmed', async () => {
|
||||
it('deletes the block when confirmed, shows a toast with undo option and restores block on undo', async () => {
|
||||
const mockCancel = jest.fn();
|
||||
render(<ComponentDeleter usageKey={usageKey} isConfirmingDelete cancelDelete={mockCancel} />, renderArgs);
|
||||
|
||||
@@ -61,5 +68,13 @@ describe('<ComponentDeleter />', () => {
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockCancel).toHaveBeenCalled(); // In order to close the modal, this also gets called.
|
||||
expect(mockShowToast).toHaveBeenCalled();
|
||||
// Get restore / undo func from the toast
|
||||
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
||||
restoreFn();
|
||||
await waitFor(() => {
|
||||
expect(mockRestore).toHaveBeenCalled();
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
AlertModal,
|
||||
Button,
|
||||
} from '@openedx/paragon';
|
||||
import { Warning } from '@openedx/paragon/icons';
|
||||
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useDeleteLibraryBlock, useLibraryBlockMetadata } from '../data/apiHooks';
|
||||
import { useDeleteLibraryBlock, useLibraryBlockMetadata, useRestoreLibraryBlock } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
|
||||
/**
|
||||
* Helper component to load and display the name of the block.
|
||||
@@ -35,11 +32,29 @@ interface Props {
|
||||
const ComponentDeleter = ({ usageKey, ...props }: Props) => {
|
||||
const intl = useIntl();
|
||||
const { sidebarComponentInfo, closeLibrarySidebar } = useSidebarContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const sidebarComponentUsageKey = sidebarComponentInfo?.id;
|
||||
|
||||
const restoreComponentMutation = useRestoreLibraryBlock();
|
||||
const restoreComponent = useCallback(async () => {
|
||||
try {
|
||||
await restoreComponentMutation.mutateAsync({ usageKey });
|
||||
showToast(intl.formatMessage(messages.undoDeleteComponentToastSuccess));
|
||||
} catch (e) {
|
||||
showToast(intl.formatMessage(messages.undoDeleteComponentToastFailed));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteComponentMutation = useDeleteLibraryBlock();
|
||||
const doDelete = React.useCallback(() => {
|
||||
deleteComponentMutation.mutateAsync({ usageKey });
|
||||
const doDelete = React.useCallback(async () => {
|
||||
await deleteComponentMutation.mutateAsync({ usageKey });
|
||||
showToast(
|
||||
intl.formatMessage(messages.deleteComponentSuccess),
|
||||
{
|
||||
label: intl.formatMessage(messages.undoDeleteCollectionToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
props.cancelDelete();
|
||||
// Close the sidebar if it's still open showing the deleted component:
|
||||
if (usageKey === sidebarComponentUsageKey) {
|
||||
@@ -52,20 +67,13 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
title={intl.formatMessage(messages.deleteComponentWarningTitle)}
|
||||
<DeleteModal
|
||||
isOpen
|
||||
onClose={props.cancelDelete}
|
||||
close={props.cancelDelete}
|
||||
variant="warning"
|
||||
title={intl.formatMessage(messages.deleteComponentWarningTitle)}
|
||||
icon={Warning}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={props.cancelDelete}><FormattedMessage {...messages.deleteComponentCancelButton} /></Button>
|
||||
<Button variant="danger" onClick={doDelete}><FormattedMessage {...messages.deleteComponentButton} /></Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
description={(
|
||||
<FormattedMessage
|
||||
{...messages.deleteComponentConfirm}
|
||||
values={{
|
||||
@@ -74,8 +82,9 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</AlertModal>
|
||||
)}
|
||||
onDeleteSubmit={doDelete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const messages = defineMessages({
|
||||
},
|
||||
deleteComponentConfirm: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-text',
|
||||
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.',
|
||||
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.',
|
||||
description: 'Confirmation text to display before deleting a component',
|
||||
},
|
||||
deleteComponentCancelButton: {
|
||||
@@ -86,6 +86,26 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Button to confirm deletion of a component',
|
||||
},
|
||||
deleteComponentSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.delete-error-success',
|
||||
defaultMessage: 'Component deleted',
|
||||
description: 'Message to display on delete component success',
|
||||
},
|
||||
undoDeleteComponentToastAction: {
|
||||
id: 'course-authoring.library-authoring.component.undo-delete-component-toast-button',
|
||||
defaultMessage: 'Undo',
|
||||
description: 'Toast message to undo deletion of component',
|
||||
},
|
||||
undoDeleteComponentToastSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.undo-delete-component-toast-text',
|
||||
defaultMessage: 'Undo successful',
|
||||
description: 'Message to display on undo delete component success',
|
||||
},
|
||||
undoDeleteComponentToastFailed: {
|
||||
id: 'course-authoring.library-authoring.component.undo-delete-component-failed',
|
||||
defaultMessage: 'Failed to undo delete component operation',
|
||||
description: 'Message to display on failure to undo delete component',
|
||||
},
|
||||
deleteCollection: {
|
||||
id: 'course-authoring.library-authoring.collection.delete-menu-text',
|
||||
defaultMessage: 'Delete',
|
||||
|
||||
@@ -243,6 +243,17 @@ mockDeleteLibraryBlock.applyMock = () => (
|
||||
jest.spyOn(api, 'deleteLibraryBlock').mockImplementation(mockDeleteLibraryBlock)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock for `restoreLibraryBlock()`
|
||||
*/
|
||||
export async function mockRestoreLibraryBlock(): ReturnType<typeof api.restoreLibraryBlock> {
|
||||
// no-op
|
||||
}
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockRestoreLibraryBlock.applyMock = () => (
|
||||
jest.spyOn(api, 'restoreLibraryBlock').mockImplementation(mockRestoreLibraryBlock)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock for `getXBlockFields()`
|
||||
*
|
||||
|
||||
@@ -29,6 +29,17 @@ describe('library data API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreLibraryBlock', () => {
|
||||
it('should restore a soft-deleted library block', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const usageKey = 'lib:org:1';
|
||||
const url = api.getLibraryBlockRestoreUrl(usageKey);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
await api.restoreLibraryBlock({ usageKey });
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitLibraryChanges', () => {
|
||||
it('should commit library changes', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
|
||||
@@ -29,6 +29,11 @@ export const getLibraryTeamMemberApiUrl = (libraryId: string, username: string)
|
||||
*/
|
||||
export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/`;
|
||||
|
||||
/**
|
||||
* Get the URL for restoring deleted library block.
|
||||
*/
|
||||
export const getLibraryBlockRestoreUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}restore/`;
|
||||
|
||||
/**
|
||||
* Get the URL for library block metadata.
|
||||
*/
|
||||
@@ -281,6 +286,11 @@ export async function deleteLibraryBlock({ usageKey }: DeleteBlockDataRequest):
|
||||
await client.delete(getLibraryBlockMetadataUrl(usageKey));
|
||||
}
|
||||
|
||||
export async function restoreLibraryBlock({ usageKey }: DeleteBlockDataRequest): Promise<void> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
await client.post(getLibraryBlockRestoreUrl(usageKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update library metadata.
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
removeComponentsFromCollection,
|
||||
publishXBlock,
|
||||
deleteXBlockAsset,
|
||||
restoreLibraryBlock,
|
||||
} from './api';
|
||||
import { VersionSpec } from '../LibraryBlock';
|
||||
|
||||
@@ -115,6 +116,7 @@ export function invalidateComponentData(queryClient: QueryClient, contentLibrary
|
||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.xblockFields(usageKey) });
|
||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(usageKey) });
|
||||
// The description and display name etc. may have changed, so refresh everything in the library too:
|
||||
// This might fail in case this helper is called after deleting the block.
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(contentLibraryId) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
|
||||
}
|
||||
@@ -158,6 +160,20 @@ export const useDeleteLibraryBlock = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this mutation to restore a deleted block in a library
|
||||
*/
|
||||
export const useRestoreLibraryBlock = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: restoreLibraryBlock,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
const libraryId = getLibraryId(variables.usageKey);
|
||||
invalidateComponentData(queryClient, libraryId, variables.usageKey);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateLibraryMetadata = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
Reference in New Issue
Block a user