feat: container delete confirmation modal (#2145)
Update container delete confirmation modal based on #1982 and #1981
This commit is contained in:
@@ -4,6 +4,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`;
|
||||
export const getContainerEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstream-containers/`;
|
||||
|
||||
export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`;
|
||||
|
||||
@@ -18,9 +19,8 @@ export interface PaginatedData<T> {
|
||||
results: T,
|
||||
}
|
||||
|
||||
export interface PublishableEntityLink {
|
||||
export interface BasePublishableEntityLink {
|
||||
id: number;
|
||||
upstreamUsageKey: string;
|
||||
upstreamContextKey: string;
|
||||
upstreamContextTitle: string;
|
||||
upstreamVersion: number;
|
||||
@@ -33,6 +33,14 @@ export interface PublishableEntityLink {
|
||||
readyToSync: boolean;
|
||||
}
|
||||
|
||||
export interface PublishableEntityLink extends BasePublishableEntityLink {
|
||||
upstreamUsageKey: string;
|
||||
}
|
||||
|
||||
export interface ContainerPublishableEntityLink extends BasePublishableEntityLink {
|
||||
upstreamContainerKey: string;
|
||||
}
|
||||
|
||||
export interface PublishableEntityLinkSummary {
|
||||
upstreamContextKey: string;
|
||||
upstreamContextTitle: string;
|
||||
@@ -58,6 +66,23 @@ export const getEntityLinks = async (
|
||||
return camelCaseObject(data);
|
||||
};
|
||||
|
||||
export const getContainerEntityLinks = async (
|
||||
downstreamContextKey?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamContainerKey?: string,
|
||||
): Promise<ContainerPublishableEntityLink[]> => {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getContainerEntityLinksByDownstreamContextUrl(), {
|
||||
params: {
|
||||
course_id: downstreamContextKey,
|
||||
ready_to_sync: readyToSync,
|
||||
upstream_container_key: upstreamContainerKey,
|
||||
no_page: true,
|
||||
},
|
||||
});
|
||||
return camelCaseObject(data);
|
||||
};
|
||||
|
||||
export const getEntityLinksSummaryByDownstreamContext = async (
|
||||
downstreamContextKey: string,
|
||||
): Promise<PublishableEntityLinkSummary[]> => {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api';
|
||||
import { getContainerEntityLinks, getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api';
|
||||
|
||||
export const courseLibrariesQueryKeys = {
|
||||
all: ['courseLibraries'],
|
||||
courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId],
|
||||
courseReadyToSyncLibraries: ({ courseId, readyToSync, upstreamUsageKey }: {
|
||||
courseReadyToSyncLibraries: ({
|
||||
courseId, readyToSync, upstreamUsageKey, upstreamContainerKey,
|
||||
}: {
|
||||
courseId?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamUsageKey?: string,
|
||||
upstreamContainerKey?: string,
|
||||
pageSize?: number,
|
||||
}) => {
|
||||
const key: Array<string | boolean | number> = [...courseLibrariesQueryKeys.all];
|
||||
@@ -22,6 +25,9 @@ export const courseLibrariesQueryKeys = {
|
||||
if (upstreamUsageKey !== undefined) {
|
||||
key.push(upstreamUsageKey);
|
||||
}
|
||||
if (upstreamContainerKey !== undefined) {
|
||||
key.push(upstreamContainerKey);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'],
|
||||
@@ -63,3 +69,29 @@ export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => (
|
||||
enabled: courseId !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook to fetch list of publishable entity links for containers by course key.
|
||||
* (That is, get a list of the library containers used in the given course.)
|
||||
*/
|
||||
export const useContainerEntityLinks = ({
|
||||
courseId, readyToSync, upstreamContainerKey,
|
||||
}: {
|
||||
courseId?: string,
|
||||
readyToSync?: boolean,
|
||||
upstreamContainerKey?: string,
|
||||
}) => (
|
||||
useQuery({
|
||||
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
|
||||
courseId,
|
||||
readyToSync,
|
||||
upstreamContainerKey,
|
||||
}),
|
||||
queryFn: () => getContainerEntityLinks(
|
||||
courseId,
|
||||
readyToSync,
|
||||
upstreamContainerKey,
|
||||
),
|
||||
enabled: courseId !== undefined || upstreamContainerKey !== undefined || readyToSync !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ const DeleteModal = ({
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<p>{modalDescription}</p>
|
||||
<div>{modalDescription}</div>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,10 +6,10 @@ import { useLibraryContext } from './common/context/LibraryContext';
|
||||
import { useSidebarContext } from './common/context/SidebarContext';
|
||||
import CollectionCard from './components/CollectionCard';
|
||||
import ComponentCard from './components/ComponentCard';
|
||||
import ContainerCard from './components/ContainerCard';
|
||||
import { ContentType } from './routes';
|
||||
import { useLoadOnScroll } from '../hooks';
|
||||
import messages from './collections/messages';
|
||||
import ContainerCard from './containers/ContainerCard';
|
||||
|
||||
/**
|
||||
* Library Content to show content grid
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -19,18 +19,17 @@ 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';
|
||||
import BaseCard from '../components/BaseCard';
|
||||
import AddComponentWidget from '../components/AddComponentWidget';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
containerKey: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
|
||||
export const ContainerMenu = ({ containerKey } : ContainerMenuProps) => {
|
||||
const intl = useIntl();
|
||||
const { libraryId, collectionId } = useLibraryContext();
|
||||
const {
|
||||
@@ -100,12 +99,13 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<ContainerDeleter
|
||||
isOpen={isConfirmingDelete}
|
||||
close={cancelDelete}
|
||||
containerId={containerKey}
|
||||
displayName={displayName}
|
||||
/>
|
||||
{isConfirmingDelete && (
|
||||
<ContainerDeleter
|
||||
isOpen={isConfirmingDelete}
|
||||
close={cancelDelete}
|
||||
containerId={containerKey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -262,10 +262,7 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
|
||||
{componentPickerMode ? (
|
||||
<AddComponentWidget usageKey={containerKey} blockType={itemType} />
|
||||
) : (
|
||||
<ContainerMenu
|
||||
containerKey={containerKey}
|
||||
displayName={hit.displayName}
|
||||
/>
|
||||
<ContainerMenu containerKey={containerKey} />
|
||||
)}
|
||||
</ActionRow>
|
||||
)}
|
||||
204
src/library-authoring/containers/ContainerDeleter.test.tsx
Normal file
204
src/library-authoring/containers/ContainerDeleter.test.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { ToastActionData } from '../../generic/toast-context';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '../../testUtils';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { SidebarProvider } from '../common/context/SidebarContext';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockGetContainerMetadata,
|
||||
mockDeleteContainer,
|
||||
mockRestoreContainer,
|
||||
mockGetContainerEntityLinks,
|
||||
} from '../data/api.mocks';
|
||||
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
|
||||
import ContainerDeleter from './ContainerDeleter';
|
||||
|
||||
mockContentLibrary.applyMock(); // Not required, but avoids 404 errors in the logs when <LibraryProvider> loads data
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockGetContainerEntityLinks.applyMock();
|
||||
const mockDelete = mockDeleteContainer.applyMock();
|
||||
const mockRestore = mockRestoreContainer.applyMock();
|
||||
|
||||
const { libraryId } = mockContentLibrary;
|
||||
const getContainerDetails = (context: string) => {
|
||||
switch (context) {
|
||||
case 'unit':
|
||||
return { containerId: mockGetContainerMetadata.unitId, parent: 'subsection' };
|
||||
case 'subsection':
|
||||
return { containerId: mockGetContainerMetadata.subsectionId, parent: 'section' };
|
||||
case 'section':
|
||||
return { containerId: mockGetContainerMetadata.sectionId, parent: null };
|
||||
default:
|
||||
return { containerId: mockGetContainerMetadata.unitId, parent: 'subsection' };
|
||||
}
|
||||
};
|
||||
|
||||
const renderArgs = {
|
||||
path: '/library/:libraryId',
|
||||
params: { libraryId },
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider libraryId={libraryId}>
|
||||
<SidebarProvider>
|
||||
{ children }
|
||||
</SidebarProvider>
|
||||
</LibraryProvider>
|
||||
),
|
||||
};
|
||||
|
||||
let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; };
|
||||
|
||||
[
|
||||
'unit' as const,
|
||||
'section' as const,
|
||||
'subsection' as const,
|
||||
].forEach((context) => {
|
||||
describe('<ContainerDeleter />', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it(`<${context}> should show a confirmation prompt the card with title and description`, async () => {
|
||||
const mockCancel = jest.fn();
|
||||
const { containerId } = getContainerDetails(context);
|
||||
render(<ContainerDeleter
|
||||
containerId={containerId}
|
||||
isOpen
|
||||
close={mockCancel}
|
||||
/>, renderArgs);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
// It should mention the component's name in the confirm dialog:
|
||||
await screen.findByText(`Test ${context}`);
|
||||
|
||||
// Clicking cancel will cancel:
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancelButton);
|
||||
expect(mockCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const deleteButton = await screen.findByRole('button', { name: 'Delete' });
|
||||
fireEvent.click(deleteButton);
|
||||
await waitFor(() => {
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockCancel).toHaveBeenCalled(); // In order to close the modal, this also gets called.
|
||||
expect(mockShowToast).toHaveBeenCalled();
|
||||
// Get restore / undo func from the toast
|
||||
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
||||
restoreFn();
|
||||
await waitFor(() => {
|
||||
expect(mockRestore).toHaveBeenCalled();
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
|
||||
});
|
||||
});
|
||||
|
||||
it(`<${context}> should show parents message if parent data is set with one parent`, async () => {
|
||||
const mockCancel = jest.fn();
|
||||
const { containerId, parent } = getContainerDetails(context);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
mockSearchResult({
|
||||
results: [ // @ts-ignore
|
||||
{
|
||||
hits: [{
|
||||
[`${parent}s`]: {
|
||||
displayName: [`${parent} 1`],
|
||||
key: [`${parent}1`],
|
||||
},
|
||||
blockType: context,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<ContainerDeleter
|
||||
containerId={containerId}
|
||||
isOpen
|
||||
close={mockCancel}
|
||||
/>,
|
||||
renderArgs,
|
||||
);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const textMatch = new RegExp(`By deleting this ${context}, you will also be deleting it from ${parent} 1 in this library.`);
|
||||
expect((await screen.findAllByText((_, element) => textMatch.test(element?.textContent || ''))).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it(`<${context}> should show parents message if parents is set with multiple parents`, async () => {
|
||||
const mockCancel = jest.fn();
|
||||
const { containerId, parent } = getContainerDetails(context);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
mockSearchResult({
|
||||
results: [ // @ts-ignore
|
||||
{
|
||||
hits: [{
|
||||
[`${parent}s`]: {
|
||||
displayName: [`${parent} 1`, `${parent} 2`],
|
||||
key: [`${parent}1`, `${parent}2`],
|
||||
},
|
||||
blockType: context,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<ContainerDeleter
|
||||
containerId={containerId}
|
||||
isOpen
|
||||
close={mockCancel}
|
||||
/>,
|
||||
renderArgs,
|
||||
);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: new RegExp(`Delete ${context}`, 'i') });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const textMatch = new RegExp(`By deleting this ${context}, you will also be deleting it from 2 ${parent}s in this library.`, 'i');
|
||||
expect((await screen.findAllByText((_, element) => textMatch.test(element?.textContent || ''))).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
src/library-authoring/containers/ContainerDeleter.tsx
Normal file
169
src/library-authoring/containers/ContainerDeleter.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
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 DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useContentFromSearchIndex, useDeleteContainer, useRestoreContainer } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
import { ContainerType } from '../../generic/key-utils';
|
||||
import { ContainerHit } from '../../search-manager';
|
||||
import { useContainerEntityLinks } from '../../course-libraries/data/apiHooks';
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
|
||||
type ContainerDeleterProps = {
|
||||
isOpen: boolean,
|
||||
close: () => void,
|
||||
containerId: string,
|
||||
};
|
||||
|
||||
const ContainerDeleter = ({
|
||||
isOpen,
|
||||
close,
|
||||
containerId,
|
||||
}: ContainerDeleterProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
sidebarItemInfo,
|
||||
closeLibrarySidebar,
|
||||
} = useSidebarContext();
|
||||
const deleteContainerMutation = useDeleteContainer(containerId);
|
||||
const restoreContainerMutation = useRestoreContainer(containerId);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const { hits, isLoading } = useContentFromSearchIndex([containerId]);
|
||||
const containerData = (hits as ContainerHit[])?.[0];
|
||||
const {
|
||||
data: dataDownstreamLinks,
|
||||
isLoading: linksIsLoading,
|
||||
} = useContainerEntityLinks({ upstreamContainerKey: containerId });
|
||||
const downstreamCount = dataDownstreamLinks?.length ?? 0;
|
||||
|
||||
const messageMap = useMemo(() => {
|
||||
const containerType = containerData?.blockType;
|
||||
let parentCount = 0;
|
||||
let parentMessage: React.ReactNode;
|
||||
switch (containerType) {
|
||||
case ContainerType.Section:
|
||||
return {
|
||||
title: intl.formatMessage(messages.deleteSectionWarningTitle),
|
||||
parentMessage: '',
|
||||
courseCount: downstreamCount,
|
||||
courseMessage: messages.deleteSectionCourseMessaage,
|
||||
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) {
|
||||
parentMessage = intl.formatMessage(
|
||||
messages.deleteSubsectionParentMessage,
|
||||
{ parentName: <b>{containerData?.sections?.displayName?.[0]}</b> },
|
||||
);
|
||||
} else if (parentCount > 1) {
|
||||
parentMessage = intl.formatMessage(messages.deleteSubsectionMultipleParentMessage, {
|
||||
parentCount: <b>{parentCount}</b>,
|
||||
});
|
||||
}
|
||||
return {
|
||||
title: intl.formatMessage(messages.deleteSubsectionWarningTitle),
|
||||
parentMessage,
|
||||
courseCount: downstreamCount,
|
||||
courseMessage: messages.deleteSubsectionCourseMessaage,
|
||||
deleteSuccess: intl.formatMessage(messages.deleteSubsectionSuccess),
|
||||
deleteError: intl.formatMessage(messages.deleteSubsectionFailed),
|
||||
undoDeleteError: messages.undoDeleteSubsectionToastFailed,
|
||||
};
|
||||
default:
|
||||
parentCount = containerData?.subsections?.displayName?.length || 0;
|
||||
if (parentCount === 1) {
|
||||
parentMessage = intl.formatMessage(
|
||||
messages.deleteUnitParentMessage,
|
||||
{ parentName: <b>{containerData?.subsections?.displayName?.[0]}</b> },
|
||||
);
|
||||
} else if (parentCount > 1) {
|
||||
parentMessage = intl.formatMessage(messages.deleteUnitMultipleParentMessage, {
|
||||
parentCount: <b>{parentCount}</b>,
|
||||
});
|
||||
}
|
||||
return {
|
||||
title: intl.formatMessage(messages.deleteUnitWarningTitle),
|
||||
parentMessage,
|
||||
courseCount: downstreamCount,
|
||||
courseMessage: messages.deleteUnitCourseMessage,
|
||||
deleteSuccess: intl.formatMessage(messages.deleteUnitSuccess),
|
||||
deleteError: intl.formatMessage(messages.deleteUnitFailed),
|
||||
undoDeleteError: messages.undoDeleteUnitToastFailed,
|
||||
};
|
||||
}
|
||||
}, [containerData, downstreamCount, messages, intl]);
|
||||
|
||||
const deleteText = intl.formatMessage(messages.deleteUnitConfirm, {
|
||||
unitName: <b>{containerData?.displayName}</b>,
|
||||
message: (
|
||||
<div className="text-danger-900">
|
||||
{messageMap.parentMessage && (
|
||||
<div className="d-flex align-items-center mt-2">
|
||||
<Icon className="mr-2" src={Error} />
|
||||
<span>{messageMap.parentMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{(messageMap.courseCount || 0) > 0 && (
|
||||
<div className="d-flex align-items-center mt-2">
|
||||
<Icon className="mr-2" src={School} />
|
||||
<span>
|
||||
{intl.formatMessage(messageMap.courseMessage, {
|
||||
courseCount: messageMap.courseCount,
|
||||
courseCountText: <b>{messageMap.courseCount}</b>,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
const restoreComponent = useCallback(async () => {
|
||||
try {
|
||||
await restoreContainerMutation.mutateAsync();
|
||||
showToast(intl.formatMessage(messages.undoDeleteContainerToastMessage));
|
||||
} catch (e) {
|
||||
showToast(intl.formatMessage(messageMap.undoDeleteError));
|
||||
}
|
||||
}, [messageMap]);
|
||||
|
||||
const onDelete = useCallback(async () => {
|
||||
await deleteContainerMutation.mutateAsync().then(() => {
|
||||
if (sidebarItemInfo?.id === containerId) {
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(
|
||||
messageMap.deleteSuccess,
|
||||
{
|
||||
label: intl.formatMessage(messages.undoDeleteContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
}).catch(() => {
|
||||
showToast(messageMap.deleteError);
|
||||
}).finally(() => {
|
||||
close();
|
||||
});
|
||||
}, [sidebarItemInfo, showToast, deleteContainerMutation, messageMap]);
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
isOpen={isOpen}
|
||||
close={close}
|
||||
variant="warning"
|
||||
title={messageMap?.title}
|
||||
icon={Warning}
|
||||
description={isLoading || linksIsLoading ? <LoadingSpinner size="sm" /> : deleteText}
|
||||
onDeleteSubmit={onDelete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerDeleter;
|
||||
@@ -10,19 +10,20 @@ import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import ContainerInfo from './ContainerInfo';
|
||||
import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api';
|
||||
import { SidebarBodyItemId, SidebarProvider } from '../common/context/SidebarContext';
|
||||
import { ContainerType } from '../../generic/key-utils';
|
||||
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
|
||||
import type { ToastActionData } from '../../generic/toast-context';
|
||||
|
||||
mockGetContainerMetadata.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockGetContainerMetadata.applyMock();
|
||||
mockGetContainerChildren.applyMock();
|
||||
|
||||
// TODO Remove this to un-skip section/subsection tests, when implemented
|
||||
const testIf = (condition) => (condition ? it : it.skip);
|
||||
|
||||
const { libraryId } = mockContentLibrary;
|
||||
const { unitId, subsectionId, sectionId } = mockGetContainerMetadata;
|
||||
|
||||
const render = (containerId, showOnlyPublished: boolean = false) => {
|
||||
const render = (containerId: string, showOnlyPublished: boolean = false) => {
|
||||
const params: { libraryId: string, selectedItemId?: string } = { libraryId, selectedItemId: containerId };
|
||||
return baseRender(<ContainerInfo />, {
|
||||
path: '/library/:libraryId/:selectedItemId?',
|
||||
@@ -45,28 +46,38 @@ const render = (containerId, showOnlyPublished: boolean = false) => {
|
||||
});
|
||||
};
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast;
|
||||
let mockShowToast: { (message: string, action?: ToastActionData | undefined): void; mock?: any; };
|
||||
|
||||
describe('<ContainerInfo />', () => {
|
||||
beforeEach(() => {
|
||||
({ axiosMock, mockShowToast } = initializeMocks());
|
||||
});
|
||||
[
|
||||
{
|
||||
containerType: ContainerType.Unit,
|
||||
containerId: unitId,
|
||||
},
|
||||
{
|
||||
containerType: ContainerType.Subsection,
|
||||
containerId: subsectionId,
|
||||
},
|
||||
{
|
||||
containerType: ContainerType.Section,
|
||||
containerId: sectionId,
|
||||
},
|
||||
].forEach(({ containerId, containerType }) => {
|
||||
describe(`<ContainerInfo /> with containerType: ${containerType}`, () => {
|
||||
beforeEach(() => {
|
||||
({ axiosMock, mockShowToast } = initializeMocks());
|
||||
mockSearchResult({
|
||||
results: [ // @ts-ignore
|
||||
{
|
||||
hits: [{
|
||||
blockType: containerType,
|
||||
displayName: `Test ${containerType}`,
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
containerType: 'Unit',
|
||||
containerId: unitId,
|
||||
},
|
||||
{
|
||||
containerType: 'Subsection',
|
||||
containerId: subsectionId,
|
||||
},
|
||||
{
|
||||
containerType: 'Section',
|
||||
containerId: sectionId,
|
||||
},
|
||||
].forEach(({ containerId, containerType }) => {
|
||||
testIf(containerType === 'Unit')(`should delete the ${containerType} using the menu`, async () => {
|
||||
it(`should delete the ${containerType} using the menu`, async () => {
|
||||
axiosMock.onDelete(getLibraryContainerApiUrl(containerId)).reply(200);
|
||||
render(containerId);
|
||||
|
||||
@@ -75,12 +86,12 @@ describe('<ContainerInfo />', () => {
|
||||
userEvent.click(screen.getByTestId('container-info-menu-toggle'));
|
||||
|
||||
// Click on Delete Item
|
||||
const deleteMenuItem = screen.getByRole('button', { name: 'Delete' });
|
||||
const deleteMenuItem = await screen.findByRole('button', { name: 'Delete' });
|
||||
expect(deleteMenuItem).toBeInTheDocument();
|
||||
fireEvent.click(deleteMenuItem);
|
||||
|
||||
// Confirm delete Modal is open
|
||||
expect(screen.getByText(`Delete ${containerType}`));
|
||||
expect(screen.getByText(`Delete ${containerType[0].toUpperCase()}${containerType.slice(1)}`));
|
||||
const deleteButton = screen.getByRole('button', { name: /delete/i });
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
|
||||
@@ -26,18 +26,16 @@ import { useLibraryRoutes } from '../routes';
|
||||
import { LibraryUnitBlocks } from '../units/LibraryUnitBlocks';
|
||||
import { LibraryContainerChildren } from '../section-subsections/LibraryContainerChildren';
|
||||
import messages from './messages';
|
||||
import componentMessages from '../components/messages';
|
||||
import ContainerDeleter from '../components/ContainerDeleter';
|
||||
import { useContainer, usePublishContainer } from '../data/apiHooks';
|
||||
import { ContainerType, getBlockType } from '../../generic/key-utils';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import ContainerDeleter from './ContainerDeleter';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
type ContainerPreviewProps = {
|
||||
containerId: string,
|
||||
displayName: string,
|
||||
};
|
||||
|
||||
const ContainerMenu = ({ containerId, displayName }: ContainerMenuProps) => {
|
||||
const ContainerMenu = ({ containerId }: ContainerPreviewProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
@@ -51,12 +49,12 @@ const ContainerMenu = ({ containerId, displayName }: ContainerMenuProps) => {
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt={intl.formatMessage(componentMessages.containerCardMenuAlt)}
|
||||
alt={intl.formatMessage(messages.containerCardMenuAlt)}
|
||||
data-testid="container-info-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={confirmDelete}>
|
||||
<FormattedMessage {...componentMessages.menuDeleteContainer} />
|
||||
<FormattedMessage {...messages.menuDeleteContainer} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
@@ -64,16 +62,11 @@ const ContainerMenu = ({ containerId, displayName }: ContainerMenuProps) => {
|
||||
isOpen={isConfirmingDelete}
|
||||
close={cancelDelete}
|
||||
containerId={containerId}
|
||||
displayName={displayName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type ContainerPreviewProps = {
|
||||
containerId: string,
|
||||
};
|
||||
|
||||
const ContainerPreview = ({ containerId } : ContainerPreviewProps) => {
|
||||
const containerType = getBlockType(containerId);
|
||||
if (containerType === ContainerType.Unit) {
|
||||
@@ -167,10 +160,7 @@ const ContainerInfo = () => {
|
||||
</Button>
|
||||
)}
|
||||
{showOpenButton && (
|
||||
<ContainerMenu
|
||||
containerId={containerId}
|
||||
displayName={container.displayName}
|
||||
/>
|
||||
<ContainerMenu containerId={containerId} />
|
||||
)}
|
||||
</div>
|
||||
<Tabs
|
||||
|
||||
1
src/library-authoring/containers/index.scss
Normal file
1
src/library-authoring/containers/index.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "./ContainerCard.scss";
|
||||
@@ -56,6 +56,161 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Failed to update container.',
|
||||
description: 'Message displayed when container update fails',
|
||||
},
|
||||
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.',
|
||||
},
|
||||
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',
|
||||
description: 'Menu item for open a collection/container.',
|
||||
},
|
||||
menuDeleteContainer: {
|
||||
id: 'course-authoring.library-authoring.container.delete-menu-text',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Menu item to delete a container.',
|
||||
},
|
||||
menuRemoveFromCollection: {
|
||||
id: 'course-authoring.library-authoring.component.menu.remove',
|
||||
defaultMessage: 'Remove from collection',
|
||||
description: 'Menu item for remove an item from collection.',
|
||||
},
|
||||
menuAddToCollection: {
|
||||
id: 'course-authoring.library-authoring.component.menu.add',
|
||||
defaultMessage: 'Add to collection',
|
||||
description: 'Menu item for add a component to collection.',
|
||||
},
|
||||
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.',
|
||||
},
|
||||
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',
|
||||
},
|
||||
deleteSectionWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.section.delete-confirmation-title',
|
||||
defaultMessage: 'Delete Section',
|
||||
description: 'Title text for the warning displayed before deleting a Section',
|
||||
},
|
||||
deleteSectionCourseMessaage: {
|
||||
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',
|
||||
},
|
||||
deleteSubsectionParentMessage: {
|
||||
id: 'course-authoring.library-authoring.subsection.delete-parent-message',
|
||||
defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentName} in this library.',
|
||||
description: 'Parent usage details shown before deleting a subsection',
|
||||
},
|
||||
deleteSubsectionMultipleParentMessage: {
|
||||
id: 'course-authoring.library-authoring.subsection.delete-multiple-parent-message',
|
||||
defaultMessage: 'By deleting this subsection, you will also be deleting it from {parentCount} Sections in this library.',
|
||||
description: 'Parent usage details shown before deleting a subsection part of multiple sections',
|
||||
},
|
||||
deleteSubsectionWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.subsection.delete-confirmation-title',
|
||||
defaultMessage: 'Delete Subsection',
|
||||
description: 'Title text for the warning displayed before deleting a Subsection',
|
||||
},
|
||||
deleteSubsectionCourseMessaage: {
|
||||
id: 'course-authoring.library-authoring.subsection.delete-parent-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',
|
||||
},
|
||||
deleteUnitParentMessage: {
|
||||
id: 'course-authoring.library-authoring.unit.delete-parent-message',
|
||||
defaultMessage: 'By deleting this unit, you will also be deleting it from {parentName} in this library.',
|
||||
description: 'Parent usage details shown before deleting a unit',
|
||||
},
|
||||
deleteUnitMultipleParentMessage: {
|
||||
id: 'course-authoring.library-authoring.unit.delete-multiple-parent-message',
|
||||
defaultMessage: 'By deleting this unit, you will also be deleting it from {parentCount} Subsections in this library.',
|
||||
description: 'Parent usage details shown before deleting a unit part of multiple subsections',
|
||||
},
|
||||
deleteUnitWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.unit.delete-confirmation-title',
|
||||
defaultMessage: 'Delete Unit',
|
||||
description: 'Title text for the warning displayed before deleting a Unit',
|
||||
},
|
||||
deleteUnitCourseMessage: {
|
||||
id: 'course-authoring.library-authoring.unit.delete-course-usage-message',
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
deleteSectionSuccess: {
|
||||
id: 'course-authoring.library-authoring.section.delete.success',
|
||||
defaultMessage: 'Section deleted',
|
||||
description: 'Message to display on delete section success',
|
||||
},
|
||||
deleteSectionFailed: {
|
||||
id: 'course-authoring.library-authoring.section.delete-failed-error',
|
||||
defaultMessage: 'Failed to delete section',
|
||||
description: 'Message to display on failure to delete a section',
|
||||
},
|
||||
undoDeleteSectionToastFailed: {
|
||||
id: 'course-authoring.library-authoring.section.undo-delete-section-failed',
|
||||
defaultMessage: 'Failed to undo delete Section operation',
|
||||
description: 'Message to display on failure to undo delete section',
|
||||
},
|
||||
deleteSubsectionSuccess: {
|
||||
id: 'course-authoring.library-authoring.subsection.delete.success',
|
||||
defaultMessage: 'Subsection deleted',
|
||||
description: 'Message to display on delete subsection success',
|
||||
},
|
||||
deleteSubsectionFailed: {
|
||||
id: 'course-authoring.library-authoring.subsection.delete-failed-error',
|
||||
defaultMessage: 'Failed to delete subsection',
|
||||
description: 'Message to display on failure to delete a subsection',
|
||||
},
|
||||
undoDeleteSubsectionToastFailed: {
|
||||
id: 'course-authoring.library-authoring.subsection.undo-delete-subsection-failed',
|
||||
defaultMessage: 'Failed to undo delete Subsection operation',
|
||||
description: 'Message to display on failure to undo delete subsection',
|
||||
},
|
||||
undoDeleteContainerToastMessage: {
|
||||
id: 'course-authoring.library-authoring.container.undo-delete-container-toast-text',
|
||||
defaultMessage: 'Undo successful',
|
||||
description: 'Message to display on undo delete container success',
|
||||
},
|
||||
undoDeleteContainerToastAction: {
|
||||
id: 'course-authoring.library-authoring.container.undo-delete-container-toast-button',
|
||||
defaultMessage: 'Undo',
|
||||
description: 'Toast message to undo deletion of container',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -256,6 +256,28 @@ mockRestoreLibraryBlock.applyMock = () => (
|
||||
jest.spyOn(api, 'restoreLibraryBlock').mockImplementation(mockRestoreLibraryBlock)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock for `deleteContainer()`
|
||||
*/
|
||||
export async function mockDeleteContainer(): ReturnType<typeof api.deleteContainer> {
|
||||
// no-op
|
||||
}
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockDeleteContainer.applyMock = () => (
|
||||
jest.spyOn(api, 'deleteContainer').mockImplementation(mockDeleteContainer)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock for `restoreContainer()`
|
||||
*/
|
||||
export async function mockRestoreContainer(): ReturnType<typeof api.restoreContainer> {
|
||||
// no-op
|
||||
}
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockRestoreContainer.applyMock = () => (
|
||||
jest.spyOn(api, 'restoreContainer').mockImplementation(mockRestoreContainer)
|
||||
);
|
||||
|
||||
/**
|
||||
* Mock for `getXBlockFields()`
|
||||
*
|
||||
@@ -751,13 +773,13 @@ export async function mockGetEntityLinks(
|
||||
): ReturnType<typeof courseLibApi.getEntityLinks> {
|
||||
const thisMock = mockGetEntityLinks;
|
||||
switch (upstreamUsageKey) {
|
||||
case thisMock.upstreamUsageKey: return thisMock.response;
|
||||
case thisMock.upstreamContainerKey: return thisMock.response;
|
||||
case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response;
|
||||
case thisMock.emptyUsageKey: return thisMock.emptyComponentUsage;
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
mockGetEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished;
|
||||
mockGetEntityLinks.upstreamContainerKey = mockLibraryBlockMetadata.usageKeyPublished;
|
||||
mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({
|
||||
id: 875,
|
||||
upstreamContextTitle: 'CS problems 3',
|
||||
@@ -779,3 +801,115 @@ mockGetEntityLinks.applyMock = () => jest.spyOn(
|
||||
courseLibApi,
|
||||
'getEntityLinks',
|
||||
).mockImplementation(mockGetEntityLinks);
|
||||
|
||||
export async function mockGetContainerEntityLinks(
|
||||
_downstreamContextKey?: string,
|
||||
_readyToSync?: boolean,
|
||||
upstreamContainerKey?: string,
|
||||
): ReturnType<typeof courseLibApi.getContainerEntityLinks> {
|
||||
const thisMock = mockGetContainerEntityLinks;
|
||||
switch (upstreamContainerKey) {
|
||||
case thisMock.unitKey: return thisMock.unitResponse;
|
||||
case thisMock.subsectionKey: return thisMock.subsectionResponse;
|
||||
case thisMock.sectionKey: return thisMock.sectionResponse;
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
mockGetContainerEntityLinks.unitKey = mockGetContainerMetadata.unitId;
|
||||
mockGetContainerEntityLinks.unitResponse = [
|
||||
{
|
||||
id: 1,
|
||||
upstreamContextTitle: 'CS problems 3',
|
||||
upstreamVersion: 1,
|
||||
readyToSync: false,
|
||||
upstreamContainerKey: mockGetContainerEntityLinks.unitKey,
|
||||
upstreamContextKey: 'lib:Axim:TEST2',
|
||||
downstreamUsageKey: 'some-key',
|
||||
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
|
||||
versionSynced: 1,
|
||||
versionDeclined: null,
|
||||
created: '2025-02-08T14:07:05.588484Z',
|
||||
updated: '2025-02-08T14:07:05.588484Z',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
upstreamContextTitle: 'CS problems 3',
|
||||
upstreamVersion: 1,
|
||||
readyToSync: false,
|
||||
upstreamContainerKey: mockGetContainerEntityLinks.unitKey,
|
||||
upstreamContextKey: 'lib:Axim:TEST2',
|
||||
downstreamUsageKey: 'some-key-1',
|
||||
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
|
||||
versionSynced: 1,
|
||||
versionDeclined: null,
|
||||
created: '2025-02-08T14:07:05.588484Z',
|
||||
updated: '2025-02-08T14:07:05.588484Z',
|
||||
},
|
||||
];
|
||||
mockGetContainerEntityLinks.subsectionKey = mockGetContainerMetadata.subsectionId;
|
||||
mockGetContainerEntityLinks.subsectionResponse = [
|
||||
{
|
||||
id: 1,
|
||||
upstreamContextTitle: 'CS problems 3',
|
||||
upstreamVersion: 1,
|
||||
readyToSync: false,
|
||||
upstreamContainerKey: mockGetContainerEntityLinks.subsectionKey,
|
||||
upstreamContextKey: 'lib:Axim:TEST2',
|
||||
downstreamUsageKey: 'some-subsection-key',
|
||||
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
|
||||
versionSynced: 1,
|
||||
versionDeclined: null,
|
||||
created: '2025-02-08T14:07:05.588484Z',
|
||||
updated: '2025-02-08T14:07:05.588484Z',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
upstreamContextTitle: 'CS problems 3',
|
||||
upstreamVersion: 1,
|
||||
readyToSync: false,
|
||||
upstreamContainerKey: mockGetContainerEntityLinks.subsectionKey,
|
||||
upstreamContextKey: 'lib:Axim:TEST2',
|
||||
downstreamUsageKey: 'some-subsection-key-1',
|
||||
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
|
||||
versionSynced: 1,
|
||||
versionDeclined: null,
|
||||
created: '2025-02-08T14:07:05.588484Z',
|
||||
updated: '2025-02-08T14:07:05.588484Z',
|
||||
},
|
||||
];
|
||||
mockGetContainerEntityLinks.sectionKey = mockGetContainerMetadata.sectionId;
|
||||
mockGetContainerEntityLinks.sectionResponse = [
|
||||
{
|
||||
id: 1,
|
||||
upstreamContextTitle: 'CS problems 3',
|
||||
upstreamVersion: 1,
|
||||
readyToSync: false,
|
||||
upstreamContainerKey: mockGetContainerEntityLinks.sectionKey,
|
||||
upstreamContextKey: 'lib:Axim:TEST2',
|
||||
downstreamUsageKey: 'some-section-key',
|
||||
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
|
||||
versionSynced: 1,
|
||||
versionDeclined: null,
|
||||
created: '2025-02-08T14:07:05.588484Z',
|
||||
updated: '2025-02-08T14:07:05.588484Z',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
upstreamContextTitle: 'CS problems 3',
|
||||
upstreamVersion: 1,
|
||||
readyToSync: false,
|
||||
upstreamContainerKey: mockGetContainerEntityLinks.sectionKey,
|
||||
upstreamContextKey: 'lib:Axim:TEST2',
|
||||
downstreamUsageKey: 'some-section-key-1',
|
||||
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
|
||||
versionSynced: 1,
|
||||
versionDeclined: null,
|
||||
created: '2025-02-08T14:07:05.588484Z',
|
||||
updated: '2025-02-08T14:07:05.588484Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockGetContainerEntityLinks.applyMock = () => jest.spyOn(
|
||||
courseLibApi,
|
||||
'getContainerEntityLinks',
|
||||
).mockImplementation(mockGetContainerEntityLinks);
|
||||
|
||||
@@ -876,11 +876,22 @@ export const usePublishContainer = (containerId: string) => {
|
||||
*/
|
||||
export const useContentFromSearchIndex = (contentIds: string[]) => {
|
||||
const { client, indexName } = useContentSearchConnection();
|
||||
const extraFilter = [`usage_key IN ["${contentIds.join('","')}"]`];
|
||||
// NOTE: assuming that all contentIds are part of a single libraryId as we don't have a usecase
|
||||
// of passing multiple contentIds from different libraries.
|
||||
if (contentIds.length > 0) {
|
||||
try {
|
||||
const libraryId = getLibraryId(contentIds?.[0]);
|
||||
extraFilter.push(`context_key = "${libraryId}"`);
|
||||
} catch {
|
||||
// Ignore as the contentIds could be part of course instead of a library.
|
||||
}
|
||||
}
|
||||
return useContentSearchResults({
|
||||
client,
|
||||
indexName,
|
||||
searchKeywords: '',
|
||||
extraFilter: [`usage_key IN ["${contentIds.join('","')}"]`],
|
||||
extraFilter,
|
||||
limit: contentIds.length,
|
||||
enabled: !!contentIds.length,
|
||||
skipBlockTypeFetch: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "./component-info/ComponentPreview";
|
||||
@import "./components";
|
||||
@import "./containers";
|
||||
@import "./generic";
|
||||
@import "./LibraryAuthoringPage";
|
||||
@import "./units";
|
||||
|
||||
@@ -22,10 +22,10 @@ import containerMessages from '../containers/messages';
|
||||
import { Container } from '../data/api';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { ContainerMenu } from '../components/ContainerCard';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useRunOnNextRender } from '../../utils';
|
||||
import { ContainerMenu } from '../containers/ContainerCard';
|
||||
|
||||
interface LibraryContainerChildrenProps {
|
||||
containerKey: string;
|
||||
@@ -111,10 +111,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
|
||||
onClick={readOnly ? undefined : jumpToManageTags}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<ContainerMenu
|
||||
containerKey={container.originalId}
|
||||
displayName={container.displayName}
|
||||
/>
|
||||
<ContainerMenu containerKey={container.originalId} />
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user