Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/client/components/table/nullSafeSort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
createTable,
getCoreRowModel,
getSortedRowModel,
type ColumnDef,
} from "@tanstack/react-table";
import { describe, expect, it } from "vitest";
import {
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";

type TestRow = { id: string; score: number | null; title: string | null };

const columns: ColumnDef<TestRow>[] = [
{ id: "score", accessorKey: "score", sortingFn: numericNullsLast },
{ id: "title", accessorKey: "title", sortingFn: stringNullsLast },
];

/**
* Sort through a real table instance rather than calling the comparators
* directly: TanStack negates a comparator's result on a descending column, and
* surviving that flip is the whole point of these helpers.
*/
function sortedIds(
rows: TestRow[],
columnId: "score" | "title",
desc: boolean,
): string[] {
const table = createTable<TestRow>({
data: rows,
columns,
state: { sorting: [{ id: columnId, desc }] },
onStateChange: () => {},
renderFallbackValue: null,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return table.getSortedRowModel().rows.map((row) => row.original.id);
}

describe("numericNullsLast", () => {
const rows: TestRow[] = [
{ id: "mid", score: 5, title: "beta" },
{ id: "blank", score: null, title: null },
{ id: "high", score: 10, title: "alpha" },
];

it("orders ascending with nulls last", () => {
expect(sortedIds(rows, "score", false)).toEqual(["mid", "high", "blank"]);
});

it("keeps nulls last when descending", () => {
expect(sortedIds(rows, "score", true)).toEqual(["high", "mid", "blank"]);
});

it("treats 0 as a value, not as blank", () => {
const withZero: TestRow[] = [
{ id: "zero", score: 0, title: "a" },
{ id: "blank", score: null, title: null },
{ id: "one", score: 1, title: "b" },
];
expect(sortedIds(withZero, "score", true)).toEqual([
"one",
"zero",
"blank",
]);
});

it("leaves multiple blanks in their original order in both directions", () => {
const manyBlanks: TestRow[] = [
{ id: "blank-1", score: null, title: null },
{ id: "scored", score: 3, title: "a" },
{ id: "blank-2", score: null, title: null },
];
const expected = ["scored", "blank-1", "blank-2"];
expect(sortedIds(manyBlanks, "score", false)).toEqual(expected);
expect(sortedIds(manyBlanks, "score", true)).toEqual(expected);
});
});

describe("stringNullsLast", () => {
const rows: TestRow[] = [
{ id: "beta", score: 1, title: "beta" },
{ id: "blank", score: null, title: null },
{ id: "alpha", score: 2, title: "alpha" },
];

it("orders ascending with blanks last", () => {
expect(sortedIds(rows, "title", false)).toEqual(["alpha", "beta", "blank"]);
});

it("keeps blanks last when descending", () => {
expect(sortedIds(rows, "title", true)).toEqual(["beta", "alpha", "blank"]);
});

it("counts an empty string as blank", () => {
const withEmpty: TestRow[] = [
{ id: "empty", score: 1, title: "" },
{ id: "named", score: 2, title: "alpha" },
];
expect(sortedIds(withEmpty, "title", false)).toEqual(["named", "empty"]);
expect(sortedIds(withEmpty, "title", true)).toEqual(["named", "empty"]);
});
});
48 changes: 41 additions & 7 deletions src/client/components/table/nullSafeSort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,26 @@ import type { Row } from "@tanstack/react-table";

/**
* Null/undefined-aware sorting functions that keep blank rows at the bottom
* regardless of direction. TanStack's built-in `sortUndefined: "last"` gets
* inverted by the desc sign flip — these helpers read the column's sort
* direction from the cell context and return a value that survives the flip.
* regardless of direction. TanStack's built-in `sortUndefined: "last"` only
* inspects `undefined`, so a column backed by a nullable DB value falls through
* to its `sortingFn` — whose result is then negated on a descending sort. These
* helpers read the column's sort direction from the cell context and return a
* value that survives the flip.
*/

function isDescending<TData>(row: Row<TData>, columnId: string): boolean {
const cell = row.getAllCells().find((c) => c.column.id === columnId);
return cell?.column.getIsSorted() === "desc";
}

/**
* Sort value for a pair where exactly one side is blank, pre-compensated for
* the `* -1` TanStack applies to comparator results on a descending column.
*/
function blankLast(aIsBlank: boolean, descending: boolean): number {
return (aIsBlank ? 1 : -1) * (descending ? -1 : 1);
}

/**
* Compare two nullable numeric values with nulls always at the bottom,
* regardless of the column's current sort direction.
Expand All @@ -22,13 +32,25 @@ function compareNumericNullsLast(
descending: boolean,
): number {
if (a == null && b == null) return 0;
if (a == null || b == null) {
const sign = descending ? -1 : 1;
return (a == null ? 1 : -1) * sign;
}
if (a == null || b == null) return blankLast(a == null, descending);
return a - b;
}

/**
* Compare two nullable strings with blanks always at the bottom, regardless of
* the column's current sort direction. Empty strings count as blank — a crawled
* page can carry `""` for a tag that is present but empty.
*/
function compareStringNullsLast(
a: string | null | undefined,
b: string | null | undefined,
descending: boolean,
): number {
if (!a && !b) return 0;
if (!a || !b) return blankLast(!a, descending);
return a.localeCompare(b);
}

export function numericNullsLast<TData>(
rowA: Row<TData>,
rowB: Row<TData>,
Expand All @@ -40,3 +62,15 @@ export function numericNullsLast<TData>(
isDescending(rowA, columnId),
);
}

export function stringNullsLast<TData>(
rowA: Row<TData>,
rowB: Row<TData>,
columnId: string,
): number {
return compareStringNullsLast(
rowA.getValue<string | null | undefined>(columnId),
rowB.getValue<string | null | undefined>(columnId),
isDescending(rowA, columnId),
);
}
26 changes: 0 additions & 26 deletions src/client/features/audit/results/AuditResultsTableFilterLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,29 +163,3 @@ 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);
}
12 changes: 7 additions & 5 deletions src/client/features/audit/results/PagesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import {
import {
EMPTY_PAGES_FILTERS,
filterPages,
nullableNumberSort,
nullableStringSort,
type PageRow,
type PagesFilters,
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
import {
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";

const pageColumnHelper = createColumnHelper<PageRow>();

Expand Down Expand Up @@ -112,7 +114,7 @@ function buildPagesColumns({
pageColumnHelper.accessor("statusCode", {
header: ({ column }) => <SortableHeader column={column} label="Status" />,
cell: ({ getValue }) => <HttpStatusBadge code={getValue()} />,
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
pageColumnHelper.accessor("title", {
header: ({ column }) => <SortableHeader column={column} label="Title" />,
Expand All @@ -137,7 +139,7 @@ function buildPagesColumns({
<EmptyCell />
);
},
sortingFn: nullableStringSort,
sortingFn: stringNullsLast,
meta: { cellClassName: "max-w-[360px]" },
}),
pageColumnHelper.accessor("h1Count", {
Expand Down Expand Up @@ -178,7 +180,7 @@ function buildPagesColumns({
<EmptyCell />
);
},
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
];
}
Expand Down
22 changes: 12 additions & 10 deletions src/client/features/audit/results/ResultsTables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ import {
EMPTY_PERFORMANCE_FILTERS,
filterPerformanceRows,
isLighthouseFailure,
nullableNumberSort,
nullableStringSort,
type PerformanceFilters,
type PerformanceRowData,
} from "@/client/features/audit/results/AuditResultsTableFilterLogic";
import {
numericNullsLast,
stringNullsLast,
} from "@/client/components/table/nullSafeSort";

const performanceColumnHelper = createColumnHelper<PerformanceRowData>();

Expand Down Expand Up @@ -126,7 +128,7 @@ function buildPerformanceColumns({
cell: ({ getValue }) => (
<span className="text-xs">{getValue() ?? "-"}</span>
),
sortingFn: nullableStringSort,
sortingFn: stringNullsLast,
meta: { cellClassName: "max-w-[180px] truncate" },
}),
performanceColumnHelper.accessor("strategy", {
Expand Down Expand Up @@ -161,17 +163,17 @@ function buildPerformanceColumns({
performanceColumnHelper.accessor("performanceScore", {
header: ({ column }) => <SortableHeader column={column} label="Perf" />,
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
performanceColumnHelper.accessor("accessibilityScore", {
header: ({ column }) => <SortableHeader column={column} label="A11y" />,
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
performanceColumnHelper.accessor("seoScore", {
header: ({ column }) => <SortableHeader column={column} label="SEO" />,
cell: ({ getValue }) => <LighthouseScoreBadge score={getValue()} />,
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
performanceColumnHelper.accessor("lcpMs", {
header: ({ column }) => <SortableHeader column={column} label="LCP" />,
Expand All @@ -183,7 +185,7 @@ function buildPerformanceColumns({
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
performanceColumnHelper.accessor("cls", {
header: ({ column }) => <SortableHeader column={column} label="CLS" />,
Expand All @@ -195,7 +197,7 @@ function buildPerformanceColumns({
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
performanceColumnHelper.accessor("inpMs", {
header: ({ column }) => <SortableHeader column={column} label="INP" />,
Expand All @@ -207,7 +209,7 @@ function buildPerformanceColumns({
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
performanceColumnHelper.accessor("ttfbMs", {
header: ({ column }) => <SortableHeader column={column} label="TTFB" />,
Expand All @@ -219,7 +221,7 @@ function buildPerformanceColumns({
<span className="text-xs text-base-content/40">-</span>
);
},
sortingFn: nullableNumberSort,
sortingFn: numericNullsLast,
}),
performanceColumnHelper.display({
id: "issues",
Expand Down