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:
@@ -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} />;
|
||||
|
||||
@@ -43,6 +43,7 @@ const contentHit: ContentHit = {
|
||||
modified: 1722434322294,
|
||||
lastPublished: null,
|
||||
collections: {},
|
||||
units: {},
|
||||
publishStatus: PublishStatus.Published,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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, };
|
||||
|
||||
Reference in New Issue
Block a user