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:
parent
7f892ca433
commit
9e5afc9d48
246
src/client/components/table/AppDataTable.tsx
Normal file
246
src/client/components/table/AppDataTable.tsx
Normal 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;
|
||||
}
|
||||
@ -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++) {
|
||||
33
src/client/components/table/url.test.ts
Normal file
33
src/client/components/table/url.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
85
src/client/components/table/url.tsx
Normal file
85
src/client/components/table/url.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
urlLikeColumnId,
|
||||
isNumeric,
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<AppDataTable
|
||||
table={table}
|
||||
getCellClassName={(_, columnId) =>
|
||||
cellClassName(
|
||||
columnId,
|
||||
urlLikeColumnId,
|
||||
table.getColumn(columnId)?.getCanSort() ?? false,
|
||||
)
|
||||
}
|
||||
getRowClassName={() => ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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={
|
||||
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>
|
||||
<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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<AppDataTable
|
||||
table={table}
|
||||
getCellClassName={(_, columnId) =>
|
||||
columnId === "domain" ? "font-medium break-all" : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,8 +42,122 @@ export function DomainKeywordsTable({
|
||||
currentSortOrder,
|
||||
onSortClick,
|
||||
onToggleKeyword,
|
||||
onToggleAllVisible,
|
||||
}: Props) {
|
||||
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")}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => getValue() ?? "-",
|
||||
}),
|
||||
keywordColumnHelper.accessor("searchVolume", {
|
||||
header: () => (
|
||||
<SortableHeader
|
||||
label="Volume"
|
||||
isActive={sortMode === "volume"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("volume")}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => formatNumber(getValue()),
|
||||
}),
|
||||
keywordColumnHelper.accessor("traffic", {
|
||||
header: () => (
|
||||
<SortableHeader
|
||||
label="Traffic"
|
||||
isActive={sortMode === "traffic"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("traffic")}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => formatFloat(getValue()),
|
||||
}),
|
||||
keywordColumnHelper.accessor("cpc", {
|
||||
header: () => (
|
||||
<SortableHeader
|
||||
label="CPC"
|
||||
helpText="Cost per click in USD."
|
||||
isActive={sortMode === "cpc"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("cpc")}
|
||||
/>
|
||||
),
|
||||
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."
|
||||
isActive={sortMode === "score"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("score")}
|
||||
/>
|
||||
),
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="mb-2 text-xs text-base-content/60">
|
||||
@ -42,121 +165,16 @@ export function DomainKeywordsTable({
|
||||
? `${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>
|
||||
<SortableHeader
|
||||
label="Rank"
|
||||
isActive={sortMode === "rank"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("rank")}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<SortableHeader
|
||||
label="Volume"
|
||||
isActive={sortMode === "volume"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("volume")}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<SortableHeader
|
||||
label="Traffic"
|
||||
isActive={sortMode === "traffic"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("traffic")}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<SortableHeader
|
||||
label="CPC"
|
||||
helpText="Cost per click in USD."
|
||||
isActive={sortMode === "cpc"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("cpc")}
|
||||
/>
|
||||
</th>
|
||||
<th>URL</th>
|
||||
<th>
|
||||
<SortableHeader
|
||||
label="Score"
|
||||
helpText="Keyword difficulty score."
|
||||
isActive={sortMode === "score"}
|
||||
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,
|
||||
);
|
||||
|
||||
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}`}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<SortableHeader
|
||||
label="Organic Traffic"
|
||||
isActive={toPageSortMode(sortMode) === "traffic"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("traffic")}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<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,
|
||||
);
|
||||
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")}
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => formatFloat(getValue()),
|
||||
}),
|
||||
pageColumnHelper.accessor("keywords", {
|
||||
header: () => (
|
||||
<SortableHeader
|
||||
label="Keywords"
|
||||
isActive={toPageSortMode(sortMode) === "keywords"}
|
||||
order={currentSortOrder}
|
||||
onClick={() => onSortClick("volume")}
|
||||
/>
|
||||
),
|
||||
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>
|
||||
</div>
|
||||
return (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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}`}`;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
<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>
|
||||
row.toggleSelected();
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user