Skip to content

Commit

Permalink
feat: undo component delete [FC-0076] (#1556)
Browse files Browse the repository at this point in the history
Allows library authors to undo component deletion by displaying a toast message with an undo button for some duration after deletion.
  • Loading branch information
navinkarkera authored Dec 13, 2024
1 parent 69bbeda commit b110b6b
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 27 deletions.
9 changes: 8 additions & 1 deletion src/generic/delete-modal/DeleteModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const DeleteModal = ({
description,
variant,
btnLabel,
icon,
}) => {
const intl = useIntl();

Expand All @@ -31,6 +32,7 @@ const DeleteModal = ({
isOpen={isOpen}
onClose={close}
variant={variant}
icon={icon}
footerNode={(
<ActionRow>
<Button
Expand Down Expand Up @@ -65,6 +67,7 @@ DeleteModal.defaultProps = {
description: '',
variant: 'default',
btnLabel: '',
icon: null,
};

DeleteModal.propTypes = {
Expand All @@ -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;
21 changes: 18 additions & 3 deletions src/library-authoring/components/ComponentDeleter.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ToastActionData } from '../../generic/toast-context';
import {
fireEvent,
render,
Expand All @@ -6,22 +7,28 @@ 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;

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 () => {
Expand All @@ -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);

Expand All @@ -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');
});
});
});
53 changes: 31 additions & 22 deletions src/library-authoring/components/ComponentDeleter.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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) {
Expand All @@ -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={{
Expand All @@ -74,8 +82,9 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => {
),
}}
/>
</p>
</AlertModal>
)}
onDeleteSubmit={doDelete}
/>
);
};

Expand Down
22 changes: 21 additions & 1 deletion src/library-authoring/components/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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',
Expand Down
11 changes: 11 additions & 0 deletions src/library-authoring/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
*
Expand Down
11 changes: 11 additions & 0 deletions src/library-authoring/data/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
16 changes: 16 additions & 0 deletions src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
removeComponentsFromCollection,
publishXBlock,
deleteXBlockAsset,
restoreLibraryBlock,
} from './api';
import { VersionSpec } from '../LibraryBlock';

Expand Down Expand Up @@ -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) });
}
Expand Down Expand Up @@ -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({
Expand Down

0 comments on commit b110b6b

Please sign in to comment.