From e0ce0643b8c8c8464c5d42b7ad9aa32fea07dd6b Mon Sep 17 00:00:00 2001 From: Souhail Krissaane <61523279+SouhailKrs@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:14:09 +0100 Subject: [PATCH 1/2] fear: Add JSON export to Studio selection menu --- .changeset/pink-llamas-unite.md | 5 + FEATURES.md | 5 +- .../table/ActiveTableView.filtering.test.tsx | 350 ++++++++++++++++++ ui/studio/views/table/ActiveTableView.tsx | 8 + .../views/table/selection-export.test.ts | 158 +++++++- ui/studio/views/table/selection-export.ts | 69 +++- 6 files changed, 580 insertions(+), 15 deletions(-) create mode 100644 .changeset/pink-llamas-unite.md diff --git a/.changeset/pink-llamas-unite.md b/.changeset/pink-llamas-unite.md new file mode 100644 index 00000000..64669182 --- /dev/null +++ b/.changeset/pink-llamas-unite.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": patch +--- + +Add JSON copy/save export for Studio selections diff --git a/FEATURES.md b/FEATURES.md index a6a8c714..ca61bc4d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -293,8 +293,9 @@ 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. -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. +When rows or cell ranges are selected, the table toolbar adds a compact `copy as` menu for exporting the current selection as Markdown, CSV, or JSON. +Markdown and CSV exports can copy directly to the clipboard or save to disk with optional column headers, while JSON exports keep the selected visible columns as keyed objects. +All export formats reuse the current grid column order and pinned-column layout so the exported shape matches what users are working with. ## Typed Cell Editing diff --git a/ui/studio/views/table/ActiveTableView.filtering.test.tsx b/ui/studio/views/table/ActiveTableView.filtering.test.tsx index fae1a881..fb111bac 100644 --- a/ui/studio/views/table/ActiveTableView.filtering.test.tsx +++ b/ui/studio/views/table/ActiveTableView.filtering.test.tsx @@ -3134,8 +3134,10 @@ describe("ActiveTableView filtering", () => { expect(document.body.textContent).toContain("include column header"); expect(document.body.textContent).toContain("copy markdown"); expect(document.body.textContent).toContain("copy csv"); + expect(document.body.textContent).toContain("copy json"); expect(document.body.textContent).toContain("save markdown"); expect(document.body.textContent).toContain("save csv"); + expect(document.body.textContent).toContain("save json"); const checkedCheckbox = findMenuCheckboxByText("include column header"); @@ -3156,6 +3158,354 @@ describe("ActiveTableView filtering", () => { view.cleanup(); }); + it("copies row selections as json using the current visible column order", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const activeTable = { + columns: { + created_at: { + ...createColumn({ + datatypeName: "timestamptz", + group: "datetime", + name: "created_at", + }), + datatype: { + format: "YYYY-MM-DD HH:mm:ss.SSSZZ", + group: "datetime", + isArray: false, + isNative: true, + name: "timestamptz", + options: [], + schema: "pg_catalog", + }, + }, + email: createColumn({ + datatypeName: "character varying(64)", + group: "string", + name: "email", + }), + id: createColumn({ + datatypeName: "uuid", + group: "string", + name: "id", + pkPosition: 1, + }), + is_active: createColumn({ + datatypeName: "boolean", + group: "string", + name: "is_active", + }), + meta: createColumn({ + datatypeName: "jsonb", + group: "string", + name: "meta", + }), + score: createColumn({ + datatypeName: "integer", + group: "numeric", + name: "score", + }), + tags: createColumn({ + datatypeName: "jsonb", + group: "string", + name: "tags", + }), + }, + name: "users", + schema: "public", + }; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText, + }, + }); + useNavigationMock.mockImplementation(() => ({ + createUrl: vi.fn(() => "#"), + metadata: { + activeTable, + }, + searchParam: navigationSearchParam, + setPageIndexParam: setPageIndexParamMock, + setSearchParam: setSearchParamMock, + })); + useIntrospectionMock.mockReturnValue({ + data: { + filterOperators: [ + "=", + "!=", + ">", + ">=", + "<", + "<=", + "is", + "is not", + "like", + "not like", + "ilike", + "not ilike", + ], + query: { + parameters: [], + sql: "", + }, + schemas: { + public: { + name: "public", + tables: { + users: activeTable, + }, + }, + }, + timezone: "UTC", + }, + refetch: vi.fn(), + }); + + useActiveTableQueryMock.mockReturnValue({ + data: { + filteredRowCount: 2, + rows: [ + { + __ps_rowid: "row-1", + created_at: "2026-03-11T00:00:00.000Z", + email: "alice@example.com", + id: "user_1", + is_active: true, + meta: { owner: "alice" }, + score: 42, + tags: ["vip", "beta"], + }, + { + __ps_rowid: "row-2", + created_at: "2026-03-12T00:00:00.000Z", + email: "bob@example.com", + id: "user_2", + is_active: false, + meta: null, + score: 0, + tags: [], + }, + ], + }, + isFetching: false, + refetch: vi.fn(), + }); + useSelectionMock.mockReturnValue({ + deleteSelection: vi.fn(), + isSelecting: true, + rowSelectionState: { + "row-2": true, + "row-1": true, + }, + setRowSelectionState: vi.fn(), + }); + gridColumnOrderState = [ + "email", + "id", + "is_active", + "score", + "tags", + "meta", + "created_at", + ]; + + const view = renderView(); + const copyAsTrigger = findButtonByText("copy as"); + + expect(copyAsTrigger).toBeDefined(); + + dispatchPointerClick(copyAsTrigger); + await flush(); + + const copyJsonButton = findMenuItemByText("copy json"); + + expect(copyJsonButton).toBeDefined(); + + dispatchPointerClick(copyJsonButton); + + expect(writeText).toHaveBeenCalledWith( + JSON.stringify( + [ + { + email: "alice@example.com", + id: "user_1", + is_active: true, + score: 42, + tags: ["vip", "beta"], + meta: { owner: "alice" }, + created_at: "2026-03-11T00:00:00.000Z", + }, + { + email: "bob@example.com", + id: "user_2", + is_active: false, + score: 0, + tags: [], + meta: null, + created_at: "2026-03-12T00:00:00.000Z", + }, + ], + null, + 2, + ), + ); + + view.cleanup(); + }); + + it("copies a single selected row as one json object", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const activeTable = { + columns: { + created_at: { + ...createColumn({ + datatypeName: "timestamptz", + group: "datetime", + name: "created_at", + }), + datatype: { + format: "YYYY-MM-DD HH:mm:ss.SSSZZ", + group: "datetime", + isArray: false, + isNative: true, + name: "timestamptz", + options: [], + schema: "pg_catalog", + }, + }, + email: createColumn({ + datatypeName: "character varying(64)", + group: "string", + name: "email", + }), + id: createColumn({ + datatypeName: "uuid", + group: "string", + name: "id", + pkPosition: 1, + }), + is_active: createColumn({ + datatypeName: "boolean", + group: "string", + name: "is_active", + }), + }, + name: "users", + schema: "public", + }; + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText, + }, + }); + useNavigationMock.mockImplementation(() => ({ + createUrl: vi.fn(() => "#"), + metadata: { + activeTable, + }, + searchParam: navigationSearchParam, + setPageIndexParam: setPageIndexParamMock, + setSearchParam: setSearchParamMock, + })); + useIntrospectionMock.mockReturnValue({ + data: { + filterOperators: [ + "=", + "!=", + ">", + ">=", + "<", + "<=", + "is", + "is not", + "like", + "not like", + "ilike", + "not ilike", + ], + query: { + parameters: [], + sql: "", + }, + schemas: { + public: { + name: "public", + tables: { + users: activeTable, + }, + }, + }, + timezone: "UTC", + }, + refetch: vi.fn(), + }); + + useActiveTableQueryMock.mockReturnValue({ + data: { + filteredRowCount: 2, + rows: [ + { + __ps_rowid: "row-1", + created_at: "2026-03-11T00:00:00.000Z", + email: "alice@example.com", + id: "user_1", + is_active: true, + }, + { + __ps_rowid: "row-2", + created_at: "2026-03-12T00:00:00.000Z", + email: "bob@example.com", + id: "user_2", + is_active: false, + }, + ], + }, + isFetching: false, + refetch: vi.fn(), + }); + useSelectionMock.mockReturnValue({ + deleteSelection: vi.fn(), + isSelecting: true, + rowSelectionState: { + "row-2": true, + }, + setRowSelectionState: vi.fn(), + }); + gridColumnOrderState = ["email", "id", "is_active", "created_at"]; + + const view = renderView(); + const copyAsTrigger = findButtonByText("copy as"); + + expect(copyAsTrigger).toBeDefined(); + + dispatchPointerClick(copyAsTrigger); + await flush(); + + const copyJsonButton = findMenuItemByText("copy json"); + + expect(copyJsonButton).toBeDefined(); + + dispatchPointerClick(copyJsonButton); + + expect(writeText).toHaveBeenCalledWith( + JSON.stringify( + { + email: "bob@example.com", + id: "user_2", + is_active: false, + created_at: "2026-03-12T00:00:00.000Z", + }, + null, + 2, + ), + ); + + view.cleanup(); + }); + it("saves markdown for all selected rows when header inclusion is turned off", async () => { const createObjectURL = vi .spyOn(URL, "createObjectURL") diff --git a/ui/studio/views/table/ActiveTableView.tsx b/ui/studio/views/table/ActiveTableView.tsx index d9d83367..769e5884 100644 --- a/ui/studio/views/table/ActiveTableView.tsx +++ b/ui/studio/views/table/ActiveTableView.tsx @@ -1690,6 +1690,10 @@ export function ActiveTableView(_props: ViewProps) { action: () => handleCopySelectionExport("csv"), label: "copy csv", }, + { + action: () => handleCopySelectionExport("json"), + label: "copy json", + }, { action: () => handleSaveSelectionExport("markdown"), label: "save markdown", @@ -1698,6 +1702,10 @@ export function ActiveTableView(_props: ViewProps) { action: () => handleSaveSelectionExport("csv"), label: "save csv", }, + { + action: () => handleSaveSelectionExport("json"), + label: "save json", + }, ].map((item) => ( { columnIds: ["name", "notes"], rows: [ ["Acme Labs", "Line 1\nLine 2"], - ["Northwind Retail", '{"owner":"ops@northwind.io"}'], + ["Northwind Retail", { owner: "ops@northwind.io" }], ], }); }); @@ -117,6 +117,126 @@ describe("selection-export", () => { ).toBe("| Acme \\| Labs | Line 1
Line 2 |"); }); + it("serializes json exports as an array of objects keyed by column id", () => { + const table = { + columnIds: ["id", "notes", "is_active", "tags", "score", "meta"], + rows: [ + [ + "org_acme", + 'Value with "quotes"', + true, + ["a", "b"], + 42, + { owner: "ops@acme.io" }, + ], + ["org_northwind", null, false, [], 0, { owner: null }], + ], + }; + + expect( + serializeSelectionExport({ + table, + format: "json", + includeColumnHeader: true, + }), + ).toBe( + JSON.stringify( + [ + { + id: "org_acme", + notes: 'Value with "quotes"', + is_active: true, + tags: ["a", "b"], + score: 42, + meta: { owner: "ops@acme.io" }, + }, + { + id: "org_northwind", + notes: null, + is_active: false, + tags: [], + score: 0, + meta: { owner: null }, + }, + ], + null, + 2, + ), + ); + expect( + serializeSelectionExport({ + table, + format: "json", + includeColumnHeader: false, + }), + ).toBe( + JSON.stringify( + [ + { + id: "org_acme", + notes: 'Value with "quotes"', + is_active: true, + tags: ["a", "b"], + score: 42, + meta: { owner: "ops@acme.io" }, + }, + { + id: "org_northwind", + notes: null, + is_active: false, + tags: [], + score: 0, + meta: { owner: null }, + }, + ], + null, + 2, + ), + ); + }); + + it("serializes a single-row json export as one object", () => { + const table = { + columnIds: ["id", "notes", "is_active"], + rows: [["org_acme", 'Value with "quotes"', true]], + }; + + expect( + serializeSelectionExport({ + table, + format: "json", + includeColumnHeader: true, + }), + ).toBe( + JSON.stringify( + { id: "org_acme", notes: 'Value with "quotes"', is_active: true }, + null, + 2, + ), + ); + }); + + it("serializes bigint and undefined json values safely", () => { + const table = { + columnIds: ["id", "count", "optional"], + rows: [["org_acme", 42n, undefined]], + }; + + expect( + serializeSelectionExport({ + table, + format: "json", + includeColumnHeader: true, + }), + ).toBe( + JSON.stringify( + { id: "org_acme", count: "42", optional: null }, + null, + 2, + ), + ); + }); + it("builds stable filenames for saved exports", () => { expect( buildSelectionExportFilename({ @@ -132,6 +252,13 @@ describe("selection-export", () => { format: "markdown", }), ).toBe("public-users-selection.md"); + expect( + buildSelectionExportFilename({ + schema: "public", + table: "users", + format: "json", + }), + ).toBe("public-users-selection.json"); }); it("downloads the serialized export via a temporary object url", async () => { @@ -164,4 +291,33 @@ describe("selection-export", () => { expect(click).toHaveBeenCalledTimes(1); expect(revokeObjectURL).toHaveBeenCalledWith("blob:selection-export"); }); + + it("downloads json exports with the json mime type", 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); + + downloadSelectionExport({ + content: '[\n {\n "id": "org_acme"\n }\n]', + filename: "public-users-selection.json", + format: "json", + }); + + const blobArg = createObjectURL.mock.calls.at(-1)?.[0]; + + if (!(blobArg instanceof Blob)) { + throw new Error("Expected selection export download to use a Blob"); + } + + expect(await blobArg.text()).toBe('[\n {\n "id": "org_acme"\n }\n]'); + expect(blobArg.type).toBe("application/json;charset=utf-8"); + expect(click.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:selection-export"); + }); }); diff --git a/ui/studio/views/table/selection-export.ts b/ui/studio/views/table/selection-export.ts index 27d6ef3e..cb906024 100644 --- a/ui/studio/views/table/selection-export.ts +++ b/ui/studio/views/table/selection-export.ts @@ -2,11 +2,11 @@ import type { RowSelectionState } from "@tanstack/react-table"; import type { GridSelectionRange } from "../../grid/cell-selection"; -export type SelectionExportFormat = "csv" | "markdown"; +export type SelectionExportFormat = "csv" | "json" | "markdown"; export interface SelectionExportTable { columnIds: string[]; - rows: string[][]; + rows: unknown[][]; } export function buildCellSelectionExportTable(args: { @@ -16,7 +16,7 @@ export function buildCellSelectionExportTable(args: { }): SelectionExportTable { const { columnIds, range, rows } = args; const selectedColumnIds: string[] = []; - const selectedRows: string[][] = []; + const selectedRows: unknown[][] = []; for ( let columnIndex = range.columnStart; @@ -47,9 +47,7 @@ export function buildCellSelectionExportTable(args: { } selectedRows.push( - selectedColumnIds.map((columnId) => - stringifySelectionExportValue(row[columnId]), - ), + selectedColumnIds.map((columnId) => row[columnId]), ); } @@ -79,9 +77,7 @@ export function buildRowSelectionExportTable(args: { return typeof rowId === "string" && rowSelectionState[rowId] === true; }) - .map((row) => - columnIds.map((columnId) => stringifySelectionExportValue(row[columnId])), - ); + .map((row) => columnIds.map((columnId) => row[columnId])); return { columnIds: [...columnIds], @@ -104,6 +100,10 @@ export function serializeSelectionExport(args: { return serializeSelectionExportCsv({ includeColumnHeader, table }); } + if (format === "json") { + return serializeSelectionExportJson(table); + } + return serializeSelectionExportMarkdown({ includeColumnHeader, table }); } @@ -112,7 +112,8 @@ export function buildSelectionExportFilename(args: { table: string; format: SelectionExportFormat; }): string { - const extension = args.format === "csv" ? "csv" : "md"; + const extension = + args.format === "csv" ? "csv" : args.format === "json" ? "json" : "md"; return `${args.schema}-${args.table}-selection.${extension}`; } @@ -128,6 +129,8 @@ export function downloadSelectionExport(args: { type: format === "csv" ? "text/csv;charset=utf-8" + : format === "json" + ? "application/json;charset=utf-8" : "text/markdown;charset=utf-8", }); const objectUrl = URL.createObjectURL(blob); @@ -143,6 +146,19 @@ export function downloadSelectionExport(args: { URL.revokeObjectURL(objectUrl); } +function serializeSelectionExportJson(table: SelectionExportTable): string { + const records = table.rows.map((row) => + Object.fromEntries( + row.map((value, index) => [ + table.columnIds[index], + normalizeSelectionExportJsonValue(value), + ]), + ), + ); + + return JSON.stringify(records.length === 1 ? records[0] : records, null, 2); +} + function serializeSelectionExportCsv(args: { table: SelectionExportTable; includeColumnHeader: boolean; @@ -155,7 +171,7 @@ function serializeSelectionExportCsv(args: { } for (const row of table.rows) { - lines.push(serializeCsvRow(row)); + lines.push(serializeCsvRow(row.map(stringifySelectionExportValue))); } return lines.join("\n"); @@ -186,7 +202,7 @@ function serializeSelectionExportMarkdown(args: { } for (const row of table.rows) { - lines.push(serializeMarkdownRow(row)); + lines.push(serializeMarkdownRow(row.map(stringifySelectionExportValue))); } return lines.join("\n"); @@ -205,6 +221,35 @@ function escapeMarkdownValue(value: string): string { .replaceAll("\r", "
"); } +function normalizeSelectionExportJsonValue(value: unknown): unknown { + if (value === undefined) { + return null; + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (Array.isArray(value)) { + return value.map(normalizeSelectionExportJsonValue); + } + + if (value instanceof Date) { + return value.toJSON(); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + normalizeSelectionExportJsonValue(entryValue), + ]), + ); + } + + return value; +} + function stringifySelectionExportValue(value: unknown): string { if (value == null) { return ""; From 9a0b461eeed6bb7b1e853110109f45e9b4f1c9fd Mon Sep 17 00:00:00 2001 From: Souhail Krissaane <61523279+SouhailKrs@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:30:48 +0100 Subject: [PATCH 2/2] test(selection-export): enhance tests for JSON export functionality --- ui/studio/views/table/selection-export.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/studio/views/table/selection-export.test.ts b/ui/studio/views/table/selection-export.test.ts index 997756ba..3abee698 100644 --- a/ui/studio/views/table/selection-export.test.ts +++ b/ui/studio/views/table/selection-export.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildCellSelectionExportTable, @@ -9,6 +9,10 @@ import { } from "./selection-export"; describe("selection-export", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("builds cell selection export tables from the selected range", () => { expect( buildCellSelectionExportTable({ @@ -309,7 +313,9 @@ describe("selection-export", () => { format: "json", }); - const blobArg = createObjectURL.mock.calls.at(-1)?.[0]; + expect(createObjectURL).toHaveBeenCalledTimes(1); + + const blobArg = createObjectURL.mock.calls[0]?.[0]; if (!(blobArg instanceof Blob)) { throw new Error("Expected selection export download to use a Blob"); @@ -317,7 +323,7 @@ describe("selection-export", () => { expect(await blobArg.text()).toBe('[\n {\n "id": "org_acme"\n }\n]'); expect(blobArg.type).toBe("application/json;charset=utf-8"); - expect(click.mock.calls.length).toBeGreaterThanOrEqual(1); + expect(click).toHaveBeenCalledTimes(1); expect(revokeObjectURL).toHaveBeenCalledWith("blob:selection-export"); }); });