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/search-row-multi-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': minor
---

feat: Select rows in the search results table to copy or download them
28 changes: 28 additions & 0 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1647,6 +1647,19 @@ export function DBSearchPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updateRelativeTimeInputValue, searchedConfig.source, isReady]);

// Row selection is scoped to one result set, so anything that re-queries
// or changes the result set should reset the selection.
const selectionResetKey = useMemo(
Comment thread
pulpdrew marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Adding or removing a displayed column silently discards the whole row selection

selectionResetKey stringifies all of searchedConfig, and select is part of queryStateMap (DBSearchPage.tsx:899); toggleColumn (DBSearchPage.tsx:1738) writes select and calls onSubmit(), so a user who selects 20 rows and then adds one column from the side panel — or clicks the X on a column header — loses the selection with no indication. Build the key from the fields that determine which rows come back (source, where, whereLanguage, filters, orderBy, time range, denoise) and leave the projection out. Worth noting the one case that genuinely needs a reset: when rowKeyColumns is undefined (no _block_number/_block_offset, DBRowTable.tsx:1506) the row id is derived from every selected column, so ids do change with select — reset explicitly for that case rather than for all.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

() =>
JSON.stringify([
searchedConfig,
searchedTimeRange[0].getTime(),
searchedTimeRange[1].getTime(),
denoiseResults,
]),
[searchedConfig, searchedTimeRange, denoiseResults],
);

useLiveUpdate({
isLive,
interval,
Expand All @@ -1662,6 +1675,18 @@ export function DBSearchPage() {
setShouldShowLiveModeHint(isLive === false);
}, [isLive]);

// Selected rows belong to one result set, so a live refresh would churn the
// table under them. Same treatment as expanding a row: leave live tail, which
// surfaces the Resume Live Tail button so the exit is visible and undoable.
const onSelectedRowsChange = useCallback(
(hasSelectedRows: boolean) => {
if (hasSelectedRows && isLive) {
setIsLive(false);
}
},
[isLive, setIsLive],
);

// Callback to handle when rows are expanded - kick user out of live tail
const onExpandedRowsChange = useCallback(
(hasExpandedRows: boolean) => {
Expand Down Expand Up @@ -2779,6 +2804,9 @@ export function DBSearchPage() {
onSortingChange={onSortingChange}
initialSortBy={initialSortBy}
enableSmallFirstWindow
enableRowSelection
selectionResetKey={selectionResetKey}
onSelectedRowsChange={onSelectedRowsChange}
onResolvedColumnsChange={onResolvedColumnsChange}
/>
)}
Expand Down
17 changes: 2 additions & 15 deletions packages/app/src/components/CsvExportButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { useCallback } from 'react';
import Papa from 'papaparse';

import { downloadTextFile } from '@/utils/downloadFile';
import { downloadCsv } from '@/utils/csv';

interface CsvExportButtonProps {
data: Record<string, any>[];
Expand Down Expand Up @@ -35,19 +34,7 @@ export const CsvExportButton: React.FC<CsvExportButtonProps> = ({

onExportStart?.();

const csv = Papa.unparse(data, {
quotes: true,
quoteChar: '"',
escapeChar: '"',
delimiter: ',',
header: true,
});
// Leading BOM so Excel reads the file as UTF-8.
downloadTextFile(
`\ufeff${csv}`,
typeof filename === 'string' ? `${filename}.csv` : `${filename()}.csv`,
'text/csv;charset=utf-8;',
);
downloadCsv(data, typeof filename === 'string' ? filename : filename());

onExportComplete?.();
} catch (error) {
Expand Down
103 changes: 79 additions & 24 deletions packages/app/src/components/DBRowTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,13 @@ import {
useAliasMapFromChartConfig,
useRenderedSqlChartConfig,
} from '@/hooks/useChartConfig';
import { useCsvExport } from '@/hooks/useCsvExport';
import { CsvColumn, useCsvExport } from '@/hooks/useCsvExport';
import { useColumns, useTableMetadata } from '@/hooks/useMetadata';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import { useGroupedPatterns } from '@/hooks/usePatterns';
import { useRowSelection } from '@/hooks/useRowSelection';
import useRowWhere, {
getRowId,
INTERNAL_ROW_FIELDS,
RowWhereResult,
WithClause,
Expand All @@ -102,12 +104,19 @@ import {
useLocalStorage,
usePrevious,
} from '@/utils';
import { csvExportFilename } from '@/utils/csv';

import ChartErrorState, {
ChartErrorStateVariant,
} from './charts/ChartErrorState';
import DBRowTableFieldWithPopover from './DBTable/DBRowTableFieldWithPopover';
import DBRowTableRowButtons from './DBTable/DBRowTableRowButtons';
import {
ROW_SELECTION_COLUMN_WIDTH,
RowSelectionCell,
RowSelectionHeaderCell,
} from './DBTable/RowSelectionCells';
import { RowSelectionMenu } from './DBTable/RowSelectionMenu';
import TableHeader from './DBTable/TableHeader';
import {
highlightText,
Expand Down Expand Up @@ -139,12 +148,10 @@ const ACCESSOR_MAP: Record<string, AccessorFn> = {
};

const MAX_SCROLL_FETCH_LINES = 1000;
const EXPAND_COLUMN_SIZE = 32;
const MAX_CELL_LENGTH = 500;
const MAX_CELL_LENGTH_WRAPPED = 50_000;

const getRowId = (row: Record<string, any>): string =>
row[INTERNAL_ROW_FIELDS.ID];

function retrieveColumnValue(column: string, row: Row): any {
const accessor = ACCESSOR_MAP[column] ?? ACCESSOR_MAP.default;
return accessor(row, column);
Expand Down Expand Up @@ -365,6 +372,9 @@ export const RawLogTable = memo(
onSortingChange,
sortOrder,
showExpandButton = true,
enableRowSelection = false,
selectionResetKey,
onSelectedRowsChange,
getRowWhere,
variant = 'default',
onRemoveColumn,
Expand Down Expand Up @@ -397,6 +407,9 @@ export const RawLogTable = memo(
onExpandedRowsChange?: (hasExpandedRows: boolean) => void;
collapseAllRows?: boolean;
showExpandButton?: boolean;
enableRowSelection?: boolean;
selectionResetKey?: string;
onSelectedRowsChange?: (hasSelectedRows: boolean) => void;
renderRowDetails?: (row: {
id: string;
aliasWith?: WithClause[];
Expand Down Expand Up @@ -493,14 +506,26 @@ export const RawLogTable = memo(
return inferLogLevelColumn(dedupedRows);
}, [dedupedRows]);

const csvColumns = useMemo<CsvColumn[]>(
() =>
displayedColumns.map(col => ({
dataKey: col,
displayName: columnNameMap?.[col] ?? col,
})),
[displayedColumns, columnNameMap],
);

const { csvData, maxRows, isLimited } = useCsvExport(
dedupedRows,
displayedColumns.map(col => ({
dataKey: col,
displayName: columnNameMap?.[col] ?? col,
})),
csvColumns,
);

const rowSelection = useRowSelection(dedupedRows, {
enabled: enableRowSelection,
resetKey: selectionResetKey,
onSelectionChange: onSelectedRowsChange,
});

// Expandable rows functionality
const {
expandedRows,
Expand All @@ -527,6 +552,10 @@ export const RawLogTable = memo(
[aliasMap, columnTypeMap, logLevelColumn, columnSizeStorage],
);

const leadingColumnsWidth =
(showExpandButton ? EXPAND_COLUMN_SIZE : 0) +
(enableRowSelection ? ROW_SELECTION_COLUMN_WIDTH : 0);

const lastColumnWidth = useMemo(() => {
if (displayedColumns.length === 0) return MIN_LAST_COLUMN_WIDTH;

Expand All @@ -540,7 +569,6 @@ export const RawLogTable = memo(
return Math.max(MIN_LAST_COLUMN_WIDTH, storedLast);
}

const expandWidth = showExpandButton ? 32 : 0;
const nonLastSum = displayedColumns
.slice(0, -1)
.reduce(
Expand All @@ -550,9 +578,9 @@ export const RawLogTable = memo(
);
return Math.max(
MIN_LAST_COLUMN_WIDTH,
containerWidth - nonLastSum - expandWidth,
containerWidth - nonLastSum - leadingColumnsWidth,
);
}, [displayedColumns, columnSizeOpts, showExpandButton, containerWidth]);
}, [displayedColumns, columnSizeOpts, leadingColumnsWidth, containerWidth]);

const [wrapLinesEnabled, setWrapLinesEnabled] = useLocalStorage<boolean>(
`${tableId}-wrap-lines`,
Expand Down Expand Up @@ -759,10 +787,8 @@ export const RawLogTable = memo(
// This enables horizontal scrolling when the viewport is narrower than
// the total column widths.
const tableMinWidth = useMemo(() => {
const EXPAND_COLUMN_SIZE = 32;
const expandWidth = showExpandButton ? EXPAND_COLUMN_SIZE : 0;
return (
expandWidth +
leadingColumnsWidth +
displayedColumns.reduce((total, column, i) => {
const size = getResolvedColumnSize(column, columnSizeOpts);
if (i === displayedColumns.length - 1) {
Expand All @@ -771,7 +797,7 @@ export const RawLogTable = memo(
return total + size;
}, 0)
);
}, [displayedColumns, columnSizeOpts, showExpandButton]);
}, [displayedColumns, columnSizeOpts, leadingColumnsWidth]);

const { rows: _rows } = table.getRowModel();

Expand Down Expand Up @@ -938,14 +964,10 @@ export const RawLogTable = memo(
shiftHighlightedLineId(-1);
});

const getCsvFilename = useCallback(() => {
// eslint-disable-next-line no-restricted-syntax
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, 19);
return `hyperdx_search_results_${timestamp}.csv`;
}, []);
const getCsvFilename = useCallback(
() => csvExportFilename('hyperdx_search_results'),
[],
);

return (
<Flex direction="column" h="100%">
Expand Down Expand Up @@ -995,6 +1017,7 @@ export const RawLogTable = memo(
{displayedColumns.length > 0 &&
table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{enableRowSelection && <RowSelectionHeaderCell />}
{headerGroup.headers.map((header, headerIndex) => {
const isLast =
headerIndex === headerGroup.headers.length - 1;
Expand Down Expand Up @@ -1085,6 +1108,16 @@ export const RawLogTable = memo(
</MantineTooltip>
</UnstyledButton>
)}
{enableRowSelection && (
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
Comment thread
pulpdrew marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The "N selected" menu is the feature's only affordance but lives inside the last column's header cell, which scrolls off-screen

lastItemButtons renders inside the last <th> (TableHeader.tsx:25, overflow-hidden, width clamped to MIN_LAST_COLUMN_WIDTH = 200px), and .tableHead is only vertically sticky. Once the columns exceed the container width — routine on the search page with the filter sidebar and row side panel open — the user must scroll horizontally to reach Download/Copy/Clear, and at 200px the ~90px button plus the existing wrap/SQL/download icons squeeze the column title out entirely. Render the selection bar in the table toolbar above the table (a sibling of TableSearchInput) so it stays reachable and doesn't compete with a resizable column header.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

<RowSelectionMenu
selectedCount={rowSelection.selectedCount}
getSelectedRows={
rowSelection.getSelectedRows
}
columns={csvColumns}
onClear={rowSelection.clearSelection}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should call clearSelection when the user changes filters/date? it felt a bit weird that when I went back to the original search they were still selected.

Either that, or maybe we still show the selection header and its a feature that users can select things in multiple filters at once (ex exporting ok and error states so they can compare)

It feels more like we should clear though because currently if you select in the new view it discards your first options.. but if you go to a new filter, dont select, go back, it maintains

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call! Updated.

/>
)}
</Group>
}
/>
Expand All @@ -1103,6 +1136,8 @@ export const RawLogTable = memo(
const row = _rows[virtualRow.index] as TableRow<any>;
const rowId = getRowId(row.original);
const isExpanded = expandedRows[rowId] ?? false;
const isRowSelected =
enableRowSelection && rowSelection.isSelected(rowId);

return (
<React.Fragment key={virtualRow.key}>
Expand All @@ -1111,10 +1146,19 @@ export const RawLogTable = memo(
className={cx(styles.tableRow, {
[styles.tableRow__selected]:
highlightedLineId && highlightedLineId === rowId,
[styles.tableRow__multiSelected]: isRowSelected,
})}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
{enableRowSelection && (
<RowSelectionCell
rowId={rowId}
isSelected={isRowSelected}
onToggle={rowSelection.toggleRow}
/>
)}

{/* Expand button cell */}
{showExpandButton && (
<td
Expand Down Expand Up @@ -1217,7 +1261,9 @@ export const RawLogTable = memo(
</tr>
{showExpandButton && isExpanded && (
<ExpandedLogRow
columnsLength={columns.length}
columnsLength={
columns.length + (enableRowSelection ? 1 : 0)
}
virtualKey={virtualRow.key.toString()}
source={source}
rowId={rowId}
Expand Down Expand Up @@ -1513,6 +1559,9 @@ function DBSqlRowTableComponent({
onExpandedRowsChange,
collapseAllRows,
showExpandButton = true,
enableRowSelection = false,
selectionResetKey,
onSelectedRowsChange,
renderRowDetails,
onSortingChange,
initialSortBy,
Expand Down Expand Up @@ -1544,6 +1593,9 @@ function DBSqlRowTableComponent({
onExpandedRowsChange?: (hasExpandedRows: boolean) => void;
collapseAllRows?: boolean;
showExpandButton?: boolean;
enableRowSelection?: boolean;
selectionResetKey?: string;
onSelectedRowsChange?: (hasSelectedRows: boolean) => void;
initialSortBy?: SortingState;
onSortingChange?: (v: SortingState | null) => void;
variant?: DBRowTableVariant;
Expand Down Expand Up @@ -1862,6 +1914,9 @@ function DBSqlRowTableComponent({
onExpandedRowsChange={onExpandedRowsChange}
collapseAllRows={collapseAllRows}
showExpandButton={showExpandButton}
enableRowSelection={enableRowSelection}
selectionResetKey={selectionResetKey}
onSelectedRowsChange={onSelectedRowsChange}
enableSorting={true}
onSortingChange={_onSortingChange}
sortOrder={orderByArray}
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/components/DBSqlRowTableWithSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ interface Props {
enableSmallFirstWindow?: boolean;
tableId?: string;
errorVariant?: ChartErrorStateVariant;
enableRowSelection?: boolean;
selectionResetKey?: string;
onSelectedRowsChange?: (hasSelectedRows: boolean) => void;
onResolvedColumnsChange?: (meta: ColumnMetaType[]) => void;
// Clicking outside the row side panel (and outside `keepOpenSelector`) closes
// it. Enabled by default; pass `false` to opt out.
Expand Down Expand Up @@ -75,6 +78,9 @@ export default function DBSqlRowTableWithSideBar({
enableSmallFirstWindow,
tableId,
errorVariant,
enableRowSelection,
selectionResetKey,
onSelectedRowsChange,
onResolvedColumnsChange,
closeOnClickOutside = true,
keepOpenSelector = DEFAULT_KEEP_OPEN_SELECTOR,
Expand Down Expand Up @@ -146,6 +152,9 @@ export default function DBSqlRowTableWithSideBar({
enableSmallFirstWindow={enableSmallFirstWindow}
tableId={tableId}
errorVariant={errorVariant}
enableRowSelection={enableRowSelection}
selectionResetKey={selectionResetKey}
onSelectedRowsChange={onSelectedRowsChange}
onResolvedColumnsChange={onResolvedColumnsChange}
/>
</RowSidePanelContext>
Expand Down
Loading
Loading