Persist table sort/filter state in the URL

Tables such as Hosts, Instances, Users, Roles, etc. now write their sort
order, column filters and pagination into the URL query string, so reload,
link-sharing and browser back/forward restore the same view (closes #927).

Every parameter is namespaced with the table name so the URL is
self-describing and two tables can share a page without colliding, e.g.
?user.sort=name&user.name=bob&user.admin=true or ?instance.name=foo.

Adds two reusable hooks, exported from @clusterio/web_ui so plugins can use
them too:
- useTableQueryState: makes an antd Table controlled and round-trips its
  sort/filter/pagination through the URL via useSearchParams. The pure
  serialisation helpers live in util/tableQuery.ts and are unit tested.
- useColumnSearch: a column-header text-search filter (matching the Users
  table style), persisted through the same filter mechanism.

Applied to Hosts, Roles, Plugins, the instance list (standalone and on the
host detail page), Users (main table and the online-players list on the
instance detail page), instance saves, the per-instance user stats, the mod
packs and stored mods tables, and the subspace_storage storage page. The
storage page's resource search moves from a standalone box into the Resource
column header, keeping its regex matching.

The bundled user filter persists as separate readable params (user.name,
user.admin, ...) via a per-column FilterCodec rather than one JSON blob.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 4.8 committed 2026-06-24 23:23:54 +00:00
1 parent 9e727f9a13
commit 49eb594e5b
19 files changed
+639 -78

No files matched your search

+30 -29
View File
@@ -1,16 +1,16 @@
import { useContext, useEffect, useState } from "react";
import { Input, Table, Typography } from "antd";
import { Table } from "antd";
import type { ColumnsType } from "antd/es/table";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon,
useTableQueryState, useColumnSearch,
} from "@clusterio/web_ui";
import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages";
import "./style.css";
const { Paragraph } = Typography;
function useStorage(control: Control) {
let plugin = control.plugins.get("subspace_storage") as WebPlugin;
@@ -37,8 +37,10 @@ function StoragePage() {
let itemMetadata = prototypes?.get("item");
let fluidMetadata = prototypes?.get("fluid");
let storage = useStorage(control);
type ItemFilter = ([name, item]: [string, Item]) => boolean;
let [filter, setFilter] = useState<null | ItemFilter>(null);
const tableState = useTableQueryState<[string, Item]>({
namespace: "storage", defaultSortKey: "quantity", defaultSortOrder: "descend", pagination: false,
});
const resourceSearch = useColumnSearch<[string, Item]>(item => getLocaleName(item[1].name), "Search");
function getLocaleName(itemName: string) {
let localeName = itemName;
@@ -65,33 +67,32 @@ function StoragePage() {
let numberFormat = new Intl.NumberFormat("en-US");
// Build the regex the resource search uses: word-boundary anchored, spaces match any gap.
function matchesResource(value: string | number | bigint | boolean, item: [string, Item]) {
let search = String(value).trim();
if (!search) {
return true;
}
search = search.replace(/(^| )(\w)/g, "$1\\b$2").replace(/ +/g, ".*");
let filterExpr;
try {
filterExpr = new RegExp(search, "i");
} catch {
return true;
}
return filterExpr.test(getLocaleName(item[1].name)) || filterExpr.test(item[1].name);
}
return <PageLayout nav={[{ name: "Storage" }]}>
<PageHeader title="Storage" />
<Paragraph>
<Input
placeholder="Search"
onChange={(event) => {
let search = event.target.value.trim();
if (!search) {
setFilter(null);
return;
}
search = search.replace(/(^| )(\w)/g, "$1\\b$2");
search = search.replace(/ +/g, ".*");
let filterExpr = new RegExp(search, "i");
setFilter(() => ((item: [string, Item]) => {
let name = getLocaleName(item[1].name);
return filterExpr.test(name) || filterExpr.test(item[1].name);
}));
}}
/>
</Paragraph>
<Table
className="subspace-storage-storage"
columns={[
columns={([
{
title: "Resource",
key: "resource",
...resourceSearch,
onFilter: matchesResource,
sorter: (a, b) => {
let aName = getLocaleName(a[1].name);
let bName = getLocaleName(b[1].name);
@@ -119,14 +120,14 @@ function StoragePage() {
title: "Quantity",
key: "quantity",
align: "right",
defaultSortOrder: "descend",
sorter: (a, b) => a[1].count - b[1].count,
render: (_, item) => numberFormat.format(item[1].count),
},
]}
dataSource={filter ? storage.filter(filter) : storage}
] satisfies ColumnsType<[string, Item]>).map(tableState.applyColumn)}
dataSource={storage}
rowKey={item => item[0]}
pagination={false}
pagination={tableState.pagination}
onChange={tableState.onChange}
/>
</PageLayout>;
}