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 {
|
||||
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<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()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
const baseColumns: ColumnDef<SuggestedKeyword>[] = [
|
||||
{
|
||||
id: "keyword",
|
||||
accessorKey: "keyword",
|
||||
@ -165,6 +147,39 @@ export function KeywordSuggestionStep({
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ 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({
|
||||
queryKey: [
|
||||
@ -342,7 +357,15 @@ export function KeywordSuggestionStep({
|
||||
<tr
|
||||
key={row.id}
|
||||
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) => (
|
||||
<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 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<string, string> = {
|
||||
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",
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
@ -119,15 +126,19 @@ const selectColumn: ColumnDef<RankTrackingRow> = {
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
cell: ({ row, table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-xs"
|
||||
checked={row.getIsSelected()}
|
||||
onClick={(event) =>
|
||||
applyShiftRangeSelection(event, row, table, anchorRef)
|
||||
}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const keywordColumn: ColumnDef<RankTrackingRow> = {
|
||||
id: "keyword",
|
||||
@ -206,9 +217,13 @@ export function useRankTrackingColumns(
|
||||
showDesktop: boolean,
|
||||
showMobile: boolean,
|
||||
domain: string,
|
||||
selectAnchorRef: MutableRefObject<SelectionAnchor | null>,
|
||||
): ColumnDef<RankTrackingRow>[] {
|
||||
return useMemo(() => {
|
||||
const cols: ColumnDef<RankTrackingRow>[] = [selectColumn, keywordColumn];
|
||||
const cols: ColumnDef<RankTrackingRow>[] = [
|
||||
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]);
|
||||
}
|
||||
|
||||
@ -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<SelectionAnchor | null>(null);
|
||||
|
||||
const columns = useRankTrackingColumns(showDesktop, showMobile, domain);
|
||||
const columns = useRankTrackingColumns(
|
||||
showDesktop,
|
||||
showMobile,
|
||||
domain,
|
||||
selectAnchorRef,
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
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