feat(admin): shift-select multiple sessions in process list
This commit is contained in:
8 files changed
+144
-9
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { processListSelectionAfterClick, type ProcessListSelectionState } from "@/lib/database/processListSelection";
|
||||
|
||||
const rows = [{ id: 11 }, { id: 12 }, { id: 13 }, { id: 14 }, { id: 15 }];
|
||||
const plain = { shiftKey: false, metaKey: false, ctrlKey: false };
|
||||
const shift = { shiftKey: true, metaKey: false, ctrlKey: false };
|
||||
const ctrl = { shiftKey: false, metaKey: false, ctrlKey: true };
|
||||
|
||||
function state(selected: number[], anchorId: number | null): ProcessListSelectionState {
|
||||
return { selected: new Set(selected), anchorId };
|
||||
}
|
||||
|
||||
describe("processListSelectionAfterClick", () => {
|
||||
it("toggles one row and moves the anchor on a plain click", () => {
|
||||
expect(processListSelectionAfterClick(rows, state([], null), 12, plain)).toEqual({ selected: new Set([12]), anchorId: 12 });
|
||||
expect(processListSelectionAfterClick(rows, state([12, 14], 12), 14, plain)).toEqual({ selected: new Set([12]), anchorId: 14 });
|
||||
});
|
||||
|
||||
it("selects the whole range on Shift+click in either direction", () => {
|
||||
expect(processListSelectionAfterClick(rows, state([12], 12), 15, shift)).toEqual({ selected: new Set([12, 13, 14, 15]), anchorId: 12 });
|
||||
expect(processListSelectionAfterClick(rows, state([15], 15), 12, shift)).toEqual({ selected: new Set([12, 13, 14, 15]), anchorId: 15 });
|
||||
});
|
||||
|
||||
it("deselects the range when the Shift+clicked row was already selected", () => {
|
||||
expect(processListSelectionAfterClick(rows, state([12, 13, 14, 15], 12), 14, shift)).toEqual({ selected: new Set([15]), anchorId: 12 });
|
||||
});
|
||||
|
||||
it("keeps the anchor so consecutive Shift+clicks re-extend from the same row", () => {
|
||||
const first = processListSelectionAfterClick(rows, state([12], 12), 14, shift);
|
||||
expect(processListSelectionAfterClick(rows, first, 15, shift)).toEqual({ selected: new Set([12, 13, 14, 15]), anchorId: 12 });
|
||||
});
|
||||
|
||||
it("keeps rows outside the range untouched", () => {
|
||||
expect(processListSelectionAfterClick(rows, state([11, 12, 15], 12), 13, shift)).toEqual({ selected: new Set([11, 12, 13, 15]), anchorId: 12 });
|
||||
});
|
||||
|
||||
it("treats Cmd/Ctrl as a single toggle", () => {
|
||||
expect(processListSelectionAfterClick(rows, state([12], 12), 14, ctrl)).toEqual({ selected: new Set([12, 14]), anchorId: 14 });
|
||||
});
|
||||
|
||||
it("falls back to a plain toggle when the Shift anchor is gone", () => {
|
||||
expect(processListSelectionAfterClick(rows, state([12], 99), 13, shift)).toEqual({ selected: new Set([12, 13]), anchorId: 13 });
|
||||
expect(processListSelectionAfterClick(rows, state([12], null), 13, shift)).toEqual({ selected: new Set([12, 13]), anchorId: 13 });
|
||||
});
|
||||
|
||||
it("ignores ids that are not selectable in the current list", () => {
|
||||
const current = state([12], 12);
|
||||
expect(processListSelectionAfterClick(rows, current, 99, plain)).toBe(current);
|
||||
expect(processListSelectionAfterClick(rows, current, 99, shift)).toBe(current);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
|
||||
import { orderedListRangeAnchorIndex, orderedListRangeIndices, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
|
||||
|
||||
describe("orderedListSelectionIntent", () => {
|
||||
it("uses Shift for range selection even when another modifier is also reported", () => {
|
||||
@@ -38,3 +38,21 @@ describe("orderedListRangeAnchorIndex", () => {
|
||||
expect(orderedListRangeAnchorIndex(items, null, { type: "file", id: "missing" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderedListRangeIndices", () => {
|
||||
it("covers both directions inclusively", () => {
|
||||
expect(orderedListRangeIndices(5, 1, 3)).toEqual([1, 2, 3]);
|
||||
expect(orderedListRangeIndices(5, 3, 1)).toEqual([1, 2, 3]);
|
||||
expect(orderedListRangeIndices(5, 2, 2)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("clamps to the list bounds", () => {
|
||||
expect(orderedListRangeIndices(3, 0, 2)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty list or an out-of-range position", () => {
|
||||
expect(orderedListRangeIndices(0, 0, 0)).toEqual([]);
|
||||
expect(orderedListRangeIndices(3, -1, 1)).toEqual([]);
|
||||
expect(orderedListRangeIndices(3, 1, 3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { orderedListRangeIndices, orderedListSelectionIntent } from "../selection/orderedListSelection";
|
||||
|
||||
/** Rows are identified by the server session id, matching the process list grid. */
|
||||
export interface ProcessListSelectionRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface ProcessListSelectionState {
|
||||
selected: Set<number>;
|
||||
anchorId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a row checkbox click to the current selection.
|
||||
*
|
||||
* A plain click toggles the clicked row and moves the anchor; Shift+click applies
|
||||
* the clicked row's next state to every selectable row between the anchor and the
|
||||
* clicked row, so long session lists can be selected in one gesture. Rows outside
|
||||
* the range keep their current state, and an unknown/stale anchor degrades to a
|
||||
* plain toggle instead of selecting an unexpected range.
|
||||
*
|
||||
* The next state is derived from the current selection because the grid binds
|
||||
* `:checked` without cancelling the native checkbox activation.
|
||||
*/
|
||||
export function processListSelectionAfterClick(rows: ProcessListSelectionRow[], state: ProcessListSelectionState, id: number, event: Pick<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey">): ProcessListSelectionState {
|
||||
const targetIndex = rows.findIndex((row) => row.id === id);
|
||||
if (targetIndex < 0) return state;
|
||||
const checked = !state.selected.has(id);
|
||||
const anchorIndex = state.anchorId === null ? -1 : rows.findIndex((row) => row.id === state.anchorId);
|
||||
if (orderedListSelectionIntent(event) === "range" && anchorIndex >= 0) {
|
||||
const selected = new Set(state.selected);
|
||||
for (const index of orderedListRangeIndices(rows.length, anchorIndex, targetIndex)) {
|
||||
const row = rows[index];
|
||||
if (!row) continue;
|
||||
if (checked) selected.add(row.id);
|
||||
else selected.delete(row.id);
|
||||
}
|
||||
return { selected, anchorId: state.anchorId };
|
||||
}
|
||||
const selected = new Set(state.selected);
|
||||
if (checked) selected.add(id);
|
||||
else selected.delete(id);
|
||||
return { selected, anchorId: id };
|
||||
}
|
||||
@@ -17,3 +17,12 @@ export function orderedListRangeAnchorIndex(items: OrderedListSelectionItem[], a
|
||||
const index = items.findIndex((item) => item.type === activeItem.type && item.id === activeItem.id);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
/** Inclusive, clamped index range between two positions in an ordered list. */
|
||||
export function orderedListRangeIndices(length: number, anchorIndex: number, focusIndex: number): number[] {
|
||||
if (length <= 0) return [];
|
||||
if (anchorIndex < 0 || anchorIndex >= length || focusIndex < 0 || focusIndex >= length) return [];
|
||||
const start = Math.min(anchorIndex, focusIndex);
|
||||
const end = Math.max(anchorIndex, focusIndex);
|
||||
return Array.from({ length: end - start + 1 }, (_, offset) => start + offset);
|
||||
}
|
||||
Reference in new issue
Block a user