Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/hdx-4405-trailing-action-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

Dashboard table tiles configured with a row-click action now show a trailing arrow-up-right icon at the right edge of each row, revealed on hover, with a small tooltip that names the destination. Actionable rows get a stronger background highlight on hover to reinforce interactivity before the user sees the arrow fade in. 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).
68 changes: 68 additions & 0 deletions packages/app/src/HDXMultiSeriesTableChart.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Trailing arrow 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 <tbody>
// 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;
}
}

.actionableRow {
// Stronger hover background on rows that resolve to a click
// destination. Non-actionable rows keep the default
// `bg-muted-hover` utility (`--color-bg-muted`), so the visual
// delta between the two reinforces interactivity before the
// user even sees the trailing arrow fade in.
&:hover {
background-color: var(--color-bg-highlighted);
}
}

.lastCell {
// Containing block for the absolute-positioned .rowActionHint.
// <tr> does not reliably form a positioning context across
// browsers, so we attach `position: relative` to the last <td>
// 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;
}
}
187 changes: 104 additions & 83 deletions packages/app/src/HDXMultiSeriesTableChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
IconArrowUpRight,
IconDownload,
IconTextWrap,
} from '@tabler/icons-react';
import {
flexRender,
getCoreRowModel,
Expand All @@ -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';

Expand Down Expand Up @@ -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 <tbody> correct:
// it sets null rather than the prior row's description. See HDX-4405.
const [hoveredVirtualIndex, setHoveredVirtualIndex] = useState<number | null>(
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<any> | 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 => ({
Expand Down Expand Up @@ -398,62 +376,105 @@ export const Table = ({
</tr>
))}
</thead>
{/* Single Tooltip.Floating wrapping the whole <tbody> 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 <tbody>, which never unmounts,
and the label is re-derived from hoveredVirtualIndex each render so
scroll re-virtualisation never shows stale text. See HDX-4405. */}
<Tooltip.Floating
label={
<span data-testid="row-action-hint">{hoveredRowDescription}</span>
}
withinPortal
disabled={!hoveredRowDescription}
>
{/* onMouseLeave on <tbody> 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. */}
<tbody onMouseLeave={clearHovered}>
{paddingTop > 0 && (
<tr>
<td colSpan={99999} style={{ height: `${paddingTop}px` }} />
</tr>
)}
{items.map(virtualRow => {
const row = rows[virtualRow.index] as TableRow<any>;
return (
<tr
key={virtualRow.key}
className="bg-muted-hover"
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
onMouseEnter={() => setHoveredVirtualIndex(virtualRow.index)}
onMouseLeave={clearHovered}
>
{row.getVisibleCells().map(cell => {
return (
<td key={cell.id} title={`${cell.getValue()}`}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
);
})}
</tr>
);
})}
{paddingBottom > 0 && (
<tr>
<td colSpan={99999} style={{ height: `${paddingBottom}px` }} />
<tbody>
{paddingTop > 0 && (
<tr>
<td colSpan={99999} style={{ height: `${paddingTop}px` }} />
</tr>
)}
{items.map(virtualRow => {
const row = rows[virtualRow.index] as TableRow<any>;
// Compute the action once per row so the trailing-icon hint
// shares the memoized result from useOnClickLinkBuilder with
// the per-cell renders. The hook keys its cache off the row
// reference via WeakMap (see useOnClickLinkBuilder), so this
// extra call is an O(1) lookup when the per-cell renders
// populate the entry first, and one extra compute per row
// only when the WeakMap entry is cold (e.g. fresh data).
const rowAction = getRowAction ? getRowAction(row.original) : null;
// Narrow `rowAction.url` to a non-null `string` once per row so
// the trailing-icon guard below (and the `<Link href={...}>`
// sink inside it) doesn't need an `as string` cast and stays
// type-safe under future changes to the `RowAction` shape.
const actionUrl = rowAction?.url ?? null;
const isActionable = actionUrl !== null;
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
return (
<tr
key={virtualRow.key}
className={cx(styles.tableRow, {
// Actionable rows get the stronger `--color-bg-highlighted`
// hover via `.actionableRow`; everything else falls back
// to the global `bg-muted-hover` utility.
[styles.actionableRow]: isActionable,
'bg-muted-hover': !isActionable,
})}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
{visibleCells.map((cell, cellIndex) => {
const isLastCell = cellIndex === lastCellIndex;
return (
<td
key={cell.id}
title={`${cell.getValue()}`}
className={cx({
[styles.lastCell]: isLastCell,
})}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
{/* Trailing arrow 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.
The arrow-up-right shape signals "navigate
elsewhere" without colliding with the
chevron-right used by sidebar group collapse
/ expand affordances. See HDX-4405. */}
{isLastCell && actionUrl !== null && rowAction && (
<Tooltip
label={rowAction.description}
position="left"
withArrow
openDelay={300}
closeDelay={100}
fz="xs"
>
<Link
href={actionUrl}
prefetch={false}
tabIndex={-1}
aria-hidden="true"
className={styles.rowActionHint}
data-testid="row-action-hint"
>
<IconArrowUpRight size={14} />
</Link>
</Tooltip>
)}
</td>
);
})}
</tr>
)}
</tbody>
</Tooltip.Floating>
);
})}
{paddingBottom > 0 && (
<tr>
<td colSpan={99999} style={{ height: `${paddingBottom}px` }} />
</tr>
)}
</tbody>
</table>
{isTruncated && (
<div className="p-2 text-center">
Expand Down
Loading
Loading