Add parseListQuery helper (todo §1); read a list URL into { q, filters, sort, page, pageSize }, defaults+clamp, zero-throw
This commit is contained in:
53
src/list-query.test.ts
Normal file
53
src/list-query.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { parseListQuery } from "./list-query.ts";
|
||||
|
||||
test("parseListQuery reads search, multi-value filters, sort and pagination from the URL", () => {
|
||||
// q is trimmed; chips repeat a key; daterange is two keys; "-field" ⇒ desc sort.
|
||||
assert.deepEqual(
|
||||
parseListQuery("?q= ada &status=active&tag=oncall&tag=lead&joined_from=2026-01-01&sort=-last_active&page=3&pageSize=50"),
|
||||
{
|
||||
filters: { joined_from: ["2026-01-01"], status: ["active"], tag: ["oncall", "lead"] },
|
||||
page: 3,
|
||||
pageSize: 50,
|
||||
q: "ada",
|
||||
sort: { dir: "desc", field: "last_active" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseListQuery applies defaults, clamps, drops empties and accepts URL/URLSearchParams/string", () => {
|
||||
// Empty query → all defaults, never throws.
|
||||
assert.deepEqual(parseListQuery("?"), { filters: {}, page: 1, pageSize: 25, q: "", sort: null });
|
||||
|
||||
// Empty values dropped (status, q); page<1 → 1; oversized pageSize clamped to max; bare sort ⇒ asc.
|
||||
assert.deepEqual(
|
||||
parseListQuery("status=&q=&page=0&pageSize=99999&sort=name"),
|
||||
{ filters: {}, page: 1, pageSize: 100, q: "", sort: { dir: "asc", field: "name" } },
|
||||
);
|
||||
|
||||
// A URL works (searchParams), multi-value preserved.
|
||||
assert.deepEqual(
|
||||
parseListQuery(new URL("http://x/users?team=engineering&team=design")),
|
||||
{ filters: { team: ["engineering", "design"] }, page: 1, pageSize: 25, q: "", sort: null },
|
||||
);
|
||||
|
||||
// URLSearchParams works; non-integer page/pageSize fall back to defaults; lone "-" sort ⇒ null.
|
||||
assert.deepEqual(
|
||||
parseListQuery(new URLSearchParams("page=abc&pageSize=-5&sort=-")),
|
||||
{ filters: {}, page: 1, pageSize: 25, q: "", sort: null },
|
||||
);
|
||||
});
|
||||
|
||||
test("parseListQuery honours custom reserved names and page-size bounds", () => {
|
||||
assert.deepEqual(
|
||||
parseListQuery("?search=hi&p=2&n=10&order=-x&status=active", {
|
||||
defaultPageSize: 20, maxPageSize: 50, pageParam: "p", pageSizeParam: "n", qParam: "search", sortParam: "order",
|
||||
}),
|
||||
{ filters: { status: ["active"] }, page: 2, pageSize: 10, q: "hi", sort: { dir: "desc", field: "x" } },
|
||||
);
|
||||
|
||||
// Custom n clamps to the custom max; the now-unreserved default names become plain filters.
|
||||
assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50);
|
||||
assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] });
|
||||
});
|
||||
72
src/list-query.ts
Normal file
72
src/list-query.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// parseListQuery (todo §1): read a list-page URL into the state the building blocks render
|
||||
// from — search, filters, sort, pagination. The URL is the only list state (README
|
||||
// "Interactivity"), so this is the inverse of the filter-bar GET form, the sort links and the
|
||||
// pagination links: bookmarkable, shareable, reproducible. Pure; never throws.
|
||||
|
||||
export interface ListSort {
|
||||
dir: "asc" | "desc";
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface ListQuery {
|
||||
filters: Record<string, string[]>; // every non-reserved param; multi-value kept, empties dropped
|
||||
page: number; // ≥ 1
|
||||
pageSize: number; // clamped to [1, maxPageSize]
|
||||
q: string; // trimmed search text, "" when absent
|
||||
sort: ListSort | null; // "field" ⇒ asc, "-field" ⇒ desc
|
||||
}
|
||||
|
||||
export interface ListQueryOptions {
|
||||
defaultPageSize?: number; // used when pageSize is absent/invalid (default 25)
|
||||
maxPageSize?: number; // upper clamp (default 100)
|
||||
pageParam?: string; // default "page"
|
||||
pageSizeParam?: string; // default "pageSize"
|
||||
qParam?: string; // default "q"
|
||||
sortParam?: string; // default "sort"
|
||||
}
|
||||
|
||||
export function parseListQuery(url: URL | URLSearchParams | string, options: ListQueryOptions = {}): ListQuery {
|
||||
const params = toParams(url);
|
||||
const qParam = options.qParam ?? "q";
|
||||
const sortParam = options.sortParam ?? "sort";
|
||||
const pageParam = options.pageParam ?? "page";
|
||||
const pageSizeParam = options.pageSizeParam ?? "pageSize";
|
||||
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam]);
|
||||
|
||||
const filters: Record<string, string[]> = {};
|
||||
for (const key of new Set(params.keys())) {
|
||||
if (reserved.has(key)) continue;
|
||||
const values = params.getAll(key).filter((v) => v !== "");
|
||||
if (values.length) filters[key] = values;
|
||||
}
|
||||
|
||||
return {
|
||||
filters,
|
||||
page: positiveInt(params.get(pageParam)) ?? 1,
|
||||
pageSize: Math.min(positiveInt(params.get(pageSizeParam)) ?? (options.defaultPageSize ?? 25), options.maxPageSize ?? 100),
|
||||
q: (params.get(qParam) ?? "").trim(),
|
||||
sort: parseSort(params.get(sortParam)),
|
||||
};
|
||||
}
|
||||
|
||||
function toParams(url: URL | URLSearchParams | string): URLSearchParams {
|
||||
if (typeof url === "string") {
|
||||
const i = url.indexOf("?");
|
||||
return new URLSearchParams(i >= 0 ? url.slice(i + 1) : url);
|
||||
}
|
||||
return url instanceof URL ? url.searchParams : url;
|
||||
}
|
||||
|
||||
// A strictly positive integer, else null so the caller falls back to a default.
|
||||
function positiveInt(raw: string | null): number | null {
|
||||
if (raw == null || raw.trim() === "") return null;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 1 ? n : null;
|
||||
}
|
||||
|
||||
function parseSort(raw: string | null): ListSort | null {
|
||||
if (raw == null) return null;
|
||||
const desc = raw.startsWith("-");
|
||||
const field = (desc ? raw.slice(1) : raw).trim();
|
||||
return field ? { dir: desc ? "desc" : "asc", field } : null;
|
||||
}
|
||||
Reference in New Issue
Block a user