fix audit tables + back link columns (#224)
This commit is contained in:
parent
8b74c7da61
commit
02a287b98e
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user