feat: Add shared table component (#154)

* fix rank tracking unranked filters

* improve modal dismissal and range selection

* refactor: create shared table component
This commit is contained in:
Ben Senescu 2026-05-06 11:23:07 -04:00 committed by GitHub
parent 7f892ca433
commit 9e5afc9d48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 909 additions and 734 deletions

View File

@ -0,0 +1,246 @@
import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type Header,
type Row,
type Table,
type TableOptions,
} from "@tanstack/react-table";
import {
useRef,
type MouseEvent,
type MutableRefObject,
type ReactNode,
} from "react";
import {
applyShiftRangeSelection,
type SelectionAnchor,
} from "./tableSelection";
type AppColumnMeta<TData> = {
headerClassName?: string;
cellClassName?: string | ((row: Row<TData>) => string | undefined);
};
declare module "@tanstack/react-table" {
interface ColumnMeta<TData, TValue> extends AppColumnMeta<TData> {
readonly __valueType?: TValue;
}
}
type UseAppTableOptions<TData> = Omit<
TableOptions<TData>,
"getCoreRowModel"
> & {
withSorting?: boolean;
withExpanded?: boolean;
};
export function useAppTable<TData>(options: UseAppTableOptions<TData>) {
const { withSorting, withExpanded, ...tableOptions } = options;
return useReactTable({
...tableOptions,
getCoreRowModel: getCoreRowModel(),
...(withSorting ? { getSortedRowModel: getSortedRowModel() } : {}),
...(withExpanded ? { getExpandedRowModel: getExpandedRowModel() } : {}),
});
}
export function useSelectionAnchor(): MutableRefObject<SelectionAnchor | null> {
return useRef<SelectionAnchor | null>(null);
}
export function makeSelectionColumn<TData>(
anchorRef: MutableRefObject<SelectionAnchor | null>,
): ColumnDef<TData> {
return {
id: "select",
size: 32,
enableSorting: false,
header: ({ table }) => (
<input
type="checkbox"
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row, table }) => (
<SelectionCheckbox row={row} table={table} anchorRef={anchorRef} />
),
};
}
function SelectionCheckbox<TData>({
row,
table,
anchorRef,
}: {
row: Row<TData>;
table: Table<TData>;
anchorRef: MutableRefObject<SelectionAnchor | null>;
}) {
const rangeHandledRef = useRef(false);
return (
<input
type="checkbox"
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
checked={row.getIsSelected()}
onClick={(event) => {
event.stopPropagation();
rangeHandledRef.current = applyShiftRangeSelection(
event,
row,
table,
anchorRef,
);
}}
onChange={(event) => {
if (rangeHandledRef.current) {
rangeHandledRef.current = false;
return;
}
row.getToggleSelectedHandler()(event);
}}
/>
);
}
export function AppDataTable<TData>({
table,
className = "table table-sm",
wrapperClassName = "overflow-x-auto",
empty,
isLoading,
loading,
getRowClassName,
getRowProps,
getCellClassName,
fixedLayout,
stickyHeader,
}: {
table: Table<TData>;
className?: string;
wrapperClassName?: string;
empty?: ReactNode;
isLoading?: boolean;
loading?: ReactNode;
getRowClassName?: (row: Row<TData>) => string | undefined;
getRowProps?: (row: Row<TData>) => {
onClick?: (event: MouseEvent<HTMLTableRowElement>) => void;
className?: string;
};
getCellClassName?: (row: Row<TData>, columnId: string) => string | undefined;
fixedLayout?: boolean;
stickyHeader?: boolean;
}) {
if (isLoading && loading) return <>{loading}</>;
if (table.getRowModel().rows.length === 0 && empty) return <>{empty}</>;
return (
<div className={wrapperClassName}>
<table
className={className}
style={fixedLayout ? { tableLayout: "fixed" } : undefined}
>
{fixedLayout ? (
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
))}
</colgroup>
) : null}
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<HeaderCell
key={header.id}
header={header}
fixedLayout={fixedLayout}
stickyHeader={stickyHeader}
/>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
const rowProps = getRowProps?.(row);
return (
<tr
key={row.id}
onClick={rowProps?.onClick}
className={[getRowClassName?.(row), rowProps?.className]
.filter(Boolean)
.join(" ")}
>
{row.getVisibleCells().map((cell) => {
const rawMeta: unknown = cell.column.columnDef.meta;
const meta = isAppColumnMeta<TData>(rawMeta)
? rawMeta
: undefined;
const metaClass = meta?.cellClassName;
return (
<td
key={cell.id}
className={[
typeof metaClass === "function"
? metaClass(row)
: metaClass,
getCellClassName?.(row, cell.column.id),
]
.filter(Boolean)
.join(" ")}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function HeaderCell<TData>({
header,
fixedLayout,
stickyHeader,
}: {
header: Header<TData, unknown>;
fixedLayout?: boolean;
stickyHeader?: boolean;
}) {
const rawMeta: unknown = header.column.columnDef.meta;
const meta = isAppColumnMeta<TData>(rawMeta) ? rawMeta : undefined;
return (
<th
className={[
stickyHeader ? "bg-base-200" : undefined,
meta?.headerClassName,
]
.filter(Boolean)
.join(" ")}
style={fixedLayout ? { width: header.getSize() } : undefined}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
);
}
function isAppColumnMeta<TData>(value: unknown): value is AppColumnMeta<TData> {
return typeof value === "object" && value !== null;
}

View File

@ -1,5 +1,5 @@
import type { MouseEvent, MutableRefObject } from "react";
import type { Row, Table } from "@tanstack/react-table";
import type { Row, RowSelectionState, Table } from "@tanstack/react-table";
export type SelectionAnchor = {
id: string;
@ -50,7 +50,7 @@ export function applyShiftRangeSelection<T>(
: [currentIndex, anchorIndex];
const selected = anchorRef.current.selected;
table.setRowSelection((currentSelection) => {
table.setRowSelection((currentSelection: RowSelectionState) => {
const nextSelection = { ...currentSelection };
for (let index = from; index <= to; index++) {

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { formatUrlForDisplay, resolveUrlHref } from "./url";
describe("table URL helpers", () => {
it("formats URLs without scroll-to-text fragments", () => {
expect(
formatUrlForDisplay("https://example.com/a%20b?q=one#:~:text=needle"),
).toBe("https://example.com/a b?q=one");
});
it("keeps normal hashes and query strings in display labels", () => {
expect(formatUrlForDisplay("https://example.com/path?q=1#section")).toBe(
"https://example.com/path?q=1#section",
);
});
it("falls back to raw display text for invalid URLs", () => {
expect(formatUrlForDisplay("/relative/path")).toBe("/relative/path");
});
it("resolves relative URLs against a base domain", () => {
expect(resolveUrlHref("/pricing", "example.com")).toBe(
"https://example.com/pricing",
);
expect(resolveUrlHref("docs", "example.com")).toBe(
"https://example.com/docs",
);
});
it("rejects unsafe absolute URL schemes", () => {
expect(resolveUrlHref("javascript:alert(1)", "example.com")).toBeNull();
});
});

View File

@ -0,0 +1,85 @@
import { ExternalLink } from "lucide-react";
export function formatUrlForDisplay(value: string): string {
try {
const url = new URL(value);
const hash = url.hash.startsWith("#:~:") ? "" : url.hash;
const cleaned = `${url.protocol}//${url.host}${url.pathname}${url.search}${hash}`;
try {
return decodeURI(cleaned);
} catch {
return cleaned;
}
} catch {
return value;
}
}
export function resolveUrlHref(
value: string | null | undefined,
baseDomain?: string,
): string | null {
if (!value) return null;
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)) {
return getSafeExternalUrl(value);
}
if (!baseDomain) return null;
return getSafeExternalUrl(
`https://${baseDomain}${value.startsWith("/") ? value : `/${value}`}`,
);
}
export function ExternalUrlCell({
value,
label,
baseDomain,
className = "link link-primary inline-flex items-center gap-1",
display = "formatted",
empty = "-",
}: {
value: string | null | undefined;
label?: string | null;
baseDomain?: string;
className?: string;
display?: "formatted" | "path" | "raw";
empty?: string;
}) {
const href = resolveUrlHref(value, baseDomain);
if (!value || !href) {
return <span className="text-base-content/40">{empty}</span>;
}
const visibleLabel = label ?? getUrlDisplayLabel(value, display);
return (
<a className={className} href={href} target="_blank" rel="noreferrer">
<span className="truncate">{visibleLabel}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
);
}
function getUrlDisplayLabel(
value: string,
display: "formatted" | "path" | "raw",
) {
if (display === "raw") return value;
if (display === "path") {
try {
return new URL(value).pathname;
} catch {
return value;
}
}
return formatUrlForDisplay(value);
}
function getSafeExternalUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: null;
} catch {
return null;
}
}

View File

@ -1,16 +1,13 @@
import {
createColumnHelper,
flexRender,
type Table,
} from "@tanstack/react-table";
import { createColumnHelper, type Table } from "@tanstack/react-table";
import { ExternalLink } from "lucide-react";
import { AppDataTable } from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import { numericNullsLast } from "@/client/components/table/nullSafeSort";
import {
formatCount,
formatPlatformLabel,
} from "@/client/features/ai-search/platformLabels";
import { formatUrlForDisplay } from "@/client/features/ai-search/urlDisplay";
import { formatUrlForDisplay } from "@/client/components/table/url";
import type { BrandLookupResult } from "@/types/schemas/ai-search";
type TopPageRow = BrandLookupResult["topPages"][number];
@ -143,55 +140,17 @@ function BrandLookupTable<T>({
urlLikeColumnId: string;
}) {
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const isNumeric = header.column.getCanSort();
return (
<th
key={header.id}
className={`text-xs uppercase tracking-wider text-base-content/60 ${
isNumeric ? "text-right" : ""
}`}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => {
const isNumeric = cell.column.getCanSort();
return (
<td
key={cell.id}
className={cellClassName(
cell.column.id,
<AppDataTable
table={table}
getCellClassName={(_, columnId) =>
cellClassName(
columnId,
urlLikeColumnId,
isNumeric,
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
table.getColumn(columnId)?.getCanSort() ?? false,
)
}
getRowClassName={() => ""}
/>
);
}

View File

@ -1,11 +1,7 @@
import { useMemo, useState } from "react";
import {
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import { type SortingState } from "@tanstack/react-table";
import { Download, Info, SlidersHorizontal } from "lucide-react";
import { useAppTable } from "@/client/components/table/AppDataTable";
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
import {
buildBrandLookupExport,
@ -251,21 +247,19 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
[result.topQueries, filters.queries.values],
);
const pagesTable = useReactTable({
const pagesTable = useAppTable({
data: filteredPages,
columns: topPagesColumns,
state: { sorting: pagesSort },
onSortingChange: setPagesSort,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
withSorting: true,
});
const queriesTable = useReactTable({
const queriesTable = useAppTable({
data: filteredQueries,
columns: topQueriesColumns,
state: { sorting: queriesSort },
onSortingChange: setQueriesSort,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
withSorting: true,
});
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and

View File

@ -10,7 +10,7 @@ import {
formatModelLabel,
getModelAccent,
} from "@/client/features/ai-search/platformLabels";
import { formatUrlForDisplay } from "@/client/features/ai-search/urlDisplay";
import { formatUrlForDisplay } from "@/client/components/table/url";
import type {
PromptExplorerModelResult,
PromptExplorerResult,

View File

@ -1,24 +0,0 @@
/**
* Prettify a URL for display: drop Chrome scroll-to-text fragments (`#:~:`)
* and decode percent-encoding so `%20` becomes a space. Google AI Overview
* citations routinely carry 200-char text fragments that are useful in the
* href (they scroll the browser to the cited passage) but pure visual noise
* as link text.
*
* The original URL is still what gets navigated to only the visible text
* changes. Falls back to the raw input if parsing fails.
*/
export function formatUrlForDisplay(value: string): string {
try {
const url = new URL(value);
const hash = url.hash.startsWith("#:~:") ? "" : url.hash;
const cleaned = `${url.protocol}//${url.host}${url.pathname}${url.search}${hash}`;
try {
return decodeURI(cleaned);
} catch {
return cleaned;
}
} catch {
return value;
}
}

View File

@ -1,11 +1,8 @@
import {
flexRender,
getCoreRowModel,
getExpandedRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useMemo } from "react";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
import { backlinksColumns } from "./BacklinksTableColumns";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
@ -18,16 +15,15 @@ export function BacklinksTable({
}) {
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
const table = useReactTable({
const table = useAppTable({
data: groupedData,
columns: backlinksColumns,
initialState: {
sorting: [{ id: "firstSeen", desc: true }],
},
getSubRows: (row) => row.subRows,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
withSorting: true,
withExpanded: true,
getRowCanExpand: (row) => row.depth === 0,
});
@ -36,51 +32,16 @@ export function BacklinksTable({
}
return (
<div className="overflow-x-auto">
<table className="table table-sm" style={{ tableLayout: "fixed" }}>
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
))}
</colgroup>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} style={{ width: header.getSize() }}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={
<AppDataTable
table={table}
fixedLayout
getRowProps={(row) => ({
className:
row.depth === 0
? "cursor-pointer bg-base-200/50 transition-colors hover:bg-base-200/80"
: "bg-base-100"
}
onClick={
row.depth === 0 ? row.getToggleExpandedHandler() : undefined
}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
: "bg-base-100",
onClick: row.depth === 0 ? row.getToggleExpandedHandler() : undefined,
})}
/>
);
}

View File

@ -1,13 +1,13 @@
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingFn,
type SortingState,
} from "@tanstack/react-table";
import { useState } from "react";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import {
compareNumericNullsLast,
@ -150,13 +150,12 @@ export function ReferringDomainsTable({
}) {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const table = useReactTable({
const table = useAppTable({
data: rows,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
withSorting: true,
});
if (rows.length === 0) {
@ -164,43 +163,11 @@ export function ReferringDomainsTable({
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={
cell.column.id === "domain"
? "font-medium break-all"
: undefined
<AppDataTable
table={table}
getCellClassName={(_, columnId) =>
columnId === "domain" ? "font-medium break-all" : undefined
}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
/>
);
}

View File

@ -1,12 +1,9 @@
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type SortingState,
} from "@tanstack/react-table";
import { createColumnHelper, type SortingState } from "@tanstack/react-table";
import { useState } from "react";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { SortableHeader } from "@/client/components/table/SortableHeader";
import {
numericNullsLast,
@ -103,13 +100,12 @@ export function TopPagesTable({
}) {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const table = useReactTable({
const table = useAppTable({
data: rows,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
withSorting: true,
});
if (rows.length === 0) {
@ -117,39 +113,11 @@ export function TopPagesTable({
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cell.column.id === "page" ? "min-w-80" : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<AppDataTable
table={table}
getCellClassName={(_, columnId) =>
columnId === "page" ? "min-w-80" : undefined
}
/>
);
}

View File

@ -150,7 +150,6 @@ export function DomainOverviewPage({
canSaveKeywords={state.canSaveKeywords}
onSortClick={state.handleSortColumnClick}
onToggleKeyword={state.toggleKeywordSelection}
onToggleAllVisible={state.toggleAllVisibleKeywords}
page={state.page}
pageSize={state.pageSize}
totalKeywordCount={state.totalKeywordCount}

View File

@ -1,11 +1,19 @@
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { useMemo } from "react";
import {
createColumnHelper,
type ColumnDef,
type RowSelectionState,
} from "@tanstack/react-table";
import {
AppDataTable,
makeSelectionColumn,
useAppTable,
useSelectionAnchor,
} from "@/client/components/table/AppDataTable";
import { ExternalUrlCell } from "@/client/components/table/url";
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import {
formatFloat,
formatNumber,
resolveDomainPageHref,
} from "@/client/features/domain/utils";
import { formatFloat, formatNumber } from "@/client/features/domain/utils";
import type {
DomainSortMode,
KeywordRow,
@ -21,9 +29,10 @@ type Props = {
currentSortOrder: SortOrder;
onSortClick: (sort: DomainSortMode) => void;
onToggleKeyword: (keyword: string) => void;
onToggleAllVisible: () => void;
};
const keywordColumnHelper = createColumnHelper<KeywordRow>();
export function DomainKeywordsTable({
domain,
rows,
@ -33,57 +42,59 @@ export function DomainKeywordsTable({
currentSortOrder,
onSortClick,
onToggleKeyword,
onToggleAllVisible,
}: Props) {
return (
<div className="overflow-x-auto">
<div className="mb-2 text-xs text-base-content/60">
{selectedKeywords.size > 0
? `${selectedKeywords.size} selected`
: "Select keywords to save"}
</div>
<table className="table table-zebra table-sm">
<thead>
<tr>
<th>
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={
visibleKeywords.length > 0 &&
visibleKeywords.every((keyword) =>
selectedKeywords.has(keyword),
)
}
onChange={onToggleAllVisible}
/>
</th>
<th>Keyword</th>
<th>
const selectAnchorRef = useSelectionAnchor();
const rowSelection = useMemo<RowSelectionState>(
() =>
Object.fromEntries(
[...selectedKeywords].map((keyword) => [keyword, true]),
) as RowSelectionState,
[selectedKeywords],
);
const columns = useMemo<ColumnDef<KeywordRow>[]>(
() => [
makeSelectionColumn<KeywordRow>(selectAnchorRef),
keywordColumnHelper.accessor("keyword", {
header: () => "Keyword",
cell: ({ getValue }) => (
<span className="font-medium">{getValue()}</span>
),
}),
keywordColumnHelper.accessor("position", {
header: () => (
<SortableHeader
label="Rank"
isActive={sortMode === "rank"}
order={currentSortOrder}
onClick={() => onSortClick("rank")}
/>
</th>
<th>
),
cell: ({ getValue }) => getValue() ?? "-",
}),
keywordColumnHelper.accessor("searchVolume", {
header: () => (
<SortableHeader
label="Volume"
isActive={sortMode === "volume"}
order={currentSortOrder}
onClick={() => onSortClick("volume")}
/>
</th>
<th>
),
cell: ({ getValue }) => formatNumber(getValue()),
}),
keywordColumnHelper.accessor("traffic", {
header: () => (
<SortableHeader
label="Traffic"
isActive={sortMode === "traffic"}
order={currentSortOrder}
onClick={() => onSortClick("traffic")}
/>
</th>
<th>
),
cell: ({ getValue }) => formatFloat(getValue()),
}),
keywordColumnHelper.accessor("cpc", {
header: () => (
<SortableHeader
label="CPC"
helpText="Cost per click in USD."
@ -91,9 +102,28 @@ export function DomainKeywordsTable({
order={currentSortOrder}
onClick={() => onSortClick("cpc")}
/>
</th>
<th>URL</th>
<th>
),
cell: ({ getValue }) => {
const value = getValue();
return value == null ? "-" : `$${value.toFixed(2)}`;
},
}),
keywordColumnHelper.display({
id: "url",
header: () => "URL",
cell: ({ row }) => (
<ExternalUrlCell
value={row.original.relativeUrl ?? row.original.url}
label={row.original.relativeUrl ?? row.original.url ?? ""}
baseDomain={domain}
/>
),
meta: {
cellClassName: "max-w-[260px] truncate",
},
}),
keywordColumnHelper.accessor("keywordDifficulty", {
header: () => (
<SortableHeader
label="Score"
helpText="Keyword difficulty score."
@ -101,62 +131,50 @@ export function DomainKeywordsTable({
order={currentSortOrder}
onClick={() => onSortClick("score")}
/>
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="py-6 text-center text-base-content/60">
No keywords match this search.
</td>
</tr>
) : (
rows.map((row) => {
const href = resolveDomainPageHref(
row.relativeUrl ?? row.url,
domain,
),
cell: ({ getValue }) => <DifficultyBadge value={getValue()} />,
}),
],
[currentSortOrder, domain, onSortClick, selectAnchorRef, sortMode],
);
const table = useAppTable({
data: rows,
columns,
state: { rowSelection },
onRowSelectionChange: (updater) => {
const next =
typeof updater === "function" ? updater(rowSelection) : updater;
const selected = Object.entries(next)
.filter(([, value]) => value)
.map(([keyword]) => keyword);
for (const keyword of visibleKeywords) {
const shouldBeSelected = selected.includes(keyword);
if (selectedKeywords.has(keyword) !== shouldBeSelected) {
onToggleKeyword(keyword);
}
}
},
getRowId: (row) => row.keyword,
enableRowSelection: true,
});
return (
<tr key={`${row.keyword}-${row.url ?? ""}`}>
<td>
<input
type="checkbox"
className="checkbox checkbox-xs"
checked={selectedKeywords.has(row.keyword)}
onChange={() => onToggleKeyword(row.keyword)}
aria-label={`Select ${row.keyword}`}
<div className="overflow-x-auto">
<div className="mb-2 text-xs text-base-content/60">
{selectedKeywords.size > 0
? `${selectedKeywords.size} selected`
: "Select keywords to save"}
</div>
<AppDataTable
table={table}
className="table table-zebra table-sm"
wrapperClassName=""
empty={
<div className="py-6 text-center text-base-content/60">
No keywords match this search.
</div>
}
/>
</td>
<td className="font-medium">{row.keyword}</td>
<td>{row.position ?? "-"}</td>
<td>{formatNumber(row.searchVolume)}</td>
<td>{formatFloat(row.traffic)}</td>
<td>{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}</td>
<td
className="max-w-[260px] truncate"
title={row.url ?? undefined}
>
{href ? (
<SafeExternalLink
url={href}
label={row.relativeUrl ?? row.url ?? ""}
className="link link-primary inline-flex items-center gap-1"
/>
) : (
"-"
)}
</td>
<td>
<DifficultyBadge value={row.keywordDifficulty} />
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}

View File

@ -1,9 +1,14 @@
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { useMemo } from "react";
import { createColumnHelper, type ColumnDef } from "@tanstack/react-table";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { ExternalUrlCell } from "@/client/components/table/url";
import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import {
formatFloat,
formatNumber,
resolveDomainPageHref,
toPageSortMode,
} from "@/client/features/domain/utils";
import type {
@ -20,6 +25,8 @@ type Props = {
onSortClick: (sort: DomainSortMode) => void;
};
const pageColumnHelper = createColumnHelper<PageRow>();
export function DomainPagesTable({
domain,
rows,
@ -27,65 +34,62 @@ export function DomainPagesTable({
currentSortOrder,
onSortClick,
}: Props) {
return (
<div className="overflow-x-auto">
<table className="table table-zebra table-sm">
<thead>
<tr>
<th>Page</th>
<th>
const columns = useMemo<ColumnDef<PageRow>[]>(
() => [
pageColumnHelper.display({
id: "page",
header: () => "Page",
cell: ({ row }) => (
<ExternalUrlCell
value={row.original.relativePath ?? row.original.page}
label={row.original.relativePath ?? row.original.page}
baseDomain={domain}
className="link link-primary inline-flex items-center gap-1"
/>
),
meta: {
cellClassName: "max-w-[420px] truncate",
},
}),
pageColumnHelper.accessor("organicTraffic", {
header: () => (
<SortableHeader
label="Organic Traffic"
isActive={toPageSortMode(sortMode) === "traffic"}
order={currentSortOrder}
onClick={() => onSortClick("traffic")}
/>
</th>
<th>
),
cell: ({ getValue }) => formatFloat(getValue()),
}),
pageColumnHelper.accessor("keywords", {
header: () => (
<SortableHeader
label="Keywords"
isActive={toPageSortMode(sortMode) === "keywords"}
order={currentSortOrder}
onClick={() => onSortClick("volume")}
/>
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={3} className="py-6 text-center text-base-content/60">
No pages match this search.
</td>
</tr>
) : (
rows.slice(0, 100).map((row) => {
const href = resolveDomainPageHref(
row.relativePath ?? row.page,
domain,
),
cell: ({ getValue }) => formatNumber(getValue()),
}),
],
[currentSortOrder, domain, onSortClick, sortMode],
);
const table = useAppTable({
data: rows.slice(0, 100),
columns,
});
return (
<tr key={row.page}>
<td className="max-w-[420px] truncate" title={row.page}>
{href ? (
<SafeExternalLink
url={href}
label={row.relativePath ?? row.page}
className="link link-primary inline-flex items-center gap-1"
/>
) : (
(row.relativePath ?? row.page)
)}
</td>
<td>{formatFloat(row.organicTraffic)}</td>
<td>{formatNumber(row.keywords)}</td>
</tr>
);
})
)}
</tbody>
</table>
<AppDataTable
table={table}
className="table table-zebra table-sm"
empty={
<div className="py-6 text-center text-base-content/60">
No pages match this search.
</div>
}
/>
);
}

View File

@ -59,7 +59,6 @@ type Props = {
canSaveKeywords: boolean;
onSortClick: (sort: DomainSortMode) => void;
onToggleKeyword: (keyword: string) => void;
onToggleAllVisible: () => void;
page: number;
pageSize: number;
totalKeywordCount: number | null;
@ -104,7 +103,6 @@ export function DomainResultsCard({
canSaveKeywords,
onSortClick,
onToggleKeyword,
onToggleAllVisible,
page,
pageSize,
totalKeywordCount,
@ -320,7 +318,6 @@ export function DomainResultsCard({
currentSortOrder={currentSortOrder}
onSortClick={onSortClick}
onToggleKeyword={onToggleKeyword}
onToggleAllVisible={onToggleAllVisible}
/>
</div>
) : (

View File

@ -117,14 +117,3 @@ export function pagesToTable(rows: PageRow[]): ExportTable {
rows: rows.map((row) => [row.page, row.organicTraffic, row.keywords]),
};
}
export function resolveDomainPageHref(
value: string | null | undefined,
domain: string,
): string | null {
if (!value) return null;
return value.includes("://")
? value
: `https://${domain}${value.startsWith("/") ? value : `/${value}`}`;
}

View File

@ -47,61 +47,6 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
);
}
export function KeywordRow({
row,
isSelected,
isActive,
onToggle,
onClick,
}: {
row: KeywordResearchRow;
isSelected: boolean;
isActive: boolean;
onToggle: () => void;
onClick: () => void;
}) {
return (
<div
className={`flex items-center gap-3 px-4 py-2 border-b border-base-200 text-sm hover:bg-base-200/50 transition-colors cursor-pointer ${
isActive ? "bg-primary/5 border-l-2 border-l-primary" : ""
}`}
onClick={onClick}
>
<input
type="checkbox"
className="checkbox checkbox-xs shrink-0"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<span
className="flex-1 min-w-0 truncate font-medium capitalize"
title={row.keyword}
>
{row.keyword}
</span>
<span className="w-16 text-right tabular-nums text-base-content/70">
{formatNumber(row.searchVolume)}
</span>
<span className="w-14 text-right tabular-nums text-base-content/70">
{row.cpc == null ? "-" : row.cpc.toFixed(2)}
</span>
<span className="w-12 text-right tabular-nums text-base-content/70">
{row.competition == null ? "-" : row.competition.toFixed(2)}
</span>
<div className="w-10 flex justify-end">
<ScoreBadge value={row.keywordDifficulty} size="sm" />
</div>
</div>
);
}
export function KeywordCard({
row,
isSelected,

View File

@ -12,18 +12,16 @@ import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywo
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import {
AreaTrendChart,
KeywordRow,
OverviewStats,
SerpAnalysisCard,
SortHeader,
} from "@/client/features/keywords/components";
import type { KeywordResearchRow } from "@/types/keywords";
import type { KeywordResearchControllerState } from "./types";
import {
EmptyFilterResults,
FilterRangeInputs,
FilterTextInput,
} from "./keywordResearchDesktopFilters";
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
const MONTH_SHORT_LABELS = [
"Jan",
@ -192,8 +190,18 @@ function DesktopTableCard({ controller }: Props) {
</div>
{showFilters ? <DesktopFilters controller={controller} /> : null}
<DesktopTableHeader controller={controller} />
<DesktopTableRows controller={controller} />
<KeywordResearchDesktopTable
activeFilterCount={controller.activeFilterCount}
filteredRows={controller.filteredRows}
overviewKeyword={controller.overviewKeyword}
selectedRows={controller.selectedRows}
setSelectedRows={controller.setSelectedRows}
sortDir={controller.sortDir}
sortField={controller.sortField}
toggleSort={controller.toggleSort}
resetFilters={controller.resetFilters}
handleRowClick={controller.handleRowClick}
/>
</div>
);
}
@ -262,93 +270,6 @@ function DesktopFilters({ controller }: Props) {
);
}
function DesktopTableHeader({ controller }: Props) {
const { filteredRows, selectedRows } = controller;
return (
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100 text-xs text-base-content/60 font-medium">
<input
type="checkbox"
className="checkbox checkbox-xs shrink-0"
checked={
filteredRows.length > 0 && selectedRows.size === filteredRows.length
}
onChange={controller.toggleAllRows}
/>
<SortHeader
label="Keyword"
field="keyword"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="flex-1 min-w-0"
/>
<SortHeader
label="Volume"
field="searchVolume"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="w-16 text-right"
/>
<SortHeader
label="CPC"
helpText="Cost per click in USD."
field="cpc"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="w-14 text-right"
/>
<SortHeader
label="Comp."
helpText="Advertiser competition."
field="competition"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="w-12 text-right"
/>
<SortHeader
label="Score"
helpText="Keyword difficulty score."
field="keywordDifficulty"
current={controller.sortField}
dir={controller.sortDir}
onToggle={controller.toggleSort}
className="w-10 text-right"
/>
</div>
);
}
function DesktopTableRows({ controller }: Props) {
const { activeFilterCount, filteredRows, overviewKeyword, selectedRows } =
controller;
return (
<div className="flex-1 overflow-y-auto">
{filteredRows.length === 0 ? (
<EmptyFilterResults
activeFilterCount={activeFilterCount}
resetFilters={controller.resetFilters}
/>
) : (
filteredRows.map((row) => (
<KeywordRow
key={row.keyword}
row={row}
isSelected={selectedRows.has(row.keyword)}
isActive={overviewKeyword?.keyword === row.keyword}
onToggle={() => controller.toggleRowSelection(row.keyword)}
onClick={() => controller.handleRowClick(row)}
/>
))
)}
</div>
);
}
function DesktopSerpPanel({ controller }: Props) {
const { overviewKeyword } = controller;
const trendRangeLabel = overviewKeyword

View File

@ -0,0 +1,215 @@
import { useMemo } from "react";
import {
createColumnHelper,
type ColumnDef,
type RowSelectionState,
} from "@tanstack/react-table";
import {
AppDataTable,
makeSelectionColumn,
useAppTable,
useSelectionAnchor,
} from "@/client/components/table/AppDataTable";
import {
SortHeader,
type SortDir,
type SortField,
} from "@/client/features/keywords/components";
import { formatNumber } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords";
import { EmptyFilterResults } from "./keywordResearchDesktopFilters";
type Props = {
activeFilterCount: number;
filteredRows: KeywordResearchRow[];
overviewKeyword: KeywordResearchRow | null;
selectedRows: Set<string>;
setSelectedRows: (rows: Set<string>) => void;
sortDir: SortDir;
sortField: SortField;
toggleSort: (field: SortField) => void;
resetFilters: () => void;
handleRowClick: (row: KeywordResearchRow) => void;
};
const keywordColumnHelper = createColumnHelper<KeywordResearchRow>();
export function KeywordResearchDesktopTable({
activeFilterCount,
filteredRows,
overviewKeyword,
selectedRows,
setSelectedRows,
sortDir,
sortField,
toggleSort,
resetFilters,
handleRowClick,
}: Props) {
const selectAnchorRef = useSelectionAnchor();
const rowSelection = useMemo<RowSelectionState>(
() =>
Object.fromEntries(
[...selectedRows].map((keyword) => [keyword, true]),
) as RowSelectionState,
[selectedRows],
);
const columns = useMemo<ColumnDef<KeywordResearchRow>[]>(
() => [
makeSelectionColumn<KeywordResearchRow>(selectAnchorRef),
keywordColumnHelper.accessor("keyword", {
header: () => (
<SortHeader
label="Keyword"
field="keyword"
current={sortField}
dir={sortDir}
onToggle={toggleSort}
className="min-w-0"
/>
),
cell: ({ row }) => (
<span
className="block truncate font-medium capitalize"
title={row.original.keyword}
>
{row.original.keyword}
</span>
),
meta: { cellClassName: "min-w-0" },
}),
keywordColumnHelper.accessor("searchVolume", {
header: () => (
<SortHeader
label="Volume"
field="searchVolume"
current={sortField}
dir={sortDir}
onToggle={toggleSort}
className="justify-end"
/>
),
cell: ({ getValue }) => formatNumber(getValue()),
meta: {
headerClassName: "text-right",
cellClassName: "text-right tabular-nums text-base-content/70",
},
}),
keywordColumnHelper.accessor("cpc", {
header: () => (
<SortHeader
label="CPC"
helpText="Cost per click in USD."
field="cpc"
current={sortField}
dir={sortDir}
onToggle={toggleSort}
className="justify-end"
/>
),
cell: ({ getValue }) => {
const value = getValue();
return value == null ? "-" : value.toFixed(2);
},
meta: {
headerClassName: "text-right",
cellClassName: "text-right tabular-nums text-base-content/70",
},
}),
keywordColumnHelper.accessor("competition", {
header: () => (
<SortHeader
label="Comp."
helpText="Advertiser competition."
field="competition"
current={sortField}
dir={sortDir}
onToggle={toggleSort}
className="justify-end"
/>
),
cell: ({ getValue }) => {
const value = getValue();
return value == null ? "-" : value.toFixed(2);
},
meta: {
headerClassName: "text-right",
cellClassName: "text-right tabular-nums text-base-content/70",
},
}),
keywordColumnHelper.accessor("keywordDifficulty", {
header: () => (
<SortHeader
label="Score"
helpText="Keyword difficulty score."
field="keywordDifficulty"
current={sortField}
dir={sortDir}
onToggle={toggleSort}
className="justify-end"
/>
),
cell: ({ getValue }) => <ScoreCell value={getValue()} />,
meta: { headerClassName: "text-right", cellClassName: "text-right" },
}),
],
[selectAnchorRef, sortDir, sortField, toggleSort],
);
const table = useAppTable({
data: filteredRows,
columns,
state: { rowSelection },
onRowSelectionChange: (updater) => {
const next =
typeof updater === "function" ? updater(rowSelection) : updater;
setSelectedRows(
new Set(
Object.entries(next)
.filter(([, selected]) => selected)
.map(([keyword]) => keyword),
),
);
},
getRowId: (row) => row.keyword,
enableRowSelection: true,
});
return (
<div className="flex-1 overflow-y-auto">
{filteredRows.length === 0 ? (
<EmptyFilterResults
activeFilterCount={activeFilterCount}
resetFilters={resetFilters}
/>
) : (
<AppDataTable
table={table}
className="table table-xs w-full"
wrapperClassName="h-full overflow-y-auto"
getRowProps={(row) => ({
className: `cursor-pointer border-b border-base-200 hover:bg-base-200/50 ${
overviewKeyword?.keyword === row.original.keyword
? "bg-primary/5 border-l-2 border-l-primary"
: ""
}`,
onClick: () => handleRowClick(row.original),
})}
/>
)}
</div>
);
}
function ScoreCell({ value }: { value: number | null }) {
if (value == null) return null;
let tierClass = "bg-success/20 text-success";
if (value > 60) tierClass = "bg-error/20 text-error";
else if (value > 30) tierClass = "bg-warning/20 text-warning";
return (
<span
className={`inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold ${tierClass}`}
>
{value}
</span>
);
}

View File

@ -139,6 +139,7 @@ export function useKeywordResearchController(
serpQuery: state.serpQuery,
serpResults: state.serpResults,
setMobileTab: state.setMobileTab,
setSelectedRows: state.setSelectedRows,
setSerpPage: state.setSerpPage,
setShowFilters: state.setShowFilters,
setShowSaveDialog: state.setShowSaveDialog,
@ -165,8 +166,13 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
const uiState = useKeywordUiState(
Object.values(filterValues).some((v) => v.trim() !== ""),
);
const { selectedRows, clearSelection, toggleRowSelection, toggleAllRows } =
useKeywordSelection();
const {
selectedRows,
setSelectedRows,
clearSelection,
toggleRowSelection,
toggleAllRows,
} = useKeywordSelection();
const {
setSerpKeyword,
serpPage,
@ -329,6 +335,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
searchedKeyword,
selectedKeyword: uiState.selectedKeyword,
selectedRows,
setSelectedRows,
saveMutation,
setPreferredLocationCode,
setSelectedKeyword: uiState.setSelectedKeyword,

View File

@ -1,10 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender,
type ColumnDef,
type RowSelectionState,
type SortingState,
@ -14,11 +10,16 @@ import { toast } from "sonner";
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import {
AppDataTable,
makeSelectionColumn,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { SortableHeader } from "./RankTrackingColumns";
import {
applyShiftRangeSelection,
type SelectionAnchor,
} from "./tableSelection";
} from "@/client/components/table/tableSelection";
type SuggestedKeyword = {
keyword: string;
@ -151,31 +152,7 @@ export function KeywordSuggestionStep({
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()}
/>
),
},
makeSelectionColumn<SuggestedKeyword>(selectAnchorRef),
...baseColumns,
],
[],
@ -217,14 +194,13 @@ export function KeywordSuggestionStep({
}
}, [suggestionsQuery.data, hasInitialized]);
const table = useReactTable({
const table = useAppTable({
data,
columns,
state: { rowSelection, sorting },
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
withSorting: true,
enableRowSelection: true,
});
@ -336,49 +312,22 @@ export function KeywordSuggestionStep({
</span>
</div>
<div className="overflow-y-auto max-h-[400px] border border-base-300 rounded-lg">
<table className="table table-xs table-pin-rows w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="bg-base-200">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="hover:bg-base-200/50 cursor-pointer"
onClick={(event) => {
if (
applyShiftRangeSelection(event, row, table, selectAnchorRef)
) {
<AppDataTable
table={table}
className="table table-xs table-pin-rows w-full"
wrapperClassName="overflow-y-auto max-h-[400px] border border-base-300 rounded-lg"
stickyHeader
getRowProps={(row) => ({
className: "hover:bg-base-200/50 cursor-pointer",
onClick: (event) => {
if (applyShiftRangeSelection(event, row, table, selectAnchorRef)) {
return;
}
row.toggleSelected();
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
},
})}
/>
<div className="flex items-center justify-end gap-3 pt-1">
<button className="btn btn-ghost btn-sm" onClick={onClose}>

View File

@ -1,6 +1,7 @@
import { useMemo, type MutableRefObject } from "react";
import { ArrowUp, ArrowDown } from "lucide-react";
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
import { makeSelectionColumn } from "@/client/components/table/AppDataTable";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import {
comparePositions,
@ -11,10 +12,7 @@ import {
SerpFeatureTags,
VolumeCell,
} from "./RankTrackingTableParts";
import {
applyShiftRangeSelection,
type SelectionAnchor,
} from "./tableSelection";
import type { SelectionAnchor } from "@/client/components/table/tableSelection";
const HEADER_TOOLTIPS: Record<string, string> = {
keyword: "The search term being tracked in Google",
@ -114,30 +112,7 @@ const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
function makeSelectColumn(
anchorRef: MutableRefObject<SelectionAnchor | null>,
): ColumnDef<RankTrackingRow> {
return {
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) =>
applyShiftRangeSelection(event, row, table, anchorRef)
}
onChange={row.getToggleSelectedHandler()}
/>
),
};
return makeSelectionColumn<RankTrackingRow>(anchorRef);
}
const keywordColumn: ColumnDef<RankTrackingRow> = {

View File

@ -1,19 +1,17 @@
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Loader2, Trash2 } from "lucide-react";
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Modal } from "@/client/components/Modal";
import {
AppDataTable,
useAppTable,
} from "@/client/components/table/AppDataTable";
import { useMutation, useQueryClient } from "@tanstack/react-query";
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";
import type { SelectionAnchor } from "@/client/components/table/tableSelection";
export function RankTrackingTable({
totalCount,
@ -47,14 +45,13 @@ export function RankTrackingTable({
selectAnchorRef,
);
const table = useReactTable({
const table = useAppTable({
data: rows,
columns,
initialState: {
sorting: [{ id: defaultSortId, desc: false }],
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
withSorting: true,
getRowId: (row) => row.trackingKeywordId,
enableRowSelection: true,
});
@ -165,37 +162,7 @@ export function RankTrackingTable({
</Modal>
)}
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="align-top">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<AppDataTable table={table} getCellClassName={() => "align-top"} />
<p className="text-xs text-base-content/60 pt-2">
{rows.length} of {totalCount} keywords
</p>

View File

@ -4,7 +4,7 @@ import type { RowSelectionState, Updater } from "@tanstack/react-table";
import {
applyShiftRangeSelection,
type SelectionAnchor,
} from "./tableSelection";
} from "@/client/components/table/tableSelection";
function makeRow(id: string, selectedIds: Set<string>) {
return {