From 6efc275ec20685d7d7f7a89e01f2a2ea525c2b6f Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 13 Oct 2026 16:27:40 +1100 Subject: [PATCH 1/4] fix(app): keep dashboard tile menu on screen with many groups The tile menu listed every tab of every group inline, so on dashboards with several multi-tab groups it outgrew the viewport and hid Edit on lower tiles. Move the targets into a scrollable "Move to group" submenu with one heading per multi-tab group. Fixes HDX-5689 --- .changeset/tidy-tile-menu-groups.md | 5 + packages/app/src/DBDashboardPage.tsx | 177 +++--------------- .../dashboard/MoveToGroupSubmenu.tsx | 86 +++++++++ .../__tests__/MoveToGroupSubmenu.test.tsx | 126 +++++++++++++ 4 files changed, 240 insertions(+), 154 deletions(-) create mode 100644 .changeset/tidy-tile-menu-groups.md create mode 100644 packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx create mode 100644 packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx diff --git a/.changeset/tidy-tile-menu-groups.md b/.changeset/tidy-tile-menu-groups.md new file mode 100644 index 0000000000..38e33c387d --- /dev/null +++ b/.changeset/tidy-tile-menu-groups.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +fix: Keep the dashboard tile menu on screen when a dashboard has many groups, by moving the group list into a scrollable "Move to group" submenu diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 40520a3e07..e0a0b73c4c 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -99,7 +99,6 @@ import { IconChevronsDown, IconChevronsUp, IconCopy, - IconCornerDownRight, IconDeviceFloppy, IconDotsVertical, IconDownload, @@ -125,6 +124,11 @@ import { import { IsolatedChartSyncProvider } from '@/chartSync'; import { mergeAnnotations } from '@/components/charts/chartAnnotations'; import { ContactSupportText } from '@/components/ContactSupportText'; +import { + buildMoveTargets, + MoveTarget, + MoveToGroupSubmenu, +} from '@/components/dashboard/MoveToGroupSubmenu'; import SnapGridLayout from '@/components/dashboard/SnapGridLayout'; import DashboardContainer from '@/components/DashboardContainer'; import { @@ -376,14 +380,6 @@ function HeatmapTile({ ); } -type MoveTarget = { - containerId: string; - tabId?: string; - label: string; - // For tabs: all tabs in order with the target tab ID - allTabs?: { id: string; title: string }[]; -}; - const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ i: chart.id, x: chart.x, @@ -990,64 +986,12 @@ const Tile = ({ Edit {canMoveToGroup && ( - <> - - Move to Group - {chart.containerId && ( - } - onClick={() => onMoveToGroup(undefined)} - > - (Ungrouped) - - )} - {moveTargets - .filter( - t => - !( - t.containerId === chart.containerId && - t.tabId === chart.tabId - ), - ) - .map(t => ( - } - onClick={() => onMoveToGroup(t.containerId, t.tabId)} - > - {t.allTabs ? ( - - {t.allTabs.map((tab, i) => ( - - {i > 0 && ( - - {' | '} - - )} - - {tab.title} - - - ))} - - ) : ( - t.label - )} - - ))} - + )} { if (readOnly) return null; @@ -1137,64 +1081,12 @@ const Tile = ({ Edit {canMoveToGroup && ( - <> - - Move to Group - {chart.containerId && ( - } - onClick={() => onMoveToGroup(undefined)} - > - (Ungrouped) - - )} - {moveTargets - .filter( - t => - !( - t.containerId === chart.containerId && - t.tabId === chart.tabId - ), - ) - .map(t => ( - } - onClick={() => onMoveToGroup(t.containerId, t.tabId)} - > - {t.allTabs ? ( - - {t.allTabs.map((tab, i) => ( - - {i > 0 && ( - - {' | '} - - )} - - {tab.title} - - - ))} - - ) : ( - t.label - )} - - ))} - + )} (() => { - const targets: MoveTarget[] = []; - for (const c of containers) { - const cTabs = c.tabs ?? []; - if (cTabs.length >= 2) { - for (const tab of cTabs) { - targets.push({ - containerId: c.id, - tabId: tab.id, - label: tab.title, - allTabs: cTabs.map(t => ({ id: t.id, title: t.title })), - }); - } - } else if (cTabs.length === 1) { - // 1-tab group: show just the group name, target the single tab - targets.push({ - containerId: c.id, - tabId: cTabs[0].id, - label: cTabs[0].title, - }); - } else { - targets.push({ containerId: c.id, label: c.title }); - } - } - return targets; - }, [containers]); + const moveTargetContainers = useMemo( + () => buildMoveTargets(containers), + [containers], + ); const hasContainers = containers.length > 0; const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]); diff --git a/packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx b/packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx new file mode 100644 index 0000000000..adf9e31a54 --- /dev/null +++ b/packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx @@ -0,0 +1,86 @@ +import { Fragment } from 'react'; +import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; +import { Menu } from '@mantine/core'; +import { IconCornerDownRight, IconFolderSymlink } from '@tabler/icons-react'; + +export type MoveTarget = { + containerId: string; + tabId?: string; + label: string; + // Heading for the tabs of a multi-tab group + group?: string; +}; + +export function buildMoveTargets( + containers: DashboardContainer[], +): MoveTarget[] { + return containers.flatMap(c => { + const tabs = c.tabs ?? []; + if (tabs.length === 0) return [{ containerId: c.id, label: c.title }]; + if (tabs.length === 1) + return [{ containerId: c.id, tabId: tabs[0].id, label: tabs[0].title }]; + // A multi-tab group is named by its first tab, matching its header + return tabs.map(tab => ({ + containerId: c.id, + tabId: tab.id, + label: tab.title, + group: tabs[0].title, + })); + }); +} + +// A submenu keeps the tile menu short however many groups the dashboard has. +export function MoveToGroupSubmenu({ + moveTargets, + containerId, + tabId, + onMoveToGroup, +}: { + moveTargets: MoveTarget[]; + containerId?: string; + tabId?: string; + onMoveToGroup: (containerId: string | undefined, tabId?: string) => void; +}) { + const targets = moveTargets.filter( + t => !(t.containerId === containerId && t.tabId === tabId), + ); + + return ( + + + }> + Move to group + + + + {containerId && ( + } + onClick={() => onMoveToGroup(undefined)} + > + (Ungrouped) + + )} + {targets.map((t, i) => { + const prev = targets[i - 1]; + // Sections key on container, since default group names repeat + const newContainer = prev?.containerId !== t.containerId; + return ( + + {prev && newContainer && (prev.group || t.group) && ( + + )} + {t.group && newContainer && {t.group}} + } + onClick={() => onMoveToGroup(t.containerId, t.tabId)} + > + {t.label} + + + ); + })} + + + ); +} diff --git a/packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx b/packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx new file mode 100644 index 0000000000..3a186f81a2 --- /dev/null +++ b/packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx @@ -0,0 +1,126 @@ +import { Menu } from '@mantine/core'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + buildMoveTargets, + MoveTarget, + MoveToGroupSubmenu, +} from '@/components/dashboard/MoveToGroupSubmenu'; + +const containers = [ + { + id: 'tabbed', + title: 'Ignored', + collapsed: false, + tabs: [ + { id: 't1', title: 'Services' }, + { id: 't2', title: 'Queues' }, + ], + }, + { + id: 'single', + title: 'Ignored', + collapsed: false, + tabs: [{ id: 's1', title: 'Single tab' }], + }, + { id: 'plain', title: 'Plain group', collapsed: false }, +]; + +describe('buildMoveTargets', () => { + it('lists each tab of a multi-tab group under its first tab, and one target per other group', () => { + expect(buildMoveTargets(containers)).toEqual([ + { + containerId: 'tabbed', + tabId: 't1', + label: 'Services', + group: 'Services', + }, + { + containerId: 'tabbed', + tabId: 't2', + label: 'Queues', + group: 'Services', + }, + { containerId: 'single', tabId: 's1', label: 'Single tab' }, + { containerId: 'plain', label: 'Plain group' }, + ]); + }); +}); + +describe('MoveToGroupSubmenu', () => { + async function openSubmenu( + props: { containerId?: string; tabId?: string } = {}, + moveTargets: MoveTarget[] = buildMoveTargets(containers), + ) { + const onMoveToGroup = jest.fn(); + // jsdom has no layout, so Popover would hide the dropdown as detached + renderWithMantine( + + + + + + + + , + ); + await userEvent.hover(screen.getByText('Move to group')); + const submenu = (await screen.findAllByRole('menu')).at(-1)!; + return { submenu: within(submenu), onMoveToGroup }; + } + + it('shows one heading per multi-tab group and hides the current location', async () => { + const { submenu } = await openSubmenu({ + containerId: 'tabbed', + tabId: 't2', + }); + + expect(submenu.getAllByText('Services')).toHaveLength(2); // heading + tab + expect(submenu.queryByText('Queues')).not.toBeInTheDocument(); + expect(submenu.getByText('Single tab')).toBeInTheDocument(); + expect(submenu.getByText('(Ungrouped)')).toBeInTheDocument(); + }); + + it('offers no "(Ungrouped)" target for an ungrouped tile', async () => { + const { submenu } = await openSubmenu(); + expect(submenu.queryByText('(Ungrouped)')).not.toBeInTheDocument(); + }); + + it('moves to the chosen tab', async () => { + const { submenu, onMoveToGroup } = await openSubmenu(); + await userEvent.click(submenu.getByText('Queues')); + expect(onMoveToGroup).toHaveBeenCalledWith('tabbed', 't2'); + }); + + it('moves out of its group via "(Ungrouped)"', async () => { + const { submenu, onMoveToGroup } = await openSubmenu({ + containerId: 'plain', + }); + expect(submenu.queryByText('Plain group')).not.toBeInTheDocument(); + await userEvent.click(submenu.getByText('(Ungrouped)')); + expect(onMoveToGroup).toHaveBeenCalledWith(undefined); + }); + + it('gives each multi-tab group its own heading when names repeat', async () => { + const tabs = (id: string) => [ + { id: `${id}-1`, title: 'New Group' }, + { id: `${id}-2`, title: 'Tab 2' }, + ]; + const { submenu } = await openSubmenu( + {}, + buildMoveTargets([ + { id: 'a', title: 'New Group', collapsed: false, tabs: tabs('a') }, + { id: 'b', title: 'New Group', collapsed: false, tabs: tabs('b') }, + ]), + ); + expect(submenu.getAllByText('New Group')).toHaveLength(4); + expect( + submenu.getAllByRole('menuitem', { name: 'New Group', hidden: true }), + ).toHaveLength(2); + }); +}); From c8b664f2d079a4abffd1f60740e6146e2feca130 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 13 Oct 2026 16:46:54 +1100 Subject: [PATCH 2/4] test(app): cover tile menu overflow in e2e Seed a dashboard with 18 groups and check that Edit and the whole move submenu stay on screen for a tile at the bottom of the page, in both the normal and narrow-tile menus. Also divide "(Ungrouped)" from the groups below it and give dividers a separator role. --- packages/app/src/DBDashboardPage.tsx | 2 +- .../dashboard/MoveToGroupSubmenu.tsx | 3 +- .../__tests__/MoveToGroupSubmenu.test.tsx | 22 +++- .../features/dashboard-tile-move-menu.spec.ts | 106 ++++++++++++++++++ .../tests/e2e/page-objects/DashboardPage.ts | 27 +++++ 5 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index e0a0b73c4c..de2f020493 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -1024,7 +1024,7 @@ const Tile = ({ readOnly, ]); - // Flat Menu.Item list for the collapsed (narrow-tile) toolbar. + // Menu items for the collapsed (narrow-tile) toolbar. // Merges the alert action + all kebab items into a single list // for ChartContainer's menu. const collapsedMenuItems = useMemo(() => { diff --git a/packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx b/packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx index adf9e31a54..a8681739d0 100644 --- a/packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx +++ b/packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx @@ -61,6 +61,7 @@ export function MoveToGroupSubmenu({ (Ungrouped) )} + {containerId && targets.length > 0 && } {targets.map((t, i) => { const prev = targets[i - 1]; // Sections key on container, since default group names repeat @@ -68,7 +69,7 @@ export function MoveToGroupSubmenu({ return ( {prev && newContainer && (prev.group || t.group) && ( - + )} {t.group && newContainer && {t.group}} { ) { const onMoveToGroup = jest.fn(); // jsdom has no layout, so Popover would hide the dropdown as detached - renderWithMantine( + const { unmount } = renderWithMantine( @@ -71,7 +71,7 @@ describe('MoveToGroupSubmenu', () => { ); await userEvent.hover(screen.getByText('Move to group')); const submenu = (await screen.findAllByRole('menu')).at(-1)!; - return { submenu: within(submenu), onMoveToGroup }; + return { submenu: within(submenu), onMoveToGroup, unmount }; } it('shows one heading per multi-tab group and hides the current location', async () => { @@ -123,4 +123,22 @@ describe('MoveToGroupSubmenu', () => { submenu.getAllByRole('menuitem', { name: 'New Group', hidden: true }), ).toHaveLength(2); }); + + it('separates tabbed sections and "(Ungrouped)" from the groups around them', async () => { + const separatorsBefore = async (props: { containerId?: string }) => { + const { submenu, unmount } = await openSubmenu(props); + const labels = submenu + .getAllByRole('separator', { hidden: true }) + .map(s => s.nextElementSibling?.textContent); + unmount(); + return labels; + }; + + // Plain groups following each other share no divider + expect(await separatorsBefore({})).toEqual(['Single tab']); + expect(await separatorsBefore({ containerId: 'plain' })).toEqual([ + 'Services', + 'Single tab', + ]); + }); }); diff --git a/packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts b/packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts new file mode 100644 index 0000000000..ed17486d11 --- /dev/null +++ b/packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts @@ -0,0 +1,106 @@ +import { Page } from '@playwright/test'; + +import { DashboardPage } from '../page-objects/DashboardPage'; +import { getApiUrl } from '../utils/api-helpers'; +import { expect, test } from '../utils/base-test'; + +test.use({ viewport: { width: 1280, height: 720 } }); + +const TAB_NAMES = [ + 'Services', + 'Bff', + 'Queues', + 'App', + 'Orders', + 'Guest', + 'Ops', +]; +const LAST_TARGET = 'Plain group 11'; + +const markdownTile = (id: string, x: number, w: number) => ({ + id, + x, + y: 0, + w, + h: 4, + containerId: 'plain-12', + config: { + name: id, + source: 'Logs', + displayType: 'markdown', + select: [], + where: '', + whereLanguage: 'lucene', + markdown: id, + }, +}); + +// Enough groups that the move list is far taller than the viewport, with +// the tiles under test in the last group at the bottom of the page. +async function seedDashboard(page: Page): Promise { + const containers = [ + ...Array.from({ length: 6 }, (_, g) => ({ + id: `tabbed-${g}`, + title: `Tabbed ${g + 1}`, + collapsed: false, + tabs: TAB_NAMES.map((name, t) => ({ + id: `tabbed-${g}-${t}`, + title: `${name} ${g + 1}`, + })), + })), + ...Array.from({ length: 12 }, (_, g) => ({ + id: `plain-${g + 1}`, + title: `Plain group ${g + 1}`, + collapsed: false, + })), + ]; + const res = await page.request.post(`${getApiUrl()}/dashboards`, { + data: { + name: `E2E tile move menu ${Date.now()}`, + containers, + tiles: [ + markdownTile('wide-tile', 0, 12), + markdownTile('narrow-tile', 12, 3), + ], + tags: [], + }, + }); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + return body.id ?? body._id; +} + +test.describe( + 'Dashboard tile move menu', + { tag: ['@dashboard', '@full-stack'] }, + () => { + let dashboardPage: DashboardPage; + + test.beforeEach(async ({ page }) => { + dashboardPage = new DashboardPage(page); + await dashboardPage.gotoDashboard(await seedDashboard(page)); + }); + + async function expectMenuFits() { + await expect(dashboardPage.getTileMenuItem('Edit')).toBeInViewport(); + const submenu = await dashboardPage.openMoveToGroupSubmenu(); + // Fully on screen, so the last target is reached by the submenu's own scroll + await expect(submenu).toBeInViewport({ ratio: 1 }); + const last = dashboardPage.getTileMenuItem(LAST_TARGET); + await last.scrollIntoViewIfNeeded(); + await expect(last).toBeInViewport(); + } + + test('keeps Edit on screen for a tile at the bottom of the page', async () => { + await dashboardPage.openTileActionsMenuById('wide-tile'); + await expectMenuFits(); + }); + + test('keeps Edit on screen in the narrow-tile menu', async () => { + await dashboardPage.openTileActionsMenuById('narrow-tile', { + narrow: true, + }); + await expectMenuFits(); + }); + }, +); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index b72a03b77c..9b862f2a68 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -657,6 +657,33 @@ export class DashboardPage { .click(); } + /** + * Open the actions menu of the tile with this id. Narrow tiles swap the + * toolbar for a single collapsed menu button. + */ + async openTileActionsMenuById(tileId: string, { narrow = false } = {}) { + const tile = this.page.getByTestId(`dashboard-tile-${tileId}`); + await tile.scrollIntoViewIfNeeded(); + await tile.hover(); + const trigger = narrow + ? tile.locator('button').filter({ visible: true }) + : this.page.getByTestId(`tile-actions-button-${tileId}`); + await trigger.click(); + } + + getTileMenuItem(name: string): Locator { + return this.page.getByRole('menuitem', { name, exact: true }); + } + + /** + * Open the "Move to group" submenu of an open tile menu. + */ + async openMoveToGroupSubmenu(): Promise { + const item = this.getTileMenuItem('Move to group'); + await item.hover(); + return this.page.locator(`#${await item.getAttribute('aria-controls')}`); + } + /** * Edit a tile */ From 5aa49f124f1fac74dc18a1605b208a2be036cdfc Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 13 Oct 2026 16:55:02 +1100 Subject: [PATCH 3/4] test(app): keep assertions in tile menu e2e test bodies playwright/expect-expect does not follow assertions into helpers, which pushed the app over its lint warning cap. --- .../tests/e2e/features/dashboard-tile-move-menu.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts b/packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts index ed17486d11..c3cac8f8a9 100644 --- a/packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts @@ -81,8 +81,7 @@ test.describe( await dashboardPage.gotoDashboard(await seedDashboard(page)); }); - async function expectMenuFits() { - await expect(dashboardPage.getTileMenuItem('Edit')).toBeInViewport(); + async function expectMoveTargetsReachable() { const submenu = await dashboardPage.openMoveToGroupSubmenu(); // Fully on screen, so the last target is reached by the submenu's own scroll await expect(submenu).toBeInViewport({ ratio: 1 }); @@ -93,14 +92,16 @@ test.describe( test('keeps Edit on screen for a tile at the bottom of the page', async () => { await dashboardPage.openTileActionsMenuById('wide-tile'); - await expectMenuFits(); + await expect(dashboardPage.getTileMenuItem('Edit')).toBeInViewport(); + await expectMoveTargetsReachable(); }); test('keeps Edit on screen in the narrow-tile menu', async () => { await dashboardPage.openTileActionsMenuById('narrow-tile', { narrow: true, }); - await expectMenuFits(); + await expect(dashboardPage.getTileMenuItem('Edit')).toBeInViewport(); + await expectMoveTargetsReachable(); }); }, ); From 941f24406f3549c610e1018dba007f81dbe92d65 Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Tue, 13 Oct 2026 22:41:01 +1100 Subject: [PATCH 4/4] test(app): cover divider before a tabbed group in move submenu --- .../__tests__/MoveToGroupSubmenu.test.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx b/packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx index b40a8b48c8..c0fbc9ca22 100644 --- a/packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx +++ b/packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx @@ -125,10 +125,13 @@ describe('MoveToGroupSubmenu', () => { }); it('separates tabbed sections and "(Ungrouped)" from the groups around them', async () => { - const separatorsBefore = async (props: { containerId?: string }) => { - const { submenu, unmount } = await openSubmenu(props); + const separatorsBefore = async ( + props: { containerId?: string }, + targets?: MoveTarget[], + ) => { + const { submenu, unmount } = await openSubmenu(props, targets); const labels = submenu - .getAllByRole('separator', { hidden: true }) + .queryAllByRole('separator', { hidden: true }) .map(s => s.nextElementSibling?.textContent); unmount(); return labels; @@ -140,5 +143,12 @@ describe('MoveToGroupSubmenu', () => { 'Services', 'Single tab', ]); + const [tabbed, single, plain] = containers; + expect( + await separatorsBefore({}, buildMoveTargets([plain, tabbed])), + ).toEqual(['Services']); + expect( + await separatorsBefore({}, buildMoveTargets([single, plain])), + ).toEqual([]); }); });