fix: update delete and remove modals design [FC-0097] (#2453)
Changes the Remove/Delete Component/Container dialogs according to the design. It also standardized the messages from Components and Containers.
This commit is contained in:
@@ -144,7 +144,7 @@ const ItemReviewList = ({
|
||||
|
||||
const {
|
||||
hits,
|
||||
isLoading: isIndexDataLoading,
|
||||
isPending: isIndexDataPending,
|
||||
hasError,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
@@ -243,7 +243,7 @@ const ItemReviewList = ({
|
||||
}
|
||||
}, [blockData]);
|
||||
|
||||
if (isIndexDataLoading) {
|
||||
if (isIndexDataPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
|
||||
import { getContentSearchConfigUrl } from '@src/search-manager/data/api';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
initializeMocks,
|
||||
} from '../testUtils';
|
||||
import { getContentSearchConfigUrl } from '../search-manager/data/api';
|
||||
} from '@src/testUtils';
|
||||
|
||||
import { mockContentLibrary } from './data/api.mocks';
|
||||
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
|
||||
import { LibraryProvider } from './common/context/LibraryContext';
|
||||
@@ -27,7 +28,7 @@ const data = {
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
searchKeywords: '',
|
||||
isFiltered: false,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
};
|
||||
|
||||
const returnEmptyResult = (_url: string, req) => {
|
||||
@@ -77,7 +78,7 @@ describe('<LibraryHome />', () => {
|
||||
it('should render a spinner while loading', async () => {
|
||||
mockUseSearchContext.mockReturnValue({
|
||||
...data,
|
||||
isLoading: true,
|
||||
isPending: true,
|
||||
});
|
||||
|
||||
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
|
||||
|
||||
@@ -36,7 +36,7 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isLoading,
|
||||
isPending,
|
||||
isFiltered,
|
||||
usageKey,
|
||||
} = useSearchContext();
|
||||
@@ -56,7 +56,7 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
|
||||
true,
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (totalHits === 0) {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
|
||||
data: dataDownstreamLinks,
|
||||
isError: isErrorDownstreamLinks,
|
||||
error: errorDownstreamLinks,
|
||||
isLoading: isLoadingDownstreamLinks,
|
||||
isPending: isPendingDownstreamLinks,
|
||||
} = useEntityLinks({ upstreamKey: usageKey, contentType: 'components' });
|
||||
|
||||
const downstreamKeys = useMemo(
|
||||
@@ -47,14 +47,14 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
|
||||
hits: downstreamHits,
|
||||
isError: isErrorIndexDocuments,
|
||||
error: errorIndexDocuments,
|
||||
isLoading: isLoadingIndexDocuments,
|
||||
isPending: isPendingIndexDocuments,
|
||||
} = useContentFromSearchIndex(downstreamKeys);
|
||||
|
||||
if (isErrorDownstreamLinks || isErrorIndexDocuments) {
|
||||
return <AlertError error={errorDownstreamLinks || errorIndexDocuments} />;
|
||||
}
|
||||
|
||||
if (isLoadingDownstreamLinks || (isLoadingIndexDocuments && !!downstreamKeys.length)) {
|
||||
if (isPendingDownstreamLinks || (isPendingIndexDocuments && !!downstreamKeys.length)) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@ import {
|
||||
IconButton,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
import { Delete, MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
import { type CollectionHit } from '../../search-manager';
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { type CollectionHit } from '@src/search-manager';
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import BaseCard from './BaseCard';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useDeleteCollection, useRestoreCollection } from '../data/apiHooks';
|
||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
import messages from './messages';
|
||||
|
||||
type CollectionMenuProps = {
|
||||
@@ -98,7 +98,8 @@ const CollectionMenu = ({ hit } : CollectionMenuProps) => {
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
close={closeDeleteModal}
|
||||
variant="warning"
|
||||
variant="danger"
|
||||
icon={Delete}
|
||||
category={type}
|
||||
description={intl.formatMessage(messages.deleteCollectionConfirm, {
|
||||
collectionTitle: displayName,
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { ToastActionData } from '../../generic/toast-context';
|
||||
import type { ToastActionData } from '@src/generic/toast-context';
|
||||
import { mockContentSearchConfig, mockSearchResult, hydrateSearchResult } from '@src/search-manager/data/api.mock';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../../testUtils';
|
||||
} from '@src/testUtils';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { SidebarProvider } from '../common/context/SidebarContext';
|
||||
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();
|
||||
@@ -41,28 +41,16 @@ describe('<ComponentDeleter />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
mockSearchResult({
|
||||
results: [ // @ts-ignore
|
||||
{
|
||||
hits: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('is invisible when isConfirmingDelete is false', async () => {
|
||||
const mockCancel = jest.fn();
|
||||
render(<ComponentDeleter usageKey={usageKey} isConfirmingDelete={false} cancelDelete={mockCancel} />, renderArgs);
|
||||
|
||||
const modal = screen.queryByRole('dialog', { name: 'Delete Component' });
|
||||
expect(modal).not.toBeInTheDocument();
|
||||
mockSearchResult(hydrateSearchResult([{
|
||||
displayName: 'Introduction to Testing 2',
|
||||
}]));
|
||||
});
|
||||
|
||||
it('should shows a confirmation prompt the card with title and description', async () => {
|
||||
const mockCancel = jest.fn();
|
||||
render(<ComponentDeleter usageKey={usageKey} isConfirmingDelete cancelDelete={mockCancel} />, renderArgs);
|
||||
render(<ComponentDeleter usageKey={usageKey} close={mockCancel} />, renderArgs);
|
||||
|
||||
const modal = screen.getByRole('dialog', { name: 'Delete Component' });
|
||||
const modal = await screen.findByRole('dialog', { name: 'Delete Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
// It should mention the component's name in the confirm dialog:
|
||||
@@ -76,9 +64,9 @@ describe('<ComponentDeleter />', () => {
|
||||
|
||||
it('deletes the block when confirmed, shows a toast with undo option and restores block on undo', async () => {
|
||||
const mockCancel = jest.fn();
|
||||
render(<ComponentDeleter usageKey={usageKey} isConfirmingDelete cancelDelete={mockCancel} />, renderArgs);
|
||||
render(<ComponentDeleter usageKey={usageKey} close={mockCancel} />, renderArgs);
|
||||
|
||||
const modal = screen.getByRole('dialog', { name: 'Delete Component' });
|
||||
const modal = await screen.findByRole('dialog', { name: 'Delete Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
||||
@@ -99,23 +87,17 @@ describe('<ComponentDeleter />', () => {
|
||||
|
||||
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'],
|
||||
},
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockSearchResult(hydrateSearchResult([{
|
||||
displayName: 'Introduction to Testing 2',
|
||||
units: {
|
||||
displayName: ['Unit 1'],
|
||||
key: ['unit1'],
|
||||
},
|
||||
}]));
|
||||
render(
|
||||
<ComponentDeleter
|
||||
usageKey={usageKey}
|
||||
isConfirmingDelete
|
||||
cancelDelete={mockCancel}
|
||||
close={mockCancel}
|
||||
/>,
|
||||
renderArgs,
|
||||
);
|
||||
@@ -131,23 +113,17 @@ describe('<ComponentDeleter />', () => {
|
||||
|
||||
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'],
|
||||
},
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockSearchResult(hydrateSearchResult([{
|
||||
displayName: 'Introduction to Testing 2',
|
||||
units: {
|
||||
displayName: ['Unit 1', 'Unit 2'],
|
||||
key: ['unit1', 'unit2'],
|
||||
},
|
||||
}]));
|
||||
render(
|
||||
<ComponentDeleter
|
||||
usageKey={usageKey}
|
||||
isConfirmingDelete
|
||||
cancelDelete={mockCancel}
|
||||
close={mockCancel}
|
||||
/>,
|
||||
renderArgs,
|
||||
);
|
||||
|
||||
@@ -1,48 +1,37 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { School, Warning, Info } from '@openedx/paragon/icons';
|
||||
import { School, Delete, Info } from '@openedx/paragon/icons';
|
||||
|
||||
import { useEntityLinks } from '@src/course-libraries/data/apiHooks';
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { type ContentHit } from '@src/search-manager';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import {
|
||||
useContentFromSearchIndex,
|
||||
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.
|
||||
*
|
||||
* This needs to be a separate component so that we only query the metadata of
|
||||
* the block when needed (when this is displayed), not on every card shown in
|
||||
* the search results.
|
||||
*/
|
||||
const BlockName = (props: { usageKey: string }) => {
|
||||
const { data: blockMetadata } = useLibraryBlockMetadata(props.usageKey);
|
||||
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <>{blockMetadata?.displayName}</> ?? <FormattedMessage {...messages.deleteComponentNamePlaceholder} />;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
usageKey: string;
|
||||
/** If true, show a confirmation modal that asks the user if they want to delete this component. */
|
||||
isConfirmingDelete: boolean;
|
||||
cancelDelete: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const ComponentDeleter = ({ usageKey, ...props }: Props) => {
|
||||
const ComponentDeleter = ({ usageKey, close }: Props) => {
|
||||
const intl = useIntl();
|
||||
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const { containerId: currentUnitId } = useLibraryContext();
|
||||
const sidebarComponentUsageKey = sidebarItemInfo?.id;
|
||||
|
||||
const restoreComponentMutation = useRestoreLibraryBlock();
|
||||
const { data: dataDownstreamLinks, isPending: isPendingLinks } = useEntityLinks({ upstreamKey: usageKey, contentType: 'components' });
|
||||
const downstreamCount = dataDownstreamLinks?.length ?? 0;
|
||||
|
||||
const restoreComponent = useCallback(async () => {
|
||||
try {
|
||||
await restoreComponentMutation.mutateAsync({ usageKey });
|
||||
@@ -62,40 +51,40 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => {
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
props.cancelDelete();
|
||||
close();
|
||||
// Close the sidebar if it's still open showing the deleted component:
|
||||
if (usageKey === sidebarComponentUsageKey) {
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
}, [usageKey, sidebarComponentUsageKey, closeLibrarySidebar]);
|
||||
|
||||
const { hits } = useContentFromSearchIndex([usageKey]);
|
||||
const { hits, isPending } = useContentFromSearchIndex([usageKey]);
|
||||
const componentHit = (hits as ContentHit[])?.[0];
|
||||
|
||||
if (!props.isConfirmingDelete) {
|
||||
// istanbul ignore if: loading state
|
||||
if (isPending || isPendingLinks) {
|
||||
// Only render the modal after loading
|
||||
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 currentUnitIndex = componentHit?.units?.key?.findIndex((id) => id === currentUnitId);
|
||||
const otherUnits = componentHit?.units?.displayName?.filter(
|
||||
(_, index) => index !== currentUnitIndex,
|
||||
);
|
||||
let unitsMessage: string | undefined;
|
||||
const otherUnitsLength = otherUnits?.length ?? 0;
|
||||
if (otherUnitsLength === 1) {
|
||||
unitsMessage = otherUnits?.[0];
|
||||
} else if (otherUnitsLength > 1) {
|
||||
unitsMessage = `${otherUnitsLength} Units`;
|
||||
}
|
||||
|
||||
const deleteText = intl.formatMessage(messages.deleteComponentConfirm, {
|
||||
componentName: <b><BlockName usageKey={usageKey} /></b>,
|
||||
componentName: <b>{componentHit?.displayName}</b>,
|
||||
message: (
|
||||
<>
|
||||
<div className="d-flex mt-2">
|
||||
<Icon className="mr-2" src={School} />
|
||||
{unitsMessage
|
||||
? intl.formatMessage(messages.deleteComponentConfirmCourseSmall)
|
||||
: intl.formatMessage(messages.deleteComponentConfirmCourse)}
|
||||
</div>
|
||||
<div className="text-danger-900">
|
||||
{unitsMessage && (
|
||||
<div className="d-flex mt-3 small text-danger-900">
|
||||
<div className="d-flex align-items-center mt-2">
|
||||
<Icon className="mr-2 mt-2" src={Info} />
|
||||
<div>
|
||||
<FormattedMessage
|
||||
@@ -107,17 +96,28 @@ const ComponentDeleter = ({ usageKey, ...props }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{(downstreamCount || 0) > 0 && (
|
||||
<div className="d-flex align-items-center mt-2">
|
||||
<Icon className="mr-2" src={School} />
|
||||
<span>
|
||||
{intl.formatMessage(messages.deleteComponentConfirmCourse, {
|
||||
courseCount: downstreamCount,
|
||||
courseCountText: <b>{downstreamCount}</b>,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
isOpen
|
||||
close={props.cancelDelete}
|
||||
variant="warning"
|
||||
close={close}
|
||||
variant="danger"
|
||||
title={intl.formatMessage(messages.deleteComponentWarningTitle)}
|
||||
icon={Warning}
|
||||
icon={Delete}
|
||||
description={deleteText}
|
||||
onDeleteSubmit={doDelete}
|
||||
/>
|
||||
|
||||
@@ -8,18 +8,16 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
import { useClipboard } from '@src/generic/clipboard';
|
||||
import { getBlockType } from '@src/generic/key-utils';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import {
|
||||
useAddItemsToContainer,
|
||||
useRemoveContainerChildren,
|
||||
useRemoveItemsFromCollection,
|
||||
} from '../data/apiHooks';
|
||||
import { useRemoveItemsFromCollection } from '../data/apiHooks';
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
import ComponentRemover from './ComponentRemover';
|
||||
import messages from './messages';
|
||||
import containerMessages from '../containers/messages';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
@@ -44,10 +42,9 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
|
||||
const canEdit = usageKey && canEditComponent(usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const addItemToContainerMutation = useAddItemsToContainer(containerId);
|
||||
const removeCollectionComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
|
||||
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const [isRemoveModalOpen, openRemoveModal, closeRemoveModal] = useToggle(false);
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
const updateClipboardClick = () => {
|
||||
@@ -66,32 +63,6 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromContainer = () => {
|
||||
const restoreComponent = () => {
|
||||
addItemToContainerMutation.mutateAsync([usageKey]).then(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
|
||||
});
|
||||
};
|
||||
|
||||
removeContainerItemMutation.mutateAsync([usageKey]).then(() => {
|
||||
if (sidebarItemInfo?.id === usageKey) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(
|
||||
intl.formatMessage(messages.removeComponentFromContainerSuccess),
|
||||
{
|
||||
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
openItemSidebar(usageKey, SidebarBodyItemId.ComponentInfo);
|
||||
openComponentEditor(usageKey);
|
||||
@@ -134,11 +105,11 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
<FormattedMessage {...messages.menuCopyToClipboard} />
|
||||
</Dropdown.Item>
|
||||
{containerId && (
|
||||
<Dropdown.Item onClick={removeFromContainer}>
|
||||
<Dropdown.Item onClick={openRemoveModal}>
|
||||
<FormattedMessage {...messages.removeComponentFromUnitMenu} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item onClick={confirmDelete}>
|
||||
<Dropdown.Item onClick={openDeleteModal}>
|
||||
<FormattedMessage {...messages.menuDelete} />
|
||||
</Dropdown.Item>
|
||||
{insideCollection && (
|
||||
@@ -155,11 +126,16 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
<FormattedMessage {...containerMessages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
{isConfirmingDelete && (
|
||||
{isDeleteModalOpen && (
|
||||
<ComponentDeleter
|
||||
usageKey={usageKey}
|
||||
isConfirmingDelete={isConfirmingDelete}
|
||||
cancelDelete={cancelDelete}
|
||||
close={closeDeleteModal}
|
||||
/>
|
||||
)}
|
||||
{isRemoveModalOpen && (
|
||||
<ComponentRemover
|
||||
usageKey={usageKey}
|
||||
close={closeRemoveModal}
|
||||
/>
|
||||
)}
|
||||
</Dropdown>
|
||||
|
||||
86
src/library-authoring/components/ComponentRemover.tsx
Normal file
86
src/library-authoring/components/ComponentRemover.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useContext } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Warning } from '@openedx/paragon/icons';
|
||||
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import {
|
||||
useContainer,
|
||||
useRemoveContainerChildren,
|
||||
useAddItemsToContainer,
|
||||
useLibraryBlockMetadata,
|
||||
} from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
interface Props {
|
||||
usageKey: string;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const ComponentRemover = ({ usageKey, close }: Props) => {
|
||||
const intl = useIntl();
|
||||
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
|
||||
const { containerId } = useLibraryContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
|
||||
const addItemToContainerMutation = useAddItemsToContainer(containerId);
|
||||
const { data: container, isPending: isPendingParentContainer } = useContainer(containerId);
|
||||
const { data: component, isPending } = useLibraryBlockMetadata(usageKey);
|
||||
|
||||
// istanbul ignore if: loading state
|
||||
if (isPending || isPendingParentContainer) {
|
||||
// Only show the modal when all data is ready
|
||||
return null;
|
||||
}
|
||||
|
||||
const removeFromContainer = () => {
|
||||
const restoreComponent = () => {
|
||||
addItemToContainerMutation.mutateAsync([usageKey]).then(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
|
||||
});
|
||||
};
|
||||
removeContainerItemMutation.mutateAsync([usageKey]).then(() => {
|
||||
if (sidebarItemInfo?.id === usageKey) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(
|
||||
intl.formatMessage(messages.removeComponentFromContainerSuccess),
|
||||
{
|
||||
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
|
||||
});
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const removeText = intl.formatMessage(messages.removeComponentConfirm, {
|
||||
componentName: <b>{component?.displayName}</b>,
|
||||
parentContainerName: <b>{container?.displayName}</b>,
|
||||
});
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
isOpen
|
||||
close={close}
|
||||
variant="warning"
|
||||
title={intl.formatMessage(messages.removeComponentWarningTitle)}
|
||||
icon={Warning}
|
||||
description={removeText}
|
||||
onDeleteSubmit={removeFromContainer}
|
||||
btnLabel={intl.formatMessage(messages.componentRemoveButtonLabel)}
|
||||
buttonVariant="primary"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentRemover;
|
||||
@@ -56,26 +56,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete Component',
|
||||
description: 'Title text for the warning displayed before deleting a component',
|
||||
},
|
||||
deleteComponentNamePlaceholder: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-placeholder',
|
||||
componentNamePlaceholder: {
|
||||
id: 'course-authoring.library-authoring.component.name-confirmation-placeholder',
|
||||
defaultMessage: 'this component',
|
||||
description: 'Text shown in place of the component\'s title while we\'re loading the title',
|
||||
},
|
||||
deleteComponentConfirm: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-text',
|
||||
defaultMessage: 'Delete {componentName}? {message}',
|
||||
defaultMessage: 'Are you sure you want to permanently delete {componentName}? This cannot be undone and will remove it from your library. {message}',
|
||||
description: 'Confirmation text to display before deleting a component',
|
||||
},
|
||||
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.',
|
||||
defaultMessage: 'This component is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.',
|
||||
description: 'First 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.',
|
||||
@@ -191,6 +186,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Remove from unit',
|
||||
description: 'Text of the menu item to remove a component from a unit',
|
||||
},
|
||||
removeComponentWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.component.remove-confirmation-title',
|
||||
defaultMessage: 'Remove Component',
|
||||
description: 'Title text for the warning displayed before removing a component from a container',
|
||||
},
|
||||
removeComponentConfirm: {
|
||||
id: 'course-authoring.library-authoring.component.remove-confirmation-text',
|
||||
defaultMessage: 'Are you sure you want to remove {componentName} from {parentContainerName}? Removing this component will not delete it from the library.',
|
||||
description: 'Confirmation text to display before removing a container from its parent',
|
||||
},
|
||||
removeComponentFromContainerSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.remove-from-container-success',
|
||||
defaultMessage: 'Component successfully removed',
|
||||
@@ -216,6 +221,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Failed to undo remove component operation',
|
||||
description: 'Message to display on failure to undo delete component',
|
||||
},
|
||||
componentRemoveButtonLabel: {
|
||||
id: 'course-authoring.library-authoring.component.remove-button-label',
|
||||
defaultMessage: 'Remove',
|
||||
description: 'Button to confirm removal of a component from its parent container',
|
||||
},
|
||||
containerPreviewText: {
|
||||
id: 'course-authoring.library-authoring.container.preview.text',
|
||||
defaultMessage: 'Contains {children}.',
|
||||
@@ -228,12 +238,12 @@ const messages = defineMessages({
|
||||
},
|
||||
removeContainerConfirm: {
|
||||
id: 'course-authoring.library-authoring.container.remove-confirmation-text',
|
||||
defaultMessage: 'Remove {containerName} from {parentContainerType} {parentContainerName}? Removing this {containerType} will not delete it from the library.',
|
||||
defaultMessage: 'Are you sure you want to remove {containerName} from {parentContainerName}? This will not delete the {containerType} from your library.',
|
||||
description: 'Confirmation text to display before removing a container from its parent',
|
||||
},
|
||||
removeContainerButton: {
|
||||
id: 'course-authoring.library-authoring.container.confirm-remove-button',
|
||||
defaultMessage: 'Remove {containerName}',
|
||||
defaultMessage: 'Remove',
|
||||
description: 'Button to confirm removal of a container from its parent',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { mockContentSearchConfig, mockSearchResult, hydrateSearchResult } from '@src/search-manager/data/api.mock';
|
||||
import {
|
||||
initializeMocks, render as baseRender, screen, waitFor,
|
||||
fireEvent,
|
||||
} from '../../testUtils';
|
||||
} from '@src/testUtils';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks';
|
||||
import { type ContainerHit, PublishStatus } from '../../search-manager';
|
||||
@@ -44,6 +45,7 @@ const getContainerHitSample = (containerType: ContainerType = ContainerType.Unit
|
||||
} as ContainerHit);
|
||||
|
||||
mockContentLibrary.applyMock();
|
||||
mockContentSearchConfig.applyMock();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -190,13 +192,16 @@ describe('<ContainerCard />', () => {
|
||||
it('should delete the container from the menu & restore the container', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock.onDelete(getLibraryContainerApiUrl(getContainerHitSample().usageKey)).reply(200);
|
||||
// Mock the search result to get the display name of the container on the Modal
|
||||
mockSearchResult(hydrateSearchResult([{
|
||||
displayName: 'unit Display Name',
|
||||
}]));
|
||||
|
||||
render(<ContainerCard hit={getContainerHitSample()} />);
|
||||
|
||||
// Open menu
|
||||
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId('container-card-menu-toggle'));
|
||||
|
||||
// Click on Delete Item
|
||||
const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
|
||||
expect(deleteMenuItem).toBeInTheDocument();
|
||||
@@ -241,7 +246,8 @@ describe('<ContainerCard />', () => {
|
||||
fireEvent.click(deleteMenuItem);
|
||||
|
||||
// Confirm delete Modal is open
|
||||
expect(screen.getByText('Delete Unit'));
|
||||
const modal = await screen.findByRole('dialog', { name: 'Delete Unit' });
|
||||
expect(modal).toBeVisible();
|
||||
const deleteButton = screen.getByRole('button', { name: /delete/i });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
@@ -446,7 +452,8 @@ describe('<ContainerCard />', () => {
|
||||
fireEvent.click(removeMenuItem);
|
||||
|
||||
// Confirm remove Modal is open
|
||||
expect(await screen.findByText(/will not delete it from the library/i)).toBeInTheDocument();
|
||||
const regex = new RegExp(`will not delete the ${containerType} from your library`, 'i');
|
||||
expect(await screen.findByText(regex)).toBeInTheDocument();
|
||||
const removeButton = screen.getByRole('button', { name: /remove/i });
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
|
||||
@@ -125,14 +125,12 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps
|
||||
</Dropdown>
|
||||
{isConfirmingDelete && (
|
||||
<ContainerDeleter
|
||||
isOpen={isConfirmingDelete}
|
||||
close={cancelDelete}
|
||||
containerId={containerKey}
|
||||
/>
|
||||
)}
|
||||
{isConfirmingRemove && (
|
||||
<ContainerRemover
|
||||
isOpen={isConfirmingRemove}
|
||||
close={cancelRemove}
|
||||
containerKey={containerKey}
|
||||
displayName={displayName}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '@src/testUtils';
|
||||
import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock';
|
||||
import { mockContentSearchConfig, mockSearchResult, hydrateSearchResult } from '@src/search-manager/data/api.mock';
|
||||
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { SidebarProvider } from '../common/context/SidebarContext';
|
||||
@@ -62,29 +62,10 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
mockSearchResult({
|
||||
results: [ // @ts-ignore
|
||||
{
|
||||
hits: [{
|
||||
blockType: context,
|
||||
displayName: `Test ${context}`,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it(`<${context}> is invisible when isOpen is false`, async () => {
|
||||
const mockClose = jest.fn();
|
||||
const { containerId } = getContainerDetails(context);
|
||||
render(<ContainerDeleter
|
||||
containerId={containerId}
|
||||
isOpen={false}
|
||||
close={mockClose}
|
||||
/>, renderArgs);
|
||||
|
||||
const modal = screen.queryByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') });
|
||||
expect(modal).not.toBeInTheDocument();
|
||||
mockSearchResult(hydrateSearchResult([{
|
||||
blockType: context,
|
||||
displayName: `Test ${context}`,
|
||||
}]));
|
||||
});
|
||||
|
||||
it(`<${context}> should show a confirmation prompt the card with title and description`, async () => {
|
||||
@@ -92,7 +73,6 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
||||
const { containerId } = getContainerDetails(context);
|
||||
render(<ContainerDeleter
|
||||
containerId={containerId}
|
||||
isOpen
|
||||
close={mockCancel}
|
||||
/>, renderArgs);
|
||||
|
||||
@@ -111,7 +91,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(<ContainerDeleter containerId={containerId} isOpen close={mockCancel} />, renderArgs);
|
||||
render(<ContainerDeleter containerId={containerId} close={mockCancel} />, renderArgs);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') });
|
||||
expect(modal).toBeVisible();
|
||||
@@ -138,23 +118,17 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
mockSearchResult({
|
||||
results: [ // @ts-ignore
|
||||
{
|
||||
hits: [{
|
||||
[`${parent}s`]: {
|
||||
displayName: [`${parent} 1`],
|
||||
key: [`${parent}1`],
|
||||
},
|
||||
blockType: context,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockSearchResult(hydrateSearchResult([{
|
||||
[`${parent}s`]: {
|
||||
displayName: [`${parent} 1`],
|
||||
key: [`${parent}1`],
|
||||
},
|
||||
blockType: context,
|
||||
}]));
|
||||
|
||||
render(
|
||||
<ContainerDeleter
|
||||
containerId={containerId}
|
||||
isOpen
|
||||
close={mockCancel}
|
||||
/>,
|
||||
renderArgs,
|
||||
@@ -173,23 +147,16 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
mockSearchResult({
|
||||
results: [ // @ts-ignore
|
||||
{
|
||||
hits: [{
|
||||
[`${parent}s`]: {
|
||||
displayName: [`${parent} 1`, `${parent} 2`],
|
||||
key: [`${parent}1`, `${parent}2`],
|
||||
},
|
||||
blockType: context,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockSearchResult(hydrateSearchResult([{
|
||||
[`${parent}s`]: {
|
||||
displayName: [`${parent} 1`, `${parent} 2`],
|
||||
key: [`${parent}1`, `${parent}2`],
|
||||
},
|
||||
blockType: context,
|
||||
}]));
|
||||
render(
|
||||
<ContainerDeleter
|
||||
containerId={containerId}
|
||||
isOpen
|
||||
close={mockCancel}
|
||||
/>,
|
||||
renderArgs,
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Error, Warning, School } from '@openedx/paragon/icons';
|
||||
import { Error, Delete, School } from '@openedx/paragon/icons';
|
||||
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { ContainerHit } from '@src/search-manager';
|
||||
import { type ContainerHit } from '@src/search-manager';
|
||||
import { useEntityLinks } from '@src/course-libraries/data/apiHooks';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useContentFromSearchIndex, useDeleteContainer, useRestoreContainer } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
type ContainerDeleterProps = {
|
||||
isOpen: boolean,
|
||||
close: () => void,
|
||||
containerId: string,
|
||||
};
|
||||
|
||||
type ContainerParents = {
|
||||
displayName?: string[],
|
||||
key?: string[],
|
||||
};
|
||||
|
||||
const getOtherParentContainers = (containerParents?: ContainerParents, currentParentId?: string) => {
|
||||
const currentParentIndex = containerParents?.key?.findIndex((id) => id === currentParentId);
|
||||
return containerParents?.displayName?.filter(
|
||||
(_, index) => index !== currentParentIndex,
|
||||
);
|
||||
};
|
||||
|
||||
const ContainerDeleter = ({
|
||||
isOpen,
|
||||
close,
|
||||
containerId,
|
||||
}: ContainerDeleterProps) => {
|
||||
@@ -30,20 +40,24 @@ const ContainerDeleter = ({
|
||||
sidebarItemInfo,
|
||||
closeLibrarySidebar,
|
||||
} = useSidebarContext();
|
||||
const {
|
||||
containerId: parentContainerId,
|
||||
} = useLibraryContext();
|
||||
const deleteContainerMutation = useDeleteContainer(containerId);
|
||||
const restoreContainerMutation = useRestoreContainer(containerId);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const { hits, isLoading } = useContentFromSearchIndex([containerId]);
|
||||
const { hits, isPending } = useContentFromSearchIndex([containerId]);
|
||||
const containerData = (hits as ContainerHit[])?.[0];
|
||||
const {
|
||||
data: dataDownstreamLinks,
|
||||
isLoading: linksIsLoading,
|
||||
isPending: isPendingLinks,
|
||||
} = useEntityLinks({ upstreamKey: containerId, contentType: 'containers' });
|
||||
const downstreamCount = dataDownstreamLinks?.length ?? 0;
|
||||
|
||||
const messageMap = useMemo(() => {
|
||||
const containerType = containerData?.blockType;
|
||||
let parentCount = 0;
|
||||
let otherParentContainers: string[] | undefined;
|
||||
let otherParentCount = 0;
|
||||
let parentMessage: React.ReactNode;
|
||||
switch (containerType) {
|
||||
case ContainerType.Section:
|
||||
@@ -51,42 +65,44 @@ const ContainerDeleter = ({
|
||||
title: intl.formatMessage(messages.deleteSectionWarningTitle),
|
||||
parentMessage: '',
|
||||
courseCount: downstreamCount,
|
||||
courseMessage: messages.deleteSectionCourseMessaage,
|
||||
courseMessage: messages.deleteSectionCourseMessage,
|
||||
deleteSuccess: intl.formatMessage(messages.deleteSectionSuccess),
|
||||
deleteError: intl.formatMessage(messages.deleteSectionFailed),
|
||||
undoDeleteError: messages.undoDeleteSectionToastFailed,
|
||||
};
|
||||
case ContainerType.Subsection:
|
||||
parentCount = containerData?.sections?.displayName?.length || 0;
|
||||
if (parentCount === 1) {
|
||||
otherParentContainers = getOtherParentContainers(containerData?.sections, parentContainerId);
|
||||
otherParentCount = otherParentContainers?.length || 0;
|
||||
if (otherParentCount === 1) {
|
||||
parentMessage = intl.formatMessage(
|
||||
messages.deleteSubsectionParentMessage,
|
||||
{ parentName: <b>{containerData?.sections?.displayName?.[0]}</b> },
|
||||
);
|
||||
} else if (parentCount > 1) {
|
||||
} else if (otherParentCount > 1) {
|
||||
parentMessage = intl.formatMessage(messages.deleteSubsectionMultipleParentMessage, {
|
||||
parentCount: <b>{parentCount}</b>,
|
||||
parentCount: <b>{otherParentCount}</b>,
|
||||
});
|
||||
}
|
||||
return {
|
||||
title: intl.formatMessage(messages.deleteSubsectionWarningTitle),
|
||||
parentMessage,
|
||||
courseCount: downstreamCount,
|
||||
courseMessage: messages.deleteSubsectionCourseMessaage,
|
||||
courseMessage: messages.deleteSubsectionCourseMessage,
|
||||
deleteSuccess: intl.formatMessage(messages.deleteSubsectionSuccess),
|
||||
deleteError: intl.formatMessage(messages.deleteSubsectionFailed),
|
||||
undoDeleteError: messages.undoDeleteSubsectionToastFailed,
|
||||
};
|
||||
default:
|
||||
parentCount = containerData?.subsections?.displayName?.length || 0;
|
||||
if (parentCount === 1) {
|
||||
default: // Unit
|
||||
otherParentContainers = getOtherParentContainers(containerData?.subsections, parentContainerId);
|
||||
otherParentCount = otherParentContainers?.length || 0;
|
||||
if (otherParentCount === 1) {
|
||||
parentMessage = intl.formatMessage(
|
||||
messages.deleteUnitParentMessage,
|
||||
{ parentName: <b>{containerData?.subsections?.displayName?.[0]}</b> },
|
||||
{ parentName: <b>{otherParentContainers?.[0]}</b> },
|
||||
);
|
||||
} else if (parentCount > 1) {
|
||||
} else if (otherParentCount > 1) {
|
||||
parentMessage = intl.formatMessage(messages.deleteUnitMultipleParentMessage, {
|
||||
parentCount: <b>{parentCount}</b>,
|
||||
parentCount: <b>{otherParentCount}</b>,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -101,8 +117,8 @@ const ContainerDeleter = ({
|
||||
}
|
||||
}, [containerData, downstreamCount, messages, intl]);
|
||||
|
||||
const deleteText = intl.formatMessage(messages.deleteUnitConfirm, {
|
||||
unitName: <b>{containerData?.displayName}</b>,
|
||||
const deleteText = intl.formatMessage(messages.deleteContainerConfirm, {
|
||||
containerName: <b>{containerData?.displayName}</b>,
|
||||
message: (
|
||||
<div className="text-danger-900">
|
||||
{messageMap.parentMessage && (
|
||||
@@ -154,14 +170,20 @@ const ContainerDeleter = ({
|
||||
});
|
||||
}, [sidebarItemInfo, showToast, deleteContainerMutation, messageMap]);
|
||||
|
||||
// istanbul ignore if: loading state
|
||||
if (isPending || isPendingLinks) {
|
||||
// Only render the modal after loading
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
isOpen={isOpen}
|
||||
isOpen
|
||||
close={close}
|
||||
variant="warning"
|
||||
variant="danger"
|
||||
title={messageMap?.title}
|
||||
icon={Warning}
|
||||
description={isLoading || linksIsLoading ? <LoadingSpinner size="sm" /> : deleteText}
|
||||
icon={Delete}
|
||||
description={deleteText}
|
||||
onDeleteSubmit={onDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@src/testUtils';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import type { ToastActionData } from '@src/generic/toast-context';
|
||||
import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock';
|
||||
import { mockContentSearchConfig, mockSearchResult, hydrateSearchResult } from '@src/search-manager/data/api.mock';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockGetContainerChildren,
|
||||
@@ -162,16 +162,10 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
||||
describe(`<ContainerInfo /> with containerType: ${containerType}`, () => {
|
||||
beforeEach(() => {
|
||||
({ axiosMock, mockShowToast } = initializeMocks());
|
||||
mockSearchResult({
|
||||
results: [ // @ts-ignore
|
||||
{
|
||||
hits: [{
|
||||
blockType: containerType,
|
||||
displayName: `Test ${containerType}`,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockSearchResult(hydrateSearchResult([{
|
||||
blockType: containerType,
|
||||
displayName: `Test ${containerType}`,
|
||||
}]));
|
||||
});
|
||||
|
||||
it(`should delete the ${containerType} using the menu`, async () => {
|
||||
@@ -189,7 +183,8 @@ let mockShowToast: { (message: string, action?: ToastActionData | undefined): vo
|
||||
fireEvent.click(deleteMenuItem);
|
||||
|
||||
// Confirm delete Modal is open
|
||||
expect(screen.getByText(`Delete ${containerType[0].toUpperCase()}${containerType.slice(1)}`));
|
||||
const modal = await screen.findByRole('dialog', { name: `Delete ${containerType[0].toUpperCase()}${containerType.slice(1)}` });
|
||||
expect(modal).toBeInTheDocument();
|
||||
const deleteButton = screen.getByRole('button', { name: /delete/i });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
|
||||
@@ -60,11 +60,12 @@ const ContainerMenu = ({ containerId }: ContainerPreviewProps) => {
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<ContainerDeleter
|
||||
isOpen={isConfirmingDelete}
|
||||
close={cancelDelete}
|
||||
containerId={containerId}
|
||||
/>
|
||||
{isConfirmingDelete && (
|
||||
<ContainerDeleter
|
||||
close={cancelDelete}
|
||||
containerId={containerId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Warning } from '@openedx/paragon/icons';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { getBlockType } from '@src/generic/key-utils';
|
||||
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useContainer, useRemoveContainerChildren } from '../data/apiHooks';
|
||||
import messages from '../components/messages';
|
||||
|
||||
type ContainerRemoverProps = {
|
||||
isOpen: boolean,
|
||||
close: () => void,
|
||||
containerKey: string,
|
||||
displayName: string,
|
||||
};
|
||||
|
||||
const ContainerRemover = ({
|
||||
isOpen,
|
||||
close,
|
||||
containerKey,
|
||||
displayName,
|
||||
@@ -32,7 +32,7 @@ const ContainerRemover = ({
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const removeContainerMutation = useRemoveContainerChildren(containerId);
|
||||
const { data: container } = useContainer(containerId);
|
||||
const { data: container, isPending } = useContainer(containerId);
|
||||
const itemType = getBlockType(containerKey);
|
||||
|
||||
const removeWarningTitle = intl.formatMessage(messages.removeContainerWarningTitle, {
|
||||
@@ -40,10 +40,9 @@ const ContainerRemover = ({
|
||||
});
|
||||
|
||||
const removeText = intl.formatMessage(messages.removeContainerConfirm, {
|
||||
containerName: <b>{capitalize(itemType)} {displayName}</b>,
|
||||
containerName: <b>{displayName}</b>,
|
||||
containerType: capitalize(itemType),
|
||||
parentContainerType: capitalize(container?.containerType),
|
||||
parentContainerName: container?.displayName,
|
||||
parentContainerName: <b>{container?.displayName}</b>,
|
||||
});
|
||||
|
||||
const removeSuccess = intl.formatMessage(messages.removeComponentFromContainerSuccess);
|
||||
@@ -72,18 +71,23 @@ const ContainerRemover = ({
|
||||
close,
|
||||
]);
|
||||
|
||||
// istanbul ignore if: loading state
|
||||
if (isPending) {
|
||||
// Only render when data is ready
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
isOpen={isOpen}
|
||||
isOpen
|
||||
close={close}
|
||||
title={removeWarningTitle}
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
description={removeText}
|
||||
onDeleteSubmit={onRemove}
|
||||
buttonVariant="primary"
|
||||
cancelButtonVariant="tertiary"
|
||||
btnLabel={intl.formatMessage(messages.removeContainerButton, {
|
||||
containerName: itemType.charAt(0).toUpperCase() + itemType.slice(1),
|
||||
})}
|
||||
btnLabel={intl.formatMessage(messages.removeContainerButton)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,7 +101,7 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete Section',
|
||||
description: 'Title text for the warning displayed before deleting a Section',
|
||||
},
|
||||
deleteSectionCourseMessaage: {
|
||||
deleteSectionCourseMessage: {
|
||||
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',
|
||||
@@ -121,7 +121,7 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete Subsection',
|
||||
description: 'Title text for the warning displayed before deleting a Subsection',
|
||||
},
|
||||
deleteSubsectionCourseMessaage: {
|
||||
deleteSubsectionCourseMessage: {
|
||||
id: 'course-authoring.library-authoring.subsection.delete-course-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',
|
||||
@@ -146,10 +146,10 @@ const messages = defineMessages({
|
||||
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',
|
||||
deleteContainerConfirm: {
|
||||
id: 'course-authoring.library-authoring.container.delete-confirmation-text',
|
||||
defaultMessage: 'Are you sure you want to permanently delete {containerName}? This cannot be undone and will remove it from your library. {message}',
|
||||
description: 'Confirmation text to display before deleting a container',
|
||||
},
|
||||
deleteUnitSuccess: {
|
||||
id: 'course-authoring.library-authoring.unit.delete.success',
|
||||
|
||||
@@ -25,10 +25,10 @@ export const LibrarySubsectionPage = () => {
|
||||
const { libraryId, containerId } = useLibraryContext();
|
||||
const { sidebarItemInfo } = useSidebarContext();
|
||||
|
||||
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
|
||||
const { data: libraryData, isPending: isLibPending } = useContentLibrary(libraryId);
|
||||
// fetch subsectionData from index as it includes its parent sections as well.
|
||||
const {
|
||||
hits, isLoading, isError, error,
|
||||
hits, isPending, isError, error,
|
||||
} = useContentFromSearchIndex(containerId ? [containerId] : []);
|
||||
const subsectionData = (hits as ContainerHit[])?.[0];
|
||||
|
||||
@@ -38,7 +38,7 @@ export const LibrarySubsectionPage = () => {
|
||||
}
|
||||
|
||||
// Only show loading if section or library data is not fetched from index yet
|
||||
if (isLibLoading || isLoading) {
|
||||
if (isLibPending || isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
|
||||
@@ -290,7 +290,6 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
|
||||
>
|
||||
{orderedBlocks?.map((block, idx) => (
|
||||
// A container can have multiple instances of the same block
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<ComponentBlock
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${block.originalId}-${idx}-${block.modified}`}
|
||||
|
||||
@@ -354,16 +354,23 @@ describe('<LibraryUnitPage />', () => {
|
||||
});
|
||||
|
||||
it('should remove a component & restore from component card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||
axiosMock.onDelete(url).reply(200);
|
||||
renderLibraryUnitPage();
|
||||
|
||||
expect(await screen.findByText('text block 0')).toBeInTheDocument();
|
||||
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const removeButton = await screen.findByText('Remove from unit');
|
||||
fireEvent.click(removeButton);
|
||||
await user.click(removeButton);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: 'Remove Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const confirmButton = await within(modal).findByRole('button', { name: 'Remove' });
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete[0].url).toEqual(url);
|
||||
@@ -385,16 +392,23 @@ describe('<LibraryUnitPage />', () => {
|
||||
});
|
||||
|
||||
it('should show error on remove a component', async () => {
|
||||
const user = userEvent.setup();
|
||||
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||
axiosMock.onDelete(url).reply(404);
|
||||
renderLibraryUnitPage();
|
||||
|
||||
expect(await screen.findByText('text block 0')).toBeInTheDocument();
|
||||
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const removeButton = await screen.findByText('Remove from unit');
|
||||
fireEvent.click(removeButton);
|
||||
await user.click(removeButton);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: 'Remove Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const confirmButton = await within(modal).findByRole('button', { name: 'Remove' });
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete[0].url).toEqual(url);
|
||||
@@ -403,16 +417,23 @@ describe('<LibraryUnitPage />', () => {
|
||||
});
|
||||
|
||||
it('should show error on restore removed component', async () => {
|
||||
const user = userEvent.setup();
|
||||
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||
axiosMock.onDelete(url).reply(200);
|
||||
renderLibraryUnitPage();
|
||||
|
||||
expect(await screen.findByText('text block 0')).toBeInTheDocument();
|
||||
const menu = screen.getAllByRole('button', { name: /component actions menu/i })[0];
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const removeButton = await screen.findByText('Remove from unit');
|
||||
fireEvent.click(removeButton);
|
||||
await user.click(removeButton);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: 'Remove Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const confirmButton = await within(modal).findByRole('button', { name: 'Remove' });
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete[0].url).toEqual(url);
|
||||
@@ -446,10 +467,16 @@ describe('<LibraryUnitPage />', () => {
|
||||
const { findByRole, findByText } = within(sidebar);
|
||||
|
||||
const menu = await findByRole('button', { name: /component actions menu/i });
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const removeButton = await findByText('Remove from unit');
|
||||
fireEvent.click(removeButton);
|
||||
await user.click(removeButton);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: 'Remove Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const confirmButton = await within(modal).findByRole('button', { name: 'Remove' });
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete[0].url).toEqual(url);
|
||||
|
||||
@@ -35,10 +35,10 @@ export const LibraryUnitPage = () => {
|
||||
|
||||
const { sidebarItemInfo } = useSidebarContext();
|
||||
|
||||
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
|
||||
const { data: libraryData, isPending: isLibPending } = useContentLibrary(libraryId);
|
||||
// fetch unitData from index as it includes its parent subsections as well.
|
||||
const {
|
||||
hits, isLoading, isError, error,
|
||||
hits, isPending, isError, error,
|
||||
} = useContentFromSearchIndex(containerId ? [containerId] : []);
|
||||
const unitData = (hits as ContainerHit[])?.[0];
|
||||
|
||||
@@ -48,7 +48,7 @@ export const LibraryUnitPage = () => {
|
||||
}
|
||||
|
||||
// Only show loading if unit or library data is not fetched from index yet
|
||||
if (isLibLoading || isLoading) {
|
||||
if (isLibPending || isPending) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface SearchContextData {
|
||||
defaultSearchSortOrder: SearchSortOption;
|
||||
hits: HitType[];
|
||||
totalHits: number;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
hasNextPage: boolean | undefined;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
|
||||
@@ -23,6 +23,9 @@ mockContentSearchConfig.applyMock = () => (
|
||||
/**
|
||||
* Mock all future Meilisearch searches with the given response.
|
||||
*
|
||||
* If you want to pass only the hits, use `hydrateSearchResult()` to create a full
|
||||
* MultiSearchResponse object.
|
||||
*
|
||||
* For a given test suite, this mock will stay in effect until you call it with
|
||||
* a different mock response, or you call `fetchMock.mockReset()`
|
||||
*/
|
||||
@@ -45,6 +48,25 @@ export function mockSearchResult(
|
||||
}, { overwriteRoutes: true });
|
||||
}
|
||||
|
||||
/** Helper to create a full MultiSearchResponse object from an array of hits.
|
||||
* You can then pass the result to `mockSearchResult()`.
|
||||
*/
|
||||
export const hydrateSearchResult: (hits: any[]) => MultiSearchResponse = (hits) => ({
|
||||
results: [
|
||||
{
|
||||
hits,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
nbHits: 0,
|
||||
exhaustiveNbHits: true,
|
||||
processingTimeMs: 1,
|
||||
query: '',
|
||||
params: '',
|
||||
indexUid: 'studio',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Mock the block types returned by the API.
|
||||
*/
|
||||
|
||||
@@ -177,7 +177,7 @@ export const useContentSearchResults = ({
|
||||
problemTypes: pages?.[0]?.problemTypes ?? {},
|
||||
publishStatus: pages?.[0]?.publishStatus ?? {},
|
||||
status: query.status,
|
||||
isLoading: query.isPending,
|
||||
isPending: query.isPending,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
|
||||
Reference in New Issue
Block a user