Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Shift+click range selection in data tables.** In selection mode you can now click one row's checkbox, then shift+click another to select every row in between (inclusive) in the order they're displayed.

### Fixed

- **Data table selection no longer desyncs when filtering.** Rows you select stay selected when you filter them out of view and then clear the filter — the checked boxes always match the selection used for bulk actions. The selection count also reads sensibly when a filter hides some of your selected rows (e.g. "5 selected (2 match filter)").

## [0.49.0] - 2026-06-01

### Added
Expand Down
1 change: 1 addition & 0 deletions frontend/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"columns": "Columns",
"tableOptions": "Table Options",
"rowsSelected": "{{selected}} of {{total}} row(s) selected",
"rowsSelectedFiltered": "{{selected}} selected ({{total}} match filter)",
"selectAll": "Select all",
"selectRow": "Select row",
"noResultsDot": "No results.",
Expand Down
1 change: 1 addition & 0 deletions frontend/public/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"columns": "Columnas",
"tableOptions": "Opciones de tabla",
"rowsSelected": "{{selected}} de {{total}} fila(s) seleccionada(s)",
"rowsSelectedFiltered": "{{selected}} seleccionada(s) ({{total}} coinciden con el filtro)",
"selectAll": "Seleccionar todo",
"selectRow": "Seleccionar fila",
"noResultsDot": "Sin resultados.",
Expand Down
1 change: 1 addition & 0 deletions frontend/public/locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"columns": "Colonnes",
"tableOptions": "Options du tableau",
"rowsSelected": "{{selected}} sur {{total}} ligne(s) sélectionnée(s)",
"rowsSelectedFiltered": "{{selected}} sélectionnée(s) ({{total}} correspondent au filtre)",
"selectAll": "Tout sélectionner",
"selectRow": "Sélectionner la ligne",
"noResultsDot": "Aucun résultat.",
Expand Down
162 changes: 162 additions & 0 deletions frontend/src/components/ui/data-table.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { ColumnDef } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";

import { DataTable } from "./data-table";

interface Row {
id: number;
name: string;
}

const columns: ColumnDef<Row>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => row.original.name,
},
];

const rows: Row[] = [
{ id: 1, name: "Alpha" },
{ id: 2, name: "Bravo" },
{ id: 3, name: "Charlie" },
{ id: 4, name: "Delta" },
{ id: 5, name: "Echo" },
];

/** Renders the table and exposes the latest reported selection via a data attr. */
function Harness({ data = rows }: { data?: Row[] }) {
const [selected, setSelected] = useState<Row[]>([]);
return (
<div>
<div data-testid="selection">{selected.map((r) => r.id).join(",")}</div>
<DataTable
columns={columns}
data={data}
enableRowSelection
enableFilterInput
getRowId={(row) => String(row.id)}
onRowSelectionChange={setSelected}
/>
</div>
);
}

const reported = () => screen.getByTestId("selection").textContent;

/** Enter selection mode and return the per-row selection checkboxes in DOM order. */
async function enterSelectionMode(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "Select" }));
return () => screen.getAllByRole("checkbox", { name: "Select row" });
}

describe("DataTable row selection", () => {
it("shift+clicking selects the inclusive range in displayed order", async () => {
const user = userEvent.setup();
render(<Harness />);
const getCheckboxes = await enterSelectionMode(user);

// Anchor on row 2 (Bravo)...
await user.click(getCheckboxes()[1]);
expect(reported()).toBe("2");

// ...then shift+click row 4 (Delta) → selects 2,3,4 inclusive.
await user.keyboard("{Shift>}");
await user.click(getCheckboxes()[3]);
await user.keyboard("{/Shift}");

expect(reported()).toBe("2,3,4");
});

it("shift+click after select-all does not range from a stale anchor", async () => {
const user = userEvent.setup();
render(<Harness />);
const getCheckboxes = await enterSelectionMode(user);

// Establish an anchor on row 1, then select-all + deselect-all. Select-all
// clears the anchor, leaving an empty selection and no anchor.
await user.click(getCheckboxes()[0]);
const selectAll = screen.getByRole("checkbox", { name: "Select all" });
await user.click(selectAll);
expect(reported()).toBe("1,2,3,4,5");
await user.click(selectAll);
expect(reported()).toBe("");

// A shift+click now behaves as a fresh single toggle. If the row-1 anchor had
// leaked, this would range-select 1..4; instead only row 4 is selected.
await user.keyboard("{Shift>}");
await user.click(getCheckboxes()[3]);
await user.keyboard("{/Shift}");

expect(reported()).toBe("4");
});

it("keeps selection consistent across filtering (filter out, select more, clear)", async () => {
const user = userEvent.setup();
render(<Harness />);
const getCheckboxes = await enterSelectionMode(user);

// Select Alpha + Bravo.
await user.click(getCheckboxes()[0]);
await user.click(getCheckboxes()[1]);
expect(reported()).toBe("1,2");

// Filter to hide the selected rows (show only Echo).
const filter = screen.getByPlaceholderText("Filter...");
await user.type(filter, "Echo");

// Only Echo is visible now; select it.
const visible = screen.getAllByRole("checkbox", { name: "Select row" });
expect(visible).toHaveLength(1);
await user.click(visible[0]);

// Selection persists across the filter: all three reported, not just Echo.
expect(reported()).toBe("1,2,5");

// Clear the filter → checkboxes and reported selection stay in sync.
await user.clear(filter);
const all = screen.getAllByRole("checkbox", { name: "Select row" });
const checkedIds = rows
.filter((_, i) => (all[i] as HTMLElement).getAttribute("data-state") === "checked")
.map((r) => r.id);
expect(checkedIds).toEqual([1, 2, 5]);
expect(reported()).toBe("1,2,5");
});

it("shows a filter-aware count when selected rows are hidden by the filter", async () => {
const user = userEvent.setup();
render(<Harness />);
const getCheckboxes = await enterSelectionMode(user);

await user.click(getCheckboxes()[0]);
await user.click(getCheckboxes()[1]);

const filter = screen.getByPlaceholderText("Filter...");
await user.type(filter, "Echo");

// 2 selected, both hidden, 1 row matches the filter → filtered-variant message.
expect(screen.getByText("2 selected (1 match filter)")).toBeInTheDocument();
});

it("uses the filter-aware count even when selected <= filtered total", async () => {
const user = userEvent.setup();
render(<Harness />);
const getCheckboxes = await enterSelectionMode(user);

// Select Alpha + Bravo (2 selected).
await user.click(getCheckboxes()[0]);
await user.click(getCheckboxes()[1]);

// Filter to show Charlie/Delta/Echo (3 visible) — none of them are selected.
// selected (2) <= filteredTotal (3), but both selected rows are hidden, so the
// plain "2 of 3 selected" would be misleading. Expect the filtered variant.
const filter = screen.getByPlaceholderText("Filter...");
await user.type(filter, "e");

expect(screen.getByText("2 selected (3 match filter)")).toBeInTheDocument();
expect(screen.queryByText("2 of 3 row(s) selected")).not.toBeInTheDocument();
});
});
123 changes: 101 additions & 22 deletions frontend/src/components/ui/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ export function DataTable<TData, TValue>({

const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [selectionModeActive, setSelectionModeActive] = useState(false);
// Anchor for shift+click range selection: the id of the last row toggled
// individually. shiftKeyRef captures the modifier from the checkbox's onClick
// (Radix's onCheckedChange doesn't expose the mouse event). rowCheckboxHandlerRef
// lets the selection column (built before `table` exists) call into a handler
// that closes over `table` without adding `table` to its memo deps.
const lastSelectedRowIdRef = useRef<string | null>(null);
const shiftKeyRef = useRef(false);
const rowCheckboxHandlerRef = useRef<(rowId: string, value: boolean) => void>(() => {});
const groupingSelectId = useId();
const computedInitialState: Partial<TableState> = {
sorting: initialSortingRef.current,
Expand Down Expand Up @@ -266,14 +274,22 @@ export function DataTable<TData, TValue>({
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
onCheckedChange={(value) => {
// Select-all resets the range anchor so a subsequent shift+click
// starts fresh rather than ranging from a stale individual click.
lastSelectedRowIdRef.current = null;
table.toggleAllPageRowsSelected(!!value);
}}
aria-label={t("selectAll")}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
shiftKeyRef.current = e.shiftKey;
}}
onCheckedChange={(value) => rowCheckboxHandlerRef.current(row.id, !!value)}
aria-label={t("selectRow")}
/>
),
Expand Down Expand Up @@ -462,22 +478,74 @@ export function DataTable<TData, TValue>({
});
}, [groupingEnabled, grouping, groupingColumnIdSet]);

// Handle a row-selection checkbox toggle, applying a shift+click range when an
// anchor exists. The range spans the currently displayed (sorted/filtered/
// paginated) rows between the anchor and the clicked row, inclusive, and only
// ever selects (never deselects) — the standard anchor range-select behavior.
// Limitation: under pagination the range is scoped to the current page, so a
// shift+click whose anchor lives on another page falls back to a single toggle.
rowCheckboxHandlerRef.current = (rowId: string, value: boolean) => {
const isShift = shiftKeyRef.current;
shiftKeyRef.current = false;
const anchorId = lastSelectedRowIdRef.current;
if (isShift && value && anchorId && anchorId !== rowId) {
const visibleRows = table.getRowModel().rows;
const from = visibleRows.findIndex((r) => r.id === anchorId);
const to = visibleRows.findIndex((r) => r.id === rowId);
if (from !== -1 && to !== -1) {
const [lo, hi] = from < to ? [from, to] : [to, from];
table.setRowSelection((prev) => {
const next = { ...prev };
for (let i = lo; i <= hi; i++) {
const row = visibleRows[i];
if (!row.getIsGrouped()) {
next[row.id] = true;
}
}
return next;
});
lastSelectedRowIdRef.current = rowId;
return;
}
}
table.getRow(rowId).toggleSelected(value);
lastSelectedRowIdRef.current = rowId;
};

useEffect(() => {
if (enableRowSelection && selectionModeActive && onRowSelectionChange) {
const selectedRows = table.getFilteredSelectedRowModel().rows.map((row) => row.original);
// Report ALL selected rows (not just filter-visible ones) so the reported
// selection always matches the checked checkboxes — selections persist
// across filtering. columnFilters is a dep so reported `.original`
// references stay fresh when the row model rebuilds on a filter change.
// Limitation: under manualPagination only the current page is in `data`,
// so selections on other pages remain in rowSelection (by id) but can't be
// reported as row objects; cross-page selection is out of scope.
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
onRowSelectionChange(selectedRows);
}
}, [rowSelection, enableRowSelection, selectionModeActive, onRowSelectionChange, table]);
}, [
rowSelection,
columnFilters,
enableRowSelection,
selectionModeActive,
onRowSelectionChange,
table,
]);

useEffect(() => {
if (!selectionModeActive && Object.keys(rowSelection).length > 0) {
setRowSelection({});
lastSelectedRowIdRef.current = null;
shiftKeyRef.current = false;
}
}, [selectionModeActive, rowSelection]);

const handleExitSelection = useCallback(() => {
setSelectionModeActive(false);
setRowSelection({});
lastSelectedRowIdRef.current = null;
shiftKeyRef.current = false;
if (onExitSelection) {
onExitSelection();
}
Expand All @@ -498,16 +566,16 @@ export function DataTable<TData, TValue>({
// When virtualization is enabled, pagination is disabled (mutually exclusive)
const showPagination = enablePagination && !enableVirtualization;

// Stable key that changes when visible columns change (selection mode toggle, column dropdown).
// Used by MemoizedVirtualCells to know when to re-render.
const visibleColumnKey = useMemo(
() =>
table
.getVisibleLeafColumns()
.map((c) => c.id)
.join(","),
[table.getVisibleLeafColumns]
);
// Key that changes when the visible columns change (selection mode toggle,
// column visibility dropdown). MemoizedVirtualCells compares it by string value
// to decide when to re-render, so already-rendered virtual rows pick up new
// columns without waiting to be scrolled out and back in. Computed inline (not
// memoized) so it always reflects the current visible columns — value equality
// means recomputing an identical string still won't trigger cell re-renders.
const visibleColumnKey = table
.getVisibleLeafColumns()
.map((c) => c.id)
.join(",");

// Padding-based virtualization: spacer rows keep scroll height correct
// while visible rows render in normal table flow for proper column alignment.
Expand All @@ -523,14 +591,25 @@ export function DataTable<TData, TValue>({
{helpText && typeof helpText === "function" ? helpText(table) : helpText}
{enableRowSelection &&
selectionModeActive &&
table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="text-muted-foreground text-sm">
{t("rowsSelected", {
selected: table.getFilteredSelectedRowModel().rows.length,
total: table.getFilteredRowModel().rows.length,
})}
</div>
)}
table.getSelectedRowModel().rows.length > 0 &&
(() => {
const selected = table.getSelectedRowModel().rows.length;
const filteredTotal = table.getFilteredRowModel().rows.length;
const filteredSelected = table.getFilteredSelectedRowModel().rows.length;
// When a filter hides any selected row, "X of Y selected" is misleading
// because X (all selected rows) includes ones not visible in the filtered
// view — e.g. it would read "2 of 3 selected" when none of the 3 visible
// rows are checked. Switch to the filter-aware variant whenever the filter
// hides at least one selected row, not just when selected > filteredTotal.
const showFilteredVariant = columnFilters.length > 0 && filteredSelected < selected;
return (
<div className="text-muted-foreground text-sm">
{showFilteredVariant
? t("rowsSelectedFiltered", { selected, total: filteredTotal })
: t("rowsSelected", { selected, total: filteredTotal })}
</div>
);
Comment thread
jordandrako marked this conversation as resolved.
})()}
<div
className={cn(
"overflow-hidden rounded-md border",
Expand Down