fix audit tables + back link columns (#224)

This commit is contained in:
Ben Senescu 2026-05-26 23:33:26 -04:00 committed by GitHub
parent 8b74c7da61
commit 02a287b98e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 199 additions and 114 deletions

View File

@ -163,3 +163,29 @@ function parseFilterNumber(value: string) {
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
export function nullableNumberSort(
left: { getValue: (columnId: string) => number | null },
right: { getValue: (columnId: string) => number | null },
columnId: string,
) {
const a = left.getValue(columnId);
const b = right.getValue(columnId);
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a - b;
}
export function nullableStringSort(
left: { getValue: (columnId: string) => string | null },
right: { getValue: (columnId: string) => string | null },
columnId: string,
) {
const a = left.getValue(columnId);
const b = right.getValue(columnId);
if (!a && !b) return 0;
if (!a) return 1;
if (!b) return -1;
return a.localeCompare(b);
}

View File

@ -1,24 +1,24 @@
import {
EMPTY_PAGES_FILTERS,
EMPTY_PERFORMANCE_FILTERS,
type PagesFilters,
type PerformanceFilters,
import { RotateCcw, SlidersHorizontal } from "lucide-react";
import type { ReactNode } from "react";
import type {
PagesFilters,
PerformanceFilters,
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
export function PagesFilterBar({
filters,
onChange,
resultCount,
totalCount,
activeFilterCount,
onReset,
}: {
filters: PagesFilters;
onChange: (filters: PagesFilters) => void;
resultCount: number;
totalCount: number;
activeFilterCount: number;
onReset: () => void;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-200/30 p-3">
<div className="flex flex-wrap items-end gap-2">
<FilterPanel activeFilterCount={activeFilterCount} onReset={onReset}>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<TextFilter
label="Search"
value={filters.query}
@ -37,6 +37,18 @@ export function PagesFilterBar({
["missing", "Missing"],
]}
/>
<SelectFilter
label="Alt text"
value={filters.missingAlt}
onChange={(missingAlt) => onChange({ ...filters, missingAlt })}
options={[
["all", "All"],
["yes", "Missing alt"],
["no", "No missing alt"],
]}
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
<RangeFilter
label="Words"
min={filters.minWords}
@ -55,40 +67,25 @@ export function PagesFilterBar({
onChange({ ...filters, maxResponseMs })
}
/>
<SelectFilter
label="Alt text"
value={filters.missingAlt}
onChange={(missingAlt) => onChange({ ...filters, missingAlt })}
options={[
["all", "All"],
["yes", "Missing alt"],
["no", "No missing alt"],
]}
/>
<FilterSummary
resultCount={resultCount}
totalCount={totalCount}
onReset={() => onChange(EMPTY_PAGES_FILTERS)}
/>
</div>
</div>
</FilterPanel>
);
}
export function PerformanceFilterBar({
filters,
onChange,
resultCount,
totalCount,
activeFilterCount,
onReset,
}: {
filters: PerformanceFilters;
onChange: (filters: PerformanceFilters) => void;
resultCount: number;
totalCount: number;
activeFilterCount: number;
onReset: () => void;
}) {
return (
<div className="rounded-lg border border-base-300 bg-base-200/30 p-3">
<div className="flex flex-wrap items-end gap-2">
<FilterPanel activeFilterCount={activeFilterCount} onReset={onReset}>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
<TextFilter
label="Search"
value={filters.query}
@ -115,6 +112,15 @@ export function PerformanceFilterBar({
["failed", "Failed"],
]}
/>
<TextFilter
label="Max LCP s"
value={filters.maxLcpSeconds}
placeholder="2.5"
type="number"
onChange={(maxLcpSeconds) => onChange({ ...filters, maxLcpSeconds })}
/>
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
<RangeFilter
label="Perf"
min={filters.minPerf}
@ -129,20 +135,8 @@ export function PerformanceFilterBar({
onMinChange={(minSeo) => onChange({ ...filters, minSeo })}
onMaxChange={(maxSeo) => onChange({ ...filters, maxSeo })}
/>
<TextFilter
label="Max LCP s"
value={filters.maxLcpSeconds}
placeholder="2.5"
type="number"
onChange={(maxLcpSeconds) => onChange({ ...filters, maxLcpSeconds })}
/>
<FilterSummary
resultCount={resultCount}
totalCount={totalCount}
onReset={() => onChange(EMPTY_PERFORMANCE_FILTERS)}
/>
</div>
</div>
</FilterPanel>
);
}
@ -150,6 +144,87 @@ export function EmptyTableMessage({ label }: { label: string }) {
return <div className="py-6 text-center text-base-content/60">{label}</div>;
}
export function TableFilterToggle({
showFilters,
onToggle,
activeFilterCount,
resultCount,
totalCount,
}: {
showFilters: boolean;
onToggle: () => void;
activeFilterCount: number;
resultCount: number;
totalCount: number;
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-base-300 px-4 py-2.5">
<button
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
onClick={onToggle}
title="Toggle filters"
type="button"
>
<SlidersHorizontal className="size-3.5" />
Filters
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount}
</span>
) : null}
</button>
<span className="text-sm tabular-nums text-base-content/60">
{resultCount.toLocaleString()} of {totalCount.toLocaleString()}
</span>
</div>
);
}
export function countActiveFilters<TFilters extends Record<string, string>>(
filters: TFilters,
emptyFilters: TFilters,
) {
return Object.keys(filters).reduce((count, key) => {
const filterKey = key as keyof TFilters;
return filters[filterKey] !== emptyFilters[filterKey] ? count + 1 : count;
}, 0);
}
function FilterPanel({
activeFilterCount,
onReset,
children,
}: {
activeFilterCount: number;
onReset: () => void;
children: ReactNode;
}) {
return (
<div className="space-y-3 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Refine results</p>
{activeFilterCount > 0 ? (
<span className="badge badge-xs badge-primary border-0 text-primary-content">
{activeFilterCount} active
</span>
) : null}
</div>
<button
type="button"
className="btn btn-xs btn-ghost gap-1"
onClick={onReset}
disabled={activeFilterCount === 0}
>
<RotateCcw className="size-3" />
Clear all
</button>
</div>
{children}
</div>
);
}
function TextFilter({
label,
value,
@ -164,12 +239,12 @@ function TextFilter({
onChange: (value: string) => void;
}) {
return (
<label className="form-control gap-1">
<label className="form-control gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<input
className="input input-bordered input-sm w-40 bg-base-100"
className="input input-bordered input-sm w-full bg-base-100"
type={type}
value={value}
placeholder={placeholder}
@ -193,20 +268,20 @@ function RangeFilter({
onMaxChange: (value: string) => void;
}) {
return (
<div className="form-control gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
<div className="space-y-2 rounded-lg border border-base-300 bg-base-100 p-2.5">
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<div className="flex gap-1">
</p>
<div className="grid grid-cols-2 gap-2">
<input
className="input input-bordered input-sm w-20 bg-base-100"
className="input input-bordered input-xs bg-base-100"
type="number"
value={min}
placeholder="Min"
onChange={(event) => onMinChange(event.target.value)}
/>
<input
className="input input-bordered input-sm w-20 bg-base-100"
className="input input-bordered input-xs bg-base-100"
type="number"
value={max}
placeholder="Max"
@ -229,12 +304,12 @@ function SelectFilter<T extends string>({
onChange: (value: T) => void;
}) {
return (
<label className="form-control gap-1">
<label className="form-control gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
{label}
</span>
<select
className="select select-bordered select-sm w-32 bg-base-100"
className="select select-bordered select-sm w-full bg-base-100"
value={value}
onChange={(event) => {
const selected = options.find(
@ -252,24 +327,3 @@ function SelectFilter<T extends string>({
</label>
);
}
function FilterSummary({
resultCount,
totalCount,
onReset,
}: {
resultCount: number;
totalCount: number;
onReset: () => void;
}) {
return (
<div className="ml-auto flex items-center gap-2 pb-0.5 text-xs text-base-content/60">
<span className="tabular-nums">
{resultCount.toLocaleString()} of {totalCount.toLocaleString()}
</span>
<button className="btn btn-ghost btn-xs" onClick={onReset}>
Clear
</button>
</div>
);
}

View File

@ -19,9 +19,11 @@ import {
} from "@/client/features/audit/shared";
import type { AuditResultsData } from "@/client/features/audit/results/types";
import {
countActiveFilters,
EmptyTableMessage,
PagesFilterBar,
PerformanceFilterBar,
TableFilterToggle,
} from "@/client/features/audit/results/AuditResultsTableFilters";
import {
EMPTY_PAGES_FILTERS,
@ -29,6 +31,8 @@ import {
filterPages,
filterPerformanceRows,
isLighthouseFailure as getIsLighthouseFailure,
nullableNumberSort,
nullableStringSort,
type LighthouseFailureFields,
type PageRow,
type PagesFilters,
@ -122,9 +126,11 @@ const pagesColumns: ColumnDef<PageRow>[] = [
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
const [filters, setFilters] = useState<PagesFilters>(EMPTY_PAGES_FILTERS);
const [showFilters, setShowFilters] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "statusCode", desc: true },
]);
const activeFilterCount = countActiveFilters(filters, EMPTY_PAGES_FILTERS);
const filteredPages = useMemo(
() => filterPages(pages, filters),
[filters, pages],
@ -139,12 +145,21 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
return (
<div className="space-y-3">
<PagesFilterBar
filters={filters}
onChange={setFilters}
<TableFilterToggle
showFilters={showFilters}
onToggle={() => setShowFilters((current) => !current)}
activeFilterCount={activeFilterCount}
resultCount={filteredPages.length}
totalCount={pages.length}
/>
{showFilters ? (
<PagesFilterBar
filters={filters}
onChange={setFilters}
activeFilterCount={activeFilterCount}
onReset={() => setFilters(EMPTY_PAGES_FILTERS)}
/>
) : null}
<AppDataTable
table={table}
className="table table-sm"
@ -168,6 +183,7 @@ export function PerformanceTable({
const [filters, setFilters] = useState<PerformanceFilters>(
EMPTY_PERFORMANCE_FILTERS,
);
const [showFilters, setShowFilters] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "performanceScore", desc: false },
]);
@ -188,6 +204,10 @@ export function PerformanceTable({
() => filterPerformanceRows(rows, filters),
[filters, rows],
);
const activeFilterCount = countActiveFilters(
filters,
EMPTY_PERFORMANCE_FILTERS,
);
const columns = useMemo(
() => buildPerformanceColumns({ auditId, projectId }),
[auditId, projectId],
@ -202,12 +222,21 @@ export function PerformanceTable({
return (
<div className="space-y-3">
<PerformanceFilterBar
filters={filters}
onChange={setFilters}
<TableFilterToggle
showFilters={showFilters}
onToggle={() => setShowFilters((current) => !current)}
activeFilterCount={activeFilterCount}
resultCount={filteredRows.length}
totalCount={rows.length}
/>
{showFilters ? (
<PerformanceFilterBar
filters={filters}
onChange={setFilters}
activeFilterCount={activeFilterCount}
onReset={() => setFilters(EMPTY_PERFORMANCE_FILTERS)}
/>
) : null}
<AppDataTable
table={table}
className="table table-sm"
@ -363,29 +392,3 @@ export function ExportDropdown({
/>
);
}
function nullableNumberSort(
left: { getValue: (columnId: string) => number | null },
right: { getValue: (columnId: string) => number | null },
columnId: string,
) {
const a = left.getValue(columnId);
const b = right.getValue(columnId);
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
return a - b;
}
function nullableStringSort(
left: { getValue: (columnId: string) => string | null },
right: { getValue: (columnId: string) => string | null },
columnId: string,
) {
const a = left.getValue(columnId);
const b = right.getValue(columnId);
if (!a && !b) return 0;
if (!a) return 1;
if (!b) return -1;
return a.localeCompare(b);
}

View File

@ -30,7 +30,7 @@ function BacklinkFlags({ row }: { row: BacklinksRow }) {
<span className="badge badge-sm badge-outline">Nofollow</span>
) : null}
{row.linksCount != null && row.linksCount > 1 ? (
<span className="badge badge-sm badge-outline inline-flex min-w-fit items-center whitespace-nowrap">
<span className="badge badge-sm badge-outline min-w-fit whitespace-nowrap">
{row.linksCount} links
</span>
) : null}
@ -43,25 +43,27 @@ function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) {
if (group.lostCount > 0) {
badges.push({
label: `${group.lostCount} Lost`,
className: "badge badge-sm badge-error badge-outline",
className:
"badge badge-sm badge-error badge-outline min-w-fit whitespace-nowrap",
});
}
if (group.brokenCount > 0) {
badges.push({
label: `${group.brokenCount} Broken`,
className: "badge badge-sm badge-warning badge-outline",
className:
"badge badge-sm badge-warning badge-outline min-w-fit whitespace-nowrap",
});
}
if (group.nofollowCount > 0) {
badges.push({
label: `${group.nofollowCount} Nofollow`,
className: "badge badge-sm badge-outline",
className: "badge badge-sm badge-outline min-w-fit whitespace-nowrap",
});
}
if (badges.length === 0) return null;
return (
<div className="flex gap-1">
<div className="flex flex-wrap gap-1">
{badges.map((badge) => (
<span key={badge.label} className={badge.className}>
{badge.label}