diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index af8108c534..b503731409 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -4,6 +4,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`; +export const getContainerEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstream-containers/`; export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`; @@ -18,9 +19,8 @@ export interface PaginatedData { results: T, } -export interface PublishableEntityLink { +export interface BasePublishableEntityLink { id: number; - upstreamUsageKey: string; upstreamContextKey: string; upstreamContextTitle: string; upstreamVersion: number; @@ -33,6 +33,14 @@ export interface PublishableEntityLink { readyToSync: boolean; } +export interface PublishableEntityLink extends BasePublishableEntityLink { + upstreamUsageKey: string; +} + +export interface ContainerPublishableEntityLink extends BasePublishableEntityLink { + upstreamContainerKey: string; +} + export interface PublishableEntityLinkSummary { upstreamContextKey: string; upstreamContextTitle: string; @@ -58,6 +66,23 @@ export const getEntityLinks = async ( return camelCaseObject(data); }; +export const getContainerEntityLinks = async ( + downstreamContextKey?: string, + readyToSync?: boolean, + upstreamContainerKey?: string, +): Promise => { + const { data } = await getAuthenticatedHttpClient() + .get(getContainerEntityLinksByDownstreamContextUrl(), { + params: { + course_id: downstreamContextKey, + ready_to_sync: readyToSync, + upstream_container_key: upstreamContainerKey, + no_page: true, + }, + }); + return camelCaseObject(data); +}; + export const getEntityLinksSummaryByDownstreamContext = async ( downstreamContextKey: string, ): Promise => { diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts index 093a63121e..506687b15d 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -1,15 +1,18 @@ import { useQuery, } from '@tanstack/react-query'; -import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api'; +import { getContainerEntityLinks, getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api'; export const courseLibrariesQueryKeys = { all: ['courseLibraries'], courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId], - courseReadyToSyncLibraries: ({ courseId, readyToSync, upstreamUsageKey }: { + courseReadyToSyncLibraries: ({ + courseId, readyToSync, upstreamUsageKey, upstreamContainerKey, + }: { courseId?: string, readyToSync?: boolean, upstreamUsageKey?: string, + upstreamContainerKey?: string, pageSize?: number, }) => { const key: Array = [...courseLibrariesQueryKeys.all]; @@ -22,6 +25,9 @@ export const courseLibrariesQueryKeys = { if (upstreamUsageKey !== undefined) { key.push(upstreamUsageKey); } + if (upstreamContainerKey !== undefined) { + key.push(upstreamContainerKey); + } return key; }, courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'], @@ -63,3 +69,29 @@ export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => ( enabled: courseId !== undefined, }) ); + +/** + * Hook to fetch list of publishable entity links for containers by course key. + * (That is, get a list of the library containers used in the given course.) + */ +export const useContainerEntityLinks = ({ + courseId, readyToSync, upstreamContainerKey, +}: { + courseId?: string, + readyToSync?: boolean, + upstreamContainerKey?: string, +}) => ( + useQuery({ + queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({ + courseId, + readyToSync, + upstreamContainerKey, + }), + queryFn: () => getContainerEntityLinks( + courseId, + readyToSync, + upstreamContainerKey, + ), + enabled: courseId !== undefined || upstreamContainerKey !== undefined || readyToSync !== undefined, + }) +); diff --git a/src/generic/delete-modal/DeleteModal.tsx b/src/generic/delete-modal/DeleteModal.tsx index cf81b96363..a92203572c 100644 --- a/src/generic/delete-modal/DeleteModal.tsx +++ b/src/generic/delete-modal/DeleteModal.tsx @@ -68,7 +68,7 @@ const DeleteModal = ({ )} > -

{modalDescription}

+
{modalDescription}
); }; diff --git a/src/library-authoring/LibraryContent.tsx b/src/library-authoring/LibraryContent.tsx index 53fdb23ecd..817c1d46dc 100644 --- a/src/library-authoring/LibraryContent.tsx +++ b/src/library-authoring/LibraryContent.tsx @@ -6,10 +6,10 @@ import { useLibraryContext } from './common/context/LibraryContext'; import { useSidebarContext } from './common/context/SidebarContext'; import CollectionCard from './components/CollectionCard'; import ComponentCard from './components/ComponentCard'; -import ContainerCard from './components/ContainerCard'; import { ContentType } from './routes'; import { useLoadOnScroll } from '../hooks'; import messages from './collections/messages'; +import ContainerCard from './containers/ContainerCard'; /** * Library Content to show content grid diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx index c1777c7cb2..b85ac96645 100644 --- a/src/library-authoring/components/ComponentMenu.tsx +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -20,6 +20,7 @@ import { import { canEditComponent } from './ComponentEditorModal'; import ComponentDeleter from './ComponentDeleter'; import messages from './messages'; +import containerMessages from '../containers/messages'; import { useLibraryRoutes } from '../routes'; import { useRunOnNextRender } from '../../utils'; @@ -58,9 +59,9 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { // Close sidebar if current component is open closeLibrarySidebar(); } - showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess)); + showToast(intl.formatMessage(containerMessages.removeComponentFromCollectionSuccess)); }).catch(() => { - showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure)); + showToast(intl.formatMessage(containerMessages.removeComponentFromCollectionFailure)); }); }; @@ -139,11 +140,11 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { {insideCollection && ( - + )} - + {isConfirmingDelete && ( diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx deleted file mode 100644 index 5d2e7b03a5..0000000000 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { 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 { - sidebarItemInfo, - 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)} -
- - ), - }); - 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 (sidebarItemInfo?.id === containerId) { - closeLibrarySidebar(); - } - showToast( - deleteSuccess, - { - label: intl.formatMessage(messages.undoDeleteContainerToastAction), - onClick: restoreComponent, - }, - ); - }).catch(() => { - showToast(deleteError); - }).finally(() => { - close(); - }); - }, [sidebarItemInfo, showToast, deleteContainerMutation]); - - return ( - - ); -}; - -export default ContainerDeleter; diff --git a/src/library-authoring/components/index.scss b/src/library-authoring/components/index.scss index fd6ce42442..c2215d5f88 100644 --- a/src/library-authoring/components/index.scss +++ b/src/library-authoring/components/index.scss @@ -1,2 +1 @@ @import "./BaseCard.scss"; -@import "./ContainerCard.scss"; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index e2d281034a..e757f33d04 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -11,11 +11,6 @@ const messages = defineMessages({ defaultMessage: 'Collection actions menu', description: 'Alt/title text for the collection card menu button.', }, - containerCardMenuAlt: { - id: 'course-authoring.library-authoring.container.menu', - defaultMessage: 'Container actions menu', - description: 'Alt/title text for the container card menu button.', - }, menuOpen: { id: 'course-authoring.library-authoring.menu.open', defaultMessage: 'Open', @@ -36,26 +31,6 @@ 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', - defaultMessage: 'Remove from collection', - description: 'Menu item for remove an item from collection.', - }, - 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', @@ -191,61 +166,6 @@ 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}', - 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', @@ -276,10 +196,5 @@ 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', - }, }); export default messages; diff --git a/src/library-authoring/components/ContainerCard.scss b/src/library-authoring/containers/ContainerCard.scss similarity index 100% rename from src/library-authoring/components/ContainerCard.scss rename to src/library-authoring/containers/ContainerCard.scss diff --git a/src/library-authoring/components/ContainerCard.test.tsx b/src/library-authoring/containers/ContainerCard.test.tsx similarity index 100% rename from src/library-authoring/components/ContainerCard.test.tsx rename to src/library-authoring/containers/ContainerCard.test.tsx diff --git a/src/library-authoring/components/ContainerCard.tsx b/src/library-authoring/containers/ContainerCard.tsx similarity index 94% rename from src/library-authoring/components/ContainerCard.tsx rename to src/library-authoring/containers/ContainerCard.tsx index 3468842b5d..483393d5ed 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/containers/ContainerCard.tsx @@ -19,18 +19,17 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; import { useRemoveItemsFromCollection } from '../data/apiHooks'; import { useLibraryRoutes } from '../routes'; -import AddComponentWidget from './AddComponentWidget'; -import BaseCard from './BaseCard'; import messages from './messages'; import ContainerDeleter from './ContainerDeleter'; import { useRunOnNextRender } from '../../utils'; +import BaseCard from '../components/BaseCard'; +import AddComponentWidget from '../components/AddComponentWidget'; type ContainerMenuProps = { containerKey: string; - displayName: string; }; -export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => { +export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => { const intl = useIntl(); const { libraryId, collectionId } = useLibraryContext(); const { @@ -100,12 +99,13 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps - + {isConfirmingDelete && ( + + )} ); }; @@ -262,10 +262,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => { {componentPickerMode ? ( ) : ( - + )} )} diff --git a/src/library-authoring/containers/ContainerDeleter.test.tsx b/src/library-authoring/containers/ContainerDeleter.test.tsx new file mode 100644 index 0000000000..e0a9f6869f --- /dev/null +++ b/src/library-authoring/containers/ContainerDeleter.test.tsx @@ -0,0 +1,204 @@ +import type { ToastActionData } from '../../generic/toast-context'; +import { + fireEvent, + render, + screen, + initializeMocks, + waitFor, +} from '../../testUtils'; +import { LibraryProvider } from '../common/context/LibraryContext'; +import { SidebarProvider } from '../common/context/SidebarContext'; +import { + mockContentLibrary, + mockGetContainerMetadata, + mockDeleteContainer, + mockRestoreContainer, + mockGetContainerEntityLinks, +} from '../data/api.mocks'; +import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock'; +import ContainerDeleter from './ContainerDeleter'; + +mockContentLibrary.applyMock(); // Not required, but avoids 404 errors in the logs when loads data +mockContentSearchConfig.applyMock(); +mockGetContainerEntityLinks.applyMock(); +const mockDelete = mockDeleteContainer.applyMock(); +const mockRestore = mockRestoreContainer.applyMock(); + +const { libraryId } = mockContentLibrary; +const getContainerDetails = (context: string) => { + switch (context) { + case 'unit': + return { containerId: mockGetContainerMetadata.unitId, parent: 'subsection' }; + case 'subsection': + return { containerId: mockGetContainerMetadata.subsectionId, parent: 'section' }; + case 'section': + return { containerId: mockGetContainerMetadata.sectionId, parent: null }; + default: + return { containerId: mockGetContainerMetadata.unitId, parent: 'subsection' }; + } +}; + +const renderArgs = { + path: '/library/:libraryId', + params: { libraryId }, + extraWrapper: ({ children }) => ( + + + { children } + + + ), +}; + +let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; }; + +[ + 'unit' as const, + 'section' as const, + 'subsection' as const, +].forEach((context) => { + describe('', () => { + beforeEach(() => { + const mocks = initializeMocks(); + mockShowToast = mocks.mockShowToast; + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [{ + blockType: context, + displayName: `Test ${context}`, + }], + }, + ], + }); + }); + + it(`<${context}> is invisible when isOpen is false`, async () => { + const mockClose = jest.fn(); + const { containerId } = getContainerDetails(context); + render(, renderArgs); + + const modal = screen.queryByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') }); + expect(modal).not.toBeInTheDocument(); + }); + + it(`<${context}> should show a confirmation prompt the card with title and description`, async () => { + const mockCancel = jest.fn(); + const { containerId } = getContainerDetails(context); + render(, renderArgs); + + const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') }); + expect(modal).toBeVisible(); + + // It should mention the component's name in the confirm dialog: + await screen.findByText(`Test ${context}`); + + // Clicking cancel will cancel: + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + expect(mockCancel).toHaveBeenCalled(); + }); + + it(`<${context}> deletes the block when confirmed, shows a toast with undo option and restores block on undo`, async () => { + const mockCancel = jest.fn(); + const { containerId } = getContainerDetails(context); + render(, renderArgs); + + const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') }); + expect(modal).toBeVisible(); + + const deleteButton = await screen.findByRole('button', { name: 'Delete' }); + fireEvent.click(deleteButton); + await waitFor(() => { + expect(mockDelete).toHaveBeenCalled(); + }); + expect(mockCancel).toHaveBeenCalled(); // In order to close the modal, this also gets called. + expect(mockShowToast).toHaveBeenCalled(); + // Get restore / undo func from the toast + const restoreFn = mockShowToast.mock.calls[0][1].onClick; + restoreFn(); + await waitFor(() => { + expect(mockRestore).toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith('Undo successful'); + }); + }); + + it(`<${context}> should show parents message if parent data is set with one parent`, async () => { + const mockCancel = jest.fn(); + const { containerId, parent } = getContainerDetails(context); + if (!parent) { + return; + } + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [{ + [`${parent}s`]: { + displayName: [`${parent} 1`], + key: [`${parent}1`], + }, + blockType: context, + }], + }, + ], + }); + render( + , + renderArgs, + ); + + const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') }); + expect(modal).toBeVisible(); + + const textMatch = new RegExp(`By deleting this ${context}, you will also be deleting it from ${parent} 1 in this library.`); + expect((await screen.findAllByText((_, element) => textMatch.test(element?.textContent || ''))).length).toBeGreaterThan(0); + }); + + it(`<${context}> should show parents message if parents is set with multiple parents`, async () => { + const mockCancel = jest.fn(); + const { containerId, parent } = getContainerDetails(context); + if (!parent) { + return; + } + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [{ + [`${parent}s`]: { + displayName: [`${parent} 1`, `${parent} 2`], + key: [`${parent}1`, `${parent}2`], + }, + blockType: context, + }], + }, + ], + }); + render( + , + renderArgs, + ); + + const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') }); + expect(modal).toBeVisible(); + + const textMatch = new RegExp(`By deleting this ${context}, you will also be deleting it from 2 ${parent}s in this library.`, 'i'); + expect((await screen.findAllByText((_, element) => textMatch.test(element?.textContent || ''))).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/library-authoring/containers/ContainerDeleter.tsx b/src/library-authoring/containers/ContainerDeleter.tsx new file mode 100644 index 0000000000..9dc9b9b4b9 --- /dev/null +++ b/src/library-authoring/containers/ContainerDeleter.tsx @@ -0,0 +1,169 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@openedx/paragon'; +import { Error, Warning, School } from '@openedx/paragon/icons'; + +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import { useSidebarContext } from '../common/context/SidebarContext'; +import { ToastContext } from '../../generic/toast-context'; +import { useContentFromSearchIndex, useDeleteContainer, useRestoreContainer } from '../data/apiHooks'; +import messages from './messages'; +import { ContainerType } from '../../generic/key-utils'; +import { ContainerHit } from '../../search-manager'; +import { useContainerEntityLinks } from '../../course-libraries/data/apiHooks'; +import { LoadingSpinner } from '../../generic/Loading'; + +type ContainerDeleterProps = { + isOpen: boolean, + close: () => void, + containerId: string, +}; + +const ContainerDeleter = ({ + isOpen, + close, + containerId, +}: ContainerDeleterProps) => { + const intl = useIntl(); + const { + sidebarItemInfo, + closeLibrarySidebar, + } = useSidebarContext(); + const deleteContainerMutation = useDeleteContainer(containerId); + const restoreContainerMutation = useRestoreContainer(containerId); + const { showToast } = useContext(ToastContext); + const { hits, isLoading } = useContentFromSearchIndex([containerId]); + const containerData = (hits as ContainerHit[])?.[0]; + const { + data: dataDownstreamLinks, + isLoading: linksIsLoading, + } = useContainerEntityLinks({ upstreamContainerKey: containerId }); + const downstreamCount = dataDownstreamLinks?.length ?? 0; + + const messageMap = useMemo(() => { + const containerType = containerData?.blockType; + let parentCount = 0; + let parentMessage: React.ReactNode; + switch (containerType) { + case ContainerType.Section: + return { + title: intl.formatMessage(messages.deleteSectionWarningTitle), + parentMessage: '', + courseCount: downstreamCount, + courseMessage: messages.deleteSectionCourseMessaage, + deleteSuccess: intl.formatMessage(messages.deleteSectionSuccess), + deleteError: intl.formatMessage(messages.deleteSectionFailed), + undoDeleteError: messages.undoDeleteSectionToastFailed, + }; + case ContainerType.Subsection: + parentCount = containerData?.sections?.displayName?.length || 0; + if (parentCount === 1) { + parentMessage = intl.formatMessage( + messages.deleteSubsectionParentMessage, + { parentName: {containerData?.sections?.displayName?.[0]} }, + ); + } else if (parentCount > 1) { + parentMessage = intl.formatMessage(messages.deleteSubsectionMultipleParentMessage, { + parentCount: {parentCount}, + }); + } + return { + title: intl.formatMessage(messages.deleteSubsectionWarningTitle), + parentMessage, + courseCount: downstreamCount, + courseMessage: messages.deleteSubsectionCourseMessaage, + deleteSuccess: intl.formatMessage(messages.deleteSubsectionSuccess), + deleteError: intl.formatMessage(messages.deleteSubsectionFailed), + undoDeleteError: messages.undoDeleteSubsectionToastFailed, + }; + default: + parentCount = containerData?.subsections?.displayName?.length || 0; + if (parentCount === 1) { + parentMessage = intl.formatMessage( + messages.deleteUnitParentMessage, + { parentName: {containerData?.subsections?.displayName?.[0]} }, + ); + } else if (parentCount > 1) { + parentMessage = intl.formatMessage(messages.deleteUnitMultipleParentMessage, { + parentCount: {parentCount}, + }); + } + return { + title: intl.formatMessage(messages.deleteUnitWarningTitle), + parentMessage, + courseCount: downstreamCount, + courseMessage: messages.deleteUnitCourseMessage, + deleteSuccess: intl.formatMessage(messages.deleteUnitSuccess), + deleteError: intl.formatMessage(messages.deleteUnitFailed), + undoDeleteError: messages.undoDeleteUnitToastFailed, + }; + } + }, [containerData, downstreamCount, messages, intl]); + + const deleteText = intl.formatMessage(messages.deleteUnitConfirm, { + unitName: {containerData?.displayName}, + message: ( +
+ {messageMap.parentMessage && ( +
+ + {messageMap.parentMessage} +
+ )} + {(messageMap.courseCount || 0) > 0 && ( +
+ + + {intl.formatMessage(messageMap.courseMessage, { + courseCount: messageMap.courseCount, + courseCountText: {messageMap.courseCount}, + })} + +
+ )} +
+ ), + }); + + const restoreComponent = useCallback(async () => { + try { + await restoreContainerMutation.mutateAsync(); + showToast(intl.formatMessage(messages.undoDeleteContainerToastMessage)); + } catch (e) { + showToast(intl.formatMessage(messageMap.undoDeleteError)); + } + }, [messageMap]); + + const onDelete = useCallback(async () => { + await deleteContainerMutation.mutateAsync().then(() => { + if (sidebarItemInfo?.id === containerId) { + closeLibrarySidebar(); + } + showToast( + messageMap.deleteSuccess, + { + label: intl.formatMessage(messages.undoDeleteContainerToastAction), + onClick: restoreComponent, + }, + ); + }).catch(() => { + showToast(messageMap.deleteError); + }).finally(() => { + close(); + }); + }, [sidebarItemInfo, showToast, deleteContainerMutation, messageMap]); + + return ( + : deleteText} + onDeleteSubmit={onDelete} + /> + ); +}; + +export default ContainerDeleter; diff --git a/src/library-authoring/containers/ContainerInfo.test.tsx b/src/library-authoring/containers/ContainerInfo.test.tsx index 5370fb0b63..9b9ee41edc 100644 --- a/src/library-authoring/containers/ContainerInfo.test.tsx +++ b/src/library-authoring/containers/ContainerInfo.test.tsx @@ -10,19 +10,20 @@ import { LibraryProvider } from '../common/context/LibraryContext'; import ContainerInfo from './ContainerInfo'; import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api'; import { SidebarBodyItemId, SidebarProvider } from '../common/context/SidebarContext'; +import { ContainerType } from '../../generic/key-utils'; +import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock'; +import type { ToastActionData } from '../../generic/toast-context'; mockGetContainerMetadata.applyMock(); mockContentLibrary.applyMock(); +mockContentSearchConfig.applyMock(); mockGetContainerMetadata.applyMock(); mockGetContainerChildren.applyMock(); -// TODO Remove this to un-skip section/subsection tests, when implemented -const testIf = (condition) => (condition ? it : it.skip); - const { libraryId } = mockContentLibrary; const { unitId, subsectionId, sectionId } = mockGetContainerMetadata; -const render = (containerId, showOnlyPublished: boolean = false) => { +const render = (containerId: string, showOnlyPublished: boolean = false) => { const params: { libraryId: string, selectedItemId?: string } = { libraryId, selectedItemId: containerId }; return baseRender(, { path: '/library/:libraryId/:selectedItemId?', @@ -45,28 +46,38 @@ const render = (containerId, showOnlyPublished: boolean = false) => { }); }; let axiosMock: MockAdapter; -let mockShowToast; - -describe('', () => { - beforeEach(() => { - ({ axiosMock, mockShowToast } = initializeMocks()); - }); +let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; }; + +[ + { + containerType: ContainerType.Unit, + containerId: unitId, + }, + { + containerType: ContainerType.Subsection, + containerId: subsectionId, + }, + { + containerType: ContainerType.Section, + containerId: sectionId, + }, +].forEach(({ containerId, containerType }) => { + describe(` with containerType: ${containerType}`, () => { + beforeEach(() => { + ({ axiosMock, mockShowToast } = initializeMocks()); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [{ + blockType: containerType, + displayName: `Test ${containerType}`, + }], + }, + ], + }); + }); - [ - { - containerType: 'Unit', - containerId: unitId, - }, - { - containerType: 'Subsection', - containerId: subsectionId, - }, - { - containerType: 'Section', - containerId: sectionId, - }, - ].forEach(({ containerId, containerType }) => { - testIf(containerType === 'Unit')(`should delete the ${containerType} using the menu`, async () => { + it(`should delete the ${containerType} using the menu`, async () => { axiosMock.onDelete(getLibraryContainerApiUrl(containerId)).reply(200); render(containerId); @@ -75,12 +86,12 @@ describe('', () => { userEvent.click(screen.getByTestId('container-info-menu-toggle')); // Click on Delete Item - const deleteMenuItem = screen.getByRole('button', { name: 'Delete' }); + const deleteMenuItem = await screen.findByRole('button', { name: 'Delete' }); expect(deleteMenuItem).toBeInTheDocument(); fireEvent.click(deleteMenuItem); // Confirm delete Modal is open - expect(screen.getByText(`Delete ${containerType}`)); + expect(screen.getByText(`Delete ${containerType[0].toUpperCase()}${containerType.slice(1)}`)); const deleteButton = screen.getByRole('button', { name: /delete/i }); fireEvent.click(deleteButton); diff --git a/src/library-authoring/containers/ContainerInfo.tsx b/src/library-authoring/containers/ContainerInfo.tsx index 8be15f53dd..76b13b4b91 100644 --- a/src/library-authoring/containers/ContainerInfo.tsx +++ b/src/library-authoring/containers/ContainerInfo.tsx @@ -26,18 +26,16 @@ import { useLibraryRoutes } from '../routes'; import { LibraryUnitBlocks } from '../units/LibraryUnitBlocks'; import { LibraryContainerChildren } from '../section-subsections/LibraryContainerChildren'; import messages from './messages'; -import componentMessages from '../components/messages'; -import ContainerDeleter from '../components/ContainerDeleter'; import { useContainer, usePublishContainer } from '../data/apiHooks'; import { ContainerType, getBlockType } from '../../generic/key-utils'; import { ToastContext } from '../../generic/toast-context'; +import ContainerDeleter from './ContainerDeleter'; -type ContainerMenuProps = { +type ContainerPreviewProps = { containerId: string, - displayName: string, }; -const ContainerMenu = ({ containerId, displayName }: ContainerMenuProps) => { +const ContainerMenu = ({ containerId }: ContainerPreviewProps) => { const intl = useIntl(); const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false); @@ -51,12 +49,12 @@ const ContainerMenu = ({ containerId, displayName }: ContainerMenuProps) => { src={MoreVert} iconAs={Icon} variant="primary" - alt={intl.formatMessage(componentMessages.containerCardMenuAlt)} + alt={intl.formatMessage(messages.containerCardMenuAlt)} data-testid="container-info-menu-toggle" /> - + @@ -64,16 +62,11 @@ const ContainerMenu = ({ containerId, displayName }: ContainerMenuProps) => { isOpen={isConfirmingDelete} close={cancelDelete} containerId={containerId} - displayName={displayName} /> ); }; -type ContainerPreviewProps = { - containerId: string, -}; - const ContainerPreview = ({ containerId } : ContainerPreviewProps) => { const containerType = getBlockType(containerId); if (containerType === ContainerType.Unit) { @@ -167,10 +160,7 @@ const ContainerInfo = () => { )} {showOpenButton && ( - + )} ( jest.spyOn(api, 'restoreLibraryBlock').mockImplementation(mockRestoreLibraryBlock) ); +/** + * Mock for `deleteContainer()` + */ +export async function mockDeleteContainer(): ReturnType { + // no-op +} +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockDeleteContainer.applyMock = () => ( + jest.spyOn(api, 'deleteContainer').mockImplementation(mockDeleteContainer) +); + +/** + * Mock for `restoreContainer()` + */ +export async function mockRestoreContainer(): ReturnType { + // no-op +} +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockRestoreContainer.applyMock = () => ( + jest.spyOn(api, 'restoreContainer').mockImplementation(mockRestoreContainer) +); + /** * Mock for `getXBlockFields()` * @@ -751,13 +773,13 @@ export async function mockGetEntityLinks( ): ReturnType { const thisMock = mockGetEntityLinks; switch (upstreamUsageKey) { - case thisMock.upstreamUsageKey: return thisMock.response; + case thisMock.upstreamContainerKey: return thisMock.response; case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response; case thisMock.emptyUsageKey: return thisMock.emptyComponentUsage; default: return []; } } -mockGetEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished; +mockGetEntityLinks.upstreamContainerKey = mockLibraryBlockMetadata.usageKeyPublished; mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({ id: 875, upstreamContextTitle: 'CS problems 3', @@ -779,3 +801,115 @@ mockGetEntityLinks.applyMock = () => jest.spyOn( courseLibApi, 'getEntityLinks', ).mockImplementation(mockGetEntityLinks); + +export async function mockGetContainerEntityLinks( + _downstreamContextKey?: string, + _readyToSync?: boolean, + upstreamContainerKey?: string, +): ReturnType { + const thisMock = mockGetContainerEntityLinks; + switch (upstreamContainerKey) { + case thisMock.unitKey: return thisMock.unitResponse; + case thisMock.subsectionKey: return thisMock.subsectionResponse; + case thisMock.sectionKey: return thisMock.sectionResponse; + default: return []; + } +} +mockGetContainerEntityLinks.unitKey = mockGetContainerMetadata.unitId; +mockGetContainerEntityLinks.unitResponse = [ + { + id: 1, + upstreamContextTitle: 'CS problems 3', + upstreamVersion: 1, + readyToSync: false, + upstreamContainerKey: mockGetContainerEntityLinks.unitKey, + upstreamContextKey: 'lib:Axim:TEST2', + downstreamUsageKey: 'some-key', + downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', + versionSynced: 1, + versionDeclined: null, + created: '2025-02-08T14:07:05.588484Z', + updated: '2025-02-08T14:07:05.588484Z', + }, + { + id: 1, + upstreamContextTitle: 'CS problems 3', + upstreamVersion: 1, + readyToSync: false, + upstreamContainerKey: mockGetContainerEntityLinks.unitKey, + upstreamContextKey: 'lib:Axim:TEST2', + downstreamUsageKey: 'some-key-1', + downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', + versionSynced: 1, + versionDeclined: null, + created: '2025-02-08T14:07:05.588484Z', + updated: '2025-02-08T14:07:05.588484Z', + }, +]; +mockGetContainerEntityLinks.subsectionKey = mockGetContainerMetadata.subsectionId; +mockGetContainerEntityLinks.subsectionResponse = [ + { + id: 1, + upstreamContextTitle: 'CS problems 3', + upstreamVersion: 1, + readyToSync: false, + upstreamContainerKey: mockGetContainerEntityLinks.subsectionKey, + upstreamContextKey: 'lib:Axim:TEST2', + downstreamUsageKey: 'some-subsection-key', + downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', + versionSynced: 1, + versionDeclined: null, + created: '2025-02-08T14:07:05.588484Z', + updated: '2025-02-08T14:07:05.588484Z', + }, + { + id: 1, + upstreamContextTitle: 'CS problems 3', + upstreamVersion: 1, + readyToSync: false, + upstreamContainerKey: mockGetContainerEntityLinks.subsectionKey, + upstreamContextKey: 'lib:Axim:TEST2', + downstreamUsageKey: 'some-subsection-key-1', + downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', + versionSynced: 1, + versionDeclined: null, + created: '2025-02-08T14:07:05.588484Z', + updated: '2025-02-08T14:07:05.588484Z', + }, +]; +mockGetContainerEntityLinks.sectionKey = mockGetContainerMetadata.sectionId; +mockGetContainerEntityLinks.sectionResponse = [ + { + id: 1, + upstreamContextTitle: 'CS problems 3', + upstreamVersion: 1, + readyToSync: false, + upstreamContainerKey: mockGetContainerEntityLinks.sectionKey, + upstreamContextKey: 'lib:Axim:TEST2', + downstreamUsageKey: 'some-section-key', + downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', + versionSynced: 1, + versionDeclined: null, + created: '2025-02-08T14:07:05.588484Z', + updated: '2025-02-08T14:07:05.588484Z', + }, + { + id: 1, + upstreamContextTitle: 'CS problems 3', + upstreamVersion: 1, + readyToSync: false, + upstreamContainerKey: mockGetContainerEntityLinks.sectionKey, + upstreamContextKey: 'lib:Axim:TEST2', + downstreamUsageKey: 'some-section-key-1', + downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX', + versionSynced: 1, + versionDeclined: null, + created: '2025-02-08T14:07:05.588484Z', + updated: '2025-02-08T14:07:05.588484Z', + }, +]; + +mockGetContainerEntityLinks.applyMock = () => jest.spyOn( + courseLibApi, + 'getContainerEntityLinks', +).mockImplementation(mockGetContainerEntityLinks); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 168117672d..2dc81e980e 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -876,11 +876,22 @@ export const usePublishContainer = (containerId: string) => { */ export const useContentFromSearchIndex = (contentIds: string[]) => { const { client, indexName } = useContentSearchConnection(); + const extraFilter = [`usage_key IN ["${contentIds.join('","')}"]`]; + // NOTE: assuming that all contentIds are part of a single libraryId as we don't have a usecase + // of passing multiple contentIds from different libraries. + if (contentIds.length > 0) { + try { + const libraryId = getLibraryId(contentIds?.[0]); + extraFilter.push(`context_key = "${libraryId}"`); + } catch { + // Ignore as the contentIds could be part of course instead of a library. + } + } return useContentSearchResults({ client, indexName, searchKeywords: '', - extraFilter: [`usage_key IN ["${contentIds.join('","')}"]`], + extraFilter, limit: contentIds.length, enabled: !!contentIds.length, skipBlockTypeFetch: true, diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 1de9533738..766d5bd391 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -1,5 +1,6 @@ @import "./component-info/ComponentPreview"; @import "./components"; +@import "./containers"; @import "./generic"; @import "./LibraryAuthoringPage"; @import "./units"; diff --git a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx index c0b2010ec5..930974c278 100644 --- a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx +++ b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx @@ -22,10 +22,10 @@ import containerMessages from '../containers/messages'; import { Container } from '../data/api'; import { ToastContext } from '../../generic/toast-context'; import TagCount from '../../generic/tag-count'; -import { ContainerMenu } from '../components/ContainerCard'; import { useLibraryRoutes } from '../routes'; import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext'; import { useRunOnNextRender } from '../../utils'; +import { ContainerMenu } from '../containers/ContainerCard'; interface LibraryContainerChildrenProps { containerKey: string; @@ -111,10 +111,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps) onClick={readOnly ? undefined : jumpToManageTags} /> {!readOnly && ( - + )}