diff --git a/.changeset/search-row-multi-select.md b/.changeset/search-row-multi-select.md new file mode 100644 index 0000000000..79b83475ca --- /dev/null +++ b/.changeset/search-row-multi-select.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +feat: Select rows in the search results table to copy or download them diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index ee30e8c46d..d93506e048 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -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( + () => + 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} /> )} diff --git a/packages/app/src/components/CsvExportButton.tsx b/packages/app/src/components/CsvExportButton.tsx index bc564d17ca..cf95be58b5 100644 --- a/packages/app/src/components/CsvExportButton.tsx +++ b/packages/app/src/components/CsvExportButton.tsx @@ -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[]; @@ -35,19 +34,7 @@ export const CsvExportButton: React.FC = ({ 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) { diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index d0c3ed9ab5..34033eface 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -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 = { }; 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 => - 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( + () => + 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( `${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 ( @@ -995,6 +1017,7 @@ export const RawLogTable = memo( {displayedColumns.length > 0 && table.getHeaderGroups().map(headerGroup => ( + {enableRowSelection && } {headerGroup.headers.map((header, headerIndex) => { const isLast = headerIndex === headerGroup.headers.length - 1; @@ -1085,6 +1108,16 @@ export const RawLogTable = memo( )} + {enableRowSelection && ( + + )} } /> @@ -1103,6 +1136,8 @@ export const RawLogTable = memo( const row = _rows[virtualRow.index] as TableRow; const rowId = getRowId(row.original); const isExpanded = expandedRows[rowId] ?? false; + const isRowSelected = + enableRowSelection && rowSelection.isSelected(rowId); return ( @@ -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 && ( + + )} + {/* Expand button cell */} {showExpandButton && ( {showExpandButton && isExpanded && ( 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} diff --git a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx index 95fe214f41..342e87e2ff 100644 --- a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx +++ b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx @@ -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. @@ -75,6 +78,9 @@ export default function DBSqlRowTableWithSideBar({ enableSmallFirstWindow, tableId, errorVariant, + enableRowSelection, + selectionResetKey, + onSelectedRowsChange, onResolvedColumnsChange, closeOnClickOutside = true, keepOpenSelector = DEFAULT_KEEP_OPEN_SELECTOR, @@ -146,6 +152,9 @@ export default function DBSqlRowTableWithSideBar({ enableSmallFirstWindow={enableSmallFirstWindow} tableId={tableId} errorVariant={errorVariant} + enableRowSelection={enableRowSelection} + selectionResetKey={selectionResetKey} + onSelectedRowsChange={onSelectedRowsChange} onResolvedColumnsChange={onResolvedColumnsChange} /> diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx index 566cd2754b..8c93830b53 100644 --- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx +++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx @@ -2,13 +2,14 @@ import React, { useState } from 'react'; import { notifications } from '@mantine/notifications'; import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react'; -import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere'; +import { RowWhereResult } from '@/hooks/useRowWhere'; import { CLIPBOARD_ERROR_MESSAGE, copyTextToClipboard, } from '@/utils/clipboard'; import { DBRowTableIconButton } from './DBRowTableIconButton'; +import { toExportableRow } from './rowExport'; import styles from '@styles/LogTable.module.scss'; @@ -32,32 +33,7 @@ const DBRowTableRowButtons: React.FC = ({ const copyRowData = async () => { try { - // Filter out internal metadata fields that start with __ or are generated IDs - - const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row; - - // Parse JSON string fields to make them proper JSON objects - const parsedRow = Object.entries(cleanRow).reduce( - (acc, [key, value]) => { - if ( - typeof value === 'string' && - (value.startsWith('{') || value.startsWith('[')) - ) { - try { - acc[key] = JSON.parse(value); - } catch { - // If parsing fails, keep the original string - acc[key] = value; - } - } else { - acc[key] = value; - } - return acc; - }, - {} as Record, - ); - - const rowData = JSON.stringify(parsedRow, null, 2); + const rowData = JSON.stringify(toExportableRow(row), null, 2); const copied = await copyTextToClipboard(rowData); if (!copied) { notifications.show({ diff --git a/packages/app/src/components/DBTable/RowSelectionCells.tsx b/packages/app/src/components/DBTable/RowSelectionCells.tsx new file mode 100644 index 0000000000..8449d32623 --- /dev/null +++ b/packages/app/src/components/DBTable/RowSelectionCells.tsx @@ -0,0 +1,46 @@ +import React, { memo } from 'react'; +import { Checkbox } from '@mantine/core'; + +import styles from '@styles/LogTable.module.scss'; + +export const ROW_SELECTION_COLUMN_WIDTH = 24; + +const CELL_WIDTH_STYLE = { + width: ROW_SELECTION_COLUMN_WIDTH, + minWidth: ROW_SELECTION_COLUMN_WIDTH, +}; + +/** Fixed-width spacer keeping the header aligned with the checkbox cells. */ +export const RowSelectionHeaderCell = () => ; + +export const RowSelectionCell = memo( + ({ + rowId, + isSelected, + onToggle, + }: { + rowId: string; + isSelected: boolean; + onToggle: (rowId: string, options: { extendRange: boolean }) => void; + }) => ( + + e.stopPropagation()} + onChange={e => + onToggle(rowId, { + extendRange: + e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey, + }) + } + /> + + ), +); + +RowSelectionCell.displayName = 'RowSelectionCell'; diff --git a/packages/app/src/components/DBTable/RowSelectionMenu.tsx b/packages/app/src/components/DBTable/RowSelectionMenu.tsx new file mode 100644 index 0000000000..918f8374d0 --- /dev/null +++ b/packages/app/src/components/DBTable/RowSelectionMenu.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useMemo } from 'react'; +import { Button, Menu } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { + IconChevronDown, + IconCopy, + IconDownload, + IconX, +} from '@tabler/icons-react'; + +import { CsvColumn, useCsvExport } from '@/hooks/useCsvExport'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; +import { csvExportFilename, downloadCsv, toCsvString } from '@/utils/csv'; + +import { toExportableRow } from './rowExport'; + +const RowSelectionMenuItems = ({ + getSelectedRows, + columns, + onClear, +}: { + getSelectedRows: () => Record[]; + columns: CsvColumn[]; + onClear: () => void; +}) => { + const selectedRows = useMemo(() => getSelectedRows(), [getSelectedRows]); + const { csvData, maxRows, isLimited } = useCsvExport(selectedRows, columns); + const exportRows = useMemo( + () => selectedRows.slice(0, maxRows), + [selectedRows, maxRows], + ); + + const copy = useCallback( + async (format: 'csv' | 'json') => { + const columnKeys = columns.map(({ dataKey }) => dataKey); + const text = + format === 'csv' + ? toCsvString(csvData) + : JSON.stringify( + exportRows.map(row => toExportableRow(row, columnKeys)), + null, + 2, + ); + const copied = await copyTextToClipboard(text); + notifications.show( + copied + ? { + color: 'green', + message: `Copied ${exportRows.length} row${exportRows.length === 1 ? '' : 's'} as ${format.toUpperCase()}`, + } + : { color: 'red', message: CLIPBOARD_ERROR_MESSAGE }, + ); + }, + [columns, csvData, exportRows], + ); + + return ( + <> + {isLimited && ( + First {maxRows.toLocaleString()} rows only + )} + } + onClick={() => + downloadCsv(csvData, csvExportFilename('hyperdx_selected_rows')) + } + data-testid="row-selection-download-csv" + > + Download CSV + + } + onClick={() => copy('csv')} + data-testid="row-selection-copy-csv" + > + Copy as CSV + + } + onClick={() => copy('json')} + data-testid="row-selection-copy-json" + > + Copy as JSON + + } + onClick={onClear} + data-testid="row-selection-clear" + > + Clear selection + + + ); +}; + +/** Renders nothing until rows are selected; lives in the table header. */ +export const RowSelectionMenu = ({ + selectedCount, + getSelectedRows, + columns, + onClear, +}: { + selectedCount: number; + getSelectedRows: () => Record[]; + columns: CsvColumn[]; + onClear: () => void; +}) => { + if (selectedCount === 0) { + return null; + } + + return ( + + + + + + + + + ); +}; diff --git a/packages/app/src/components/DBTable/rowExport.ts b/packages/app/src/components/DBTable/rowExport.ts new file mode 100644 index 0000000000..cfc43da754 --- /dev/null +++ b/packages/app/src/components/DBTable/rowExport.ts @@ -0,0 +1,42 @@ +import { INTERNAL_ROW_FIELDS } from '@/hooks/useRowWhere'; + +/** + * Strips the table's internal tracking fields and restores JSON columns that + * were stringified for display, so an exported row reads like the source event. + * + * `columns` limits the result to those keys, in that order. Rows carry more + * than the user selected — the query appends primary key, partition key and + * block columns to identify rows — so exports that should mirror the table + * pass the displayed columns. + */ +export function toExportableRow( + row: Record, + columns?: string[], +): Record { + const { + [INTERNAL_ROW_FIELDS.ID]: _id, + [INTERNAL_ROW_FIELDS.ALIAS_WITH]: _aliasWith, + ...rest + } = row; + + const entries = + columns == null + ? Object.entries(rest) + : columns.map(column => [column, rest[column]] as const); + + return Object.fromEntries( + entries.map(([key, value]) => { + if ( + typeof value === 'string' && + (value.startsWith('{') || value.startsWith('[')) + ) { + try { + return [key, JSON.parse(value)]; + } catch { + return [key, value]; + } + } + return [key, value]; + }), + ); +} diff --git a/packages/app/src/components/__tests__/DBRowTable.test.tsx b/packages/app/src/components/__tests__/DBRowTable.test.tsx index e3a7b168dc..048e22c5c6 100644 --- a/packages/app/src/components/__tests__/DBRowTable.test.tsx +++ b/packages/app/src/components/__tests__/DBRowTable.test.tsx @@ -44,6 +44,47 @@ describe('RawLogTable', () => { expect(await screen.findByTestId('db-row-table-no-results')).toBeTruthy(); }); + describe('Row selection', () => { + // Rows are virtualized and jsdom gives the scroll container no height, so + // only the header and the action bar are observable here. Checkbox clicks + // are covered by tests/e2e/features/search/row-multi-select.spec.ts. + const baseProps = { + displayedColumns: ['col1', 'col2'], + rows: [{ col1: 'value1', col2: 'value2' }], + isLoading: false, + dedupRows: false, + hasNextPage: false, + onRowDetailsClick: () => {}, + generateRowId: () => mockRowWhereResult, + columnTypeMap: new Map(), + showExpandButton: false, + }; + + // The table is shared with dashboards, Kubernetes panels and side panels, + // none of which opt in. + it('adds no leading column unless selection is enabled', () => { + const { container } = renderWithMantine(); + + expect(container.querySelectorAll('th')).toHaveLength(2); + }); + + it('adds a leading header cell when selection is enabled', () => { + const { container } = renderWithMantine( + , + ); + + expect(container.querySelectorAll('th')).toHaveLength(3); + }); + + it('shows no selection menu before anything is selected', () => { + renderWithMantine(); + + expect( + screen.queryByTestId('row-selection-count'), + ).not.toBeInTheDocument(); + }); + }); + describe('Sorting', () => { const baseProps = { displayedColumns: ['col1', 'col2'], diff --git a/packages/app/src/hooks/__tests__/useRowSelection.test.tsx b/packages/app/src/hooks/__tests__/useRowSelection.test.tsx new file mode 100644 index 0000000000..1e20681ce0 --- /dev/null +++ b/packages/app/src/hooks/__tests__/useRowSelection.test.tsx @@ -0,0 +1,228 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useRowSelection } from '@/hooks/useRowSelection'; +import { INTERNAL_ROW_FIELDS } from '@/hooks/useRowWhere'; + +const makeRows = (count: number, offset = 0) => + Array.from({ length: count }, (_, i) => ({ + [INTERNAL_ROW_FIELDS.ID]: `id-${i + offset}`, + body: `row ${i + offset}`, + })); + +const idsOf = (rows: Record[]) => + rows.map(row => row[INTERNAL_ROW_FIELDS.ID]); + +describe('useRowSelection', () => { + it('toggles a row on and off', () => { + const rows = makeRows(3); + const { result } = renderHook(() => useRowSelection(rows)); + + act(() => result.current.toggleRow('id-1')); + expect(result.current.selectedCount).toBe(1); + expect(result.current.isSelected('id-1')).toBe(true); + + act(() => result.current.toggleRow('id-1')); + expect(result.current.selectedCount).toBe(0); + expect(result.current.getSelectedRows()).toEqual([]); + }); + + it('keeps both rows when two clicks land in the same tick', () => { + const rows = makeRows(3); + const { result } = renderHook(() => useRowSelection(rows)); + + act(() => { + result.current.toggleRow('id-0'); + result.current.toggleRow('id-1'); + }); + + expect(idsOf(result.current.getSelectedRows())).toEqual(['id-0', 'id-1']); + }); + + it('shift-clicking forward selects the inclusive range', () => { + const rows = makeRows(5); + const { result } = renderHook(() => useRowSelection(rows)); + + act(() => result.current.toggleRow('id-1')); + act(() => result.current.toggleRow('id-3', { extendRange: true })); + + expect(idsOf(result.current.getSelectedRows())).toEqual([ + 'id-1', + 'id-2', + 'id-3', + ]); + }); + + it('shift-clicking backward selects the inclusive range', () => { + const rows = makeRows(5); + const { result } = renderHook(() => useRowSelection(rows)); + + act(() => result.current.toggleRow('id-3')); + act(() => result.current.toggleRow('id-1', { extendRange: true })); + + expect(idsOf(result.current.getSelectedRows())).toEqual([ + 'id-1', + 'id-2', + 'id-3', + ]); + }); + + it('shift-clicking never deselects an already selected row', () => { + const rows = makeRows(4); + const { result } = renderHook(() => useRowSelection(rows)); + + act(() => result.current.toggleRow('id-0')); + act(() => result.current.toggleRow('id-2')); + act(() => result.current.toggleRow('id-0', { extendRange: true })); + + expect(idsOf(result.current.getSelectedRows())).toEqual([ + 'id-0', + 'id-1', + 'id-2', + ]); + }); + + it('falls back to a plain toggle when the anchor is no longer loaded', () => { + const { result, rerender } = renderHook( + ({ rows }) => useRowSelection(rows), + { initialProps: { rows: makeRows(4) } }, + ); + + act(() => result.current.toggleRow('id-0')); + // Live tail evicts the page holding the anchor. + rerender({ rows: makeRows(3, 2) }); + act(() => result.current.toggleRow('id-4', { extendRange: true })); + + expect(result.current.selectedCount).toBe(2); + expect(result.current.isSelected('id-4')).toBe(true); + }); + + it('returns selected rows in display order', () => { + const rows = makeRows(4); + const { result } = renderHook(() => useRowSelection(rows)); + + act(() => result.current.toggleRow('id-3')); + act(() => result.current.toggleRow('id-0')); + act(() => result.current.toggleRow('id-2')); + + expect(idsOf(result.current.getSelectedRows())).toEqual([ + 'id-0', + 'id-2', + 'id-3', + ]); + }); + + it('survives a rows array identity change', () => { + const { result, rerender } = renderHook( + ({ rows }) => useRowSelection(rows), + { initialProps: { rows: makeRows(3) } }, + ); + + act(() => result.current.toggleRow('id-1')); + rerender({ rows: makeRows(3) }); + + expect(result.current.selectedCount).toBe(1); + expect(result.current.isSelected('id-1')).toBe(true); + }); + + it('keeps rows that have been evicted, appended after the loaded ones', () => { + const { result, rerender } = renderHook( + ({ rows }) => useRowSelection(rows), + { initialProps: { rows: makeRows(4) } }, + ); + + act(() => result.current.toggleRow('id-0')); + act(() => result.current.toggleRow('id-3')); + rerender({ rows: makeRows(3, 2) }); + + expect(idsOf(result.current.getSelectedRows())).toEqual(['id-3', 'id-0']); + }); + + it('prefers the freshly fetched row object over the snapshot', () => { + const stale = [{ [INTERNAL_ROW_FIELDS.ID]: 'id-0', body: 'stale' }]; + const fresh = [{ [INTERNAL_ROW_FIELDS.ID]: 'id-0', body: 'fresh' }]; + const { result, rerender } = renderHook( + ({ rows }) => useRowSelection(rows), + { initialProps: { rows: stale } }, + ); + + act(() => result.current.toggleRow('id-0')); + rerender({ rows: fresh }); + + expect(result.current.getSelectedRows()[0].body).toBe('fresh'); + }); + + it('clears the selection', () => { + const rows = makeRows(3); + const { result } = renderHook(() => useRowSelection(rows)); + + act(() => result.current.toggleRow('id-0')); + act(() => result.current.clearSelection()); + + expect(result.current.selectedCount).toBe(0); + }); + + it('clears when the reset key changes', () => { + const rows = makeRows(3); + const { result, rerender } = renderHook( + ({ resetKey }) => useRowSelection(rows, { resetKey }), + { initialProps: { resetKey: 'search-a' } }, + ); + + act(() => result.current.toggleRow('id-0')); + rerender({ resetKey: 'search-b' }); + + expect(result.current.selectedCount).toBe(0); + }); + + it('does not restore the selection when the reset key reverts', () => { + const rows = makeRows(3); + const { result, rerender } = renderHook( + ({ resetKey }) => useRowSelection(rows, { resetKey }), + { initialProps: { resetKey: 'search-a' } }, + ); + + act(() => result.current.toggleRow('id-0')); + rerender({ resetKey: 'search-b' }); + rerender({ resetKey: 'search-a' }); + + expect(result.current.selectedCount).toBe(0); + }); + + it('reports whether a selection exists', () => { + const rows = makeRows(3); + const onSelectionChange = jest.fn(); + const { result } = renderHook(() => + useRowSelection(rows, { onSelectionChange }), + ); + + act(() => result.current.toggleRow('id-0')); + expect(onSelectionChange).toHaveBeenLastCalledWith(true); + + act(() => result.current.toggleRow('id-1')); + expect(onSelectionChange).toHaveBeenLastCalledWith(true); + + // Deselecting the last row empties the selection. + act(() => result.current.toggleRow('id-0')); + act(() => result.current.toggleRow('id-1')); + expect(onSelectionChange).toHaveBeenLastCalledWith(false); + + act(() => result.current.toggleRow('id-2')); + act(() => result.current.clearSelection()); + expect(onSelectionChange).toHaveBeenLastCalledWith(false); + }); + + it('no-ops and clears when disabled', () => { + const rows = makeRows(3); + const { result, rerender } = renderHook( + ({ enabled }) => useRowSelection(rows, { enabled }), + { initialProps: { enabled: true } }, + ); + + act(() => result.current.toggleRow('id-0')); + rerender({ enabled: false }); + expect(result.current.selectedCount).toBe(0); + + act(() => result.current.toggleRow('id-1')); + expect(result.current.selectedCount).toBe(0); + }); +}); diff --git a/packages/app/src/hooks/useRowSelection.ts b/packages/app/src/hooks/useRowSelection.ts new file mode 100644 index 0000000000..e475c43e52 --- /dev/null +++ b/packages/app/src/hooks/useRowSelection.ts @@ -0,0 +1,140 @@ +import { useCallback, useState } from 'react'; + +import { getRowId } from '@/hooks/useRowWhere'; + +type SelectableRow = Record; + +const EMPTY_SELECTION: ReadonlyMap = new Map(); + +interface Selection { + resetKey?: string; + anchorId: string | null; + rows: Map; +} + +interface UseRowSelectionResult { + selectedCount: number; + /** + * Selected rows in table display order. Rows that are no longer loaded (live + * tail evicts pages) are appended last from the snapshot taken at selection. + */ + getSelectedRows: () => SelectableRow[]; + isSelected: (rowId: string) => boolean; + toggleRow: (rowId: string, options?: { extendRange?: boolean }) => void; + clearSelection: () => void; +} + +/** + * Tracks selected table rows by `__hyperdx_id`. + * + * `resetKey` identifies the result set the selection belongs to. When it + * changes the selection is dropped during render rather than in an effect, so a + * new search cannot report rows the user can no longer see, and returning to an + * earlier query does not resurrect the rows selected under it. + */ +export function useRowSelection( + rows: SelectableRow[], + { + enabled = true, + resetKey, + onSelectionChange, + }: { + enabled?: boolean; + resetKey?: string; + onSelectionChange?: (hasSelection: boolean) => void; + } = {}, +): UseRowSelectionResult { + const [selection, setSelection] = useState(() => ({ + resetKey, + // Anchor row for range selection (shift-click) + anchorId: null, + rows: new Map(), + })); + + const isCurrent = selection.resetKey === resetKey; + if (!isCurrent) { + setSelection({ resetKey, anchorId: null, rows: new Map() }); + } + const selected = enabled && isCurrent ? selection.rows : EMPTY_SELECTION; + + const clearSelection = useCallback(() => { + setSelection({ resetKey, anchorId: null, rows: new Map() }); + onSelectionChange?.(false); + }, [onSelectionChange, resetKey]); + + const toggleRow = useCallback( + ( + rowId: string, + { extendRange = false }: { extendRange?: boolean } = {}, + ) => { + if (!enabled) { + return; + } + const indexOf = (id: string) => + rows.findIndex(row => getRowId(row) === id); + const targetIndex = indexOf(rowId); + + setSelection(prev => { + const prevIsCurrent = prev.resetKey === resetKey; + const next = new Map(prevIsCurrent ? prev.rows : []); + const anchorId = prevIsCurrent ? prev.anchorId : null; + const anchorIndex = anchorId != null ? indexOf(anchorId) : -1; + + // Shift-click "extend range" behavior + if (extendRange && anchorIndex !== -1 && targetIndex !== -1) { + const [from, to] = + anchorIndex <= targetIndex + ? [anchorIndex, targetIndex] + : [targetIndex, anchorIndex]; + for (let i = from; i <= to; i++) { + next.set(getRowId(rows[i]), rows[i]); + } + } else if (next.has(rowId)) { + next.delete(rowId); + } else if (targetIndex !== -1) { + next.set(rowId, rows[targetIndex]); + } + + onSelectionChange?.(next.size > 0); + return { resetKey, anchorId: rowId, rows: next }; + }); + }, + [enabled, onSelectionChange, resetKey, rows], + ); + + const isSelected = useCallback( + (rowId: string) => selected.has(rowId), + [selected], + ); + + const getSelectedRows = useCallback((): SelectableRow[] => { + if (selected.size === 0) { + return []; + } + const loaded: SelectableRow[] = []; + const seen = new Set(); + for (const row of rows) { + const rowId = getRowId(row); + if (selected.has(rowId)) { + // Prefer the freshly fetched row object over the snapshot. + loaded.push(row); + seen.add(rowId); + } + } + const evicted: SelectableRow[] = []; + for (const [rowId, row] of selected) { + if (!seen.has(rowId)) { + evicted.push(row); + } + } + return [...loaded, ...evicted]; + }, [rows, selected]); + + return { + selectedCount: selected.size, + getSelectedRows, + isSelected, + toggleRow, + clearSelection, + }; +} diff --git a/packages/app/src/hooks/useRowWhere.tsx b/packages/app/src/hooks/useRowWhere.tsx index 4a30b7a409..ad0f5ede8e 100644 --- a/packages/app/src/hooks/useRowWhere.tsx +++ b/packages/app/src/hooks/useRowWhere.tsx @@ -22,6 +22,9 @@ export const INTERNAL_ROW_FIELDS = { ALIAS_WITH: '__hyperdx_alias_with', } as const; +export const getRowId = (row: Record): string => + row[INTERNAL_ROW_FIELDS.ID]; + // Result type for row WHERE clause with alias support export type RowWhereResult = { where: string; diff --git a/packages/app/src/utils/__tests__/csv.test.ts b/packages/app/src/utils/__tests__/csv.test.ts new file mode 100644 index 0000000000..4e4181acfa --- /dev/null +++ b/packages/app/src/utils/__tests__/csv.test.ts @@ -0,0 +1,38 @@ +import { csvExportFilename, toCsvString } from '@/utils/csv'; + +describe('toCsvString', () => { + it('writes a header row from the object keys', () => { + expect(toCsvString([{ level: 'error', count: 2 }])).toBe( + '"level","count"\r\n"error","2"', + ); + }); + + it('quotes and escapes commas, quotes and newlines', () => { + const csv = toCsvString([ + { body: 'a,b', quoted: 'say "hi"', multi: 'one\ntwo' }, + ]); + + expect(csv).toContain('"a,b"'); + expect(csv).toContain('"say ""hi"""'); + expect(csv).toContain('"one\ntwo"'); + }); + + it('returns an empty string for no rows', () => { + expect(toCsvString([])).toBe(''); + }); + + it('does not prepend a BOM — that belongs to the download path only', () => { + expect(toCsvString([{ a: 1 }]).charCodeAt(0)).not.toBe(0xfeff); + }); +}); + +describe('csvExportFilename', () => { + it('returns a timestamped name with no extension', () => { + const filename = csvExportFilename('hyperdx_selected_rows'); + + expect(filename).not.toMatch(/\.csv$/); + expect(filename).toMatch( + /^hyperdx_selected_rows_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/, + ); + }); +}); diff --git a/packages/app/src/utils/csv.ts b/packages/app/src/utils/csv.ts new file mode 100644 index 0000000000..24d23c619f --- /dev/null +++ b/packages/app/src/utils/csv.ts @@ -0,0 +1,33 @@ +import Papa from 'papaparse'; + +import { downloadTextFile } from '@/utils/downloadFile'; + +/** Serialize an array of objects to a CSV string. */ +export function toCsvString(rows: Record[]): string { + return Papa.unparse(rows, { + quotes: true, + quoteChar: '"', + escapeChar: '"', + delimiter: ',', + header: true, + }); +} + +/** Writes `rows` as a CSV file. Prefix with a BOM that makes Excel read it as UTF-8. */ +export function downloadCsv( + rows: Record[], + filename: string, +): void { + downloadTextFile( + `\ufeff${toCsvString(rows)}`, + `${filename}.csv`, + 'text/csv;charset=utf-8;', + ); +} + +/** Timestamped export filename, without an extension. */ +export function csvExportFilename(prefix: string): string { + // eslint-disable-next-line no-restricted-syntax + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + return `${prefix}_${timestamp}`; +} diff --git a/packages/app/styles/LogTable.module.scss b/packages/app/styles/LogTable.module.scss index f504eba048..29cea10431 100644 --- a/packages/app/styles/LogTable.module.scss +++ b/packages/app/styles/LogTable.module.scss @@ -40,6 +40,12 @@ $button-height: 18px; font-weight: bold; } + // Multi-select. Kept to a subtle tint so row text stays legible in every + // theme; the checked checkbox is the primary affordance. + &__multiSelected { + background-color: var(--color-bg-highlighted); + } + &:hover .rowButtons { opacity: 1; } @@ -57,6 +63,17 @@ $button-height: 18px; } } +.selectCell { + vertical-align: middle; + user-select: none; +} + +.selectCheckbox { + display: flex; + justify-content: center; + padding-left: 4px; +} + .expandedRowContent { max-height: 400px; overflow: auto; diff --git a/packages/app/tests/e2e/components/TableComponent.ts b/packages/app/tests/e2e/components/TableComponent.ts index 3484d1d1c2..05f8df27e1 100644 --- a/packages/app/tests/e2e/components/TableComponent.ts +++ b/packages/app/tests/e2e/components/TableComponent.ts @@ -115,15 +115,34 @@ export class TableComponent { return this.getRow(row).locator(`[data-column="${column}"]`); } + /** + * The multi-select checkbox of a row. Only rendered where row selection is + * enabled (the search results table). + */ + getRowCheckbox(index: number) { + return this.getRow(index).getByTestId('row-select-checkbox'); + } + /** * Select multiple rows by indices */ async selectRows(indices: number[]) { for (const index of indices) { - await this.getRow(index).locator('[type="checkbox"]').check(); + await this.getRowCheckbox(index).click(); } } + /** + * Extend the selection from the last clicked row to this one. + */ + async shiftSelectRow(index: number) { + await this.getRowCheckbox(index).click({ modifiers: ['Shift'] }); + } + + async getSelectedRowCount() { + return this.getRows().locator('input:checked').count(); + } + /** * Get the table container for visibility checks */ diff --git a/packages/app/tests/e2e/features/search/row-multi-select.spec.ts b/packages/app/tests/e2e/features/search/row-multi-select.spec.ts new file mode 100644 index 0000000000..aeb050d953 --- /dev/null +++ b/packages/app/tests/e2e/features/search/row-multi-select.spec.ts @@ -0,0 +1,193 @@ +import { SearchPage } from '../../page-objects/SearchPage'; +import { expect, test } from '../../utils/base-test'; + +test.describe('Search row multi-select', { tag: '@search' }, () => { + let searchPage: SearchPage; + + test.beforeEach(async ({ page }) => { + searchPage = new SearchPage(page); + await searchPage.goto(); + }); + + test( + 'selects rows and reports the count', + { tag: ['@local-mode'] }, + async () => { + await expect(searchPage.selectionCount).toBeHidden(); + + await searchPage.table.selectRows([0, 1]); + + await expect(searchPage.selectionCount).toHaveText('2 selected'); + }, + ); + + test( + 'shift-clicking extends the selection', + { tag: ['@local-mode'] }, + async () => { + await searchPage.table.selectRows([0]); + await searchPage.table.shiftSelectRow(3); + + await expect(searchPage.selectionCount).toHaveText('4 selected'); + expect(await searchPage.table.getSelectedRowCount()).toBe(4); + }, + ); + + test( + 'clearing the selection hides the bar', + { tag: ['@local-mode'] }, + async () => { + await searchPage.table.selectRows([0]); + await expect(searchPage.selectionCount).toBeVisible(); + + await searchPage.clearRowSelection(); + + await expect(searchPage.selectionCount).toBeHidden(); + expect(await searchPage.table.getSelectedRowCount()).toBe(0); + }, + ); + + test( + 'downloads the selected rows as CSV', + { tag: ['@local-mode'] }, + async () => { + await searchPage.table.selectRows([0, 1]); + + const { filename, content } = await searchPage.downloadSelectedRowsCsv(); + + expect(filename).toMatch(/^hyperdx_selected_rows_.*\.csv$/); + expect(filename).not.toMatch(/\.csv\.csv$/); + + const lines = content + .replace(/^\ufeff/, '') + .trim() + .split(/\r?\n/); + // One header row plus one row per selection. + expect(lines).toHaveLength(3); + expect(lines[0]).toContain('Timestamp'); + }, + ); + + test( + 'copies the selected rows as CSV', + { tag: ['@local-mode'] }, + async ({ context, page }) => { + // copyTextToClipboard falls back to execCommand when the async clipboard + // API is unavailable, and that fallback's result is not observable, so + // the toast is the primary assertion here. + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await searchPage.table.selectRows([0, 1]); + await searchPage.copySelectedRows(); + + await expect(searchPage.getCopiedSelectionToast()).toBeVisible(); + + const clipboard = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(clipboard.split(/\r?\n/)).toHaveLength(3); + expect(clipboard).not.toContain('__hyperdx_'); + }, + ); + + test( + 'copies the selected rows as JSON', + { tag: ['@local-mode'] }, + async ({ context, page }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await searchPage.table.selectRows([0, 1]); + await searchPage.copySelectedRows('json'); + + await expect(searchPage.getCopiedSelectionToast()).toBeVisible(); + + const clipboard = await page.evaluate(() => + navigator.clipboard.readText(), + ); + const rows = JSON.parse(clipboard); + expect(rows).toHaveLength(2); + + // The same columns the CSV writes — the query appends primary key, + // partition key and block columns that must not reach an export. + const { content } = await searchPage.downloadSelectedRowsCsv(); + const header = content + .replace(/^\ufeff/, '') + .split(/\r?\n/)[0] + .split(',') + .map(name => name.replace(/^"|"$/g, '')); + expect(Object.keys(rows[0])).toEqual(header); + }, + ); + + test( + 'leaves live tail, visibly, when rows are selected', + { tag: ['@local-mode'] }, + async () => { + await expect(searchPage.resumeLiveTailButton).toBeHidden(); + + await searchPage.table.selectRows([0]); + + // Same exit as expanding a row: the Resume button is the affordance that + // says tailing stopped. + await expect(searchPage.resumeLiveTailButton).toBeVisible(); + await expect(searchPage.selectionCount).toHaveText('1 selected'); + + // And it is undoable. The selection outlives the click itself, since + // resuming only moves the searched window on the next refresh. + await searchPage.resumeLiveTail(); + + await expect(searchPage.resumeLiveTailButton).toBeHidden(); + }, + ); + + test( + 'drops the selection when the time range changes', + { tag: ['@local-mode'] }, + async () => { + await searchPage.table.selectRows([0, 1]); + await expect(searchPage.selectionCount).toHaveText('2 selected'); + + // A different window is a different result set. + await searchPage.timePicker.selectRelativeTime('Last 1 days'); + + await expect(searchPage.selectionCount).toBeHidden(); + }, + ); + + test( + 'does not restore the selection when the query reverts', + { tag: ['@local-mode'] }, + async () => { + // Pin an absolute window first. In live tail every submit re-parses + // "Live Tail" into a fresh range, so the reset key would differ on the + // way back for reasons other than the query. + await searchPage.timePicker.selectRelativeTime('Last 1 days'); + await searchPage.performSearch('SeverityText:info'); + + await searchPage.table.selectRows([0, 1]); + await expect(searchPage.selectionCount).toHaveText('2 selected'); + + await searchPage.performSearch('SeverityText:warn'); + await expect(searchPage.selectionCount).toBeHidden(); + + // Back to the query the rows were selected under: the same result set is + // on screen, but the selection stays gone. + await searchPage.performSearch('SeverityText:info'); + + await expect(searchPage.table.firstRow).toBeVisible(); + await expect(searchPage.selectionCount).toBeHidden(); + expect(await searchPage.table.getSelectedRowCount()).toBe(0); + }, + ); + + test( + 'checking a row does not open the side panel', + { tag: ['@local-mode'] }, + async () => { + await searchPage.table.selectRows([0]); + await expect(searchPage.sidePanel.container).toBeHidden(); + + await searchPage.table.clickRow(0); + + await expect(searchPage.sidePanel.container).toBeVisible(); + }, + ); +}); diff --git a/packages/app/tests/e2e/page-objects/SearchPage.ts b/packages/app/tests/e2e/page-objects/SearchPage.ts index 46aba7fbe9..bf56ede396 100644 --- a/packages/app/tests/e2e/page-objects/SearchPage.ts +++ b/packages/app/tests/e2e/page-objects/SearchPage.ts @@ -201,6 +201,62 @@ export class SearchPage { await expect(this.sidePanel.tabs).toBeVisible(); } + /** The selection menu button in the table header, once rows are selected. */ + get selectionCount() { + return this.page.getByTestId('row-selection-count'); + } + + /** Only rendered while live tail is stopped. */ + get resumeLiveTailButton() { + return this.page.getByRole('button', { name: 'Resume Live Tail' }); + } + + async resumeLiveTail() { + await this.resumeLiveTailButton.click(); + } + + /** Opens the selection menu and picks one of its actions. */ + private async clickSelectionMenuItem(testId: string) { + await this.selectionCount.click(); + await this.page.getByTestId(testId).click(); + } + + async copySelectedRows(format: 'csv' | 'json' = 'csv') { + await this.clickSelectionMenuItem(`row-selection-copy-${format}`); + } + + /** The toast confirming a clipboard copy of the selected rows. */ + getCopiedSelectionToast() { + return this.page.getByText(/copied \d+ rows? as (CSV|JSON)/i); + } + + async clearRowSelection() { + await this.clickSelectionMenuItem('row-selection-clear'); + } + + /** + * Download the selected rows and return the CSV text. The export triggers a + * client-side anchor download, so capture the Playwright download event and + * read its stream. + */ + async downloadSelectedRowsCsv(): Promise<{ + filename: string; + content: string; + }> { + const downloadPromise = this.page.waitForEvent('download'); + await this.clickSelectionMenuItem('row-selection-download-csv'); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return { + filename: download.suggestedFilename(), + content: Buffer.concat(chunks).toString('utf-8'), + }; + } + /** * Open the waterfall's spans filter — a `SearchWhereInput` with * `allowMultiline={false}` in both SQL and Lucene.