From b8a934ef6103dfe00ba8fec9ddd5873b99fccd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Paulmier?= Date: Mon, 31 Aug 2026 12:21:33 +0200 Subject: [PATCH 1/4] refactor: extract the selection export menu into a shared grid component The copy-as menu, its state and its serialization helpers lived inside ActiveTableView, so no other grid consumer could reuse them. Move the export module next to the shared DataGrid, turn the dropdown into a SelectionExportMenu component, and let the host pass the filename base. --- ui/studio/grid/SelectionExportMenu.test.tsx | 191 ++++++++++++++++ ui/studio/grid/SelectionExportMenu.tsx | 199 ++++++++++++++++ .../table => grid}/selection-export.test.ts | 36 ++- .../{views/table => grid}/selection-export.ts | 41 +++- ui/studio/views/table/ActiveTableView.tsx | 216 +----------------- 5 files changed, 468 insertions(+), 215 deletions(-) create mode 100644 ui/studio/grid/SelectionExportMenu.test.tsx create mode 100644 ui/studio/grid/SelectionExportMenu.tsx rename ui/studio/{views/table => grid}/selection-export.test.ts (82%) rename ui/studio/{views/table => grid}/selection-export.ts (81%) diff --git a/ui/studio/grid/SelectionExportMenu.test.tsx b/ui/studio/grid/SelectionExportMenu.test.tsx new file mode 100644 index 00000000..2d719bfe --- /dev/null +++ b/ui/studio/grid/SelectionExportMenu.test.tsx @@ -0,0 +1,191 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SelectionExportMenu } from "./SelectionExportMenu"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const ROWS = [ + { + __ps_rowid: "row-1", + email: "alice@example.com", + id: "user_1", + }, + { + __ps_rowid: "row-2", + email: "bob@example.com", + id: "user_2", + }, +]; + +afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +async function flush() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function dispatchPointerClick(element: Element | null | undefined) { + if (!element) { + return; + } + + const PointerEventConstructor = window.PointerEvent ?? MouseEvent; + + act(() => { + element.dispatchEvent( + new PointerEventConstructor("pointerdown", { + bubbles: true, + button: 0, + cancelable: true, + }), + ); + element.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + button: 0, + cancelable: true, + }), + ); + }); +} + +function findButtonByText(text: string) { + return Array.from( + document.querySelectorAll("button"), + ).find((button) => button.textContent?.trim() === text); +} + +function findMenuItemByText(text: string) { + return Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.trim() === text); +} + +function renderMenu( + props: Partial[0]> = {}, +) { + const container = document.createElement("div"); + + document.body.appendChild(container); + + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + return { + cleanup() { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("SelectionExportMenu", () => { + it("stays hidden while nothing is selected", () => { + const view = renderMenu(); + + expect(findButtonByText("copy as")).toBeUndefined(); + + view.cleanup(); + }); + + it("copies the selected rows as csv with column headers by default", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + + vi.stubGlobal("navigator", { + ...navigator, + clipboard: { writeText }, + }); + + const view = renderMenu({ + rowSelectionState: { "row-1": true, "row-2": true }, + }); + + dispatchPointerClick(findButtonByText("copy as")); + await flush(); + + dispatchPointerClick(findMenuItemByText("copy csv")); + + expect(writeText).toHaveBeenCalledWith( + "id,email\nuser_1,alice@example.com\nuser_2,bob@example.com", + ); + + view.cleanup(); + }); + + it("saves a cell range using the filename base of the host view", async () => { + const createObjectURL = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:selection-export"); + const revokeObjectURL = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); + const anchors: HTMLAnchorElement[] = []; + const createElement = document.createElement.bind(document); + + vi.spyOn(document, "createElement").mockImplementation( + (tagName, options) => { + const element = createElement(tagName, options); + + if (element instanceof HTMLAnchorElement) { + anchors.push(element); + } + + return element; + }, + ); + + const view = renderMenu({ + cellSelectionRange: { + columnEnd: 0, + columnStart: 0, + rowEnd: 1, + rowStart: 0, + }, + }); + + dispatchPointerClick(findButtonByText("copy as")); + await flush(); + + dispatchPointerClick(findMenuItemByText("save csv")); + + expect(click).toHaveBeenCalledTimes(1); + expect(anchors.at(-1)?.download).toBe("sql-result-selection.csv"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:selection-export"); + + const blobArg = createObjectURL.mock.calls[0]?.[0]; + + if (!(blobArg instanceof Blob)) { + throw new Error("Expected selection export download to use a Blob"); + } + + expect(await blobArg.text()).toBe("id\nuser_1\nuser_2"); + + view.cleanup(); + }); +}); diff --git a/ui/studio/grid/SelectionExportMenu.tsx b/ui/studio/grid/SelectionExportMenu.tsx new file mode 100644 index 00000000..f23e3446 --- /dev/null +++ b/ui/studio/grid/SelectionExportMenu.tsx @@ -0,0 +1,199 @@ +import type { RowSelectionState } from "@tanstack/react-table"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "../../components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../../components/ui/dropdown-menu"; +import { cn } from "../../lib/utils"; +import type { GridSelectionRange } from "./cell-selection"; +import { + buildCellSelectionExportTable, + buildRowSelectionExportTable, + buildSelectionExportFilename, + downloadSelectionExport, + type SelectionExportFormat, + type SelectionExportTable, + serializeSelectionExport, +} from "./selection-export"; + +interface SelectionExportMenuProps { + cellSelectionRange: GridSelectionRange | null; + columnIds: string[]; + filenameBase: string; + rows: Record[]; + rowSelectionState: RowSelectionState; +} + +/** + * Renders the `copy as` menu for the current grid selection. Any view that + * renders the shared `DataGrid` can mount it, so cell ranges and row + * selections export the same way everywhere. + */ +export function SelectionExportMenu(props: SelectionExportMenuProps) { + const { + cellSelectionRange, + columnIds, + filenameBase, + rowSelectionState, + rows, + } = props; + const [isOpen, setOpen] = useState(false); + const [includeColumnHeader, setIncludeColumnHeader] = useState(true); + const selectedRowCount = + Object.values(rowSelectionState).filter(Boolean).length; + + if (cellSelectionRange == null && selectedRowCount === 0) { + return null; + } + + function buildSelectionExportTable(): SelectionExportTable | null { + if (cellSelectionRange) { + return buildCellSelectionExportTable({ + columnIds, + range: cellSelectionRange, + rows, + }); + } + + if (selectedRowCount > 0) { + return buildRowSelectionExportTable({ + columnIds, + rowSelectionState, + rows, + }); + } + + return null; + } + + function buildSerializedSelectionExport( + format: SelectionExportFormat, + ): string { + const table = buildSelectionExportTable(); + + if (!table) { + return ""; + } + + return serializeSelectionExport({ + table, + format, + includeColumnHeader, + }); + } + + function handleCopySelectionExport(format: SelectionExportFormat) { + const content = buildSerializedSelectionExport(format); + + setOpen(false); + + if (!content || typeof navigator.clipboard?.writeText !== "function") { + return; + } + + void navigator.clipboard.writeText(content).catch((error) => { + console.error("Failed to copy selection export:", error); + }); + } + + function handleSaveSelectionExport(format: SelectionExportFormat) { + const content = buildSerializedSelectionExport(format); + + setOpen(false); + + if (!content) { + return; + } + + downloadSelectionExport({ + content, + filename: buildSelectionExportFilename({ + base: filenameBase, + format, + }), + format, + }); + } + + return ( + { + setOpen(open); + + if (open) { + setIncludeColumnHeader(true); + } + }} + > + + + + + + setIncludeColumnHeader(checked === true) + } + onSelect={(event) => { + event.preventDefault(); + }} + > + include column header + + +
+ {[ + { + action: () => handleCopySelectionExport("markdown"), + label: "copy markdown", + }, + { + action: () => handleCopySelectionExport("csv"), + label: "copy csv", + }, + { + action: () => handleSaveSelectionExport("markdown"), + label: "save markdown", + }, + { + action: () => handleSaveSelectionExport("csv"), + label: "save csv", + }, + ].map((item) => ( + + {item.label} + + ))} +
+
+
+ ); +} diff --git a/ui/studio/views/table/selection-export.test.ts b/ui/studio/grid/selection-export.test.ts similarity index 82% rename from ui/studio/views/table/selection-export.test.ts rename to ui/studio/grid/selection-export.test.ts index 0046cc40..e568875a 100644 --- a/ui/studio/views/table/selection-export.test.ts +++ b/ui/studio/grid/selection-export.test.ts @@ -5,6 +5,7 @@ import { buildRowSelectionExportTable, buildSelectionExportFilename, downloadSelectionExport, + getSelectionExportColumnIds, serializeSelectionExport, } from "./selection-export"; @@ -120,18 +121,45 @@ describe("selection-export", () => { it("builds stable filenames for saved exports", () => { expect( buildSelectionExportFilename({ - schema: "public", - table: "users", + base: "public-users", format: "csv", }), ).toBe("public-users-selection.csv"); expect( buildSelectionExportFilename({ - schema: "public", - table: "users", + base: "public-users", format: "markdown", }), ).toBe("public-users-selection.md"); + expect( + buildSelectionExportFilename({ + base: "sql-result", + format: "csv", + }), + ).toBe("sql-result-selection.csv"); + }); + + it("orders export columns by pinning then visible column order", () => { + expect( + getSelectionExportColumnIds({ + columnOrder: ["email", "id", "created_at"], + columnPinning: { + left: ["__ps_select", "created_at"], + right: [], + }, + defaultColumnIds: ["id", "email", "created_at"], + }), + ).toEqual(["created_at", "email", "id"]); + }); + + it("ignores column order entries that are not part of the result", () => { + expect( + getSelectionExportColumnIds({ + columnOrder: ["dropped_column", "email"], + columnPinning: {}, + defaultColumnIds: ["id", "email"], + }), + ).toEqual(["email", "id"]); }); it("downloads the serialized export via a temporary object url", async () => { diff --git a/ui/studio/views/table/selection-export.ts b/ui/studio/grid/selection-export.ts similarity index 81% rename from ui/studio/views/table/selection-export.ts rename to ui/studio/grid/selection-export.ts index 27d6ef3e..320f9818 100644 --- a/ui/studio/views/table/selection-export.ts +++ b/ui/studio/grid/selection-export.ts @@ -1,6 +1,11 @@ -import type { RowSelectionState } from "@tanstack/react-table"; +import type { + ColumnPinningState, + RowSelectionState, +} from "@tanstack/react-table"; -import type { GridSelectionRange } from "../../grid/cell-selection"; +import type { GridSelectionRange } from "./cell-selection"; + +const ROW_SELECTION_COLUMN_ID = "__ps_select"; export type SelectionExportFormat = "csv" | "markdown"; @@ -108,13 +113,39 @@ export function serializeSelectionExport(args: { } export function buildSelectionExportFilename(args: { - schema: string; - table: string; + base: string; format: SelectionExportFormat; }): string { const extension = args.format === "csv" ? "csv" : "md"; - return `${args.schema}-${args.table}-selection.${extension}`; + return `${args.base}-selection.${extension}`; +} + +export function getSelectionExportColumnIds(args: { + defaultColumnIds: string[]; + columnOrder: string[]; + columnPinning: ColumnPinningState; +}): string[] { + const { columnOrder, columnPinning, defaultColumnIds } = args; + const validColumnIds = new Set(defaultColumnIds); + const orderedColumnIds = [ + ...columnOrder.filter((columnId) => validColumnIds.has(columnId)), + ...defaultColumnIds.filter((columnId) => !columnOrder.includes(columnId)), + ]; + const pinnedColumnIds = (columnPinning.left ?? []).filter( + (columnId) => + columnId !== ROW_SELECTION_COLUMN_ID && validColumnIds.has(columnId), + ); + const seen = new Set(); + + return [...pinnedColumnIds, ...orderedColumnIds].filter((columnId) => { + if (seen.has(columnId)) { + return false; + } + + seen.add(columnId); + return true; + }); } export function downloadSelectionExport(args: { diff --git a/ui/studio/views/table/ActiveTableView.tsx b/ui/studio/views/table/ActiveTableView.tsx index d9d83367..ce732c88 100644 --- a/ui/studio/views/table/ActiveTableView.tsx +++ b/ui/studio/views/table/ActiveTableView.tsx @@ -1,6 +1,6 @@ import { useIsMutating } from "@tanstack/react-query"; import { type ColumnDef, type ColumnPinningState } from "@tanstack/react-table"; -import { ChevronDown, History, RefreshCw } from "lucide-react"; +import { History, RefreshCw } from "lucide-react"; import { type Dispatch, type KeyboardEvent as ReactKeyboardEvent, @@ -31,14 +31,6 @@ import { AlertDialogTitle, } from "../../../components/ui/alert-dialog"; import { Button, type ButtonProps } from "../../../components/ui/button"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../../components/ui/dropdown-menu"; import { TableHead } from "../../../components/ui/table"; import { useActiveTableInsert } from "../../../hooks/use-active-table-insert"; import { useActiveTableQuery } from "../../../hooks/use-active-table-query"; @@ -81,6 +73,7 @@ import { type GridFocusedCell, moveFocusedCell, } from "../../grid/focused-cell"; +import { getSelectionExportColumnIds } from "../../grid/selection-export"; import { getCellSelectionAnchor, getCellSelectionRange, @@ -88,6 +81,7 @@ import { type GridSelectionMachineState, transitionGridSelectionMachine, } from "../../grid/selection-state-machine"; +import { SelectionExportMenu } from "../../grid/SelectionExportMenu"; import { ExpandableSearchControl } from "../../input/ExpandableSearchControl"; import { type CellEditNavigationDirection, @@ -113,14 +107,6 @@ import { InlineTableFilterAddButton, InlineTableFiltersHeaderRow, } from "./InlineTableFilters"; -import { - buildCellSelectionExportTable, - buildRowSelectionExportTable, - buildSelectionExportFilename, - downloadSelectionExport, - type SelectionExportFormat, - serializeSelectionExport, -} from "./selection-export"; import { StagedRows } from "./StagedRows"; import { applyAiTableFilterRequest } from "./table-ai-filter"; import { useActiveTableRowSearch } from "./use-active-table-row-search"; @@ -376,9 +362,6 @@ export function ActiveTableView(_props: ViewProps) { ); const [aiFocusRequestKey, setAiFocusRequestKey] = useState(0); const [gridFocusRequestId, setGridFocusRequestId] = useState(0); - const [isSelectionExportOpen, setSelectionExportOpen] = useState(false); - const [includeSelectionExportHeader, setIncludeSelectionExportHeader] = - useState(true); const [discardWiggleCount, setDiscardWiggleCount] = useState(0); const [isSaveDialogOpen, setSaveDialogOpen] = useState(false); const [isDiscardDialogOpen, setDiscardDialogOpen] = useState(false); @@ -426,7 +409,6 @@ export function ActiveTableView(_props: ViewProps) { : `Open history for ${activeTable.schema}.${activeTable.name}`; }, [activeTable, hasPrismaWalStream, selectedRowHistoryClause]); const cellSelectionRange = getCellSelectionRange(gridSelectionState); - const hasSelectionExport = cellSelectionRange != null || selectedRowCount > 0; const deleteSelectionLabel = getDeleteSelectionLabel(selectedRowCount); const deleteConfirmationPrompt = getDeleteConfirmationPrompt(selectedRowCount); @@ -563,12 +545,6 @@ export function ActiveTableView(_props: ViewProps) { isSaveDialogOpen, ]); - useEffect(() => { - if (!hasSelectionExport && isSelectionExportOpen) { - setSelectionExportOpen(false); - } - }, [hasSelectionExport, isSelectionExportOpen]); - const readonly = !Object.values(activeTable?.columns ?? {}).some( (column) => column.pkPosition != null, ); @@ -639,33 +615,6 @@ export function ActiveTableView(_props: ViewProps) { }), [fallbackSelectionExportColumnIds, gridColumnOrder, gridColumnPinning], ); - const selectionExportTable = useMemo(() => { - const exportRows = displayRows; - - if (cellSelectionRange) { - return buildCellSelectionExportTable({ - columnIds: selectionExportColumnIds, - range: cellSelectionRange, - rows: exportRows, - }); - } - - if (selectedRowCount > 0) { - return buildRowSelectionExportTable({ - columnIds: selectionExportColumnIds, - rowSelectionState, - rows: exportRows, - }); - } - - return null; - }, [ - cellSelectionRange, - displayRows, - rowSelectionState, - selectedRowCount, - selectionExportColumnIds, - ]); const editableColumnIds = useMemo( () => getEditableColumnIds(activeTable?.columns, readonly), [activeTable?.columns, readonly], @@ -911,58 +860,6 @@ export function ActiveTableView(_props: ViewProps) { setDeleteDialogOpen(false); } - function buildSerializedSelectionExport( - format: SelectionExportFormat, - ): string { - if (!selectionExportTable) { - return ""; - } - - return serializeSelectionExport({ - table: selectionExportTable, - format, - includeColumnHeader: includeSelectionExportHeader, - }); - } - - function handleCopySelectionExport(format: SelectionExportFormat) { - const content = buildSerializedSelectionExport(format); - - setSelectionExportOpen(false); - - if (!content || typeof navigator.clipboard?.writeText !== "function") { - return; - } - - void navigator.clipboard.writeText(content).catch((error) => { - console.error("Failed to copy selection export:", error); - }); - } - - function handleSaveSelectionExport(format: SelectionExportFormat) { - if (!activeTable) { - return; - } - - const content = buildSerializedSelectionExport(format); - - setSelectionExportOpen(false); - - if (!content) { - return; - } - - downloadSelectionExport({ - content, - filename: buildSelectionExportFilename({ - format, - schema: activeTable.schema, - table: activeTable.name, - }), - format, - }); - } - const commandPaletteActions = useMemo( () => createActiveTableCommandPaletteActions({ @@ -1637,80 +1534,13 @@ export function ActiveTableView(_props: ViewProps) { > Insert row - {hasSelectionExport && ( - { - setSelectionExportOpen(open); - - if (open) { - setIncludeSelectionExportHeader(true); - } - }} - > - - - - - - setIncludeSelectionExportHeader(checked === true) - } - onSelect={(event) => { - event.preventDefault(); - }} - > - include column header - - -
- {[ - { - action: () => handleCopySelectionExport("markdown"), - label: "copy markdown", - }, - { - action: () => handleCopySelectionExport("csv"), - label: "copy csv", - }, - { - action: () => handleSaveSelectionExport("markdown"), - label: "save markdown", - }, - { - action: () => handleSaveSelectionExport("csv"), - label: "save csv", - }, - ].map((item) => ( - - {item.label} - - ))} -
-
-
- )} + {hasStagedChanges && ( <> ); + const sqlToolbarActions = ( + <> + + {runSqlButton} + + ); return (
- + {hasAiSql ? (
Date: Mon, 31 Aug 2026 12:39:42 +0200 Subject: [PATCH 3/4] fix: keep right-pinned columns last in selection exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSelectionExportColumnIds only prepended columnPinning.left, so a right-pinned column would land in the middle of an export instead of at the end. No user-facing change today — the pin control and the URL pin param only produce left pins — but the helper now matches how the grid renders columns. --- ui/studio/grid/selection-export.test.ts | 13 +++++++++++++ ui/studio/grid/selection-export.ts | 26 +++++++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ui/studio/grid/selection-export.test.ts b/ui/studio/grid/selection-export.test.ts index e568875a..cb42c6ba 100644 --- a/ui/studio/grid/selection-export.test.ts +++ b/ui/studio/grid/selection-export.test.ts @@ -152,6 +152,19 @@ describe("selection-export", () => { ).toEqual(["created_at", "email", "id"]); }); + it("keeps right-pinned columns last, as the grid renders them", () => { + expect( + getSelectionExportColumnIds({ + columnOrder: ["id", "email", "created_at"], + columnPinning: { + left: ["__ps_select", "created_at"], + right: ["id"], + }, + defaultColumnIds: ["id", "email", "created_at"], + }), + ).toEqual(["created_at", "email", "id"]); + }); + it("ignores column order entries that are not part of the result", () => { expect( getSelectionExportColumnIds({ diff --git a/ui/studio/grid/selection-export.ts b/ui/studio/grid/selection-export.ts index 320f9818..36376a46 100644 --- a/ui/studio/grid/selection-export.ts +++ b/ui/studio/grid/selection-export.ts @@ -128,17 +128,31 @@ export function getSelectionExportColumnIds(args: { }): string[] { const { columnOrder, columnPinning, defaultColumnIds } = args; const validColumnIds = new Set(defaultColumnIds); + const isExportableColumnId = (columnId: string) => + columnId !== ROW_SELECTION_COLUMN_ID && validColumnIds.has(columnId); + const leftPinnedColumnIds = (columnPinning.left ?? []).filter( + isExportableColumnId, + ); + const rightPinnedColumnIds = (columnPinning.right ?? []).filter( + isExportableColumnId, + ); + const pinnedColumnIds = new Set([ + ...leftPinnedColumnIds, + ...rightPinnedColumnIds, + ]); + // Same order as the grid renders: left-pinned, then the visible order, then + // right-pinned. const orderedColumnIds = [ ...columnOrder.filter((columnId) => validColumnIds.has(columnId)), ...defaultColumnIds.filter((columnId) => !columnOrder.includes(columnId)), - ]; - const pinnedColumnIds = (columnPinning.left ?? []).filter( - (columnId) => - columnId !== ROW_SELECTION_COLUMN_ID && validColumnIds.has(columnId), - ); + ].filter((columnId) => !pinnedColumnIds.has(columnId)); const seen = new Set(); - return [...pinnedColumnIds, ...orderedColumnIds].filter((columnId) => { + return [ + ...leftPinnedColumnIds, + ...orderedColumnIds, + ...rightPinnedColumnIds, + ].filter((columnId) => { if (seen.has(columnId)) { return false; } From 9f500db4d8df9d3b59f15712f27a39b6272b3cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Paulmier?= Date: Mon, 31 Aug 2026 14:11:46 +0200 Subject: [PATCH 4/4] fix: never export the row selection column from SQL results SQL result column ids come from the query itself, so a column literally named __ps_select could reach the export helper and be serialized as a data column and header. The exportable-column guard now applies to the ordered segment as well, not only to the pinned groups. --- ui/studio/grid/selection-export.test.ts | 10 ++++++++++ ui/studio/grid/selection-export.ts | 7 +++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/ui/studio/grid/selection-export.test.ts b/ui/studio/grid/selection-export.test.ts index cb42c6ba..15bc8510 100644 --- a/ui/studio/grid/selection-export.test.ts +++ b/ui/studio/grid/selection-export.test.ts @@ -165,6 +165,16 @@ describe("selection-export", () => { ).toEqual(["created_at", "email", "id"]); }); + it("never exports the row selection column, whatever the query returns", () => { + expect( + getSelectionExportColumnIds({ + columnOrder: ["__ps_select", "id"], + columnPinning: {}, + defaultColumnIds: ["__ps_select", "id"], + }), + ).toEqual(["id"]); + }); + it("ignores column order entries that are not part of the result", () => { expect( getSelectionExportColumnIds({ diff --git a/ui/studio/grid/selection-export.ts b/ui/studio/grid/selection-export.ts index 36376a46..42291cf2 100644 --- a/ui/studio/grid/selection-export.ts +++ b/ui/studio/grid/selection-export.ts @@ -143,8 +143,11 @@ export function getSelectionExportColumnIds(args: { // Same order as the grid renders: left-pinned, then the visible order, then // right-pinned. const orderedColumnIds = [ - ...columnOrder.filter((columnId) => validColumnIds.has(columnId)), - ...defaultColumnIds.filter((columnId) => !columnOrder.includes(columnId)), + ...columnOrder.filter(isExportableColumnId), + ...defaultColumnIds.filter( + (columnId) => + isExportableColumnId(columnId) && !columnOrder.includes(columnId), + ), ].filter((columnId) => !pinnedColumnIds.has(columnId)); const seen = new Set();