feat: remove unit/subsection from subsection/section (#2149)

Let the user remove a unit/subsection from a subsection/section.
This commit is contained in:
Daniel Valenzuela
2025-06-27 09:08:40 -04:00
committed by GitHub
parent aeefcc639f
commit e2da13d129
7 changed files with 272 additions and 31 deletions

View File

@@ -8,6 +8,7 @@ import {
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { getBlockType } from '@src/generic/key-utils';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useClipboard } from '../../generic/clipboard';
@@ -112,6 +113,8 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
navigateTo,
]);
const containerType = containerId ? getBlockType(containerId) : 'collection';
return (
<Dropdown id="component-card-dropdown">
<Dropdown.Toggle
@@ -140,7 +143,12 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
</Dropdown.Item>
{insideCollection && (
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...containerMessages.menuRemoveFromCollection} />
<FormattedMessage
{...containerMessages.menuRemoveFromContainer}
values={{
containerType,
}}
/>
</Dropdown.Item>
)}
<Dropdown.Item onClick={showManageCollections}>

View File

@@ -31,6 +31,31 @@ const messages = defineMessages({
defaultMessage: 'Delete',
description: 'Menu item for deleting a component.',
},
menuAddToCollection: {
id: 'course-authoring.library-authoring.component.menu.add',
defaultMessage: 'Add to collection',
description: 'Menu item for add a component to collection.',
},
menuRemoveFromCollection: {
id: 'course-authoring.library-authoring.component.menu.remove-from-collection',
defaultMessage: 'Remove from collection',
description: 'Menu item for remove a component from collection.',
},
menuRemoveFromContainer: {
id: 'course-authoring.library-authoring.component.menu.remove',
defaultMessage: 'Remove from {containerType}',
description: 'Menu item for remove an item from {containerType}.',
},
removeComponentFromCollectionSuccess: {
id: 'course-authoring.library-authoring.component.remove-from-collection-success',
defaultMessage: 'Item successfully removed',
description: 'Message for successful removal of an item from collection.',
},
removeComponentFromCollectionFailure: {
id: 'course-authoring.library-authoring.component.remove-from-collection-failure',
defaultMessage: 'Failed to remove item',
description: 'Message for failure of removal of an item from collection.',
},
deleteComponentWarningTitle: {
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
defaultMessage: 'Delete Component',
@@ -196,5 +221,25 @@ const messages = defineMessages({
defaultMessage: 'Failed to undo remove component operation',
description: 'Message to display on failure to undo delete component',
},
containerPreviewText: {
id: 'course-authoring.library-authoring.container.preview.text',
defaultMessage: 'Contains {children}.',
description: 'Preview message for section/subsections with the names of children separated by commas',
},
removeContainerWarningTitle: {
id: 'course-authoring.library-authoring.container.remove-confirmation-title',
defaultMessage: 'Remove {containerType}',
description: 'Title text for the warning displayed before removing a container from its parent',
},
removeContainerConfirm: {
id: 'course-authoring.library-authoring.container.remove-confirmation-text',
defaultMessage: 'Remove {containerName} from {parentContainerType} {parentContainerName}? Removing this {containerType} will not delete it from the library.',
description: 'Confirmation text to display before removing a container from its parent',
},
removeContainerButton: {
id: 'course-authoring.library-authoring.container.confirm-remove-button',
defaultMessage: 'Remove {containerName}',
description: 'Button to confirm removal of a container from its parent',
},
});
export default messages;

View File

@@ -6,10 +6,10 @@ import {
fireEvent,
} from '../../testUtils';
import { LibraryProvider } from '../common/context/LibraryContext';
import { mockContentLibrary } from '../data/api.mocks';
import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks';
import { type ContainerHit, PublishStatus } from '../../search-manager';
import ContainerCard from './ContainerCard';
import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api';
import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl, getLibraryContainerChildrenApiUrl } from '../data/api';
import { ContainerType } from '../../generic/key-utils';
let axiosMock: MockAdapter;
@@ -50,18 +50,31 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, {
path: '/library/:libraryId',
params: { libraryId },
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
showOnlyPublished={showOnlyPublished}
>
{children}
</LibraryProvider>
),
});
const render = (
ui: React.ReactElement,
showOnlyPublished: boolean = false,
containerContext?: { type: ContainerType, id: string },
) => {
const path = containerContext
? `/library/:libraryId/${containerContext.type}/:containerId`
: '/library/:libraryId';
const params: Record<string, string> = containerContext
? { libraryId, containerId: containerContext.id }
: { libraryId };
return baseRender(ui, {
path,
params,
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
showOnlyPublished={showOnlyPublished}
>
{children}
</LibraryProvider>
),
});
};
describe('<ContainerCard />', () => {
beforeEach(() => {
@@ -387,4 +400,55 @@ describe('<ContainerCard />', () => {
expect(screen.getByText(displayName)).toBeInTheDocument();
expect(screen.queryByText(/contains/i)).not.toBeInTheDocument();
});
test.each([
{
label: 'should be able to remove unit from subsection menu item',
containerType: ContainerType.Unit,
parentType: ContainerType.Subsection,
parentId: mockGetContainerMetadata.subsectionId,
expectedRemoveText: 'Remove from subsection',
},
{
label: 'should be able to remove subsection from section menu item',
containerType: ContainerType.Subsection,
parentType: ContainerType.Section,
parentId: mockGetContainerMetadata.sectionId,
expectedRemoveText: 'Remove from section',
},
])('$label', async ({
containerType, parentType, parentId, expectedRemoveText,
}) => {
const containerHit = getContainerHitSample(containerType);
axiosMock.onDelete(getLibraryContainerChildrenApiUrl(parentId)).reply(200);
axiosMock.onGet(getLibraryContainerApiUrl(parentId)).reply(200, {
containerType: parentType,
displayName: 'Parent Container Display Name',
});
render(
<ContainerCard hit={containerHit} />,
false,
{ type: parentType, id: parentId },
);
// Open menu
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
// Click on Remove Item
const removeMenuItem = await screen.findByRole('button', { name: expectedRemoveText });
expect(removeMenuItem).toBeInTheDocument();
fireEvent.click(removeMenuItem);
// Confirm remove Modal is open
expect(await screen.findByText(/will not delete it from the library/i)).toBeInTheDocument();
const removeButton = screen.getByRole('button', { name: /remove/i });
fireEvent.click(removeButton);
await waitFor(() => {
expect(axiosMock.history.delete.length).toBe(1);
});
expect(mockShowToast).toHaveBeenCalled();
});
});

View File

@@ -10,9 +10,9 @@ import {
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
import { getBlockType } from '../../generic/key-utils';
import { ToastContext } from '../../generic/toast-context';
import { getItemIcon, getComponentStyleColor } from '@src/generic/block-type-utils';
import { getBlockType } from '@src/generic/key-utils';
import { ToastContext } from '@src/generic/toast-context';
import { type ContainerHit, Highlight, PublishStatus } from '../../search-manager';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
@@ -21,32 +21,41 @@ import { useRemoveItemsFromCollection } from '../data/apiHooks';
import { useLibraryRoutes } from '../routes';
import messages from './messages';
import ContainerDeleter from './ContainerDeleter';
import ContainerRemover from './ContainerRemover';
import { useRunOnNextRender } from '../../utils';
import BaseCard from '../components/BaseCard';
import AddComponentWidget from '../components/AddComponentWidget';
type ContainerMenuProps = {
containerKey: string;
displayName: string;
};
export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
const intl = useIntl();
const { libraryId, collectionId } = useLibraryContext();
const { libraryId, collectionId, containerId } = useLibraryContext();
const {
sidebarItemInfo,
closeLibrarySidebar,
setSidebarAction,
} = useSidebarContext();
const { showToast } = useContext(ToastContext);
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
const { navigateTo, insideCollection } = useLibraryRoutes();
const [isConfirmingRemove, confirmRemove, cancelRemove] = useToggle(false);
const {
navigateTo,
insideCollection,
insideSection,
insideSubsection,
} = useLibraryRoutes();
const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
const removeFromCollection = () => {
const handleRemoveFromCollection = () => {
removeComponentsMutation.mutateAsync([containerKey]).then(() => {
if (sidebarItemInfo?.id === containerKey) {
// Close sidebar if current component is open
// Close sidebar if current component is open
closeLibrarySidebar();
}
showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess));
@@ -55,6 +64,14 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
});
};
const handleRemove = () => {
if (insideCollection) {
handleRemoveFromCollection();
} else if (insideSection || insideSubsection) {
confirmRemove();
}
};
const scheduleJumpToCollection = useRunOnNextRender(() => {
// TODO: Ugly hack to make sure sidebar shows add to collection section
// This needs to run after all changes to url takes place to avoid conflicts.
@@ -70,6 +87,8 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
navigateTo({ containerId: containerKey });
}, [navigateTo, containerKey]);
const containerType = containerId ? getBlockType(containerId) : 'collection';
return (
<>
<Dropdown id="container-card-dropdown">
@@ -89,9 +108,14 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
<Dropdown.Item onClick={confirmDelete}>
<FormattedMessage {...messages.menuDeleteContainer} />
</Dropdown.Item>
{insideCollection && (
<Dropdown.Item onClick={removeFromCollection}>
<FormattedMessage {...messages.menuRemoveFromCollection} />
{(insideCollection || insideSection || insideSubsection) && (
<Dropdown.Item onClick={handleRemove}>
<FormattedMessage
{...messages.menuRemoveFromContainer}
values={{
containerType,
}}
/>
</Dropdown.Item>
)}
<Dropdown.Item onClick={showManageCollections}>
@@ -106,6 +130,14 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
containerId={containerKey}
/>
)}
{isConfirmingRemove && (
<ContainerRemover
isOpen={isConfirmingRemove}
close={cancelRemove}
containerKey={containerKey}
displayName={displayName}
/>
)}
</>
);
};
@@ -262,7 +294,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
{componentPickerMode ? (
<AddComponentWidget usageKey={containerKey} blockType={itemType} />
) : (
<ContainerMenu containerKey={containerKey} />
<ContainerMenu containerKey={containerKey} displayName={displayName} />
)}
</ActionRow>
)}

View File

@@ -0,0 +1,89 @@
import { useCallback, useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { capitalize } from 'lodash';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import { getBlockType } from '@src/generic/key-utils';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useContainer, useRemoveContainerChildren } from '../data/apiHooks';
import messages from '../components/messages';
type ContainerRemoverProps = {
isOpen: boolean,
close: () => void,
containerKey: string,
displayName: string,
};
const ContainerRemover = ({
isOpen,
close,
containerKey,
displayName,
}: ContainerRemoverProps) => {
const intl = useIntl();
const {
sidebarItemInfo,
closeLibrarySidebar,
} = useSidebarContext();
const { containerId } = useLibraryContext();
const { showToast } = useContext(ToastContext);
const removeContainerMutation = useRemoveContainerChildren(containerId);
const { data: container } = useContainer(containerId);
const itemType = getBlockType(containerKey);
const removeWarningTitle = intl.formatMessage(messages.removeContainerWarningTitle, {
containerType: capitalize(itemType),
});
const removeText = intl.formatMessage(messages.removeContainerConfirm, {
containerName: <b>{capitalize(itemType)} {displayName}</b>,
containerType: capitalize(itemType),
parentContainerType: capitalize(container?.containerType),
parentContainerName: container?.displayName,
});
const removeSuccess = intl.formatMessage(messages.removeComponentFromContainerSuccess);
const removeError = intl.formatMessage(messages.removeComponentFromContainerFailure);
const onRemove = useCallback(async () => {
try {
await removeContainerMutation.mutateAsync([containerKey]);
if (sidebarItemInfo?.id === containerKey) {
closeLibrarySidebar();
}
showToast(removeSuccess);
} catch (e) {
showToast(removeError);
} finally {
close();
}
}, [
containerKey,
removeContainerMutation,
sidebarItemInfo,
closeLibrarySidebar,
showToast,
removeSuccess,
removeError,
close,
]);
return (
<DeleteModal
isOpen={isOpen}
close={close}
title={removeWarningTitle}
description={removeText}
onDeleteSubmit={onRemove}
btnLabel={intl.formatMessage(messages.removeContainerButton, {
containerName: itemType.charAt(0).toUpperCase() + itemType.slice(1),
})}
/>
);
};
export default ContainerRemover;

View File

@@ -81,10 +81,10 @@ const messages = defineMessages({
defaultMessage: 'Delete',
description: 'Menu item to delete a container.',
},
menuRemoveFromCollection: {
menuRemoveFromContainer: {
id: 'course-authoring.library-authoring.component.menu.remove',
defaultMessage: 'Remove from collection',
description: 'Menu item for remove an item from collection.',
defaultMessage: 'Remove from {containerType}',
description: 'Menu item for remove an item from container.',
},
menuAddToCollection: {
id: 'course-authoring.library-authoring.component.menu.add',

View File

@@ -111,7 +111,10 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
onClick={readOnly ? undefined : jumpToManageTags}
/>
{!readOnly && (
<ContainerMenu containerKey={container.originalId} />
<ContainerMenu
containerKey={container.originalId}
displayName={container.displayName}
/>
)}
</Stack>
</>