From 404b386784c654a77be8fcdc3f91b3ae343031a7 Mon Sep 17 00:00:00 2001 From: 2160039878-cyber <285580214+2160039878-cyber@users.noreply.github.com> Date: Thu, 10 Sep 2026 03:46:07 +0800 Subject: [PATCH] feat(results): copy cells, rows and result sets --- README.md | 1 + docs/FEATURES.md | 1 + src/components/ResultsGrid.tsx | 117 +++++++++----- .../results-grid/ResultContextMenu.tsx | 30 ++++ src/components/results-grid/StatsBar.tsx | 34 +++- src/lib/export/clipboard.ts | 26 +++ tests/components/ResultsGrid.test.tsx | 150 +++++++++++++++++- .../components/results-grid/StatsBar.test.tsx | 41 +++++ tests/setup-dom.ts | 1 + tests/unit/lib/export/clipboard.test.ts | 63 ++++++++ 10 files changed, 426 insertions(+), 38 deletions(-) create mode 100644 src/components/results-grid/ResultContextMenu.tsx create mode 100644 src/lib/export/clipboard.ts create mode 100644 tests/unit/lib/export/clipboard.test.ts diff --git a/README.md b/README.md index 1e2740e90..02f511433 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ Standalone application only: the embedded `@libredb/studio` package carries no a - **Column Filtering**: Per-column text filters on query results for instant data exploration. - **Interactive Pivot Table**: Client-side pivoting with 5 aggregation functions (COUNT, SUM, AVG, MIN, MAX) and SQL generation. - **Expert Exporter**: Instant CSV and JSON exports for reporting. +- **Result Clipboard**: Right-click a cell to copy its full value or its row as JSON. **Copy rows** copies loaded, filtered and sorted results as JSON, YAML or CSV, respecting the active display mask. ### Advanced Data Visualization - **8 Chart Types**: Bar, Line, Pie, Area, Scatter, Histogram, Stacked Bar, and Stacked Area charts powered by Recharts. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index fd9069f7c..3364c57e3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -116,6 +116,7 @@ ### 15. Professional Data Export * **Format Versatility:** Instantly export query result sets to CSV, JSON, SQL `INSERT` statements, or a generated `CREATE TABLE` DDL. +* **Clipboard:** Right-click a grid cell for **Copy Cell** or **Copy Row as JSON**; mobile cards also offer row copy. **Copy rows** copies the loaded result rows in the current filter and sort order as JSON, YAML or CSV, without fetching more pages. Active display masking also applies to copied data, including temporarily revealed cells. * **Developer-Ready:** Clean data output optimized for external analysis, reporting, or database migrations. * **Formula-Safe CSV:** A cell whose value starts with `=`, `+`, `-`, `@`, a tab or a carriage return is written with a leading apostrophe, so a spreadsheet shows it as text instead of evaluating it when the file is opened; this is unconditional and has no setting, and a plain number such as `-12.5` is left exactly as it is. diff --git a/src/components/ResultsGrid.tsx b/src/components/ResultsGrid.tsx index b7c43c8d9..084d7229b 100644 --- a/src/components/ResultsGrid.tsx +++ b/src/components/ResultsGrid.tsx @@ -30,6 +30,11 @@ import { ResultCard } from "@/components/results-grid/ResultCard"; import { RowDetailSheet } from "@/components/results-grid/RowDetailSheet"; import { StatsBar, LoadMoreFooter } from "@/components/results-grid/StatsBar"; import { describeWarning, formatCellValue } from "@/components/results-grid/utils"; +import { ResultContextMenu } from "@/components/results-grid/ResultContextMenu"; +import { writeToClipboard } from "@/components/copy-button"; +import { resultClipboardText } from "@/lib/export/clipboard"; +import { cellOf } from "@/lib/export/csv"; +import { toast } from "sonner"; export interface CellChange { rowIndex: number; @@ -217,6 +222,25 @@ export function ResultsGrid({ setActiveFilterCol(null); }, []); + const copyRow = (row: Record, rowIndex?: number): Record => + Object.fromEntries( + result.fields.map((field) => { + const change = rowIndex === undefined ? undefined : getCellChange(rowIndex, field); + const value = change === undefined ? cellOf(row, field) : change.newValue; + const pattern = sensitiveColumns.get(field); + // A temporary on-screen reveal must not bypass the export mask. + return [ + field, + effectiveMaskingEnabled && pattern && value != null ? maskValueByPattern(value, pattern) : value, + ]; + }), + ); + + const copyText = async (text: string) => { + if (await writeToClipboard(text)) toast.success("Copied to clipboard"); + else toast.error("Could not copy to clipboard"); + }; + const columns = useMemo>[]>(() => { return result.fields.map((field) => ({ // `id` + `accessorFn`, never `accessorKey`: TanStack reads a DOT in an @@ -536,34 +560,48 @@ export function ResultsGrid({ pendingChanges={pendingChanges} onApplyChanges={onApplyChanges} onDiscardChanges={onDiscardChanges} + onCopyRows={(format) => + copyText( + resultClipboardText( + format, + rows.map((row) => copyRow(row.original, row.index)), + result.fields, + ), + ) + } />
{cardVirtualizer.getVirtualItems().map((virtualRow) => ( -
copyRow(result.rows[virtualRow.index])} + onCopy={copyText} > - setSelectedRow({ row: result.rows[virtualRow.index], index: virtualRow.index })} - maskingActive={effectiveMaskingEnabled} - sensitiveColumns={sensitiveColumns} - /> -
+
+ setSelectedRow({ row: result.rows[virtualRow.index], index: virtualRow.index })} + maskingActive={effectiveMaskingEnabled} + sensitiveColumns={sensitiveColumns} + /> +
+ ))}
@@ -635,16 +673,17 @@ export function ResultsGrid({ const className = isMasked ? "text-fg-muted italic" : formatCellValue(row[field]).className; return ( -
- {displayValue} -
+ copyRow(row)} onCopy={copyText}> +
+ {displayValue} +
+
); })} @@ -697,13 +736,19 @@ export function ResultsGrid({ className="flex group hover:bg-brand-tint/[0.03] transition-colors border-b border-hairline" > {row.getVisibleCells().map((cell) => ( -
copyRow(row.original, row.index)} + onCopy={copyText} > - {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))} ); diff --git a/src/components/results-grid/ResultContextMenu.tsx b/src/components/results-grid/ResultContextMenu.tsx new file mode 100644 index 000000000..666600905 --- /dev/null +++ b/src/components/results-grid/ResultContextMenu.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { ReactElement } from "react"; +import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem } from "@/components/ui/context-menu"; +import { clipboardCellText } from "@/lib/export/clipboard"; +import { cellOf } from "@/lib/export/csv"; +import { jsonText } from "@/lib/export/json"; + +interface ResultContextMenuProps { + children: ReactElement; + getRow: () => Record; + column?: string; + onCopy: (text: string) => void; +} + +export function ResultContextMenu({ children, getRow, column, onCopy }: ResultContextMenuProps) { + return ( + + {children} + event.stopPropagation()}> + {column !== undefined && ( + onCopy(clipboardCellText(cellOf(getRow(), column)))}> + Copy Cell + + )} + onCopy(jsonText(getRow(), 2))}>Copy Row as JSON + + + ); +} diff --git a/src/components/results-grid/StatsBar.tsx b/src/components/results-grid/StatsBar.tsx index 437ef98a3..2a1b768f9 100644 --- a/src/components/results-grid/StatsBar.tsx +++ b/src/components/results-grid/StatsBar.tsx @@ -7,6 +7,13 @@ import { ChevronDown, LayoutGrid, Table2, LoaderCircle, EyeOff, Eye, Save, X, Fu import { Button } from "@/components/ui/button"; import type { CellChange } from "@/components/ResultsGrid"; import { describeWarning } from "@/components/results-grid/utils"; +import type { ClipboardFormat } from "@/lib/export/clipboard"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; const MASKED_LABEL = "MASKED"; const LOADING_LABEL = "Loading..."; @@ -19,6 +26,7 @@ export interface StatsBarProps { onClearFilters: () => void; viewMode: "card" | "table"; onSetViewMode: (mode: "card" | "table") => void; + onCopyRows?: (format: ClipboardFormat) => void; // Masking props hasSensitive: boolean; effectiveMaskingEnabled: boolean; @@ -41,6 +49,7 @@ export function StatsBar({ onClearFilters, viewMode, onSetViewMode, + onCopyRows, hasSensitive, effectiveMaskingEnabled, userCanToggle, @@ -54,7 +63,7 @@ export function StatsBar({ const warningDetail = warnings.map(describeWarning).join("\n"); return ( -
+
@@ -85,6 +94,29 @@ export function StatsBar({
+ {onCopyRows && ( + + + + + + {(["json", "yaml", "csv"] as const).map((format) => ( + onCopyRows(format)}> + Copy as {format.toUpperCase()} + + ))} + + + )} {hasSensitive && (userCanToggle && onToggleMasking ? (