Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sql-result-selection-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prisma/studio-core": minor
---

Offer the `copy as` selection export in SQL view. Selected result rows and cell ranges copy or save as CSV or Markdown from the SQL toolbar, using the same menu, column order and header option as the table toolbar, so query output no longer has to leave Studio one cell at a time.
1 change: 1 addition & 0 deletions Architecture/sql-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ SQL result visualization is governed by:
include client-side render/layout time.
- The idle AI-visualization affordance MUST render on the same summary row as the `"row(s) returned in Xms"` text, right-aligned from the row-count copy.
- SQL result rows MUST be adapted with a stable synthetic `__ps_rowid` for shared grid row identity.
- SQL result selections MUST expose the shared selection-export menu in the view toolbar, and its selection subscription MUST live in an isolated component so selecting rows or cells never rerenders the SQL editor.
- Result columns are dynamic and derived from query output keys.
- SQL headers/cells MUST reuse table-view header/cell components (`DataGridHeader`, `getCell`) with synthetic column metadata.
- Any mounted AI visualization chart for SQL results MUST live inside the shared scrollable grid header region, so it scrolls with the same container as the result rows.
Expand Down
3 changes: 2 additions & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,8 @@ Paste operations map matrix values into selected writable cells, enabling spread

## Selection Export Formats

When rows or cell ranges are selected, the table toolbar adds a compact `copy as` menu for exporting the current selection as Markdown or CSV.
When rows or cell ranges are selected, a compact `copy as` menu appears in the view toolbar for exporting the current selection as Markdown or CSV.
The same menu serves the table toolbar and the SQL toolbar next to the run control, so a query result leaves Studio exactly like table data instead of one cell at a time.
Exports can copy directly to the clipboard or save to disk, include column headers by default, and reuse the current grid column order and pinned-column layout so the exported shape matches what users are working with.

## Typed Cell Editing
Expand Down
191 changes: 191 additions & 0 deletions ui/studio/grid/SelectionExportMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === text);
}

function findMenuItemByText(text: string) {
return Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === text);
}

function renderMenu(
props: Partial<Parameters<typeof SelectionExportMenu>[0]> = {},
) {
const container = document.createElement("div");

document.body.appendChild(container);

const root = createRoot(container);

act(() => {
root.render(
<SelectionExportMenu
cellSelectionRange={null}
columnIds={["id", "email"]}
filenameBase="sql-result"
rows={ROWS}
rowSelectionState={{}}
{...props}
/>,
);
});

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();
});
});
Loading