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:
parent
89091ca9d5
commit
6b7d0464f7
@ -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}>
|
||||||
|
|||||||
@ -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,7 +111,10 @@ const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectColumn: ColumnDef<RankTrackingRow> = {
|
function makeSelectColumn(
|
||||||
|
anchorRef: MutableRefObject<SelectionAnchor | null>,
|
||||||
|
): ColumnDef<RankTrackingRow> {
|
||||||
|
return {
|
||||||
id: "select",
|
id: "select",
|
||||||
size: 32,
|
size: 32,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@ -119,15 +126,19 @@ const selectColumn: ColumnDef<RankTrackingRow> = {
|
|||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row, table }) => (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="checkbox checkbox-xs"
|
className="checkbox checkbox-xs"
|
||||||
checked={row.getIsSelected()}
|
checked={row.getIsSelected()}
|
||||||
|
onClick={(event) =>
|
||||||
|
applyShiftRangeSelection(event, row, table, anchorRef)
|
||||||
|
}
|
||||||
onChange={row.getToggleSelectedHandler()}
|
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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
107
src/client/features/rank-tracking/tableSelection.test.ts
Normal file
107
src/client/features/rank-tracking/tableSelection.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/client/features/rank-tracking/tableSelection.ts
Normal file
71
src/client/features/rank-tracking/tableSelection.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user