Skip to content

Commit e0ce064

Browse files
committed
fear: Add JSON export to Studio selection menu
1 parent 550f51d commit e0ce064

6 files changed

Lines changed: 580 additions & 15 deletions

File tree

.changeset/pink-llamas-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prisma/studio-core": patch
3+
---
4+
5+
Add JSON copy/save export for Studio selections

FEATURES.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,9 @@ Paste operations map matrix values into selected writable cells, enabling spread
293293

294294
## Selection Export Formats
295295

296-
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.
297-
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.
296+
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.
297+
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.
298+
All export formats reuse the current grid column order and pinned-column layout so the exported shape matches what users are working with.
298299

299300
## Typed Cell Editing
300301

ui/studio/views/table/ActiveTableView.filtering.test.tsx

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3134,8 +3134,10 @@ describe("ActiveTableView filtering", () => {
31343134
expect(document.body.textContent).toContain("include column header");
31353135
expect(document.body.textContent).toContain("copy markdown");
31363136
expect(document.body.textContent).toContain("copy csv");
3137+
expect(document.body.textContent).toContain("copy json");
31373138
expect(document.body.textContent).toContain("save markdown");
31383139
expect(document.body.textContent).toContain("save csv");
3140+
expect(document.body.textContent).toContain("save json");
31393141

31403142
const checkedCheckbox = findMenuCheckboxByText("include column header");
31413143

@@ -3156,6 +3158,354 @@ describe("ActiveTableView filtering", () => {
31563158
view.cleanup();
31573159
});
31583160

3161+
it("copies row selections as json using the current visible column order", async () => {
3162+
const writeText = vi.fn().mockResolvedValue(undefined);
3163+
const activeTable = {
3164+
columns: {
3165+
created_at: {
3166+
...createColumn({
3167+
datatypeName: "timestamptz",
3168+
group: "datetime",
3169+
name: "created_at",
3170+
}),
3171+
datatype: {
3172+
format: "YYYY-MM-DD HH:mm:ss.SSSZZ",
3173+
group: "datetime",
3174+
isArray: false,
3175+
isNative: true,
3176+
name: "timestamptz",
3177+
options: [],
3178+
schema: "pg_catalog",
3179+
},
3180+
},
3181+
email: createColumn({
3182+
datatypeName: "character varying(64)",
3183+
group: "string",
3184+
name: "email",
3185+
}),
3186+
id: createColumn({
3187+
datatypeName: "uuid",
3188+
group: "string",
3189+
name: "id",
3190+
pkPosition: 1,
3191+
}),
3192+
is_active: createColumn({
3193+
datatypeName: "boolean",
3194+
group: "string",
3195+
name: "is_active",
3196+
}),
3197+
meta: createColumn({
3198+
datatypeName: "jsonb",
3199+
group: "string",
3200+
name: "meta",
3201+
}),
3202+
score: createColumn({
3203+
datatypeName: "integer",
3204+
group: "numeric",
3205+
name: "score",
3206+
}),
3207+
tags: createColumn({
3208+
datatypeName: "jsonb",
3209+
group: "string",
3210+
name: "tags",
3211+
}),
3212+
},
3213+
name: "users",
3214+
schema: "public",
3215+
};
3216+
3217+
Object.defineProperty(navigator, "clipboard", {
3218+
configurable: true,
3219+
value: {
3220+
writeText,
3221+
},
3222+
});
3223+
useNavigationMock.mockImplementation(() => ({
3224+
createUrl: vi.fn(() => "#"),
3225+
metadata: {
3226+
activeTable,
3227+
},
3228+
searchParam: navigationSearchParam,
3229+
setPageIndexParam: setPageIndexParamMock,
3230+
setSearchParam: setSearchParamMock,
3231+
}));
3232+
useIntrospectionMock.mockReturnValue({
3233+
data: {
3234+
filterOperators: [
3235+
"=",
3236+
"!=",
3237+
">",
3238+
">=",
3239+
"<",
3240+
"<=",
3241+
"is",
3242+
"is not",
3243+
"like",
3244+
"not like",
3245+
"ilike",
3246+
"not ilike",
3247+
],
3248+
query: {
3249+
parameters: [],
3250+
sql: "",
3251+
},
3252+
schemas: {
3253+
public: {
3254+
name: "public",
3255+
tables: {
3256+
users: activeTable,
3257+
},
3258+
},
3259+
},
3260+
timezone: "UTC",
3261+
},
3262+
refetch: vi.fn(),
3263+
});
3264+
3265+
useActiveTableQueryMock.mockReturnValue({
3266+
data: {
3267+
filteredRowCount: 2,
3268+
rows: [
3269+
{
3270+
__ps_rowid: "row-1",
3271+
created_at: "2026-03-11T00:00:00.000Z",
3272+
email: "alice@example.com",
3273+
id: "user_1",
3274+
is_active: true,
3275+
meta: { owner: "alice" },
3276+
score: 42,
3277+
tags: ["vip", "beta"],
3278+
},
3279+
{
3280+
__ps_rowid: "row-2",
3281+
created_at: "2026-03-12T00:00:00.000Z",
3282+
email: "bob@example.com",
3283+
id: "user_2",
3284+
is_active: false,
3285+
meta: null,
3286+
score: 0,
3287+
tags: [],
3288+
},
3289+
],
3290+
},
3291+
isFetching: false,
3292+
refetch: vi.fn(),
3293+
});
3294+
useSelectionMock.mockReturnValue({
3295+
deleteSelection: vi.fn(),
3296+
isSelecting: true,
3297+
rowSelectionState: {
3298+
"row-2": true,
3299+
"row-1": true,
3300+
},
3301+
setRowSelectionState: vi.fn(),
3302+
});
3303+
gridColumnOrderState = [
3304+
"email",
3305+
"id",
3306+
"is_active",
3307+
"score",
3308+
"tags",
3309+
"meta",
3310+
"created_at",
3311+
];
3312+
3313+
const view = renderView();
3314+
const copyAsTrigger = findButtonByText("copy as");
3315+
3316+
expect(copyAsTrigger).toBeDefined();
3317+
3318+
dispatchPointerClick(copyAsTrigger);
3319+
await flush();
3320+
3321+
const copyJsonButton = findMenuItemByText("copy json");
3322+
3323+
expect(copyJsonButton).toBeDefined();
3324+
3325+
dispatchPointerClick(copyJsonButton);
3326+
3327+
expect(writeText).toHaveBeenCalledWith(
3328+
JSON.stringify(
3329+
[
3330+
{
3331+
email: "alice@example.com",
3332+
id: "user_1",
3333+
is_active: true,
3334+
score: 42,
3335+
tags: ["vip", "beta"],
3336+
meta: { owner: "alice" },
3337+
created_at: "2026-03-11T00:00:00.000Z",
3338+
},
3339+
{
3340+
email: "bob@example.com",
3341+
id: "user_2",
3342+
is_active: false,
3343+
score: 0,
3344+
tags: [],
3345+
meta: null,
3346+
created_at: "2026-03-12T00:00:00.000Z",
3347+
},
3348+
],
3349+
null,
3350+
2,
3351+
),
3352+
);
3353+
3354+
view.cleanup();
3355+
});
3356+
3357+
it("copies a single selected row as one json object", async () => {
3358+
const writeText = vi.fn().mockResolvedValue(undefined);
3359+
const activeTable = {
3360+
columns: {
3361+
created_at: {
3362+
...createColumn({
3363+
datatypeName: "timestamptz",
3364+
group: "datetime",
3365+
name: "created_at",
3366+
}),
3367+
datatype: {
3368+
format: "YYYY-MM-DD HH:mm:ss.SSSZZ",
3369+
group: "datetime",
3370+
isArray: false,
3371+
isNative: true,
3372+
name: "timestamptz",
3373+
options: [],
3374+
schema: "pg_catalog",
3375+
},
3376+
},
3377+
email: createColumn({
3378+
datatypeName: "character varying(64)",
3379+
group: "string",
3380+
name: "email",
3381+
}),
3382+
id: createColumn({
3383+
datatypeName: "uuid",
3384+
group: "string",
3385+
name: "id",
3386+
pkPosition: 1,
3387+
}),
3388+
is_active: createColumn({
3389+
datatypeName: "boolean",
3390+
group: "string",
3391+
name: "is_active",
3392+
}),
3393+
},
3394+
name: "users",
3395+
schema: "public",
3396+
};
3397+
3398+
Object.defineProperty(navigator, "clipboard", {
3399+
configurable: true,
3400+
value: {
3401+
writeText,
3402+
},
3403+
});
3404+
useNavigationMock.mockImplementation(() => ({
3405+
createUrl: vi.fn(() => "#"),
3406+
metadata: {
3407+
activeTable,
3408+
},
3409+
searchParam: navigationSearchParam,
3410+
setPageIndexParam: setPageIndexParamMock,
3411+
setSearchParam: setSearchParamMock,
3412+
}));
3413+
useIntrospectionMock.mockReturnValue({
3414+
data: {
3415+
filterOperators: [
3416+
"=",
3417+
"!=",
3418+
">",
3419+
">=",
3420+
"<",
3421+
"<=",
3422+
"is",
3423+
"is not",
3424+
"like",
3425+
"not like",
3426+
"ilike",
3427+
"not ilike",
3428+
],
3429+
query: {
3430+
parameters: [],
3431+
sql: "",
3432+
},
3433+
schemas: {
3434+
public: {
3435+
name: "public",
3436+
tables: {
3437+
users: activeTable,
3438+
},
3439+
},
3440+
},
3441+
timezone: "UTC",
3442+
},
3443+
refetch: vi.fn(),
3444+
});
3445+
3446+
useActiveTableQueryMock.mockReturnValue({
3447+
data: {
3448+
filteredRowCount: 2,
3449+
rows: [
3450+
{
3451+
__ps_rowid: "row-1",
3452+
created_at: "2026-03-11T00:00:00.000Z",
3453+
email: "alice@example.com",
3454+
id: "user_1",
3455+
is_active: true,
3456+
},
3457+
{
3458+
__ps_rowid: "row-2",
3459+
created_at: "2026-03-12T00:00:00.000Z",
3460+
email: "bob@example.com",
3461+
id: "user_2",
3462+
is_active: false,
3463+
},
3464+
],
3465+
},
3466+
isFetching: false,
3467+
refetch: vi.fn(),
3468+
});
3469+
useSelectionMock.mockReturnValue({
3470+
deleteSelection: vi.fn(),
3471+
isSelecting: true,
3472+
rowSelectionState: {
3473+
"row-2": true,
3474+
},
3475+
setRowSelectionState: vi.fn(),
3476+
});
3477+
gridColumnOrderState = ["email", "id", "is_active", "created_at"];
3478+
3479+
const view = renderView();
3480+
const copyAsTrigger = findButtonByText("copy as");
3481+
3482+
expect(copyAsTrigger).toBeDefined();
3483+
3484+
dispatchPointerClick(copyAsTrigger);
3485+
await flush();
3486+
3487+
const copyJsonButton = findMenuItemByText("copy json");
3488+
3489+
expect(copyJsonButton).toBeDefined();
3490+
3491+
dispatchPointerClick(copyJsonButton);
3492+
3493+
expect(writeText).toHaveBeenCalledWith(
3494+
JSON.stringify(
3495+
{
3496+
email: "bob@example.com",
3497+
id: "user_2",
3498+
is_active: false,
3499+
created_at: "2026-03-12T00:00:00.000Z",
3500+
},
3501+
null,
3502+
2,
3503+
),
3504+
);
3505+
3506+
view.cleanup();
3507+
});
3508+
31593509
it("saves markdown for all selected rows when header inclusion is turned off", async () => {
31603510
const createObjectURL = vi
31613511
.spyOn(URL, "createObjectURL")

0 commit comments

Comments
 (0)