diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx
index b748a25695..adb54c74a7 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx
@@ -55,6 +55,7 @@ describe('', () => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
+ jest.useFakeTimers();
fetchMock.mockReset();
// The Meilisearch client-side API uses fetch, not Axios.
@@ -89,6 +90,10 @@ describe('', () => {
});
});
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
const renderLibraryCollectionPage = async (collectionId?: string, libraryId?: string) => {
const libId = libraryId || mockContentLibrary.libraryId;
const colId = collectionId || mockCollection.collectionId;
@@ -354,28 +359,40 @@ describe('', () => {
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
});
- it('should remove component from collection and hides sidebar', async () => {
+ it('should remove unit from collection and hides sidebar', async () => {
const url = getLibraryCollectionItemsApiUrl(
mockContentLibrary.libraryId,
mockCollection.collectionId,
);
axiosMock.onDelete(url).reply(204);
- const displayName = 'Introduction to Testing';
+ const displayName = 'Test Unit';
await renderLibraryCollectionPage();
+ // Wait for the unit cards to load
+ await waitFor(() => expect(screen.getAllByTestId('container-card-menu-toggle').length).toBeGreaterThan(0));
+
// open sidebar
fireEvent.click(await screen.findByText(displayName));
+
+ // advance timers so the sidebar opens
+ jest.advanceTimersByTime(500);
+
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).toBeInTheDocument());
- const menuBtns = await screen.findAllByRole('button', { name: 'Component actions menu' });
- // open menu
- fireEvent.click(menuBtns[0]);
+ // Open menu
+ fireEvent.click((await screen.findAllByTestId('container-card-menu-toggle'))[0]);
+
+ // Click remove to collection
+ fireEvent.click(screen.getByRole('button', { name: 'Remove from collection' }));
- fireEvent.click(await screen.findByText('Remove from collection'));
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Item successfully removed');
+
+ // advance timers so the sidebar close logic executes
+ jest.advanceTimersByTime(500);
+
// Should close sidebar as component was removed
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
@@ -399,7 +416,7 @@ describe('', () => {
expect(mockShowToast).toHaveBeenCalledWith('Failed to remove item');
});
- it('should remove unit from collection and hides sidebar', async () => {
+ it.only('should remove unit from collection and hides sidebar', async () => {
const url = getLibraryCollectionItemsApiUrl(
mockContentLibrary.libraryId,
mockCollection.collectionId,
@@ -413,6 +430,8 @@ describe('', () => {
// open sidebar
fireEvent.click(await screen.findByText(displayName));
+ // ⏩ let the 500ms pass in test-land
+ jest.advanceTimersByTime(500);
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).toBeInTheDocument());
// Open menu
@@ -425,6 +444,8 @@ describe('', () => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Item successfully removed');
+ // ⏩ let the 500ms pass in test-land
+ jest.advanceTimersByTime(500);
// Should close sidebar as component was removed
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
diff --git a/src/library-authoring/containers/ContainerCard.test.tsx b/src/library-authoring/containers/ContainerCard.test.tsx
index 020aacfe83..a073e214a8 100644
--- a/src/library-authoring/containers/ContainerCard.test.tsx
+++ b/src/library-authoring/containers/ContainerCard.test.tsx
@@ -251,6 +251,27 @@ describe('', () => {
expect(mockShowToast).toHaveBeenCalledWith('Failed to delete unit');
});
+ it('should clear click timer on unmount to avoid running sidebar open after unmount', () => {
+ jest.useFakeTimers();
+ const container = getContainerHitSample();
+ const { unmount } = render();
+
+ // Single click -> starts the setTimeout
+ fireEvent.click(screen.getByText('unit Display Formated Name'));
+
+ // Unmount before the timeout fires
+ unmount();
+
+ // Fast-forward timers
+ jest.advanceTimersByTime(500);
+
+ // Expect nothing got called after unmount
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockShowToast).not.toHaveBeenCalled();
+
+ jest.useRealTimers();
+ });
+
it('should render no child blocks in unit card preview', async () => {
render();
diff --git a/src/library-authoring/containers/ContainerCard.tsx b/src/library-authoring/containers/ContainerCard.tsx
index e3e946c3c7..a492101612 100644
--- a/src/library-authoring/containers/ContainerCard.tsx
+++ b/src/library-authoring/containers/ContainerCard.tsx
@@ -1,4 +1,6 @@
-import { ReactNode, useCallback, useContext } from 'react';
+import {
+ ReactNode, useCallback, useContext, useRef, useEffect,
+} from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
@@ -26,6 +28,8 @@ import { useRunOnNextRender } from '../../utils';
import BaseCard from '../components/BaseCard';
import AddComponentWidget from '../components/AddComponentWidget';
+const DOUBLE_CLICK_DELAY = 500; // ms
+
type ContainerMenuProps = {
containerKey: string;
displayName: string;
@@ -247,6 +251,8 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
const { showOnlyPublished } = useLibraryContext();
const { openContainerInfoSidebar, openItemSidebar, sidebarItemInfo } = useSidebarContext();
+ const clickTimerRef = useRef | null>(null);
+
const {
blockType: itemType,
formatted,
@@ -269,18 +275,33 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
const { navigateTo } = useLibraryRoutes();
- const selectContainer = useCallback((e?: React.MouseEvent) => {
- const doubleClicked = (e?.detail || 0) > 1;
- if (componentPickerMode) {
- // In component picker mode, we want to open the sidebar
- // without changing the URL
- openContainerInfoSidebar(containerKey);
- } else if (!doubleClicked) {
- openItemSidebar(containerKey, SidebarBodyItemId.ContainerInfo);
- } else {
- navigateTo({ containerId: containerKey });
+ const selectContainer = useCallback(
+ () => {
+ if (clickTimerRef.current) {
+ clearTimeout(clickTimerRef.current);
+ clickTimerRef.current = null;
+
+ navigateTo({ containerId: containerKey });
+ } else {
+ clickTimerRef.current = setTimeout(() => {
+ clickTimerRef.current = null;
+
+ if (componentPickerMode) {
+ openContainerInfoSidebar(containerKey);
+ } else {
+ openItemSidebar(containerKey, SidebarBodyItemId.ContainerInfo);
+ }
+ }, DOUBLE_CLICK_DELAY);
+ }
+ },
+ [containerKey, componentPickerMode, openContainerInfoSidebar, openItemSidebar, navigateTo],
+ );
+
+ useEffect(() => () => {
+ if (clickTimerRef.current) {
+ clearTimeout(clickTimerRef.current);
}
- }, [containerKey, openContainerInfoSidebar, openItemSidebar, navigateTo]);
+ }, []);
return (