Repository navigation
feat: Support search row multi-select download/copy #3140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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, | ||
|
|
@@ -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) => { | ||
|
|
@@ -2779,6 +2804,9 @@ export function DBSearchPage() { | |
| onSortingChange={onSortingChange} | ||
| initialSortBy={initialSortBy} | ||
| enableSmallFirstWindow | ||
| enableRowSelection | ||
| selectionResetKey={selectionResetKey} | ||
| onSelectedRowsChange={onSelectedRowsChange} | ||
| onResolvedColumnsChange={onResolvedColumnsChange} | ||
| /> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
|
@@ -365,6 +372,9 @@ export const RawLogTable = memo( | |
| onSortingChange, | ||
| sortOrder, | ||
| showExpandButton = true, | ||
| enableRowSelection = false, | ||
| selectionResetKey, | ||
| onSelectedRowsChange, | ||
| getRowWhere, | ||
| variant = 'default', | ||
| onRemoveColumn, | ||
|
|
@@ -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[]; | ||
|
|
@@ -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, | ||
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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( | ||
|
|
@@ -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`, | ||
|
|
@@ -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) { | ||
|
|
@@ -771,7 +797,7 @@ export const RawLogTable = memo( | |
| return total + size; | ||
| }, 0) | ||
| ); | ||
| }, [displayedColumns, columnSizeOpts, showExpandButton]); | ||
| }, [displayedColumns, columnSizeOpts, leadingColumnsWidth]); | ||
|
|
||
| const { rows: _rows } = table.getRowModel(); | ||
|
|
||
|
|
@@ -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%"> | ||
|
|
@@ -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; | ||
|
|
@@ -1085,6 +1108,16 @@ export const RawLogTable = memo( | |
| </MantineTooltip> | ||
| </UnstyledButton> | ||
| )} | ||
| {enableRowSelection && ( | ||
|
pulpdrew marked this conversation as resolved.
Resolved
pulpdrew marked this conversation as resolved.
Resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you think we should call 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call! Updated. |
||
| /> | ||
| )} | ||
| </Group> | ||
| } | ||
| /> | ||
|
|
@@ -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}> | ||
|
|
@@ -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 | ||
|
|
@@ -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} | ||
|
|
@@ -1513,6 +1559,9 @@ function DBSqlRowTableComponent({ | |
| onExpandedRowsChange, | ||
| collapseAllRows, | ||
| showExpandButton = true, | ||
| enableRowSelection = false, | ||
| selectionResetKey, | ||
| onSelectedRowsChange, | ||
| renderRowDetails, | ||
| onSortingChange, | ||
| initialSortBy, | ||
|
|
@@ -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; | ||
|
|
@@ -1862,6 +1914,9 @@ function DBSqlRowTableComponent({ | |
| onExpandedRowsChange={onExpandedRowsChange} | ||
| collapseAllRows={collapseAllRows} | ||
| showExpandButton={showExpandButton} | ||
| enableRowSelection={enableRowSelection} | ||
| selectionResetKey={selectionResetKey} | ||
| onSelectedRowsChange={onSelectedRowsChange} | ||
| enableSorting={true} | ||
| onSortingChange={_onSortingChange} | ||
| sortOrder={orderByArray} | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.