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={(
-
)}
>
@@ -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()}>
-
+ >
);
};
@@ -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,
};
}