Skip to content

Commit 180a1f1

Browse files
authored
feat: Add click-to-adhoc-filter to table charts (#3389)
1 parent be5304d commit 180a1f1

18 files changed

Lines changed: 1313 additions & 41 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperdx/common-utils': patch
3+
'@hyperdx/app': patch
4+
---
5+
6+
feat: Filter table tile group-by cells into adhoc dashboard filters

‎packages/app/src/ChartUtils.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ function inferValueColumns(
547547
);
548548
}
549549

550-
function inferGroupColumns(meta: Array<{ name: string; type: string }>) {
550+
export function inferGroupColumns(meta: Array<{ name: string; type: string }>) {
551551
return filterColumnMetaByType(meta, [
552552
JSDataType.String,
553553
JSDataType.Map,

‎packages/app/src/DBDashboardPage.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,6 +1321,7 @@ const Tile = ({
13211321
})
13221322
: undefined
13231323
}
1324+
onFilter={onFilter}
13241325
/>
13251326
</Box>
13261327
)}
@@ -1500,6 +1501,7 @@ const Tile = ({
15001501
tileVariables,
15011502
variableNames,
15021503
searchRowContext,
1504+
onFilter,
15031505
],
15041506
);
15051507

‎packages/app/src/HDXMultiSeriesTableChart.tsx‎

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useRef, useState } from 'react';
1+
import { useCallback, useMemo, useState } from 'react';
22
import Link from 'next/link';
33
import cx from 'classnames';
44
import {
@@ -28,6 +28,9 @@ import { ColumnDef } from '@tanstack/react-table';
2828
import { useVirtualizer } from '@tanstack/react-virtual';
2929

3030
import { CsvExportButton } from './components/CsvExportButton';
31+
import DBRowTableFieldWithPopover, {
32+
type CellFilterHandler,
33+
} from './components/DBTable/DBRowTableFieldWithPopover';
3134
import TableHeader from './components/DBTable/TableHeader';
3235
import { useCsvExport } from './hooks/useCsvExport';
3336
import type { RowAction } from './hooks/useOnClickLinkBuilder';
@@ -61,6 +64,7 @@ export const Table = ({
6164
variant = 'default',
6265
alternateRowBackground = false,
6366
className,
67+
onCellFilter,
6468
}: {
6569
data: any[];
6670
columns: {
@@ -78,6 +82,9 @@ export const Table = ({
7882
// Ordered conditional color rules evaluated against each cell's value
7983
// (last match wins). Resolves to `color` when no rule matches.
8084
colorRules?: ColorCondition[];
85+
// Expression (or PromQL label) the column's values filter on. Cells of a
86+
// column with a key offer filter / exclude actions via `onCellFilter`.
87+
filterKey?: string;
8188
}[];
8289
groupColumnName?: string;
8390
// Returns the row click destination + a hover-hint description. When
@@ -105,11 +112,14 @@ export const Table = ({
105112
// Extra classes for the scroll container, e.g. `effect-pulse` while the
106113
// tile is refreshing.
107114
className?: string;
115+
onCellFilter?: CellFilterHandler;
108116
}) => {
109117
const brandName = useBrandDisplayName();
110118
const MIN_COLUMN_WIDTH_PX = 100;
111119
//we need a reference to the scrolling element for logic down below
112-
const tableContainerRef = useRef<HTMLDivElement>(null);
120+
const [tableContainer, setTableContainer] = useState<HTMLDivElement | null>(
121+
null,
122+
);
113123

114124
const truncatedData = useMemo(() => {
115125
if (data.length > MAX_TABLE_ROWS) {
@@ -119,7 +129,7 @@ export const Table = ({
119129
}, [data]);
120130
const isTruncated = truncatedData.length === MAX_TABLE_ROWS;
121131

122-
const tableWidth = tableContainerRef.current?.clientWidth;
132+
const tableWidth = tableContainer?.clientWidth;
123133
const numColumns = columns.filter(c => c.visible !== false).length + 1;
124134
const dataColumnsWidthPerc = columns
125135
.filter(c => c.visible !== false)
@@ -163,6 +173,7 @@ export const Table = ({
163173
sortingFn,
164174
color,
165175
colorRules,
176+
filterKey,
166177
},
167178
i,
168179
) =>
@@ -189,6 +200,29 @@ export const Table = ({
189200
formattedValue = formatNumber(value, numberFormat);
190201
}
191202

203+
// Map / Array values render as JSON, which their `toString()`
204+
// in a filter condition wouldn't match.
205+
const isAdhocFilterable =
206+
filterKey &&
207+
onCellFilter &&
208+
value != null &&
209+
typeof value !== 'object';
210+
211+
const content: React.ReactNode = isAdhocFilterable ? (
212+
<DBRowTableFieldWithPopover
213+
cellValue={value}
214+
columnName={filterKey}
215+
onPropertyAddClick={onCellFilter}
216+
wrapLinesEnabled={wrapLinesEnabled}
217+
tableContainerRef={tableContainer}
218+
inline
219+
>
220+
{formattedValue}
221+
</DBRowTableFieldWithPopover>
222+
) : (
223+
formattedValue
224+
);
225+
192226
// Resolve this cell's color from the column config: ordered
193227
// rules first (last match wins), then the column's static
194228
// color, else no override. ClickHouse serializes numeric
@@ -257,7 +291,7 @@ export const Table = ({
257291
data-testid="dashboard-table-row-action"
258292
data-shape="external-link"
259293
>
260-
{formattedValue}
294+
{content}
261295
</a>
262296
);
263297
}
@@ -273,7 +307,7 @@ export const Table = ({
273307
data-testid="dashboard-table-row-action"
274308
data-shape="link"
275309
>
276-
{formattedValue}
310+
{content}
277311
</Link>
278312
);
279313
}
@@ -295,7 +329,7 @@ export const Table = ({
295329
data-testid="dashboard-table-row-action"
296330
data-shape="button"
297331
>
298-
{formattedValue}
332+
{content}
299333
</button>
300334
);
301335
}
@@ -312,15 +346,15 @@ export const Table = ({
312346
data-testid="dashboard-table-row-action"
313347
data-shape="link"
314348
>
315-
{formattedValue}
349+
{content}
316350
</Link>
317351
);
318352
}
319353
}
320354

321355
return (
322356
<div className={className} style={colorStyle}>
323-
{formattedValue}
357+
{content}
324358
</div>
325359
);
326360
},
@@ -376,7 +410,7 @@ export const Table = ({
376410

377411
const rowVirtualizer = useVirtualizer({
378412
count: rows.length,
379-
getScrollElement: () => tableContainerRef.current,
413+
getScrollElement: () => tableContainer,
380414
estimateSize: useCallback(() => 58, []),
381415
overscan: 10,
382416
paddingEnd: 20,
@@ -409,7 +443,7 @@ export const Table = ({
409443
return (
410444
<div
411445
className={cx('overflow-auto h-100', styles.tableWrapper, className)}
412-
ref={tableContainerRef}
446+
ref={setTableContainer}
413447
>
414448
<table
415449
className="w-100"

‎packages/app/src/__tests__/DBDashboardImportPage.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ jest.mock('next/router', () => ({
2727
jest.mock('nuqs', () => ({
2828
parseAsString: 'parseAsString',
2929
useQueryState: () => [null, jest.fn()],
30+
createParser: (opts: object) => opts,
3031
}));
3132
jest.mock('../source', () => ({
3233
useSources: () => ({ data: mockSources }),

‎packages/app/src/__tests__/HDXMultiSeriesTableChart.test.tsx‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,4 +566,83 @@ describe('HDXMultiSeriesTableChart <Table>', () => {
566566
});
567567
});
568568
});
569+
570+
describe('cell filter actions', () => {
571+
const filterColumns = [
572+
{ ...baseColumns[0], filterKey: 'ServiceName' },
573+
baseColumns[1],
574+
{
575+
id: 'attrs',
576+
dataKey: 'Attrs',
577+
displayName: 'Attrs',
578+
filterKey: 'Attrs',
579+
},
580+
];
581+
const filterData = [{ ServiceName: 'web', Count: 10, Attrs: { a: '1' } }];
582+
const renderTable = (onCellFilter = jest.fn()) =>
583+
renderWithMantine(
584+
<Table
585+
data={filterData}
586+
columns={filterColumns}
587+
onCellFilter={onCellFilter}
588+
sorting={[]}
589+
onSortingChange={() => {}}
590+
/>,
591+
);
592+
593+
it('excludes a value by its column filter key', async () => {
594+
const onCellFilter = jest.fn();
595+
renderTable(onCellFilter);
596+
597+
await userEvent.hover(screen.getByText('web'));
598+
await userEvent.click(
599+
await screen.findByRole('button', { name: 'Exclude this value' }),
600+
);
601+
602+
expect(onCellFilter).toHaveBeenCalledWith(
603+
'ServiceName',
604+
'web',
605+
'exclude',
606+
);
607+
});
608+
609+
it("keeps row-action links' accessible name when cells are filterable", () => {
610+
renderWithMantine(
611+
<Table
612+
data={filterData}
613+
columns={filterColumns}
614+
onCellFilter={jest.fn()}
615+
getRowAction={() => ({
616+
url: '/search?q=web',
617+
description: 'Search HyperDX Logs',
618+
})}
619+
sorting={[]}
620+
onSortingChange={() => {}}
621+
/>,
622+
);
623+
624+
expect(screen.getByRole('link', { name: 'web' })).toBeInTheDocument();
625+
});
626+
627+
it('offers no actions on columns without a filter key', async () => {
628+
renderTable();
629+
630+
await userEvent.hover(screen.getByText('10'));
631+
632+
// The popover opens through a transition, so wait it out
633+
await expect(
634+
screen.findByRole('button', { name: 'Copy field value' }),
635+
).rejects.toThrow();
636+
});
637+
638+
it('offers no actions on Map values', async () => {
639+
renderTable();
640+
641+
await userEvent.hover(screen.getByText('{"a":"1"}'));
642+
643+
await expect(
644+
screen.findByRole('button', { name: 'Copy field value' }),
645+
).rejects.toThrow();
646+
});
647+
});
569648
});

‎packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import { DBRowTableIconButton } from './DBRowTableIconButton';
1515

1616
import styles from '@styles/LogTable.module.scss';
1717

18+
/** Filter on (`include`) or out (`exclude`) a cell's value, by its column's filter key. */
19+
export type CellFilterHandler = (
20+
key: string,
21+
value: string,
22+
action: 'include' | 'exclude',
23+
) => void;
24+
1825
interface DBRowTableFieldWithPopoverProps {
1926
children: React.ReactNode;
2027
cellValue: unknown;
@@ -24,6 +31,10 @@ interface DBRowTableFieldWithPopoverProps {
2431
filterValue?: string;
2532
tableContainerRef: HTMLDivElement | null;
2633
isChart?: boolean;
34+
/** Leaves out the cell padding, for tables that pad their own cells. */
35+
inline?: boolean;
36+
/** Overrides the side panel context's filter handler. */
37+
onPropertyAddClick?: CellFilterHandler;
2738
}
2839

2940
const DBRowTableFieldWithPopover = ({
@@ -34,6 +45,8 @@ const DBRowTableFieldWithPopover = ({
3445
columnName,
3546
filterValue,
3647
isChart = false,
48+
inline = false,
49+
onPropertyAddClick: onPropertyAddClickProp,
3750
}: DBRowTableFieldWithPopoverProps) => {
3851
const [opened, { close, open }] = useDisclosure(false);
3952
const [isCopied, setIsCopied] = useState(false);
@@ -54,7 +67,10 @@ const DBRowTableFieldWithPopover = ({
5467
}, []);
5568

5669
// Get filter functionality from context
57-
const { onPropertyAddClick } = use(RowSidePanelContext);
70+
const { onPropertyAddClick: onPropertyAddClickContext } =
71+
use(RowSidePanelContext);
72+
const onPropertyAddClick =
73+
onPropertyAddClickProp ?? onPropertyAddClickContext;
5874

5975
// Check if we have both the column name and filter function available
6076
const canFilter = columnName && onPropertyAddClick && cellValue != null;
@@ -151,6 +167,7 @@ const DBRowTableFieldWithPopover = ({
151167
[styles.truncated]: !wrapLinesEnabled && !isChart,
152168
[styles.wrapped]: wrapLinesEnabled,
153169
[styles.chart]: isChart,
170+
[styles.inline]: inline,
154171
})}
155172
>
156173
<Popover
@@ -168,7 +185,6 @@ const DBRowTableFieldWithPopover = ({
168185
onMouseLeave={handleMouseLeave}
169186
onClick={handleClick}
170187
tabIndex={-1}
171-
aria-hidden="true"
172188
style={{ cursor: 'pointer' }}
173189
>
174190
{children}

0 commit comments

Comments
 (0)