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 01/11] 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"); + }); + }); +}); From 245483cb359adfce53b4e7f13fe0b460372863d8 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:24:31 +0000 Subject: [PATCH 02/11] Adopt agreed URL scheme and drop the dist-based test Following discussion on the PR, the sort value now packs the direction as `.` (e.g. user.sort=name.desc) instead of a separate `.order` param, page size uses `.size` instead of `.pageSize`, and the reserved system keys are sort/page/size. `.sort` is read tolerant of repeats so multi-column sort can be added later without changing the URL shape. Filterable columns must not use the reserved system names. Removed the web_ui unit test since it required importing from the built dist. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/util/tableQuery.ts | 53 +++++++------ test/web_ui/tableQuery.js | 105 ------------------------- 2 files changed, 30 insertions(+), 128 deletions(-) delete mode 100644 test/web_ui/tableQuery.js diff --git a/packages/web_ui/src/util/tableQuery.ts b/packages/web_ui/src/util/tableQuery.ts index 27e3ae81..4fa4cd0a 100644 --- a/packages/web_ui/src/util/tableQuery.ts +++ b/packages/web_ui/src/util/tableQuery.ts @@ -5,15 +5,15 @@ * * 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" + * .sort `.`, e.g. user.sort=name.desc * .page 1-based page number - * .pageSize rows per page + * .size 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. + * System keys (`sort`, `page`, `size`) are reserved, so filterable columns must + * not use those names. `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 encoding once. */ export type SortOrder = "ascend" | "descend"; @@ -47,16 +47,23 @@ export interface FilterCodec { } /** Suffixes reserved for table-level state; any other `` is a column filter. */ -const RESERVED_SUFFIXES = new Set(["sort", "order", "page", "pageSize"]); +const RESERVED_SUFFIXES = new Set(["sort", "page", "size"]); -/** Parse an order parameter, returning undefined for anything unexpected. */ -export function parseOrder(value: string | null): SortOrder | undefined { - return value === "ascend" || value === "descend" ? value : undefined; +/** Convert a URL sort direction token to the antd sort order. */ +function orderFromToken(token: string): SortOrder { + return token === "desc" ? "descend" : "ascend"; +} + +/** Convert an antd sort order to a URL direction token. */ +function tokenFromOrder(order: SortOrder): string { + return order === "descend" ? "desc" : "asc"; } /** * Read the sort state from the query, falling back to the supplied defaults - * when no (valid) sort parameter is present. + * when no sort parameter is present. The value is `.`; only the + * first `sort` parameter is used (the format allows repeats so + * multi-column sort can be added later without changing the URL shape). */ export function parseSort( params: URLSearchParams, @@ -64,11 +71,14 @@ export function parseSort( defaultKey?: string, defaultOrder: SortOrder = "ascend", ): TableSortState { - const columnKey = params.get(`${prefix}sort`); - if (!columnKey) { + const raw = params.getAll(`${prefix}sort`)[0]; + if (!raw) { return { columnKey: defaultKey, order: defaultKey ? defaultOrder : undefined }; } - return { columnKey, order: parseOrder(params.get(`${prefix}order`)) ?? defaultOrder }; + const dot = raw.lastIndexOf("."); + const columnKey = dot === -1 ? raw : raw.slice(0, dot); + const order = dot === -1 ? defaultOrder : orderFromToken(raw.slice(dot + 1)); + return { columnKey, order }; } /** Read pagination state from the query, tolerating missing/garbage values. */ @@ -79,7 +89,7 @@ export function parsePagination( defaultPage = 1, ): TablePageState { const page = Number.parseInt(params.get(`${prefix}page`) ?? "", 10); - const pageSize = Number.parseInt(params.get(`${prefix}pageSize`) ?? "", 10); + const pageSize = Number.parseInt(params.get(`${prefix}size`) ?? "", 10); return { page: Number.isInteger(page) && page >= 1 ? page : defaultPage, pageSize: Number.isInteger(pageSize) && pageSize >= 1 ? pageSize : defaultPageSize, @@ -114,13 +124,10 @@ export function applySort( ): 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.delete(`${prefix}sort`); + if (columnKey && order && !isDefault) { + params.set(`${prefix}sort`, `${columnKey}.${tokenFromOrder(order)}`); } - params.set(`${prefix}sort`, columnKey); - params.set(`${prefix}order`, order); } /** @@ -161,8 +168,8 @@ export function applyPagination( params.delete(`${prefix}page`); } if (pageSize !== defaultPageSize) { - params.set(`${prefix}pageSize`, String(pageSize)); + params.set(`${prefix}size`, String(pageSize)); } else { - params.delete(`${prefix}pageSize`); + params.delete(`${prefix}size`); } } diff --git a/test/web_ui/tableQuery.js b/test/web_ui/tableQuery.js deleted file mode 100644 index 12656ce1..00000000 --- a/test/web_ui/tableQuery.js +++ /dev/null @@ -1,105 +0,0 @@ -"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"); - }); - }); -}); From 82629a57c6228e10c3ff652b83a2db3ebf1b2d17 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:40:21 +0000 Subject: [PATCH 03/11] Always persist table state; drop the persistState prop Every render site of InstanceList and UsersTable already passed persistState, so the prop and its uncontrolled fallback branch were dead config. Remove the prop and the conditionals so these tables always persist their state to the URL. No runtime behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/HostViewPage.tsx | 2 +- packages/web_ui/src/components/InstanceList.tsx | 11 ++++------- packages/web_ui/src/components/InstancesPage.tsx | 2 +- .../web_ui/src/components/OnlinePlayersList.tsx | 2 +- packages/web_ui/src/components/UsersPage.tsx | 2 +- packages/web_ui/src/components/UsersTable.tsx | 14 +++++--------- 6 files changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 3cc0cf6e..c55eddd8 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/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index 15f59d76..d9627a33 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -23,8 +23,6 @@ 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) { @@ -32,7 +30,6 @@ 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, }); @@ -58,7 +55,7 @@ export default function InstanceList(props: InstanceListProps) { dataIndex: "name", defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a["name"], b["name"]), - ...(persist ? nameSearch : {}), + ...nameSearch, }, { title: "Assigned Host", @@ -128,15 +125,15 @@ export default function InstanceList(props: InstanceListProps) { columns.splice(1, 1); } - const finalColumns = persist ? columns.map(tableState.applyColumn) : columns; + const finalColumns = columns.map(tableState.applyColumn); return
instance["id"]} - pagination={persist ? tableState.pagination : false} - onChange={persist ? tableState.onChange : undefined} + pagination={tableState.pagination} + onChange={tableState.onChange} 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 78cb80d2..cfa24922 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/OnlinePlayersList.tsx b/packages/web_ui/src/components/OnlinePlayersList.tsx index 13950da2..31e5a560 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})

- } } /> - + ; } diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index 0cea72f9..7009a565 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -26,14 +26,12 @@ 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, persistState }: UsersTableProps + { instanceId, onlyOnline = false, pagination, size }: UsersTableProps ) { const [roles, rolesSynced] = useRoles(); const [users, usersSynced] = useUsers(); @@ -181,16 +179,14 @@ export default function UsersTable( let tablePagination: false | object; if (pagination === false) { tablePagination = false; - } else if (persistState) { + } else { // 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; + let tableColumns = columns.map(tableState.applyColumn); // Seed the online-only default when nothing for the Last Seen column is in the URL yet. - if (persistState && onlyOnline && !("lastSeen" in tableState.filters)) { + if (onlyOnline && !("lastSeen" in tableState.filters)) { tableColumns = tableColumns.map(column => ( column.key === "lastSeen" ? { ...column, filteredValue: ["online"] } : column )); @@ -202,7 +198,7 @@ export default function UsersTable( dataSource={data} rowKey={(user) => user.name} pagination={tablePagination} - onChange={persistState ? tableState.onChange : undefined} + onChange={tableState.onChange} size={size} scroll={{ x: "max-content" }} loading={!usersSynced || !rolesSynced} From 4e94092730c50cc791ecacd67969127461fa908d Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:55:47 +0000 Subject: [PATCH 04/11] Type UsersTable columns instead of any UsersTable declared its columns as any[], which masked a few type mismatches. Type them as ColumnType[] (matching InstanceList) and fix what surfaced: the Roles column and onFilterUser value types (antd keys include bigint) and useUserFilter's filterDropdown prop type (FilterDropdownProps instead of a hand-written string[] shape). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/UsersFilters.tsx | 11 +++-------- packages/web_ui/src/components/UsersTable.tsx | 5 +++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx index 71ea2cd0..4c32ee78 100644 --- a/packages/web_ui/src/components/UsersFilters.tsx +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -79,7 +79,7 @@ function matchesTriState(value: boolean, filter?: boolean) { } export function onFilterUser( - value: string | number | boolean, + value: boolean | React.Key, record: UserDetails ) { const filter = decodeFilter(String(value)); @@ -126,14 +126,9 @@ export function useUserFilter(withStatus?: boolean) { setSelectedKeys, confirm, clearFilters, - }: { - selectedKeys: string[]; - setSelectedKeys: (keys: string[]) => void; - confirm: FilterDropdownProps["confirm"]; - clearFilters: FilterDropdownProps["clearFilters"]; - }) { + }: FilterDropdownProps) { const filter: UserFilter = selectedKeys[0] - ? decodeFilter(selectedKeys[0]) + ? decodeFilter(String(selectedKeys[0])) : {}; function update(next: Partial) { diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index 7009a565..2e9e65e7 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Table, Tag } from "antd"; import { SearchOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; +import type { ColumnType } from "antd/es/table/interface"; import * as lib from "@clusterio/lib"; @@ -91,7 +92,7 @@ export default function UsersTable( } } - const columns: any[] = [ + const columns: ColumnType[] = [ { title: "Name", key: "name", @@ -112,7 +113,7 @@ export default function UsersTable( key: "roles", filters: roleFilters, filterMultiple: true, - onFilter: (value: string | number | boolean, record: lib.UserDetails) => ( + onFilter: (value: boolean | React.Key, record: lib.UserDetails) => ( record.roleIds.has(Number(value)) ), render: (_: any, user: lib.UserDetails) => ( From 8cacdc51122e60e18769c43635a8365131c1da75 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 25 Jun 2026 02:01:31 +0000 Subject: [PATCH 05/11] Use inline sort/filter getters instead of mapping columns Replace the satisfies-ColumnsType + .map(applyColumn) approach with inline tableState.sortOrder()/filteredValue() getters set per column, so the column arrays stay plain literals (no satisfies, no constraints; UsersTable stays any[]). Move useColumnSearch into util/ next to useTableQueryState. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/HostsPage.tsx | 20 +++++++++---- .../web_ui/src/components/InstanceList.tsx | 13 ++++---- packages/web_ui/src/components/ModsPage.tsx | 15 ++++++---- .../web_ui/src/components/PluginsPage.tsx | 11 ++++--- packages/web_ui/src/components/RolesPage.tsx | 9 +++--- packages/web_ui/src/components/SavesList.tsx | 8 +++-- .../web_ui/src/components/UserViewPage.tsx | 10 +++++-- packages/web_ui/src/components/UsersTable.tsx | 27 ++++++++--------- packages/web_ui/src/index.ts | 2 +- .../{components => util}/useColumnSearch.tsx | 0 .../web_ui/src/util/useTableQueryState.ts | 30 ++++++++----------- plugins/subspace_storage/web/index.tsx | 8 +++-- 12 files changed, 86 insertions(+), 67 deletions(-) rename packages/web_ui/src/{components => util}/useColumnSearch.tsx (100%) diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index be93c33e..41ecc9a2 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -3,8 +3,6 @@ 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"; @@ -23,7 +21,7 @@ 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"; +import useColumnSearch from "../util/useColumnSearch"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -221,17 +219,20 @@ export default function HostsPage() { />
strcmp(a.name, b.name), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), ...nameSearch, }, { title: "CPU Model", dataIndex: "cpuModel", sorter: (a, b) => strcmp(systems.get(a.id)?.cpuModel ?? "", systems.get(b.id)?.cpuModel ?? ""), + sortOrder: tableState.sortOrder("cpuModel"), render: (_, host) => systems.get(host.id)?.cpuModel, hidden: !showCpuModel, }, @@ -239,6 +240,7 @@ export default function HostsPage() { title: "CPU%", key: "cpuPct", sorter: (a, b) => (systems.get(a.id)?.cpuRatio ?? 0) - (systems.get(b.id)?.cpuRatio ?? 0), + sortOrder: tableState.sortOrder("cpuPct"), render: (_, host) => , hidden: !showRatios, }, @@ -248,6 +250,7 @@ export default function HostsPage() { sorter: (a, b) => ( (systems.get(a.id)?.cpuUsed ?? 0) - (systems.get(b.id)?.cpuUsed ?? 0) ), + sortOrder: tableState.sortOrder("cores"), render: (_, host) => , hidden: !showNumbers, }, @@ -257,6 +260,7 @@ export default function HostsPage() { sorter: (a, b) => ( (systems.get(a.id)?.memoryRatio ?? 0) - (systems.get(b.id)?.memoryRatio ?? 0) ), + sortOrder: tableState.sortOrder("memPct"), render: (_, host) => , hidden: !showRatios, }, @@ -266,6 +270,7 @@ export default function HostsPage() { sorter: (a, b) => ( (systems.get(a.id)?.memoryUsed ?? 0) - (systems.get(b.id)?.memoryUsed ?? 0) ), + sortOrder: tableState.sortOrder("memory"), render: (_, host) => , hidden: !showNumbers, }, @@ -273,6 +278,7 @@ export default function HostsPage() { title: "Disk%", key: "diskPct", sorter: (a, b) => (systems.get(a.id)?.diskAvailable ?? 0) - (systems.get(b.id)?.diskAvailable ?? 0), + sortOrder: tableState.sortOrder("diskPct"), render: (_, host) => , hidden: !showRatios, }, @@ -282,6 +288,7 @@ export default function HostsPage() { sorter: (a, b) => ( (systems.get(a.id)?.diskUsed ?? 0) - (systems.get(b.id)?.diskUsed ?? 0) ), + sortOrder: tableState.sortOrder("disk"), render: (_, host) => , hidden: !showNumbers, }, @@ -295,11 +302,13 @@ export default function HostsPage() { } , sorter: (a, b) => strcmp(a.version, b.version), + sortOrder: tableState.sortOrder("version"), }, { title: "Public address", dataIndex: "publicAddress", sorter: (a, b) => strcmp(a.publicAddress??"", b.publicAddress??""), + sortOrder: tableState.sortOrder("publicAddress"), }, { title: "Connected", @@ -309,8 +318,9 @@ export default function HostsPage() { , sorter: (a, b) => Number(a.connected) - Number(b.connected), + sortOrder: tableState.sortOrder("connected"), }, - ] satisfies ColumnsType).map(tableState.applyColumn)} + ]} dataSource={[...hosts.values()]} rowKey={host => host.id} pagination={tableState.pagination} diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index d9627a33..492e21eb 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -15,7 +15,7 @@ import * as lib from "@clusterio/lib"; import Link from "./Link"; import { instancePublicAddress } from "../util/instance"; import useTableQueryState from "../util/useTableQueryState"; -import useColumnSearch from "./useColumnSearch"; +import useColumnSearch from "../util/useColumnSearch"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -53,8 +53,9 @@ export default function InstanceList(props: InstanceListProps) { { title: "Name", dataIndex: "name", - defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a["name"], b["name"]), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), ...nameSearch, }, { @@ -70,6 +71,7 @@ export default function InstanceList(props: InstanceListProps) { , sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)), + sortOrder: tableState.sortOrder("assignedHost"), responsive: ["sm"], }, { @@ -94,6 +96,7 @@ export default function InstanceList(props: InstanceListProps) { instancePublicAddress(a, hosts.get(a.assignedHost!) ?? null), instancePublicAddress(b, hosts.get(b.assignedHost!) ?? null) ), + sortOrder: tableState.sortOrder("publicAddress"), responsive: ["lg"], }, { @@ -101,6 +104,7 @@ export default function InstanceList(props: InstanceListProps) { key: "version", render: (_, instance) => instance.factorioVersion ?? "unknown", sorter: (a, b) => integerFactorioVersionOrDefault(a) - integerFactorioVersionOrDefault(b), + sortOrder: tableState.sortOrder("version"), responsive: ["xl"], }, { @@ -108,6 +112,7 @@ export default function InstanceList(props: InstanceListProps) { key: "status", render: (_, instance) => , sorter: (a, b) => strcmp(a["status"], b["status"]), + sortOrder: tableState.sortOrder("status"), }, ]; @@ -125,11 +130,9 @@ export default function InstanceList(props: InstanceListProps) { columns.splice(1, 1); } - const finalColumns = columns.map(tableState.applyColumn); - return
instance["id"]} pagination={tableState.pagination} diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 0a25b1bb..99c22d95 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -6,8 +6,6 @@ import { import { ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined, } from "@ant-design/icons"; -import type { ColumnsType } from "antd/es/table"; - import * as lib from "@clusterio/lib"; import { useAccount } from "../model/account"; @@ -579,23 +577,25 @@ export default function ModsPage() { } />
strcmp(a.name, b.name), + sortOrder: modPackTable.sortOrder("name"), }, { title: "Factorio Version", dataIndex: "factorioVersion", sorter: (a, b) => a.integerFactorioVersion - b.integerFactorioVersion, + sortOrder: modPackTable.sortOrder("factorioVersion"), }, { title: "Mods", key: "mods", render: (_, modPack) => modPack.mods.size, }, - ] satisfies ColumnsType).map(modPackTable.applyColumn)} + ]} dataSource={[...modPacks.values()]} pagination={modPackTable.pagination} onChange={modPackTable.onChange} @@ -625,13 +625,14 @@ export default function ModsPage() { >
( strcmp(a.name, b.name) || a.integerVersion - b.integerVersion ), + sortOrder: modTable.sortOrder("title"), }, { title: "Version", @@ -647,6 +648,7 @@ export default function ModsPage() { dataIndex: "filename", responsive: ["xl"], sorter: (a, b) => strcmp(a.filename, b.filename), + sortOrder: modTable.sortOrder("filename"), }, { title: "Size", @@ -655,6 +657,7 @@ export default function ModsPage() { render: (_, mod) => lib.formatBytes(mod.size), align: "right", sorter: (a, b) => a.size - b.size, + sortOrder: modTable.sortOrder("size"), }, { title: "Action", @@ -662,7 +665,7 @@ export default function ModsPage() { responsive: ["lg"], render: (_, mod) => actions(mod), }, - ] satisfies ColumnsType).map(modTable.applyColumn)} + ]} expandable={{ expandedRowRender: (mod: lib.ModInfo) => , expandedRowClassName: () => "no-expanded-padding", diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index 6333b14c..ffe95355 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -1,7 +1,6 @@ import React, { useContext, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Table } from "antd"; -import type { ColumnsType } from "antd/es/table"; import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled"; import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled"; @@ -12,7 +11,7 @@ import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; import PageHeader from "./PageHeader"; import useTableQueryState from "../util/useTableQueryState"; -import useColumnSearch from "./useColumnSearch"; +import useColumnSearch from "../util/useColumnSearch"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -65,12 +64,14 @@ export default function PluginsPage() { return
(plugin.info ? plugin.info.title : plugin.meta.name), sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), ...nameSearch, }, { @@ -89,15 +90,17 @@ export default function PluginsPage() { return plugin.package.version; }, sorter: (a, b) => strcmp(a.meta.version, b.meta.version), + sortOrder: tableState.sortOrder("version"), }, { title: "Loaded", key: "loaded", render: (_, plugin) => (plugin.package ? "Yes" : null), sorter: (a, b) => Number(Boolean(a.package)) - Number(Boolean(b.package)), + sortOrder: tableState.sortOrder("loaded"), responsive: ["sm"], }, - ] satisfies ColumnsType).map(tableState.applyColumn)} + ]} dataSource={tableContents} rowKey={plugin => plugin.meta.name} pagination={tableState.pagination} diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index a21f5fe7..faedd8c8 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -1,7 +1,6 @@ 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"; @@ -13,7 +12,7 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import useTableQueryState from "../util/useTableQueryState"; -import useColumnSearch from "./useColumnSearch"; +import useColumnSearch from "../util/useColumnSearch"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -75,18 +74,20 @@ export default function RolesPage() { extra={account.hasPermission("core.role.create") ? : undefined} />
strcmp(a.name, b.name), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), ...nameSearch, }, { title: "Description", dataIndex: "description", }, - ] satisfies ColumnsType).map(tableState.applyColumn)} + ]} dataSource={[...roles.values()]} pagination={tableState.pagination} rowKey={role => role.id} diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index 890e55c4..b949c3a4 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -5,7 +5,6 @@ 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"; @@ -214,7 +213,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) { const saveTable =
} , sorter: (a, b) => a.name.localeCompare(b.name), + sortOrder: tableState.sortOrder("name"), }, { title: "Size", @@ -232,14 +232,16 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) { render: (_, save) => lib.formatBytes(save.size), align: "right", sorter: (a, b) => a.size - b.size, + sortOrder: tableState.sortOrder("size"), }, { title: "Last Modified", key: "mtimeMs", render: (_, save) => new Date(save.mtimeMs).toLocaleString(), sorter: (a, b) => a.mtimeMs - b.mtimeMs, + sortOrder: tableState.sortOrder("mtimeMs"), }, - ] satisfies ColumnsType).map(tableState.applyColumn)} + ]} dataSource={[...saves.values()]} pagination={tableState.pagination} onChange={tableState.onChange} diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 2cfc25e0..491044f3 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -5,7 +5,6 @@ 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"; @@ -299,24 +298,27 @@ export default function UserViewPage() {
instanceName(id), sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])), + sortOrder: statsTable.sortOrder("instance"), }, { title: "Online time", key: "onlineTime", render: (_, [, stats]) => formatDuration(stats.onlineTimeMs || 0), sorter: (a, b) => (a[1].onlineTimeMs || 0) - (b[1].onlineTimeMs || 0), + sortOrder: statsTable.sortOrder("onlineTime"), }, { title: "Join count", key: "joinCoint", render: (_, [, stats]) => stats.joinCount || 0, sorter: (a, b) => (a[1].joinCount || 0) - (b[1].joinCount || 0), + sortOrder: statsTable.sortOrder("joinCoint"), responsive: ["sm"], }, { @@ -324,14 +326,16 @@ export default function UserViewPage() { key: "firstSeen", render: (_, [id]) => formatFirstSeen(user, id), sorter: (a, b) => sortFirstSeen(user, user, a[0], b[0]), + sortOrder: statsTable.sortOrder("firstSeen"), }, { title: "Last seen", key: "lastSeen", render: (_, [id]) => formatLastSeen(user, id), sorter: (a, b) => sortLastSeen(user, user, a[0], b[0]), + sortOrder: statsTable.sortOrder("lastSeen"), }, - ] satisfies ColumnsType<[number, lib.PlayerStats]>).map(statsTable.applyColumn)} + ]} dataSource={[...(user.instanceStats || []).entries()]} pagination={statsTable.pagination} onChange={statsTable.onChange} diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index 2e9e65e7..fa5816c6 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -2,7 +2,6 @@ import React from "react"; import { Table, Tag } from "antd"; import { SearchOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; -import type { ColumnType } from "antd/es/table/interface"; import * as lib from "@clusterio/lib"; @@ -92,15 +91,16 @@ export default function UsersTable( } } - const columns: ColumnType[] = [ + const columns: any[] = [ { title: "Name", key: "name", render: (_: any, user: lib.UserDetails) => ( ), - defaultSortOrder: "ascend", sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), filterIcon: (filtered: boolean) => ( ), @@ -113,6 +113,7 @@ export default function UsersTable( key: "roles", filters: roleFilters, filterMultiple: true, + filteredValue: tableState.filteredValue("roles"), onFilter: (value: boolean | React.Key, record: lib.UserDetails) => ( record.roleIds.has(Number(value)) ), @@ -136,6 +137,7 @@ export default function UsersTable( const statsB = getUserStats(b, instanceId); return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); }, + sortOrder: tableState.sortOrder("onlineTime"), }, { title: "Join Count", @@ -148,27 +150,30 @@ export default function UsersTable( const statsB = getUserStats(b, instanceId); return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); }, + sortOrder: tableState.sortOrder("joinCount"), }, { title: "First Seen", key: "firstSeen", filterMultiple: false, filters: durationFilters, + filteredValue: tableState.filteredValue("firstSeen"), onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateFirstSeen), render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId), sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b, instanceId, instanceId), + sortOrder: tableState.sortOrder("firstSeen"), }, { title: "Last Seen", key: "lastSeen", filterMultiple: false, filters: durationFilters, - defaultFilteredValue: onlyOnline ? ["online"] : undefined, + // Default to online-only when nothing for this column is in the URL yet. + filteredValue: tableState.filteredValue("lastSeen") ?? (onlyOnline ? ["online"] : null), onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateLastSeen), sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId), + sortOrder: tableState.sortOrder("lastSeen"), render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId), - // Responsive breaks defaultFilteredValue, see: https://github.com/ant-design/ant-design/issues/32847 - // responsive: ["lg"], }, ]; @@ -185,17 +190,9 @@ export default function UsersTable( tablePagination = { ...displayPagination, ...callerPagination, ...(tableState.pagination || {}) }; } - let tableColumns = columns.map(tableState.applyColumn); - // Seed the online-only default when nothing for the Last Seen column is in the URL yet. - if (onlyOnline && !("lastSeen" in tableState.filters)) { - tableColumns = tableColumns.map(column => ( - column.key === "lastSeen" ? { ...column, filteredValue: ["online"] } : column - )); - } - return (
user.name} pagination={tablePagination} diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index e870e173..501b032e 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -44,4 +44,4 @@ 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"; +export { default as useColumnSearch } from "./util/useColumnSearch"; diff --git a/packages/web_ui/src/components/useColumnSearch.tsx b/packages/web_ui/src/util/useColumnSearch.tsx similarity index 100% rename from packages/web_ui/src/components/useColumnSearch.tsx rename to packages/web_ui/src/util/useColumnSearch.tsx diff --git a/packages/web_ui/src/util/useTableQueryState.ts b/packages/web_ui/src/util/useTableQueryState.ts index 3d471ac9..f4b92002 100644 --- a/packages/web_ui/src/util/useTableQueryState.ts +++ b/packages/web_ui/src/util/useTableQueryState.ts @@ -1,6 +1,6 @@ import { useSearchParams } from "react-router-dom"; import type { TablePaginationConfig } from "antd"; -import type { ColumnType, FilterValue, SorterResult } from "antd/es/table/interface"; +import type { FilterValue, SorterResult } from "antd/es/table/interface"; import { applyFilters, applyPagination, applySort, @@ -28,12 +28,12 @@ export interface TableQueryState { filters: Record, sorter: SorterResult | SorterResult[], ) => void; - /** Wrap each column to inject controlled `sortOrder`/`filteredValue` from the URL. */ - applyColumn: (column: ColumnType) => ColumnType; + /** Controlled `sortOrder` for the column with this key (null unless it's the active sort). */ + sortOrder: (columnKey: string) => SortOrder | null; + /** Controlled `filteredValue` for the column with this key (null when no filter is active). */ + filteredValue: (columnKey: string) => string[] | null; /** 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; } /** @@ -83,23 +83,17 @@ export default function useTableQueryState(options: TableQueryStateOptions): 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; + sortOrder(columnKey) { + return columnKey === sort.columnKey ? (sort.order ?? null) : null; + }, + + filteredValue(columnKey) { + const codec = codecs[columnKey]; + return codec ? codec.decode(params, prefix) : (filters[columnKey] ?? null); }, 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 a90d803c..3eb72a02 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -1,6 +1,5 @@ import { useContext, useEffect, useState } from "react"; import { Table } from "antd"; -import type { ColumnsType } from "antd/es/table"; import { BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler, @@ -87,12 +86,14 @@ function StoragePage() {
{ let aName = getLocaleName(a[1].name); let bName = getLocaleName(b[1].name); @@ -121,9 +122,10 @@ function StoragePage() { key: "quantity", align: "right", sorter: (a, b) => a[1].count - b[1].count, + sortOrder: tableState.sortOrder("quantity"), render: (_, item) => numberFormat.format(item[1].count), }, - ] satisfies ColumnsType<[string, Item]>).map(tableState.applyColumn)} + ]} dataSource={storage} rowKey={item => item[0]} pagination={tableState.pagination} From 1e7b8f08cf7e4b6ec86d7e6198cefcda09527d04 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:26:39 +0000 Subject: [PATCH 06/11] Address review: tidy pagination handling and comments Let useTableQueryState accept a full TablePaginationConfig (only defaultPageSize is read) so UsersTable passes its pagination prop straight through instead of extracting and casting it. Drop the redundant pagination: false from the tables that just want it disabled (that is already the default). Clarify the comment on the Last Seen online-only default to mention the onlyOnline prop, and explain why onChange only uses the first sorter. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/HostsPage.tsx | 2 +- packages/web_ui/src/components/InstanceList.tsx | 2 +- packages/web_ui/src/components/ModsPage.tsx | 4 ++-- packages/web_ui/src/components/PluginsPage.tsx | 2 +- packages/web_ui/src/components/RolesPage.tsx | 2 +- packages/web_ui/src/components/SavesList.tsx | 2 +- packages/web_ui/src/components/UserViewPage.tsx | 2 +- packages/web_ui/src/components/UsersTable.tsx | 14 +++++++------- packages/web_ui/src/util/useTableQueryState.ts | 6 ++++-- 9 files changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 41ecc9a2..9219218e 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -208,7 +208,7 @@ export default function HostsPage() { const [showNumbers, setShowNumbers] = useState(false); const [showCpuModel, setShowCpuModel] = useState(false); const tableState = useTableQueryState({ - namespace: "host", defaultSortKey: "name", pagination: false, + namespace: "host", defaultSortKey: "name", }); const nameSearch = useColumnSearch(host => host.name, "Search hosts"); diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index 492e21eb..478d66f2 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -31,7 +31,7 @@ export default function InstanceList(props: InstanceListProps) { let [hosts] = useHosts(); const [systems] = useSystems(); const tableState = useTableQueryState({ - namespace: "instance", defaultSortKey: "name", pagination: false, + namespace: "instance", defaultSortKey: "name", }); const nameSearch = useColumnSearch(instance => instance.name, "Search instances"); diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 99c22d95..8ca6b83b 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -511,10 +511,10 @@ export default function ModsPage() { let [mods] = useMods(); let [modPacks] = useModPacks(); const modPackTable = useTableQueryState({ - namespace: "modPack", defaultSortKey: "name", pagination: false, + namespace: "modPack", defaultSortKey: "name", }); const modTable = useTableQueryState({ - namespace: "mod", defaultSortKey: "title", pagination: false, + namespace: "mod", defaultSortKey: "title", }); function actions(mod: lib.ModInfo) { diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index ffe95355..13ee636d 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -27,7 +27,7 @@ export default function PluginsPage() { let navigate = useNavigate(); let [pluginList, setPluginList] = useState([]); const tableState = useTableQueryState({ - namespace: "plugin", defaultSortKey: "name", pagination: false, + namespace: "plugin", defaultSortKey: "name", }); const nameSearch = useColumnSearch( plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins" diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index faedd8c8..44b34e07 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -65,7 +65,7 @@ export default function RolesPage() { let account = useAccount(); let navigate = useNavigate(); const [roles] = useRoles(); - const tableState = useTableQueryState({ namespace: "role", pagination: false }); + const tableState = useTableQueryState({ namespace: "role" }); const nameSearch = useColumnSearch(role => role.name, "Search roles"); return diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index b949c3a4..6219899e 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -206,7 +206,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) { let [starting, setStarting] = useState(false); let [uploadingFiles, setUploadingFiles] = useState([]); const tableState = useTableQueryState({ - namespace: "save", defaultSortKey: "mtimeMs", defaultSortOrder: "descend", pagination: false, + namespace: "save", defaultSortKey: "mtimeMs", defaultSortOrder: "descend", }); let hostOffline = ["unassigned", "unknown"].includes(props.instance.status!); diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 491044f3..fd0c2eea 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -40,7 +40,7 @@ export default function UserViewPage() { let [roles] = useRoles(); let [form] = Form.useForm(); const statsTable = useTableQueryState<[number, lib.PlayerStats]>({ - namespace: "stats", defaultSortKey: "instance", pagination: false, + namespace: "stats", defaultSortKey: "instance", }); let [rolesDirty, setRolesDirty] = useState(false); let [banReasonDirty, setBanReasonDirty] = useState(false); diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index fa5816c6..11d17163 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Table, Tag } from "antd"; +import { Table, Tag, type TablePaginationConfig } from "antd"; import { SearchOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; @@ -23,7 +23,7 @@ export interface UsersTableProps { /** Show only players that are currently online, works with instanceId to show online of a particular instance */ onlyOnline?: boolean; /** Ant Design pagination prop. Pass `false` to disable pagination. */ - pagination?: false | object; + pagination?: false | TablePaginationConfig; /** Ant Design size prop */ size?: "small" | "middle" | "large"; } @@ -36,12 +36,11 @@ export default function UsersTable( 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 }, + // Default to 50 per page when the caller does not specify a pagination config. + pagination: pagination === false ? false : (pagination ?? { defaultPageSize: 50 }), filterCodecs: { name: userFilterCodec }, }); @@ -168,7 +167,8 @@ export default function UsersTable( key: "lastSeen", filterMultiple: false, filters: durationFilters, - // Default to online-only when nothing for this column is in the URL yet. + // With the onlyOnline prop set, seed this column's filter to "online" until the + // URL provides one, so the table starts out showing only online players. filteredValue: tableState.filteredValue("lastSeen") ?? (onlyOnline ? ["online"] : null), onFilter: (value: any, record: lib.UserDetails) => onFilterDuration(value, record, calculateLastSeen), sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId), @@ -187,7 +187,7 @@ export default function UsersTable( tablePagination = false; } else { // Controlled current/pageSize from the URL win over the caller's display props. - tablePagination = { ...displayPagination, ...callerPagination, ...(tableState.pagination || {}) }; + tablePagination = { ...displayPagination, ...pagination, ...(tableState.pagination || {}) }; } return ( diff --git a/packages/web_ui/src/util/useTableQueryState.ts b/packages/web_ui/src/util/useTableQueryState.ts index f4b92002..dad0be56 100644 --- a/packages/web_ui/src/util/useTableQueryState.ts +++ b/packages/web_ui/src/util/useTableQueryState.ts @@ -15,8 +15,8 @@ export interface TableQueryStateOptions { 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 }; + /** Pagination config to persist pagination; only `defaultPageSize` is read (omit for `pagination={false}`). */ + pagination?: false | TablePaginationConfig; /** Per-column-key codecs to persist a bundled filter value as separate readable params. */ filterCodecs?: Record; } @@ -59,6 +59,8 @@ export default function useTableQueryState(options: TableQueryStateOptions): return { onChange(pagination, tableFilters, sorter) { + // Only single-column sort is persisted; antd passes an array when several + // columns are sorted, so take the first (primary) 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; From 1f2730a64fbf797bd8448449849d34135cd13dc4 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:58:16 +0000 Subject: [PATCH 07/11] Add name-column search to the Mods and Mod Packs tables These were the only tables without the column search filter; wire useColumnSearch into both name columns with URL-persisted filteredValue, matching the other tables. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/ModsPage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 29fb95e3..a6af79fe 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -18,6 +18,7 @@ import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; import SectionHeader from "./SectionHeader"; import useTableQueryState from "../util/useTableQueryState"; +import useColumnSearch from "../util/useColumnSearch"; import useRowNavigation from "../util/useRowNavigation"; import Link from "./Link"; import ModDetails from "./ModDetails"; @@ -517,6 +518,8 @@ export default function ModsPage() { const modTable = useTableQueryState({ namespace: "mod", defaultSortKey: "title", }); + const modPackNameSearch = useColumnSearch(modPack => modPack.name, "Search mod packs"); + const modNameSearch = useColumnSearch(mod => mod.title, "Search mods"); const rowNav = useRowNavigation(); function actions(mod: lib.ModInfo) { @@ -585,6 +588,7 @@ export default function ModsPage() { dataIndex: "name", sorter: (a, b) => strcmp(a.name, b.name), sortOrder: modPackTable.sortOrder("name"), + filteredValue: modPackTable.filteredValue("name"), className: "table-link-cell", render: (_, modPack) => {modPack.name} , + ...modPackNameSearch, }, { title: "Factorio Version", @@ -638,6 +643,8 @@ export default function ModsPage() { strcmp(a.name, b.name) || a.integerVersion - b.integerVersion ), sortOrder: modTable.sortOrder("title"), + filteredValue: modTable.filteredValue("title"), + ...modNameSearch, }, { title: "Version", From bb4c82ea5a2eb9efbb55fa269b98e464866683a0 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:14:32 +0000 Subject: [PATCH 08/11] Make all table search live and only write the URL on close Add a live filter override to useTableQueryState: setFilter updates the filter immediately (so every table's search filters as you type) without touching the URL, and commitFilter writes it to the URL when the filter dropdown closes. useColumnSearch and useUserFilter now filter live via setFilter and commit on close, so the Users table no longer rewrites the URL on every keystroke and the other tables gain live search. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/HostsPage.tsx | 2 +- .../web_ui/src/components/InstanceList.tsx | 4 +- packages/web_ui/src/components/ModsPage.tsx | 6 ++- .../web_ui/src/components/PluginsPage.tsx | 2 +- packages/web_ui/src/components/RolesPage.tsx | 2 +- .../web_ui/src/components/UsersFilters.tsx | 30 ++++++++------- packages/web_ui/src/components/UsersTable.tsx | 2 +- packages/web_ui/src/util/useColumnSearch.tsx | 36 +++++++++++++----- .../web_ui/src/util/useTableQueryState.ts | 38 +++++++++++++++++++ plugins/subspace_storage/web/index.tsx | 4 +- 10 files changed, 95 insertions(+), 31 deletions(-) diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 9eca290c..2176e6ec 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -210,7 +210,7 @@ export default function HostsPage() { const tableState = useTableQueryState({ namespace: "host", defaultSortKey: "name", }); - const nameSearch = useColumnSearch(host => host.name, "Search hosts"); + const nameSearch = useColumnSearch(tableState, "name", host => host.name, "Search hosts"); const rowNav = useRowNavigation(); return diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index b3adb4bd..d0806b4d 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -32,7 +32,9 @@ export default function InstanceList(props: InstanceListProps) { const tableState = useTableQueryState({ namespace: "instance", defaultSortKey: "name", }); - const nameSearch = useColumnSearch(instance => instance.name, "Search instances"); + const nameSearch = useColumnSearch( + tableState, "name", instance => instance.name, "Search instances" + ); const rowNav = useRowNavigation(); function hostName(hostId?: number) { diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index a6af79fe..34963f4e 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -518,8 +518,10 @@ export default function ModsPage() { const modTable = useTableQueryState({ namespace: "mod", defaultSortKey: "title", }); - const modPackNameSearch = useColumnSearch(modPack => modPack.name, "Search mod packs"); - const modNameSearch = useColumnSearch(mod => mod.title, "Search mods"); + const modPackNameSearch = useColumnSearch( + modPackTable, "name", modPack => modPack.name, "Search mod packs" + ); + const modNameSearch = useColumnSearch(modTable, "title", mod => mod.title, "Search mods"); const rowNav = useRowNavigation(); function actions(mod: lib.ModInfo) { diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index 1a8960f1..399a92de 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -30,7 +30,7 @@ export default function PluginsPage() { namespace: "plugin", defaultSortKey: "name", }); const nameSearch = useColumnSearch( - plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins" + tableState, "name", plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins" ); const rowNav = useRowNavigation(); diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index 24f2c5eb..e3c9dbf9 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -67,7 +67,7 @@ export default function RolesPage() { let account = useAccount(); const [roles] = useRoles(); const tableState = useTableQueryState({ namespace: "role" }); - const nameSearch = useColumnSearch(role => role.name, "Search roles"); + const nameSearch = useColumnSearch(tableState, "name", role => role.name, "Search roles"); const rowNav = useRowNavigation(); return diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx index 4c32ee78..ca54014c 100644 --- a/packages/web_ui/src/components/UsersFilters.tsx +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -4,6 +4,7 @@ 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"; +import type { TableQueryState } from "../util/useTableQueryState"; const { Text } = Typography; export interface UserFilter { @@ -118,27 +119,29 @@ export function Username({ ); } -export function useUserFilter(withStatus?: boolean) { +export function useUserFilter( + tableState: TableQueryState, + columnKey: string, + withStatus?: boolean, +) { const searchInput = useRef(null); function filterDropdown({ selectedKeys, setSelectedKeys, - confirm, - clearFilters, + close, }: FilterDropdownProps) { const filter: UserFilter = selectedKeys[0] ? decodeFilter(String(selectedKeys[0])) : {}; + // Update the live filter as the user types/toggles; the URL is only written + // when the dropdown closes (see filterDropdownProps.onOpenChange below). function update(next: Partial) { const encoded = encodeFilter({ ...filter, ...next }); - if (encoded === "{}") { - clearFilters?.({ closeDropdown: false }); - } else { - setSelectedKeys([encoded]); - } - confirm({ closeDropdown: false }); + const values = encoded === "{}" ? null : [encoded]; + setSelectedKeys(values ?? []); + tableState.setFilter(columnKey, values); } return ( @@ -149,12 +152,9 @@ export function useUserFilter(withStatus?: boolean) { placeholder="Search username" value={filter.search} allowClear - onSearch={() => confirm({ closeDropdown: true })} + onSearch={() => close()} onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })} - onClear={() => { - clearFilters?.({ closeDropdown: false }); - confirm({ closeDropdown: true }); - }} + onClear={() => update({ search: undefined })} /> {withStatus &&<> @@ -209,6 +209,8 @@ export function useUserFilter(withStatus?: boolean) { onOpenChange: (open: boolean) => { if (open) { setTimeout(() => searchInput.current?.select(), 100); + } else { + tableState.commitFilter(columnKey); } }, }; diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index 6e2b41c7..8cbd2580 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -44,7 +44,7 @@ export default function UsersTable( }); const rowNav = useRowNavigation(); - const {filterDropdown, filterDropdownProps} = useUserFilter(true); + const {filterDropdown, filterDropdownProps} = useUserFilter(tableState, "name", true); const data = [...users.values()]; const roleFilters = [...roles.values()].map(role => ({ text: role.name, value: role.id })); diff --git a/packages/web_ui/src/util/useColumnSearch.tsx b/packages/web_ui/src/util/useColumnSearch.tsx index 676a3b4c..4fe3451d 100644 --- a/packages/web_ui/src/util/useColumnSearch.tsx +++ b/packages/web_ui/src/util/useColumnSearch.tsx @@ -3,34 +3,50 @@ import { Input, InputRef } from "antd"; import { SearchOutlined } from "@ant-design/icons"; import type { FilterDropdownProps } from "antd/es/table/interface"; +import type { TableQueryState } from "./useTableQueryState"; + /** * 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}. + * dropdown (the same style as the Users table). Spread the result onto a column + * whose `filteredValue` is `tableState.filteredValue(columnKey)`. * + * Filtering is live: the table updates as you type via {@link TableQueryState.setFilter}, + * and the value is only written to the URL when the dropdown closes (via + * {@link TableQueryState.commitFilter}). + * + * @param tableState The table's query state. + * @param columnKey Key of the column this search filters (matches its filteredValue key). * @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") { +export default function useColumnSearch( + tableState: TableQueryState, + columnKey: string, + getText: (record: T) => string, + placeholder = "Search", +) { const searchInput = useRef(null); return { filterIcon: (filtered: boolean) => ( ), - filterDropdown: ({ selectedKeys, setSelectedKeys, confirm, clearFilters }: FilterDropdownProps) => ( + filterDropdown: ({ selectedKeys, setSelectedKeys, close }: FilterDropdownProps) => (
e.stopPropagation()}> setSelectedKeys(e.target.value ? [e.target.value] : [])} - onSearch={() => confirm({ closeDropdown: true })} + onChange={e => { + const values = e.target.value ? [e.target.value] : []; + setSelectedKeys(values); + tableState.setFilter(columnKey, values.length ? values : null); + }} + onSearch={() => close()} onClear={() => { - clearFilters?.({ closeDropdown: false }); - confirm({ closeDropdown: true }); + setSelectedKeys([]); + tableState.setFilter(columnKey, null); }} />
@@ -39,6 +55,8 @@ export default function useColumnSearch(getText: (record: T) => string, place onOpenChange: (open: boolean) => { if (open) { setTimeout(() => searchInput.current?.select(), 100); + } else { + tableState.commitFilter(columnKey); } }, }, diff --git a/packages/web_ui/src/util/useTableQueryState.ts b/packages/web_ui/src/util/useTableQueryState.ts index dad0be56..b7e43e3e 100644 --- a/packages/web_ui/src/util/useTableQueryState.ts +++ b/packages/web_ui/src/util/useTableQueryState.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import type { TablePaginationConfig } from "antd"; import type { FilterValue, SorterResult } from "antd/es/table/interface"; @@ -32,6 +33,10 @@ export interface TableQueryState { sortOrder: (columnKey: string) => SortOrder | null; /** Controlled `filteredValue` for the column with this key (null when no filter is active). */ filteredValue: (columnKey: string) => string[] | null; + /** Set a column's live filter — filters the table immediately without writing the URL. */ + setFilter: (columnKey: string, values: string[] | null) => void; + /** Commit a column's live filter to the URL; call when its filter dropdown closes. */ + commitFilter: (columnKey: string) => void; /** Controlled pagination ({@link current}/{@link pageSize}), or `false` when disabled. */ pagination: false | TablePaginationConfig; } @@ -52,6 +57,14 @@ export default function useTableQueryState(options: TableQueryStateOptions): const defaultPageSize = paginationOptions?.defaultPageSize ?? 10; const [params, setParams] = useSearchParams(); + // Live filter overrides let the table filter as the user types without writing the + // URL on every keystroke; they are committed to the URL when the filter dropdown + // closes (see commitFilter) and cleared whenever the URL changes (commit, back/forward). + const [liveFilters, setLiveFilters] = useState>({}); + const paramsString = params.toString(); + useEffect(() => { + setLiveFilters({}); + }, [paramsString]); const sort = parseSort(params, prefix, defaultSortKey, defaultSortOrder); const filters = parseFilters(params, prefix); @@ -90,10 +103,35 @@ export default function useTableQueryState(options: TableQueryStateOptions): }, filteredValue(columnKey) { + if (columnKey in liveFilters) { + return liveFilters[columnKey]; + } const codec = codecs[columnKey]; return codec ? codec.decode(params, prefix) : (filters[columnKey] ?? null); }, + setFilter(columnKey, values) { + setLiveFilters(prev => ({ ...prev, [columnKey]: values })); + }, + + commitFilter(columnKey) { + // Only write the URL when a live edit is pending for this column. + if (!(columnKey in liveFilters)) { + return; + } + const next = new URLSearchParams(params); + const codec = codecs[columnKey]; + if (codec) { + codec.encode(next, liveFilters[columnKey], prefix); + } else { + applyFilters(next, prefix, { [columnKey]: liveFilters[columnKey] }); + } + // Avoid pushing a duplicate history entry when nothing actually changed. + if (next.toString() !== paramsString) { + setParams(next); + } + }, + pagination: paginationEnabled && page ? { current: page.page, pageSize: page.pageSize } : false, diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 3eb72a02..2ead807c 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -39,7 +39,9 @@ function StoragePage() { const tableState = useTableQueryState<[string, Item]>({ namespace: "storage", defaultSortKey: "quantity", defaultSortOrder: "descend", pagination: false, }); - const resourceSearch = useColumnSearch<[string, Item]>(item => getLocaleName(item[1].name), "Search"); + const resourceSearch = useColumnSearch<[string, Item]>( + tableState, "resource", item => getLocaleName(item[1].name), "Search" + ); function getLocaleName(itemName: string) { let localeName = itemName; From 2898fdf5d3a53eeb27de4910bffd5e44761397f5 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:37:32 +0000 Subject: [PATCH 09/11] Write the URL immediately when a search filter is cleared The search box clear (X) button cleared the filter live but only wrote the URL when the dropdown closed. commitFilter now accepts an explicit value so the clear handlers can commit right away (avoiding the stale-state race of reading the live override in the same event), for both useColumnSearch and the Users filter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web_ui/src/components/UsersFilters.tsx | 13 ++++++++----- packages/web_ui/src/util/useColumnSearch.tsx | 2 ++ .../web_ui/src/util/useTableQueryState.ts | 19 ++++++++++++------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx index ca54014c..3aeae466 100644 --- a/packages/web_ui/src/components/UsersFilters.tsx +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -1,5 +1,5 @@ import React, { useRef } from "react"; -import { InputRef, Input, Space, Segmented, Button, Typography, Tag } from "antd"; +import { InputRef, Input, Space, Segmented, 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"; @@ -135,13 +135,16 @@ export function useUserFilter( ? decodeFilter(String(selectedKeys[0])) : {}; - // Update the live filter as the user types/toggles; the URL is only written - // when the dropdown closes (see filterDropdownProps.onOpenChange below). - function update(next: Partial) { + // Update the live filter as the user types/toggles; the URL is only written when the + // dropdown closes (see onOpenChange), or immediately when `commit` is set (e.g. clear). + function update(next: Partial, commit = false) { const encoded = encodeFilter({ ...filter, ...next }); const values = encoded === "{}" ? null : [encoded]; setSelectedKeys(values ?? []); tableState.setFilter(columnKey, values); + if (commit) { + tableState.commitFilter(columnKey, values); + } } return ( @@ -154,7 +157,7 @@ export function useUserFilter( allowClear onSearch={() => close()} onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })} - onClear={() => update({ search: undefined })} + onClear={() => update({ search: undefined }, true)} /> {withStatus &&<> diff --git a/packages/web_ui/src/util/useColumnSearch.tsx b/packages/web_ui/src/util/useColumnSearch.tsx index 4fe3451d..37c4833c 100644 --- a/packages/web_ui/src/util/useColumnSearch.tsx +++ b/packages/web_ui/src/util/useColumnSearch.tsx @@ -47,6 +47,8 @@ export default function useColumnSearch( onClear={() => { setSelectedKeys([]); tableState.setFilter(columnKey, null); + // Clearing is a deliberate action, so write it to the URL right away. + tableState.commitFilter(columnKey, null); }} /> diff --git a/packages/web_ui/src/util/useTableQueryState.ts b/packages/web_ui/src/util/useTableQueryState.ts index b7e43e3e..8ee1c1a5 100644 --- a/packages/web_ui/src/util/useTableQueryState.ts +++ b/packages/web_ui/src/util/useTableQueryState.ts @@ -35,8 +35,8 @@ export interface TableQueryState { filteredValue: (columnKey: string) => string[] | null; /** Set a column's live filter — filters the table immediately without writing the URL. */ setFilter: (columnKey: string, values: string[] | null) => void; - /** Commit a column's live filter to the URL; call when its filter dropdown closes. */ - commitFilter: (columnKey: string) => void; + /** Commit a column's filter to the URL: pass `values` to write now (e.g. clear), omit to commit the override. */ + commitFilter: (columnKey: string, values?: string[] | null) => void; /** Controlled pagination ({@link current}/{@link pageSize}), or `false` when disabled. */ pagination: false | TablePaginationConfig; } @@ -114,17 +114,22 @@ export default function useTableQueryState(options: TableQueryStateOptions): setLiveFilters(prev => ({ ...prev, [columnKey]: values })); }, - commitFilter(columnKey) { - // Only write the URL when a live edit is pending for this column. - if (!(columnKey in liveFilters)) { + commitFilter(columnKey, values) { + // Explicit values (e.g. from the clear button) are written immediately; otherwise + // commit the current live override, if any (e.g. when the dropdown closes). + let effective = values; + if (effective === undefined && columnKey in liveFilters) { + effective = liveFilters[columnKey]; + } + if (effective === undefined) { return; } const next = new URLSearchParams(params); const codec = codecs[columnKey]; if (codec) { - codec.encode(next, liveFilters[columnKey], prefix); + codec.encode(next, effective, prefix); } else { - applyFilters(next, prefix, { [columnKey]: liveFilters[columnKey] }); + applyFilters(next, prefix, { [columnKey]: effective }); } // Avoid pushing a duplicate history entry when nothing actually changed. if (next.toString() !== paramsString) { From 40407e52d609e52fccbda814334231215d8e3b84 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:02:25 +0000 Subject: [PATCH 10/11] Fix clearing a live-but-uncommitted search re-committing the old value commitFilter now reads the live overrides from a ref that setFilter updates synchronously, so committing within the same event that changed a filter no longer sees a stale render closure. antd's Input.Search fires onSearch (before onChange) when the clear button is clicked, so onSearch now commits the cleared state instead of closing the dropdown, which had re-committed the still-live typed value. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/UsersFilters.tsx | 10 ++++++++-- packages/web_ui/src/util/useColumnSearch.tsx | 17 +++++++++++------ packages/web_ui/src/util/useTableQueryState.ts | 14 ++++++++++---- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx index 3aeae466..4a8bac72 100644 --- a/packages/web_ui/src/components/UsersFilters.tsx +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -155,9 +155,15 @@ export function useUserFilter( placeholder="Search username" value={filter.search} allowClear - onSearch={() => close()} + onSearch={value => { + if (value) { + close(); + } else { + // Clear (X) fires onSearch with an empty value before onChange. + update({ search: undefined }, true); + } + }} onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })} - onClear={() => update({ search: undefined }, true)} /> {withStatus &&<> diff --git a/packages/web_ui/src/util/useColumnSearch.tsx b/packages/web_ui/src/util/useColumnSearch.tsx index 37c4833c..c5fed6b9 100644 --- a/packages/web_ui/src/util/useColumnSearch.tsx +++ b/packages/web_ui/src/util/useColumnSearch.tsx @@ -43,12 +43,17 @@ export default function useColumnSearch( setSelectedKeys(values); tableState.setFilter(columnKey, values.length ? values : null); }} - onSearch={() => close()} - onClear={() => { - setSelectedKeys([]); - tableState.setFilter(columnKey, null); - // Clearing is a deliberate action, so write it to the URL right away. - tableState.commitFilter(columnKey, null); + onSearch={value => { + if (value) { + close(); + } else { + // antd fires onSearch with an empty value when the clear (X) is clicked + // (before onChange); commit the cleared state instead of closing, which + // would re-commit the still-live typed value. + setSelectedKeys([]); + tableState.setFilter(columnKey, null); + tableState.commitFilter(columnKey, null); + } }} /> diff --git a/packages/web_ui/src/util/useTableQueryState.ts b/packages/web_ui/src/util/useTableQueryState.ts index 8ee1c1a5..068f33fd 100644 --- a/packages/web_ui/src/util/useTableQueryState.ts +++ b/packages/web_ui/src/util/useTableQueryState.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import type { TablePaginationConfig } from "antd"; import type { FilterValue, SorterResult } from "antd/es/table/interface"; @@ -61,8 +61,12 @@ export default function useTableQueryState(options: TableQueryStateOptions): // URL on every keystroke; they are committed to the URL when the filter dropdown // closes (see commitFilter) and cleared whenever the URL changes (commit, back/forward). const [liveFilters, setLiveFilters] = useState>({}); + // A ref mirrors the overrides so commitFilter always reads the latest values even within + // the same event that set them (e.g. clearing then closing), instead of a stale closure. + const liveFiltersRef = useRef>(liveFilters); const paramsString = params.toString(); useEffect(() => { + liveFiltersRef.current = {}; setLiveFilters({}); }, [paramsString]); @@ -111,15 +115,17 @@ export default function useTableQueryState(options: TableQueryStateOptions): }, setFilter(columnKey, values) { - setLiveFilters(prev => ({ ...prev, [columnKey]: values })); + const nextLive = { ...liveFiltersRef.current, [columnKey]: values }; + liveFiltersRef.current = nextLive; + setLiveFilters(nextLive); }, commitFilter(columnKey, values) { // Explicit values (e.g. from the clear button) are written immediately; otherwise // commit the current live override, if any (e.g. when the dropdown closes). let effective = values; - if (effective === undefined && columnKey in liveFilters) { - effective = liveFilters[columnKey]; + if (effective === undefined && columnKey in liveFiltersRef.current) { + effective = liveFiltersRef.current[columnKey]; } if (effective === undefined) { return; From 3cc99a1ea69803318a2d895875dd563a338cc9c0 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:10:54 +0100 Subject: [PATCH 11/11] Fix commit filter logic for empty search strings --- packages/web_ui/src/components/UsersFilters.tsx | 17 +++++------------ packages/web_ui/src/util/useColumnSearch.tsx | 14 +++----------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx index 4a8bac72..03fdc1bf 100644 --- a/packages/web_ui/src/components/UsersFilters.tsx +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -136,15 +136,12 @@ export function useUserFilter( : {}; // Update the live filter as the user types/toggles; the URL is only written when the - // dropdown closes (see onOpenChange), or immediately when `commit` is set (e.g. clear). - function update(next: Partial, commit = false) { + // dropdown closes (see onOpenChange) or when enter is pressed (see onPressEnter) + function update(next: Partial) { const encoded = encodeFilter({ ...filter, ...next }); const values = encoded === "{}" ? null : [encoded]; setSelectedKeys(values ?? []); tableState.setFilter(columnKey, values); - if (commit) { - tableState.commitFilter(columnKey, values); - } } return ( @@ -155,13 +152,9 @@ export function useUserFilter( placeholder="Search username" value={filter.search} allowClear - onSearch={value => { - if (value) { - close(); - } else { - // Clear (X) fires onSearch with an empty value before onChange. - update({ search: undefined }, true); - } + onPressEnter={e => { + close(); + tableState.commitFilter(columnKey); }} onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })} /> diff --git a/packages/web_ui/src/util/useColumnSearch.tsx b/packages/web_ui/src/util/useColumnSearch.tsx index c5fed6b9..eb5e015f 100644 --- a/packages/web_ui/src/util/useColumnSearch.tsx +++ b/packages/web_ui/src/util/useColumnSearch.tsx @@ -43,17 +43,9 @@ export default function useColumnSearch( setSelectedKeys(values); tableState.setFilter(columnKey, values.length ? values : null); }} - onSearch={value => { - if (value) { - close(); - } else { - // antd fires onSearch with an empty value when the clear (X) is clicked - // (before onChange); commit the cleared state instead of closing, which - // would re-commit the still-live typed value. - setSelectedKeys([]); - tableState.setFilter(columnKey, null); - tableState.commitFilter(columnKey, null); - } + onPressEnter={e => { + close(); + tableState.commitFilter(columnKey); }} />