diff --git a/src/library-authoring/component-info/ComponentUsage.tsx b/src/library-authoring/component-info/ComponentUsage.tsx index 13e59565f..f3b0d4604 100644 --- a/src/library-authoring/component-info/ComponentUsage.tsx +++ b/src/library-authoring/component-info/ComponentUsage.tsx @@ -6,8 +6,8 @@ import { useEntityLinks } from '../../course-libraries/data/apiHooks'; import AlertError from '../../generic/alert-error'; import Loading from '../../generic/Loading'; -import { useContentSearchConnection, useContentSearchResults } from '../../search-manager'; import messages from './messages'; +import { useComponentsFromSearchIndex } from '../data/apiHooks'; interface ComponentUsageProps { usageKey: string; @@ -41,21 +41,12 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => { [dataDownstreamLinks], ); - const { client, indexName } = useContentSearchConnection(); const { hits: downstreamHits, isError: isErrorIndexDocuments, error: errorIndexDocuments, isLoading: isLoadingIndexDocuments, - } = useContentSearchResults({ - client, - indexName, - searchKeywords: '', - extraFilter: [`usage_key IN ["${downstreamKeys.join('","')}"]`], - limit: downstreamKeys.length, - enabled: !!downstreamKeys.length, - skipBlockTypeFetch: true, - }); + } = useComponentsFromSearchIndex(downstreamKeys); if (isErrorDownstreamLinks || isErrorIndexDocuments) { return ; diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx index e0e2c44e2..dd56c30f8 100644 --- a/src/library-authoring/components/ComponentCard.test.tsx +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -43,6 +43,7 @@ const contentHit: ContentHit = { modified: 1722434322294, lastPublished: null, collections: {}, + units: {}, publishStatus: PublishStatus.Published, }; diff --git a/src/library-authoring/components/ComponentDeleter.test.tsx b/src/library-authoring/components/ComponentDeleter.test.tsx index 315228616..f6af3d4a7 100644 --- a/src/library-authoring/components/ComponentDeleter.test.tsx +++ b/src/library-authoring/components/ComponentDeleter.test.tsx @@ -11,9 +11,11 @@ import { mockContentLibrary, mockDeleteLibraryBlock, mockLibraryBlockMetadata, mockRestoreLibraryBlock, } from '../data/api.mocks'; import ComponentDeleter from './ComponentDeleter'; +import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock'; mockContentLibrary.applyMock(); // Not required, but avoids 404 errors in the logs when loads data mockLibraryBlockMetadata.applyMock(); +mockContentSearchConfig.applyMock(); const mockDelete = mockDeleteLibraryBlock.applyMock(); const mockRestore = mockRestoreLibraryBlock.applyMock(); @@ -29,6 +31,13 @@ describe('', () => { beforeEach(() => { const mocks = initializeMocks(); mockShowToast = mocks.mockShowToast; + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [], + }, + ], + }); }); it('is invisible when isConfirmingDelete is false', async () => { @@ -77,4 +86,68 @@ describe('', () => { expect(mockShowToast).toHaveBeenCalledWith('Undo successful'); }); }); + + it('should show units message if `unitsData` is set with one unit', async () => { + const mockCancel = jest.fn(); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [{ + units: { + displayName: ['Unit 1'], + key: ['unit1'], + }, + }], + }, + ], + }); + render( + , + renderArgs, + ); + + const modal = await screen.findByRole('dialog', { name: 'Delete Component' }); + expect(modal).toBeVisible(); + + expect(await screen.findByText( + /by deleting this component, you will also be deleting it from in this library\./i, + )).toBeInTheDocument(); + expect(screen.getByText(/unit 1/i)).toBeInTheDocument(); + }); + + it('should show units message if `unitsData` is set with multiple units', async () => { + const mockCancel = jest.fn(); + mockSearchResult({ + results: [ // @ts-ignore + { + hits: [{ + units: { + displayName: ['Unit 1', 'Unit 2'], + key: ['unit1', 'unit2'], + }, + }], + }, + ], + }); + render( + , + renderArgs, + ); + + const modal = await screen.findByRole('dialog', { name: 'Delete Component' }); + expect(modal).toBeVisible(); + + expect(await screen.findByText( + /by deleting this component, you will also be deleting it from in this library\./i, + )).toBeInTheDocument(); + expect(screen.getByText(/2 units/i)).toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/components/ComponentDeleter.tsx b/src/library-authoring/components/ComponentDeleter.tsx index c48dedec1..629795ad0 100644 --- a/src/library-authoring/components/ComponentDeleter.tsx +++ b/src/library-authoring/components/ComponentDeleter.tsx @@ -1,13 +1,19 @@ import React, { useCallback, useContext } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Icon } from '@openedx/paragon'; -import { CalendarViewDay, School, Warning } from '@openedx/paragon/icons'; +import { School, Warning, Info } from '@openedx/paragon/icons'; import { useSidebarContext } from '../common/context/SidebarContext'; -import { useDeleteLibraryBlock, useLibraryBlockMetadata, useRestoreLibraryBlock } from '../data/apiHooks'; +import { + useComponentsFromSearchIndex, + useDeleteLibraryBlock, + useLibraryBlockMetadata, + useRestoreLibraryBlock, +} from '../data/apiHooks'; import messages from './messages'; import { ToastContext } from '../../generic/toast-context'; import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import { type ContentHit } from '../../search-manager'; /** * Helper component to load and display the name of the block. @@ -63,22 +69,44 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => { } }, [usageKey, sidebarComponentUsageKey, closeLibrarySidebar]); + const { hits } = useComponentsFromSearchIndex([usageKey]); + const componentHit = (hits as ContentHit[])?.[0]; + if (!props.isConfirmingDelete) { return null; } + let unitsMessage; + const unitsLength = componentHit?.units?.displayName?.length ?? 0; + if (unitsLength === 1) { + unitsMessage = componentHit?.units?.displayName?.[0]; + } else if (unitsLength > 1) { + unitsMessage = `${unitsLength} units`; + } + const deleteText = intl.formatMessage(messages.deleteComponentConfirm, { componentName: , message: ( <>
- {intl.formatMessage(messages.deleteComponentConfirmMsg1)} -
-
- - {intl.formatMessage(messages.deleteComponentConfirmMsg2)} + {unitsMessage + ? intl.formatMessage(messages.deleteComponentConfirmCourseSmall) + : intl.formatMessage(messages.deleteComponentConfirmCourse)}
+ {unitsMessage && ( +
+ +
+ {unitsMessage}, + }} + /> +
+
+ )} ), }); diff --git a/src/library-authoring/components/ComponentMenu.tsx b/src/library-authoring/components/ComponentMenu.tsx index ba3711711..b5bd16749 100644 --- a/src/library-authoring/components/ComponentMenu.tsx +++ b/src/library-authoring/components/ComponentMenu.tsx @@ -148,7 +148,13 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { - + {isConfirmingDelete && ( + + )} ); }; diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 8248b4f43..e107eedd2 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -71,15 +71,20 @@ const messages = defineMessages({ defaultMessage: 'Delete {componentName}? {message}', description: 'Confirmation text to display before deleting a component', }, - deleteComponentConfirmMsg1: { + deleteComponentConfirmCourse: { id: 'course-authoring.library-authoring.component.delete-confirmation-msg-1', defaultMessage: 'If this component has been used in a course, those copies won\'t be deleted, but they will no longer receive updates from the library.', description: 'First part of confirmation message to display before deleting a component', }, - deleteComponentConfirmMsg2: { - id: 'course-authoring.library-authoring.component.delete-confirmation-msg-2', - defaultMessage: 'If this component has been used in any units, it will also be deleted from those units.', - description: 'Second part of confirmation message to display before deleting a component', + deleteComponentConfirmCourseSmall: { + id: 'course-authoring.library-authoring.component.delete-confirmation-text.course-small', + defaultMessage: 'Any course instances will stop receiving library updates.', + description: 'Small message text about courses when deleting a component', + }, + deleteComponentConfirmUnits: { + id: 'course-authoring.library-authoring.component.delete-confirmation-text.units', + defaultMessage: 'By deleting this component, you will also be deleting it from {unit} in this library.', + description: 'Message text about units when deleting a component', }, deleteComponentCancelButton: { id: 'course-authoring.library-authoring.component.cancel-delete-button', diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 65b9445ad..49b5e72c3 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -12,6 +12,7 @@ import { useCallback } from 'react'; import { getLibraryId } from '../../generic/key-utils'; import * as api from './api'; import { VersionSpec } from '../LibraryBlock'; +import { useContentSearchConnection, useContentSearchResults } from '../../search-manager'; export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => { // Invalidate all content queries related to this library. @@ -802,3 +803,19 @@ export const usePublishContainer = (containerId: string) => { }, }); }; + +/** + * Use this mutations to get a list of components from the search index + */ +export const useComponentsFromSearchIndex = (componentIds: string[]) => { + const { client, indexName } = useContentSearchConnection(); + return useContentSearchResults({ + client, + indexName, + searchKeywords: '', + extraFilter: [`usage_key IN ["${componentIds.join('","')}"]`], + limit: componentIds.length, + enabled: !!componentIds.length, + skipBlockTypeFetch: true, + }); +}; diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index e1a6aeaa3..01811883c 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -142,6 +142,7 @@ export interface ContentHit extends BaseContentHit { content?: ContentDetails; lastPublished: number | null; collections: { displayName?: string[], key?: string[] }; + units: { displayName?: string[], key?: string[] }; published?: ContentPublishedData; publishStatus: PublishStatus; formatted: BaseContentHit['formatted'] & { published?: ContentPublishedData, };