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 cc6c0c873b..ada2e7e680 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..a8681739d0
--- /dev/null
+++ b/packages/app/src/components/dashboard/MoveToGroupSubmenu.tsx
@@ -0,0 +1,87 @@
+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)
+
+ )}
+ {containerId && targets.length > 0 && }
+ {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..c0fbc9ca22
--- /dev/null
+++ b/packages/app/src/components/dashboard/__tests__/MoveToGroupSubmenu.test.tsx
@@ -0,0 +1,154 @@
+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
+ const { unmount } = renderWithMantine(
+ ,
+ );
+ await userEvent.hover(screen.getByText('Move to group'));
+ const submenu = (await screen.findAllByRole('menu')).at(-1)!;
+ return { submenu: within(submenu), onMoveToGroup, unmount };
+ }
+
+ 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);
+ });
+
+ it('separates tabbed sections and "(Ungrouped)" from the groups around them', async () => {
+ const separatorsBefore = async (
+ props: { containerId?: string },
+ targets?: MoveTarget[],
+ ) => {
+ const { submenu, unmount } = await openSubmenu(props, targets);
+ const labels = submenu
+ .queryAllByRole('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',
+ ]);
+ const [tabbed, single, plain] = containers;
+ expect(
+ await separatorsBefore({}, buildMoveTargets([plain, tabbed])),
+ ).toEqual(['Services']);
+ expect(
+ await separatorsBefore({}, buildMoveTargets([single, plain])),
+ ).toEqual([]);
+ });
+});
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..c3cac8f8a9
--- /dev/null
+++ b/packages/app/tests/e2e/features/dashboard-tile-move-menu.spec.ts
@@ -0,0 +1,107 @@
+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 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 });
+ 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 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 expect(dashboardPage.getTileMenuItem('Edit')).toBeInViewport();
+ await expectMoveTargetsReachable();
+ });
+ },
+);
diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts
index fcfcdaf9f3..b47de94465 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
*/