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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user