diff --git a/src/generic/delete-modal/DeleteModal.jsx b/src/generic/delete-modal/DeleteModal.jsx
index 3384c0f3b..4159d9d3f 100644
--- a/src/generic/delete-modal/DeleteModal.jsx
+++ b/src/generic/delete-modal/DeleteModal.jsx
@@ -51,6 +51,7 @@ const DeleteModal = ({
e.stopPropagation();
await onDeleteSubmit();
}}
+ variant="brand"
label={defaultBtnLabel}
/>
diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx
index dd5da2f20..a6359fafd 100644
--- a/src/library-authoring/components/ContainerCard.test.tsx
+++ b/src/library-authoring/components/ContainerCard.test.tsx
@@ -1,12 +1,15 @@
import userEvent from '@testing-library/user-event';
+import type MockAdapter from 'axios-mock-adapter';
import {
initializeMocks, render as baseRender, screen, waitFor,
+ fireEvent,
} from '../../testUtils';
import { LibraryProvider } from '../common/context/LibraryContext';
import { mockContentLibrary, mockGetContainerChildren } from '../data/api.mocks';
import { type ContainerHit, PublishStatus } from '../../search-manager';
import ContainerCard from './ContainerCard';
+import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api';
const containerHitSample: ContainerHit = {
id: 'lctorg1democourse-unit-display-name-123',
@@ -33,6 +36,8 @@ const containerHitSample: ContainerHit = {
tags: {},
publishStatus: PublishStatus.Published,
};
+let axiosMock: MockAdapter;
+let mockShowToast;
mockContentLibrary.applyMock();
mockGetContainerChildren.applyMock();
@@ -50,7 +55,7 @@ const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => b
describe('', () => {
beforeEach(() => {
- initializeMocks();
+ ({ axiosMock, mockShowToast } = initializeMocks());
});
it('should render the card with title', () => {
@@ -85,6 +90,68 @@ describe('', () => {
// );
});
+ it('should delete the container from the menu & restore the container', async () => {
+ axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(200);
+
+ render();
+
+ // Open menu
+ expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
+ userEvent.click(screen.getByTestId('container-card-menu-toggle'));
+
+ // Click on Delete Item
+ const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
+ expect(deleteMenuItem).toBeInTheDocument();
+ fireEvent.click(deleteMenuItem);
+
+ // Confirm delete Modal is open
+ expect(screen.getByText('Delete Unit'));
+ const deleteButton = screen.getByRole('button', { name: /delete/i });
+ fireEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(axiosMock.history.delete.length).toBe(1);
+ });
+ expect(mockShowToast).toHaveBeenCalled();
+
+ // Get restore / undo func from the toast
+ const restoreFn = mockShowToast.mock.calls[0][1].onClick;
+
+ const restoreUrl = getLibraryContainerRestoreApiUrl(containerHitSample.usageKey);
+ 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 error on delete the container from the menu', async () => {
+ axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(400);
+
+ render();
+
+ // Open menu
+ expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
+ userEvent.click(screen.getByTestId('container-card-menu-toggle'));
+
+ // Click on Delete Item
+ const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
+ expect(deleteMenuItem).toBeInTheDocument();
+ fireEvent.click(deleteMenuItem);
+
+ // Confirm delete Modal is open
+ expect(screen.getByText('Delete Unit'));
+ const deleteButton = screen.getByRole('button', { name: /delete/i });
+ fireEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(axiosMock.history.delete.length).toBe(1);
+ });
+ expect(mockShowToast).toHaveBeenCalledWith('Failed to delete unit');
+ });
+
it('should render no child blocks in card preview', async () => {
render();
diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx
index d508c2082..e3efa6553 100644
--- a/src/library-authoring/components/ContainerCard.tsx
+++ b/src/library-authoring/components/ContainerCard.tsx
@@ -1,10 +1,11 @@
-import { ReactNode, useCallback } from 'react';
+import { useCallback, ReactNode } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Dropdown,
Icon,
IconButton,
+ useToggle,
Stack,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
@@ -16,9 +17,10 @@ import { useComponentPickerContext } from '../common/context/ComponentPickerCont
import { useLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
import BaseCard from './BaseCard';
-import { useContainerChildren } from '../data/apiHooks';
import { useLibraryRoutes } from '../routes';
import messages from './messages';
+import { useContainerChildren } from '../data/apiHooks';
+import ContainerDeleter from './ContainerDeleter';
type ContainerMenuProps = {
hit: ContainerHit,
@@ -26,29 +28,47 @@ type ContainerMenuProps = {
const ContainerMenu = ({ hit } : ContainerMenuProps) => {
const intl = useIntl();
- const { contextKey, blockId } = hit;
+ const {
+ contextKey,
+ blockId,
+ usageKey: containerId,
+ displayName,
+ } = hit;
+
+ const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
return (
-
-
+ >
);
};
diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx
new file mode 100644
index 000000000..9b7d5db05
--- /dev/null
+++ b/src/library-authoring/components/ContainerDeleter.tsx
@@ -0,0 +1,96 @@
+import { ReactNode, useCallback, useContext } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Icon } from '@openedx/paragon';
+import { Warning, School, Widgets } from '@openedx/paragon/icons';
+
+import DeleteModal from '../../generic/delete-modal/DeleteModal';
+import { useSidebarContext } from '../common/context/SidebarContext';
+import { ToastContext } from '../../generic/toast-context';
+import { useDeleteContainer, useRestoreContainer } from '../data/apiHooks';
+import messages from './messages';
+
+type ContainerDeleterProps = {
+ isOpen: boolean,
+ close: () => void,
+ containerId: string,
+ displayName: string,
+};
+
+const ContainerDeleter = ({
+ isOpen,
+ close,
+ containerId,
+ displayName,
+}: ContainerDeleterProps) => {
+ const intl = useIntl();
+ const {
+ sidebarComponentInfo,
+ closeLibrarySidebar,
+ } = useSidebarContext();
+ const deleteContainerMutation = useDeleteContainer(containerId);
+ const restoreContainerMutation = useRestoreContainer(containerId);
+ const { showToast } = useContext(ToastContext);
+
+ // TODO: support other container types besides 'unit'
+ const deleteWarningTitle = intl.formatMessage(messages.deleteUnitWarningTitle);
+ const deleteText = intl.formatMessage(messages.deleteUnitConfirm, {
+ unitName: {displayName},
+ message: (
+ <>
+
+
+ {intl.formatMessage(messages.deleteUnitConfirmMsg1)}
+
+
+
+ {intl.formatMessage(messages.deleteUnitConfirmMsg2)}
+
+ >
+ ),
+ }) as ReactNode as string;
+ const deleteSuccess = intl.formatMessage(messages.deleteUnitSuccess);
+ const deleteError = intl.formatMessage(messages.deleteUnitFailed);
+ const undoDeleteError = messages.undoDeleteUnitToastFailed;
+
+ const restoreComponent = useCallback(async () => {
+ try {
+ await restoreContainerMutation.mutateAsync();
+ showToast(intl.formatMessage(messages.undoDeleteContainerToastMessage));
+ } catch (e) {
+ showToast(intl.formatMessage(undoDeleteError));
+ }
+ }, []);
+
+ const onDelete = useCallback(async () => {
+ await deleteContainerMutation.mutateAsync().then(() => {
+ if (sidebarComponentInfo?.id === containerId) {
+ closeLibrarySidebar();
+ }
+ showToast(
+ deleteSuccess,
+ {
+ label: intl.formatMessage(messages.undoDeleteContainerToastAction),
+ onClick: restoreComponent,
+ },
+ );
+ }).catch(() => {
+ showToast(deleteError);
+ }).finally(() => {
+ close();
+ });
+ }, [sidebarComponentInfo, showToast, deleteContainerMutation]);
+
+ return (
+
+ );
+};
+
+export default ContainerDeleter;
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts
index 831855abc..ac4798136 100644
--- a/src/library-authoring/components/messages.ts
+++ b/src/library-authoring/components/messages.ts
@@ -176,6 +176,56 @@ const messages = defineMessages({
defaultMessage: 'This component can be synced in courses after publish.',
description: 'Alert text of the modal to confirm publish a component in a library.',
},
+ menuDeleteContainer: {
+ id: 'course-authoring.library-authoring.container.delete-menu-text',
+ defaultMessage: 'Delete',
+ description: 'Menu item to delete a container.',
+ },
+ deleteUnitWarningTitle: {
+ id: 'course-authoring.library-authoring.unit.delete-confirmation-title',
+ defaultMessage: 'Delete Unit',
+ description: 'Title text for the warning displayed before deleting a Unit',
+ },
+ deleteUnitConfirm: {
+ id: 'course-authoring.library-authoring.unit.delete-confirmation-text',
+ defaultMessage: 'Delete {unitName}? {message}',
+ description: 'Confirmation text to display before deleting a unit',
+ },
+ deleteUnitConfirmMsg1: {
+ id: 'course-authoring.library-authoring.unit.delete-confirmation-msg-1',
+ defaultMessage: 'Any course instances will stop receiving updates.',
+ description: 'First part of confirmation message to display before deleting a unit',
+ },
+ deleteUnitConfirmMsg2: {
+ id: 'course-authoring.library-authoring.unit.delete-confirmation-msg-2',
+ defaultMessage: 'Any components will remain in the library.',
+ description: 'Second part of confirmation message to display before deleting a unit',
+ },
+ deleteUnitSuccess: {
+ id: 'course-authoring.library-authoring.unit.delete.success',
+ defaultMessage: 'Unit deleted',
+ description: 'Message to display on delete unit success',
+ },
+ deleteUnitFailed: {
+ id: 'course-authoring.library-authoring.unit.delete-failed-error',
+ defaultMessage: 'Failed to delete unit',
+ description: 'Message to display on failure to delete a unit',
+ },
+ undoDeleteContainerToastAction: {
+ id: 'course-authoring.library-authoring.container.undo-delete-container-toast-button',
+ defaultMessage: 'Undo',
+ description: 'Toast message to undo deletion of container',
+ },
+ undoDeleteContainerToastMessage: {
+ id: 'course-authoring.library-authoring.container.undo-delete-container-toast-text',
+ defaultMessage: 'Undo successful',
+ description: 'Message to display on undo delete container success',
+ },
+ undoDeleteUnitToastFailed: {
+ id: 'course-authoring.library-authoring.unit.undo-delete-unit-failed',
+ defaultMessage: 'Failed to undo delete Unit operation',
+ description: 'Message to display on failure to undo delete unit',
+ },
containerPreviewMoreBlocks: {
id: 'course-authoring.library-authoring.component.container-card-preview.more-blocks',
defaultMessage: '+{count}',
diff --git a/src/library-authoring/containers/UnitInfo.test.tsx b/src/library-authoring/containers/UnitInfo.test.tsx
new file mode 100644
index 000000000..b93437664
--- /dev/null
+++ b/src/library-authoring/containers/UnitInfo.test.tsx
@@ -0,0 +1,64 @@
+import userEvent from '@testing-library/user-event';
+import type MockAdapter from 'axios-mock-adapter';
+
+import {
+ initializeMocks, render as baseRender, screen, waitFor,
+ fireEvent,
+} from '../../testUtils';
+import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks';
+import { LibraryProvider } from '../common/context/LibraryContext';
+import UnitInfo from './UnitInfo';
+import { getLibraryContainerApiUrl } from '../data/api';
+import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
+
+mockGetContainerMetadata.applyMock();
+const { libraryId } = mockContentLibrary;
+const { containerId } = mockGetContainerMetadata;
+const render = () => baseRender(, {
+ extraWrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+});
+let axiosMock: MockAdapter;
+let mockShowToast;
+
+describe('', () => {
+ beforeEach(() => {
+ ({ axiosMock, mockShowToast } = initializeMocks());
+ });
+
+ it('should detele the unit using the menu', async () => {
+ axiosMock.onDelete(getLibraryContainerApiUrl(containerId)).reply(200);
+ render();
+
+ // Open menu
+ expect(await screen.findByTestId('unit-info-menu-toggle')).toBeInTheDocument();
+ userEvent.click(screen.getByTestId('unit-info-menu-toggle'));
+
+ // Click on Delete Item
+ const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
+ expect(deleteMenuItem).toBeInTheDocument();
+ fireEvent.click(deleteMenuItem);
+
+ // Confirm delete Modal is open
+ expect(screen.getByText('Delete Unit'));
+ const deleteButton = screen.getByRole('button', { name: /delete/i });
+ fireEvent.click(deleteButton);
+
+ await waitFor(() => {
+ expect(axiosMock.history.delete.length).toBe(1);
+ });
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+});
diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx
index 90ad23008..41ab09bcc 100644
--- a/src/library-authoring/containers/UnitInfo.tsx
+++ b/src/library-authoring/containers/UnitInfo.tsx
@@ -1,10 +1,16 @@
-import { useIntl } from '@edx/frontend-platform/i18n';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Stack,
Tab,
Tabs,
+ Dropdown,
+ Icon,
+ IconButton,
+ useToggle,
} from '@openedx/paragon';
+import { MoreVert } from '@openedx/paragon/icons';
+
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import {
type UnitInfoTab,
@@ -14,6 +20,47 @@ import {
} from '../common/context/SidebarContext';
import ContainerOrganize from './ContainerOrganize';
import messages from './messages';
+import componentMessages from '../components/messages';
+import ContainerDeleter from '../components/ContainerDeleter';
+import { useContainer } from '../data/apiHooks';
+
+type ContainerMenuProps = {
+ containerId: string,
+ displayName: string,
+};
+
+const UnitMenu = ({ containerId, displayName }: ContainerMenuProps) => {
+ const intl = useIntl();
+
+ const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
const UnitInfo = () => {
const intl = useIntl();
@@ -31,11 +78,17 @@ const UnitInfo = () => {
throw new Error('unitId is required');
}
- const showOpenCollectionButton = !componentPickerMode;
+ const showOpenUnitButton = !componentPickerMode;
+
+ const { data: container } = useContainer(unitId);
+
+ if (!container) {
+ return null;
+ }
return (
- {showOpenCollectionButton && (
+ {showOpenUnitButton && (
+
)}
{
expect(axiosMock.history.post[0].url).toEqual(url);
});
+
+ it('should delete a container', async () => {
+ const { axiosMock } = initializeMocks();
+ const containerId = 'lct:org:lib1';
+ const url = api.getLibraryContainerApiUrl(containerId);
+
+ axiosMock.onDelete(url).reply(200);
+
+ await api.deleteContainer(containerId);
+ expect(axiosMock.history.delete[0].url).toEqual(url);
+ });
+
+ it('should restore a container', async () => {
+ const { axiosMock } = initializeMocks();
+ const containerId = 'lct:org:lib1';
+ const url = api.getLibraryContainerRestoreApiUrl(containerId);
+
+ axiosMock.onPost(url).reply(200);
+
+ await api.restoreContainer(containerId);
+ expect(axiosMock.history.post[0].url).toEqual(url);
+ });
});
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index 7348eedc9..c1491c529 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -111,6 +111,10 @@ export const getLibraryContainersApiUrl = (libraryId: string) => `${getApiBaseUr
* Get the URL for the container detail api.
*/
export const getLibraryContainerApiUrl = (containerId: string) => `${getApiBaseUrl()}/api/libraries/v2/containers/${containerId}/`;
+/**
+ * Get the URL for restore a container
+ */
+export const getLibraryContainerRestoreApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}restore/`;
/**
* Get the URL for a single container children api.
*/
@@ -621,6 +625,22 @@ export async function updateContainerMetadata(
await client.patch(getLibraryContainerApiUrl(containerId), snakeCaseObject(containerData));
}
+/**
+ * Delete a container
+ */
+export async function deleteContainer(containerId: string) {
+ const client = getAuthenticatedHttpClient();
+ await client.delete(getLibraryContainerApiUrl(containerId));
+}
+
+/**
+ * Restore a container
+ */
+export async function restoreContainer(containerId: string) {
+ const client = getAuthenticatedHttpClient();
+ await client.post(getLibraryContainerRestoreApiUrl(containerId));
+}
+
/**
* Fetch a library container's children's metadata.
*/
diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx
index ddc263f37..8208b9c38 100644
--- a/src/library-authoring/data/apiHooks.test.tsx
+++ b/src/library-authoring/data/apiHooks.test.tsx
@@ -13,6 +13,7 @@ import {
getLibraryCollectionApiUrl,
getBlockTypesMetaDataUrl,
getLibraryContainerApiUrl,
+ getLibraryContainerRestoreApiUrl,
getLibraryContainerChildrenApiUrl,
} from './api';
import {
@@ -24,6 +25,8 @@ import {
useCollection,
useBlockTypesMetadata,
useContainer,
+ useDeleteContainer,
+ useRestoreContainer,
useContainerChildren,
} from './apiHooks';
@@ -155,6 +158,30 @@ describe('library api hooks', () => {
expect(axiosMock.history.get[0].url).toEqual(url);
});
+ it('should delete a container', async () => {
+ const containerId = 'lct:org:lib1';
+ const url = getLibraryContainerApiUrl(containerId);
+
+ axiosMock.onDelete(url).reply(200);
+ const { result } = renderHook(() => useDeleteContainer(containerId), { wrapper });
+ await result.current.mutateAsync();
+ await waitFor(() => {
+ expect(axiosMock.history.delete[0].url).toEqual(url);
+ });
+ });
+
+ it('should restore a container', async () => {
+ const containerId = 'lct:org:lib1';
+ const url = getLibraryContainerRestoreApiUrl(containerId);
+
+ axiosMock.onPost(url).reply(200);
+ const { result } = renderHook(() => useRestoreContainer(containerId), { wrapper });
+ await result.current.mutateAsync();
+ await waitFor(() => {
+ expect(axiosMock.history.post[0].url).toEqual(url);
+ });
+ });
+
it('should get container children', async () => {
const containerId = 'lct:lib:org:unit:unit1';
const url = getLibraryContainerChildrenApiUrl(containerId);
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 839492748..71b1726af 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -50,7 +50,9 @@ import {
type CreateLibraryContainerDataRequest,
getContainerMetadata,
updateContainerMetadata,
+ deleteContainer,
type UpdateContainerDataRequest,
+ restoreContainer,
getContainerChildren,
} from './api';
import { VersionSpec } from '../LibraryBlock';
@@ -621,6 +623,35 @@ export const useUpdateContainer = (containerId: string) => {
});
};
+/**
+ * Use this mutation to soft delete containers in a library
+ */
+export const useDeleteContainer = (containerId: string) => {
+ const libraryId = getLibraryId(containerId);
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async () => deleteContainer(containerId),
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
+ queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
+ },
+ });
+};
+
+/**
+ * Use this mutation to restore a container
+ */
+export const useRestoreContainer = (containerId: string) => {
+ const libraryId = getLibraryId(containerId);
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async () => restoreContainer(containerId),
+ onSettled: () => {
+ queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
+ },
+ });
+};
+
/**
* Get the metadata and children for a container in a library
*/