diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 9ead51fe..2176e6ec 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -19,6 +19,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 "../util/useColumnSearch"; import useRowNavigation from "../util/useRowNavigation"; import Link from "./Link"; @@ -205,6 +207,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", + }); + const nameSearch = useColumnSearch(tableState, "name", host => host.name, "Search hosts"); const rowNav = useRowNavigation(); return @@ -218,17 +224,20 @@ export default function HostsPage() { { title: "Name", dataIndex: "name", - defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a.name, b.name), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), className: "table-link-cell", render: (_, host) => {host.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, }, @@ -236,6 +245,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, }, @@ -245,6 +255,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, }, @@ -254,6 +265,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, }, @@ -263,6 +275,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, }, @@ -270,6 +283,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, }, @@ -279,6 +293,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, }, @@ -292,11 +307,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", @@ -306,11 +323,13 @@ export default function HostsPage() { , sorter: (a, b) => Number(a.connected) - Number(b.connected), + sortOrder: tableState.sortOrder("connected"), }, ]} dataSource={[...hosts.values()]} rowKey={host => host.id} - pagination={false} + pagination={tableState.pagination} + onChange={tableState.onChange} onRow={record => rowNav(`/hosts/${record.id}/view`)} /> diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index 449a7eb7..02315695 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -2,7 +2,7 @@ import React from "react"; 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"; @@ -13,6 +13,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 "../util/useColumnSearch"; import useRowNavigation from "../util/useRowNavigation"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -27,6 +29,12 @@ export default function InstanceList(props: InstanceListProps) { let account = useAccount(); let [hosts] = useHosts(); const [systems] = useSystems(); + const tableState = useTableQueryState({ + namespace: "instance", defaultSortKey: "name", + }); + const nameSearch = useColumnSearch( + tableState, "name", instance => instance.name, "Search instances" + ); const rowNav = useRowNavigation(); function hostName(hostId?: number) { @@ -44,16 +52,18 @@ export default function InstanceList(props: InstanceListProps) { return -1; } - let columns: ColumnsType = [ + let columns: ColumnType[] = [ { title: "Name", dataIndex: "name", - defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a["name"], b["name"]), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), className: "table-link-cell", render: (_, instance) => {instance.name} , + ...nameSearch, }, { title: "Assigned Host", @@ -69,6 +79,7 @@ export default function InstanceList(props: InstanceListProps) { , sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)), + sortOrder: tableState.sortOrder("assignedHost"), responsive: ["sm"], }, { @@ -93,6 +104,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"], }, { @@ -100,6 +112,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"], }, { @@ -107,6 +120,7 @@ export default function InstanceList(props: InstanceListProps) { key: "status", render: (_, instance) => , sorter: (a, b) => strcmp(a["status"], b["status"]), + sortOrder: tableState.sortOrder("status"), }, ]; @@ -129,7 +143,8 @@ export default function InstanceList(props: InstanceListProps) { columns={columns} dataSource={[...props.instances.values()]} rowKey={instance => instance["id"]} - pagination={false} + pagination={tableState.pagination} + onChange={tableState.onChange} onRow={record => rowNav(`/instances/${record.id}/view`)} />; } diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 8d76cc78..9f22a1a1 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -6,7 +6,6 @@ import { import { ImportOutlined, PlusOutlined, SearchOutlined, DownloadOutlined, } from "@ant-design/icons"; - import * as lib from "@clusterio/lib"; import { useAccount } from "../model/account"; @@ -18,6 +17,8 @@ import PageHeader from "./PageHeader"; 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"; @@ -514,6 +515,16 @@ export default function ModsPage() { let control = useContext(ControlContext); let [mods] = useMods(); let [modPacks] = useModPacks(); + const modPackTable = useTableQueryState({ + namespace: "modPack", defaultSortKey: "name", + }); + const modTable = useTableQueryState({ + namespace: "mod", defaultSortKey: "title", + }); + 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) { @@ -580,8 +591,9 @@ export default function ModsPage() { { title: "Name", dataIndex: "name", - defaultSortOrder: "ascend", 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", dataIndex: "factorioVersion", sorter: (a, b) => a.integerFactorioVersion - b.integerFactorioVersion, + sortOrder: modPackTable.sortOrder("factorioVersion"), }, { title: "Mods", @@ -602,7 +616,8 @@ export default function ModsPage() { }, ]} dataSource={[...modPacks.values()]} - pagination={false} + pagination={modPackTable.pagination} + onChange={modPackTable.onChange} rowKey={modPack => Number(modPack.id)} onRow={modPack => rowNav(`/mods/mod-packs/${modPack.id}/view`)} /> @@ -629,10 +644,12 @@ export default function ModsPage() { { title: "Name", dataIndex: "title", - defaultSortOrder: "ascend", sorter: (a, b) => ( strcmp(a.name, b.name) || a.integerVersion - b.integerVersion ), + sortOrder: modTable.sortOrder("title"), + filteredValue: modTable.filteredValue("title"), + ...modNameSearch, }, { title: "Version", @@ -648,6 +665,7 @@ export default function ModsPage() { dataIndex: "filename", responsive: ["xl"], sorter: (a, b) => strcmp(a.filename, b.filename), + sortOrder: modTable.sortOrder("filename"), }, { title: "Size", @@ -656,6 +674,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", @@ -669,7 +688,8 @@ export default function ModsPage() { 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/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index 3aa2a810..399a92de 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -3,21 +3,35 @@ import { Table } from "antd"; import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled"; import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled"; -import type { PluginWebApi } from "@clusterio/lib"; +import type { PluginWebApi, PluginWebpackEnvInfo } from "@clusterio/lib"; import notify from "../util/notify"; import ControlContext from "./ControlContext"; import PageLayout from "./PageLayout"; import PageHeader from "./PageHeader"; +import useTableQueryState from "../util/useTableQueryState"; +import useColumnSearch from "../util/useColumnSearch"; import useRowNavigation from "../util/useRowNavigation"; import Link from "./Link"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; +type PluginRow = { + meta: PluginWebApi; + info?: PluginWebpackEnvInfo; + package?: any; +}; + export default function PluginsPage() { const control = useContext(ControlContext); let [pluginList, setPluginList] = useState([]); + const tableState = useTableQueryState({ + namespace: "plugin", defaultSortKey: "name", + }); + const nameSearch = useColumnSearch( + tableState, "name", plugin => (plugin.info ? plugin.info.title : plugin.meta.name), "Search plugins" + ); const rowNav = useRowNavigation(); useEffect(() => { @@ -31,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)!; @@ -62,8 +76,10 @@ export default function PluginsPage() { > {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), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), + ...nameSearch, }, { title: "Version", @@ -81,18 +97,21 @@ 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"], }, ]} dataSource={tableContents} rowKey={plugin => plugin.meta.name} - pagination={false} + pagination={tableState.pagination} + onChange={tableState.onChange} onRow={plugin => rowNav(`/plugins/${plugin.meta.name}/view`)} /> ; diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index 1d01f847..e3c9dbf9 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -11,6 +11,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 "../util/useColumnSearch"; import useRowNavigation from "../util/useRowNavigation"; import Link from "./Link"; @@ -64,6 +66,8 @@ function CreateRoleButton() { export default function RolesPage() { let account = useAccount(); const [roles] = useRoles(); + const tableState = useTableQueryState({ namespace: "role" }); + const nameSearch = useColumnSearch(tableState, "name", role => role.name, "Search roles"); const rowNav = useRowNavigation(); return @@ -77,10 +81,13 @@ export default function RolesPage() { title: "Name", dataIndex: "name", sorter: (a, b) => strcmp(a.name, b.name), + sortOrder: tableState.sortOrder("name"), + filteredValue: tableState.filteredValue("name"), className: "table-link-cell", render: (_, role) => {role.name} , + ...nameSearch, }, { title: "Description", @@ -88,8 +95,9 @@ export default function RolesPage() { }, ]} dataSource={[...roles.values()]} - pagination={false} + pagination={tableState.pagination} rowKey={role => role.id} + onChange={tableState.onChange} onRow={role => rowNav(`/roles/${role.id}/view`)} /> diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index d4a84465..0ad07eb2 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -9,6 +9,7 @@ import LeftOutlined from "@ant-design/icons/LeftOutlined"; 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,6 +205,9 @@ 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", + }); let hostOffline = ["unassigned", "unknown"].includes(props.instance.status!); const saveTable = } , sorter: (a, b) => a.name.localeCompare(b.name), + sortOrder: tableState.sortOrder("name"), }, { title: "Size", @@ -227,16 +232,19 @@ 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, - defaultSortOrder: "descend", + sortOrder: tableState.sortOrder("mtimeMs"), }, ]} 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 4eb0c206..fd6b2848 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -9,6 +9,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; 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"; @@ -41,6 +42,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", + }); let [rolesDirty, setRolesDirty] = useState(false); let [banReasonDirty, setBanReasonDirty] = useState(false); let [applyingRoles, setApplyingRoles] = useState(false); @@ -305,20 +309,22 @@ export default function UserViewPage() { render: (_, [id]) => {instanceName(id)} , - defaultSortOrder: "ascend", 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"], }, { @@ -326,16 +332,19 @@ 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"), }, ]} dataSource={[...(user.instanceStats || []).entries()]} - pagination={false} + pagination={statsTable.pagination} + onChange={statsTable.onChange} rowKey={([id]) => id} onRow={([id]) => rowNav(`/instances/${id}/view`)} /> diff --git a/packages/web_ui/src/components/UsersFilters.tsx b/packages/web_ui/src/components/UsersFilters.tsx index 264fa60d..03fdc1bf 100644 --- a/packages/web_ui/src/components/UsersFilters.tsx +++ b/packages/web_ui/src/components/UsersFilters.tsx @@ -1,8 +1,10 @@ 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"; +import type { FilterCodec } from "../util/tableQuery"; +import type { TableQueryState } from "../util/useTableQueryState"; const { Text } = Typography; export interface UserFilter { @@ -24,6 +26,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"; } @@ -41,7 +80,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)); @@ -80,32 +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, - }: { - selectedKeys: string[]; - setSelectedKeys: (keys: string[]) => void; - confirm: FilterDropdownProps["confirm"]; - clearFilters: FilterDropdownProps["clearFilters"]; - }) { + close, + }: FilterDropdownProps) { const filter: UserFilter = selectedKeys[0] - ? decodeFilter(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 onOpenChange) or when enter is pressed (see onPressEnter) 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 ( @@ -116,12 +152,11 @@ export function useUserFilter(withStatus?: boolean) { placeholder="Search username" value={filter.search} allowClear - onSearch={() => confirm({ closeDropdown: true })} - onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })} - onClear={() => { - clearFilters?.({ closeDropdown: false }); - confirm({ closeDropdown: true }); + onPressEnter={e => { + close(); + tableState.commitFilter(columnKey); }} + onChange={(e) => update({ search: e.target.value !== "" ? e.target.value : undefined })} /> {withStatus &&<> @@ -176,6 +211,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 ba5f3c70..8cbd2580 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 * as lib from "@clusterio/lib"; @@ -12,8 +12,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"; import useRowNavigation from "../util/useRowNavigation"; export interface UsersTableProps { @@ -22,19 +23,28 @@ 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"; } 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 }: UsersTableProps +) { const [roles, rolesSynced] = useRoles(); const [users, usersSynced] = useUsers(); + const tableState = useTableQueryState({ + namespace: "user", + defaultSortKey: "name", + // Default to 50 per page when the caller does not specify a pagination config. + pagination: pagination === false ? false : (pagination ?? { defaultPageSize: 50 }), + filterCodecs: { name: userFilterCodec }, + }); 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 })); @@ -90,8 +100,9 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, ), - 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) => ( ), @@ -104,8 +115,9 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, key: "roles", filters: roleFilters, filterMultiple: true, - onFilter: (value: string | number | boolean, record: lib.UserDetails) => ( - record.roleIds.has(value as number) + filteredValue: tableState.filteredValue("roles"), + onFilter: (value: boolean | React.Key, record: lib.UserDetails) => ( + record.roleIds.has(Number(value)) ), render: (_: any, user: lib.UserDetails) => ( [...user.roleIds].map((id) => ( @@ -127,6 +139,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, const statsB = getUserStats(b, instanceId); return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); }, + sortOrder: tableState.sortOrder("onlineTime"), }, { title: "Join Count", @@ -139,45 +152,54 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, 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, + // 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), + 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"], }, ]; - 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 { + // Controlled current/pageSize from the URL win over the caller's display props. + tablePagination = { ...displayPagination, ...pagination, ...(tableState.pagination || {}) }; + } return (
user.name} - pagination={defaultPagination} + pagination={tablePagination} + onChange={tableState.onChange} size={size} scroll={{ x: "max-content" }} loading={!usersSynced || !rolesSynced} diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index 1715f79b..2de8cedf 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -43,4 +43,6 @@ 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 "./util/useColumnSearch"; export { default as useRowNavigation } from "./util/useRowNavigation"; diff --git a/packages/web_ui/src/util/tableQuery.ts b/packages/web_ui/src/util/tableQuery.ts new file mode 100644 index 00000000..4fa4cd0a --- /dev/null +++ b/packages/web_ui/src/util/tableQuery.ts @@ -0,0 +1,175 @@ +/** + * 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 `.`, e.g. user.sort=name.desc + * .page 1-based page number + * .size rows per page + * . filter value for that column (e.g. user.name, user.admin) + * + * 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"; + +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", "page", "size"]); + +/** 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 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, + prefix: string, + defaultKey?: string, + defaultOrder: SortOrder = "ascend", +): TableSortState { + const raw = params.getAll(`${prefix}sort`)[0]; + if (!raw) { + return { columnKey: defaultKey, order: defaultKey ? defaultOrder : undefined }; + } + 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. */ +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}size`) ?? "", 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; + params.delete(`${prefix}sort`); + if (columnKey && order && !isDefault) { + params.set(`${prefix}sort`, `${columnKey}.${tokenFromOrder(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}size`, String(pageSize)); + } else { + params.delete(`${prefix}size`); + } +} diff --git a/packages/web_ui/src/util/useColumnSearch.tsx b/packages/web_ui/src/util/useColumnSearch.tsx new file mode 100644 index 00000000..eb5e015f --- /dev/null +++ b/packages/web_ui/src/util/useColumnSearch.tsx @@ -0,0 +1,66 @@ +import React, { useRef } from "react"; +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 + * 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( + tableState: TableQueryState, + columnKey: string, + getText: (record: T) => string, + placeholder = "Search", +) { + const searchInput = useRef(null); + + return { + filterIcon: (filtered: boolean) => ( + + ), + filterDropdown: ({ selectedKeys, setSelectedKeys, close }: FilterDropdownProps) => ( +
e.stopPropagation()}> + { + const values = e.target.value ? [e.target.value] : []; + setSelectedKeys(values); + tableState.setFilter(columnKey, values.length ? values : null); + }} + onPressEnter={e => { + close(); + tableState.commitFilter(columnKey); + }} + /> +
+ ), + filterDropdownProps: { + onOpenChange: (open: boolean) => { + if (open) { + setTimeout(() => searchInput.current?.select(), 100); + } else { + tableState.commitFilter(columnKey); + } + }, + }, + onFilter: (value: React.Key | boolean, record: T) => ( + getText(record).toLowerCase().includes(String(value).toLowerCase()) + ), + }; +} diff --git a/packages/web_ui/src/util/useTableQueryState.ts b/packages/web_ui/src/util/useTableQueryState.ts new file mode 100644 index 00000000..068f33fd --- /dev/null +++ b/packages/web_ui/src/util/useTableQueryState.ts @@ -0,0 +1,150 @@ +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"; + +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; + /** 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; +} + +export interface TableQueryState { + /** Spread onto `
` to persist sort/filter/pagination (pushes history). */ + onChange: ( + pagination: TablePaginationConfig, + filters: Record, + sorter: SorterResult | SorterResult[], + ) => void; + /** 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; + /** 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 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; +} + +/** + * 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(); + // 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>({}); + // 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]); + + 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) { + // 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; + + 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); + }, + + sortOrder(columnKey) { + return columnKey === sort.columnKey ? (sort.order ?? null) : null; + }, + + 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) { + 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 liveFiltersRef.current) { + effective = liveFiltersRef.current[columnKey]; + } + if (effective === undefined) { + return; + } + const next = new URLSearchParams(params); + const codec = codecs[columnKey]; + if (codec) { + codec.encode(next, effective, prefix); + } else { + applyFilters(next, prefix, { [columnKey]: effective }); + } + // 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 63f669d7..2ead807c 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -1,16 +1,15 @@ import { useContext, useEffect, useState } from "react"; -import { Input, Table, Typography } from "antd"; +import { Table } from "antd"; 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 +36,12 @@ 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]>( + tableState, "resource", item => getLocaleName(item[1].name), "Search" + ); function getLocaleName(itemName: string) { let localeName = itemName; @@ -65,33 +68,34 @@ 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 +123,15 @@ function StoragePage() { title: "Quantity", key: "quantity", align: "right", - defaultSortOrder: "descend", sorter: (a, b) => a[1].count - b[1].count, + sortOrder: tableState.sortOrder("quantity"), render: (_, item) => numberFormat.format(item[1].count), }, ]} - dataSource={filter ? storage.filter(filter) : storage} + dataSource={storage} rowKey={item => item[0]} - pagination={false} + pagination={tableState.pagination} + onChange={tableState.onChange} /> ; }