Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<SavedQueryType | null>(null);

const [sideNavExpandedHeight, setSideNavExpandedHeight] = useState(0);
const [mainContentElement, setMainContentElement] = useState<Element | null>(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),
Expand Down Expand Up @@ -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 (
<div className={styles.container} aria-label={translations('savedQueriesHeaderLabel')}>
<DndContext
Expand All @@ -218,7 +167,6 @@ export default function CollapsibleSideBar({ handleEditQueryName }: CollapsibleS
<div className={styles.sidebarWrapper}>
<div
className={isExpanded ? styles.sideNavExpanded : styles.sideNavCollapsed}
style={{ height: sideNavExpandedHeight }}
aria-label={translations('savedQueriesSidebarLabel')}
>
<div className={styles.innerContentWrapper}>
Expand Down
43 changes: 40 additions & 3 deletions galasa-ui/src/components/test-runs/saved-queries/QueryItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -46,6 +48,37 @@ export default function QueryItem({
disabled,
});

// Ref for the query item container to calculate position
const itemRef = useRef<HTMLDivElement>(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);
Comment on lines +72 to +74
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we add event listeners for each query item, could this impact the page's performance when someone has a lot of saved queries?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did give it a go and it seemed okay, and it has a massive advantage over hard coding in the last 3 elements as that will depend on screen size and lots of other bits. Do you have another idea? 😁

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps you could look into using the IntersectionObserver or ResizeObserver APIs?


return () => {
window.removeEventListener('resize', calculateMenuDirection);
window.removeEventListener('scroll', calculateMenuDirection);
};
}, []);

const isDefault = defaultQuery.createdAt === query.createdAt;

// Actions for the query item
Expand Down Expand Up @@ -183,7 +216,11 @@ export default function QueryItem({

return (
<div
ref={setNodeRef}
ref={(node) => {
// Combine refs: one for dnd-kit sortable, one for position calculation
setNodeRef(node);
(itemRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
style={style}
className={`${styles.sideNavItem} ${disabled ? styles.disabled : ''} ${isCollapsed ? styles.collapsed : ''}`}
>
Expand All @@ -208,7 +245,7 @@ export default function QueryItem({
iconDescription={translations('actions')}
flipped
className={styles.overflowMenu}
align="top"
direction={shouldOpenUpwards ? 'top' : 'bottom'}
>
{actions.map((action) => (
<OverflowMenuItem
Expand Down
4 changes: 0 additions & 4 deletions galasa-ui/src/styles/test-runs/TestRunsPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@
}
}

.tabsContainer {
height: 100%;
}

.titleText {
font-size: 1rem;
margin: 0.5rem 0.5rem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,33 +352,4 @@ describe('CollapsibleSideBar', () => {
expect(mockSetSavedQueries).not.toHaveBeenCalled();
});
});

describe('updating side nav height', () => {
test('should not observe the main content if main content not loaded', async () => {
render(<CollapsibleSideBar handleEditQueryName={mockHandleEditQueryName} />);
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(<CollapsibleSideBar handleEditQueryName={mockHandleEditQueryName} />);

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 = '';
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(<QueryItem query={standardQuery} />);

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(<QueryItem query={standardQuery} />);

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(<QueryItem query={standardQuery} />);

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(<QueryItem query={standardQuery} />);

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(<QueryItem query={standardQuery} />);

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(<QueryItem query={standardQuery} />);

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(<QueryItem query={standardQuery} />);

unmount();

// Verify that event listeners were removed
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));

removeEventListenerSpy.mockRestore();
});
});
});