diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx index b85ac9664..52de18ee1 100644 --- a/src/library-authoring/components/ComponentMenu.tsx +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -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 ( { {insideCollection && ( - + )} diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index e757f33d0..0c5188c4e 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -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; diff --git a/src/library-authoring/containers/ContainerCard.test.tsx b/src/library-authoring/containers/ContainerCard.test.tsx index 23ca834a8..62f100504 100644 --- a/src/library-authoring/containers/ContainerCard.test.tsx +++ b/src/library-authoring/containers/ContainerCard.test.tsx @@ -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 }) => ( - - {children} - - ), -}); +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 = containerContext + ? { libraryId, containerId: containerContext.id } + : { libraryId }; + + return baseRender(ui, { + path, + params, + extraWrapper: ({ children }) => ( + + {children} + + ), + }); +}; describe('', () => { beforeEach(() => { @@ -387,4 +400,55 @@ describe('', () => { 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( + , + 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(); + }); }); diff --git a/src/library-authoring/containers/ContainerCard.tsx b/src/library-authoring/containers/ContainerCard.tsx index 483393d5e..f96b2a041 100644 --- a/src/library-authoring/containers/ContainerCard.tsx +++ b/src/library-authoring/containers/ContainerCard.tsx @@ -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 ( <> @@ -89,9 +108,14 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => { - {insideCollection && ( - - + {(insideCollection || insideSection || insideSubsection) && ( + + )} @@ -106,6 +130,14 @@ export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => { containerId={containerKey} /> )} + {isConfirmingRemove && ( + + )} ); }; @@ -262,7 +294,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { {componentPickerMode ? ( ) : ( - + )} )} diff --git a/src/library-authoring/containers/ContainerRemover.tsx b/src/library-authoring/containers/ContainerRemover.tsx new file mode 100644 index 000000000..2ac9e84ab --- /dev/null +++ b/src/library-authoring/containers/ContainerRemover.tsx @@ -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: {capitalize(itemType)} {displayName}, + 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 ( + + ); +}; + +export default ContainerRemover; diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index 866dfa821..95489bfac 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -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', diff --git a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx index cb1d4f83c..d431ebe27 100644 --- a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx +++ b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx @@ -111,7 +111,10 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps) onClick={readOnly ? undefined : jumpToManageTags} /> {!readOnly && ( - + )}