feat: delete collection [FC-0062] (#1333)

* feat: delete collection

* feat: update button status on delete

* test: add tests for collection delete
This commit is contained in:
Navin Karkera
2024-10-08 22:29:06 +05:30
committed by GitHub
parent 75f937e11a
commit 434fea3a95
14 changed files with 409 additions and 111 deletions

View File

@@ -226,7 +226,7 @@ describe('<CourseOutline />', () => {
});
it('check video sharing option shows error on failure', async () => {
const { findByLabelText, queryByRole } = render(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onPost(getCourseBlockApiUrl(courseId), {
@@ -235,7 +235,7 @@ describe('<CourseOutline />', () => {
},
})
.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('<CourseOutline />', () => {
},
}));
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('<CourseOutline />', () => {
notificationDismissUrl: '/some/url',
});
const { findByRole } = render(<RootWrapper />);
expect(await findByRole('alert')).toBeInTheDocument();
const dismissBtn = await findByRole('button', { name: 'Dismiss' });
render(<RootWrapper />);
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('<CourseOutline />', () => {
});
it('check whether unit copy & paste option works correctly', async () => {
const { findAllByTestId, queryByTestId, findAllByRole } = render(<RootWrapper />);
render(<RootWrapper />);
// 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('<CourseOutline />', () => {
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('<CourseOutline />', () => {
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

View File

@@ -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('<CourseUnit />', () => {
});
it('should display a warning alert for unpublished course unit version', async () => {
const { getByRole } = render(<RootWrapper />);
render(<RootWrapper />);
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(<RootWrapper />);
render(<RootWrapper />);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
@@ -547,8 +549,10 @@ describe('<CourseUnit />', () => {
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();
});
});

View File

@@ -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={(
<ActionRow>
<Button variant="tertiary" onClick={close}>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button
data-testid="delete-confirm-button"
variant="tertiary"
onClick={(e) => {
e.preventDefault();
onDeleteSubmit();
e.stopPropagation();
close();
}}
>
{intl.formatMessage(messages.deleteButton, { category })}
{intl.formatMessage(messages.cancelButton)}
</Button>
<StatefulButton
data-testid="delete-confirm-button"
state={btnState}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDeleteSubmit();
}}
labels={{
default: intl.formatMessage(messages.deleteButton),
pending: intl.formatMessage(messages.pendingDeleteButton),
}}
/>
</ActionRow>
)}
>
@@ -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;

View File

@@ -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',

View File

@@ -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;
.processing-notification-icon {
animation: rotate 1s linear infinite;
}
&.is-show {
bottom: .625rem;
}
.processing-notification-icon {
margin-right: .625rem;
animation: rotate 1s linear infinite;
}
.processing-notification-title {
font-size: 1rem;
line-height: 1.5rem;
color: $white;
margin-bottom: 0;
.processing-notification-hide-close-button {
.btn-icon {
display: none;
}
}
.toast-container {
right: 1.25rem;
left: unset;
z-index: $zindex-popover;
}

View File

@@ -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('<ProcessingNotification />', () => {
beforeEach(() => {
initializeMocks();
});
it('renders successfully', () => {
const { getByText } = render(<ProcessingNotification {...props} />);
expect(getByText(capitalize(props.title))).toBeInTheDocument();
render(<ProcessingNotification {...props} close={() => {}} />);
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(<ProcessingNotification {...props} />);
expect(screen.getByText(capitalize(props.title))).toBeInTheDocument();
expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).toBeInTheDocument();
});
});

View File

@@ -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 }) => (
<Badge
className={classNames('processing-notification', {
'is-show': isShow,
})}
variant="secondary"
const ProcessingNotification = ({
isShow, title, action, close,
}) => (
<Toast
className={classNames({ 'processing-notification-hide-close-button': !close })}
show={isShow}
aria-hidden={isShow}
action={action && { ...action }}
onClose={close || (() => {})}
>
<Icon className="processing-notification-icon" src={IconSettings} />
<h2 className="processing-notification-title">
{capitalize(title)}
</h2>
</Badge>
<span className="d-flex align-items-center">
<Icon className="processing-notification-icon mb-0 mr-2" src={IconSettings} />
<span className="font-weight-bold h4 mb-0 text-white">{capitalize(title)}</span>
</span>
</Toast>
);
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;

View File

@@ -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<ToastContextData>({
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<string | null>(null);
const [toastAction, setToastAction] = React.useState<ToastActionData | undefined>(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 (
<ToastContext.Provider value={context}>
{props.children}
{ toastMessage && (
<ProcessingNotification isShow={toastMessage !== null} title={toastMessage} />
<ProcessingNotification
isShow={toastMessage !== null}
title={toastMessage}
action={toastAction}
close={closeToast}
/>
)}
</ToastContext.Provider>
);

View File

@@ -1,22 +1,22 @@
import React from 'react';
import {
initializeMocks,
fireEvent,
render as baseRender,
screen,
} from '../../testUtils';
import userEvent from '@testing-library/user-event';
import type MockAdapter from 'axios-mock-adapter';
import {
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 }) => <LibraryProvider libraryId="lib:Axim:TEST">{ children }</LibraryProvider>,
});
describe('<CollectionCard />', () => {
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('<CollectionCard />', () => {
// 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(<CollectionCard collectionHit={CollectionHitSample} />);
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(<CollectionCard collectionHit={CollectionHitSample} />);
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');
});
});
});

View File

@@ -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 (
<Dropdown id="collection-card-dropdown" onClick={(e) => e.stopPropagation()}>
<Dropdown.Toggle
id="collection-card-menu-toggle"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
data-testid="collection-card-menu-toggle"
<>
<Dropdown id="collection-card-dropdown" onClick={(e) => e.stopPropagation()}>
<Dropdown.Toggle
id="collection-card-menu-toggle"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
data-testid="collection-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item
as={Link}
to={`/library/${collectionHit.contextKey}/collection/${collectionHit.blockId}/`}
>
<FormattedMessage {...messages.menuOpen} />
</Dropdown.Item>
<Dropdown.Item onClick={openDeleteModal}>
<FormattedMessage {...messages.deleteCollection} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<DeleteModal
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
variant="warning"
category={collectionHit.type}
description={intl.formatMessage(messages.deleteCollectionConfirm, {
collectionTitle: collectionHit.displayName,
})}
onDeleteSubmit={deleteCollection}
btnState={confirmBtnState}
/>
<Dropdown.Menu>
<Dropdown.Item
as={Link}
to={`/library/${collectionHit.contextKey}/collection/${collectionHit.blockId}/`}
>
<FormattedMessage {...messages.menuOpen} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
);
};
@@ -43,7 +107,7 @@ type CollectionCardProps = {
collectionHit: CollectionHit,
};
const CollectionCard = ({ collectionHit }: CollectionCardProps) => {
const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
const {
openCollectionInfoSidebar,
} = useLibraryContext();

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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) });
},
});
};

View File

@@ -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,
};
}