Skip to content

Commit 4457c2e

Browse files
authored
feat: Add click-to-adhoc-filter to table charts
1 parent b2041c8 commit 4457c2e

18 files changed

Lines changed: 1141 additions & 35 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
@@ -542,7 +542,7 @@ function inferValueColumns(
542542
);
543543
}
544544

545-
function inferGroupColumns(meta: Array<{ name: string; type: string }>) {
545+
export function inferGroupColumns(meta: Array<{ name: string; type: string }>) {
546546
return filterColumnMetaByType(meta, [
547547
JSDataType.String,
548548
JSDataType.Map,

‎packages/app/src/DBDashboardPage.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,6 +1298,7 @@ const Tile = ({
12981298
})
12991299
: undefined
13001300
}
1301+
onFilter={onFilter}
13011302
/>
13021303
</Box>
13031304
)}
@@ -1477,6 +1478,7 @@ const Tile = ({
14771478
tileVariables,
14781479
variableNames,
14791480
searchRowContext,
1481+
onFilter,
14801482
],
14811483
);
14821484

‎packages/app/src/HDXMultiSeriesTableChart.tsx‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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,6 +112,7 @@ 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;
@@ -163,6 +171,7 @@ export const Table = ({
163171
sortingFn,
164172
color,
165173
colorRules,
174+
filterKey,
166175
},
167176
i,
168177
) =>
@@ -189,6 +198,27 @@ export const Table = ({
189198
formattedValue = formatNumber(value, numberFormat);
190199
}
191200

201+
// Map / Array values render as JSON, which their `toString()`
202+
// in a filter condition wouldn't match.
203+
const content: React.ReactNode =
204+
filterKey &&
205+
onCellFilter &&
206+
value != null &&
207+
typeof value !== 'object' ? (
208+
<DBRowTableFieldWithPopover
209+
cellValue={value}
210+
columnName={filterKey}
211+
onPropertyAddClick={onCellFilter}
212+
wrapLinesEnabled={wrapLinesEnabled}
213+
tableContainerRef={tableContainerRef.current}
214+
inline
215+
>
216+
{formattedValue}
217+
</DBRowTableFieldWithPopover>
218+
) : (
219+
formattedValue
220+
);
221+
192222
// Resolve this cell's color from the column config: ordered
193223
// rules first (last match wins), then the column's static
194224
// color, else no override. ClickHouse serializes numeric
@@ -257,7 +287,7 @@ export const Table = ({
257287
data-testid="dashboard-table-row-action"
258288
data-shape="external-link"
259289
>
260-
{formattedValue}
290+
{content}
261291
</a>
262292
);
263293
}
@@ -273,7 +303,7 @@ export const Table = ({
273303
data-testid="dashboard-table-row-action"
274304
data-shape="link"
275305
>
276-
{formattedValue}
306+
{content}
277307
</Link>
278308
);
279309
}
@@ -295,7 +325,7 @@ export const Table = ({
295325
data-testid="dashboard-table-row-action"
296326
data-shape="button"
297327
>
298-
{formattedValue}
328+
{content}
299329
</button>
300330
);
301331
}
@@ -312,15 +342,15 @@ export const Table = ({
312342
data-testid="dashboard-table-row-action"
313343
data-shape="link"
314344
>
315-
{formattedValue}
345+
{content}
316346
</Link>
317347
);
318348
}
319349
}
320350

321351
return (
322352
<div className={className} style={colorStyle}>
323-
{formattedValue}
353+
{content}
324354
</div>
325355
);
326356
},

‎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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,4 +566,64 @@ 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('filters on and out of 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('offers no actions on columns without a filter key', async () => {
610+
renderTable();
611+
612+
await userEvent.hover(screen.getByText('10'));
613+
614+
expect(
615+
screen.queryByRole('button', { name: 'Copy field value' }),
616+
).toBeNull();
617+
});
618+
619+
it('offers no actions on Map values', async () => {
620+
renderTable();
621+
622+
await userEvent.hover(screen.getByText('{"a":"1"}'));
623+
624+
expect(
625+
screen.queryByRole('button', { name: 'Copy field value' }),
626+
).toBeNull();
627+
});
628+
});
569629
});

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

Lines changed: 18 additions & 1 deletion
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

‎packages/app/src/components/DBTableChart.tsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,20 @@ import { getMinGranularitySeconds } from '@/hooks/useChartConfig';
2525
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
2626
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
2727
import { useOnClickLinkBuilder } from '@/hooks/useOnClickLinkBuilder';
28+
import { useResultColumnFilterKeys } from '@/hooks/useResultColumnFilterKeys';
2829
import {
2930
getBuilderValueColumnCount,
3031
useChartNumberFormats,
3132
useSource,
3233
} from '@/source';
34+
import type { TileFilterHandler } from '@/types';
3335
import { useIntersectionObserver } from '@/utils';
3436

3537
import ChartContainer from './charts/ChartContainer';
3638
import ChartErrorState, {
3739
ChartErrorStateVariant,
3840
} from './charts/ChartErrorState';
41+
import type { CellFilterHandler } from './DBTable/DBRowTableFieldWithPopover';
3942
import { getClientSideSortingFn } from './DBTable/sorting';
4043
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
4144

@@ -53,6 +56,7 @@ export default function DBTableChart({
5356
showMVOptimizationIndicator = true,
5457
variant,
5558
errorVariant,
59+
onFilter,
5660
}: {
5761
config: ChartConfigWithOptTimestamp;
5862
getRowSearchLink?: (row: any) => string | null;
@@ -67,6 +71,8 @@ export default function DBTableChart({
6771
showMVOptimizationIndicator?: boolean;
6872
variant?: TableVariant;
6973
errorVariant?: ChartErrorStateVariant;
74+
/** When set, group-by cells offer filter / exclude actions for their value. */
75+
onFilter?: TileFilterHandler;
7076
}) {
7177
const [sort, setSort] = useState<SortingState>([]);
7278

@@ -194,6 +200,16 @@ export default function DBTableChart({
194200
return { colorByColumn, rulesByColumn };
195201
}, [data?.meta, queriedConfig]);
196202

203+
const filterKeyByColumn = useResultColumnFilterKeys({
204+
config: queriedConfig,
205+
meta: data?.meta,
206+
enabled: !!onFilter,
207+
});
208+
const onCellFilter = useCallback<CellFilterHandler>(
209+
(key, value, action) => onFilter?.([{ key, value }], action),
210+
[onFilter],
211+
);
212+
197213
const columns = useMemo(() => {
198214
const rows = data?.data ?? [];
199215
if (rows.length === 0) {
@@ -253,11 +269,13 @@ export default function DBTableChart({
253269
? undefined
254270
: rulesByColumn.get(key),
255271
sortingFn: getClientSideSortingFn(data?.meta, key),
272+
filterKey: filterKeyByColumn.get(key),
256273
}));
257274
}, [
258275
data,
259276
queriedConfig,
260277
hiddenColumns,
278+
filterKeyByColumn,
261279
aliasMap,
262280
formatByColumn,
263281
colorByColumn,
@@ -349,6 +367,10 @@ export default function DBTableChart({
349367
}
350368
onSortingChange={handleSortingChange}
351369
variant={variant}
370+
// Placeholder rows belong to the previous query's columns
371+
onCellFilter={
372+
onFilter && !isPlaceholderData ? onCellFilter : undefined
373+
}
352374
alternateRowBackground={!!queriedConfig.alternateRowBackground}
353375
className={isPlaceholderData ? 'effect-pulse' : undefined}
354376
tableBottom={

‎packages/app/src/components/__tests__/DBTableChart.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ jest.mock('@/hooks/useOnClickLinkBuilder', () => ({
5454
useOnClickLinkBuilder: jest.fn().mockReturnValue(null),
5555
}));
5656

57+
jest.mock('@/hooks/useResultColumnFilterKeys', () => ({
58+
useResultColumnFilterKeys: jest.fn(() => new Map()),
59+
}));
60+
5761
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
5862
jest.fn(() => null),
5963
);

0 commit comments

Comments
 (0)