From f1eb606c07f95420645e6a9a511ab17770f7e977 Mon Sep 17 00:00:00 2001 From: James Cocker Date: Mon, 9 Mar 2026 13:44:03 +0000 Subject: [PATCH 1/3] Removed height limitations on components with some logic to invert the last few saved queries Signed-off-by: James Cocker --- .../saved-queries/CollapsibleSideBar.tsx | 78 ++++--------------- .../test-runs/saved-queries/QueryItem.tsx | 4 +- .../styles/test-runs/TestRunsPage.module.css | 4 - .../saved-queries/CollapsibleSideBar.test.tsx | 29 ------- 4 files changed, 20 insertions(+), 95 deletions(-) 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..a0eac739 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 (
@@ -262,15 +210,23 @@ export default function CollapsibleSideBar({ handleEditQueryName }: CollapsibleS items={filteredSortableQueries.map((query) => query.createdAt)} strategy={verticalListSortingStrategy} > - {filteredSortableQueries.map((query) => ( - - ))} + {filteredSortableQueries.map((query, index) => { + // If there are more than 10 saved queries, the last 3 should display menu upwards + const shouldDisplayMenuUpwards = + filteredSortableQueries.length > 12 && + index >= filteredSortableQueries.length - 3; + + 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..c12a7dfd 100644 --- a/galasa-ui/src/components/test-runs/saved-queries/QueryItem.tsx +++ b/galasa-ui/src/components/test-runs/saved-queries/QueryItem.tsx @@ -24,6 +24,7 @@ interface QueryItemProps { isCollapsed?: boolean; handleEditQueryName?: (queryName: string) => void; setNotification?: Dispatch>; + displayMenuUpwards?: boolean; } const ICON_SIZE = 18; @@ -34,6 +35,7 @@ export default function QueryItem({ isCollapsed = false, handleEditQueryName, setNotification, + displayMenuUpwards = false, }: QueryItemProps) { const translations = useTranslations('QueryItem'); const router = useRouter(); @@ -208,7 +210,7 @@ export default function QueryItem({ iconDescription={translations('actions')} flipped className={styles.overflowMenu} - align="top" + direction={displayMenuUpwards ? '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 = ''; - }); - }); - }); }); From d2e1408c3e404315718b74045a2bf3cc891f4c11 Mon Sep 17 00:00:00 2001 From: James Cocker Date: Mon, 9 Mar 2026 13:54:41 +0000 Subject: [PATCH 2/3] Switched out menu invertion from last 3 elements to a dynamic approach Signed-off-by: James Cocker --- .../saved-queries/CollapsibleSideBar.tsx | 26 ++++------- .../test-runs/saved-queries/QueryItem.tsx | 45 ++++++++++++++++--- 2 files changed, 49 insertions(+), 22 deletions(-) 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 a0eac739..7491a7fa 100644 --- a/galasa-ui/src/components/test-runs/saved-queries/CollapsibleSideBar.tsx +++ b/galasa-ui/src/components/test-runs/saved-queries/CollapsibleSideBar.tsx @@ -210,23 +210,15 @@ export default function CollapsibleSideBar({ handleEditQueryName }: CollapsibleS items={filteredSortableQueries.map((query) => query.createdAt)} strategy={verticalListSortingStrategy} > - {filteredSortableQueries.map((query, index) => { - // If there are more than 10 saved queries, the last 3 should display menu upwards - const shouldDisplayMenuUpwards = - filteredSortableQueries.length > 12 && - index >= filteredSortableQueries.length - 3; - - return ( - - ); - })} + {filteredSortableQueries.map((query) => ( + + ))}
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 c12a7dfd..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'; @@ -24,10 +24,11 @@ interface QueryItemProps { isCollapsed?: boolean; handleEditQueryName?: (queryName: string) => void; setNotification?: Dispatch>; - displayMenuUpwards?: boolean; } const ICON_SIZE = 18; +// Height of the overflow menu with 5 items (40px per item) +const MENU_HEIGHT = 200; export default function QueryItem({ query, @@ -35,7 +36,6 @@ export default function QueryItem({ isCollapsed = false, handleEditQueryName, setNotification, - displayMenuUpwards = false, }: QueryItemProps) { const translations = useTranslations('QueryItem'); const router = useRouter(); @@ -48,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 @@ -185,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 : ''}`} > @@ -210,7 +245,7 @@ export default function QueryItem({ iconDescription={translations('actions')} flipped className={styles.overflowMenu} - direction={displayMenuUpwards ? 'top' : 'bottom'} + direction={shouldOpenUpwards ? 'top' : 'bottom'} > {actions.map((action) => ( Date: Mon, 9 Mar 2026 13:54:58 +0000 Subject: [PATCH 3/3] Added unit tests Signed-off-by: James Cocker --- .../saved-queries/QueryItem.test.tsx | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) 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(); + }); + }); });