From 49eb594e5bec8f33c286f233f87cd6423e7bac3a Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:23:54 +0000 Subject: [PATCH] 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) --- .../web_ui/src/components/HostViewPage.tsx | 2 +- packages/web_ui/src/components/HostsPage.tsx | 17 +- .../web_ui/src/components/InstanceList.tsx | 21 ++- .../web_ui/src/components/InstancesPage.tsx | 2 +- packages/web_ui/src/components/ModsPage.tsx | 24 ++- .../src/components/OnlinePlayersList.tsx | 2 +- .../web_ui/src/components/PluginsPage.tsx | 28 ++- packages/web_ui/src/components/RolesPage.tsx | 13 +- packages/web_ui/src/components/SavesList.tsx | 12 +- .../web_ui/src/components/UserViewPage.tsx | 13 +- .../web_ui/src/components/UsersFilters.tsx | 38 ++++ packages/web_ui/src/components/UsersPage.tsx | 2 +- packages/web_ui/src/components/UsersTable.tsx | 54 ++++-- .../web_ui/src/components/useColumnSearch.tsx | 49 +++++ packages/web_ui/src/index.ts | 3 + packages/web_ui/src/util/tableQuery.ts | 168 ++++++++++++++++++ .../web_ui/src/util/useTableQueryState.ts | 105 +++++++++++ plugins/subspace_storage/web/index.tsx | 59 +++--- test/web_ui/tableQuery.js | 105 +++++++++++ 19 files changed, 639 insertions(+), 78 deletions(-) create mode 100644 packages/web_ui/src/components/useColumnSearch.tsx create mode 100644 packages/web_ui/src/util/tableQuery.ts create mode 100644 packages/web_ui/src/util/useTableQueryState.ts create mode 100644 test/web_ui/tableQuery.js 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"); + }); + }); +});