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

View File

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

View File

@ -239,7 +239,7 @@ function ReferringDomainsFilters({
/> />
</div> </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 <FilterRangeInputs
form={form} form={form}
title="Backlinks" title="Backlinks"
@ -252,6 +252,13 @@ function ReferringDomainsFilters({
minName="minRank" minName="minRank"
maxName="maxRank" maxName="maxRank"
/> />
<FilterRangeInputs
form={form}
title="Spam Score"
minName="minSpamScore"
maxName="maxSpamScore"
step="0.1"
/>
</div> </div>
</> </>
); );

View File

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

View File

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

View File

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

View File

@ -19,6 +19,8 @@ export type ReferringDomainsFilterValues = {
maxBacklinks: string; maxBacklinks: string;
minRank: string; minRank: string;
maxRank: string; maxRank: string;
minSpamScore: string;
maxSpamScore: string;
}; };
export type TopPagesFilterValues = { export type TopPagesFilterValues = {
@ -53,6 +55,8 @@ export const EMPTY_REFERRING_DOMAINS_FILTERS: ReferringDomainsFilterValues = {
maxBacklinks: "", maxBacklinks: "",
minRank: "", minRank: "",
maxRank: "", maxRank: "",
minSpamScore: "",
maxSpamScore: "",
}; };
export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = { 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; return false;
if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank)) if (!passesNumericFilter(row.rank, filters.minRank, filters.maxRank))
return false; return false;
if (
!passesNumericFilter(
row.spamScore,
filters.minSpamScore,
filters.maxSpamScore,
)
)
return false;
return true; return true;
}); });
} }

View File

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