From b52a0149b39de98f95d3da87a41dc3d86da8726c Mon Sep 17 00:00:00 2001 From: Keven Leone Date: Sat, 2 May 2026 23:12:46 -0300 Subject: [PATCH 1/7] fix: render checkboxes in row selection column fixes: #1504 The __ps_select column rendered empty cells with no visual state indicator. Render CheckboxTable inside header and cell with pointer-events-none so selection stays handled by DataGrid mousedown handlers. --- ui/studio/grid/test-utils.tsx | 23 +++++++++++++++++---- ui/studio/views/sql/SqlView.tsx | 25 +++++++++++++++++++---- ui/studio/views/table/ActiveTableView.tsx | 25 +++++++++++++++++++---- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/ui/studio/grid/test-utils.tsx b/ui/studio/grid/test-utils.tsx index a071bd51..cbad90d5 100644 --- a/ui/studio/grid/test-utils.tsx +++ b/ui/studio/grid/test-utils.tsx @@ -2,6 +2,7 @@ import type { ColumnDef } from "@tanstack/react-table"; import { act } from "react"; import { type Mock, vi } from "vitest"; +import { CheckboxTable } from "../../components/ui/checkbox-table"; import { TableHead } from "../../components/ui/table"; import { Cell, type CellProps } from "../cell/Cell"; @@ -58,14 +59,28 @@ export function createReadOnlyColumns(args?: { enableSorting: false, size: 35, minSize: 35, - header() { + header({ table }) { return (props: Omit) => ( - + +
+ +
+
); }, - cell() { + cell({ row }) { return (props: Omit) => ( - + +
+ +
+
); }, }; diff --git a/ui/studio/views/sql/SqlView.tsx b/ui/studio/views/sql/SqlView.tsx index 219f92df..020310fa 100644 --- a/ui/studio/views/sql/SqlView.tsx +++ b/ui/studio/views/sql/SqlView.tsx @@ -25,6 +25,7 @@ import { createSqlEditorSchemaFromIntrospection } from "../../../../data/sql-edi import { getTopLevelSqlStatementAtCursor } from "../../../../data/sql-statements"; import { Button } from "../../../components/ui/button"; import { Input } from "../../../components/ui/input"; +import { CheckboxTable } from "../../../components/ui/checkbox-table"; import { TableHead, TableRow } from "../../../components/ui/table"; import { useColumnPinning } from "../../../hooks/use-column-pinning"; import { useIntrospection } from "../../../hooks/use-introspection"; @@ -103,15 +104,31 @@ const SQL_ROW_SELECTION_COLUMN_DEF = { size: 35, minSize: 35, header({ table }) { - void table; return (props: Omit) => { - return ; + return ( + +
+ +
+
+ ); }; }, cell({ row }) { - void row; return (props: Omit) => { - return ; + return ( + +
+ +
+
+ ); }; }, } satisfies ColumnDef>; diff --git a/ui/studio/views/table/ActiveTableView.tsx b/ui/studio/views/table/ActiveTableView.tsx index d9d83367..8141aea3 100644 --- a/ui/studio/views/table/ActiveTableView.tsx +++ b/ui/studio/views/table/ActiveTableView.tsx @@ -39,6 +39,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; +import { CheckboxTable } from "../../../components/ui/checkbox-table"; import { TableHead } from "../../../components/ui/table"; import { useActiveTableInsert } from "../../../hooks/use-active-table-insert"; import { useActiveTableQuery } from "../../../hooks/use-active-table-query"; @@ -1546,15 +1547,31 @@ export function ActiveTableView(_props: ViewProps) { size: 35, minSize: 35, header({ table }) { - void table; return (props: Omit) => { - return ; + return ( + +
+ +
+
+ ); }; }, cell({ row }) { - void row; return (props: Omit) => { - return ; + return ( + +
+ +
+
+ ); }; }, }, From 71ba3d75e5261532045e063ac9f86fcf6803928b Mon Sep 17 00:00:00 2001 From: Keven Leone Date: Sat, 2 May 2026 23:29:02 -0300 Subject: [PATCH 2/7] fix: improve row selection handling and clean up code, fixes: #1504 --- ui/studio/grid/DataGrid.tsx | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/ui/studio/grid/DataGrid.tsx b/ui/studio/grid/DataGrid.tsx index cd8a362c..fd1995dc 100644 --- a/ui/studio/grid/DataGrid.tsx +++ b/ui/studio/grid/DataGrid.tsx @@ -947,7 +947,7 @@ export function DataGrid(props: DataGridProps) { }); const shouldEnablePreview = Boolean( dragDropTarget.compatibleOverId && - dragDropTarget.compatibleOverId !== activeId, + dragDropTarget.compatibleOverId !== activeId, ); setIsColumnReorderPreviewEnabled((current) => current === shouldEnablePreview ? current : shouldEnablePreview, @@ -1552,8 +1552,8 @@ export function DataGrid(props: DataGridProps) { const text = hasRowSelection ? getSelectedRowClipboardText() : hasCellSelection - ? getSelectedClipboardText() - : getFocusedCellClipboardText(); + ? getSelectedClipboardText() + : getFocusedCellClipboardText(); if (!text) { return; @@ -2140,12 +2140,26 @@ export function DataGrid(props: DataGridProps) { return; } - selectSingleRowMode({ rowId, rowIndex, drag: isPrimaryButton }); + clearNativeTextSelection(); + clearCellSelectionState(); + + const nextSelection = { ...rowSelectionState }; + + if (nextSelection[rowId]) { + delete nextSelection[rowId]; + } else { + nextSelection[rowId] = true; + } + + setRowSelection(nextSelection); + + rowSelectionDragRef.current = isPrimaryButton; + rowSelectionAnchorRef.current = rowIndex; }, [ clearCellSelectionState, + clearNativeTextSelection, rowSelectionState, - selectSingleRowMode, setRowSelection, ], ); From a36fea93091dd095c1f5d9b2e0b70506819c0cc9 Mon Sep 17 00:00:00 2001 From: Keven Leone Date: Tue, 28 Jul 2026 12:19:26 -0300 Subject: [PATCH 3/7] fix: preserve existing row selection when drag-selecting a range Merge the drag range into the selection captured at drag start instead of replacing it, and stop arming the drag ref on a deselecting click so a stray mouse movement can't re-select the row. --- ui/studio/grid/DataGrid.tsx | 59 +++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/ui/studio/grid/DataGrid.tsx b/ui/studio/grid/DataGrid.tsx index fd1995dc..a4be556e 100644 --- a/ui/studio/grid/DataGrid.tsx +++ b/ui/studio/grid/DataGrid.tsx @@ -687,6 +687,7 @@ export function DataGrid(props: DataGridProps) { } | null>(null); const rowSelectionAnchorRef = useRef(null); const rowSelectionDragRef = useRef(false); + const rowSelectionDragBaseRef = useRef({}); const previousSelectionScopeKeyRef = useRef( selectionScopeKey, ); @@ -947,7 +948,7 @@ export function DataGrid(props: DataGridProps) { }); const shouldEnablePreview = Boolean( dragDropTarget.compatibleOverId && - dragDropTarget.compatibleOverId !== activeId, + dragDropTarget.compatibleOverId !== activeId, ); setIsColumnReorderPreviewEnabled((current) => current === shouldEnablePreview ? current : shouldEnablePreview, @@ -1470,6 +1471,7 @@ export function DataGrid(props: DataGridProps) { const handleMouseUp = (event: MouseEvent) => { rowSelectionDragRef.current = false; rowSelectionAnchorRef.current = null; + rowSelectionDragBaseRef.current = {}; const pointerSelection = pointerSelectionRef.current; @@ -1552,8 +1554,8 @@ export function DataGrid(props: DataGridProps) { const text = hasRowSelection ? getSelectedRowClipboardText() : hasCellSelection - ? getSelectedClipboardText() - : getFocusedCellClipboardText(); + ? getSelectedClipboardText() + : getFocusedCellClipboardText(); if (!text) { return; @@ -1660,6 +1662,7 @@ export function DataGrid(props: DataGridProps) { const resetRowSelectionInteractionState = useCallback(() => { rowSelectionAnchorRef.current = null; rowSelectionDragRef.current = false; + rowSelectionDragBaseRef.current = {}; }, []); const resetSelectionInteractionState = useCallback(() => { @@ -2029,7 +2032,9 @@ export function DataGrid(props: DataGridProps) { (fromRowIndex: number, toRowIndex: number) => { const start = Math.min(fromRowIndex, toRowIndex); const end = Math.max(fromRowIndex, toRowIndex); - const nextSelection: RowSelectionState = {}; + const nextSelection: RowSelectionState = { + ...rowSelectionDragBaseRef.current, + }; const rowModel = table.getRowModel().rows; for (let index = start; index <= end; index++) { @@ -2118,48 +2123,38 @@ export function DataGrid(props: DataGridProps) { return; } - if (isPrimaryButton) { - event.preventDefault(); - } + event.preventDefault(); event.stopPropagation(); - - if (event.shiftKey) { - clearNativeTextSelection(); - clearCellSelectionState(); - const nextSelection = { ...rowSelectionState }; - - if (nextSelection[rowId] === true) { - delete nextSelection[rowId]; - } else { - nextSelection[rowId] = true; - } - - setRowSelection(nextSelection); - rowSelectionDragRef.current = false; - rowSelectionAnchorRef.current = rowIndex; - return; - } - clearNativeTextSelection(); clearCellSelectionState(); - const nextSelection = { ...rowSelectionState }; + const previousSelection = rowSelectionState; + const isSelecting = previousSelection[rowId] !== true; + const nextSelection = { ...previousSelection }; - if (nextSelection[rowId]) { - delete nextSelection[rowId]; - } else { + if (isSelecting) { nextSelection[rowId] = true; + } else { + delete nextSelection[rowId]; } setRowSelection(nextSelection); - - rowSelectionDragRef.current = isPrimaryButton; rowSelectionAnchorRef.current = rowIndex; + + // Shift+click toggles a single row without starting a drag-range + // selection; deselecting a row also skips arming the drag so a tiny + // mouse movement afterward doesn't re-select it via mouseenter. + if (isSelecting && !event.shiftKey) { + rowSelectionDragBaseRef.current = previousSelection; + rowSelectionDragRef.current = true; + } else { + rowSelectionDragRef.current = false; + } }, [ clearCellSelectionState, - clearNativeTextSelection, rowSelectionState, + selectSingleRowMode, setRowSelection, ], ); From 46f7f25aab95c1c0df330f77aed81bc897fd56ef Mon Sep 17 00:00:00 2001 From: Keven Leone Date: Tue, 28 Jul 2026 12:19:33 -0300 Subject: [PATCH 4/7] refactor: make SelectHeaderCell/SelectRowCell presentation-only Drop the unused interactive onCheckedChange/disabled props and show an indeterminate state on partial selection, matching the pointer-events-none architecture DataGrid's own mousedown handlers rely on. --- ui/studio/cell/SelectHeaderCell.tsx | 21 +++++++++++++-------- ui/studio/cell/SelectRowCell.tsx | 15 +++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/ui/studio/cell/SelectHeaderCell.tsx b/ui/studio/cell/SelectHeaderCell.tsx index 7c47df2b..c2879543 100644 --- a/ui/studio/cell/SelectHeaderCell.tsx +++ b/ui/studio/cell/SelectHeaderCell.tsx @@ -4,18 +4,23 @@ import { CheckboxTable } from "@/ui/components/ui/checkbox-table"; export interface SelectHeaderCellProps { table: Table>; - readonly: boolean; } export function SelectHeaderCell(props: SelectHeaderCellProps) { - const { table, readonly } = props; + const { table } = props; return ( - table.toggleAllRowsSelected(!!value)} - disabled={readonly} - className="w-full h-full rounded-none border-none" - /> +
+ +
); } diff --git a/ui/studio/cell/SelectRowCell.tsx b/ui/studio/cell/SelectRowCell.tsx index 9a4beb37..46fdc4e2 100644 --- a/ui/studio/cell/SelectRowCell.tsx +++ b/ui/studio/cell/SelectRowCell.tsx @@ -4,18 +4,17 @@ import { CheckboxTable } from "@/ui/components/ui/checkbox-table"; export interface SelectRowCellProps { row: Row>; - readonly: boolean; } export function SelectRowCell(props: SelectRowCellProps) { - const { row, readonly } = props; + const { row } = props; return ( - +
+ +
); } From aeec873c908f79b2d75e83e5b45863fe3e9fef86 Mon Sep 17 00:00:00 2001 From: Keven Leone Date: Tue, 28 Jul 2026 12:19:38 -0300 Subject: [PATCH 5/7] refactor: reuse SelectHeaderCell/SelectRowCell instead of duplicating JSX Replace the inline checkbox markup in test-utils, SqlView, and ActiveTableView with the shared components. --- ui/studio/grid/test-utils.tsx | 17 ++++------------- ui/studio/views/sql/SqlView.tsx | 17 ++++------------- ui/studio/views/table/ActiveTableView.tsx | 17 ++++------------- 3 files changed, 12 insertions(+), 39 deletions(-) diff --git a/ui/studio/grid/test-utils.tsx b/ui/studio/grid/test-utils.tsx index cbad90d5..561d4b69 100644 --- a/ui/studio/grid/test-utils.tsx +++ b/ui/studio/grid/test-utils.tsx @@ -2,9 +2,10 @@ import type { ColumnDef } from "@tanstack/react-table"; import { act } from "react"; import { type Mock, vi } from "vitest"; -import { CheckboxTable } from "../../components/ui/checkbox-table"; import { TableHead } from "../../components/ui/table"; import { Cell, type CellProps } from "../cell/Cell"; +import { SelectHeaderCell } from "../cell/SelectHeaderCell"; +import { SelectRowCell } from "../cell/SelectRowCell"; export type GridRow = Record; export type GridColumnDef = ColumnDef; @@ -62,24 +63,14 @@ export function createReadOnlyColumns(args?: { header({ table }) { return (props: Omit) => ( -
- -
+
); }, cell({ row }) { return (props: Omit) => ( -
- -
+
); }, diff --git a/ui/studio/views/sql/SqlView.tsx b/ui/studio/views/sql/SqlView.tsx index 020310fa..4f497c3f 100644 --- a/ui/studio/views/sql/SqlView.tsx +++ b/ui/studio/views/sql/SqlView.tsx @@ -25,7 +25,6 @@ import { createSqlEditorSchemaFromIntrospection } from "../../../../data/sql-edi import { getTopLevelSqlStatementAtCursor } from "../../../../data/sql-statements"; import { Button } from "../../../components/ui/button"; import { Input } from "../../../components/ui/input"; -import { CheckboxTable } from "../../../components/ui/checkbox-table"; import { TableHead, TableRow } from "../../../components/ui/table"; import { useColumnPinning } from "../../../hooks/use-column-pinning"; import { useIntrospection } from "../../../hooks/use-introspection"; @@ -33,6 +32,8 @@ import { useNavigation } from "../../../hooks/use-navigation"; import type { CellProps } from "../../cell/Cell"; import { Cell } from "../../cell/Cell"; import { getCell } from "../../cell/get-cell"; +import { SelectHeaderCell } from "../../cell/SelectHeaderCell"; +import { SelectRowCell } from "../../cell/SelectRowCell"; import { useStudio } from "../../context"; import { DataGrid, type DataGridProps } from "../../grid/DataGrid"; import { DataGridDraggableHeaderCell } from "../../grid/DataGridDraggableHeaderCell"; @@ -107,12 +108,7 @@ const SQL_ROW_SELECTION_COLUMN_DEF = { return (props: Omit) => { return ( -
- -
+
); }; @@ -121,12 +117,7 @@ const SQL_ROW_SELECTION_COLUMN_DEF = { return (props: Omit) => { return ( -
- -
+
); }; diff --git a/ui/studio/views/table/ActiveTableView.tsx b/ui/studio/views/table/ActiveTableView.tsx index 8141aea3..fb8348b5 100644 --- a/ui/studio/views/table/ActiveTableView.tsx +++ b/ui/studio/views/table/ActiveTableView.tsx @@ -39,7 +39,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; -import { CheckboxTable } from "../../../components/ui/checkbox-table"; import { TableHead } from "../../../components/ui/table"; import { useActiveTableInsert } from "../../../hooks/use-active-table-insert"; import { useActiveTableQuery } from "../../../hooks/use-active-table-query"; @@ -66,6 +65,8 @@ import { } from "../../cell/Cell"; import { getCell } from "../../cell/get-cell"; import { Link, RelationLink } from "../../cell/Link"; +import { SelectHeaderCell } from "../../cell/SelectHeaderCell"; +import { SelectRowCell } from "../../cell/SelectRowCell"; import { WriteableCell } from "../../cell/WriteableCell"; import { useRegisterCommandPaletteActions } from "../../CommandPalette"; import { type TableUiState, useStudio } from "../../context"; @@ -1550,12 +1551,7 @@ export function ActiveTableView(_props: ViewProps) { return (props: Omit) => { return ( -
- -
+
); }; @@ -1564,12 +1560,7 @@ export function ActiveTableView(_props: ViewProps) { return (props: Omit) => { return ( -
- -
+
); }; From 542b488441f1192659b50b26c0d6e04dccd4d602 Mon Sep 17 00:00:00 2001 From: Keven Leone Date: Tue, 28 Jul 2026 12:19:46 -0300 Subject: [PATCH 6/7] test: add regression coverage for row selection fixes Cover checkbox rendering/indeterminate state, drag-select preserving an existing selection, and deselect-click no longer arming the drag ref. --- ui/studio/grid/DataGrid.interactions.test.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/ui/studio/grid/DataGrid.interactions.test.tsx b/ui/studio/grid/DataGrid.interactions.test.tsx index 18d380b2..5a810170 100644 --- a/ui/studio/grid/DataGrid.interactions.test.tsx +++ b/ui/studio/grid/DataGrid.interactions.test.tsx @@ -1095,6 +1095,105 @@ describe("DataGrid interactions", () => { cleanup(); }); + it("renders a checkbox in the row selection column that reflects selection state", () => { + createSelection({ isCollapsed: true }); + + const { cleanup, getCell } = renderGrid({ + columnDefs: createReadOnlyColumns({ includeRowSelector: true }), + }); + + const checkbox = getCell(0, "__ps_select").querySelector( + '[role="checkbox"]', + ); + + expect(checkbox).not.toBeNull(); + expect(checkbox?.getAttribute("data-state")).toBe("unchecked"); + + dispatchMouse(getCell(0, "__ps_select"), "mousedown", { button: 0 }); + dispatchMouse(window, "mouseup"); + + expect( + getCell(0, "__ps_select") + .querySelector('[role="checkbox"]') + ?.getAttribute("data-state"), + ).toBe("checked"); + + cleanup(); + }); + + it("shows an indeterminate header checkbox when only some rows are selected", () => { + createSelection({ isCollapsed: true }); + + const { cleanup, container, getCell } = renderGrid({ + columnDefs: createReadOnlyColumns({ includeRowSelector: true }), + }); + + dispatchMouse(getCell(0, "__ps_select"), "mousedown", { button: 0 }); + dispatchMouse(window, "mouseup"); + + const headerCheckbox = container.querySelector( + 'th[aria-label="Row selection spacer"] [role="checkbox"]', + ); + + expect(headerCheckbox?.getAttribute("data-state")).toBe("indeterminate"); + + cleanup(); + }); + + it("preserves existing row selection when drag-selecting a different range", () => { + createSelection({ isCollapsed: true }); + + const { cleanup, getCell, getSelectedRowCount } = renderGrid({ + columnDefs: createReadOnlyColumns({ includeRowSelector: true }), + }); + + dispatchMouse(getCell(0, "__ps_select"), "mousedown", { button: 0 }); + dispatchMouse(window, "mouseup"); + + expect(getSelectedRowCount()).toBe(1); + + dispatchMouse(getCell(2, "__ps_select"), "mousedown", { button: 0 }); + dispatchMouse(getCell(1, "__ps_select"), "mouseover", { button: 0 }); + dispatchMouse(window, "mouseup"); + + expect(getSelectedRowCount()).toBe(3); + expect(getCell(0, "__ps_select").closest("tr")?.dataset.rowSelected).toBe( + "true", + ); + expect(getCell(1, "__ps_select").closest("tr")?.dataset.rowSelected).toBe( + "true", + ); + expect(getCell(2, "__ps_select").closest("tr")?.dataset.rowSelected).toBe( + "true", + ); + + cleanup(); + }); + + it("does not re-select a row from a stray mouse movement after a deselecting click", () => { + createSelection({ isCollapsed: true }); + + const { cleanup, getCell, getSelectedRowCount } = renderGrid({ + columnDefs: createReadOnlyColumns({ includeRowSelector: true }), + }); + + dispatchMouse(getCell(0, "__ps_select"), "mousedown", { button: 0 }); + dispatchMouse(window, "mouseup"); + + expect(getSelectedRowCount()).toBe(1); + + dispatchMouse(getCell(0, "__ps_select"), "mousedown", { button: 0 }); + dispatchMouse(getCell(1, "__ps_select"), "mouseover", { button: 0 }); + dispatchMouse(window, "mouseup"); + + expect(getSelectedRowCount()).toBe(0); + expect( + getCell(1, "__ps_select").closest("tr")?.dataset.rowSelected, + ).toBeUndefined(); + + cleanup(); + }); + it("selects all rows when clicking the top-left spacer header cell", () => { createSelection({ isCollapsed: true }); From a91d35f4dde5788c63e2211b33ae7df903d30c3b Mon Sep 17 00:00:00 2001 From: Keven Leone Date: Tue, 28 Jul 2026 12:19:51 -0300 Subject: [PATCH 7/7] chore: add changeset for row selection fixes --- .changeset/checkbox-row-selection.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/checkbox-row-selection.md diff --git a/.changeset/checkbox-row-selection.md b/.changeset/checkbox-row-selection.md new file mode 100644 index 00000000..db3a842d --- /dev/null +++ b/.changeset/checkbox-row-selection.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": patch +--- + +Fix row-selection checkboxes rendering as empty cells with no visual state indicator, and fix drag-selecting a range of rows silently dropping previously selected rows outside that range.