feat: remove unit/subsection from subsection/section (#2149)
Let the user remove a unit/subsection from a subsection/section.
This commit is contained in:
committed by
GitHub
parent
aeefcc639f
commit
e2da13d129
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
89
src/library-authoring/containers/ContainerRemover.tsx
Normal file
89
src/library-authoring/containers/ContainerRemover.tsx
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user