feat: New confirmation modal when deleting library components (#1906)

* feat: New units message on delete confirm modal when deleting components
* test: Tests for new delete confirmation messages
* refactor: useComponentsFromSearchIndex added
* refactor: Move fetch units to ComponentDeleter
* style: Delete unnecessary code
This commit is contained in:
Chris Chávez
2025-05-21 12:04:19 -05:00
committed by GitHub
parent e212e1a1ef
commit 0e40aa295d
8 changed files with 146 additions and 24 deletions

View File

@@ -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 <AlertError error={errorDownstreamLinks || errorIndexDocuments} />;

View File

@@ -43,6 +43,7 @@ const contentHit: ContentHit = {
modified: 1722434322294,
lastPublished: null,
collections: {},
units: {},
publishStatus: PublishStatus.Published,
};

View File

@@ -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 <LibraryProvider> loads data
mockLibraryBlockMetadata.applyMock();
mockContentSearchConfig.applyMock();
const mockDelete = mockDeleteLibraryBlock.applyMock();
const mockRestore = mockRestoreLibraryBlock.applyMock();
@@ -29,6 +31,13 @@ describe('<ComponentDeleter />', () => {
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('<ComponentDeleter />', () => {
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(
<ComponentDeleter
usageKey={usageKey}
isConfirmingDelete
cancelDelete={mockCancel}
/>,
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(
<ComponentDeleter
usageKey={usageKey}
isConfirmingDelete
cancelDelete={mockCancel}
/>,
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();
});
});

View File

@@ -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: <b><BlockName usageKey={usageKey} /></b>,
message: (
<>
<div className="d-flex mt-2">
<Icon className="mr-2" src={School} />
{intl.formatMessage(messages.deleteComponentConfirmMsg1)}
</div>
<div className="d-flex mt-2">
<Icon className="mr-2" src={CalendarViewDay} />
{intl.formatMessage(messages.deleteComponentConfirmMsg2)}
{unitsMessage
? intl.formatMessage(messages.deleteComponentConfirmCourseSmall)
: intl.formatMessage(messages.deleteComponentConfirmCourse)}
</div>
{unitsMessage && (
<div className="d-flex mt-3 small text-danger-900">
<Icon className="mr-2 mt-2" src={Info} />
<div>
<FormattedMessage
{...messages.deleteComponentConfirmUnits}
values={{
unit: <strong>{unitsMessage}</strong>,
}}
/>
</div>
</div>
)}
</>
),
});

View File

@@ -148,7 +148,13 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
<FormattedMessage {...messages.menuAddToCollection} />
</Dropdown.Item>
</Dropdown.Menu>
<ComponentDeleter usageKey={usageKey} isConfirmingDelete={isConfirmingDelete} cancelDelete={cancelDelete} />
{isConfirmingDelete && (
<ComponentDeleter
usageKey={usageKey}
isConfirmingDelete={isConfirmingDelete}
cancelDelete={cancelDelete}
/>
)}
</Dropdown>
);
};

View File

@@ -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',

View File

@@ -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,
});
};

View File

@@ -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, };