diff --git a/src/generic/delete-modal/DeleteModal.jsx b/src/generic/delete-modal/DeleteModal.jsx index 3384c0f3b..4159d9d3f 100644 --- a/src/generic/delete-modal/DeleteModal.jsx +++ b/src/generic/delete-modal/DeleteModal.jsx @@ -51,6 +51,7 @@ const DeleteModal = ({ e.stopPropagation(); await onDeleteSubmit(); }} + variant="brand" label={defaultBtnLabel} /> diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/components/ContainerCard.test.tsx index dd5da2f20..a6359fafd 100644 --- a/src/library-authoring/components/ContainerCard.test.tsx +++ b/src/library-authoring/components/ContainerCard.test.tsx @@ -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('', () => { beforeEach(() => { - initializeMocks(); + ({ axiosMock, mockShowToast } = initializeMocks()); }); it('should render the card with title', () => { @@ -85,6 +90,68 @@ describe('', () => { // ); }); + it('should delete the container from the menu & restore the container', async () => { + axiosMock.onDelete(getLibraryContainerApiUrl(containerHitSample.usageKey)).reply(200); + + render(); + + // 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(); + + // 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(); diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index d508c2082..e3efa6553 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -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 ( - - + + + + + + + + + + + + - - - - - - + ); }; diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx new file mode 100644 index 000000000..9b7d5db05 --- /dev/null +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -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: {displayName}, + message: ( + <> +
+ + {intl.formatMessage(messages.deleteUnitConfirmMsg1)} +
+
+ + {intl.formatMessage(messages.deleteUnitConfirmMsg2)} +
+ + ), + }) 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 ( + + ); +}; + +export default ContainerDeleter; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 831855abc..ac4798136 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -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}', diff --git a/src/library-authoring/containers/UnitInfo.test.tsx b/src/library-authoring/containers/UnitInfo.test.tsx new file mode 100644 index 000000000..b93437664 --- /dev/null +++ b/src/library-authoring/containers/UnitInfo.test.tsx @@ -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(, { + extraWrapper: ({ children }) => ( + + + {children} + + + ), +}); +let axiosMock: MockAdapter; +let mockShowToast; + +describe('', () => { + 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(); + }); +}); diff --git a/src/library-authoring/containers/UnitInfo.tsx b/src/library-authoring/containers/UnitInfo.tsx index 90ad23008..41ab09bcc 100644 --- a/src/library-authoring/containers/UnitInfo.tsx +++ b/src/library-authoring/containers/UnitInfo.tsx @@ -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 ( + <> + + + + + + + + + + + ); +}; 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 ( - {showOpenCollectionButton && ( + {showOpenUnitButton && (
+
)} { 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); + }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 7348eedc9..c1491c529 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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. */ diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index ddc263f37..8208b9c38 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -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); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 839492748..71b1726af 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -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 */