Fix backlink tab states (#218)
* Fix tab active state wiring across result views * Add spam filters to referring domains
This commit is contained in:
parent
a3721a0f53
commit
45234e6945
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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
|
||||
? {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
46
src/client/features/backlinks/backlinksFiltering.test.ts
Normal file
46
src/client/features/backlinks/backlinksFiltering.test.ts
Normal 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]]);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user