← back to Open Seo
src/client/features/lighthouse/issues/LighthouseIssuesScreen.tsx
252 lines
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, TriangleAlert } from "lucide-react";
import { toast } from "sonner";
import {
exportAuditLighthouseIssues,
getAuditLighthouseIssues,
} from "@/serverFunctions/lighthouse";
import { downloadFile } from "@/client/lib/download";
import { exportTableToSheets } from "@/client/lib/exportToSheets";
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
import { categoryLabel, issuesToCsv, issuesToTable } from "./utils";
import {
LighthouseIssueList,
LighthouseIssuesHeader,
LighthouseIssuesToolbar,
} from "./LighthouseIssuesParts";
import { categoryTabs } from "./types";
type LighthouseIssuesScreenProps = {
projectId: string;
resultId: string;
category: CategoryTab;
backLabel: string;
onBack: () => void;
onCategoryChange: (next: CategoryTab) => void;
};
export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
const { projectId, resultId, category, backLabel, onBack, onCategoryChange } =
props;
const issuesQuery = useQuery({
queryKey: ["auditLighthouseIssues", projectId, resultId],
queryFn: () =>
getAuditLighthouseIssues({
data: {
projectId,
resultId,
},
}),
});
const exportMutation = useMutation({
mutationFn: (
data: ExportPayload,
): Promise<{ filename: string; content: string }> =>
exportAuditLighthouseIssues({
data: {
projectId,
resultId,
...data,
},
}),
});
const {
allIssues,
categoryCounts,
runCopy,
runExport,
runExportCsv,
runExportSheets,
selectedCategoryLabel,
severityCounts,
visibleIssues,
} = useLighthouseIssuesActions({
category,
exportMutation,
allIssues: issuesQuery.data?.issues ?? [],
});
const issuesErrorMessage =
issuesQuery.error instanceof Error
? issuesQuery.error.message
: "Failed to load Lighthouse issues.";
const showsLegacyPayloadNotice =
issuesQuery.data != null && !issuesQuery.data.hasIssueDetails;
const emptyMessage = showsLegacyPayloadNotice
? "This audit was saved without issue-level Lighthouse details. Re-run the audit to populate this screen."
: undefined;
return (
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
<LighthouseIssuesHeader
backLabel={backLabel}
onBack={onBack}
scannedAt={issuesQuery.data?.createdAt}
finalUrl={issuesQuery.data?.finalUrl}
scores={issuesQuery.data?.scores}
metrics={issuesQuery.data?.metrics}
severityCounts={severityCounts}
/>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
{issuesQuery.isError ? (
<div className="alert alert-error">
<AlertCircle className="size-4" />
<span>{issuesErrorMessage}</span>
</div>
) : null}
{showsLegacyPayloadNotice ? (
<div className="alert alert-warning">
<TriangleAlert className="size-4" />
<span>
This Lighthouse run was stored before issue details were
preserved. Re-run the audit to see category counts and issue
cards.
</span>
</div>
) : null}
<LighthouseIssuesToolbar
category={category}
categoryCounts={categoryCounts}
selectedCategoryLabel={selectedCategoryLabel}
isBusy={exportMutation.isPending}
visibleIssues={visibleIssues}
allIssues={allIssues}
onCategoryChange={onCategoryChange}
onCopy={(data, message) => {
void runCopy(data, message);
}}
onExport={(data) => {
void runExport(data);
}}
onExportCsv={runExportCsv}
onExportSheets={runExportSheets}
/>
<LighthouseIssueList
issues={visibleIssues}
isLoading={issuesQuery.isLoading}
emptyMessage={emptyMessage}
/>
</div>
</div>
</div>
</div>
);
}
function useLighthouseIssuesActions({
allIssues,
category,
exportMutation,
}: {
allIssues: LighthouseIssue[];
category: CategoryTab;
exportMutation: {
mutateAsync: (
data: ExportPayload,
) => Promise<{ filename: string; content: string }>;
};
}) {
const visibleIssues =
category === "all"
? allIssues
: allIssues.filter((issue) => issue.category === category);
const selectedCategoryLabel = categoryLabel(category);
const categoryCounts = getCategoryCounts(allIssues);
const severityCounts = getSeverityCounts(visibleIssues);
const runExport = async (data: ExportPayload) => {
try {
const exported = await exportMutation.mutateAsync(data);
downloadFile(exported.content, exported.filename, "application/json");
toast.success("Download started");
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to export payload";
toast.error(message);
}
};
const runExportCsv = (
rows: LighthouseIssue[],
variant: "all" | "current",
) => {
const filename = `lighthouse-${variant}-${category}-issues.csv`;
downloadFile(issuesToCsv(rows), filename, "text/csv");
toast.success("CSV download started");
};
const runExportSheets = (
rows: LighthouseIssue[],
variant: "all" | "current",
) => {
const table = issuesToTable(rows);
void exportTableToSheets({
headers: table.headers,
rows: table.rows,
feature: `lighthouse_issues_${variant}`,
});
};
const runCopy = async (data: ExportPayload, toastMessage: string) => {
try {
const exported = await exportMutation.mutateAsync(data);
await navigator.clipboard.writeText(exported.content);
toast.success(toastMessage);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to copy payload";
toast.error(message);
}
};
return {
allIssues,
categoryCounts,
runCopy,
runExport,
runExportCsv,
runExportSheets,
selectedCategoryLabel,
severityCounts,
visibleIssues,
};
}
function getCategoryCounts(
allIssues: LighthouseIssue[],
): Record<CategoryTab, number> {
return categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => {
if (tab === "all") {
acc[tab] = allIssues.length;
return acc;
}
acc[tab] = allIssues.filter((issue) => issue.category === tab).length;
return acc;
},
{
all: allIssues.length,
performance: 0,
accessibility: 0,
"best-practices": 0,
seo: 0,
},
);
}
function getSeverityCounts(issues: LighthouseIssue[]) {
return {
critical: issues.filter((issue) => issue.severity === "critical").length,
warning: issues.filter((issue) => issue.severity === "warning").length,
info: issues.filter((issue) => issue.severity === "info").length,
};
}