feat: add shift range table selection (#20)

* add shift range table selection

* format keyword suggestion selection

* credit original draft author

Co-authored-by: Granata005 <granata005@gmail.com>

---------

Co-authored-by: Granata005 <granata005@gmail.com>
This commit is contained in:
Ben Senescu 2026-05-06 10:46:21 -04:00 committed by GitHub
parent 89091ca9d5
commit 6b7d0464f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 274 additions and 51 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
useReactTable, useReactTable,
@ -15,6 +15,10 @@ import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { SortableHeader } from "./RankTrackingColumns"; import { SortableHeader } from "./RankTrackingColumns";
import {
applyShiftRangeSelection,
type SelectionAnchor,
} from "./tableSelection";
type SuggestedKeyword = { type SuggestedKeyword = {
keyword: string; keyword: string;
@ -25,29 +29,7 @@ type SuggestedKeyword = {
const PRE_SELECT_COUNT = 20; const PRE_SELECT_COUNT = 20;
const columns: ColumnDef<SuggestedKeyword>[] = [ const baseColumns: ColumnDef<SuggestedKeyword>[] = [
{
id: "select",
size: 32,
enableSorting: false,
header: ({ table }) => (
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
{ {
id: "keyword", id: "keyword",
accessorKey: "keyword", accessorKey: "keyword",
@ -165,6 +147,39 @@ export function KeywordSuggestionStep({
const [sorting, setSorting] = useState<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
{ id: "position", desc: false }, { id: "position", desc: false },
]); ]);
const selectAnchorRef = useRef<SelectionAnchor | null>(null);
const columns = useMemo<ColumnDef<SuggestedKeyword>[]>(
() => [
{
id: "select",
size: 32,
enableSorting: false,
header: ({ table }) => (
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row, table }) => (
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={row.getIsSelected()}
onClick={(event) => {
event.stopPropagation();
applyShiftRangeSelection(event, row, table, selectAnchorRef);
}}
onChange={row.getToggleSelectedHandler()}
/>
),
},
...baseColumns,
],
[],
);
const suggestionsQuery = useQuery({ const suggestionsQuery = useQuery({
queryKey: [ queryKey: [
@ -342,7 +357,15 @@ export function KeywordSuggestionStep({
<tr <tr
key={row.id} key={row.id}
className="hover:bg-base-200/50 cursor-pointer" className="hover:bg-base-200/50 cursor-pointer"
onClick={row.getToggleSelectedHandler()} onClick={(event) => {
if (
applyShiftRangeSelection(event, row, table, selectAnchorRef)
) {
return;
}
row.toggleSelected();
}}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id}> <td key={cell.id}>

View File

@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useMemo, type MutableRefObject } from "react";
import { ArrowUp, ArrowDown } from "lucide-react"; import { ArrowUp, ArrowDown } from "lucide-react";
import type { ColumnDef, SortingFn } from "@tanstack/react-table"; import type { ColumnDef, SortingFn } from "@tanstack/react-table";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
@ -11,6 +11,10 @@ import {
SerpFeatureTags, SerpFeatureTags,
VolumeCell, VolumeCell,
} from "./RankTrackingTableParts"; } from "./RankTrackingTableParts";
import {
applyShiftRangeSelection,
type SelectionAnchor,
} from "./tableSelection";
const HEADER_TOOLTIPS: Record<string, string> = { const HEADER_TOOLTIPS: Record<string, string> = {
keyword: "The search term being tracked in Google", keyword: "The search term being tracked in Google",
@ -107,27 +111,34 @@ const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
); );
}; };
const selectColumn: ColumnDef<RankTrackingRow> = { function makeSelectColumn(
id: "select", anchorRef: MutableRefObject<SelectionAnchor | null>,
size: 32, ): ColumnDef<RankTrackingRow> {
enableSorting: false, return {
header: ({ table }) => ( id: "select",
<input size: 32,
type="checkbox" enableSorting: false,
className="checkbox checkbox-xs" header: ({ table }) => (
checked={table.getIsAllRowsSelected()} <input
onChange={table.getToggleAllRowsSelectedHandler()} type="checkbox"
/> className="checkbox checkbox-xs"
), checked={table.getIsAllRowsSelected()}
cell: ({ row }) => ( onChange={table.getToggleAllRowsSelectedHandler()}
<input />
type="checkbox" ),
className="checkbox checkbox-xs" cell: ({ row, table }) => (
checked={row.getIsSelected()} <input
onChange={row.getToggleSelectedHandler()} type="checkbox"
/> className="checkbox checkbox-xs"
), checked={row.getIsSelected()}
}; onClick={(event) =>
applyShiftRangeSelection(event, row, table, anchorRef)
}
onChange={row.getToggleSelectedHandler()}
/>
),
};
}
const keywordColumn: ColumnDef<RankTrackingRow> = { const keywordColumn: ColumnDef<RankTrackingRow> = {
id: "keyword", id: "keyword",
@ -206,9 +217,13 @@ export function useRankTrackingColumns(
showDesktop: boolean, showDesktop: boolean,
showMobile: boolean, showMobile: boolean,
domain: string, domain: string,
selectAnchorRef: MutableRefObject<SelectionAnchor | null>,
): ColumnDef<RankTrackingRow>[] { ): ColumnDef<RankTrackingRow>[] {
return useMemo(() => { return useMemo(() => {
const cols: ColumnDef<RankTrackingRow>[] = [selectColumn, keywordColumn]; const cols: ColumnDef<RankTrackingRow>[] = [
makeSelectColumn(selectAnchorRef),
keywordColumn,
];
if (showDesktop) { if (showDesktop) {
cols.push(makeDeviceColumn("desktop")); cols.push(makeDeviceColumn("desktop"));
cols.push(makeUrlColumn("desktop", domain)); cols.push(makeUrlColumn("desktop", domain));
@ -225,5 +240,5 @@ export function useRankTrackingColumns(
cols.push(makeSerpColumn("mobile")); cols.push(makeSerpColumn("mobile"));
} }
return cols; return cols;
}, [showDesktop, showMobile, domain]); }, [showDesktop, showMobile, domain, selectAnchorRef]);
} }

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Trash2 } from "lucide-react"; import { Loader2, Trash2 } from "lucide-react";
import { import {
@ -13,6 +13,7 @@ import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import { useRankTrackingColumns } from "./RankTrackingColumns"; import { useRankTrackingColumns } from "./RankTrackingColumns";
import type { SelectionAnchor } from "./tableSelection";
export function RankTrackingTable({ export function RankTrackingTable({
totalCount, totalCount,
@ -37,8 +38,14 @@ export function RankTrackingTable({
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const selectAnchorRef = useRef<SelectionAnchor | null>(null);
const columns = useRankTrackingColumns(showDesktop, showMobile, domain); const columns = useRankTrackingColumns(
showDesktop,
showMobile,
domain,
selectAnchorRef,
);
const table = useReactTable({ const table = useReactTable({
data: rows, data: rows,

View File

@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import type { MutableRefObject } from "react";
import type { RowSelectionState, Updater } from "@tanstack/react-table";
import {
applyShiftRangeSelection,
type SelectionAnchor,
} from "./tableSelection";
function makeRow(id: string, selectedIds: Set<string>) {
return {
id,
getIsSelected: () => selectedIds.has(id),
};
}
function makeEvent(shiftKey: boolean) {
const event = {
shiftKey,
defaultPrevented: false,
preventDefault() {
event.defaultPrevented = true;
},
};
return event;
}
function makeTable(ids: string[], selectedIds: Set<string>) {
return {
getRowModel: () => ({
rows: ids.map((id) => makeRow(id, selectedIds)),
}),
setRowSelection: (updater: Updater<RowSelectionState>) => {
const currentSelection = Object.fromEntries(
Array.from(selectedIds).map((id) => [id, true]),
);
const nextSelection =
typeof updater === "function" ? updater(currentSelection) : updater;
selectedIds.clear();
Object.entries(nextSelection).forEach(([id, selected]) => {
if (selected) selectedIds.add(id);
});
},
};
}
describe("applyShiftRangeSelection", () => {
it("records the next selected state on a plain click", () => {
const selectedIds = new Set<string>();
const table = makeTable(["a", "b"], selectedIds);
const anchorRef: MutableRefObject<SelectionAnchor | null> = {
current: null,
};
const event = makeEvent(false);
expect(
applyShiftRangeSelection(
event,
makeRow("a", selectedIds),
table,
anchorRef,
),
).toBe(false);
expect(anchorRef.current).toEqual({ id: "a", selected: true });
expect(event.defaultPrevented).toBe(false);
});
it("selects the visible range from a selected anchor", () => {
const selectedIds = new Set<string>(["a"]);
const table = makeTable(["a", "b", "c", "d"], selectedIds);
const anchorRef: MutableRefObject<SelectionAnchor | null> = {
current: { id: "a", selected: true },
};
const event = makeEvent(true);
expect(
applyShiftRangeSelection(
event,
makeRow("c", selectedIds),
table,
anchorRef,
),
).toBe(true);
expect(Array.from(selectedIds)).toEqual(["a", "b", "c"]);
expect(anchorRef.current).toEqual({ id: "c", selected: true });
expect(event.defaultPrevented).toBe(true);
});
it("clears the visible range from a deselected anchor", () => {
const selectedIds = new Set<string>(["a", "b", "c", "d"]);
const table = makeTable(["a", "b", "c", "d"], selectedIds);
const anchorRef: MutableRefObject<SelectionAnchor | null> = {
current: { id: "b", selected: false },
};
applyShiftRangeSelection(
makeEvent(true),
makeRow("d", selectedIds),
table,
anchorRef,
);
expect(Array.from(selectedIds)).toEqual(["a"]);
expect(anchorRef.current).toEqual({ id: "d", selected: false });
});
});

View File

@ -0,0 +1,71 @@
import type { MouseEvent, MutableRefObject } from "react";
import type { Row, Table } from "@tanstack/react-table";
export type SelectionAnchor = {
id: string;
selected: boolean;
};
type SelectionRow<T> = Pick<Row<T>, "id" | "getIsSelected">;
type SelectionTable<T> = Pick<Table<T>, "setRowSelection"> & {
getRowModel: () => {
rows: SelectionRow<T>[];
};
};
export function applyShiftRangeSelection<T>(
event: Pick<MouseEvent<HTMLElement>, "shiftKey" | "preventDefault">,
row: SelectionRow<T>,
table: SelectionTable<T>,
anchorRef: MutableRefObject<SelectionAnchor | null>,
): boolean {
if (!event.shiftKey || !anchorRef.current) {
anchorRef.current = {
id: row.id,
selected: !row.getIsSelected(),
};
return false;
}
const rows = table.getRowModel().rows;
const anchorIndex = rows.findIndex((candidate) => {
return candidate.id === anchorRef.current?.id;
});
const currentIndex = rows.findIndex((candidate) => candidate.id === row.id);
if (anchorIndex === -1 || currentIndex === -1) {
anchorRef.current = {
id: row.id,
selected: !row.getIsSelected(),
};
return false;
}
event.preventDefault();
const [from, to] =
anchorIndex < currentIndex
? [anchorIndex, currentIndex]
: [currentIndex, anchorIndex];
const selected = anchorRef.current.selected;
table.setRowSelection((currentSelection) => {
const nextSelection = { ...currentSelection };
for (let index = from; index <= to; index++) {
const rangeRow = rows[index];
if (!rangeRow) continue;
if (selected) {
nextSelection[rangeRow.id] = true;
} else {
delete nextSelection[rangeRow.id];
}
}
return nextSelection;
});
anchorRef.current = { id: row.id, selected };
return true;
}