mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 07:24:52 +09:00
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:
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user