diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx index 17d5a4b5b..3c3bf4547 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -380,6 +380,25 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); + it('should show error when remove component from collection', async () => { + const url = getLibraryCollectionItemsApiUrl( + mockContentLibrary.libraryId, + mockCollection.collectionId, + ); + axiosMock.onDelete(url).reply(404); + await renderLibraryCollectionPage(); + + const menuBtns = await screen.findAllByRole('button', { name: 'Component actions menu' }); + // open menu + fireEvent.click(menuBtns[0]); + + fireEvent.click(await screen.findByText('Remove from collection')); + await waitFor(() => { + expect(axiosMock.history.delete.length).toEqual(1); + }); + expect(mockShowToast).toHaveBeenCalledWith('Failed to remove item'); + }); + it('should remove unit from collection and hides sidebar', async () => { const url = getLibraryCollectionItemsApiUrl( mockContentLibrary.libraryId, diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index 54380c9b3..86dccbfd4 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -1,110 +1,21 @@ -import { useCallback, useContext } from 'react'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { useCallback } from 'react'; import { ActionRow, - Dropdown, - Icon, - IconButton, - useToggle, } from '@openedx/paragon'; -import { MoreVert } from '@openedx/paragon/icons'; -import { useClipboard } from '../../generic/clipboard'; -import { ToastContext } from '../../generic/toast-context'; import { type ContentHit, PublishStatus } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; -import { useRemoveItemsFromCollection } from '../data/apiHooks'; +import { useSidebarContext } from '../common/context/SidebarContext'; import { useLibraryRoutes } from '../routes'; - import AddComponentWidget from './AddComponentWidget'; import BaseCard from './BaseCard'; -import { canEditComponent } from './ComponentEditorModal'; -import ComponentDeleter from './ComponentDeleter'; -import messages from './messages'; +import { ComponentMenu } from './ComponentMenu'; type ComponentCardProps = { hit: ContentHit, }; -export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { - const intl = useIntl(); - const { - libraryId, - collectionId, - openComponentEditor, - } = useLibraryContext(); - - const { - sidebarComponentInfo, - openComponentInfoSidebar, - closeLibrarySidebar, - setSidebarAction, - } = useSidebarContext(); - - const canEdit = usageKey && canEditComponent(usageKey); - const { showToast } = useContext(ToastContext); - const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId); - const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false); - const { copyToClipboard } = useClipboard(); - - const updateClipboardClick = () => { - copyToClipboard(usageKey); - }; - - const removeFromCollection = () => { - removeComponentsMutation.mutateAsync([usageKey]).then(() => { - if (sidebarComponentInfo?.id === usageKey) { - // Close sidebar if current component is open - closeLibrarySidebar(); - } - showToast(intl.formatMessage(messages.removeComponentSucess)); - }).catch(() => { - showToast(intl.formatMessage(messages.removeComponentFailure)); - }); - }; - - const showManageCollections = useCallback(() => { - setSidebarAction(SidebarActions.JumpToAddCollections); - openComponentInfoSidebar(usageKey); - }, [setSidebarAction, openComponentInfoSidebar, usageKey]); - - return ( - - - - openComponentEditor(usageKey) } : { disabled: true })}> - - - - - - - - - {collectionId && ( - - - - )} - - - - - - - ); -}; - const ComponentCard = ({ hit }: ComponentCardProps) => { const { showOnlyPublished } = useLibraryContext(); const { openComponentInfoSidebar } = useSidebarContext(); diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx new file mode 100644 index 000000000..589901326 --- /dev/null +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -0,0 +1,137 @@ +import { useCallback, useContext } from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Dropdown, + Icon, + IconButton, + useToggle, +} from '@openedx/paragon'; +import { MoreVert } from '@openedx/paragon/icons'; + +import { useLibraryContext } from '../common/context/LibraryContext'; +import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; +import { useClipboard } from '../../generic/clipboard'; +import { ToastContext } from '../../generic/toast-context'; +import { + useAddComponentsToContainer, + useRemoveContainerChildren, + useRemoveItemsFromCollection, +} from '../data/apiHooks'; +import { canEditComponent } from './ComponentEditorModal'; +import ComponentDeleter from './ComponentDeleter'; +import messages from './messages'; + +export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { + const intl = useIntl(); + const { + libraryId, + collectionId, + unitId, + openComponentEditor, + } = useLibraryContext(); + + const { + sidebarComponentInfo, + openComponentInfoSidebar, + closeLibrarySidebar, + setSidebarAction, + } = useSidebarContext(); + + const canEdit = usageKey && canEditComponent(usageKey); + const { showToast } = useContext(ToastContext); + const addComponentToContainerMutation = useAddComponentsToContainer(unitId); + const removeCollectionComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId); + const removeContainerComponentsMutation = useRemoveContainerChildren(unitId); + const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false); + const { copyToClipboard } = useClipboard(); + + const updateClipboardClick = () => { + copyToClipboard(usageKey); + }; + + const removeFromCollection = () => { + removeCollectionComponentsMutation.mutateAsync([usageKey]).then(() => { + if (sidebarComponentInfo?.id === usageKey) { + // Close sidebar if current component is open + closeLibrarySidebar(); + } + showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess)); + }).catch(() => { + showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure)); + }); + }; + + const removeFromContainer = () => { + const restoreComponent = () => { + addComponentToContainerMutation.mutateAsync([usageKey]).then(() => { + showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess)); + }).catch(() => { + showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed)); + }); + }; + + removeContainerComponentsMutation.mutateAsync([usageKey]).then(() => { + if (sidebarComponentInfo?.id === usageKey) { + // Close sidebar if current component is open + closeLibrarySidebar(); + } + showToast( + intl.formatMessage(messages.removeComponentFromContainerSuccess), + { + label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction), + onClick: restoreComponent, + }, + ); + }).catch(() => { + showToast(intl.formatMessage(messages.removeComponentFromContainerFailure)); + }); + }; + + const showManageCollections = useCallback(() => { + setSidebarAction(SidebarActions.JumpToAddCollections); + openComponentInfoSidebar(usageKey); + }, [setSidebarAction, openComponentInfoSidebar, usageKey]); + + return ( + + + + openComponentEditor(usageKey) } : { disabled: true })}> + + + + + + {unitId && ( + + + + )} + + + + {collectionId && ( + + + + )} + {!unitId && ( + + + + )} + + + + ); +}; + +export default ComponentMenu; diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index d25c1c560..45268aa15 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -53,9 +53,9 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => { // Close sidebar if current component is open closeLibrarySidebar(); } - showToast(intl.formatMessage(messages.removeComponentSucess)); + showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess)); }).catch(() => { - showToast(intl.formatMessage(messages.removeComponentFailure)); + showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure)); }); }; diff --git a/src/library-authoring/components/index.ts b/src/library-authoring/components/index.ts index 119d3de91..29fcca8a2 100644 --- a/src/library-authoring/components/index.ts +++ b/src/library-authoring/components/index.ts @@ -1 +1 @@ -export { ComponentMenu as default } from './ComponentCard'; +export { ComponentMenu as default } from './ComponentMenu'; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 6e897b950..250793077 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -46,12 +46,12 @@ const messages = defineMessages({ defaultMessage: 'Remove from collection', description: 'Menu item for remove an item from collection.', }, - removeComponentSucess: { + 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.', }, - removeComponentFailure: { + 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.', @@ -231,5 +231,35 @@ const messages = defineMessages({ defaultMessage: '+{count}', description: 'Count shown when a container has more blocks than will fit on the card preview.', }, + removeComponentFromUnitMenu: { + id: 'course-authoring.library-authoring.unit.component.remove.button', + defaultMessage: 'Remove from unit', + description: 'Text of the menu item to remove a component from a unit', + }, + removeComponentFromContainerSuccess: { + id: 'course-authoring.library-authoring.component.remove-from-container-success', + defaultMessage: 'Component successfully removed', + description: 'Message for successful removal of a component from container.', + }, + removeComponentFromContainerFailure: { + id: 'course-authoring.library-authoring.component.remove-from-container-failure', + defaultMessage: 'Failed to remove component', + description: 'Message for failure of removal of a component from container.', + }, + undoRemoveComponentFromContainerToastAction: { + id: 'course-authoring.library-authoring.component.undo-remove-from-container-toast-button', + defaultMessage: 'Undo', + description: 'Toast message to undo remove a component from container.', + }, + undoRemoveComponentFromContainerToastSuccess: { + id: 'course-authoring.library-authoring.component.undo-remove-component-from-container-toast-text', + defaultMessage: 'Undo successful', + description: 'Message to display on undo delete component success', + }, + undoRemoveComponentFromContainerToastFailed: { + id: 'course-authoring.library-authoring.component.undo-remove-component-from-container-failed', + defaultMessage: 'Failed to undo remove component operation', + description: 'Message to display on failure to undo delete component', + }, }); export default messages; diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index d1796c43f..f5882ee67 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -1,10 +1,15 @@ import { initializeMocks } from '../../testUtils'; import * as api from './api'; +let axiosMock; + describe('library data API', () => { + beforeEach(() => { + ({ axiosMock } = initializeMocks()); + }); + describe('createLibraryBlock', () => { it('should create library block', async () => { - const { axiosMock } = initializeMocks(); const libraryId = 'lib:org:1'; const url = api.getCreateLibraryBlockUrl(libraryId); axiosMock.onPost(url).reply(200); @@ -20,7 +25,6 @@ describe('library data API', () => { describe('deleteLibraryBlock', () => { it('should delete a library block', async () => { - const { axiosMock } = initializeMocks(); const usageKey = 'lib:org:1'; const url = api.getLibraryBlockMetadataUrl(usageKey); axiosMock.onDelete(url).reply(200); @@ -31,7 +35,6 @@ describe('library data API', () => { describe('restoreLibraryBlock', () => { it('should restore a soft-deleted library block', async () => { - const { axiosMock } = initializeMocks(); const usageKey = 'lib:org:1'; const url = api.getLibraryBlockRestoreUrl(usageKey); axiosMock.onPost(url).reply(200); @@ -42,7 +45,6 @@ describe('library data API', () => { describe('commitLibraryChanges', () => { it('should commit library changes', async () => { - const { axiosMock } = initializeMocks(); const libraryId = 'lib:org:1'; const url = api.getCommitLibraryChangesUrl(libraryId); axiosMock.onPost(url).reply(200); @@ -55,7 +57,6 @@ describe('library data API', () => { describe('revertLibraryChanges', () => { it('should revert library changes', async () => { - const { axiosMock } = initializeMocks(); const libraryId = 'lib:org:1'; const url = api.getCommitLibraryChangesUrl(libraryId); axiosMock.onDelete(url).reply(200); @@ -68,7 +69,6 @@ describe('library data API', () => { describe('getBlockTypes', () => { it('should get block types metadata', async () => { - const { axiosMock } = initializeMocks(); const libraryId = 'lib:org:1'; const url = api.getBlockTypesMetaDataUrl(libraryId); axiosMock.onGet(url).reply(200); @@ -80,7 +80,6 @@ describe('library data API', () => { }); it('should create collection', async () => { - const { axiosMock } = initializeMocks(); const libraryId = 'lib:org:1'; const url = api.getLibraryCollectionsApiUrl(libraryId); @@ -95,7 +94,6 @@ describe('library data API', () => { }); it('should delete a container', async () => { - const { axiosMock } = initializeMocks(); const containerId = 'lct:org:lib1'; const url = api.getLibraryContainerApiUrl(containerId); @@ -106,7 +104,6 @@ describe('library data API', () => { }); it('should restore a container', async () => { - const { axiosMock } = initializeMocks(); const containerId = 'lct:org:lib1'; const url = api.getLibraryContainerRestoreApiUrl(containerId); @@ -116,7 +113,6 @@ describe('library data API', () => { }); it('should add components to unit', async () => { - const { axiosMock } = initializeMocks(); const componentId = 'lb:org:lib:html:1'; const containerId = 'lct:org:lib:unit:1'; const url = api.getLibraryContainerChildrenApiUrl(containerId); @@ -128,7 +124,6 @@ describe('library data API', () => { }); it('should update container children', async () => { - const { axiosMock } = initializeMocks(); const containerId = 'lct:org:lib1'; const url = api.getLibraryContainerChildrenApiUrl(containerId); @@ -137,4 +132,14 @@ describe('library data API', () => { await api.updateLibraryContainerChildren(containerId, ['test']); expect(axiosMock.history.patch[0].url).toEqual(url); }); + + it('should remove container children', async () => { + const containerId = 'lct:org:lib1'; + const url = api.getLibraryContainerChildrenApiUrl(containerId); + + axiosMock.onDelete(url).reply(200); + + await api.removeLibraryContainerChildren(containerId, ['test']); + expect(axiosMock.history.delete[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 1eabb381e..9c6f5eaa1 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -684,3 +684,19 @@ export async function updateLibraryContainerChildren( ); return camelCaseObject(data); } + +/** + * Remove components in `children` from library container. + */ +export async function removeLibraryContainerChildren( + containerId: string, + children: string[], +): Promise { + const { data } = await getAuthenticatedHttpClient().delete( + getLibraryContainerChildrenApiUrl(containerId), + { + data: { usage_keys: children }, + }, + ); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index f026829cb..3f2a1b5d1 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -30,6 +30,7 @@ import { useContainerChildren, useAddComponentsToContainer, useUpdateContainerChildren, + useRemoveContainerChildren, } from './apiHooks'; let axiosMock; @@ -287,4 +288,24 @@ describe('library api hooks', () => { expect(axiosMock.history.patch.length).toEqual(0); }); }); + + it('should remove container children', async () => { + const containerId = 'lct:org:lib1'; + const url = getLibraryContainerChildrenApiUrl(containerId); + + axiosMock.onDelete(url).reply(200); + const { result } = renderHook(() => useRemoveContainerChildren(containerId), { wrapper }); + await result.current.mutateAsync([]); + await waitFor(() => { + expect(axiosMock.history.delete[0].url).toEqual(url); + }); + }); + + it('should not attempt request if containerId is not defined in remove children from container', async () => { + const { result } = renderHook(() => useRemoveContainerChildren(), { wrapper }); + await result.current.mutateAsync([]); + await waitFor(() => { + expect(axiosMock.history.patch.length).toEqual(0); + }); + }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 0bf7fe86a..317bf43a8 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -57,6 +57,7 @@ import { getLibraryContainerChildren, updateContainerCollections, updateLibraryContainerChildren, + removeLibraryContainerChildren, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -732,3 +733,28 @@ export const useUpdateContainerChildren = (containerId?: string) => { }, }); }; + +/** + * Remove components from container + */ +export const useRemoveContainerChildren = (containerId?: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (usageKeys: string[]) => { + if (!containerId) { + return undefined; + } + return removeLibraryContainerChildren(containerId, usageKeys); + }, + onSettled: () => { + if (!containerId) { + return; + } + // NOTE: We invalidate the library query here because we need to update the container + // count in the library + const libraryId = getLibraryId(containerId); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) }); + }, + }); +}; diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index cee3503d6..03ac5b566 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -50,9 +50,7 @@ jest.mock('@dnd-kit/core', () => ({ describe('', () => { beforeEach(() => { - const mocks = initializeMocks(); - axiosMock = mocks.axiosMock; - mockShowToast = mocks.mockShowToast; + ({ axiosMock, mockShowToast } = initializeMocks()); }); afterEach(() => { @@ -183,7 +181,7 @@ describe('', () => { expect(await screen.findByTestId('library-sidebar')).toBeInTheDocument(); }); - it('should open and component sidebar on component selection', async () => { + it('should open and close component sidebar on component selection', async () => { renderLibraryUnitPage(); const component = await screen.findByText('text block 0'); @@ -232,6 +230,109 @@ describe('', () => { await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Failed to update components order')); }); + it('should remove a component & restore from component card', async () => { + const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId); + axiosMock.onDelete(url).reply(200); + renderLibraryUnitPage(); + + expect(await screen.findByText('text block 0')).toBeInTheDocument(); + const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; + fireEvent.click(menu); + + const removeButton = await screen.getByText('Remove from unit'); + fireEvent.click(removeButton); + + await waitFor(() => { + expect(axiosMock.history.delete[0].url).toEqual(url); + }); + await waitFor(() => expect(mockShowToast).toHaveBeenCalled()); + + // Get restore / undo func from the toast + // @ts-ignore + const restoreFn = mockShowToast.mock.calls[0][1].onClick; + + const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId); + 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 remove a component', async () => { + const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId); + axiosMock.onDelete(url).reply(404); + renderLibraryUnitPage(); + + expect(await screen.findByText('text block 0')).toBeInTheDocument(); + const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; + fireEvent.click(menu); + + const removeButton = await screen.getByText('Remove from unit'); + fireEvent.click(removeButton); + + await waitFor(() => { + expect(axiosMock.history.delete[0].url).toEqual(url); + }); + expect(mockShowToast).toHaveBeenCalledWith('Failed to remove component'); + }); + + it('should show error on restore removed component', async () => { + const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId); + axiosMock.onDelete(url).reply(200); + renderLibraryUnitPage(); + + expect(await screen.findByText('text block 0')).toBeInTheDocument(); + const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0]; + fireEvent.click(menu); + + const removeButton = await screen.getByText('Remove from unit'); + fireEvent.click(removeButton); + + await waitFor(() => { + expect(axiosMock.history.delete[0].url).toEqual(url); + }); + await waitFor(() => expect(mockShowToast).toHaveBeenCalled()); + + // Get restore / undo func from the toast + // @ts-ignore + const restoreFn = mockShowToast.mock.calls[0][1].onClick; + + const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId); + axiosMock.onPost(restoreUrl).reply(404); + // restore collection + restoreFn(); + await waitFor(() => { + expect(axiosMock.history.post.length).toEqual(1); + }); + expect(mockShowToast).toHaveBeenCalledWith('Failed to undo remove component operation'); + }); + + it('should remove a component from component sidebar', async () => { + const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId); + axiosMock.onDelete(url).reply(200); + renderLibraryUnitPage(); + + const component = await screen.findByText('text block 0'); + userEvent.click(component); + const sidebar = await screen.findByTestId('library-sidebar'); + + const { findByRole, findByText } = within(sidebar); + + const menu = await findByRole('button', { name: /component actions menu/i }); + fireEvent.click(menu); + + const removeButton = await findByText('Remove from unit'); + fireEvent.click(removeButton); + + await waitFor(() => { + expect(axiosMock.history.delete[0].url).toEqual(url); + }); + await waitFor(() => expect(mockShowToast).toHaveBeenCalled()); + }); + it('should show editor on double click', async () => { renderLibraryUnitPage(); const component = await screen.findByText('text block 0');