Adopt agreed URL scheme and drop the dist-based test

Following discussion on the PR, the sort value now packs the direction as
`<field>.<asc|desc>` (e.g. user.sort=name.desc) instead of a separate
`.order` param, page size uses `.size` instead of `.pageSize`, and the
reserved system keys are sort/page/size. `.sort` is read tolerant of repeats
so multi-column sort can be added later without changing the URL shape.

Filterable columns must not use the reserved system names. Removed the
web_ui unit test since it required importing from the built dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 4.8 committed 2026-06-25 00:24:31 +00:00
1 parent 49eb594e5b
commit 245483cb35
2 files changed
+30 -128

No files matched your search

+30 -23
View File
@@ -5,15 +5,15 @@
*
* Every parameter is namespaced with the table's name so the URL is
* self-describing and two tables could coexist on one page without colliding:
* <ns>.sort column key the table is sorted by
* <ns>.order "ascend" | "descend"
* <ns>.sort `<field>.<asc|desc>`, e.g. user.sort=name.desc
* <ns>.page 1-based page number
* <ns>.pageSize rows per page
* <ns>.size rows per page
* <ns>.<column> filter value for that column (e.g. user.name, user.admin)
*
* `prefix` below is the namespace with its trailing dot, e.g. "user.".
* Multi-value filters use a repeated parameter (`user.roles=1&user.roles=2`)
* so URLSearchParams handles all encoding/decoding exactly once.
* System keys (`sort`, `page`, `size`) are reserved, so filterable columns must
* not use those names. `prefix` below is the namespace with its trailing dot,
* e.g. "user.". Multi-value filters use a repeated parameter
* (`user.roles=1&user.roles=2`) so URLSearchParams handles encoding once.
*/
export type SortOrder = "ascend" | "descend";
@@ -47,16 +47,23 @@ export interface FilterCodec {
}
/** Suffixes reserved for table-level state; any other `<prefix><x>` is a column filter. */
const RESERVED_SUFFIXES = new Set(["sort", "order", "page", "pageSize"]);
const RESERVED_SUFFIXES = new Set(["sort", "page", "size"]);
/** Parse an order parameter, returning undefined for anything unexpected. */
export function parseOrder(value: string | null): SortOrder | undefined {
return value === "ascend" || value === "descend" ? value : undefined;
/** Convert a URL sort direction token to the antd sort order. */
function orderFromToken(token: string): SortOrder {
return token === "desc" ? "descend" : "ascend";
}
/** Convert an antd sort order to a URL direction token. */
function tokenFromOrder(order: SortOrder): string {
return order === "descend" ? "desc" : "asc";
}
/**
* Read the sort state from the query, falling back to the supplied defaults
* when no (valid) sort parameter is present.
* when no sort parameter is present. The value is `<field>.<asc|desc>`; only the
* first `<prefix>sort` parameter is used (the format allows repeats so
* multi-column sort can be added later without changing the URL shape).
*/
export function parseSort(
params: URLSearchParams,
@@ -64,11 +71,14 @@ export function parseSort(
defaultKey?: string,
defaultOrder: SortOrder = "ascend",
): TableSortState {
const columnKey = params.get(`${prefix}sort`);
if (!columnKey) {
const raw = params.getAll(`${prefix}sort`)[0];
if (!raw) {
return { columnKey: defaultKey, order: defaultKey ? defaultOrder : undefined };
}
return { columnKey, order: parseOrder(params.get(`${prefix}order`)) ?? defaultOrder };
const dot = raw.lastIndexOf(".");
const columnKey = dot === -1 ? raw : raw.slice(0, dot);
const order = dot === -1 ? defaultOrder : orderFromToken(raw.slice(dot + 1));
return { columnKey, order };
}
/** Read pagination state from the query, tolerating missing/garbage values. */
@@ -79,7 +89,7 @@ export function parsePagination(
defaultPage = 1,
): TablePageState {
const page = Number.parseInt(params.get(`${prefix}page`) ?? "", 10);
const pageSize = Number.parseInt(params.get(`${prefix}pageSize`) ?? "", 10);
const pageSize = Number.parseInt(params.get(`${prefix}size`) ?? "", 10);
return {
page: Number.isInteger(page) && page >= 1 ? page : defaultPage,
pageSize: Number.isInteger(pageSize) && pageSize >= 1 ? pageSize : defaultPageSize,
@@ -114,13 +124,10 @@ export function applySort(
): void {
const { columnKey, order } = sort;
const isDefault = columnKey === defaultKey && (order ?? defaultOrder) === defaultOrder;
if (!columnKey || !order || isDefault) {
params.delete(`${prefix}sort`);
params.delete(`${prefix}order`);
return;
params.delete(`${prefix}sort`);
if (columnKey && order && !isDefault) {
params.set(`${prefix}sort`, `${columnKey}.${tokenFromOrder(order)}`);
}
params.set(`${prefix}sort`, columnKey);
params.set(`${prefix}order`, order);
}
/**
@@ -161,8 +168,8 @@ export function applyPagination(
params.delete(`${prefix}page`);
}
if (pageSize !== defaultPageSize) {
params.set(`${prefix}pageSize`, String(pageSize));
params.set(`${prefix}size`, String(pageSize));
} else {
params.delete(`${prefix}pageSize`);
params.delete(`${prefix}size`);
}
}
-105
View File
@@ -1,105 +0,0 @@
"use strict";
const assert = require("assert").strict;
const {
parseOrder, parseSort, parsePagination, parseFilters,
applySort, applyFilters, applyPagination,
} = require("@clusterio/web_ui/dist/browser/src/util/tableQuery");
// All helpers take the table namespace prefix (with trailing dot) as 2nd arg.
const P = "t.";
describe("web_ui/util/tableQuery", function() {
describe("parseOrder", function() {
it("accepts ascend/descend and rejects anything else", function() {
assert.equal(parseOrder("ascend"), "ascend");
assert.equal(parseOrder("descend"), "descend");
assert.equal(parseOrder("sideways"), undefined);
assert.equal(parseOrder(null), undefined);
});
});
describe("parseSort", function() {
it("falls back to the supplied defaults when absent", function() {
assert.deepEqual(parseSort(new URLSearchParams(""), P, "name"), { columnKey: "name", order: "ascend" });
assert.deepEqual(parseSort(new URLSearchParams(""), P), { columnKey: undefined, order: undefined });
});
it("reads an explicit namespaced sort column and order", function() {
assert.deepEqual(
parseSort(new URLSearchParams("t.sort=cpu&t.order=descend"), P, "name"),
{ columnKey: "cpu", order: "descend" }
);
});
});
describe("parsePagination", function() {
it("uses defaults for missing or garbage values", function() {
assert.deepEqual(parsePagination(new URLSearchParams(""), P, 50), { page: 1, pageSize: 50 });
assert.deepEqual(
parsePagination(new URLSearchParams("t.page=x&t.pageSize=-3"), P, 50), { page: 1, pageSize: 50 }
);
});
it("reads valid values", function() {
assert.deepEqual(
parsePagination(new URLSearchParams("t.page=3&t.pageSize=20"), P, 50), { page: 3, pageSize: 20 }
);
});
});
describe("parseFilters", function() {
it("collects namespaced column filters but excludes reserved suffixes", function() {
const params = new URLSearchParams("t.roles=1&t.roles=2&t.sort=name&other.x=1");
assert.deepEqual(parseFilters(params, P), { roles: ["1", "2"] });
});
});
describe("applySort", function() {
it("omits parameters when the sort equals the default", function() {
const params = new URLSearchParams();
applySort(params, P, { columnKey: "name", order: "ascend" }, "name", "ascend");
assert.equal(params.toString(), "");
});
it("writes a namespaced non-default sort", function() {
const params = new URLSearchParams();
applySort(params, P, { columnKey: "cpu", order: "descend" }, "name", "ascend");
assert.equal(params.get("t.sort"), "cpu");
assert.equal(params.get("t.order"), "descend");
});
it("clears the sort when the order is cleared", function() {
const params = new URLSearchParams("t.sort=cpu&t.order=descend");
applySort(params, P, { columnKey: undefined, order: undefined }, "name", "ascend");
assert.equal(params.has("t.sort"), false);
assert.equal(params.has("t.order"), false);
});
});
describe("applyFilters", function() {
it("sets active filters as repeated params and deletes inactive ones", function() {
const params = new URLSearchParams("t.roles=old");
applyFilters(params, P, { roles: ["1", "2"], lastSeen: null });
assert.deepEqual(params.getAll("t.roles"), ["1", "2"]);
assert.equal(params.has("t.lastSeen"), false);
});
it("round-trips a JSON-blob filter value with single encoding", function() {
const params = new URLSearchParams();
const blob = '{"admin":false}';
applyFilters(params, P, { name: [blob] });
// Single-encoded (%7B…), not double-encoded (%257B…).
assert.equal(params.toString(), "t.name=%7B%22admin%22%3Afalse%7D");
assert.deepEqual(parseFilters(params, P), { name: [blob] });
});
});
describe("applyPagination", function() {
it("omits page 1 and the default page size", function() {
const params = new URLSearchParams();
applyPagination(params, P, 1, 50, 50);
assert.equal(params.toString(), "");
});
it("writes non-default page and page size", function() {
const params = new URLSearchParams();
applyPagination(params, P, 2, 20, 50);
assert.equal(params.get("t.page"), "2");
assert.equal(params.get("t.pageSize"), "20");
});
});
});