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:
Rômulo Penido
2025-09-18 18:27:35 -03:00
committed by GitHub
parent d98a34ac3f
commit 1efa94d410
25 changed files with 392 additions and 300 deletions

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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}
/>

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`}

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ export interface SearchContextData {
defaultSearchSortOrder: SearchSortOption;
hits: HitType[];
totalHits: number;
isLoading: boolean;
isPending: boolean;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
fetchNextPage: () => void;

View File

@@ -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.
*/

View File

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