feat: container delete confirmation modal (#2145)

Update container delete confirmation modal based on #1982 and #1981
This commit is contained in:
Navin Karkera
2025-06-24 19:37:14 +02:00
committed by GitHub
parent 60cebf703d
commit 4905f3bbc7
21 changed files with 802 additions and 256 deletions

View File

@@ -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[]> => {

View File

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

View File

@@ -68,7 +68,7 @@ const DeleteModal = ({
</ActionRow>
)}
>
<p>{modalDescription}</p>
<div>{modalDescription}</div>
</AlertModal>
);
};

View File

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

View File

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

View File

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

View File

@@ -1,2 +1 @@
@import "./BaseCard.scss";
@import "./ContainerCard.scss";

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
@import "./ContainerCard.scss";

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
@import "./component-info/ComponentPreview";
@import "./components";
@import "./containers";
@import "./generic";
@import "./LibraryAuthoringPage";
@import "./units";

View File

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