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);
|
const parsed = Number(trimmed);
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
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 {
|
import { RotateCcw, SlidersHorizontal } from "lucide-react";
|
||||||
EMPTY_PAGES_FILTERS,
|
import type { ReactNode } from "react";
|
||||||
EMPTY_PERFORMANCE_FILTERS,
|
import type {
|
||||||
type PagesFilters,
|
PagesFilters,
|
||||||
type PerformanceFilters,
|
PerformanceFilters,
|
||||||
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
|
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
|
||||||
|
|
||||||
export function PagesFilterBar({
|
export function PagesFilterBar({
|
||||||
filters,
|
filters,
|
||||||
onChange,
|
onChange,
|
||||||
resultCount,
|
activeFilterCount,
|
||||||
totalCount,
|
onReset,
|
||||||
}: {
|
}: {
|
||||||
filters: PagesFilters;
|
filters: PagesFilters;
|
||||||
onChange: (filters: PagesFilters) => void;
|
onChange: (filters: PagesFilters) => void;
|
||||||
resultCount: number;
|
activeFilterCount: number;
|
||||||
totalCount: number;
|
onReset: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-base-300 bg-base-200/30 p-3">
|
<FilterPanel activeFilterCount={activeFilterCount} onReset={onReset}>
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||||
<TextFilter
|
<TextFilter
|
||||||
label="Search"
|
label="Search"
|
||||||
value={filters.query}
|
value={filters.query}
|
||||||
@ -37,6 +37,18 @@ export function PagesFilterBar({
|
|||||||
["missing", "Missing"],
|
["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
|
<RangeFilter
|
||||||
label="Words"
|
label="Words"
|
||||||
min={filters.minWords}
|
min={filters.minWords}
|
||||||
@ -55,40 +67,25 @@ export function PagesFilterBar({
|
|||||||
onChange({ ...filters, maxResponseMs })
|
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>
|
||||||
</div>
|
</FilterPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PerformanceFilterBar({
|
export function PerformanceFilterBar({
|
||||||
filters,
|
filters,
|
||||||
onChange,
|
onChange,
|
||||||
resultCount,
|
activeFilterCount,
|
||||||
totalCount,
|
onReset,
|
||||||
}: {
|
}: {
|
||||||
filters: PerformanceFilters;
|
filters: PerformanceFilters;
|
||||||
onChange: (filters: PerformanceFilters) => void;
|
onChange: (filters: PerformanceFilters) => void;
|
||||||
resultCount: number;
|
activeFilterCount: number;
|
||||||
totalCount: number;
|
onReset: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-base-300 bg-base-200/30 p-3">
|
<FilterPanel activeFilterCount={activeFilterCount} onReset={onReset}>
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-4">
|
||||||
<TextFilter
|
<TextFilter
|
||||||
label="Search"
|
label="Search"
|
||||||
value={filters.query}
|
value={filters.query}
|
||||||
@ -115,6 +112,15 @@ export function PerformanceFilterBar({
|
|||||||
["failed", "Failed"],
|
["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
|
<RangeFilter
|
||||||
label="Perf"
|
label="Perf"
|
||||||
min={filters.minPerf}
|
min={filters.minPerf}
|
||||||
@ -129,20 +135,8 @@ export function PerformanceFilterBar({
|
|||||||
onMinChange={(minSeo) => onChange({ ...filters, minSeo })}
|
onMinChange={(minSeo) => onChange({ ...filters, minSeo })}
|
||||||
onMaxChange={(maxSeo) => onChange({ ...filters, maxSeo })}
|
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>
|
||||||
</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>;
|
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({
|
function TextFilter({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@ -164,12 +239,12 @@ function TextFilter({
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<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}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@ -193,20 +268,20 @@ function RangeFilter({
|
|||||||
onMaxChange: (value: string) => void;
|
onMaxChange: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="form-control gap-1">
|
<div className="space-y-2 rounded-lg border border-base-300 bg-base-100 p-2.5">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</p>
|
||||||
<div className="flex gap-1">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<input
|
<input
|
||||||
className="input input-bordered input-sm w-20 bg-base-100"
|
className="input input-bordered input-xs bg-base-100"
|
||||||
type="number"
|
type="number"
|
||||||
value={min}
|
value={min}
|
||||||
placeholder="Min"
|
placeholder="Min"
|
||||||
onChange={(event) => onMinChange(event.target.value)}
|
onChange={(event) => onMinChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="input input-bordered input-sm w-20 bg-base-100"
|
className="input input-bordered input-xs bg-base-100"
|
||||||
type="number"
|
type="number"
|
||||||
value={max}
|
value={max}
|
||||||
placeholder="Max"
|
placeholder="Max"
|
||||||
@ -229,12 +304,12 @@ function SelectFilter<T extends string>({
|
|||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<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}
|
value={value}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const selected = options.find(
|
const selected = options.find(
|
||||||
@ -252,24 +327,3 @@ function SelectFilter<T extends string>({
|
|||||||
</label>
|
</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";
|
} from "@/client/features/audit/shared";
|
||||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||||
import {
|
import {
|
||||||
|
countActiveFilters,
|
||||||
EmptyTableMessage,
|
EmptyTableMessage,
|
||||||
PagesFilterBar,
|
PagesFilterBar,
|
||||||
PerformanceFilterBar,
|
PerformanceFilterBar,
|
||||||
|
TableFilterToggle,
|
||||||
} from "@/client/features/audit/results/AuditResultsTableFilters";
|
} from "@/client/features/audit/results/AuditResultsTableFilters";
|
||||||
import {
|
import {
|
||||||
EMPTY_PAGES_FILTERS,
|
EMPTY_PAGES_FILTERS,
|
||||||
@ -29,6 +31,8 @@ import {
|
|||||||
filterPages,
|
filterPages,
|
||||||
filterPerformanceRows,
|
filterPerformanceRows,
|
||||||
isLighthouseFailure as getIsLighthouseFailure,
|
isLighthouseFailure as getIsLighthouseFailure,
|
||||||
|
nullableNumberSort,
|
||||||
|
nullableStringSort,
|
||||||
type LighthouseFailureFields,
|
type LighthouseFailureFields,
|
||||||
type PageRow,
|
type PageRow,
|
||||||
type PagesFilters,
|
type PagesFilters,
|
||||||
@ -122,9 +126,11 @@ const pagesColumns: ColumnDef<PageRow>[] = [
|
|||||||
|
|
||||||
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
||||||
const [filters, setFilters] = useState<PagesFilters>(EMPTY_PAGES_FILTERS);
|
const [filters, setFilters] = useState<PagesFilters>(EMPTY_PAGES_FILTERS);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "statusCode", desc: true },
|
{ id: "statusCode", desc: true },
|
||||||
]);
|
]);
|
||||||
|
const activeFilterCount = countActiveFilters(filters, EMPTY_PAGES_FILTERS);
|
||||||
const filteredPages = useMemo(
|
const filteredPages = useMemo(
|
||||||
() => filterPages(pages, filters),
|
() => filterPages(pages, filters),
|
||||||
[filters, pages],
|
[filters, pages],
|
||||||
@ -139,12 +145,21 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PagesFilterBar
|
<TableFilterToggle
|
||||||
filters={filters}
|
showFilters={showFilters}
|
||||||
onChange={setFilters}
|
onToggle={() => setShowFilters((current) => !current)}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
resultCount={filteredPages.length}
|
resultCount={filteredPages.length}
|
||||||
totalCount={pages.length}
|
totalCount={pages.length}
|
||||||
/>
|
/>
|
||||||
|
{showFilters ? (
|
||||||
|
<PagesFilterBar
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
onReset={() => setFilters(EMPTY_PAGES_FILTERS)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<AppDataTable
|
<AppDataTable
|
||||||
table={table}
|
table={table}
|
||||||
className="table table-sm"
|
className="table table-sm"
|
||||||
@ -168,6 +183,7 @@ export function PerformanceTable({
|
|||||||
const [filters, setFilters] = useState<PerformanceFilters>(
|
const [filters, setFilters] = useState<PerformanceFilters>(
|
||||||
EMPTY_PERFORMANCE_FILTERS,
|
EMPTY_PERFORMANCE_FILTERS,
|
||||||
);
|
);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "performanceScore", desc: false },
|
{ id: "performanceScore", desc: false },
|
||||||
]);
|
]);
|
||||||
@ -188,6 +204,10 @@ export function PerformanceTable({
|
|||||||
() => filterPerformanceRows(rows, filters),
|
() => filterPerformanceRows(rows, filters),
|
||||||
[filters, rows],
|
[filters, rows],
|
||||||
);
|
);
|
||||||
|
const activeFilterCount = countActiveFilters(
|
||||||
|
filters,
|
||||||
|
EMPTY_PERFORMANCE_FILTERS,
|
||||||
|
);
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => buildPerformanceColumns({ auditId, projectId }),
|
() => buildPerformanceColumns({ auditId, projectId }),
|
||||||
[auditId, projectId],
|
[auditId, projectId],
|
||||||
@ -202,12 +222,21 @@ export function PerformanceTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PerformanceFilterBar
|
<TableFilterToggle
|
||||||
filters={filters}
|
showFilters={showFilters}
|
||||||
onChange={setFilters}
|
onToggle={() => setShowFilters((current) => !current)}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
resultCount={filteredRows.length}
|
resultCount={filteredRows.length}
|
||||||
totalCount={rows.length}
|
totalCount={rows.length}
|
||||||
/>
|
/>
|
||||||
|
{showFilters ? (
|
||||||
|
<PerformanceFilterBar
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
onReset={() => setFilters(EMPTY_PERFORMANCE_FILTERS)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<AppDataTable
|
<AppDataTable
|
||||||
table={table}
|
table={table}
|
||||||
className="table table-sm"
|
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>
|
<span className="badge badge-sm badge-outline">Nofollow</span>
|
||||||
) : null}
|
) : null}
|
||||||
{row.linksCount != null && row.linksCount > 1 ? (
|
{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
|
{row.linksCount} links
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@ -43,25 +43,27 @@ function DomainFlagBadges({ group }: { group: GroupedBacklinkDomain }) {
|
|||||||
if (group.lostCount > 0) {
|
if (group.lostCount > 0) {
|
||||||
badges.push({
|
badges.push({
|
||||||
label: `${group.lostCount} Lost`,
|
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) {
|
if (group.brokenCount > 0) {
|
||||||
badges.push({
|
badges.push({
|
||||||
label: `${group.brokenCount} Broken`,
|
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) {
|
if (group.nofollowCount > 0) {
|
||||||
badges.push({
|
badges.push({
|
||||||
label: `${group.nofollowCount} Nofollow`,
|
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;
|
if (badges.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{badges.map((badge) => (
|
{badges.map((badge) => (
|
||||||
<span key={badge.label} className={badge.className}>
|
<span key={badge.label} className={badge.className}>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user