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

@@ -0,0 +1,105 @@
import { useSearchParams } from "react-router-dom";
import type { TablePaginationConfig } from "antd";
import type { ColumnType, FilterValue, SorterResult } from "antd/es/table/interface";
import {
applyFilters, applyPagination, applySort,
parseFilters, parsePagination, parseSort,
type FilterCodec, type SortOrder,
} from "./tableQuery";
export interface TableQueryStateOptions {
/** Table name used to namespace every query parameter, e.g. "user" -> `user.sort`, `user.name`. */
namespace: string;
/** Column key sorted by default (no `sort` param present). Omit for no default sort. */
defaultSortKey?: string;
/** Direction of the default sort. Default "ascend". */
defaultSortOrder?: SortOrder;
/** Pass an object to persist pagination; omit/false for `pagination={false}` tables. */
pagination?: false | { defaultPageSize?: number };
/** Per-column-key codecs to persist a bundled filter value as separate readable params. */
filterCodecs?: Record<string, FilterCodec>;
}
export interface TableQueryState<T> {
/** Spread onto `<Table onChange={...}>` to persist sort/filter/pagination (pushes history). */
onChange: (
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<T> | SorterResult<T>[],
) => void;
/** Wrap each column to inject controlled `sortOrder`/`filteredValue` from the URL. */
applyColumn: (column: ColumnType<T>) => ColumnType<T>;
/** Controlled pagination ({@link current}/{@link pageSize}), or `false` when disabled. */
pagination: false | TablePaginationConfig;
/** The active column filters parsed from the URL, keyed by column key. */
filters: Record<string, string[]>;
}
/**
* Persist an antd Table's sort, column filters and pagination into the URL
* query string, so reload, link-sharing and browser back/forward restore the
* same view (clusterio issue #927).
*/
export default function useTableQueryState<T>(options: TableQueryStateOptions): TableQueryState<T> {
const { defaultSortKey, defaultSortOrder = "ascend" } = options;
const prefix = `${options.namespace}.`;
const codecs = options.filterCodecs ?? {};
const paginationOptions = options.pagination && typeof options.pagination === "object"
? options.pagination
: undefined;
const paginationEnabled = paginationOptions !== undefined;
const defaultPageSize = paginationOptions?.defaultPageSize ?? 10;
const [params, setParams] = useSearchParams();
const sort = parseSort(params, prefix, defaultSortKey, defaultSortOrder);
const filters = parseFilters(params, prefix);
const page = paginationEnabled ? parsePagination(params, prefix, defaultPageSize) : undefined;
return {
onChange(pagination, tableFilters, sorter) {
const single = Array.isArray(sorter) ? sorter[0] : sorter;
const order = single && single.order ? single.order : undefined;
const columnKey = order ? String(single.columnKey ?? single.field ?? "") : undefined;
const next = new URLSearchParams(params);
applySort(next, prefix, { columnKey, order }, defaultSortKey, defaultSortOrder);
const plainFilters: Record<string, FilterValue | null> = {};
for (const [key, values] of Object.entries(tableFilters)) {
const codec = codecs[key];
if (codec) {
codec.encode(next, values, prefix);
} else {
plainFilters[key] = values;
}
}
applyFilters(next, prefix, plainFilters);
if (paginationEnabled) {
applyPagination(
next, prefix, pagination.current ?? 1, pagination.pageSize ?? defaultPageSize, defaultPageSize
);
}
setParams(next);
},
applyColumn(column) {
const key = String(column.key ?? column.dataIndex ?? "");
const next: ColumnType<T> = { ...column };
if (column.sorter) {
next.sortOrder = key === sort.columnKey ? (sort.order ?? null) : null;
}
if (column.filters || column.onFilter || column.filterDropdown) {
const codec = codecs[key];
next.filteredValue = codec ? codec.decode(params, prefix) : (filters[key] ?? null);
}
return next;
},
pagination: paginationEnabled && page
? { current: page.page, pageSize: page.pageSize }
: false,
filters,
};
}