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 (