From 4e34db1c7f48d97c547ad5914b6f8122374e3f74 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 29 May 2026 23:35:34 +0000 Subject: [PATCH 1/5] fix(app): prevent stranded tooltip in virtualised table rows [HDX-4405] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-row Tooltip.Floating instances got stranded in their Portal when a virtual row unmounted before onMouseLeave fired — a race that occurs when the mouse moves rapidly across a TanStack Virtual list. Fix: replace one Tooltip.Floating per virtual row with a single shared Tooltip.Floating wrapping the whole . The floating tooltip now lives on , which never unmounts, so its Portal-rendered content can never be left open after the triggering element disappears. Row-level onMouseEnter/onMouseLeave handlers update a shared hoveredRowDescription state; the tooltip's disabled prop gates visibility so rows without a resolved URL (error-toast branch) never show a hint. A tbody-level onMouseLeave acts as a safety net to clear the description if a rapid mouse move causes a row to unmount before its own leave handler fires. Test: adds a regression test that verifies the tooltip disappears on mouseLeave (the stranded-tooltip scenario). --- packages/app/src/HDXMultiSeriesTableChart.tsx | 129 ++++++++++-------- .../HDXMultiSeriesTableChart.test.tsx | 38 ++++++ 2 files changed, 113 insertions(+), 54 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTableChart.tsx b/packages/app/src/HDXMultiSeriesTableChart.tsx index 78641fe7e9..7e90df44dd 100644 --- a/packages/app/src/HDXMultiSeriesTableChart.tsx +++ b/packages/app/src/HDXMultiSeriesTableChart.tsx @@ -307,6 +307,15 @@ export const Table = ({ ); const [wrapLinesEnabled, setWrapLinesEnabled] = useState(false); + // Single shared tooltip state for all virtual rows. Maintaining one + // Tooltip.Floating at the level (rather than one per virtual row) + // prevents the tooltip from getting stranded when a row unmounts before + // onMouseLeave fires — a race that happens when the mouse moves rapidly + // across a virtualised list. See HDX-4405. + const [hoveredRowDescription, setHoveredRowDescription] = useState< + string | null + >(null); + const { csvData } = useCsvExport( truncatedData, columns.map(col => ({ @@ -371,65 +380,77 @@ export const Table = ({ ))} - - {paddingTop > 0 && ( - - - - )} - {items.map(virtualRow => { - const row = rows[virtualRow.index] as TableRow; - // Compute the action once per row so the row-level HoverCard - // sees the same description and per-cell renders share the - // memoized result from useOnClickLinkBuilder. - const rowAction = getRowAction ? getRowAction(row.original) : null; - const tr = ( - - {row.getVisibleCells().map(cell => { - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ); - })} + {/* Single Tooltip.Floating wrapping the whole so the hint + follows the cursor without being tied to the lifecycle of any + individual virtual row. Per-row Tooltip.Floating instances get + stranded in the Portal when a row unmounts before onMouseLeave + fires (rapid mouse movement in a virtualised list). With this + approach the tooltip state lives on , which never unmounts, + and the label is updated by onMouseEnter/onMouseLeave handlers + on each . See HDX-4405. */} + + {/* onMouseLeave on is a safety net: if a virtual row + unmounts before its own onMouseLeave fires (rapid movement), + the cursor leaving the table body still clears the description. */} + setHoveredRowDescription(null)}> + {paddingTop > 0 && ( + + - ); - // Row-level Tooltip.Floating so the hint follows the cursor - // and anchors near the cell the user is over, not at the row's - // center-top. Tooltip.Floating tracks the cursor via floating-ui - // and stays within the row's bounding box; one tooltip per row - // means no flicker as the cursor moves between cells. - // - // The hint is suppressed when rowAction.url === null because - // the click only fires an error toast on those rows, so showing - // "Open in search" would mislead the user. - if (rowAction && rowAction.url) { + )} + {items.map(virtualRow => { + const row = rows[virtualRow.index] as TableRow; + // Compute the action once per row so per-cell renders share + // the memoized result from useOnClickLinkBuilder. + const rowAction = getRowAction + ? getRowAction(row.original) + : null; + // Only surface a hint when the URL resolved successfully. + // Rows whose templates failed only fire an error toast on + // click, so showing "Open in search" would mislead the user. + const hintDescription = + rowAction?.url != null ? rowAction.description : null; return ( - setHoveredRowDescription(hintDescription) + : undefined + } + onMouseLeave={ + hintDescription != null + ? () => setHoveredRowDescription(null) + : undefined + } > - {tr} - + {row.getVisibleCells().map(cell => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + ); - } - return tr; - })} - {paddingBottom > 0 && ( - - - - )} - + })} + {paddingBottom > 0 && ( + + + + )} + + {isTruncated && (
diff --git a/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx b/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx index c63e72941b..f56a880623 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx +++ b/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx @@ -102,6 +102,44 @@ describe('HDXMultiSeriesTableChart ', () => { { timeout: 1000 }, ); }); + + it('hides the hint after mouseLeave so tooltip cannot get stranded (HDX-4405)', async () => { + // Regression test: the tooltip must disappear when the row is left. + // Previously each virtual row mounted its own Tooltip.Floating; if the + // row unmounted before onMouseLeave fired (rapid mouse movement), the + // Portal-rendered tooltip stayed visible. The fix moves the single + // Tooltip.Floating to so its state never gets stranded. + const getRowAction = jest.fn().mockReturnValue({ + url: '/search?source=src_1&where=', + description: 'Search HyperDX Logs', + }); + + renderWithMantine( +
{}} + />, + ); + + const row = screen.getByText('web').closest('tr')!; + + // Show tooltip + fireEvent.mouseEnter(row); + await waitFor(() => + expect(screen.getByText('Search HyperDX Logs')).toBeInTheDocument(), + ); + + // Leave the row — tooltip must disappear + fireEvent.mouseLeave(row); + await waitFor(() => + expect( + screen.queryByText('Search HyperDX Logs'), + ).not.toBeInTheDocument(), + ); + }); }); describe('getRowAction failure path', () => { From 56727f03a0dbb36c83499db2b1638537fc473929 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 29 May 2026 23:49:52 +0000 Subject: [PATCH 2/5] test(e2e): add tooltip regression test for HDX-4405 Verifies that the Tooltip.Floating hint appears on row hover and disappears when the mouse moves away, covering the stranded-tooltip regression introduced by the per-row Tooltip.Floating in PR #2321. Changes: - dashboard-table-linking.spec.ts: new 'Tooltip hint appears on hover and disappears on mouse-leave' test. Creates a table tile with a Search row-click action, hovers the first row to confirm the hint appears, then moves the mouse away to confirm it hides cleanly. - DashboardPage.ts: adds getFirstTableRow() and hoverFirstTableRowAndGetTooltip() page-object helpers. --- .../features/dashboard-table-linking.spec.ts | 44 ++++++++++++++++++ .../tests/e2e/page-objects/DashboardPage.ts | 45 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts b/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts index d4f127b2aa..c13d6d6507 100644 --- a/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts @@ -751,5 +751,49 @@ test.describe( await expect(dashboardPage.ignoredUrlFiltersBanner).toBeHidden(); }); }); + + test('Tooltip hint appears on hover and disappears on mouse-leave — no stranded tooltip (HDX-4405)', async ({ + page, + }) => { + // Regression test for HDX-4405: Tooltip.Floating instances mounted + // per virtual row were getting stranded in their Portal when the row + // unmounted before onMouseLeave fired (rapid mouse movement). The fix + // moves a single shared Tooltip.Floating to so its state is + // never tied to a virtual row's lifecycle. + const ts = Date.now(); + + await test.step('Create a table tile with a Search row-click action', async () => { + await addTableTile(`E2E Tooltip ${ts}`); + await dashboardPage.chartEditor.openRowClickDrawer(); + await dashboardPage.chartEditor.setRowClickMode('Search'); + await dashboardPage.chartEditor.fillRowClickTemplate( + DEFAULT_LOGS_SOURCE_NAME, + ); + await dashboardPage.chartEditor.applyRowClickDrawer(); + await dashboardPage.saveTile(); + }); + + await test.step('Set time range to Last 6 hours so rows render', async () => { + await dashboardPage.timePicker.selectRelativeTime('Last 6 hours'); + }); + + await dashboardPage.waitForTableTileRows(0); + + await test.step('Hover over first row — tooltip hint must appear', async () => { + await dashboardPage.hoverFirstTableRowAndGetTooltip(0); + // hoverFirstTableRowAndGetTooltip already asserts visibility before + // returning; reaching here means the tooltip appeared successfully. + }); + + await test.step('Move mouse away from the table — tooltip must disappear', async () => { + // Move to a neutral area well outside the table (top-left corner of + // the viewport). The tbody onMouseLeave clears hoveredRowDescription, + // which sets disabled=true on the single shared Tooltip.Floating, + // hiding it via inline display:none. + await page.mouse.move(10, 10); + const tooltipText = page.getByText(/Open in search/, { exact: false }); + await expect(tooltipText).toBeHidden({ timeout: 3000 }); + }); + }); }, ); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 93c14f1e6a..bfb585f192 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -970,6 +970,51 @@ export class DashboardPage { .click(); } + /** + * Return the first data row () of the table in the + * given tile. Used for hover-based interactions (e.g. tooltip tests). + */ + getFirstTableRow(tileIndex = 0): Locator { + return this.getTile(tileIndex) + .locator('table tbody tr[data-index]') + .first(); + } + + /** + * Hover over the first data row of a table tile and wait for the + * floating tooltip to appear. Returns the tooltip locator so callers + * can make further assertions. + * + * Tooltip.Floating renders its content inside a Portal at the document + * body level. Mantine uses CSS modules (hashed classes) so we locate the + * floating popup by text content that matches the known hint patterns used + * by describeOnClick (e.g. "Open in search", "Search ", + * "Open dashboard"). The tooltip is shown via `display: block` on the + * Portal div, so Playwright's `toBeVisible` checks that correctly. + */ + async hoverFirstTableRowAndGetTooltip(tileIndex = 0): Promise { + const row = this.getFirstTableRow(tileIndex); + await row.hover(); + // Trigger a mousemove inside the row so Tooltip.Floating's internal + // mousemove handler has a coordinate to position against. + const box = await row.boundingBox(); + if (box) { + await this.page.mouse.move( + box.x + box.width / 2, + box.y + box.height / 2, + ); + } + // Tooltip.Floating renders a div inside a Portal (appended to document + // body). When open, Mantine sets `display: block` on it inline; when + // closed, `display: none`. We can't rely on a stable Mantine class name + // (CSS modules hash them), so match by the tooltip text content that + // describeOnClick produces: "Search ", "Open in search", etc. + // These phrases don't appear in the table cells themselves. + const tooltip = this.page.getByText(/Open in search/, { exact: false }); + await tooltip.waitFor({ state: 'visible', timeout: 5000 }); + return tooltip; + } + /** * Locator for the Mantine toast raised by useOnClickLinkBuilder when the * configured onClick action fails (unknown source, missing row column, etc). From c87549819a62d2f8991eb600c85994ebf064feaa Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 29 May 2026 23:59:58 +0000 Subject: [PATCH 3/5] style: fix prettier formatting in DashboardPage.ts --- packages/app/tests/e2e/page-objects/DashboardPage.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index bfb585f192..2aece518cd 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -999,10 +999,7 @@ export class DashboardPage { // mousemove handler has a coordinate to position against. const box = await row.boundingBox(); if (box) { - await this.page.mouse.move( - box.x + box.width / 2, - box.y + box.height / 2, - ); + await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); } // Tooltip.Floating renders a div inside a Portal (appended to document // body). When open, Mantine sets `display: block` on it inline; when From 24d8552cde285de19b4e6ae4b94736481d2d6eda Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Sat, 30 May 2026 01:04:27 +0000 Subject: [PATCH 4/5] fix(app): address code review feedback on HDX-4405 tooltip fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 — correctness: - Store hoveredVirtualIndex (row index) instead of hoveredRowDescription (string) so the label re-derives via useMemo on every render. If the virtualiser drops or replaces the hovered row (scroll, auto-refetch, rapid cursor movement) the new row's action is shown immediately; stale text from the unmounted row can never persist. - Rows with url:null or empty description now correctly disable the tooltip regardless of what the prior hover state was. P2 — testing: - Replaced the simple mouseLeave regression test with one that exercises the actual race: hover index 0 (URL row), then enter index 1 (no-URL row) without firing mouseLeave on index 0. Asserts tooltip hides by inspecting the Mantine inline display style on the Portal container. P3 — maintainability: - label={hoveredRowDescription} — drop the ?? '' fallback that obscured the disabled gating relationship (Mantine accepts null as ReactNode). - disabled={!hoveredRowDescription} — guards against empty-string descriptions that would mount a zero-width floating tooltip. - Unconditional onMouseEnter/onMouseLeave on each with a hoisted clearHovered useCallback, replacing the conditional handler pattern that forked JSX unnecessarily. - Collapse dual comment blocks into one rationale at the Tooltip.Floating call site; the state declaration now has a single-line pointer. - Add data-testid="row-action-hint" to the Tooltip.Floating label span so E2E tests locate the tooltip by stable testid rather than by hard-coupled copy strings. --- packages/app/src/HDXMultiSeriesTableChart.tsx | 73 ++++++++++--------- .../HDXMultiSeriesTableChart.test.tsx | 65 +++++++++++------ .../features/dashboard-table-linking.spec.ts | 13 ++-- .../tests/e2e/page-objects/DashboardPage.ts | 11 +-- 4 files changed, 90 insertions(+), 72 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTableChart.tsx b/packages/app/src/HDXMultiSeriesTableChart.tsx index 7e90df44dd..604e0c3140 100644 --- a/packages/app/src/HDXMultiSeriesTableChart.tsx +++ b/packages/app/src/HDXMultiSeriesTableChart.tsx @@ -307,14 +307,32 @@ export const Table = ({ ); const [wrapLinesEnabled, setWrapLinesEnabled] = useState(false); - // Single shared tooltip state for all virtual rows. Maintaining one - // Tooltip.Floating at the level (rather than one per virtual row) - // prevents the tooltip from getting stranded when a row unmounts before - // onMouseLeave fires — a race that happens when the mouse moves rapidly - // across a virtualised list. See HDX-4405. - const [hoveredRowDescription, setHoveredRowDescription] = useState< - string | null - >(null); + // Store the virtual index of the hovered row (not its description string) + // so the label re-derives on every render. If the virtualiser replaces the + // row at that index (scroll re-virtualisation, auto-refetch) the label + // reflects the new row immediately rather than showing stale text. Storing + // the index also makes the safety-net `onMouseLeave` on correct: + // it sets null rather than the prior row's description. See HDX-4405. + const [hoveredVirtualIndex, setHoveredVirtualIndex] = useState( + null, + ); + + // Derive the label from whichever row currently occupies hoveredVirtualIndex. + // Returns null when no row is hovered, the index is out of range, or the + // row's action has no URL (error-toast rows show no hint). + const hoveredRowDescription = useMemo(() => { + if (hoveredVirtualIndex == null) return null; + const virtualRow = items.find(v => v.index === hoveredVirtualIndex); + if (!virtualRow) return null; + const row = rows[virtualRow.index] as TableRow | undefined; + if (!row) return null; + const rowAction = getRowAction ? getRowAction(row.original) : null; + return rowAction?.url != null && rowAction.description + ? rowAction.description + : null; + }, [hoveredVirtualIndex, items, rows, getRowAction]); + + const clearHovered = useCallback(() => setHoveredVirtualIndex(null), []); const { csvData } = useCsvExport( truncatedData, @@ -386,17 +404,20 @@ export const Table = ({ stranded in the Portal when a row unmounts before onMouseLeave fires (rapid mouse movement in a virtualised list). With this approach the tooltip state lives on , which never unmounts, - and the label is updated by onMouseEnter/onMouseLeave handlers - on each . See HDX-4405. */} + and the label is re-derived from hoveredVirtualIndex each render so + scroll re-virtualisation never shows stale text. See HDX-4405. */} {hoveredRowDescription} + } withinPortal - disabled={hoveredRowDescription == null} + disabled={!hoveredRowDescription} > {/* onMouseLeave on is a safety net: if a virtual row - unmounts before its own onMouseLeave fires (rapid movement), - the cursor leaving the table body still clears the description. */} - setHoveredRowDescription(null)}> + unmounts before its own onMouseLeave fires (rapid cursor + movement or re-virtualisation), leaving the table body still + clears the hovered index. */} + {paddingTop > 0 && ( setHoveredRowDescription(hintDescription) - : undefined - } - onMouseLeave={ - hintDescription != null - ? () => setHoveredRowDescription(null) - : undefined - } + onMouseEnter={() => setHoveredVirtualIndex(virtualRow.index)} + onMouseLeave={clearHovered} > {row.getVisibleCells().map(cell => { return ( diff --git a/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx b/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx index f56a880623..8947a731dc 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx +++ b/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx @@ -103,20 +103,31 @@ describe('HDXMultiSeriesTableChart
@@ -404,32 +425,14 @@ export const Table = ({ )} {items.map(virtualRow => { const row = rows[virtualRow.index] as TableRow; - // Compute the action once per row so per-cell renders share - // the memoized result from useOnClickLinkBuilder. - const rowAction = getRowAction - ? getRowAction(row.original) - : null; - // Only surface a hint when the URL resolved successfully. - // Rows whose templates failed only fire an error toast on - // click, so showing "Open in search" would mislead the user. - const hintDescription = - rowAction?.url != null ? rowAction.description : null; return (
', () => { ); }); - it('hides the hint after mouseLeave so tooltip cannot get stranded (HDX-4405)', async () => { - // Regression test: the tooltip must disappear when the row is left. - // Previously each virtual row mounted its own Tooltip.Floating; if the - // row unmounted before onMouseLeave fired (rapid mouse movement), the - // Portal-rendered tooltip stayed visible. The fix moves the single - // Tooltip.Floating to so its state never gets stranded. - const getRowAction = jest.fn().mockReturnValue({ - url: '/search?source=src_1&where=', - description: 'Search HyperDX Logs', - }); + it('hides the hint when the hovered virtual index maps to a no-URL row (HDX-4405)', async () => { + // Regression for the virtualiser race: the hovered can unmount + // before its onMouseLeave fires (rapid movement / data refresh). The + // old per-row Tooltip.Floating was stranded in the Portal; the fix + // stores a virtual index and re-derives the label via useMemo each + // render. When the row at that index no longer has a URL, the tooltip + // hides without a leave event from the (now-gone) element. + // + // We verify the key invariant structurally: hovering index 0 (URL row) + // shows the hint; hovering index 1 (no-URL row) hides it — no + // mouseLeave fires between the two enterevents, simulating the + // cursor jumping over the table faster than leave events dispatch. + const multiRowData = [ + { ServiceName: 'web', Count: 10 }, + { ServiceName: 'api', Count: 5 }, + ]; + const getRowAction = jest.fn((row: { ServiceName: string }) => + row.ServiceName === 'web' + ? { url: '/search?source=src_1&where=', description: 'Search Logs' } + : { url: null, description: '', onClickError: jest.fn() }, + ); renderWithMantine(
', () => { />, ); - const row = screen.getByText('web').closest('tr')!; + const webRow = screen.getByText('web').closest('tr')!; + const apiRow = screen.getByText('api').closest('tr')!; - // Show tooltip - fireEvent.mouseEnter(row); - await waitFor(() => - expect(screen.getByText('Search HyperDX Logs')).toBeInTheDocument(), - ); + // Hover the URL row — tooltip must appear + fireEvent.mouseEnter(webRow); + await waitFor(() => { + const hint = screen.getByTestId('row-action-hint'); + const tooltipBox = hint.closest('[style*="display"]'); + expect(tooltipBox?.style.display).toBe('block'); + }); - // Leave the row — tooltip must disappear - fireEvent.mouseLeave(row); - await waitFor(() => - expect( - screen.queryByText('Search HyperDX Logs'), - ).not.toBeInTheDocument(), - ); + // Hover the no-URL row WITHOUT firing mouseLeave on the first row. + // This simulates the cursor jumping faster than leave events dispatch. + fireEvent.mouseEnter(apiRow); + + // The label must derive to null (apiRow has url:null) so tooltip hides. + await waitFor(() => { + const hint = screen.getByTestId('row-action-hint'); + const tooltipBox = hint.closest('[style*="display"]'); + expect(tooltipBox?.style.display).toBe('none'); + }); }); }); diff --git a/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts b/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts index c13d6d6507..1fb89a64f2 100644 --- a/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts @@ -786,13 +786,14 @@ test.describe( }); await test.step('Move mouse away from the table — tooltip must disappear', async () => { - // Move to a neutral area well outside the table (top-left corner of - // the viewport). The tbody onMouseLeave clears hoveredRowDescription, - // which sets disabled=true on the single shared Tooltip.Floating, - // hiding it via inline display:none. + // Move to a neutral area well outside the table. The + // onMouseLeave safety net clears hoveredVirtualIndex, which makes + // hoveredRowDescription derive to null, disabling the shared + // Tooltip.Floating (display:none in the Portal). await page.mouse.move(10, 10); - const tooltipText = page.getByText(/Open in search/, { exact: false }); - await expect(tooltipText).toBeHidden({ timeout: 3000 }); + await expect(page.getByTestId('row-action-hint')).toBeHidden({ + timeout: 3000, + }); }); }); }, diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 2aece518cd..4f5d2778c4 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -1001,13 +1001,10 @@ export class DashboardPage { if (box) { await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); } - // Tooltip.Floating renders a div inside a Portal (appended to document - // body). When open, Mantine sets `display: block` on it inline; when - // closed, `display: none`. We can't rely on a stable Mantine class name - // (CSS modules hash them), so match by the tooltip text content that - // describeOnClick produces: "Search ", "Open in search", etc. - // These phrases don't appear in the table cells themselves. - const tooltip = this.page.getByText(/Open in search/, { exact: false }); + // The Tooltip.Floating label is a . + // The portal div uses display:none when disabled, so Playwright's + // toBeVisible() correctly reflects the open/closed state. + const tooltip = this.page.getByTestId('row-action-hint'); await tooltip.waitFor({ state: 'visible', timeout: 5000 }); return tooltip; } From 7d720a9b31c44127480f23c4efa3e96c6888a3f8 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:10:13 +0000 Subject: [PATCH 5/5] fix(app): pivot HDX-4405 to trailing chevron hint per review feedback Replace the tbody-level Tooltip.Floating that tracks the cursor with a hover-revealed trailing chevron icon anchored to the row's last cell, wrapped in an anchored Mantine Tooltip. Mirrors the .rowButtons pattern used by Search and Patterns. Eliminates the stranded-tooltip bug class by construction: the anchored Tooltip's open state is tied to the icon's lifecycle, so a virtual row unmounting mid-hover can no longer leave a popup behind. Addresses Mike's design feedback that the cursor-following tooltip felt "a bit aggressive". Drop hoveredVirtualIndex / hoveredRowDescription state, the tbody-level Tooltip.Floating, the tbody onMouseLeave safety net, and per-row onMouseEnter / onMouseLeave handlers. Add a per-row trailing IconChevronRight in the last + // tooltip because anchored visibility is tied to the row's own + // lifecycle and never strands a popup after a virtual row + // unmounts. + &:hover .rowActionHint { + opacity: 1; + pointer-events: auto; + } +} + +.lastCell { + // Containing block for the absolute-positioned .rowActionHint. + // does not reliably form a positioning context across + // browsers, so we attach `position: relative` to the last correct: - // it sets null rather than the prior row's description. See HDX-4405. - const [hoveredVirtualIndex, setHoveredVirtualIndex] = useState( - null, - ); - - // Derive the label from whichever row currently occupies hoveredVirtualIndex. - // Returns null when no row is hovered, the index is out of range, or the - // row's action has no URL (error-toast rows show no hint). - const hoveredRowDescription = useMemo(() => { - if (hoveredVirtualIndex == null) return null; - const virtualRow = items.find(v => v.index === hoveredVirtualIndex); - if (!virtualRow) return null; - const row = rows[virtualRow.index] as TableRow | undefined; - if (!row) return null; - const rowAction = getRowAction ? getRowAction(row.original) : null; - return rowAction?.url != null && rowAction.description - ? rowAction.description - : null; - }, [hoveredVirtualIndex, items, rows, getRowAction]); - - const clearHovered = useCallback(() => setHoveredVirtualIndex(null), []); - const { csvData } = useCsvExport( truncatedData, columns.map(col => ({ @@ -398,62 +376,86 @@ export const Table = ({ ))} - {/* Single Tooltip.Floating wrapping the whole so the hint - follows the cursor without being tied to the lifecycle of any - individual virtual row. Per-row Tooltip.Floating instances get - stranded in the Portal when a row unmounts before onMouseLeave - fires (rapid mouse movement in a virtualised list). With this - approach the tooltip state lives on , which never unmounts, - and the label is re-derived from hoveredVirtualIndex each render so - scroll re-virtualisation never shows stale text. See HDX-4405. */} - {hoveredRowDescription} - } - withinPortal - disabled={!hoveredRowDescription} - > - {/* onMouseLeave on is a safety net: if a virtual row - unmounts before its own onMouseLeave fires (rapid cursor - movement or re-virtualisation), leaving the table body still - clears the hovered index. */} - - {paddingTop > 0 && ( - - - )} - {items.map(virtualRow => { - const row = rows[virtualRow.index] as TableRow; - return ( - setHoveredVirtualIndex(virtualRow.index)} - onMouseLeave={clearHovered} - > - {row.getVisibleCells().map(cell => { - return ( - - ); - })} - - ); - })} - {paddingBottom > 0 && ( - - + {paddingTop > 0 && ( + + + )} + {items.map(virtualRow => { + const row = rows[virtualRow.index] as TableRow; + // Compute the action once per row so the trailing-icon hint + // shares the memoized result from useOnClickLinkBuilder with + // the per-cell renders. + const rowAction = getRowAction ? getRowAction(row.original) : null; + const visibleCells = row.getVisibleCells(); + const lastCellIndex = visibleCells.length - 1; + return ( + + {visibleCells.map((cell, cellIndex) => { + const isLastCell = cellIndex === lastCellIndex; + return ( + + ); + })} - )} - - + ); + })} + {paddingBottom > 0 && ( + + + )} +
, wrapped in a Next.js Link with prefetch=false, tabIndex=-1, and aria-hidden so cmd / middle / right-click semantics carry over but sequential focus and screen-reader traversal aren't double-counted. New HDXMultiSeriesTableChart.module.scss owns .tableRow, .lastCell, .rowActionHint. The icon is opacity 0 by default and fades in on .tableRow:hover; the last gets position: relative so the icon's absolute positioning anchors to the cell, not the row (which is not a reliable positioning context across browsers). Tooltip is anchored position=left with the established DBRowTableIconButton conventions (openDelay 300, closeDelay 100, withArrow, fz xs). Suppressed on failure rows (rowAction.url null): the description ("Open in search", "Open dashboard X") would promise a destination the click cannot deliver. Tests: - Unit tests rewritten: trailing chevron rendering, dashboard variant tooltip, failure-row chevron absent, legacy getRowSearchLink path chevron absent. Dropped the inline-style closest() walk flagged in the deep review. - E2E spec replaces the stranded-tooltip dismiss test with opacity-gated visibility, anchored tooltip text on hover, and click-on-chevron navigation. - DashboardPage hint helper hovers the row to reveal the icon, then hovers the icon to open the anchored Tooltip. --- .changeset/hdx-4405-trailing-chevron-hint.md | 5 + .../src/HDXMultiSeriesTableChart.module.scss | 57 ++++++ packages/app/src/HDXMultiSeriesTableChart.tsx | 168 +++++++++--------- .../HDXMultiSeriesTableChart.test.tsx | 127 +++++++------ .../features/dashboard-table-linking.spec.ts | 50 ++++-- .../tests/e2e/page-objects/DashboardPage.ts | 46 +++-- 6 files changed, 274 insertions(+), 179 deletions(-) create mode 100644 .changeset/hdx-4405-trailing-chevron-hint.md create mode 100644 packages/app/src/HDXMultiSeriesTableChart.module.scss diff --git a/.changeset/hdx-4405-trailing-chevron-hint.md b/.changeset/hdx-4405-trailing-chevron-hint.md new file mode 100644 index 0000000000..f447967237 --- /dev/null +++ b/.changeset/hdx-4405-trailing-chevron-hint.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +Dashboard table tiles configured with a row-click action now show the action hint as a trailing chevron icon at the right edge of each row, revealed on hover, with a small tooltip that names the destination. The icon click navigates to the same URL as a row click, with all the standard native browser behaviors (cmd-click new tab, middle-click new tab, right-click context menu). diff --git a/packages/app/src/HDXMultiSeriesTableChart.module.scss b/packages/app/src/HDXMultiSeriesTableChart.module.scss new file mode 100644 index 0000000000..83be3be999 --- /dev/null +++ b/packages/app/src/HDXMultiSeriesTableChart.module.scss @@ -0,0 +1,57 @@ +// Trailing chevron hint for dashboard table tile rows with a +// configured onClick row action. The icon sits at the right edge of +// the last cell and is revealed on row hover. Mirrors the +// .rowButtons pattern from LogTable.module.scss (lines 156-172), +// scoped down to a single icon. See HDX-4405. + +.tableRow { + // Row hover is the visibility trigger for the trailing-icon hint. + // We rely on a descendant selector rather than wrapping a
+ // only. The icon then anchors to the cell's right edge, which + // visually maps to the row's right edge because the last data + // column claims UNDEFINED_WIDTH (see reactTableColumns above). + position: relative; +} + +.rowActionHint { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + color: var(--color-text-muted); + background: transparent; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease-in-out; + text-decoration: none; + + &:hover, + &:focus-visible { + color: var(--color-text-primary); + background-color: var(--color-bg-muted); + } + + &:focus-visible { + outline: 2px solid var(--color-outline-focus); + outline-offset: 1px; + } +} diff --git a/packages/app/src/HDXMultiSeriesTableChart.tsx b/packages/app/src/HDXMultiSeriesTableChart.tsx index 604e0c3140..8f6db00236 100644 --- a/packages/app/src/HDXMultiSeriesTableChart.tsx +++ b/packages/app/src/HDXMultiSeriesTableChart.tsx @@ -2,7 +2,11 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import cx from 'classnames'; import { Tooltip, UnstyledButton } from '@mantine/core'; -import { IconDownload, IconTextWrap } from '@tabler/icons-react'; +import { + IconChevronRight, + IconDownload, + IconTextWrap, +} from '@tabler/icons-react'; import { flexRender, getCoreRowModel, @@ -28,6 +32,7 @@ import type { NumberFormat } from './types'; import { formatNumber } from './utils'; import focusStyles from '../styles/focus.module.scss'; +import styles from './HDXMultiSeriesTableChart.module.scss'; export type TableVariant = 'default' | 'muted'; @@ -307,33 +312,6 @@ export const Table = ({ ); const [wrapLinesEnabled, setWrapLinesEnabled] = useState(false); - // Store the virtual index of the hovered row (not its description string) - // so the label re-derives on every render. If the virtualiser replaces the - // row at that index (scroll re-virtualisation, auto-refetch) the label - // reflects the new row immediately rather than showing stale text. Storing - // the index also makes the safety-net `onMouseLeave` on
-
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} -
+
+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + {/* Trailing chevron hint: anchored Mantine Tooltip + wrapping a Next.js Link in the last cell of + rows that resolve to a URL. The icon is hidden + (opacity: 0) by default and revealed on row + hover via the .tableRow:hover .rowActionHint + rule. The Link inherits the same native + cmd-click / middle-click / right-click + semantics as the per-cell Link in the row body. + Suppressed when the row's templates failed + (rowAction.url === null) so the icon never + promises a destination the click won't open. + See HDX-4405. */} + {isLastCell && rowAction && rowAction.url && ( + + + + + + )} +
+
{isTruncated && (
diff --git a/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx b/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx index 8947a731dc..851c58f2e9 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx +++ b/packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Table } from '@/HDXMultiSeriesTableChart'; @@ -76,7 +77,7 @@ describe('HDXMultiSeriesTableChart ', () => { }); }); - it('reveals the hint at the cursor when hovering a success row', async () => { + it('renders a trailing chevron hint in the last cell of a success row', () => { const getRowAction = jest.fn().mockReturnValue({ url: '/search?source=src_1&where=', description: 'Search HyperDX Logs', @@ -92,42 +93,54 @@ describe('HDXMultiSeriesTableChart
', () => { />, ); - const row = screen.getByText('web').closest('tr')!; - fireEvent.mouseEnter(row); + const hint = screen.getByTestId('row-action-hint'); + expect(hint.tagName).toBe('A'); + expect(hint.getAttribute('href')).toContain('/search?source=src_1'); + expect(hint.getAttribute('aria-hidden')).toBe('true'); + }); + + it('shows the description in an anchored tooltip when the chevron is hovered', async () => { + const user = userEvent.setup(); + const getRowAction = jest.fn().mockReturnValue({ + url: '/search?source=src_1&where=', + description: 'Search HyperDX Logs', + }); + + renderWithMantine( +
{}} + />, + ); + const hint = screen.getByTestId('row-action-hint'); + await user.hover(hint); + + // Mantine renders the Tooltip label as an element with role="tooltip" + // (with the label text as its accessible name) once opened. await waitFor( () => { - expect(screen.getByText('Search HyperDX Logs')).toBeInTheDocument(); + expect( + screen.getByRole('tooltip', { name: 'Search HyperDX Logs' }), + ).toBeInTheDocument(); }, - { timeout: 1000 }, + { timeout: 2000 }, ); }); - it('hides the hint when the hovered virtual index maps to a no-URL row (HDX-4405)', async () => { - // Regression for the virtualiser race: the hovered can unmount - // before its onMouseLeave fires (rapid movement / data refresh). The - // old per-row Tooltip.Floating was stranded in the Portal; the fix - // stores a virtual index and re-derives the label via useMemo each - // render. When the row at that index no longer has a URL, the tooltip - // hides without a leave event from the (now-gone) element. - // - // We verify the key invariant structurally: hovering index 0 (URL row) - // shows the hint; hovering index 1 (no-URL row) hides it — no - // mouseLeave fires between the two enterevents, simulating the - // cursor jumping over the table faster than leave events dispatch. - const multiRowData = [ - { ServiceName: 'web', Count: 10 }, - { ServiceName: 'api', Count: 5 }, - ]; - const getRowAction = jest.fn((row: { ServiceName: string }) => - row.ServiceName === 'web' - ? { url: '/search?source=src_1&where=', description: 'Search Logs' } - : { url: null, description: '', onClickError: jest.fn() }, - ); + it('renders the dashboard variant description in the tooltip', async () => { + const user = userEvent.setup(); + const getRowAction = jest.fn().mockReturnValue({ + url: '/dashboards/dash_1?where=', + description: 'Open dashboard "API Latency"', + }); renderWithMantine(
', () => { />, ); - const webRow = screen.getByText('web').closest('tr')!; - const apiRow = screen.getByText('api').closest('tr')!; - - // Hover the URL row — tooltip must appear - fireEvent.mouseEnter(webRow); - await waitFor(() => { - const hint = screen.getByTestId('row-action-hint'); - const tooltipBox = hint.closest('[style*="display"]'); - expect(tooltipBox?.style.display).toBe('block'); - }); - - // Hover the no-URL row WITHOUT firing mouseLeave on the first row. - // This simulates the cursor jumping faster than leave events dispatch. - fireEvent.mouseEnter(apiRow); + const hint = screen.getByTestId('row-action-hint'); + await user.hover(hint); - // The label must derive to null (apiRow has url:null) so tooltip hides. - await waitFor(() => { - const hint = screen.getByTestId('row-action-hint'); - const tooltipBox = hint.closest('[style*="display"]'); - expect(tooltipBox?.style.display).toBe('none'); - }); + await waitFor( + () => { + expect( + screen.getByRole('tooltip', { + name: 'Open dashboard "API Latency"', + }), + ).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); }); }); describe('getRowAction failure path', () => { - it('renders the cell as a
', () => { expect(btn.hasAttribute('href')).toBe(false); }); - fireEvent.click(buttons[0]); + await user.click(buttons[0]); expect(onClickError).toHaveBeenCalledTimes(1); }); - it('does not reveal a hint when the row action has no url', async () => { + it('does not render a trailing chevron hint when rowAction.url is null', () => { const getRowAction = jest.fn().mockReturnValue({ url: null, description: 'Search HyperDX Logs', @@ -209,17 +215,15 @@ describe('HDXMultiSeriesTableChart
', () => { />, ); - const row = screen.getByText('web').closest('tr')!; - fireEvent.mouseEnter(row); - - // Give the tooltip a chance to mount; assert it never does. - await new Promise(resolve => setTimeout(resolve, 250)); - expect(screen.queryByText('Search HyperDX Logs')).not.toBeInTheDocument(); + // The icon must not be in the DOM at all on failure rows: showing + // a chevron would promise a destination the click can't deliver, + // because the click only fires an error toast. + expect(screen.queryByTestId('row-action-hint')).toBeNull(); }); }); describe('legacy getRowSearchLink fallback', () => { - it('renders the cell as a Link without a HoverCard when only getRowSearchLink is provided', () => { + it('renders the cell as a Link without a trailing chevron hint when only getRowSearchLink is provided', () => { const getRowSearchLink = jest .fn() .mockReturnValue('/search?source=legacy&where='); @@ -240,11 +244,15 @@ describe('HDXMultiSeriesTableChart
', () => { expect(link.getAttribute('data-shape')).toBe('link'); expect(link.getAttribute('href')).toContain('/search?source=legacy'); }); + + // No trailing chevron on the legacy path; it's an additive surface + // tied to the new getRowAction prop only. + expect(screen.queryByTestId('row-action-hint')).toBeNull(); }); }); describe('no action configured', () => { - it('renders plain cells with no Link, button, or HoverCard', () => { + it('renders plain cells with no Link, button, or trailing chevron', () => { renderWithMantine(
', () => { expect( screen.queryAllByTestId('dashboard-table-row-action'), ).toHaveLength(0); + expect(screen.queryByTestId('row-action-hint')).toBeNull(); }); }); }); diff --git a/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts b/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts index 1fb89a64f2..328e503a3a 100644 --- a/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-table-linking.spec.ts @@ -752,14 +752,15 @@ test.describe( }); }); - test('Tooltip hint appears on hover and disappears on mouse-leave — no stranded tooltip (HDX-4405)', async ({ + test('Trailing chevron hint appears on row hover and navigates to the action URL (HDX-4405)', async ({ page, }) => { - // Regression test for HDX-4405: Tooltip.Floating instances mounted - // per virtual row were getting stranded in their Portal when the row - // unmounted before onMouseLeave fired (rapid mouse movement). The fix - // moves a single shared Tooltip.Floating to so its state is - // never tied to a virtual row's lifecycle. + // Pivot of the original HDX-4405 regression test: the row-click hint + // is now an anchored Mantine Tooltip wrapping a trailing chevron icon + // in the last cell, rather than a Tooltip.Floating that tracks the + // cursor. The chevron is hidden until the row is hovered and the + // tooltip is tied to the icon's lifecycle, so no stranded popup is + // possible by construction. const ts = Date.now(); await test.step('Create a table tile with a Search row-click action', async () => { @@ -779,21 +780,34 @@ test.describe( await dashboardPage.waitForTableTileRows(0); - await test.step('Hover over first row — tooltip hint must appear', async () => { - await dashboardPage.hoverFirstTableRowAndGetTooltip(0); - // hoverFirstTableRowAndGetTooltip already asserts visibility before - // returning; reaching here means the tooltip appeared successfully. + await test.step('Trailing chevron is hidden until the row is hovered', async () => { + const hint = dashboardPage.getRowActionHint(0); + // The icon is in the DOM but its visibility is gated by the + // .tableRow:hover .rowActionHint CSS opacity transition. + await expect(hint).toHaveCSS('opacity', '0'); }); - await test.step('Move mouse away from the table — tooltip must disappear', async () => { - // Move to a neutral area well outside the table. The - // onMouseLeave safety net clears hoveredVirtualIndex, which makes - // hoveredRowDescription derive to null, disabling the shared - // Tooltip.Floating (display:none in the Portal). + await test.step('Hovering the row reveals the chevron and the tooltip describes the action', async () => { + const tooltip = await dashboardPage.hoverFirstTableRowAndGetTooltip(0); + await expect(tooltip).toContainText(/Search|Open/); + }); + + await test.step('Moving the cursor away closes the tooltip', async () => { + // Move to a neutral area; Mantine Tooltip closes on mouseleave + // (closeDelay=100ms in the component). await page.mouse.move(10, 10); - await expect(page.getByTestId('row-action-hint')).toBeHidden({ - timeout: 3000, - }); + await expect(page.getByRole('tooltip')).toBeHidden({ timeout: 3000 }); + }); + + await test.step('Clicking the trailing chevron navigates to the same destination as the row body', async () => { + // Re-hover the row, then click the chevron. The Link inside the + // tooltip-wrapped chevron points to the same href as the per-cell + // links, so click navigates exactly as a row click would. + const row = dashboardPage.getFirstTableRow(0); + await row.hover(); + const hint = dashboardPage.getRowActionHint(0); + await hint.click(); + await expect(page).toHaveURL(/\/search\?/, { timeout: 10000 }); }); }); }, diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 4f5d2778c4..48cb13eba1 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -981,30 +981,38 @@ export class DashboardPage { } /** - * Hover over the first data row of a table tile and wait for the - * floating tooltip to appear. Returns the tooltip locator so callers - * can make further assertions. + * Locator for the trailing chevron hint element rendered in the last + * cell of clickable rows. The icon carries + * `data-testid="row-action-hint"` and is the trigger element for the + * anchored Mantine Tooltip describing the row's onClick destination. + */ + getRowActionHint(tileIndex = 0): Locator { + return this.getTile(tileIndex) + .locator('table tbody tr[data-index]') + .first() + .getByTestId('row-action-hint'); + } + + /** + * Hover the first data row of a table tile, then hover its trailing + * chevron hint so the anchored Mantine Tooltip opens. Returns the + * tooltip locator so callers can assert on the description text. * - * Tooltip.Floating renders its content inside a Portal at the document - * body level. Mantine uses CSS modules (hashed classes) so we locate the - * floating popup by text content that matches the known hint patterns used - * by describeOnClick (e.g. "Open in search", "Search ", - * "Open dashboard"). The tooltip is shown via `display: block` on the - * Portal div, so Playwright's `toBeVisible` checks that correctly. + * The chevron icon (`data-testid="row-action-hint"`) is hidden + * (`opacity: 0`) until the row is hovered. Hovering the row reveals + * the icon via the `.tableRow:hover .rowActionHint` CSS rule. The + * Mantine Tooltip wrapping the icon then opens when the cursor moves + * to the icon itself, rendering its label in a portal at the body + * level (resolved via `getByRole('tooltip')`). */ async hoverFirstTableRowAndGetTooltip(tileIndex = 0): Promise { const row = this.getFirstTableRow(tileIndex); await row.hover(); - // Trigger a mousemove inside the row so Tooltip.Floating's internal - // mousemove handler has a coordinate to position against. - const box = await row.boundingBox(); - if (box) { - await this.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - } - // The Tooltip.Floating label is a . - // The portal div uses display:none when disabled, so Playwright's - // toBeVisible() correctly reflects the open/closed state. - const tooltip = this.page.getByTestId('row-action-hint'); + const hint = this.getRowActionHint(tileIndex); + // Hover the icon directly so the anchored Tooltip's mouseEnter + // listener fires; row-hover alone only fades the icon in. + await hint.hover(); + const tooltip = this.page.getByRole('tooltip'); await tooltip.waitFor({ state: 'visible', timeout: 5000 }); return tooltip; }