From 4ae73f9cbc0435023c744bb7cecb488cabe76b51 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 13 Jun 2025 18:40:44 +0200 Subject: [PATCH 01/15] feat: container delete modal --- src/course-libraries/data/api.ts | 18 ++++ src/course-libraries/data/apiHooks.ts | 35 ++++++- .../components/ContainerCard.tsx | 14 +-- .../components/ContainerDeleter.tsx | 94 +++++++++++++++---- src/library-authoring/components/messages.ts | 43 +++++++-- 5 files changed, 170 insertions(+), 34 deletions(-) diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index af8108c534..10535a1f35 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`; @@ -58,6 +59,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..a045ed7531 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -1,15 +1,16 @@ 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 +23,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 +67,30 @@ 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/library-authoring/components/ContainerCard.tsx b/src/library-authoring/components/ContainerCard.tsx index 3468842b5d..2352458d69 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/components/ContainerCard.tsx @@ -100,12 +100,14 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps - + {isConfirmingDelete && ( + + )} ); }; diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx index 5d2e7b03a5..896af856d2 100644 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -1,13 +1,16 @@ -import { useCallback, useContext } from 'react'; +import { useCallback, useContext, useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; -import { Warning, School, Widgets } from '@openedx/paragon/icons'; +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 { useDeleteContainer, useRestoreContainer } from '../data/apiHooks'; +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'; type ContainerDeleterProps = { isOpen: boolean, @@ -30,24 +33,77 @@ const ContainerDeleter = ({ const deleteContainerMutation = useDeleteContainer(containerId); const restoreContainerMutation = useRestoreContainer(containerId); const { showToast } = useContext(ToastContext); + const { hits } = useContentFromSearchIndex([containerId]); + const containerData = (hits as ContainerHit[])?.[0]; + const { data: dataDownstreamLinks, isLoading, isError } = useContainerEntityLinks({ upstreamContainerKey: containerId }); + const downstreamCount = dataDownstreamLinks?.length ?? 0; + + const messageMap = useMemo(() => { + const containerType = containerData?.blockType; + switch (containerType) { + case ContainerType.Section: + return { + title: intl.formatMessage(messages.deleteSectionWarningTitle), + parentCount: 0, + parentNames: '', + parentMessage: {}, + // Update below fields when sections are linked to courses + courseCount: downstreamCount, + courseMessage: messages.deleteSectionCourseMessaage, + } + case ContainerType.Subsection: + return { + title: intl.formatMessage(messages.deleteSubsectionWarningTitle), + parentCount: containerData?.sections?.displayName?.length || 0, + parentNames: containerData?.sections?.displayName?.join(', '), + parentMessage: messages.deleteSubsectionParentMessage, + // Update below fields when subsections are linked to courses + courseCount: downstreamCount, + courseMessage: messages.deleteSubsectionCourseMessaage, + } + default: + return { + title: intl.formatMessage(messages.deleteUnitWarningTitle), + parentCount: containerData?.subsections?.displayName?.length || 0, + parentNames: containerData?.subsections?.displayName?.join(', '), + parentMessage: messages.deleteUnitParentMessage, + // Update below fields when unit are linked to courses + courseCount: downstreamCount, + courseMessage: messages.deleteUnitCourseMessage, + } + } + }, [containerId, displayName, containerData]); - // 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)} -
- +
+ {messageMap.parentCount > 0 && ( +
+ + + {intl.formatMessage(messageMap.parentMessage, { + parentNames: {messageMap.parentNames}, + parentCount: messageMap.parentCount, + })} + +
+ )} + {(messageMap.courseCount || 0) > 0 && ( +
+ + + {intl.formatMessage(messageMap.courseMessage, { + courseCount: {messageMap.courseCount}, + parentCount: messageMap.parentCount, + })} + +
+ )} +
), }); + const deleteSuccess = intl.formatMessage(messages.deleteUnitSuccess); const deleteError = intl.formatMessage(messages.deleteUnitFailed); const undoDeleteError = messages.undoDeleteUnitToastFailed; @@ -80,12 +136,16 @@ const ContainerDeleter = ({ }); }, [sidebarItemInfo, showToast, deleteContainerMutation]); + if (!isOpen || isLoading || isError) { + return null; + } + return ( Date: Fri, 13 Jun 2025 18:42:58 +0200 Subject: [PATCH 02/15] fix: lint issues --- src/course-libraries/data/apiHooks.ts | 5 +++-- .../components/ContainerDeleter.tsx | 14 +++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts index a045ed7531..506687b15d 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -6,7 +6,9 @@ import { getContainerEntityLinks, getEntityLinks, getEntityLinksSummaryByDownstr export const courseLibrariesQueryKeys = { all: ['courseLibraries'], courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId], - courseReadyToSyncLibraries: ({ courseId, readyToSync, upstreamUsageKey, upstreamContainerKey }: { + courseReadyToSyncLibraries: ({ + courseId, readyToSync, upstreamUsageKey, upstreamContainerKey, + }: { courseId?: string, readyToSync?: boolean, upstreamUsageKey?: string, @@ -93,4 +95,3 @@ export const useContainerEntityLinks = ({ enabled: courseId !== undefined || upstreamContainerKey !== undefined || readyToSync !== undefined, }) ); - diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx index 896af856d2..a64b139284 100644 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -35,7 +35,11 @@ const ContainerDeleter = ({ const { showToast } = useContext(ToastContext); const { hits } = useContentFromSearchIndex([containerId]); const containerData = (hits as ContainerHit[])?.[0]; - const { data: dataDownstreamLinks, isLoading, isError } = useContainerEntityLinks({ upstreamContainerKey: containerId }); + const { + data: dataDownstreamLinks, + isLoading, + isError, + } = useContainerEntityLinks({ upstreamContainerKey: containerId }); const downstreamCount = dataDownstreamLinks?.length ?? 0; const messageMap = useMemo(() => { @@ -50,7 +54,7 @@ const ContainerDeleter = ({ // Update below fields when sections are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteSectionCourseMessaage, - } + }; case ContainerType.Subsection: return { title: intl.formatMessage(messages.deleteSubsectionWarningTitle), @@ -60,7 +64,7 @@ const ContainerDeleter = ({ // Update below fields when subsections are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteSubsectionCourseMessaage, - } + }; default: return { title: intl.formatMessage(messages.deleteUnitWarningTitle), @@ -70,14 +74,14 @@ const ContainerDeleter = ({ // Update below fields when unit are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteUnitCourseMessage, - } + }; } }, [containerId, displayName, containerData]); const deleteText = intl.formatMessage(messages.deleteUnitConfirm, { unitName: {displayName}, message: ( -
+
{messageMap.parentCount > 0 && (
From 40fbd893c29ce0f1fba24c9d72bebf65d07bb439 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Sat, 14 Jun 2025 15:09:14 +0200 Subject: [PATCH 03/15] refactor: container delete messaging --- .../components/ContainerDeleter.tsx | 51 ++++++++++++------- src/library-authoring/components/messages.ts | 20 ++++++-- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx index a64b139284..7ae73da41d 100644 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -44,53 +44,66 @@ const ContainerDeleter = ({ 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), - parentCount: 0, - parentNames: '', - parentMessage: {}, + parentMessage: '', // Update below fields when sections are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteSectionCourseMessaage, }; 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), - parentCount: containerData?.sections?.displayName?.length || 0, - parentNames: containerData?.sections?.displayName?.join(', '), - parentMessage: messages.deleteSubsectionParentMessage, + parentMessage, // Update below fields when subsections are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteSubsectionCourseMessaage, }; 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), - parentCount: containerData?.subsections?.displayName?.length || 0, - parentNames: containerData?.subsections?.displayName?.join(', '), - parentMessage: messages.deleteUnitParentMessage, + parentMessage, // Update below fields when unit are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteUnitCourseMessage, }; } - }, [containerId, displayName, containerData]); + }, [containerData, downstreamCount, messages, intl]); const deleteText = intl.formatMessage(messages.deleteUnitConfirm, { unitName: {displayName}, message: (
- {messageMap.parentCount > 0 && ( + {messageMap.parentMessage && (
- - {intl.formatMessage(messageMap.parentMessage, { - parentNames: {messageMap.parentNames}, - parentCount: messageMap.parentCount, - })} - + {messageMap.parentMessage}
)} {(messageMap.courseCount || 0) > 0 && ( @@ -98,8 +111,8 @@ const ContainerDeleter = ({ {intl.formatMessage(messageMap.courseMessage, { - courseCount: {messageMap.courseCount}, - parentCount: messageMap.parentCount, + courseCount: messageMap.courseCount, + courseCountText: {messageMap.courseCount}, })}
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index ae2c3ba406..ad6a0fb1ad 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -218,27 +218,37 @@ const messages = defineMessages({ }, deleteUnitParentMessage: { id: 'course-authoring.library-authoring.unit.delete-parent-message', - defaultMessage: 'By deleting this unit, you will also be deleting it from {parentNames} in this library.', + defaultMessage: 'By deleting this unit, you will also be deleting it from {parentName} in this library.', description: 'Parent usage details shown before deleting a unit', }, deleteSubsectionParentMessage: { id: 'course-authoring.library-authoring.subsection.delete-parent-message', - defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentNames} in this library.', + defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentName} in this library.', description: 'Parent usage details shown before deleting a subsection', }, + deleteUnitMultipleParentMessage: { + id: 'course-authoring.library-authoring.unit.delete-multiple-parent-message', + defaultMessage: 'By deleting this unit, you will also be deleting it from {parentCount, plural, one {1 Subsection} other {{parentCount} Subsections}} in this library.', + description: 'Parent usage details shown before deleting a unit part of multiple subsections', + }, + deleteSubsectionMultipleParentMessage: { + id: 'course-authoring.library-authoring.subsection.delete-multiple-parent-message', + defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentCount, plural, one {1 Section} other {{parentCount} Sections}} in this library.', + description: 'Parent usage details shown before deleting a subsection part of multiple sections', + }, deleteUnitCourseMessage: { id: 'course-authoring.library-authoring.unit.delete-course-usage-message', - defaultMessage: 'This unit is used {courseCount} times in courses, and will stop receiving updates there.', + defaultMessage: 'This unit is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', description: 'Course usage details shown before deleting a unit', }, deleteSubsectionCourseMessaage: { id: 'course-authoring.library-authoring.subsection.delete-parent-message', - defaultMessage: 'This subsection is used {courseCount} times in courses, and will stop receiving updates there.', + defaultMessage: 'This subsection is used {courseCount, plural, one {1 time} other {{courseCount} times}} in courses, and will stop receiving updates there.', description: 'Course usage details shown before deleting a subsection', }, deleteSectionCourseMessaage: { id: 'course-authoring.library-authoring.section.delete-parent-message', - defaultMessage: 'This section is used {courseCount} times in courses, and will stop receiving updates there.', + defaultMessage: 'This section is used {courseCount, plural, one {1 time} other {{courseCount} times}} in courses, and will stop receiving updates there.', description: 'Course usage details shown before deleting a section', }, deleteUnitSuccess: { From dace6e22c26d6f7ec53c269c1e5be83415540ff8 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Sat, 14 Jun 2025 17:17:53 +0200 Subject: [PATCH 04/15] test: container deleter modal --- src/course-libraries/data/api.ts | 13 +- src/generic/delete-modal/DeleteModal.tsx | 2 +- .../components/ContainerDeleter.test.tsx | 205 ++++++++++++++++++ .../components/ContainerDeleter.tsx | 6 +- src/library-authoring/data/api.mocks.ts | 138 +++++++++++- 5 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 src/library-authoring/components/ContainerDeleter.test.tsx diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index 10535a1f35..b503731409 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -19,9 +19,8 @@ export interface PaginatedData { results: T, } -export interface PublishableEntityLink { +export interface BasePublishableEntityLink { id: number; - upstreamUsageKey: string; upstreamContextKey: string; upstreamContextTitle: string; upstreamVersion: number; @@ -34,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; @@ -63,7 +70,7 @@ export const getContainerEntityLinks = async ( downstreamContextKey?: string, readyToSync?: boolean, upstreamContainerKey?: string, -): Promise => { +): Promise => { const { data } = await getAuthenticatedHttpClient() .get(getContainerEntityLinksByDownstreamContextUrl(), { params: { 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/components/ContainerDeleter.test.tsx b/src/library-authoring/components/ContainerDeleter.test.tsx new file mode 100644 index 0000000000..c1cddb139c --- /dev/null +++ b/src/library-authoring/components/ContainerDeleter.test.tsx @@ -0,0 +1,205 @@ +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 }], + }, + ], + }); + }); + + 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 subsection 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/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx index 7ae73da41d..bc3ed0b6ff 100644 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -37,8 +37,6 @@ const ContainerDeleter = ({ const containerData = (hits as ContainerHit[])?.[0]; const { data: dataDownstreamLinks, - isLoading, - isError, } = useContainerEntityLinks({ upstreamContainerKey: containerId }); const downstreamCount = dataDownstreamLinks?.length ?? 0; @@ -153,13 +151,13 @@ const ContainerDeleter = ({ }); }, [sidebarItemInfo, showToast, deleteContainerMutation]); - if (!isOpen || isLoading || isError) { + if (!isOpen) { return null; } return ( ( 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); From 17f90a9502ef330d8c703787afcc4f7730c6b697 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 14:58:25 +0200 Subject: [PATCH 05/15] fix: show loading spinner in delete modal --- src/library-authoring/components/ContainerDeleter.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx index bc3ed0b6ff..d9fb7db9d6 100644 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -11,6 +11,7 @@ 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, @@ -33,10 +34,11 @@ const ContainerDeleter = ({ const deleteContainerMutation = useDeleteContainer(containerId); const restoreContainerMutation = useRestoreContainer(containerId); const { showToast } = useContext(ToastContext); - const { hits } = useContentFromSearchIndex([containerId]); + const { hits, isLoading } = useContentFromSearchIndex([containerId]); const containerData = (hits as ContainerHit[])?.[0]; const { data: dataDownstreamLinks, + isLoading: linksIsLoading, } = useContainerEntityLinks({ upstreamContainerKey: containerId }); const downstreamCount = dataDownstreamLinks?.length ?? 0; @@ -151,10 +153,6 @@ const ContainerDeleter = ({ }); }, [sidebarItemInfo, showToast, deleteContainerMutation]); - if (!isOpen) { - return null; - } - return ( : deleteText} onDeleteSubmit={onDelete} /> ); From cb10faa44dd76fc8330b72f2a40273d78a782e4c Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 15:01:12 +0200 Subject: [PATCH 06/15] fix: variable name in messages --- src/library-authoring/components/messages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index ad6a0fb1ad..e24b12ba46 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -243,12 +243,12 @@ const messages = defineMessages({ }, deleteSubsectionCourseMessaage: { id: 'course-authoring.library-authoring.subsection.delete-parent-message', - defaultMessage: 'This subsection is used {courseCount, plural, one {1 time} other {{courseCount} times}} in courses, and will stop receiving updates there.', + defaultMessage: 'This subsection is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', description: 'Course usage details shown before deleting a subsection', }, deleteSectionCourseMessaage: { id: 'course-authoring.library-authoring.section.delete-parent-message', - defaultMessage: 'This section is used {courseCount, plural, one {1 time} other {{courseCount} times}} in courses, and will stop receiving updates there.', + defaultMessage: 'This section is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', description: 'Course usage details shown before deleting a section', }, deleteUnitSuccess: { From 00ad09a13cbd5a64b4ebb98a17c00d998806aa0c Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 15:02:38 +0200 Subject: [PATCH 07/15] chore: remove redundant comment --- src/library-authoring/components/ContainerDeleter.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/components/ContainerDeleter.tsx index d9fb7db9d6..fb51f3e4db 100644 --- a/src/library-authoring/components/ContainerDeleter.tsx +++ b/src/library-authoring/components/ContainerDeleter.tsx @@ -51,7 +51,6 @@ const ContainerDeleter = ({ return { title: intl.formatMessage(messages.deleteSectionWarningTitle), parentMessage: '', - // Update below fields when sections are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteSectionCourseMessaage, }; @@ -70,7 +69,6 @@ const ContainerDeleter = ({ return { title: intl.formatMessage(messages.deleteSubsectionWarningTitle), parentMessage, - // Update below fields when subsections are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteSubsectionCourseMessaage, }; @@ -89,7 +87,6 @@ const ContainerDeleter = ({ return { title: intl.formatMessage(messages.deleteUnitWarningTitle), parentMessage, - // Update below fields when unit are linked to courses courseCount: downstreamCount, courseMessage: messages.deleteUnitCourseMessage, }; From 0c8f75b67fe8512aad57412e7f7148489cb01216 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 15:12:52 +0200 Subject: [PATCH 08/15] refactor: move container components under containers folder --- src/library-authoring/LibraryContent.tsx | 2 +- src/library-authoring/components/index.scss | 1 - src/library-authoring/components/messages.ts | 125 ------------------ .../ContainerCard.scss | 0 .../ContainerCard.test.tsx | 0 .../ContainerCard.tsx | 4 +- .../ContainerDeleter.test.tsx | 0 .../ContainerDeleter.tsx | 0 .../containers/ContainerInfo.tsx | 7 +- src/library-authoring/containers/index.scss | 1 + src/library-authoring/containers/messages.ts | 125 ++++++++++++++++++ src/library-authoring/index.scss | 1 + .../LibraryContainerChildren.tsx | 2 +- 13 files changed, 134 insertions(+), 134 deletions(-) rename src/library-authoring/{components => containers}/ContainerCard.scss (100%) rename src/library-authoring/{components => containers}/ContainerCard.test.tsx (100%) rename src/library-authoring/{components => containers}/ContainerCard.tsx (98%) rename src/library-authoring/{components => containers}/ContainerDeleter.test.tsx (100%) rename src/library-authoring/{components => containers}/ContainerDeleter.tsx (100%) create mode 100644 src/library-authoring/containers/index.scss 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/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 e24b12ba46..a2bb61f2cf 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -11,16 +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', - description: 'Menu item for open a collection/container.', - }, menuEdit: { id: 'course-authoring.library-authoring.component.menu.edit', defaultMessage: 'Edit', @@ -36,26 +26,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,96 +161,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', - }, - deleteSubsectionWarningTitle: { - id: 'course-authoring.library-authoring.subsection.delete-confirmation-title', - defaultMessage: 'Delete Subsection', - description: 'Title text for the warning displayed before deleting a Subsection', - }, - deleteSectionWarningTitle: { - id: 'course-authoring.library-authoring.section.delete-confirmation-title', - defaultMessage: 'Delete Section', - description: 'Title text for the warning displayed before deleting a Section', - }, - deleteUnitConfirm: { - id: 'course-authoring.library-authoring.unit.delete-confirmation-text', - defaultMessage: 'Delete {unitName}? {message}', - description: 'Confirmation text to display before deleting a unit', - }, - deleteUnitParentMessage: { - id: 'course-authoring.library-authoring.unit.delete-parent-message', - defaultMessage: 'By deleting this unit, you will also be deleting it from {parentName} in this library.', - description: 'Parent usage details shown before deleting a unit', - }, - deleteSubsectionParentMessage: { - id: 'course-authoring.library-authoring.subsection.delete-parent-message', - defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentName} in this library.', - description: 'Parent usage details shown before deleting a subsection', - }, - deleteUnitMultipleParentMessage: { - id: 'course-authoring.library-authoring.unit.delete-multiple-parent-message', - defaultMessage: 'By deleting this unit, you will also be deleting it from {parentCount, plural, one {1 Subsection} other {{parentCount} Subsections}} in this library.', - description: 'Parent usage details shown before deleting a unit part of multiple subsections', - }, - deleteSubsectionMultipleParentMessage: { - id: 'course-authoring.library-authoring.subsection.delete-multiple-parent-message', - defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentCount, plural, one {1 Section} other {{parentCount} Sections}} in this library.', - description: 'Parent usage details shown before deleting a subsection part of multiple sections', - }, - deleteUnitCourseMessage: { - id: 'course-authoring.library-authoring.unit.delete-course-usage-message', - defaultMessage: 'This unit is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', - description: 'Course usage details shown before deleting a unit', - }, - deleteSubsectionCourseMessaage: { - id: 'course-authoring.library-authoring.subsection.delete-parent-message', - defaultMessage: 'This subsection is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', - description: 'Course usage details shown before deleting a subsection', - }, - deleteSectionCourseMessaage: { - id: 'course-authoring.library-authoring.section.delete-parent-message', - defaultMessage: 'This section is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', - description: 'Course usage details shown before deleting a section', - }, - 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', @@ -311,10 +191,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 98% rename from src/library-authoring/components/ContainerCard.tsx rename to src/library-authoring/containers/ContainerCard.tsx index 2352458d69..314aee8d69 100644 --- a/src/library-authoring/components/ContainerCard.tsx +++ b/src/library-authoring/containers/ContainerCard.tsx @@ -19,11 +19,11 @@ 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; diff --git a/src/library-authoring/components/ContainerDeleter.test.tsx b/src/library-authoring/containers/ContainerDeleter.test.tsx similarity index 100% rename from src/library-authoring/components/ContainerDeleter.test.tsx rename to src/library-authoring/containers/ContainerDeleter.test.tsx diff --git a/src/library-authoring/components/ContainerDeleter.tsx b/src/library-authoring/containers/ContainerDeleter.tsx similarity index 100% rename from src/library-authoring/components/ContainerDeleter.tsx rename to src/library-authoring/containers/ContainerDeleter.tsx diff --git a/src/library-authoring/containers/ContainerInfo.tsx b/src/library-authoring/containers/ContainerInfo.tsx index 8be15f53dd..82da9f1c4f 100644 --- a/src/library-authoring/containers/ContainerInfo.tsx +++ b/src/library-authoring/containers/ContainerInfo.tsx @@ -26,11 +26,10 @@ 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 = { containerId: string, @@ -51,12 +50,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" /> - + diff --git a/src/library-authoring/containers/index.scss b/src/library-authoring/containers/index.scss new file mode 100644 index 0000000000..84de0f08fc --- /dev/null +++ b/src/library-authoring/containers/index.scss @@ -0,0 +1 @@ +@import "./ContainerCard.scss"; diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index 1dec3cafc1..778b9da128 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -56,6 +56,131 @@ const messages = defineMessages({ defaultMessage: 'Failed to update container.', description: 'Message displayed when container update fails', }, + 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.', + }, + 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', + description: 'Menu item for open a collection/container.', + }, + menuDeleteContainer: { + id: 'course-authoring.library-authoring.container.delete-menu-text', + defaultMessage: 'Delete', + description: 'Menu item to delete a container.', + }, + menuRemoveFromCollection: { + id: 'course-authoring.library-authoring.component.menu.remove', + defaultMessage: 'Remove from collection', + description: 'Menu item for remove an item from collection.', + }, + menuAddToCollection: { + id: 'course-authoring.library-authoring.component.menu.add', + defaultMessage: 'Add to collection', + description: 'Menu item for add a component to collection.', + }, + 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.', + }, + 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', + }, + deleteSectionWarningTitle: { + id: 'course-authoring.library-authoring.section.delete-confirmation-title', + defaultMessage: 'Delete Section', + description: 'Title text for the warning displayed before deleting a Section', + }, + deleteSectionCourseMessaage: { + id: 'course-authoring.library-authoring.section.delete-parent-message', + defaultMessage: 'This section is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', + description: 'Course usage details shown before deleting a section', + }, + deleteSubsectionParentMessage: { + id: 'course-authoring.library-authoring.subsection.delete-parent-message', + defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentName} in this library.', + description: 'Parent usage details shown before deleting a subsection', + }, + deleteSubsectionMultipleParentMessage: { + id: 'course-authoring.library-authoring.subsection.delete-multiple-parent-message', + defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentCount, plural, one {1 Section} other {{parentCount} Sections}} in this library.', + description: 'Parent usage details shown before deleting a subsection part of multiple sections', + }, + deleteSubsectionWarningTitle: { + id: 'course-authoring.library-authoring.subsection.delete-confirmation-title', + defaultMessage: 'Delete Subsection', + description: 'Title text for the warning displayed before deleting a Subsection', + }, + deleteSubsectionCourseMessaage: { + id: 'course-authoring.library-authoring.subsection.delete-parent-message', + defaultMessage: 'This subsection is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', + description: 'Course usage details shown before deleting a subsection', + }, + deleteUnitParentMessage: { + id: 'course-authoring.library-authoring.unit.delete-parent-message', + defaultMessage: 'By deleting this unit, you will also be deleting it from {parentName} in this library.', + description: 'Parent usage details shown before deleting a unit', + }, + deleteUnitMultipleParentMessage: { + id: 'course-authoring.library-authoring.unit.delete-multiple-parent-message', + defaultMessage: 'By deleting this unit, you will also be deleting it from {parentCount, plural, one {1 Subsection} other {{parentCount} Subsections}} in this library.', + description: 'Parent usage details shown before deleting a unit part of multiple subsections', + }, + deleteUnitWarningTitle: { + id: 'course-authoring.library-authoring.unit.delete-confirmation-title', + defaultMessage: 'Delete Unit', + description: 'Title text for the warning displayed before deleting a Unit', + }, + deleteUnitCourseMessage: { + id: 'course-authoring.library-authoring.unit.delete-course-usage-message', + defaultMessage: 'This unit is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.', + description: 'Course usage details shown 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', + }, + 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', + }, + 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', + }, + undoDeleteContainerToastMessage: { + id: 'course-authoring.library-authoring.container.undo-delete-container-toast-text', + defaultMessage: 'Undo successful', + description: 'Message to display on undo delete container success', + }, + undoDeleteContainerToastAction: { + id: 'course-authoring.library-authoring.container.undo-delete-container-toast-button', + defaultMessage: 'Undo', + description: 'Toast message to undo deletion of container', + }, }); export default messages; 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..e79cecf71b 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; From d8f1820944cb9ba99cf04e47ce18dc14a68190a5 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 15:35:34 +0200 Subject: [PATCH 09/15] fix: invalidating single search query --- .../containers/ContainerCard.tsx | 1 - .../containers/ContainerDeleter.test.tsx | 11 +++++------ .../containers/ContainerDeleter.tsx | 4 +--- .../containers/ContainerInfo.tsx | 15 +++------------ src/library-authoring/data/apiHooks.ts | 8 +++++++- 5 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/library-authoring/containers/ContainerCard.tsx b/src/library-authoring/containers/ContainerCard.tsx index 314aee8d69..c135e1ba6d 100644 --- a/src/library-authoring/containers/ContainerCard.tsx +++ b/src/library-authoring/containers/ContainerCard.tsx @@ -105,7 +105,6 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps isOpen={isConfirmingDelete} close={cancelDelete} containerId={containerKey} - displayName={displayName} /> )} diff --git a/src/library-authoring/containers/ContainerDeleter.test.tsx b/src/library-authoring/containers/ContainerDeleter.test.tsx index c1cddb139c..509515f3d8 100644 --- a/src/library-authoring/containers/ContainerDeleter.test.tsx +++ b/src/library-authoring/containers/ContainerDeleter.test.tsx @@ -64,7 +64,10 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo mockSearchResult({ results: [ // @ts-ignore { - hits: [{ blockType: context }], + hits: [{ + blockType: context, + displayName: `Test ${context}`, + }], }, ], }); @@ -76,7 +79,6 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo render(, renderArgs); @@ -88,7 +90,6 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo const mockCancel = jest.fn(); const { containerId } = getContainerDetails(context); render( 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); + render(, renderArgs); const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') }); expect(modal).toBeVisible(); @@ -152,7 +153,6 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo render( , @@ -188,7 +188,6 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo render( , diff --git a/src/library-authoring/containers/ContainerDeleter.tsx b/src/library-authoring/containers/ContainerDeleter.tsx index fb51f3e4db..d53e241f07 100644 --- a/src/library-authoring/containers/ContainerDeleter.tsx +++ b/src/library-authoring/containers/ContainerDeleter.tsx @@ -17,14 +17,12 @@ type ContainerDeleterProps = { isOpen: boolean, close: () => void, containerId: string, - displayName: string, }; const ContainerDeleter = ({ isOpen, close, containerId, - displayName, }: ContainerDeleterProps) => { const intl = useIntl(); const { @@ -94,7 +92,7 @@ const ContainerDeleter = ({ }, [containerData, downstreamCount, messages, intl]); const deleteText = intl.formatMessage(messages.deleteUnitConfirm, { - unitName: {displayName}, + unitName: {containerData?.displayName}, message: (
{messageMap.parentMessage && ( diff --git a/src/library-authoring/containers/ContainerInfo.tsx b/src/library-authoring/containers/ContainerInfo.tsx index 82da9f1c4f..76b13b4b91 100644 --- a/src/library-authoring/containers/ContainerInfo.tsx +++ b/src/library-authoring/containers/ContainerInfo.tsx @@ -31,12 +31,11 @@ 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); @@ -63,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) { @@ -166,10 +160,7 @@ const ContainerInfo = () => { )} {showOpenButton && ( - + )}
{ */ export const useContentFromSearchIndex = (contentIds: string[]) => { const { client, indexName } = useContentSearchConnection(); + let libraryId: string | undefined; + // 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) { + libraryId = getLibraryId(contentIds?.[0]); + } return useContentSearchResults({ client, indexName, searchKeywords: '', - extraFilter: [`usage_key IN ["${contentIds.join('","')}"]`], + extraFilter: [`usage_key IN ["${contentIds.join('","')}"]`, `context_key = "${libraryId}"`], limit: contentIds.length, enabled: !!contentIds.length, skipBlockTypeFetch: true, From 448f0adb8abac17146ae4431ca95237668e57d77 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 15:38:36 +0200 Subject: [PATCH 10/15] chore: fix lint issues --- src/library-authoring/containers/ContainerCard.tsx | 8 ++------ .../containers/ContainerDeleter.test.tsx | 2 +- .../section-subsections/LibraryContainerChildren.tsx | 5 +---- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/library-authoring/containers/ContainerCard.tsx b/src/library-authoring/containers/ContainerCard.tsx index c135e1ba6d..483393d5ed 100644 --- a/src/library-authoring/containers/ContainerCard.tsx +++ b/src/library-authoring/containers/ContainerCard.tsx @@ -27,10 +27,9 @@ 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 { @@ -263,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 index 509515f3d8..1d827549fc 100644 --- a/src/library-authoring/containers/ContainerDeleter.test.tsx +++ b/src/library-authoring/containers/ContainerDeleter.test.tsx @@ -110,7 +110,7 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo 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); + render(, renderArgs); const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') }); expect(modal).toBeVisible(); diff --git a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx index e79cecf71b..930974c278 100644 --- a/src/library-authoring/section-subsections/LibraryContainerChildren.tsx +++ b/src/library-authoring/section-subsections/LibraryContainerChildren.tsx @@ -111,10 +111,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps) onClick={readOnly ? undefined : jumpToManageTags} /> {!readOnly && ( - + )} From 3515dfa9f24a3a97a62ff5af4cd2a044d01dc6ed Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 15:42:14 +0200 Subject: [PATCH 11/15] refactor: messages --- src/library-authoring/containers/ContainerDeleter.test.tsx | 2 +- src/library-authoring/containers/messages.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/containers/ContainerDeleter.test.tsx b/src/library-authoring/containers/ContainerDeleter.test.tsx index 1d827549fc..e0a9f6869f 100644 --- a/src/library-authoring/containers/ContainerDeleter.test.tsx +++ b/src/library-authoring/containers/ContainerDeleter.test.tsx @@ -131,7 +131,7 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo }); }); - it(`<${context}> should show subsection message if parent data is set with one parent`, async () => { + 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) { diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index 778b9da128..0a803af426 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -118,7 +118,7 @@ const messages = defineMessages({ }, deleteSubsectionMultipleParentMessage: { id: 'course-authoring.library-authoring.subsection.delete-multiple-parent-message', - defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentCount, plural, one {1 Section} other {{parentCount} Sections}} in this library.', + defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentCount} Sections in this library.', description: 'Parent usage details shown before deleting a subsection part of multiple sections', }, deleteSubsectionWarningTitle: { @@ -138,7 +138,7 @@ const messages = defineMessages({ }, deleteUnitMultipleParentMessage: { id: 'course-authoring.library-authoring.unit.delete-multiple-parent-message', - defaultMessage: 'By deleting this unit, you will also be deleting it from {parentCount, plural, one {1 Subsection} other {{parentCount} Subsections}} in this library.', + defaultMessage: 'By deleting this unit, you will also be deleting it from {parentCount} Subsections in this library.', description: 'Parent usage details shown before deleting a unit part of multiple subsections', }, deleteUnitWarningTitle: { From 64716e972f7d5e1f1f452d29d5b83a451722c9a7 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 15:59:06 +0200 Subject: [PATCH 12/15] test: container info for subsection and sections --- .../containers/ContainerInfo.test.tsx | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) 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); From 7c54c7b41bcf667e0f30796d4b064f3db13fa6fd Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 16:09:42 +0200 Subject: [PATCH 13/15] fix: toast messages in delete operation --- .../containers/ContainerDeleter.tsx | 23 ++++++++------ src/library-authoring/containers/messages.ts | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/library-authoring/containers/ContainerDeleter.tsx b/src/library-authoring/containers/ContainerDeleter.tsx index d53e241f07..9dc9b9b4b9 100644 --- a/src/library-authoring/containers/ContainerDeleter.tsx +++ b/src/library-authoring/containers/ContainerDeleter.tsx @@ -51,6 +51,9 @@ const ContainerDeleter = ({ 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; @@ -69,6 +72,9 @@ const ContainerDeleter = ({ 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; @@ -87,6 +93,9 @@ const ContainerDeleter = ({ parentMessage, courseCount: downstreamCount, courseMessage: messages.deleteUnitCourseMessage, + deleteSuccess: intl.formatMessage(messages.deleteUnitSuccess), + deleteError: intl.formatMessage(messages.deleteUnitFailed), + undoDeleteError: messages.undoDeleteUnitToastFailed, }; } }, [containerData, downstreamCount, messages, intl]); @@ -116,18 +125,14 @@ const ContainerDeleter = ({ ), }); - 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)); + showToast(intl.formatMessage(messageMap.undoDeleteError)); } - }, []); + }, [messageMap]); const onDelete = useCallback(async () => { await deleteContainerMutation.mutateAsync().then(() => { @@ -135,18 +140,18 @@ const ContainerDeleter = ({ closeLibrarySidebar(); } showToast( - deleteSuccess, + messageMap.deleteSuccess, { label: intl.formatMessage(messages.undoDeleteContainerToastAction), onClick: restoreComponent, }, ); }).catch(() => { - showToast(deleteError); + showToast(messageMap.deleteError); }).finally(() => { close(); }); - }, [sidebarItemInfo, showToast, deleteContainerMutation]); + }, [sidebarItemInfo, showToast, deleteContainerMutation, messageMap]); return ( Date: Tue, 24 Jun 2025 16:24:35 +0200 Subject: [PATCH 14/15] chore: fix lint issues --- src/library-authoring/components/ComponentMenu.tsx | 9 +++++---- src/library-authoring/components/messages.ts | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) 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/messages.ts b/src/library-authoring/components/messages.ts index a2bb61f2cf..e757f33d04 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -11,6 +11,11 @@ const messages = defineMessages({ defaultMessage: 'Collection actions menu', description: 'Alt/title text for the collection card menu button.', }, + menuOpen: { + id: 'course-authoring.library-authoring.menu.open', + defaultMessage: 'Open', + description: 'Menu item for open a collection/container.', + }, menuEdit: { id: 'course-authoring.library-authoring.component.menu.edit', defaultMessage: 'Edit', From a2b30565bbffed1d612da180ecf9352ccb479614 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 24 Jun 2025 16:42:14 +0200 Subject: [PATCH 15/15] fix: library id in single index query --- src/library-authoring/data/apiHooks.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 74f3bb6a95..2dc81e980e 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -876,17 +876,22 @@ export const usePublishContainer = (containerId: string) => { */ export const useContentFromSearchIndex = (contentIds: string[]) => { const { client, indexName } = useContentSearchConnection(); - let libraryId: string | undefined; + 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) { - libraryId = getLibraryId(contentIds?.[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('","')}"]`, `context_key = "${libraryId}"`], + extraFilter, limit: contentIds.length, enabled: !!contentIds.length, skipBlockTypeFetch: true,