Fix backlink tab states (#218)

* Fix tab active state wiring across result views

* Add spam filters to referring domains
This commit is contained in:
Ben Senescu 2026-05-22 22:49:05 -04:00 committed by GitHub
parent a3721a0f53
commit 45234e6945
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 153 additions and 61 deletions

View File

@ -277,6 +277,8 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
const canExport = exportTable.rows.length > 0;
const currentFilterCount = filters[activeTab].activeFilterCount;
const queriesActive = activeTab === "queries";
const pagesActive = activeTab === "pages";
return (
<section className="overflow-hidden rounded-xl border border-base-300 bg-base-100">
@ -285,7 +287,8 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
<button
type="button"
role="tab"
className={`tab ${activeTab === "queries" ? "tab-active" : ""}`}
aria-selected={queriesActive}
className={`tab ${queriesActive ? "tab-active" : ""}`}
onClick={() => setActiveTab("queries")}
>
Queries
@ -293,7 +296,8 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
<button
type="button"
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
aria-selected={pagesActive}
className={`tab ${pagesActive ? "tab-active" : ""}`}
onClick={() => setActiveTab("pages")}
>
Related pages

View File

@ -1,5 +1,4 @@
import { useMemo } from "react";
import { Link } from "@tanstack/react-router";
import { StatCard } from "@/client/features/audit/shared";
import {
exportPages,
@ -13,14 +12,18 @@ import {
PerformanceTable,
} from "@/client/features/audit/results/ResultsTables";
type ResultsTab = "pages" | "performance";
export function ResultsView({
projectId,
data,
onTabChange,
tab,
}: {
projectId: string;
data: AuditResultsData;
tab: string;
onTabChange: (tab: ResultsTab) => void;
}) {
const { audit, pages, lighthouse } = data;
const hasPerformanceTab = lighthouse.length > 0;
@ -40,12 +43,11 @@ export function ResultsView({
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<ResultsHeader
projectId={projectId}
auditId={audit.id}
pageCount={pages.length}
lighthouseCount={lighthouse.length}
hasPerformanceTab={hasPerformanceTab}
activeTab={activeTab}
onTabChange={onTabChange}
onExport={(format) => {
if (activeTab === "performance") {
exportPerformance(lighthouse, pages, format);
@ -115,46 +117,45 @@ function useResultStats(
}
function ResultsHeader({
projectId,
auditId,
pageCount,
lighthouseCount,
hasPerformanceTab,
activeTab,
onTabChange,
onExport,
}: {
projectId: string;
auditId: string;
pageCount: number;
lighthouseCount: number;
hasPerformanceTab: boolean;
activeTab: string;
onTabChange: (tab: ResultsTab) => void;
onExport: (format: "csv" | "json" | "sheets") => void;
}) {
const tabs: Array<{ tab: ResultsTab; label: string }> = [
{ tab: "pages", label: `Pages (${pageCount})` },
{ tab: "performance", label: `Performance (${lighthouseCount})` },
];
return (
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
{hasPerformanceTab ? (
<div role="tablist" className="tabs tabs-box w-fit">
<Link
to="/p/$projectId/audit"
params={{ projectId }}
search={{ auditId, tab: "pages" }}
replace
role="tab"
className={`tab ${activeTab === "pages" ? "tab-active" : ""}`}
>
Pages ({pageCount})
</Link>
<Link
to="/p/$projectId/audit"
params={{ projectId }}
search={{ auditId, tab: "performance" }}
replace
role="tab"
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
>
Performance ({lighthouseCount})
</Link>
{tabs.map(({ label, tab }) => {
const isActive = activeTab === tab;
return (
<button
key={tab}
type="button"
role="tab"
aria-selected={isActive}
className={`tab ${isActive ? "tab-active" : ""}`}
onClick={() => onTabChange(tab)}
>
{label}
</button>
);
})}
</div>
) : (
<h3 className="text-base font-medium">Pages ({pageCount})</h3>

View File

@ -239,7 +239,7 @@ function ReferringDomainsFilters({
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<FilterRangeInputs
form={form}
title="Backlinks"
@ -252,6 +252,13 @@ function ReferringDomainsFilters({
minName="minRank"
maxName="maxRank"
/>
<FilterRangeInputs
form={form}
title="Spam Score"
minName="minSpamScore"
maxName="maxSpamScore"
step="0.1"
/>
</div>
</>
);

View File

@ -65,6 +65,18 @@ export function BacklinksPage({
},
[navigate],
);
const handleResultTabChange = useCallback(
(tab: BacklinksSearchState["tab"]) => {
navigate({
search: (prev) => ({
...prev,
tab: tab === "backlinks" ? undefined : tab,
}),
replace: true,
});
},
[navigate],
);
const searchTabs = useSearchTabNavigation({
storageKey: `backlinks:${projectId}`,
urlInput: urlTabInput,
@ -134,6 +146,7 @@ export function BacklinksPage({
topPages={topPagesQuery.data}
onRemoveHistoryItem={removeHistoryItem}
onRetryOverview={() => void overviewQuery.refetch()}
onTabChange={handleResultTabChange}
searchTabs={
searchState.target
? {

View File

@ -47,6 +47,7 @@ type BacklinksBodyProps = {
topPages: BacklinksTopPagesData | undefined;
onRemoveHistoryItem: (timestamp: number) => void;
onRetryOverview: () => void;
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
searchTabs: {
activeTabId: string | null;
tabs: SearchTab[];
@ -73,6 +74,7 @@ export function BacklinksBody({
topPages,
onRemoveHistoryItem,
onRetryOverview,
onTabChange,
searchTabs,
}: BacklinksBodyProps) {
const mergedData = useMemo(
@ -179,7 +181,6 @@ export function BacklinksBody({
summaryStats={summaryStats}
/>
<BacklinksResultsCard
projectId={projectId}
activeTab={searchState.tab}
filteredData={filteredData}
filters={filters}
@ -188,6 +189,7 @@ export function BacklinksBody({
searchState.tab !== "backlinks" ? tabErrorMessage : null
}
exportTarget={mergedData.displayTarget || searchState.target}
onTabChange={onTabChange}
/>
</>
);

View File

@ -22,6 +22,15 @@ import {
import { buildBacklinksTabExport, exportBacklinksTabCsv } from "./export";
import type { BacklinksFiltersState } from "./useBacklinksFilters";
const BACKLINKS_RESULTS_TABS: Array<{
tab: BacklinksSearchState["tab"];
label: string;
}> = [
{ tab: "backlinks", label: "Backlinks" },
{ tab: "domains", label: "Referring Domains" },
{ tab: "pages", label: "Top Pages" },
];
export function BacklinksOverviewPanels({
projectId,
data,
@ -66,15 +75,14 @@ export function BacklinksOverviewPanels({
}
export function BacklinksResultsCard({
projectId,
activeTab,
filteredData,
filters,
isTabLoading,
tabErrorMessage,
exportTarget,
onTabChange,
}: {
projectId: string;
activeTab: BacklinksSearchState["tab"];
filteredData: {
backlinks: BacklinksOverviewData["backlinks"];
@ -85,6 +93,7 @@ export function BacklinksResultsCard({
isTabLoading: boolean;
tabErrorMessage: string | null;
exportTarget: string;
onTabChange: (tab: BacklinksSearchState["tab"]) => void;
}) {
const currentFilterCount = filters[activeTab].activeFilterCount;
const exportTable = useMemo(
@ -97,19 +106,15 @@ export function BacklinksResultsCard({
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 px-4 py-3 border-b border-base-300">
<div className="space-y-2">
<div role="tablist" className="tabs tabs-box w-fit">
<TabLink
projectId={projectId}
activeTab={activeTab}
tab="backlinks"
>
Backlinks
</TabLink>
<TabLink projectId={projectId} activeTab={activeTab} tab="domains">
Referring Domains
</TabLink>
<TabLink projectId={projectId} activeTab={activeTab} tab="pages">
Top Pages
</TabLink>
{BACKLINKS_RESULTS_TABS.map(({ label, tab }) => (
<TabLink
key={tab}
activeTab={activeTab}
label={label}
onSelect={onTabChange}
tab={tab}
/>
))}
</div>
<p className="max-w-xl text-sm text-base-content/60">
{TAB_DESCRIPTIONS[activeTab]}
@ -276,30 +281,28 @@ function TrendCard({
}
function TabLink({
projectId,
activeTab,
children,
label,
onSelect,
tab,
}: {
projectId: string;
activeTab: BacklinksSearchState["tab"];
children: string;
label: string;
onSelect: (tab: BacklinksSearchState["tab"]) => void;
tab: BacklinksSearchState["tab"];
}) {
const isActive = activeTab === tab;
return (
<Link
to="/p/$projectId/backlinks"
params={{ projectId }}
search={(prev) => ({
...prev,
tab: tab === "backlinks" ? undefined : tab,
})}
replace
<button
type="button"
role="tab"
className={`tab ${activeTab === tab ? "tab-active" : ""}`}
aria-selected={isActive}
className={`tab ${isActive ? "tab-active" : ""}`}
onClick={() => onSelect(tab)}
>
{children}
</Link>
{label}
</button>
);
}

View File

@ -19,6 +19,8 @@ export type ReferringDomainsFilterValues = {
maxBacklinks: string;
minRank: string;
maxRank: string;
minSpamScore: string;
maxSpamScore: string;
};
export type TopPagesFilterValues = {
@ -53,6 +55,8 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = {
maxBacklinks: "",
minRank: "",
maxRank: "",
minSpamScore: "",
maxSpamScore: "",
};
export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = {

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { BacklinksOverviewData } from "./backlinksPageTypes";
import { EMPTY_REFERRING_DOMAINS_FILTERS } from "./backlinksFilterTypes";
import { filterReferringDomainRows } from "./backlinksFiltering";
type ReferringDomainRow = BacklinksOverviewData["referringDomains"][number];
function makeReferringDomainRow(
overrides: Partial<ReferringDomainRow> = {},
): ReferringDomainRow {
return {
domain: "example.com",
backlinks: 10,
referringPages: 5,
rank: 20,
spamScore: 2,
firstSeen: null,
brokenBacklinks: 0,
brokenPages: 0,
...overrides,
};
}
describe("filterReferringDomainRows", () => {
it("filters by spam score range", () => {
const rows = [
makeReferringDomainRow({ domain: "clean.example", spamScore: 1 }),
makeReferringDomainRow({ domain: "risky.example", spamScore: 7 }),
makeReferringDomainRow({ domain: "unknown.example", spamScore: null }),
];
expect(
filterReferringDomainRows(rows, {
...EMPTY_REFERRING_DOMAINS_FILTERS,
maxSpamScore: "3",
}),
).toEqual([rows[0], rows[2]]);
expect(
filterReferringDomainRows(rows, {
...EMPTY_REFERRING_DOMAINS_FILTERS,
minSpamScore: "3",
}),
).toEqual([rows[1], rows[2]]);
});
});

View File

@ -107,6 +107,14 @@ export function filterReferringDomainRows(
return false;
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
return false;
if (
!passesNumericFilter(
row.spamScore,
filters.minSpamScore,
filters.maxSpamScore,
)
)
return false;
return true;
});
}

View File

@ -56,6 +56,7 @@ function SiteAuditPage() {
auditId={auditId}
tab={tab}
onBack={() => setSearchParams({ auditId: undefined })}
onTabChange={(nextTab) => setSearchParams({ tab: nextTab })}
/>
);
}
@ -65,11 +66,13 @@ function AuditDetail({
auditId,
tab,
onBack,
onTabChange,
}: {
projectId: string;
auditId: string;
tab: string;
onBack: () => void;
onTabChange: (tab: "pages" | "performance") => void;
}) {
const statusQuery = useQuery({
queryKey: ["audit-status", projectId, auditId],
@ -178,6 +181,7 @@ function AuditDetail({
projectId={projectId}
data={resultsQuery.data}
tab={tab}
onTabChange={onTabChange}
/>
)}
</div>