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"); + }); + }); +});