diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx
index c55eddd8..3cc0cf6e 100644
--- a/packages/web_ui/src/components/HostViewPage.tsx
+++ b/packages/web_ui/src/components/HostViewPage.tsx
@@ -169,7 +169,7 @@ export default function HostViewPage() {
{account.hasPermission("core.instance.list") && <>
Instances
-
+
>}
{account.hasPermission("core.log.follow") && <>
diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx
index 5b1dfa15..be93c33e 100644
--- a/packages/web_ui/src/components/HostsPage.tsx
+++ b/packages/web_ui/src/components/HostsPage.tsx
@@ -3,6 +3,8 @@ import { useNavigate } from "react-router-dom";
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
+import type { ColumnsType } from "antd/es/table";
+
import * as lib from "@clusterio/lib";
import webUiPackage from "../../package.json";
@@ -20,6 +22,8 @@ import {
import { useHosts } from "../model/host";
import { useSystems } from "../model/system";
import notify, { notifyErrorHandler } from "../util/notify";
+import useTableQueryState from "../util/useTableQueryState";
+import useColumnSearch from "./useColumnSearch";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -205,6 +209,10 @@ export default function HostsPage() {
const [showRatios, setShowRatios] = useState(true);
const [showNumbers, setShowNumbers] = useState(false);
const [showCpuModel, setShowCpuModel] = useState(false);
+ const tableState = useTableQueryState({
+ namespace: "host", defaultSortKey: "name", pagination: false,
+ });
+ const nameSearch = useColumnSearch(host => host.name, "Search hosts");
return
strcmp(a.name, b.name),
+ ...nameSearch,
},
{
title: "CPU Model",
@@ -302,10 +310,11 @@ export default function HostsPage() {
>,
sorter: (a, b) => Number(a.connected) - Number(b.connected),
},
- ]}
+ ] satisfies ColumnsType).map(tableState.applyColumn)}
dataSource={[...hosts.values()]}
rowKey={host => host.id}
- pagination={false}
+ pagination={tableState.pagination}
+ onChange={tableState.onChange}
onRow={(record, rowIndex) => ({
onClick: event => {
navigate(`/hosts/${record.id}/view`);
diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx
index 46b75f9d..15f59d76 100644
--- a/packages/web_ui/src/components/InstanceList.tsx
+++ b/packages/web_ui/src/components/InstanceList.tsx
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { message, Button, Table, Space } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import type { SizeType } from "antd/es/config-provider/SizeContext";
-import type { ColumnsType } from "antd/es/table";
+import type { ColumnType } from "antd/es/table/interface";
import { useAccount } from "../model/account";
import { useSystems } from "../model/system";
@@ -14,6 +14,8 @@ import InstanceControlButton, { InstanceControlButtonPermissions } from "./Insta
import * as lib from "@clusterio/lib";
import Link from "./Link";
import { instancePublicAddress } from "../util/instance";
+import useTableQueryState from "../util/useTableQueryState";
+import useColumnSearch from "./useColumnSearch";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -21,6 +23,8 @@ type InstanceListProps = {
instances: ReadonlyMap>;
size?: SizeType;
hideAssignedHost?: boolean;
+ /** Persist sort/search state in the URL and show a search box (for the standalone Instances page). */
+ persistState?: boolean;
};
export default function InstanceList(props: InstanceListProps) {
@@ -28,6 +32,11 @@ export default function InstanceList(props: InstanceListProps) {
let navigate = useNavigate();
let [hosts] = useHosts();
const [systems] = useSystems();
+ const persist = props.persistState ?? false;
+ const tableState = useTableQueryState({
+ namespace: "instance", defaultSortKey: "name", pagination: false,
+ });
+ const nameSearch = useColumnSearch(instance => instance.name, "Search instances");
function hostName(hostId?: number) {
if (hostId === undefined) {
@@ -43,12 +52,13 @@ export default function InstanceList(props: InstanceListProps) {
return lib.integerPartialVersion(instance.factorioVersion);
}
- let columns: ColumnsType = [
+ let columns: ColumnType[] = [
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
+ ...(persist ? nameSearch : {}),
},
{
title: "Assigned Host",
@@ -118,12 +128,15 @@ export default function InstanceList(props: InstanceListProps) {
columns.splice(1, 1);
}
+ const finalColumns = persist ? columns.map(tableState.applyColumn) : columns;
+
return instance["id"]}
- pagination={false}
+ pagination={persist ? tableState.pagination : false}
+ onChange={persist ? tableState.onChange : undefined}
onRow={(record, rowIndex) => ({
onClick: event => {
navigate(`/instances/${record.id}/view`);
diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx
index cfa24922..78cb80d2 100644
--- a/packages/web_ui/src/components/InstancesPage.tsx
+++ b/packages/web_ui/src/components/InstancesPage.tsx
@@ -111,7 +111,7 @@ export default function InstancesPage() {
}
/>
-
+
;
}
diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx
index a6ece310..0a25b1bb 100644
--- a/packages/web_ui/src/components/ModsPage.tsx
+++ b/packages/web_ui/src/components/ModsPage.tsx
@@ -6,6 +6,7 @@ import {
import {
ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined,
} from "@ant-design/icons";
+import type { ColumnsType } from "antd/es/table";
import * as lib from "@clusterio/lib";
@@ -18,6 +19,7 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
+import useTableQueryState from "../util/useTableQueryState";
import ModDetails from "./ModDetails";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
@@ -510,6 +512,12 @@ export default function ModsPage() {
let navigate = useNavigate();
let [mods] = useMods();
let [modPacks] = useModPacks();
+ const modPackTable = useTableQueryState({
+ namespace: "modPack", defaultSortKey: "name", pagination: false,
+ });
+ const modTable = useTableQueryState({
+ namespace: "mod", defaultSortKey: "title", pagination: false,
+ });
function actions(mod: lib.ModInfo) {
return
@@ -571,11 +579,10 @@ export default function ModsPage() {
}
/>
strcmp(a.name, b.name),
},
{
@@ -588,9 +595,10 @@ export default function ModsPage() {
key: "mods",
render: (_, modPack) => modPack.mods.size,
},
- ]}
+ ] satisfies ColumnsType).map(modPackTable.applyColumn)}
dataSource={[...modPacks.values()]}
- pagination={false}
+ pagination={modPackTable.pagination}
+ onChange={modPackTable.onChange}
rowKey={modPack => Number(modPack.id)}
onRow={(modPack, rowIndex) => ({
onClick: event => {
@@ -617,11 +625,10 @@ export default function ModsPage() {
>
(
strcmp(a.name, b.name) || a.integerVersion - b.integerVersion
),
@@ -655,13 +662,14 @@ export default function ModsPage() {
responsive: ["lg"],
render: (_, mod) => actions(mod),
},
- ]}
+ ] satisfies ColumnsType).map(modTable.applyColumn)}
expandable={{
expandedRowRender: (mod: lib.ModInfo) => ,
expandedRowClassName: () => "no-expanded-padding",
}}
dataSource={[...mods.values()]}
- pagination={false}
+ pagination={modTable.pagination}
+ onChange={modTable.onChange}
rowKey={mod => mod.id}
/>
diff --git a/packages/web_ui/src/components/OnlinePlayersList.tsx b/packages/web_ui/src/components/OnlinePlayersList.tsx
index 31e5a560..13950da2 100644
--- a/packages/web_ui/src/components/OnlinePlayersList.tsx
+++ b/packages/web_ui/src/components/OnlinePlayersList.tsx
@@ -20,7 +20,7 @@ export default function OnlinePlayersList({ instanceId }: OnlinePlayersListProps
return (
Online Players ({onlineUsers.length})
-
([]);
+ const tableState = useTableQueryState({
+ namespace: "plugin", defaultSortKey: "name", pagination: false,
+ });
+ const nameSearch = useColumnSearch(
+ plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins"
+ );
useEffect(() => {
(async () => {
@@ -30,7 +45,7 @@ export default function PluginsPage() {
})();
}, []);
- let tableContents = [];
+ let tableContents: PluginRow[] = [];
for (let meta of pluginList) {
if (control.plugins.has(meta.name)) {
let plugin = control.plugins.get(meta.name)!;
@@ -50,13 +65,13 @@ export default function PluginsPage() {
return
(plugin.info ? plugin.info.title : plugin.meta.name),
- defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
+ ...nameSearch,
},
{
title: "Version",
@@ -82,10 +97,11 @@ export default function PluginsPage() {
sorter: (a, b) => Number(Boolean(a.package)) - Number(Boolean(b.package)),
responsive: ["sm"],
},
- ]}
+ ] satisfies ColumnsType).map(tableState.applyColumn)}
dataSource={tableContents}
rowKey={plugin => plugin.meta.name}
- pagination={false}
+ pagination={tableState.pagination}
+ onChange={tableState.onChange}
onRow={plugin => ({
onClick: event => {
navigate(`/plugins/${plugin.meta.name}/view`);
diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx
index 4e392c27..a21f5fe7 100644
--- a/packages/web_ui/src/components/RolesPage.tsx
+++ b/packages/web_ui/src/components/RolesPage.tsx
@@ -1,6 +1,7 @@
import React, { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal, Table } from "antd";
+import type { ColumnsType } from "antd/es/table";
import * as lib from "@clusterio/lib";
@@ -11,6 +12,8 @@ import { notifyErrorHandler } from "../util/notify";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
+import useTableQueryState from "../util/useTableQueryState";
+import useColumnSearch from "./useColumnSearch";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -63,6 +66,8 @@ export default function RolesPage() {
let account = useAccount();
let navigate = useNavigate();
const [roles] = useRoles();
+ const tableState = useTableQueryState({ namespace: "role", pagination: false });
+ const nameSearch = useColumnSearch(role => role.name, "Search roles");
return
: undefined}
/>
strcmp(a.name, b.name),
+ ...nameSearch,
},
{
title: "Description",
dataIndex: "description",
},
- ]}
+ ] satisfies ColumnsType).map(tableState.applyColumn)}
dataSource={[...roles.values()]}
- pagination={false}
+ pagination={tableState.pagination}
rowKey={role => role.id}
+ onChange={tableState.onChange}
onRow={(role, rowIndex) => ({
onClick: event => {
navigate(`/roles/${role.id}/view`);
diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx
index 9089335a..890e55c4 100644
--- a/packages/web_ui/src/components/SavesList.tsx
+++ b/packages/web_ui/src/components/SavesList.tsx
@@ -5,10 +5,12 @@ import {
} from "antd";
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
import LeftOutlined from "@ant-design/icons/LeftOutlined";
+import type { ColumnsType } from "antd/es/table";
import * as lib from "@clusterio/lib";
import { useAccount } from "../model/account";
+import useTableQueryState from "../util/useTableQueryState";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
@@ -204,12 +206,15 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
let [saves] = useSavesOfInstance(props.instance.id);
let [starting, setStarting] = useState(false);
let [uploadingFiles, setUploadingFiles] = useState([]);
+ const tableState = useTableQueryState({
+ namespace: "save", defaultSortKey: "mtimeMs", defaultSortOrder: "descend", pagination: false,
+ });
let hostOffline = ["unassigned", "unknown"].includes(props.instance.status!);
const saveTable = new Date(save.mtimeMs).toLocaleString(),
sorter: (a, b) => a.mtimeMs - b.mtimeMs,
- defaultSortOrder: "descend",
},
- ]}
+ ] satisfies ColumnsType).map(tableState.applyColumn)}
dataSource={[...saves.values()]}
+ pagination={tableState.pagination}
+ onChange={tableState.onChange}
rowKey={save => save.name}
expandable={{
columnWidth: 33,
diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx
index 694f1bf8..2cfc25e0 100644
--- a/packages/web_ui/src/components/UserViewPage.tsx
+++ b/packages/web_ui/src/components/UserViewPage.tsx
@@ -5,10 +5,12 @@ import {
Row, Table, Tag, Select, Space, Spin, Switch,
} from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
+import type { ColumnsType } from "antd/es/table";
import * as lib from "@clusterio/lib";
import { useRoles } from "../model/roles";
+import useTableQueryState from "../util/useTableQueryState";
import { useAccount } from "../model/account";
import { useInstances } from "../model/instance";
import ControlContext from "./ControlContext";
@@ -38,6 +40,9 @@ export default function UserViewPage() {
const [user, synced] = useUser(userName);
let [roles] = useRoles();
let [form] = Form.useForm();
+ const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
+ namespace: "stats", defaultSortKey: "instance", pagination: false,
+ });
let [rolesDirty, setRolesDirty] = useState(false);
let [banReasonDirty, setBanReasonDirty] = useState(false);
let [applyingRoles, setApplyingRoles] = useState(false);
@@ -294,12 +299,11 @@ export default function UserViewPage() {
instanceName(id),
- defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])),
},
{
@@ -327,9 +331,10 @@ export default function UserViewPage() {
render: (_, [id]) => formatLastSeen(user, id),
sorter: (a, b) => sortLastSeen(user, user, a[0], b[0]),
},
- ]}
+ ] satisfies ColumnsType<[number, lib.PlayerStats]>).map(statsTable.applyColumn)}
dataSource={[...(user.instanceStats || []).entries()]}
- pagination={false}
+ pagination={statsTable.pagination}
+ onChange={statsTable.onChange}
rowKey={([id]) => id}
onRow={([id], rowIndex) => ({
onClick: event => {
diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx
index 264fa60d..71ea2cd0 100644
--- a/packages/web_ui/src/components/UsersFilters.tsx
+++ b/packages/web_ui/src/components/UsersFilters.tsx
@@ -3,6 +3,7 @@ import { InputRef, Input, Space, Segmented, Button, Typography, Tag } from "antd
import type { FilterDropdownProps } from "antd/es/table/interface";
import { CheckOutlined, CloseOutlined, MinusOutlined } from "@ant-design/icons";
import { UserDetails } from "@clusterio/lib";
+import type { FilterCodec } from "../util/tableQuery";
const { Text } = Typography;
export interface UserFilter {
@@ -24,6 +25,43 @@ function decodeFilter(value: string): UserFilter {
}
}
+function triFromParam(value: string | null): boolean | undefined {
+ if (value === "true") { return true; }
+ if (value === "false") { return false; }
+ return undefined;
+}
+
+/**
+ * Persists the bundled user filter as separate readable query parameters
+ * (`filter.name` for the text search plus `filter.admin`/`filter.whitelisted`/
+ * `filter.banned`) instead of one opaque JSON blob. Plug into
+ * useTableQueryState via `filterCodecs: { name: userFilterCodec }`.
+ */
+export const userFilterCodec: FilterCodec = {
+ decode(params, prefix) {
+ const filter: UserFilter = {};
+ const search = params.get(`${prefix}name`);
+ if (search) { filter.search = search; }
+ const admin = triFromParam(params.get(`${prefix}admin`));
+ if (admin !== undefined) { filter.admin = admin; }
+ const whitelisted = triFromParam(params.get(`${prefix}whitelisted`));
+ if (whitelisted !== undefined) { filter.whitelisted = whitelisted; }
+ const banned = triFromParam(params.get(`${prefix}banned`));
+ if (banned !== undefined) { filter.banned = banned; }
+ return Object.keys(filter).length ? [encodeFilter(filter)] : null;
+ },
+ encode(params, value, prefix) {
+ const filter = value && value[0] ? decodeFilter(String(value[0])) : {};
+ const set = (key: string, val: string | undefined) => (
+ val !== undefined ? params.set(prefix + key, val) : params.delete(prefix + key)
+ );
+ set("name", filter.search || undefined);
+ set("admin", filter.admin === undefined ? undefined : String(filter.admin));
+ set("whitelisted", filter.whitelisted === undefined ? undefined : String(filter.whitelisted));
+ set("banned", filter.banned === undefined ? undefined : String(filter.banned));
+ },
+};
+
function triStateToSegmented(value?: boolean) {
if (value === true) { return "yes"; }
if (value === false) { return "no"; }
diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx
index 7418a4c9..6576d380 100644
--- a/packages/web_ui/src/components/UsersPage.tsx
+++ b/packages/web_ui/src/components/UsersPage.tsx
@@ -386,7 +386,7 @@ export default function UsersPage() {
&& }
}
/>
-
+
;
}
diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx
index d776bd50..0cea72f9 100644
--- a/packages/web_ui/src/components/UsersTable.tsx
+++ b/packages/web_ui/src/components/UsersTable.tsx
@@ -13,8 +13,9 @@ import {
useUsers, getUserStats, isUserOnline,
} from "../model/user";
-import { onFilterUser, Username, useUserFilter } from "./UsersFilters";
+import { onFilterUser, Username, useUserFilter, userFilterCodec } from "./UsersFilters";
import Link from "./Link";
+import useTableQueryState from "../util/useTableQueryState";
export interface UsersTableProps {
/** Optional instance id. If provided, stats will be filtered to that instance only */
@@ -25,14 +26,26 @@ export interface UsersTableProps {
pagination?: false | object;
/** Ant Design size prop */
size?: "small" | "middle" | "large";
+ /** Persist sort/filter/pagination state in the URL (for the standalone Users page). */
+ persistState?: boolean;
}
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
-export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) {
+export default function UsersTable(
+ { instanceId, onlyOnline = false, pagination, size, persistState }: UsersTableProps
+) {
const [roles, rolesSynced] = useRoles();
const [users, usersSynced] = useUsers();
const navigate = useNavigate();
+ const callerPagination = pagination && typeof pagination === "object" ? pagination : undefined;
+ const defaultPageSize = (callerPagination as { defaultPageSize?: number })?.defaultPageSize ?? 50;
+ const tableState = useTableQueryState({
+ namespace: "user",
+ defaultSortKey: "name",
+ pagination: pagination === false ? false : { defaultPageSize },
+ filterCodecs: { name: userFilterCodec },
+ });
const {filterDropdown, filterDropdownProps} = useUserFilter(true);
@@ -102,7 +115,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
filters: roleFilters,
filterMultiple: true,
onFilter: (value: string | number | boolean, record: lib.UserDetails) => (
- record.roleIds.has(value as number)
+ record.roleIds.has(Number(value))
),
render: (_: any, user: lib.UserDetails) => (
[...user.roleIds].map((id) => (
@@ -160,21 +173,36 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
},
];
- const defaultPagination = pagination === undefined
- ? {
- defaultPageSize: 50,
- showSizeChanger: true,
- pageSizeOptions: ["10", "20", "50", "100", "200"],
- showTotal: (total: number) => `${total} Users`,
- }
- : pagination;
+ const displayPagination = {
+ showSizeChanger: true,
+ pageSizeOptions: ["10", "20", "50", "100", "200"],
+ showTotal: (total: number) => `${total} Users`,
+ };
+ let tablePagination: false | object;
+ if (pagination === false) {
+ tablePagination = false;
+ } else if (persistState) {
+ // Controlled current/pageSize from the URL win over the caller's display props.
+ tablePagination = { ...displayPagination, ...callerPagination, ...(tableState.pagination || {}) };
+ } else {
+ tablePagination = { ...displayPagination, ...(callerPagination ?? { defaultPageSize: 50 }) };
+ }
+
+ let tableColumns = persistState ? columns.map(tableState.applyColumn) : columns;
+ // Seed the online-only default when nothing for the Last Seen column is in the URL yet.
+ if (persistState && onlyOnline && !("lastSeen" in tableState.filters)) {
+ tableColumns = tableColumns.map(column => (
+ column.key === "lastSeen" ? { ...column, filteredValue: ["online"] } : column
+ ));
+ }
return (
user.name}
- pagination={defaultPagination}
+ pagination={tablePagination}
+ onChange={persistState ? tableState.onChange : undefined}
size={size}
scroll={{ x: "max-content" }}
loading={!usersSynced || !rolesSynced}
diff --git a/packages/web_ui/src/components/useColumnSearch.tsx b/packages/web_ui/src/components/useColumnSearch.tsx
new file mode 100644
index 00000000..676a3b4c
--- /dev/null
+++ b/packages/web_ui/src/components/useColumnSearch.tsx
@@ -0,0 +1,49 @@
+import React, { useRef } from "react";
+import { Input, InputRef } from "antd";
+import { SearchOutlined } from "@ant-design/icons";
+import type { FilterDropdownProps } from "antd/es/table/interface";
+
+/**
+ * Column props for a free-text search filter rendered in the column header
+ * dropdown (the same style as the Users table). Spread the result onto a
+ * column; the search value is persisted through the table's filter state by
+ * {@link useTableQueryState}.
+ *
+ * @param getText Returns the searchable text for a row.
+ * @param placeholder Placeholder shown in the search box.
+ */
+export default function useColumnSearch(getText: (record: T) => string, placeholder = "Search") {
+ const searchInput = useRef(null);
+
+ return {
+ filterIcon: (filtered: boolean) => (
+
+ ),
+ filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: FilterDropdownProps) => (
+ e.stopPropagation()}>
+ setSelectedKeys(e.target.value ? [e.target.value] : [])}
+ onSearch={() => confirm({ closeDropdown: true })}
+ onClear={() => {
+ clearFilters?.({ closeDropdown: false });
+ confirm({ closeDropdown: true });
+ }}
+ />
+
+ ),
+ filterDropdownProps: {
+ onOpenChange: (open: boolean) => {
+ if (open) {
+ setTimeout(() => searchInput.current?.select(), 100);
+ }
+ },
+ },
+ onFilter: (value: React.Key | boolean, record: T) => (
+ getText(record).toLowerCase().includes(String(value).toLowerCase())
+ ),
+ };
+}
diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts
index 8ced5c7c..e870e173 100644
--- a/packages/web_ui/src/index.ts
+++ b/packages/web_ui/src/index.ts
@@ -42,3 +42,6 @@ export { default as SectionHeader } from "./components/SectionHeader";
export { default as Link } from "./components/Link";
export { default as FactorioIcon } from "./components/FactorioIcon";
+
+export { default as useTableQueryState } from "./util/useTableQueryState";
+export { default as useColumnSearch } from "./components/useColumnSearch";
diff --git a/packages/web_ui/src/util/tableQuery.ts b/packages/web_ui/src/util/tableQuery.ts
new file mode 100644
index 00000000..27e3ae81
--- /dev/null
+++ b/packages/web_ui/src/util/tableQuery.ts
@@ -0,0 +1,168 @@
+/**
+ * Pure helpers for storing antd Table state (sort / filters / pagination) in
+ * the URL query string. Kept free of React and antd runtime imports so the
+ * serialisation can be unit tested in isolation.
+ *
+ * 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"
+ * .page 1-based page number
+ * .pageSize 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.
+ */
+
+export type SortOrder = "ascend" | "descend";
+
+export interface TableSortState {
+ /** Column key the table is sorted by, or undefined for no/default sort. */
+ columnKey?: string;
+ /** Sort direction, or undefined when unsorted. */
+ order?: SortOrder;
+}
+
+export interface TablePageState {
+ page: number;
+ pageSize: number;
+}
+
+/** A single filter value as produced by antd (React.Key is string | number | bigint). */
+export type FilterValue = string | number | bigint | boolean;
+
+/**
+ * Maps a single column's antd filter value to/from one or more query
+ * parameters. Lets a column that bundles several controls (e.g. the user
+ * filter) persist as separate readable params instead of one opaque blob.
+ * `prefix` is the table namespace with its trailing dot, e.g. "user.".
+ */
+export interface FilterCodec {
+ /** Read this column's `filteredValue` from the query, or null when inactive. */
+ decode(params: URLSearchParams, prefix: string): string[] | null;
+ /** Write this column's antd filter value into `params` (mutating). */
+ encode(params: URLSearchParams, value: ReadonlyArray | null, prefix: string): void;
+}
+
+/** Suffixes reserved for table-level state; any other `` is a column filter. */
+const RESERVED_SUFFIXES = new Set(["sort", "order", "page", "pageSize"]);
+
+/** Parse an order parameter, returning undefined for anything unexpected. */
+export function parseOrder(value: string | null): SortOrder | undefined {
+ return value === "ascend" || value === "descend" ? value : undefined;
+}
+
+/**
+ * Read the sort state from the query, falling back to the supplied defaults
+ * when no (valid) sort parameter is present.
+ */
+export function parseSort(
+ params: URLSearchParams,
+ prefix: string,
+ defaultKey?: string,
+ defaultOrder: SortOrder = "ascend",
+): TableSortState {
+ const columnKey = params.get(`${prefix}sort`);
+ if (!columnKey) {
+ return { columnKey: defaultKey, order: defaultKey ? defaultOrder : undefined };
+ }
+ return { columnKey, order: parseOrder(params.get(`${prefix}order`)) ?? defaultOrder };
+}
+
+/** Read pagination state from the query, tolerating missing/garbage values. */
+export function parsePagination(
+ params: URLSearchParams,
+ prefix: string,
+ defaultPageSize: number,
+ defaultPage = 1,
+): TablePageState {
+ const page = Number.parseInt(params.get(`${prefix}page`) ?? "", 10);
+ const pageSize = Number.parseInt(params.get(`${prefix}pageSize`) ?? "", 10);
+ return {
+ page: Number.isInteger(page) && page >= 1 ? page : defaultPage,
+ pageSize: Number.isInteger(pageSize) && pageSize >= 1 ? pageSize : defaultPageSize,
+ };
+}
+
+/** Collect every namespaced column-filter parameter into a map of column key -> values. */
+export function parseFilters(params: URLSearchParams, prefix: string): Record {
+ const filters: Record = {};
+ for (const key of params.keys()) {
+ if (key.startsWith(prefix)) {
+ const column = key.slice(prefix.length);
+ if (!RESERVED_SUFFIXES.has(column) && !(column in filters)) {
+ filters[column] = params.getAll(key);
+ }
+ }
+ }
+ return filters;
+}
+
+/**
+ * Write the sort state into `params` (mutating it). The parameters are omitted
+ * when the sort matches the table default or is cleared, keeping default URLs
+ * clean.
+ */
+export function applySort(
+ params: URLSearchParams,
+ prefix: string,
+ sort: TableSortState,
+ defaultKey?: string,
+ defaultOrder: SortOrder = "ascend",
+): 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.set(`${prefix}sort`, columnKey);
+ params.set(`${prefix}order`, order);
+}
+
+/**
+ * Write the filter state into `params` (mutating it). `filters` is the object
+ * antd hands to onChange: every filterable column key mapped to its current
+ * value array, or null when inactive.
+ */
+export function applyFilters(
+ params: URLSearchParams,
+ prefix: string,
+ filters: Record | null>,
+): void {
+ for (const [key, values] of Object.entries(filters)) {
+ const param = prefix + key;
+ params.delete(param);
+ if (values) {
+ for (const value of values) {
+ params.append(param, String(value));
+ }
+ }
+ }
+}
+
+/**
+ * Write pagination into `params` (mutating it). Defaults (page 1 / default
+ * page size) are omitted to keep URLs clean.
+ */
+export function applyPagination(
+ params: URLSearchParams,
+ prefix: string,
+ page: number,
+ pageSize: number,
+ defaultPageSize: number,
+): void {
+ if (page > 1) {
+ params.set(`${prefix}page`, String(page));
+ } else {
+ params.delete(`${prefix}page`);
+ }
+ if (pageSize !== defaultPageSize) {
+ params.set(`${prefix}pageSize`, String(pageSize));
+ } else {
+ params.delete(`${prefix}pageSize`);
+ }
+}
diff --git a/packages/web_ui/src/util/useTableQueryState.ts b/packages/web_ui/src/util/useTableQueryState.ts
new file mode 100644
index 00000000..3d471ac9
--- /dev/null
+++ b/packages/web_ui/src/util/useTableQueryState.ts
@@ -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;
+}
+
+export interface TableQueryState {
+ /** Spread onto `` to persist sort/filter/pagination (pushes history). */
+ onChange: (
+ pagination: TablePaginationConfig,
+ filters: Record,
+ sorter: SorterResult | SorterResult[],
+ ) => void;
+ /** Wrap each column to inject controlled `sortOrder`/`filteredValue` from the URL. */
+ applyColumn: (column: ColumnType) => ColumnType;
+ /** 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;
+}
+
+/**
+ * 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(options: TableQueryStateOptions): TableQueryState {
+ 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 = {};
+ 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 = { ...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,
+ };
+}
diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx
index 63f669d7..a90d803c 100644
--- a/plugins/subspace_storage/web/index.tsx
+++ b/plugins/subspace_storage/web/index.tsx
@@ -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);
+ 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
-
- {
- 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);
- }));
- }}
- />
-
{
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}
/>
;
}
diff --git a/test/web_ui/tableQuery.js b/test/web_ui/tableQuery.js
new file mode 100644
index 00000000..12656ce1
--- /dev/null
+++ b/test/web_ui/tableQuery.js
@@ -0,0 +1,105 @@
+"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");
+ });
+ });
+});