feat: container delete confirmation modal (#2145)
Update container delete confirmation modal based on #1982 and #1981
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
import messages from './messages';
|
||||
import containerMessages from '../containers/messages';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import { useRunOnNextRender } from '../../utils';
|
||||
|
||||
@@ -58,9 +59,9 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess));
|
||||
showToast(intl.formatMessage(containerMessages.removeComponentFromCollectionSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure));
|
||||
showToast(intl.formatMessage(containerMessages.removeComponentFromCollectionFailure));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -139,11 +140,11 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
</Dropdown.Item>
|
||||
{insideCollection && (
|
||||
<Dropdown.Item onClick={removeFromCollection}>
|
||||
<FormattedMessage {...messages.menuRemoveFromCollection} />
|
||||
<FormattedMessage {...containerMessages.menuRemoveFromCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item onClick={showManageCollections}>
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
<FormattedMessage {...containerMessages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
{isConfirmingDelete && (
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.container-card-preview-text {
|
||||
display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
};
|
||||
@@ -1,390 +0,0 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import {
|
||||
initializeMocks, render as baseRender, screen, waitFor,
|
||||
fireEvent,
|
||||
} from '../../testUtils';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { mockContentLibrary } from '../data/api.mocks';
|
||||
import { type ContainerHit, PublishStatus } from '../../search-manager';
|
||||
import ContainerCard from './ContainerCard';
|
||||
import { getLibraryContainerApiUrl, getLibraryContainerRestoreApiUrl } from '../data/api';
|
||||
import { ContainerType } from '../../generic/key-utils';
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast;
|
||||
const mockNavigate = jest.fn();
|
||||
const libraryId = 'lib:Axim:TEST';
|
||||
|
||||
const getContainerHitSample = (containerType: ContainerType = ContainerType.Unit) => ({
|
||||
id: `lctorg1democourse-${containerType}-display-name-123`,
|
||||
type: 'library_container',
|
||||
contextKey: libraryId,
|
||||
usageKey: `lct:org1:Demo_Course:${containerType}:${containerType}-display-name-123`,
|
||||
org: 'org1',
|
||||
blockId: `${containerType}-display-name-123`,
|
||||
blockType: containerType,
|
||||
breadcrumbs: [{ displayName: 'Demo Lib' }],
|
||||
displayName: `${containerType} Display Name`,
|
||||
formatted: {
|
||||
displayName: `${containerType} Display Formated Name`,
|
||||
published: {
|
||||
displayName: `Published ${containerType} Display Name`,
|
||||
},
|
||||
},
|
||||
created: 1722434322294,
|
||||
modified: 1722434322294,
|
||||
numChildren: 2,
|
||||
published: {
|
||||
numChildren: 1,
|
||||
},
|
||||
tags: {},
|
||||
publishStatus: PublishStatus.Published,
|
||||
} as ContainerHit);
|
||||
|
||||
mockContentLibrary.applyMock();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, {
|
||||
path: '/library/:libraryId',
|
||||
params: { libraryId },
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider
|
||||
libraryId={libraryId}
|
||||
showOnlyPublished={showOnlyPublished}
|
||||
>
|
||||
{children}
|
||||
</LibraryProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe('<ContainerCard />', () => {
|
||||
beforeEach(() => {
|
||||
({ axiosMock, mockShowToast } = initializeMocks());
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should render the unit card with title',
|
||||
containerType: ContainerType.Unit,
|
||||
displayName: 'unit Display Formated Name',
|
||||
},
|
||||
{
|
||||
label: 'should render the subsection card with title',
|
||||
containerType: ContainerType.Subsection,
|
||||
displayName: 'subsection Display Formated Name',
|
||||
},
|
||||
{
|
||||
label: 'should render the section card with title',
|
||||
containerType: ContainerType.Section,
|
||||
displayName: 'section Display Formated Name',
|
||||
},
|
||||
])('$label', ({ containerType, displayName }) => {
|
||||
const container = getContainerHitSample(containerType);
|
||||
render(<ContainerCard hit={container} />);
|
||||
|
||||
expect(screen.getByText(displayName)).toBeInTheDocument();
|
||||
expect(screen.queryByText('2')).toBeInTheDocument(); // Component count
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'sould render published content of unit card',
|
||||
containerType: ContainerType.Unit,
|
||||
displayName: 'Published unit Display Name',
|
||||
},
|
||||
{
|
||||
label: 'sould render published content of subsection card',
|
||||
containerType: ContainerType.Subsection,
|
||||
displayName: 'Published subsection Display Name',
|
||||
},
|
||||
{
|
||||
label: 'sould render published content of section card',
|
||||
containerType: ContainerType.Section,
|
||||
displayName: 'Published section Display Name',
|
||||
},
|
||||
])('$label', ({ containerType, displayName }) => {
|
||||
const container = getContainerHitSample(containerType);
|
||||
render(<ContainerCard hit={container} />, true);
|
||||
|
||||
expect(screen.getByText(displayName)).toBeInTheDocument();
|
||||
expect(screen.queryByText('1')).toBeInTheDocument(); // Published Component Count
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should navigate to the unit if the open menu clicked',
|
||||
containerType: ContainerType.Unit,
|
||||
},
|
||||
{
|
||||
label: 'should navigate to the section if the open menu clicked',
|
||||
containerType: ContainerType.Section,
|
||||
},
|
||||
{
|
||||
label: 'should navigate to the subsection if the open menu clicked',
|
||||
containerType: ContainerType.Subsection,
|
||||
},
|
||||
])('$label', async ({ containerType }) => {
|
||||
render(<ContainerCard hit={getContainerHitSample(containerType)} />);
|
||||
|
||||
// Open menu
|
||||
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
|
||||
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
|
||||
|
||||
// Open menu item
|
||||
const openMenuItem = await screen.findByRole('button', { name: 'Open' });
|
||||
expect(openMenuItem).toBeInTheDocument();
|
||||
userEvent.click(openMenuItem);
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
pathname: `/library/${libraryId}/${containerType}/${getContainerHitSample(containerType).usageKey}`,
|
||||
search: '',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should navigate to the unit if the card is double clicked',
|
||||
containerType: ContainerType.Unit,
|
||||
},
|
||||
{
|
||||
label: 'should navigate to the section if the card is double clicked',
|
||||
containerType: ContainerType.Section,
|
||||
},
|
||||
{
|
||||
label: 'should navigate to the subsection if the card is double clicked',
|
||||
containerType: ContainerType.Subsection,
|
||||
},
|
||||
])('$label', async ({ containerType }) => {
|
||||
render(<ContainerCard hit={getContainerHitSample(containerType)} />);
|
||||
|
||||
// Open menu item
|
||||
const cardItem = await screen.findByText(`${containerType} Display Formated Name`);
|
||||
expect(cardItem).toBeInTheDocument();
|
||||
userEvent.click(cardItem, undefined, { clickCount: 2 });
|
||||
expect(mockNavigate).toHaveBeenCalledWith({
|
||||
pathname: `/library/${libraryId}/${containerType}/${getContainerHitSample(containerType).usageKey}`,
|
||||
search: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete the container from the menu & restore the container', async () => {
|
||||
axiosMock.onDelete(getLibraryContainerApiUrl(getContainerHitSample().usageKey)).reply(200);
|
||||
|
||||
render(<ContainerCard hit={getContainerHitSample()} />);
|
||||
|
||||
// Open menu
|
||||
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
|
||||
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
|
||||
|
||||
// Click on Delete Item
|
||||
const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
|
||||
expect(deleteMenuItem).toBeInTheDocument();
|
||||
fireEvent.click(deleteMenuItem);
|
||||
|
||||
// Confirm delete Modal is open
|
||||
expect(await screen.findByText('Delete Unit')).toBeInTheDocument();
|
||||
const deleteButton = screen.getByRole('button', { name: /delete/i });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete.length).toBe(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalled();
|
||||
|
||||
// Get restore / undo func from the toast
|
||||
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
||||
|
||||
const restoreUrl = getLibraryContainerRestoreApiUrl(getContainerHitSample().usageKey);
|
||||
axiosMock.onPost(restoreUrl).reply(200);
|
||||
// restore collection
|
||||
restoreFn();
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
|
||||
});
|
||||
|
||||
it('should show error on delete the container from the menu', async () => {
|
||||
axiosMock.onDelete(getLibraryContainerApiUrl(getContainerHitSample().usageKey)).reply(400);
|
||||
|
||||
render(<ContainerCard hit={getContainerHitSample()} />);
|
||||
|
||||
// Open menu
|
||||
expect(screen.getByTestId('container-card-menu-toggle')).toBeInTheDocument();
|
||||
userEvent.click(screen.getByTestId('container-card-menu-toggle'));
|
||||
|
||||
// Click on Delete Item
|
||||
const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
|
||||
expect(deleteMenuItem).toBeInTheDocument();
|
||||
fireEvent.click(deleteMenuItem);
|
||||
|
||||
// Confirm delete Modal is open
|
||||
expect(screen.getByText('Delete Unit'));
|
||||
const deleteButton = screen.getByRole('button', { name: /delete/i });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete.length).toBe(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Failed to delete unit');
|
||||
});
|
||||
|
||||
it('should render no child blocks in unit card preview', async () => {
|
||||
render(<ContainerCard hit={getContainerHitSample()} />);
|
||||
|
||||
expect(screen.queryByTitle('lb:org1:Demo_course:html:text-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('+0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render <=5 child blocks in unit card preview', async () => {
|
||||
const containerWith5Children = {
|
||||
...getContainerHitSample(),
|
||||
content: {
|
||||
childUsageKeys: Array(5).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`),
|
||||
},
|
||||
} satisfies ContainerHit;
|
||||
render(<ContainerCard hit={containerWith5Children} />);
|
||||
|
||||
expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(5);
|
||||
expect(screen.queryByText('+0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render >5 child blocks with +N in unit card preview', async () => {
|
||||
const containerWith6Children = {
|
||||
...getContainerHitSample(),
|
||||
content: {
|
||||
childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`),
|
||||
},
|
||||
} satisfies ContainerHit;
|
||||
render(<ContainerCard hit={containerWith6Children} />);
|
||||
|
||||
expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(4);
|
||||
expect(screen.queryByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render published child blocks when rendering a published unit card preview', async () => {
|
||||
const containerWithPublishedChildren = {
|
||||
...getContainerHitSample(),
|
||||
content: {
|
||||
childUsageKeys: Array(6).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`),
|
||||
},
|
||||
published: {
|
||||
content: {
|
||||
childUsageKeys: Array(2).fill('').map((_child, idx) => `lb:org1:Demo_course:html:text-${idx}`),
|
||||
},
|
||||
},
|
||||
} satisfies ContainerHit;
|
||||
render(
|
||||
<ContainerCard hit={containerWithPublishedChildren} />,
|
||||
true,
|
||||
);
|
||||
|
||||
expect((await screen.findAllByTitle(/lb:org1:Demo_course:html:text-*/)).length).toBe(2);
|
||||
expect(screen.queryByText('+2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should render published child in subsection card preview',
|
||||
containerType: ContainerType.Subsection,
|
||||
childrenType: 'unit',
|
||||
displayName: 'Published subsection Display Name',
|
||||
expected: /contains unit 0, unit 1\./i,
|
||||
},
|
||||
{
|
||||
label: 'should render published child in section card preview',
|
||||
containerType: ContainerType.Section,
|
||||
childrenType: 'subsection',
|
||||
displayName: 'Published section Display Name',
|
||||
expected: /contains subsection 0, subsection 1\./i,
|
||||
},
|
||||
])('$label', ({
|
||||
containerType,
|
||||
childrenType,
|
||||
displayName,
|
||||
expected,
|
||||
}) => {
|
||||
const containerWithChildren = {
|
||||
...getContainerHitSample(containerType),
|
||||
content: {
|
||||
childUsageKeys: Array(6).fill('').map(
|
||||
(_child, idx) => `lct:org1:Demo_Course:${childrenType}:${childrenType}-${idx}`,
|
||||
),
|
||||
childDisplayNames: Array(6).fill('').map((_child, idx) => `${childrenType} ${idx}`),
|
||||
},
|
||||
published: {
|
||||
content: {
|
||||
childUsageKeys: Array(2).fill('').map(
|
||||
(_child, idx) => `lct:org1:Demo_Course:${childrenType}:${childrenType}-${idx}`,
|
||||
),
|
||||
childDisplayNames: Array(2).fill('').map((_child, idx) => `${childrenType} ${idx}`),
|
||||
},
|
||||
},
|
||||
} satisfies ContainerHit;
|
||||
|
||||
render(<ContainerCard hit={containerWithChildren} />, true);
|
||||
|
||||
expect(screen.getByText(displayName)).toBeInTheDocument();
|
||||
expect(screen.getByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should render subsection card preview with children',
|
||||
containerType: ContainerType.Subsection,
|
||||
childrenType: 'unit',
|
||||
displayName: 'subsection Display Formated Name',
|
||||
expected: /contains unit 0, unit 1\./i,
|
||||
},
|
||||
{
|
||||
label: 'should render section card preview with children',
|
||||
containerType: ContainerType.Section,
|
||||
childrenType: 'subsection',
|
||||
displayName: 'section Display Formated Name',
|
||||
expected: /contains subsection 0, subsection 1\./i,
|
||||
},
|
||||
])('$label', ({
|
||||
containerType,
|
||||
childrenType,
|
||||
displayName,
|
||||
expected,
|
||||
}) => {
|
||||
const containerWithChildren = {
|
||||
...getContainerHitSample(containerType),
|
||||
content: {
|
||||
childUsageKeys: Array(2).fill('').map(
|
||||
(_child, idx) => `lct:org1:Demo_Course:${childrenType}:${childrenType}-${idx}`,
|
||||
),
|
||||
childDisplayNames: Array(2).fill('').map((_child, idx) => `${childrenType} ${idx}`),
|
||||
},
|
||||
} satisfies ContainerHit;
|
||||
render(<ContainerCard hit={containerWithChildren} />);
|
||||
|
||||
expect(screen.getByText(displayName)).toBeInTheDocument();
|
||||
expect(screen.getByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'should render subsection card preview without children',
|
||||
containerType: ContainerType.Subsection,
|
||||
displayName: 'subsection Display Formated Name',
|
||||
},
|
||||
{
|
||||
label: 'should render section card preview without children',
|
||||
containerType: ContainerType.Section,
|
||||
displayName: 'section Display Formated Name',
|
||||
},
|
||||
])('$label', ({ containerType, displayName }) => {
|
||||
const container = getContainerHitSample(containerType);
|
||||
render(<ContainerCard hit={container} />);
|
||||
|
||||
expect(screen.getByText(displayName)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/contains/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,279 +0,0 @@
|
||||
import { ReactNode, useCallback, useContext } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Dropdown,
|
||||
Icon,
|
||||
IconButton,
|
||||
useToggle,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
|
||||
import { getBlockType } from '../../generic/key-utils';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { type ContainerHit, Highlight, PublishStatus } from '../../search-manager';
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '../data/apiHooks';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import AddComponentWidget from './AddComponentWidget';
|
||||
import BaseCard from './BaseCard';
|
||||
import messages from './messages';
|
||||
import ContainerDeleter from './ContainerDeleter';
|
||||
import { useRunOnNextRender } from '../../utils';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
containerKey: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
|
||||
const intl = useIntl();
|
||||
const { libraryId, collectionId } = useLibraryContext();
|
||||
const {
|
||||
sidebarItemInfo,
|
||||
closeLibrarySidebar,
|
||||
setSidebarAction,
|
||||
} = useSidebarContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
const { navigateTo, insideCollection } = useLibraryRoutes();
|
||||
|
||||
const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
|
||||
|
||||
const removeFromCollection = () => {
|
||||
removeComponentsMutation.mutateAsync([containerKey]).then(() => {
|
||||
if (sidebarItemInfo?.id === containerKey) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure));
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleJumpToCollection = useRunOnNextRender(() => {
|
||||
// TODO: Ugly hack to make sure sidebar shows add to collection section
|
||||
// This needs to run after all changes to url takes place to avoid conflicts.
|
||||
setTimeout(() => setSidebarAction(SidebarActions.JumpToManageCollections));
|
||||
});
|
||||
|
||||
const showManageCollections = useCallback(() => {
|
||||
navigateTo({ selectedItemId: containerKey });
|
||||
scheduleJumpToCollection();
|
||||
}, [scheduleJumpToCollection, navigateTo, containerKey]);
|
||||
|
||||
const openContainer = useCallback(() => {
|
||||
navigateTo({ containerId: containerKey });
|
||||
}, [navigateTo, containerKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown id="container-card-dropdown">
|
||||
<Dropdown.Toggle
|
||||
id="container-card-menu-toggle"
|
||||
as={IconButton}
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt={intl.formatMessage(messages.containerCardMenuAlt)}
|
||||
data-testid="container-card-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={openContainer}>
|
||||
<FormattedMessage {...messages.menuOpen} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={confirmDelete}>
|
||||
<FormattedMessage {...messages.menuDeleteContainer} />
|
||||
</Dropdown.Item>
|
||||
{insideCollection && (
|
||||
<Dropdown.Item onClick={removeFromCollection}>
|
||||
<FormattedMessage {...messages.menuRemoveFromCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item onClick={showManageCollections}>
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<ContainerDeleter
|
||||
isOpen={isConfirmingDelete}
|
||||
close={cancelDelete}
|
||||
containerId={containerKey}
|
||||
displayName={displayName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type UnitCardPreviewProps = {
|
||||
childKeys: Array<string>;
|
||||
showMaxChildren?: number;
|
||||
};
|
||||
|
||||
const UnitcardPreview = ({ childKeys, showMaxChildren = 5 }: UnitCardPreviewProps) => {
|
||||
const hiddenChildren = childKeys.length - showMaxChildren;
|
||||
return (
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
{
|
||||
childKeys.slice(0, showMaxChildren).map((usageKey, idx) => {
|
||||
const blockType = getBlockType(usageKey);
|
||||
let blockPreview: ReactNode;
|
||||
let classNames;
|
||||
|
||||
if (idx < showMaxChildren - 1 || hiddenChildren <= 0) {
|
||||
// Show the first N-1 blocks as item icons
|
||||
// (or all N blocks if no hidden children)
|
||||
classNames = `rounded p-1 ${getComponentStyleColor(blockType)}`;
|
||||
blockPreview = (
|
||||
<Icon
|
||||
src={getItemIcon(blockType)}
|
||||
screenReaderText={blockType}
|
||||
title={usageKey}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Container has more blocks than can fit in the preview, so show "+N"
|
||||
blockPreview = (
|
||||
<FormattedMessage
|
||||
{...messages.containerPreviewMoreBlocks}
|
||||
values={{ count: hiddenChildren + 1 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
// A container can have multiple instances of the same block
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${usageKey}-${idx}`}
|
||||
className={classNames}
|
||||
>
|
||||
{blockPreview}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type ContainerCardPreviewProps = {
|
||||
hit: ContainerHit,
|
||||
};
|
||||
|
||||
const ContainerCardPreview = ({ hit }: ContainerCardPreviewProps) => {
|
||||
const intl = useIntl();
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
const {
|
||||
blockType: itemType,
|
||||
published,
|
||||
content,
|
||||
} = hit;
|
||||
|
||||
if (itemType === 'unit') {
|
||||
const childKeys: Array<string> = (
|
||||
showOnlyPublished ? published?.content?.childUsageKeys : content?.childUsageKeys
|
||||
) ?? [];
|
||||
|
||||
return <UnitcardPreview childKeys={childKeys} />;
|
||||
}
|
||||
// TODO Section highlights
|
||||
|
||||
const childNames: Array<string> = (
|
||||
showOnlyPublished ? published?.content?.childDisplayNames : content?.childDisplayNames
|
||||
) ?? [];
|
||||
|
||||
if (childNames.length > 0) {
|
||||
// Preview with a truncated text with all children display names
|
||||
const childrenText = intl.formatMessage(
|
||||
messages.containerPreviewText,
|
||||
{
|
||||
children: childNames.join(', '),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container-card-preview-text">
|
||||
<Highlight text={childrenText} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Empty preview
|
||||
return null;
|
||||
};
|
||||
|
||||
type ContainerCardProps = {
|
||||
hit: ContainerHit,
|
||||
};
|
||||
|
||||
const ContainerCard = ({ hit } : ContainerCardProps) => {
|
||||
const { componentPickerMode } = useComponentPickerContext();
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
const { openContainerInfoSidebar, sidebarItemInfo } = useSidebarContext();
|
||||
|
||||
const {
|
||||
blockType: itemType,
|
||||
formatted,
|
||||
tags,
|
||||
numChildren,
|
||||
published,
|
||||
publishStatus,
|
||||
usageKey: containerKey,
|
||||
} = hit;
|
||||
|
||||
const numChildrenCount = showOnlyPublished ? (
|
||||
published?.numChildren || 0
|
||||
) : numChildren;
|
||||
|
||||
const displayName: string = (
|
||||
showOnlyPublished ? formatted.published?.displayName : formatted.displayName
|
||||
) ?? '';
|
||||
|
||||
const selected = sidebarItemInfo?.id === containerKey;
|
||||
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
|
||||
const selectContainer = useCallback((e?: React.MouseEvent) => {
|
||||
const doubleClicked = (e?.detail || 0) > 1;
|
||||
if (componentPickerMode) {
|
||||
// In component picker mode, we want to open the sidebar
|
||||
// without changing the URL
|
||||
openContainerInfoSidebar(containerKey);
|
||||
} else if (!doubleClicked) {
|
||||
navigateTo({ selectedItemId: containerKey });
|
||||
} else {
|
||||
navigateTo({ containerId: containerKey });
|
||||
}
|
||||
}, [containerKey, openContainerInfoSidebar, navigateTo]);
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
itemType={itemType}
|
||||
displayName={displayName}
|
||||
preview={<ContainerCardPreview hit={hit} />}
|
||||
tags={tags}
|
||||
numChildren={numChildrenCount}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
{componentPickerMode ? (
|
||||
<AddComponentWidget usageKey={containerKey} blockType={itemType} />
|
||||
) : (
|
||||
<ContainerMenu
|
||||
containerKey={containerKey}
|
||||
displayName={hit.displayName}
|
||||
/>
|
||||
)}
|
||||
</ActionRow>
|
||||
)}
|
||||
hasUnpublishedChanges={publishStatus !== PublishStatus.Published}
|
||||
onSelect={selectContainer}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerCard;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Warning, School, Widgets } from '@openedx/paragon/icons';
|
||||
|
||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useDeleteContainer, useRestoreContainer } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
type ContainerDeleterProps = {
|
||||
isOpen: boolean,
|
||||
close: () => void,
|
||||
containerId: string,
|
||||
displayName: string,
|
||||
};
|
||||
|
||||
const ContainerDeleter = ({
|
||||
isOpen,
|
||||
close,
|
||||
containerId,
|
||||
displayName,
|
||||
}: ContainerDeleterProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
sidebarItemInfo,
|
||||
closeLibrarySidebar,
|
||||
} = useSidebarContext();
|
||||
const deleteContainerMutation = useDeleteContainer(containerId);
|
||||
const restoreContainerMutation = useRestoreContainer(containerId);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
// TODO: support other container types besides 'unit'
|
||||
const deleteWarningTitle = intl.formatMessage(messages.deleteUnitWarningTitle);
|
||||
const deleteText = intl.formatMessage(messages.deleteUnitConfirm, {
|
||||
unitName: <b>{displayName}</b>,
|
||||
message: (
|
||||
<>
|
||||
<div className="d-flex mt-2">
|
||||
<Icon className="mr-2" src={School} />
|
||||
{intl.formatMessage(messages.deleteUnitConfirmMsg1)}
|
||||
</div>
|
||||
<div className="d-flex mt-2">
|
||||
<Icon className="mr-2" src={Widgets} />
|
||||
{intl.formatMessage(messages.deleteUnitConfirmMsg2)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
const deleteSuccess = intl.formatMessage(messages.deleteUnitSuccess);
|
||||
const deleteError = intl.formatMessage(messages.deleteUnitFailed);
|
||||
const undoDeleteError = messages.undoDeleteUnitToastFailed;
|
||||
|
||||
const restoreComponent = useCallback(async () => {
|
||||
try {
|
||||
await restoreContainerMutation.mutateAsync();
|
||||
showToast(intl.formatMessage(messages.undoDeleteContainerToastMessage));
|
||||
} catch (e) {
|
||||
showToast(intl.formatMessage(undoDeleteError));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDelete = useCallback(async () => {
|
||||
await deleteContainerMutation.mutateAsync().then(() => {
|
||||
if (sidebarItemInfo?.id === containerId) {
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(
|
||||
deleteSuccess,
|
||||
{
|
||||
label: intl.formatMessage(messages.undoDeleteContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
}).catch(() => {
|
||||
showToast(deleteError);
|
||||
}).finally(() => {
|
||||
close();
|
||||
});
|
||||
}, [sidebarItemInfo, showToast, deleteContainerMutation]);
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
isOpen={isOpen}
|
||||
close={close}
|
||||
variant="warning"
|
||||
title={deleteWarningTitle}
|
||||
icon={Warning}
|
||||
description={deleteText}
|
||||
onDeleteSubmit={onDelete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerDeleter;
|
||||
@@ -1,2 +1 @@
|
||||
@import "./BaseCard.scss";
|
||||
@import "./ContainerCard.scss";
|
||||
|
||||
@@ -11,11 +11,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Collection actions menu',
|
||||
description: 'Alt/title text for the collection card menu button.',
|
||||
},
|
||||
containerCardMenuAlt: {
|
||||
id: 'course-authoring.library-authoring.container.menu',
|
||||
defaultMessage: 'Container actions menu',
|
||||
description: 'Alt/title text for the container card menu button.',
|
||||
},
|
||||
menuOpen: {
|
||||
id: 'course-authoring.library-authoring.menu.open',
|
||||
defaultMessage: 'Open',
|
||||
@@ -36,26 +31,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Menu item for deleting a component.',
|
||||
},
|
||||
menuAddToCollection: {
|
||||
id: 'course-authoring.library-authoring.component.menu.add',
|
||||
defaultMessage: 'Add to collection',
|
||||
description: 'Menu item for add a component to collection.',
|
||||
},
|
||||
menuRemoveFromCollection: {
|
||||
id: 'course-authoring.library-authoring.component.menu.remove',
|
||||
defaultMessage: 'Remove from collection',
|
||||
description: 'Menu item for remove an item from collection.',
|
||||
},
|
||||
removeComponentFromCollectionSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.remove-from-collection-success',
|
||||
defaultMessage: 'Item successfully removed',
|
||||
description: 'Message for successful removal of an item from collection.',
|
||||
},
|
||||
removeComponentFromCollectionFailure: {
|
||||
id: 'course-authoring.library-authoring.component.remove-from-collection-failure',
|
||||
defaultMessage: 'Failed to remove item',
|
||||
description: 'Message for failure of removal of an item from collection.',
|
||||
},
|
||||
deleteComponentWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
|
||||
defaultMessage: 'Delete Component',
|
||||
@@ -191,61 +166,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'This component can be synced in courses after publish.',
|
||||
description: 'Alert text of the modal to confirm publish a component in a library.',
|
||||
},
|
||||
menuDeleteContainer: {
|
||||
id: 'course-authoring.library-authoring.container.delete-menu-text',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Menu item to delete a container.',
|
||||
},
|
||||
deleteUnitWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.unit.delete-confirmation-title',
|
||||
defaultMessage: 'Delete Unit',
|
||||
description: 'Title text for the warning displayed 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',
|
||||
},
|
||||
deleteUnitConfirmMsg1: {
|
||||
id: 'course-authoring.library-authoring.unit.delete-confirmation-msg-1',
|
||||
defaultMessage: 'Any course instances will stop receiving updates.',
|
||||
description: 'First part of confirmation message to display before deleting a unit',
|
||||
},
|
||||
deleteUnitConfirmMsg2: {
|
||||
id: 'course-authoring.library-authoring.unit.delete-confirmation-msg-2',
|
||||
defaultMessage: 'Any components will remain in the library.',
|
||||
description: 'Second part of confirmation message to display before deleting a unit',
|
||||
},
|
||||
deleteUnitSuccess: {
|
||||
id: 'course-authoring.library-authoring.unit.delete.success',
|
||||
defaultMessage: 'Unit deleted',
|
||||
description: 'Message to display on delete unit success',
|
||||
},
|
||||
deleteUnitFailed: {
|
||||
id: 'course-authoring.library-authoring.unit.delete-failed-error',
|
||||
defaultMessage: 'Failed to delete unit',
|
||||
description: 'Message to display on failure to delete a unit',
|
||||
},
|
||||
undoDeleteContainerToastAction: {
|
||||
id: 'course-authoring.library-authoring.container.undo-delete-container-toast-button',
|
||||
defaultMessage: 'Undo',
|
||||
description: 'Toast message to undo deletion of container',
|
||||
},
|
||||
undoDeleteContainerToastMessage: {
|
||||
id: 'course-authoring.library-authoring.container.undo-delete-container-toast-text',
|
||||
defaultMessage: 'Undo successful',
|
||||
description: 'Message to display on undo delete container success',
|
||||
},
|
||||
undoDeleteUnitToastFailed: {
|
||||
id: 'course-authoring.library-authoring.unit.undo-delete-unit-failed',
|
||||
defaultMessage: 'Failed to undo delete Unit operation',
|
||||
description: 'Message to display on failure to undo delete unit',
|
||||
},
|
||||
containerPreviewMoreBlocks: {
|
||||
id: 'course-authoring.library-authoring.component.container-card-preview.more-blocks',
|
||||
defaultMessage: '+{count}',
|
||||
description: 'Count shown when a container has more blocks than will fit on the card preview.',
|
||||
},
|
||||
removeComponentFromUnitMenu: {
|
||||
id: 'course-authoring.library-authoring.unit.component.remove.button',
|
||||
defaultMessage: 'Remove from unit',
|
||||
@@ -276,10 +196,5 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Failed to undo remove component operation',
|
||||
description: 'Message to display on failure to undo delete component',
|
||||
},
|
||||
containerPreviewText: {
|
||||
id: 'course-authoring.library-authoring.container.preview.text',
|
||||
defaultMessage: 'Contains {children}.',
|
||||
description: 'Preview message for section/subsections with the names of children separated by commas',
|
||||
},
|
||||
});
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user