From b110b6bdc9216759d509c660c025bfb8c6b973d8 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 13 Dec 2024 23:48:29 +0530 Subject: [PATCH] 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. --- src/generic/delete-modal/DeleteModal.jsx | 9 +++- .../components/ComponentDeleter.test.tsx | 21 ++++++-- .../components/ComponentDeleter.tsx | 53 +++++++++++-------- src/library-authoring/components/messages.ts | 22 +++++++- src/library-authoring/data/api.mocks.ts | 11 ++++ src/library-authoring/data/api.test.ts | 11 ++++ src/library-authoring/data/api.ts | 10 ++++ src/library-authoring/data/apiHooks.ts | 16 ++++++ 8 files changed, 126 insertions(+), 27 deletions(-) diff --git a/src/generic/delete-modal/DeleteModal.jsx b/src/generic/delete-modal/DeleteModal.jsx index c7a22c5d9e..3384c0f3bc 100644 --- a/src/generic/delete-modal/DeleteModal.jsx +++ b/src/generic/delete-modal/DeleteModal.jsx @@ -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={( - - - )} - > -

+ description={( { ), }} /> -

- +)} + onDeleteSubmit={doDelete} + /> ); }; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index b591d956f5..0e466736e6 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -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', diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index bfd4d81ef5..0e064f8a0f 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -243,6 +243,17 @@ mockDeleteLibraryBlock.applyMock = () => ( jest.spyOn(api, 'deleteLibraryBlock').mockImplementation(mockDeleteLibraryBlock) ); +/** + * Mock for `restoreLibraryBlock()` + */ +export async function mockRestoreLibraryBlock(): ReturnType { + // 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()` * diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index 9cace9b3ae..86a5249905 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -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(); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 35615df1c6..6432aaafd3 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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 { + const client = getAuthenticatedHttpClient(); + await client.post(getLibraryBlockRestoreUrl(usageKey)); +} + /** * Update library metadata. */ diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 549e211b8f..46cd148925 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -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({