From 434fea3a955e02e05e670cf420df9481d1ba38bd Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 8 Oct 2024 22:29:06 +0530 Subject: [PATCH] feat: delete collection [FC-0062] (#1333) * feat: delete collection * feat: update button status on delete * test: add tests for collection delete --- src/course-outline/CourseOutline.test.jsx | 27 +++-- src/course-unit/CourseUnit.test.jsx | 16 ++- src/generic/delete-modal/DeleteModal.jsx | 29 ++++- src/generic/delete-modal/messages.js | 4 + .../ProccessingNotification.scss | 32 ++---- .../ProcessingNotification.test.jsx | 28 ++++- src/generic/processing-notification/index.jsx | 40 ++++--- src/generic/toast-context/index.tsx | 38 +++++-- .../components/CollectionCard.test.tsx | 101 +++++++++++++++-- .../components/CollectionCard.tsx | 106 ++++++++++++++---- src/library-authoring/components/messages.ts | 35 ++++++ src/library-authoring/data/api.ts | 22 +++- src/library-authoring/data/apiHooks.ts | 33 +++++- src/testUtils.tsx | 3 + 14 files changed, 406 insertions(+), 108 deletions(-) diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 062711d41a..b7f8332eeb 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -226,7 +226,7 @@ describe('', () => { }); it('check video sharing option shows error on failure', async () => { - const { findByLabelText, queryByRole } = render(); + render(); axiosMock .onPost(getCourseBlockApiUrl(courseId), { @@ -235,7 +235,7 @@ describe('', () => { }, }) .reply(500); - const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage); + const optionDropdown = await screen.findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage); await act( async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }), ); @@ -247,8 +247,10 @@ describe('', () => { }, })); - const alertElement = queryByRole('alert'); - expect(alertElement).toHaveTextContent( + const alertElements = screen.queryAllByRole('alert'); + expect(alertElements.find( + (el) => el.classList.contains('alert-content'), + )).toHaveTextContent( pageAlertMessages.alertFailedGeneric.defaultMessage, ); }); @@ -511,9 +513,10 @@ describe('', () => { notificationDismissUrl: '/some/url', }); - const { findByRole } = render(); - expect(await findByRole('alert')).toBeInTheDocument(); - const dismissBtn = await findByRole('button', { name: 'Dismiss' }); + render(); + const alert = await screen.findByText(pageAlertMessages.configurationErrorTitle.defaultMessage); + expect(alert).toBeInTheDocument(); + const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' }); axiosMock .onDelete('/some/url') .reply(204); @@ -2160,10 +2163,10 @@ describe('', () => { }); it('check whether unit copy & paste option works correctly', async () => { - const { findAllByTestId, queryByTestId, findAllByRole } = render(); + render(); // get first section -> first subsection -> first unit element const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - const [sectionElement] = await findAllByTestId('section-card'); + const [sectionElement] = await screen.findAllByTestId('section-card'); const [subsection] = section.childInfo.children; axiosMock .onGet(getXBlockApiUrl(section.id)) @@ -2202,7 +2205,7 @@ describe('', () => { await act(async () => fireEvent.mouseOver(clipboardLabel)); // find clipboard content popover link - const popoverContent = queryByTestId('popover-content'); + const popoverContent = screen.queryByTestId('popover-content'); expect(popoverContent.tagName).toBe('A'); expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`); @@ -2233,8 +2236,10 @@ describe('', () => { errorFiles: ['error.css'], }); + let alerts = await screen.findAllByRole('alert'); + // Exclude processing notification toast + alerts = alerts.filter((el) => !el.classList.contains('toast-container')); // 3 alerts should be present - const alerts = await findAllByRole('alert'); expect(alerts.length).toEqual(3); // check alerts for errorFiles diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 91e8a2f51a..d6b00a385d 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { - act, render, waitFor, fireEvent, within, + act, render, waitFor, fireEvent, within, screen, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; @@ -525,17 +525,19 @@ describe('', () => { }); it('should display a warning alert for unpublished course unit version', async () => { - const { getByRole } = render(); + render(); await waitFor(() => { - const unpublishedAlert = getByRole('alert', { class: 'course-unit-unpublished-alert' }); + const unpublishedAlert = screen.getAllByRole('alert').find( + (el) => el.classList.contains('alert-content'), + ); expect(unpublishedAlert).toHaveTextContent(messages.alertUnpublishedVersion.defaultMessage); expect(unpublishedAlert).toHaveClass('alert-warning'); }); }); it('should not display an unpublished alert for a course unit with explicit staff lock and unpublished status', async () => { - const { queryByRole } = render(); + render(); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -547,8 +549,10 @@ describe('', () => { await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await waitFor(() => { - const unpublishedAlert = queryByRole('alert', { class: 'course-unit-unpublished-alert' }); - expect(unpublishedAlert).toBeNull(); + const alert = screen.queryAllByRole('alert').find( + (el) => el.classList.contains('alert-content'), + ); + expect(alert).toBeUndefined(); }); }); diff --git a/src/generic/delete-modal/DeleteModal.jsx b/src/generic/delete-modal/DeleteModal.jsx index 97768f6808..82dfd0b139 100644 --- a/src/generic/delete-modal/DeleteModal.jsx +++ b/src/generic/delete-modal/DeleteModal.jsx @@ -3,6 +3,7 @@ import { ActionRow, Button, AlertModal, + StatefulButton, } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -15,6 +16,8 @@ const DeleteModal = ({ onDeleteSubmit, title, description, + variant, + btnState, }) => { const intl = useIntl(); @@ -26,20 +29,32 @@ const DeleteModal = ({ title={modalTitle} isOpen={isOpen} onClose={close} + variant={variant} footerNode={( - - + labels={{ + default: intl.formatMessage(messages.deleteButton), + pending: intl.formatMessage(messages.pendingDeleteButton), + }} + /> )} > @@ -52,6 +67,8 @@ DeleteModal.defaultProps = { category: '', title: '', description: '', + variant: 'default', + btnState: 'default', }; DeleteModal.propTypes = { @@ -61,6 +78,8 @@ DeleteModal.propTypes = { onDeleteSubmit: PropTypes.func.isRequired, title: PropTypes.string, description: PropTypes.string, + variant: PropTypes.string, + btnState: PropTypes.string, }; export default DeleteModal; diff --git a/src/generic/delete-modal/messages.js b/src/generic/delete-modal/messages.js index 748120551e..c586a05991 100644 --- a/src/generic/delete-modal/messages.js +++ b/src/generic/delete-modal/messages.js @@ -13,6 +13,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.delete-modal.button.delete', defaultMessage: 'Delete', }, + pendingDeleteButton: { + id: 'course-authoring.course-outline.delete-modal.button.pending-delete', + defaultMessage: 'Deleting', + }, cancelButton: { id: 'course-authoring.course-outline.delete-modal.button.cancel', defaultMessage: 'Cancel', diff --git a/src/generic/processing-notification/ProccessingNotification.scss b/src/generic/processing-notification/ProccessingNotification.scss index 257cbd2afc..455c16a407 100644 --- a/src/generic/processing-notification/ProccessingNotification.scss +++ b/src/generic/processing-notification/ProccessingNotification.scss @@ -1,25 +1,15 @@ -.processing-notification { - display: flex; - position: fixed; - bottom: -13rem; - transition: bottom 1s; - right: 1.25rem; - padding: .625rem 1.25rem; - z-index: $zindex-popover; - - &.is-show { - bottom: .625rem; - } +.processing-notification-icon { + animation: rotate 1s linear infinite; +} - .processing-notification-icon { - margin-right: .625rem; - animation: rotate 1s linear infinite; +.processing-notification-hide-close-button { + .btn-icon { + display: none; } +} - .processing-notification-title { - font-size: 1rem; - line-height: 1.5rem; - color: $white; - margin-bottom: 0; - } +.toast-container { + right: 1.25rem; + left: unset; + z-index: $zindex-popover; } diff --git a/src/generic/processing-notification/ProcessingNotification.test.jsx b/src/generic/processing-notification/ProcessingNotification.test.jsx index 16b86401ea..97f57429bf 100644 --- a/src/generic/processing-notification/ProcessingNotification.test.jsx +++ b/src/generic/processing-notification/ProcessingNotification.test.jsx @@ -1,17 +1,37 @@ -import React from 'react'; -import { render } from '@testing-library/react'; import { capitalize } from 'lodash'; +import userEvent from '@testing-library/user-event'; +import { initializeMocks, render, screen } from '../../testUtils'; import { NOTIFICATION_MESSAGES } from '../../constants'; import ProcessingNotification from '.'; +const mockUndo = jest.fn(); + const props = { title: NOTIFICATION_MESSAGES.saving, isShow: true, + action: { + label: 'Undo', + onClick: mockUndo, + }, }; describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + it('renders successfully', () => { - const { getByText } = render(); - expect(getByText(capitalize(props.title))).toBeInTheDocument(); + render( {}} />); + expect(screen.getByText(capitalize(props.title))).toBeInTheDocument(); + expect(screen.getByText('Undo')).toBeInTheDocument(); + expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).not.toBeInTheDocument(); + userEvent.click(screen.getByText('Undo')); + expect(mockUndo).toBeCalled(); + }); + + it('add hide-close-button class if no close action is passed', () => { + render(); + expect(screen.getByText(capitalize(props.title))).toBeInTheDocument(); + expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).toBeInTheDocument(); }); }); diff --git a/src/generic/processing-notification/index.jsx b/src/generic/processing-notification/index.jsx index 75c718c830..b31150a957 100644 --- a/src/generic/processing-notification/index.jsx +++ b/src/generic/processing-notification/index.jsx @@ -1,28 +1,40 @@ -import React from 'react'; import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { Badge, Icon } from '@openedx/paragon'; +import { + Icon, Toast, +} from '@openedx/paragon'; import { Settings as IconSettings } from '@openedx/paragon/icons'; import { capitalize } from 'lodash'; +import classNames from 'classnames'; -const ProcessingNotification = ({ isShow, title }) => ( - ( + {})} > - -

- {capitalize(title)} -

-
+ + + {capitalize(title)} + + ); +ProcessingNotification.defaultProps = { + close: null, +}; + ProcessingNotification.propTypes = { isShow: PropTypes.bool.isRequired, title: PropTypes.string.isRequired, + action: PropTypes.shape({ + label: PropTypes.string.isRequired, + onClick: PropTypes.func, + }), + close: PropTypes.func, }; export default ProcessingNotification; diff --git a/src/generic/toast-context/index.tsx b/src/generic/toast-context/index.tsx index 40145068fa..ea47dce8d2 100644 --- a/src/generic/toast-context/index.tsx +++ b/src/generic/toast-context/index.tsx @@ -2,9 +2,15 @@ import React from 'react'; import ProcessingNotification from '../processing-notification'; +export interface ToastActionData { + label: string; + onClick: () => void; +} + export interface ToastContextData { toastMessage: string | null; - showToast: (message: string) => void; + toastAction?: ToastActionData; + showToast: (message: string, action?: ToastActionData) => void; closeToast: () => void; } @@ -18,6 +24,7 @@ export interface ToastProviderProps { */ export const ToastContext = React.createContext({ toastMessage: null, + toastAction: undefined, showToast: () => {}, closeToast: () => {}, }); @@ -30,32 +37,41 @@ export const ToastProvider = (props: ToastProviderProps) => { // see: https://github.com/open-craft/frontend-app-course-authoring/pull/38#discussion_r1638990647 const [toastMessage, setToastMessage] = React.useState(null); + const [toastAction, setToastAction] = React.useState(undefined); + + const resetState = React.useCallback(() => { + setToastMessage(null); + setToastAction(undefined); + }, []); React.useEffect(() => () => { // Cleanup function to avoid updating state on unmounted component - setToastMessage(null); + resetState(); }, []); - const showToast = React.useCallback((message) => { + const showToast = React.useCallback((message, action?: ToastActionData) => { setToastMessage(message); - // Close the toast after 5 seconds - setTimeout(() => { - setToastMessage(null); - }, 5000); - }, [setToastMessage]); - const closeToast = React.useCallback(() => setToastMessage(null), [setToastMessage]); + setToastAction(action); + }, [setToastMessage, setToastAction]); + const closeToast = React.useCallback(() => resetState(), [setToastMessage, setToastAction]); const context = React.useMemo(() => ({ toastMessage, + toastAction, showToast, closeToast, - }), [toastMessage, showToast, closeToast]); + }), [toastMessage, toastAction, showToast, closeToast]); return ( {props.children} { toastMessage && ( - + )} ); diff --git a/src/library-authoring/components/CollectionCard.test.tsx b/src/library-authoring/components/CollectionCard.test.tsx index d95c542ffa..6aa55cdfdf 100644 --- a/src/library-authoring/components/CollectionCard.test.tsx +++ b/src/library-authoring/components/CollectionCard.test.tsx @@ -1,22 +1,22 @@ -import React from 'react'; +import userEvent from '@testing-library/user-event'; +import type MockAdapter from 'axios-mock-adapter'; + import { - initializeMocks, - fireEvent, - render as baseRender, - screen, + initializeMocks, render as baseRender, screen, waitFor, waitForElementToBeRemoved, within, } from '../../testUtils'; - import { LibraryProvider } from '../common/context'; import { type CollectionHit } from '../../search-manager'; import CollectionCard from './CollectionCard'; +import messages from './messages'; +import { getLibraryCollectionApiUrl, getLibraryCollectionRestoreApiUrl } from '../data/api'; const CollectionHitSample: CollectionHit = { - id: '1', + id: 'lib-collectionorg1democourse-collection-display-name', type: 'collection', contextKey: 'lb:org1:Demo_Course', - usageKey: 'lb:org1:Demo_Course:collection1', - blockId: 'collection1', + usageKey: 'lib-collection:org1:Demo_Course:collection-display-name', org: 'org1', + blockId: 'collection-display-name', breadcrumbs: [{ displayName: 'Demo Lib' }], displayName: 'Collection Display Name', description: 'Collection description', @@ -30,13 +30,18 @@ const CollectionHitSample: CollectionHit = { tags: {}, }; +let axiosMock: MockAdapter; +let mockShowToast; + const render = (ui: React.ReactElement) => baseRender(ui, { extraWrapper: ({ children }) => { children }, }); describe('', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; }); it('should render the card with title and description', () => { @@ -52,12 +57,84 @@ describe('', () => { // Open menu expect(screen.getByTestId('collection-card-menu-toggle')).toBeInTheDocument(); - fireEvent.click(screen.getByTestId('collection-card-menu-toggle')); + userEvent.click(screen.getByTestId('collection-card-menu-toggle')); // Open menu item const openMenuItem = screen.getByRole('link', { name: 'Open' }); expect(openMenuItem).toBeInTheDocument(); - expect(openMenuItem).toHaveAttribute('href', '/library/lb:org1:Demo_Course/collection/collection1/'); + expect(openMenuItem).toHaveAttribute('href', '/library/lb:org1:Demo_Course/collection/collection-display-name/'); + }); + + it('should show confirmation box, delete collection and show toast to undo deletion', async () => { + const url = getLibraryCollectionApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId); + axiosMock.onDelete(url).reply(204); + render(); + + expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument(); + // Open menu + let menuBtn = await screen.findByRole('button', { name: messages.collectionCardMenuAlt.defaultMessage }); + userEvent.click(menuBtn); + // find and click delete menu option. + expect(screen.queryByText('Delete')).toBeInTheDocument(); + let deleteBtn = await screen.findByRole('button', { name: 'Delete' }); + userEvent.click(deleteBtn); + // verify confirmation dialog and click on cancel button + let dialog = await screen.findByRole('dialog', { name: 'Delete this collection?' }); + expect(dialog).toBeInTheDocument(); + const cancelBtn = await screen.findByRole('button', { name: 'Cancel' }); + userEvent.click(cancelBtn); + expect(axiosMock.history.delete.length).toEqual(0); + expect(cancelBtn).not.toBeInTheDocument(); + + // Open menu + menuBtn = await screen.findByRole('button', { name: messages.collectionCardMenuAlt.defaultMessage }); + userEvent.click(menuBtn); + // click on confirm button to delete + deleteBtn = await screen.findByRole('button', { name: 'Delete' }); + userEvent.click(deleteBtn); + dialog = await screen.findByRole('dialog', { name: 'Delete this collection?' }); + const confirmBtn = await within(dialog).findByRole('button', { name: 'Delete' }); + userEvent.click(confirmBtn); + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Delete this collection?' })); + + await waitFor(() => { + expect(axiosMock.history.delete.length).toEqual(1); + expect(mockShowToast).toHaveBeenCalled(); + }); + // Get restore / undo func from the toast + const restoreFn = mockShowToast.mock.calls[0][1].onClick; + + const restoreUrl = getLibraryCollectionRestoreApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId); + axiosMock.onPost(restoreUrl).reply(200); + // restore collection + restoreFn(); + await waitFor(() => { + expect(axiosMock.history.post.length).toEqual(1); + expect(mockShowToast).toHaveBeenCalledWith('Undo successful'); + }); + }); + + it('should show failed toast on delete collection failure', async () => { + const url = getLibraryCollectionApiUrl(CollectionHitSample.contextKey, CollectionHitSample.blockId); + axiosMock.onDelete(url).reply(404); + render(); + + expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument(); + // Open menu + const menuBtn = await screen.findByRole('button', { name: messages.collectionCardMenuAlt.defaultMessage }); + userEvent.click(menuBtn); + // find and click delete menu option. + const deleteBtn = await screen.findByRole('button', { name: 'Delete' }); + userEvent.click(deleteBtn); + const dialog = await screen.findByRole('dialog', { name: 'Delete this collection?' }); + const confirmBtn = await within(dialog).findByRole('button', { name: 'Delete' }); + userEvent.click(confirmBtn); + await waitForElementToBeRemoved(() => screen.queryByRole('dialog', { name: 'Delete this collection?' })); + + await waitFor(() => { + expect(axiosMock.history.delete.length).toEqual(1); + expect(mockShowToast).toHaveBeenCalledWith('Failed to delete collection'); + }); }); }); diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx index 1d81e29dc3..ad3a6c1628 100644 --- a/src/library-authoring/components/CollectionCard.tsx +++ b/src/library-authoring/components/CollectionCard.tsx @@ -1,9 +1,11 @@ -import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useCallback, useContext, useState } from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Dropdown, Icon, IconButton, + useToggle, } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; import { Link } from 'react-router-dom'; @@ -11,31 +13,93 @@ import { Link } from 'react-router-dom'; import { type CollectionHit } from '../../search-manager'; import { useLibraryContext } from '../common/context'; import BaseComponentCard from './BaseComponentCard'; +import { ToastContext } from '../../generic/toast-context'; +import { useDeleteCollection, useRestoreCollection } from '../data/apiHooks'; +import DeleteModal from '../../generic/delete-modal/DeleteModal'; import messages from './messages'; -export const CollectionMenu = ({ collectionHit }: { collectionHit: CollectionHit }) => { +type CollectionMenuProps = { + collectionHit: CollectionHit, +}; + +const CollectionMenu = ({ collectionHit } : CollectionMenuProps) => { const intl = useIntl(); + const { showToast } = useContext(ToastContext); + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [confirmBtnState, setConfirmBtnState] = useState('default'); + const { closeLibrarySidebar, currentCollectionId } = useLibraryContext(); + + const restoreCollectionMutation = useRestoreCollection(collectionHit.contextKey, collectionHit.blockId); + const restoreCollection = useCallback(() => { + restoreCollectionMutation.mutateAsync() + .then(() => { + showToast(intl.formatMessage(messages.undoDeleteCollectionToastMessage)); + }).catch(() => { + showToast(intl.formatMessage(messages.undoDeleteCollectionToastFailed)); + }); + }, []); + + const deleteCollectionMutation = useDeleteCollection(collectionHit.contextKey, collectionHit.blockId); + const deleteCollection = useCallback(() => { + setConfirmBtnState('pending'); + if (currentCollectionId === collectionHit.blockId) { + // Close sidebar if current collection is open to avoid displaying + // deleted collection in sidebar + closeLibrarySidebar(); + } + deleteCollectionMutation.mutateAsync() + .then(() => { + showToast( + intl.formatMessage(messages.deleteCollectionSuccess), + { + label: intl.formatMessage(messages.undoDeleteCollectionToastAction), + onClick: restoreCollection, + }, + ); + }).catch(() => { + showToast(intl.formatMessage(messages.deleteCollectionFailed)); + }).finally(() => { + setConfirmBtnState('default'); + closeDeleteModal(); + }); + }, [currentCollectionId]); return ( - e.stopPropagation()}> - + e.stopPropagation()}> + + + + + + + + + + + - - - - - - + ); }; @@ -43,7 +107,7 @@ type CollectionCardProps = { collectionHit: CollectionHit, }; -const CollectionCard = ({ collectionHit }: CollectionCardProps) => { +const CollectionCard = ({ collectionHit } : CollectionCardProps) => { const { openCollectionInfoSidebar, } = useLibraryContext(); diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index ef2b89fe3b..3bac3ad177 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -41,6 +41,41 @@ const messages = defineMessages({ defaultMessage: 'Failed to copy component to clipboard', description: 'Message for failed to copy component to clipboard.', }, + deleteCollection: { + id: 'course-authoring.library-authoring.collection.delete-menu-text', + defaultMessage: 'Delete', + description: 'Menu item to delete a collection.', + }, + deleteCollectionConfirm: { + id: 'course-authoring.library-authoring.collection.delete-confirmation-text', + defaultMessage: 'Are you sure you want to delete collection: {collectionTitle}?', + description: 'Confirmation text to display before deleting collection', + }, + deleteCollectionFailed: { + id: 'course-authoring.library-authoring.collection.delete-failed-error', + defaultMessage: 'Failed to delete collection', + description: 'Message to display on failure to delete collection', + }, + deleteCollectionSuccess: { + id: 'course-authoring.library-authoring.collection.delete-error-success', + defaultMessage: 'Collection deleted', + description: 'Message to display on delete collection success', + }, + undoDeleteCollectionToastAction: { + id: 'course-authoring.library-authoring.collection.undo-delete-collection-toast-button', + defaultMessage: 'Undo', + description: 'Toast message to undo deletion of collection', + }, + undoDeleteCollectionToastMessage: { + id: 'course-authoring.library-authoring.collection.undo-delete-collection-toast-text', + defaultMessage: 'Undo successful', + description: 'Message to display on undo delete collection success', + }, + undoDeleteCollectionToastFailed: { + id: 'course-authoring.library-authoring.collection.undo-delete-collection-failed', + defaultMessage: 'Failed to undo delete collection operation', + description: 'Message to display on failure to undo delete collection', + }, }); export default messages; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index d549e673ce..1c609722b9 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -50,13 +50,17 @@ export const getXBlockAssetsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/a */ export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/collections/`; /** - * Get the URL for the collection API. + * Get the URL for the collection detail API. */ export const getLibraryCollectionApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionsApiUrl(libraryId)}${collectionId}/`; /** * Get the URL for the collection API. */ export const getLibraryCollectionComponentApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}components/`; +/** + * Get the API URL for restoring deleted collection. + */ +export const getLibraryCollectionRestoreApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}restore/`; export interface ContentLibrary { id: string; @@ -357,3 +361,19 @@ export async function updateCollectionComponents(libraryId: string, collectionId usage_keys: usageKeys, }); } + +/** + * Soft-Delete collection. + */ +export async function deleteCollection(libraryId: string, collectionId: string) { + const client = getAuthenticatedHttpClient(); + await client.delete(getLibraryCollectionApiUrl(libraryId, collectionId)); +} + +/** + * Restore soft-deleted collection + */ +export async function restoreCollection(libraryId: string, collectionId: string) { + const client = getAuthenticatedHttpClient(); + await client.post(getLibraryCollectionRestoreApiUrl(libraryId, collectionId)); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 752e91659a..42a1f53a34 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -30,6 +30,8 @@ import { updateCollectionComponents, type CreateLibraryCollectionDataRequest, getCollectionMetadata, + deleteCollection, + restoreCollection, setXBlockOLX, getXBlockAssets, } from './api'; @@ -335,11 +337,38 @@ export const useUpdateCollectionComponents = (libraryId?: string, collectionId?: } return undefined; }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onSettled: (_data, _error, _variables) => { + onSettled: () => { if (libraryId !== undefined && collectionId !== undefined) { queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); } }, }); }; + +/** + * Use this mutation to soft delete collections in a library + */ +export const useDeleteCollection = (libraryId: string, collectionId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => deleteCollection(libraryId, collectionId), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + }, + }); +}; + +/** + * Use this mutation to restore soft deleted collections in a library + */ +export const useRestoreCollection = (libraryId: string, collectionId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => restoreCollection(libraryId, collectionId), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) }); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + }, + }); +}; diff --git a/src/testUtils.tsx b/src/testUtils.tsx index 95c4b798c1..6fc15089bd 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -34,6 +34,7 @@ let axiosMock: MockAdapter; let mockToastContext: ToastContextData = { showToast: jest.fn(), closeToast: jest.fn(), + toastAction: undefined, toastMessage: null, }; @@ -176,6 +177,7 @@ export function initializeMocks({ user = defaultUser, initialState = undefined } showToast: jest.fn(), closeToast: jest.fn(), toastMessage: null, + toastAction: undefined, }; // Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history. @@ -185,6 +187,7 @@ export function initializeMocks({ user = defaultUser, initialState = undefined } reduxStore, axiosMock, mockShowToast: mockToastContext.showToast, + mockToastAction: mockToastContext.toastAction, }; }