diff --git a/galasa-ui/src/components/test-runs/saved-queries/CollapsibleSideBar.tsx b/galasa-ui/src/components/test-runs/saved-queries/CollapsibleSideBar.tsx index ac592332..7491a7fa 100644 --- a/galasa-ui/src/components/test-runs/saved-queries/CollapsibleSideBar.tsx +++ b/galasa-ui/src/components/test-runs/saved-queries/CollapsibleSideBar.tsx @@ -9,7 +9,6 @@ import { useEffect, useMemo, useState } from 'react'; import { HeaderMenuButton, Search, Button, InlineNotification } from '@carbon/react'; import { Add } from '@carbon/icons-react'; import styles from '@/styles/test-runs/saved-queries/CollapsibleSideBar.module.css'; -import testRunsPageStyles from '@/styles/test-runs/TestRunsPage.module.css'; import { arrayMove, SortableContext, @@ -58,11 +57,6 @@ export default function CollapsibleSideBar({ handleEditQueryName }: CollapsibleS // State to hold the data of the item currently being dragged for the DragOverlay const [activeQuery, setActiveQuery] = useState(null); - const [sideNavExpandedHeight, setSideNavExpandedHeight] = useState(0); - const [mainContentElement, setMainContentElement] = useState(null); - const SIDE_NAV_MIN_HEIGHT_PIXELS = 700; - const SIDE_NAV_HEIGHT_IF_NOT_RESIZABLE_PIXELS = 850; - // Isolate user-sortable queries from the default query const sortableQueries = useMemo( () => savedQueries.filter((query) => query.createdAt !== defaultQuery.createdAt), @@ -154,51 +148,6 @@ export default function CollapsibleSideBar({ handleEditQueryName }: CollapsibleS return sortableQueries; }, [searchTerm, sortableQueries]); - // Grab the main content element on page load. - useEffect(() => { - setMainContentElement(document.querySelector('.' + testRunsPageStyles.mainContent)); - }, []); - - useEffect(() => { - const updateSideNavHeight = () => { - if (mainContentElement) { - // As the mainContent for the test runs details is also flex, we must set this height to a minimum, wait a short while, then set the height of this element to the main content minus an offset. - setSideNavExpandedHeight(SIDE_NAV_MIN_HEIGHT_PIXELS); - setTimeout(() => { - // The .clientHeight seems to need mainContentElement checked inside the setTimeout(). - if (mainContentElement) { - const newHeight = mainContentElement.clientHeight - 50; - setSideNavExpandedHeight(newHeight); - } - }, 0); - } - }; - - // Initial update - updateSideNavHeight(); - - // Add event listener for main content resize. - const resizeObserver = new ResizeObserver((entries) => { - // Check if there's a valid entry. - if (entries[0]) { - updateSideNavHeight(); - } - }); - - if (mainContentElement) { - resizeObserver.observe(mainContentElement); - } else { - setSideNavExpandedHeight(SIDE_NAV_HEIGHT_IF_NOT_RESIZABLE_PIXELS); - } - - // Cleanup function to remove the event listener when the component unmounts - return () => { - if (mainContentElement) { - resizeObserver.unobserve(mainContentElement); - } - }; - }, [mainContentElement]); - return (
diff --git a/galasa-ui/src/components/test-runs/saved-queries/QueryItem.tsx b/galasa-ui/src/components/test-runs/saved-queries/QueryItem.tsx index 9fc30d1d..f575d670 100644 --- a/galasa-ui/src/components/test-runs/saved-queries/QueryItem.tsx +++ b/galasa-ui/src/components/test-runs/saved-queries/QueryItem.tsx @@ -13,7 +13,7 @@ import { OverflowMenu, OverflowMenuItem } from '@carbon/react'; import { useSavedQueries } from '@/contexts/SavedQueriesContext'; import { useTranslations } from 'next-intl'; import { usePathname, useRouter } from 'next/navigation'; -import { Dispatch, SetStateAction } from 'react'; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; import { generateUniqueQueryName } from '@/utils/functions/savedQueries'; import { decodeStateFromUrlParam, encodeStateToUrlParam } from '@/utils/encoding/urlEncoder'; import { NOTIFICATION_VISIBLE_MILLISECS, TEST_RUNS_QUERY_PARAMS } from '@/utils/constants/common'; @@ -27,6 +27,8 @@ interface QueryItemProps { } const ICON_SIZE = 18; +// Height of the overflow menu with 5 items (40px per item) +const MENU_HEIGHT = 200; export default function QueryItem({ query, @@ -46,6 +48,37 @@ export default function QueryItem({ disabled, }); + // Ref for the query item container to calculate position + const itemRef = useRef(null); + // State to track whether the menu should open upwards + const [shouldOpenUpwards, setShouldOpenUpwards] = useState(false); + + // Calculate menu direction based on viewport position + useEffect(() => { + const calculateMenuDirection = () => { + if (!itemRef.current) return; + + const rect = itemRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + + // Check if opening the menu downward would cause it to overflow the viewport + const wouldOverflow = rect.bottom + MENU_HEIGHT > viewportHeight; + setShouldOpenUpwards(wouldOverflow); + }; + + // Calculate on mount and when the component updates + calculateMenuDirection(); + + // Recalculate on window resize + window.addEventListener('resize', calculateMenuDirection); + window.addEventListener('scroll', calculateMenuDirection); + + return () => { + window.removeEventListener('resize', calculateMenuDirection); + window.removeEventListener('scroll', calculateMenuDirection); + }; + }, []); + const isDefault = defaultQuery.createdAt === query.createdAt; // Actions for the query item @@ -183,7 +216,11 @@ export default function QueryItem({ return (
{ + // Combine refs: one for dnd-kit sortable, one for position calculation + setNodeRef(node); + (itemRef as React.MutableRefObject).current = node; + }} style={style} className={`${styles.sideNavItem} ${disabled ? styles.disabled : ''} ${isCollapsed ? styles.collapsed : ''}`} > @@ -208,7 +245,7 @@ export default function QueryItem({ iconDescription={translations('actions')} flipped className={styles.overflowMenu} - align="top" + direction={shouldOpenUpwards ? 'top' : 'bottom'} > {actions.map((action) => ( { expect(mockSetSavedQueries).not.toHaveBeenCalled(); }); }); - - describe('updating side nav height', () => { - test('should not observe the main content if main content not loaded', async () => { - render(); - expect(mockObserve).toHaveBeenCalledTimes(0); - }); - - test('should observe the main content if main content rendered, and set to height of main content -50px', async () => { - const mainContentElement = document.createElement('div'); - mainContentElement.className = 'mainContent'; - document.body.appendChild(mainContentElement); - - render(); - - const sidebar = screen.getByLabelText('Saved Queries Sidebar'); - - await waitFor(() => { - expect(mockObserve).toHaveBeenCalledTimes(1); - - if (sidebar) { - expect(sidebar.style.height).toBe('-50px'); - } else { - fail('could not find sidebar'); - } - - document.body.innerHTML = ''; - }); - }); - }); }); diff --git a/galasa-ui/src/tests/components/test-runs/saved-queries/QueryItem.test.tsx b/galasa-ui/src/tests/components/test-runs/saved-queries/QueryItem.test.tsx index 51bb3c28..afda7cdf 100644 --- a/galasa-ui/src/tests/components/test-runs/saved-queries/QueryItem.test.tsx +++ b/galasa-ui/src/tests/components/test-runs/saved-queries/QueryItem.test.tsx @@ -331,4 +331,173 @@ describe('QueryItem', () => { } as NotificationType); }); }); + + describe('Dynamic Menu Direction', () => { + beforeEach(() => { + // Mock getBoundingClientRect + Element.prototype.getBoundingClientRect = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should set menu direction to bottom (downward) when there is enough space below', () => { + // Mock element positioned at top of viewport with plenty of space below + (Element.prototype.getBoundingClientRect as jest.Mock).mockReturnValue({ + bottom: 100, // Element is at 100px from top + top: 80, + left: 0, + right: 100, + width: 100, + height: 20, + }); + + // Mock window.innerHeight to simulate a tall viewport + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800, // 800px viewport height + }); + + render(); + + const overflowMenu = screen.getByTestId('overflow-menu'); + // Menu should open downward (direction="bottom") when there's space + expect(overflowMenu).toHaveAttribute('direction', 'bottom'); + }); + + test('should set menu direction to top (upward) when opening downward would overflow viewport', () => { + // Mock element positioned near bottom of viewport + (Element.prototype.getBoundingClientRect as jest.Mock).mockReturnValue({ + bottom: 750, // Element is at 750px from top + top: 730, + left: 0, + right: 100, + width: 100, + height: 20, + }); + + // Mock window.innerHeight + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800, // 800px viewport height + }); + + // With element at 750px and estimated menu height of 200px, + // total would be 950px which exceeds viewport height of 800px + render(); + + const overflowMenu = screen.getByTestId('overflow-menu'); + // Menu should open upward (direction="top") to avoid overflow + expect(overflowMenu).toHaveAttribute('direction', 'top'); + }); + + test('should recalculate menu direction on window resize', () => { + // Start with element near bottom + (Element.prototype.getBoundingClientRect as jest.Mock).mockReturnValue({ + bottom: 750, + top: 730, + left: 0, + right: 100, + width: 100, + height: 20, + }); + + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800, + }); + + const { rerender } = render(); + + let overflowMenu = screen.getByTestId('overflow-menu'); + expect(overflowMenu).toHaveAttribute('direction', 'top'); + + // Simulate window resize to make viewport taller + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 1200, // Now there's enough space + }); + + // Trigger resize event + fireEvent(window, new Event('resize')); + + // Re-render to get updated component + rerender(); + + overflowMenu = screen.getByTestId('overflow-menu'); + // Menu should now open downward since there's space + expect(overflowMenu).toHaveAttribute('direction', 'bottom'); + }); + + test('should recalculate menu direction on scroll', () => { + // Start with element in middle of viewport + (Element.prototype.getBoundingClientRect as jest.Mock).mockReturnValue({ + bottom: 400, + top: 380, + left: 0, + right: 100, + width: 100, + height: 20, + }); + + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800, + }); + + const { rerender } = render(); + + let overflowMenu = screen.getByTestId('overflow-menu'); + expect(overflowMenu).toHaveAttribute('direction', 'bottom'); + + // Simulate scroll that moves element near bottom + (Element.prototype.getBoundingClientRect as jest.Mock).mockReturnValue({ + bottom: 750, + top: 730, + left: 0, + right: 100, + width: 100, + height: 20, + }); + + // Trigger scroll event + fireEvent(window, new Event('scroll')); + + // Re-render to get updated component + rerender(); + + overflowMenu = screen.getByTestId('overflow-menu'); + // Menu should now open upward since it would overflow + expect(overflowMenu).toHaveAttribute('direction', 'top'); + }); + + test('should clean up event listeners on unmount', () => { + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + (Element.prototype.getBoundingClientRect as jest.Mock).mockReturnValue({ + bottom: 100, + top: 80, + left: 0, + right: 100, + width: 100, + height: 20, + }); + + const { unmount } = render(); + + unmount(); + + // Verify that event listeners were removed + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + + removeEventListenerSpy.mockRestore(); + }); + }); });