From 9e5afc9d48da4fc0d54cf3d3138d1947f7b3c817 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 6 May 2026 11:23:07 -0400 Subject: [PATCH] feat: Add shared table component (#154) * fix rank tracking unranked filters * improve modal dismissal and range selection * refactor: create shared table component --- src/client/components/table/AppDataTable.tsx | 246 ++++++++++++++++ .../table}/tableSelection.ts | 4 +- src/client/components/table/url.test.ts | 33 +++ src/client/components/table/url.tsx | 85 ++++++ .../components/BrandLookupCitationTables.tsx | 69 +---- .../components/BrandLookupResults.tsx | 18 +- .../components/PromptExplorerResults.tsx | 2 +- src/client/features/ai-search/urlDisplay.ts | 24 -- .../features/backlinks/BacklinksTable.tsx | 75 ++--- .../backlinks/ReferringDomainsTable.tsx | 57 +--- .../features/backlinks/TopPagesTable.tsx | 58 +--- .../features/domain/DomainOverviewPage.tsx | 1 - .../domain/components/DomainKeywordsTable.tsx | 264 ++++++++++-------- .../domain/components/DomainPagesTable.tsx | 126 +++++---- .../domain/components/DomainResultsCard.tsx | 3 - src/client/features/domain/utils.ts | 11 - .../keywords/components/KeywordUi.tsx | 55 ---- .../page/KeywordResearchDesktopResults.tsx | 105 +------ .../page/KeywordResearchDesktopTable.tsx | 215 ++++++++++++++ .../state/useKeywordResearchController.ts | 11 +- .../rank-tracking/KeywordSuggestionStep.tsx | 99 ++----- .../rank-tracking/RankTrackingColumns.tsx | 31 +- .../rank-tracking/RankTrackingTable.tsx | 49 +--- .../rank-tracking/tableSelection.test.ts | 2 +- 24 files changed, 909 insertions(+), 734 deletions(-) create mode 100644 src/client/components/table/AppDataTable.tsx rename src/client/{features/rank-tracking => components/table}/tableSelection.ts (92%) create mode 100644 src/client/components/table/url.test.ts create mode 100644 src/client/components/table/url.tsx delete mode 100644 src/client/features/ai-search/urlDisplay.ts create mode 100644 src/client/features/keywords/page/KeywordResearchDesktopTable.tsx diff --git a/src/client/components/table/AppDataTable.tsx b/src/client/components/table/AppDataTable.tsx new file mode 100644 index 0000000..f3878aa --- /dev/null +++ b/src/client/components/table/AppDataTable.tsx @@ -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 = { + headerClassName?: string; + cellClassName?: string | ((row: Row) => string | undefined); +}; + +declare module "@tanstack/react-table" { + interface ColumnMeta extends AppColumnMeta { + readonly __valueType?: TValue; + } +} + +type UseAppTableOptions = Omit< + TableOptions, + "getCoreRowModel" +> & { + withSorting?: boolean; + withExpanded?: boolean; +}; + +export function useAppTable(options: UseAppTableOptions) { + const { withSorting, withExpanded, ...tableOptions } = options; + return useReactTable({ + ...tableOptions, + getCoreRowModel: getCoreRowModel(), + ...(withSorting ? { getSortedRowModel: getSortedRowModel() } : {}), + ...(withExpanded ? { getExpandedRowModel: getExpandedRowModel() } : {}), + }); +} + +export function useSelectionAnchor(): MutableRefObject { + return useRef(null); +} + +export function makeSelectionColumn( + anchorRef: MutableRefObject, +): ColumnDef { + return { + id: "select", + size: 32, + enableSorting: false, + header: ({ table }) => ( + + ), + cell: ({ row, table }) => ( + + ), + }; +} + +function SelectionCheckbox({ + row, + table, + anchorRef, +}: { + row: Row; + table: Table; + anchorRef: MutableRefObject; +}) { + const rangeHandledRef = useRef(false); + return ( + { + event.stopPropagation(); + rangeHandledRef.current = applyShiftRangeSelection( + event, + row, + table, + anchorRef, + ); + }} + onChange={(event) => { + if (rangeHandledRef.current) { + rangeHandledRef.current = false; + return; + } + row.getToggleSelectedHandler()(event); + }} + /> + ); +} + +export function AppDataTable({ + table, + className = "table table-sm", + wrapperClassName = "overflow-x-auto", + empty, + isLoading, + loading, + getRowClassName, + getRowProps, + getCellClassName, + fixedLayout, + stickyHeader, +}: { + table: Table; + className?: string; + wrapperClassName?: string; + empty?: ReactNode; + isLoading?: boolean; + loading?: ReactNode; + getRowClassName?: (row: Row) => string | undefined; + getRowProps?: (row: Row) => { + onClick?: (event: MouseEvent) => void; + className?: string; + }; + getCellClassName?: (row: Row, columnId: string) => string | undefined; + fixedLayout?: boolean; + stickyHeader?: boolean; +}) { + if (isLoading && loading) return <>{loading}; + if (table.getRowModel().rows.length === 0 && empty) return <>{empty}; + + return ( +
+ + {fixedLayout ? ( + + {table.getVisibleLeafColumns().map((column) => ( + + ))} + + ) : null} + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => { + const rowProps = getRowProps?.(row); + return ( + + {row.getVisibleCells().map((cell) => { + const rawMeta: unknown = cell.column.columnDef.meta; + const meta = isAppColumnMeta(rawMeta) + ? rawMeta + : undefined; + const metaClass = meta?.cellClassName; + return ( + + ); + })} + + ); + })} + +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ ); +} + +function HeaderCell({ + header, + fixedLayout, + stickyHeader, +}: { + header: Header; + fixedLayout?: boolean; + stickyHeader?: boolean; +}) { + const rawMeta: unknown = header.column.columnDef.meta; + const meta = isAppColumnMeta(rawMeta) ? rawMeta : undefined; + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); +} + +function isAppColumnMeta(value: unknown): value is AppColumnMeta { + return typeof value === "object" && value !== null; +} diff --git a/src/client/features/rank-tracking/tableSelection.ts b/src/client/components/table/tableSelection.ts similarity index 92% rename from src/client/features/rank-tracking/tableSelection.ts rename to src/client/components/table/tableSelection.ts index a4edb65..cfb732e 100644 --- a/src/client/features/rank-tracking/tableSelection.ts +++ b/src/client/components/table/tableSelection.ts @@ -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( : [currentIndex, anchorIndex]; const selected = anchorRef.current.selected; - table.setRowSelection((currentSelection) => { + table.setRowSelection((currentSelection: RowSelectionState) => { const nextSelection = { ...currentSelection }; for (let index = from; index <= to; index++) { diff --git a/src/client/components/table/url.test.ts b/src/client/components/table/url.test.ts new file mode 100644 index 0000000..71e54be --- /dev/null +++ b/src/client/components/table/url.test.ts @@ -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(); + }); +}); diff --git a/src/client/components/table/url.tsx b/src/client/components/table/url.tsx new file mode 100644 index 0000000..a34e6f2 --- /dev/null +++ b/src/client/components/table/url.tsx @@ -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 {empty}; + } + + const visibleLabel = label ?? getUrlDisplayLabel(value, display); + return ( + + {visibleLabel} + + + ); +} + +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; + } +} diff --git a/src/client/features/ai-search/components/BrandLookupCitationTables.tsx b/src/client/features/ai-search/components/BrandLookupCitationTables.tsx index a14bd88..1a3a69f 100644 --- a/src/client/features/ai-search/components/BrandLookupCitationTables.tsx +++ b/src/client/features/ai-search/components/BrandLookupCitationTables.tsx @@ -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({ urlLikeColumnId: string; }) { return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isNumeric = header.column.getCanSort(); - return ( - - ); - })} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const isNumeric = cell.column.getCanSort(); - return ( - - ); - })} - - ))} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
+ + cellClassName( + columnId, + urlLikeColumnId, + table.getColumn(columnId)?.getCanSort() ?? false, + ) + } + getRowClassName={() => ""} + /> ); } diff --git a/src/client/features/ai-search/components/BrandLookupResults.tsx b/src/client/features/ai-search/components/BrandLookupResults.tsx index 1a1f6c3..a7d68d4 100644 --- a/src/client/features/ai-search/components/BrandLookupResults.tsx +++ b/src/client/features/ai-search/components/BrandLookupResults.tsx @@ -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 diff --git a/src/client/features/ai-search/components/PromptExplorerResults.tsx b/src/client/features/ai-search/components/PromptExplorerResults.tsx index b74374f..171769e 100644 --- a/src/client/features/ai-search/components/PromptExplorerResults.tsx +++ b/src/client/features/ai-search/components/PromptExplorerResults.tsx @@ -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, diff --git a/src/client/features/ai-search/urlDisplay.ts b/src/client/features/ai-search/urlDisplay.ts deleted file mode 100644 index a512871..0000000 --- a/src/client/features/ai-search/urlDisplay.ts +++ /dev/null @@ -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; - } -} diff --git a/src/client/features/backlinks/BacklinksTable.tsx b/src/client/features/backlinks/BacklinksTable.tsx index 443c2e0..affa959 100644 --- a/src/client/features/backlinks/BacklinksTable.tsx +++ b/src/client/features/backlinks/BacklinksTable.tsx @@ -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 ( -
- - - {table.getVisibleLeafColumns().map((column) => ( - - ))} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
+ ({ + 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, + })} + /> ); } diff --git a/src/client/features/backlinks/ReferringDomainsTable.tsx b/src/client/features/backlinks/ReferringDomainsTable.tsx index 9c75f7f..6e9b62b 100644 --- a/src/client/features/backlinks/ReferringDomainsTable.tsx +++ b/src/client/features/backlinks/ReferringDomainsTable.tsx @@ -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(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 ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
+ + columnId === "domain" ? "font-medium break-all" : undefined + } + /> ); } diff --git a/src/client/features/backlinks/TopPagesTable.tsx b/src/client/features/backlinks/TopPagesTable.tsx index 338486c..51138e0 100644 --- a/src/client/features/backlinks/TopPagesTable.tsx +++ b/src/client/features/backlinks/TopPagesTable.tsx @@ -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(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 ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
+ + columnId === "page" ? "min-w-80" : undefined + } + /> ); } diff --git a/src/client/features/domain/DomainOverviewPage.tsx b/src/client/features/domain/DomainOverviewPage.tsx index 50bd9c5..a03132e 100644 --- a/src/client/features/domain/DomainOverviewPage.tsx +++ b/src/client/features/domain/DomainOverviewPage.tsx @@ -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} diff --git a/src/client/features/domain/components/DomainKeywordsTable.tsx b/src/client/features/domain/components/DomainKeywordsTable.tsx index a19ea33..68ccdfb 100644 --- a/src/client/features/domain/components/DomainKeywordsTable.tsx +++ b/src/client/features/domain/components/DomainKeywordsTable.tsx @@ -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(); + export function DomainKeywordsTable({ domain, rows, @@ -33,8 +42,122 @@ export function DomainKeywordsTable({ currentSortOrder, onSortClick, onToggleKeyword, - onToggleAllVisible, }: Props) { + const selectAnchorRef = useSelectionAnchor(); + const rowSelection = useMemo( + () => + Object.fromEntries( + [...selectedKeywords].map((keyword) => [keyword, true]), + ) as RowSelectionState, + [selectedKeywords], + ); + const columns = useMemo[]>( + () => [ + makeSelectionColumn(selectAnchorRef), + keywordColumnHelper.accessor("keyword", { + header: () => "Keyword", + cell: ({ getValue }) => ( + {getValue()} + ), + }), + keywordColumnHelper.accessor("position", { + header: () => ( + onSortClick("rank")} + /> + ), + cell: ({ getValue }) => getValue() ?? "-", + }), + keywordColumnHelper.accessor("searchVolume", { + header: () => ( + onSortClick("volume")} + /> + ), + cell: ({ getValue }) => formatNumber(getValue()), + }), + keywordColumnHelper.accessor("traffic", { + header: () => ( + onSortClick("traffic")} + /> + ), + cell: ({ getValue }) => formatFloat(getValue()), + }), + keywordColumnHelper.accessor("cpc", { + header: () => ( + onSortClick("cpc")} + /> + ), + cell: ({ getValue }) => { + const value = getValue(); + return value == null ? "-" : `$${value.toFixed(2)}`; + }, + }), + keywordColumnHelper.display({ + id: "url", + header: () => "URL", + cell: ({ row }) => ( + + ), + meta: { + cellClassName: "max-w-[260px] truncate", + }, + }), + keywordColumnHelper.accessor("keywordDifficulty", { + header: () => ( + onSortClick("score")} + /> + ), + cell: ({ 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 (
@@ -42,121 +165,16 @@ export function DomainKeywordsTable({ ? `${selectedKeywords.size} selected` : "Select keywords to save"}
- - - - - - - - - - - - - - - {rows.length === 0 ? ( - - - - ) : ( - rows.map((row) => { - const href = resolveDomainPageHref( - row.relativeUrl ?? row.url, - domain, - ); - - return ( - - - - - - - - - - - ); - }) - )} - -
- 0 && - visibleKeywords.every((keyword) => - selectedKeywords.has(keyword), - ) - } - onChange={onToggleAllVisible} - /> - Keyword - onSortClick("rank")} - /> - - onSortClick("volume")} - /> - - onSortClick("traffic")} - /> - - onSortClick("cpc")} - /> - URL - onSortClick("score")} - /> -
- No keywords match this search. -
- onToggleKeyword(row.keyword)} - aria-label={`Select ${row.keyword}`} - /> - {row.keyword}{row.position ?? "-"}{formatNumber(row.searchVolume)}{formatFloat(row.traffic)}{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`} - {href ? ( - - ) : ( - "-" - )} - - -
+ + No keywords match this search. +
+ } + /> ); } diff --git a/src/client/features/domain/components/DomainPagesTable.tsx b/src/client/features/domain/components/DomainPagesTable.tsx index e021325..c3004da 100644 --- a/src/client/features/domain/components/DomainPagesTable.tsx +++ b/src/client/features/domain/components/DomainPagesTable.tsx @@ -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(); + export function DomainPagesTable({ domain, rows, @@ -27,65 +34,62 @@ export function DomainPagesTable({ currentSortOrder, onSortClick, }: Props) { - return ( -
- - - - - - - - - - {rows.length === 0 ? ( - - - - ) : ( - rows.slice(0, 100).map((row) => { - const href = resolveDomainPageHref( - row.relativePath ?? row.page, - domain, - ); + const columns = useMemo[]>( + () => [ + pageColumnHelper.display({ + id: "page", + header: () => "Page", + cell: ({ row }) => ( + + ), + meta: { + cellClassName: "max-w-[420px] truncate", + }, + }), + pageColumnHelper.accessor("organicTraffic", { + header: () => ( + onSortClick("traffic")} + /> + ), + cell: ({ getValue }) => formatFloat(getValue()), + }), + pageColumnHelper.accessor("keywords", { + header: () => ( + onSortClick("volume")} + /> + ), + cell: ({ getValue }) => formatNumber(getValue()), + }), + ], + [currentSortOrder, domain, onSortClick, sortMode], + ); + const table = useAppTable({ + data: rows.slice(0, 100), + columns, + }); - return ( - - - - - - ); - }) - )} - -
Page - onSortClick("traffic")} - /> - - onSortClick("volume")} - /> -
- No pages match this search. -
- {href ? ( - - ) : ( - (row.relativePath ?? row.page) - )} - {formatFloat(row.organicTraffic)}{formatNumber(row.keywords)}
-
+ return ( + + No pages match this search. + + } + /> ); } diff --git a/src/client/features/domain/components/DomainResultsCard.tsx b/src/client/features/domain/components/DomainResultsCard.tsx index 6854c98..e53a4a3 100644 --- a/src/client/features/domain/components/DomainResultsCard.tsx +++ b/src/client/features/domain/components/DomainResultsCard.tsx @@ -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} /> ) : ( diff --git a/src/client/features/domain/utils.ts b/src/client/features/domain/utils.ts index f22ae86..588acea 100644 --- a/src/client/features/domain/utils.ts +++ b/src/client/features/domain/utils.ts @@ -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}`}`; -} diff --git a/src/client/features/keywords/components/KeywordUi.tsx b/src/client/features/keywords/components/KeywordUi.tsx index cad8561..1633fa7 100644 --- a/src/client/features/keywords/components/KeywordUi.tsx +++ b/src/client/features/keywords/components/KeywordUi.tsx @@ -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 ( -
- { - e.stopPropagation(); - onToggle(); - }} - onClick={(e) => e.stopPropagation()} - /> - - - {row.keyword} - - - - {formatNumber(row.searchVolume)} - - - {row.cpc == null ? "-" : row.cpc.toFixed(2)} - - - {row.competition == null ? "-" : row.competition.toFixed(2)} - - -
- -
-
- ); -} - export function KeywordCard({ row, isSelected, diff --git a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx index 42d24ab..5a83c96 100644 --- a/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx +++ b/src/client/features/keywords/page/KeywordResearchDesktopResults.tsx @@ -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) { {showFilters ? : null} - - + ); } @@ -262,93 +270,6 @@ function DesktopFilters({ controller }: Props) { ); } -function DesktopTableHeader({ controller }: Props) { - const { filteredRows, selectedRows } = controller; - - return ( -
- 0 && selectedRows.size === filteredRows.length - } - onChange={controller.toggleAllRows} - /> - - - - - -
- ); -} - -function DesktopTableRows({ controller }: Props) { - const { activeFilterCount, filteredRows, overviewKeyword, selectedRows } = - controller; - - return ( -
- {filteredRows.length === 0 ? ( - - ) : ( - filteredRows.map((row) => ( - controller.toggleRowSelection(row.keyword)} - onClick={() => controller.handleRowClick(row)} - /> - )) - )} -
- ); -} - function DesktopSerpPanel({ controller }: Props) { const { overviewKeyword } = controller; const trendRangeLabel = overviewKeyword diff --git a/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx b/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx new file mode 100644 index 0000000..1be17dd --- /dev/null +++ b/src/client/features/keywords/page/KeywordResearchDesktopTable.tsx @@ -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; + setSelectedRows: (rows: Set) => void; + sortDir: SortDir; + sortField: SortField; + toggleSort: (field: SortField) => void; + resetFilters: () => void; + handleRowClick: (row: KeywordResearchRow) => void; +}; + +const keywordColumnHelper = createColumnHelper(); + +export function KeywordResearchDesktopTable({ + activeFilterCount, + filteredRows, + overviewKeyword, + selectedRows, + setSelectedRows, + sortDir, + sortField, + toggleSort, + resetFilters, + handleRowClick, +}: Props) { + const selectAnchorRef = useSelectionAnchor(); + const rowSelection = useMemo( + () => + Object.fromEntries( + [...selectedRows].map((keyword) => [keyword, true]), + ) as RowSelectionState, + [selectedRows], + ); + const columns = useMemo[]>( + () => [ + makeSelectionColumn(selectAnchorRef), + keywordColumnHelper.accessor("keyword", { + header: () => ( + + ), + cell: ({ row }) => ( + + {row.original.keyword} + + ), + meta: { cellClassName: "min-w-0" }, + }), + keywordColumnHelper.accessor("searchVolume", { + header: () => ( + + ), + cell: ({ getValue }) => formatNumber(getValue()), + meta: { + headerClassName: "text-right", + cellClassName: "text-right tabular-nums text-base-content/70", + }, + }), + keywordColumnHelper.accessor("cpc", { + header: () => ( + + ), + 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: () => ( + + ), + 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: () => ( + + ), + cell: ({ 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 ( +
+ {filteredRows.length === 0 ? ( + + ) : ( + ({ + 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), + })} + /> + )} +
+ ); +} + +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 ( + + {value} + + ); +} diff --git a/src/client/features/keywords/state/useKeywordResearchController.ts b/src/client/features/keywords/state/useKeywordResearchController.ts index 30f4316..70dae31 100644 --- a/src/client/features/keywords/state/useKeywordResearchController.ts +++ b/src/client/features/keywords/state/useKeywordResearchController.ts @@ -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, diff --git a/src/client/features/rank-tracking/KeywordSuggestionStep.tsx b/src/client/features/rank-tracking/KeywordSuggestionStep.tsx index 7405ce9..18767a7 100644 --- a/src/client/features/rank-tracking/KeywordSuggestionStep.tsx +++ b/src/client/features/rank-tracking/KeywordSuggestionStep.tsx @@ -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[]>( () => [ - { - id: "select", - size: 32, - enableSorting: false, - header: ({ table }) => ( - - ), - cell: ({ row, table }) => ( - { - event.stopPropagation(); - applyShiftRangeSelection(event, row, table, selectAnchorRef); - }} - onChange={row.getToggleSelectedHandler()} - /> - ), - }, + makeSelectionColumn(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({ -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - { - if ( - applyShiftRangeSelection(event, row, table, selectAnchorRef) - ) { - return; - } + ({ + className: "hover:bg-base-200/50 cursor-pointer", + onClick: (event) => { + if (applyShiftRangeSelection(event, row, table, selectAnchorRef)) { + return; + } - row.toggleSelected(); - }} - > - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
+ row.toggleSelected(); + }, + })} + />