feat: Delete unit [FC-0083] (#1773)

* Adds the delete menu item in unit cards.
* Delete a unit with a confirmation modal.
* Restore a component
This commit is contained in:
Chris Chávez
2025-04-10 18:59:10 -05:00
committed by GitHub
parent 04faf54ad8
commit 5df7adffec
11 changed files with 481 additions and 26 deletions

View File

@@ -51,6 +51,7 @@ const DeleteModal = ({
e.stopPropagation();
await onDeleteSubmit();
}}
variant="brand"
label={defaultBtnLabel}
/>
</ActionRow>

View File

@@ -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('<ContainerCard />', () => {
beforeEach(() => {
initializeMocks();
({ axiosMock, mockShowToast } = initializeMocks());
});
it('should render the card with title', () => {
@@ -85,6 +90,68 @@ describe('<ContainerCard />', () => {
// );
});
it('should delete the container from the menu & restore the container', async () => {
axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(200);
render(<ContainerCard hit={containerHitSample} />);
// 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(<ContainerCard hit={containerHitSample} />);
// 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(<ContainerCard hit={containerHitSample} />);

View File

@@ -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 (
<Dropdown id="container-card-dropdown">
<Dropdown.Toggle
id="container-card-menu-toggle"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
data-testid="container-card-menu-toggle"
<>
<Dropdown id="container-card-dropdown">
<Dropdown.Toggle
id="container-card-menu-toggle"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
data-testid="container-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item
as={Link}
to={`/library/${contextKey}/container/${blockId}`}
disabled
>
<FormattedMessage {...messages.menuOpen} />
</Dropdown.Item>
<Dropdown.Item onClick={confirmDelete}>
<FormattedMessage {...messages.menuDeleteContainer} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<ContainerDeleter
isOpen={isConfirmingDelete}
close={cancelDelete}
containerId={containerId}
displayName={displayName}
/>
<Dropdown.Menu>
<Dropdown.Item
as={Link}
to={`/library/${contextKey}/container/${blockId}`}
disabled
>
<FormattedMessage {...messages.menuOpen} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
);
};

View File

@@ -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: <b>{displayName}</b>,
message: (
<>
<div className="d-flex mt-2">
<Icon className="mr-2" src={School} />
{intl.formatMessage(messages.deleteUnitConfirmMsg1)}
</div>
<div className="d-flex mt-2">
<Icon className="mr-2" src={Widgets} />
{intl.formatMessage(messages.deleteUnitConfirmMsg2)}
</div>
</>
),
}) 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 (
<DeleteModal
isOpen={isOpen}
close={close}
variant="warning"
title={deleteWarningTitle}
icon={Warning}
description={deleteText}
onDeleteSubmit={onDelete}
/>
);
};
export default ContainerDeleter;

View File

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

View File

@@ -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(<UnitInfo />, {
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
>
<SidebarProvider
initialSidebarComponentInfo={{
id: containerId,
type: SidebarBodyComponentId.UnitInfo,
}}
>
{children}
</SidebarProvider>
</LibraryProvider>
),
});
let axiosMock: MockAdapter;
let mockShowToast;
describe('<UnitInfo />', () => {
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();
});
});

View File

@@ -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 (
<>
<Dropdown id="unit-info-dropdown">
<Dropdown.Toggle
id="unit-info-menu-toggle"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(componentMessages.containerCardMenuAlt)}
data-testid="unit-info-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item onClick={confirmDelete}>
<FormattedMessage {...componentMessages.menuDeleteContainer} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<ContainerDeleter
isOpen={isConfirmingDelete}
close={cancelDelete}
containerId={containerId}
displayName={displayName}
/>
</>
);
};
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 (
<Stack>
{showOpenCollectionButton && (
{showOpenUnitButton && (
<div className="d-flex flex-wrap">
<Button
variant="outline-primary"
@@ -44,6 +97,10 @@ const UnitInfo = () => {
>
{intl.formatMessage(messages.openUnitButton)}
</Button>
<UnitMenu
containerId={unitId}
displayName={container.displayName}
/>
</div>
)}
<Tabs

View File

@@ -93,4 +93,26 @@ describe('library data API', () => {
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);
});
});

View File

@@ -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.
*/

View File

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

View File

@@ -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
*/