diff --git a/packages/web_ui/src/util/tableQuery.ts b/packages/web_ui/src/util/tableQuery.ts index 27e3ae81..4fa4cd0a 100644 --- a/packages/web_ui/src/util/tableQuery.ts +++ b/packages/web_ui/src/util/tableQuery.ts @@ -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: - * .sort column key the table is sorted by - * .order "ascend" | "descend" + * .sort `.`, e.g. user.sort=name.desc * .page 1-based page number - * .pageSize rows per page + * .size rows per page * . 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 `` 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 `.`; only the + * first `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`); } } diff --git a/test/web_ui/tableQuery.js b/test/web_ui/tableQuery.js deleted file mode 100644 index 12656ce1..00000000 --- a/test/web_ui/tableQuery.js +++ /dev/null @@ -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"); - }); - }); -});