From 6b7d0464f72799234bc998c83f7d61427e76adbb Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 6 May 2026 10:46:21 -0400 Subject: [PATCH] feat: add shift range table selection (#20) * add shift range table selection * format keyword suggestion selection * credit original draft author Co-authored-by: Granata005 --------- Co-authored-by: Granata005 --- .../rank-tracking/KeywordSuggestionStep.tsx | 73 ++++++++---- .../rank-tracking/RankTrackingColumns.tsx | 63 +++++++---- .../rank-tracking/RankTrackingTable.tsx | 11 +- .../rank-tracking/tableSelection.test.ts | 107 ++++++++++++++++++ .../features/rank-tracking/tableSelection.ts | 71 ++++++++++++ 5 files changed, 274 insertions(+), 51 deletions(-) create mode 100644 src/client/features/rank-tracking/tableSelection.test.ts create mode 100644 src/client/features/rank-tracking/tableSelection.ts diff --git a/src/client/features/rank-tracking/KeywordSuggestionStep.tsx b/src/client/features/rank-tracking/KeywordSuggestionStep.tsx index 0fce6b3..edaf762 100644 --- a/src/client/features/rank-tracking/KeywordSuggestionStep.tsx +++ b/src/client/features/rank-tracking/KeywordSuggestionStep.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useReactTable, @@ -15,6 +15,10 @@ import { getDomainKeywordSuggestions } from "@/serverFunctions/domain"; import { addTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { SortableHeader } from "./RankTrackingColumns"; +import { + applyShiftRangeSelection, + type SelectionAnchor, +} from "./tableSelection"; type SuggestedKeyword = { keyword: string; @@ -25,29 +29,7 @@ type SuggestedKeyword = { const PRE_SELECT_COUNT = 20; -const columns: ColumnDef[] = [ - { - id: "select", - size: 32, - enableSorting: false, - header: ({ table }) => ( - - ), - cell: ({ row }) => ( - e.stopPropagation()} - onChange={row.getToggleSelectedHandler()} - /> - ), - }, +const baseColumns: ColumnDef[] = [ { id: "keyword", accessorKey: "keyword", @@ -165,6 +147,39 @@ export function KeywordSuggestionStep({ const [sorting, setSorting] = useState([ { id: "position", desc: false }, ]); + const selectAnchorRef = useRef(null); + + const columns = useMemo[]>( + () => [ + { + id: "select", + size: 32, + enableSorting: false, + header: ({ table }) => ( + + ), + cell: ({ row, table }) => ( + { + event.stopPropagation(); + applyShiftRangeSelection(event, row, table, selectAnchorRef); + }} + onChange={row.getToggleSelectedHandler()} + /> + ), + }, + ...baseColumns, + ], + [], + ); const suggestionsQuery = useQuery({ queryKey: [ @@ -342,7 +357,15 @@ export function KeywordSuggestionStep({ { + if ( + applyShiftRangeSelection(event, row, table, selectAnchorRef) + ) { + return; + } + + row.toggleSelected(); + }} > {row.getVisibleCells().map((cell) => ( diff --git a/src/client/features/rank-tracking/RankTrackingColumns.tsx b/src/client/features/rank-tracking/RankTrackingColumns.tsx index b92fb43..2a4dd68 100644 --- a/src/client/features/rank-tracking/RankTrackingColumns.tsx +++ b/src/client/features/rank-tracking/RankTrackingColumns.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, type MutableRefObject } from "react"; import { ArrowUp, ArrowDown } from "lucide-react"; import type { ColumnDef, SortingFn } from "@tanstack/react-table"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; @@ -11,6 +11,10 @@ import { SerpFeatureTags, VolumeCell, } from "./RankTrackingTableParts"; +import { + applyShiftRangeSelection, + type SelectionAnchor, +} from "./tableSelection"; const HEADER_TOOLTIPS: Record = { keyword: "The search term being tracked in Google", @@ -107,27 +111,34 @@ const positionSort: SortingFn = (rowA, rowB, columnId) => { ); }; -const selectColumn: ColumnDef = { - id: "select", - size: 32, - enableSorting: false, - header: ({ table }) => ( - - ), - cell: ({ row }) => ( - - ), -}; +function makeSelectColumn( + anchorRef: MutableRefObject, +): ColumnDef { + return { + id: "select", + size: 32, + enableSorting: false, + header: ({ table }) => ( + + ), + cell: ({ row, table }) => ( + + applyShiftRangeSelection(event, row, table, anchorRef) + } + onChange={row.getToggleSelectedHandler()} + /> + ), + }; +} const keywordColumn: ColumnDef = { id: "keyword", @@ -206,9 +217,13 @@ export function useRankTrackingColumns( showDesktop: boolean, showMobile: boolean, domain: string, + selectAnchorRef: MutableRefObject, ): ColumnDef[] { return useMemo(() => { - const cols: ColumnDef[] = [selectColumn, keywordColumn]; + const cols: ColumnDef[] = [ + makeSelectColumn(selectAnchorRef), + keywordColumn, + ]; if (showDesktop) { cols.push(makeDeviceColumn("desktop")); cols.push(makeUrlColumn("desktop", domain)); @@ -225,5 +240,5 @@ export function useRankTrackingColumns( cols.push(makeSerpColumn("mobile")); } return cols; - }, [showDesktop, showMobile, domain]); + }, [showDesktop, showMobile, domain, selectAnchorRef]); } diff --git a/src/client/features/rank-tracking/RankTrackingTable.tsx b/src/client/features/rank-tracking/RankTrackingTable.tsx index 4cb48ff..2da9feb 100644 --- a/src/client/features/rank-tracking/RankTrackingTable.tsx +++ b/src/client/features/rank-tracking/RankTrackingTable.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { Loader2, Trash2 } from "lucide-react"; import { @@ -13,6 +13,7 @@ import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import type { RankTrackingRow } from "@/types/schemas/rank-tracking"; import { useRankTrackingColumns } from "./RankTrackingColumns"; +import type { SelectionAnchor } from "./tableSelection"; export function RankTrackingTable({ totalCount, @@ -37,8 +38,14 @@ export function RankTrackingTable({ }) { const queryClient = useQueryClient(); const [showConfirm, setShowConfirm] = useState(false); + const selectAnchorRef = useRef(null); - const columns = useRankTrackingColumns(showDesktop, showMobile, domain); + const columns = useRankTrackingColumns( + showDesktop, + showMobile, + domain, + selectAnchorRef, + ); const table = useReactTable({ data: rows, diff --git a/src/client/features/rank-tracking/tableSelection.test.ts b/src/client/features/rank-tracking/tableSelection.test.ts new file mode 100644 index 0000000..f2d2911 --- /dev/null +++ b/src/client/features/rank-tracking/tableSelection.test.ts @@ -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) { + 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) { + return { + getRowModel: () => ({ + rows: ids.map((id) => makeRow(id, selectedIds)), + }), + setRowSelection: (updater: Updater) => { + 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(); + const table = makeTable(["a", "b"], selectedIds); + const anchorRef: MutableRefObject = { + 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(["a"]); + const table = makeTable(["a", "b", "c", "d"], selectedIds); + const anchorRef: MutableRefObject = { + 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(["a", "b", "c", "d"]); + const table = makeTable(["a", "b", "c", "d"], selectedIds); + const anchorRef: MutableRefObject = { + 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 }); + }); +}); diff --git a/src/client/features/rank-tracking/tableSelection.ts b/src/client/features/rank-tracking/tableSelection.ts new file mode 100644 index 0000000..a4edb65 --- /dev/null +++ b/src/client/features/rank-tracking/tableSelection.ts @@ -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 = Pick, "id" | "getIsSelected">; + +type SelectionTable = Pick, "setRowSelection"> & { + getRowModel: () => { + rows: SelectionRow[]; + }; +}; + +export function applyShiftRangeSelection( + event: Pick, "shiftKey" | "preventDefault">, + row: SelectionRow, + table: SelectionTable, + anchorRef: MutableRefObject, +): 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; +}