Merge pull request #930 from bbassie/feature/table-url-state

Persist table sort/filter state in the URL
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-07-10 23:13:53 +01:00
commit 58f2ad9810
14 files changed
+644 -89

No files matched your search

+21 -2
View File
@@ -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<lib.HostDetails>({
namespace: "host", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<lib.HostDetails>(tableState, "name", host => host.name, "Search hosts");
const rowNav = useRowNavigation();
return <PageLayout nav={[{ name: "Hosts" }]}>
@@ -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) => <Link to={`/hosts/${host.id}/view`} style={{ color: "inherit" }}>
{host.name}
</Link>,
...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) => <MetricCpuRatio system={systems.get(host.id)} />,
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) => <MetricCpuUsed system={systems.get(host.id)} />,
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) => <MetricMemoryRatio system={systems.get(host.id)} />,
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) => <MetricMemoryUsed system={systems.get(host.id)} />,
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) => <MetricDiskRatio system={systems.get(host.id)} />,
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) => <MetricDiskUsed system={systems.get(host.id)} />,
hidden: !showNumbers,
},
@@ -292,11 +307,13 @@ export default function HostsPage() {
</Tooltip>}
</Space>,
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() {
<RestartRequired system={systems.get(host.id)}/>
</>,
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`)}
/>
<Flex wrap="wrap" style={{ margin: "16px 0 0 0" }} gap="small">
@@ -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<lib.InstanceDetails>({
namespace: "instance", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<lib.InstanceDetails>(
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<lib.InstanceDetails> = [
let columns: ColumnType<lib.InstanceDetails>[] = [
{
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) => <Link to={`/instances/${instance.id}/view`} style={{ color: "inherit" }}>
{instance.name}
</Link>,
...nameSearch,
},
{
title: "Assigned Host",
@@ -69,6 +79,7 @@ export default function InstanceList(props: InstanceListProps) {
<RestartRequired system={instance.assignedHost ? systems.get(instance.assignedHost) : undefined}/>
</Space>,
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) => <InstanceStatusTag status={instance["status"]} />,
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`)}
/>;
}
+25 -5
View File
@@ -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<lib.ModPack>({
namespace: "modPack", defaultSortKey: "name",
});
const modTable = useTableQueryState<lib.ModInfo>({
namespace: "mod", defaultSortKey: "title",
});
const modPackNameSearch = useColumnSearch<lib.ModPack>(
modPackTable, "name", modPack => modPack.name, "Search mod packs"
);
const modNameSearch = useColumnSearch<lib.ModInfo>(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) => <Link
to={`/mods/mod-packs/${modPack.id}/view`}
@@ -589,11 +601,13 @@ export default function ModsPage() {
>
{modPack.name}
</Link>,
...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}
/>
</Upload.Dragger>
+23 -4
View File
@@ -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<PluginWebApi[]>([]);
const tableState = useTableQueryState<PluginRow>({
namespace: "plugin", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<PluginRow>(
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}
</Link>,
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`)}
/>
</PageLayout>;
+9 -1
View File
@@ -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<lib.Role>({ namespace: "role" });
const nameSearch = useColumnSearch<lib.Role>(tableState, "name", role => role.name, "Search roles");
const rowNav = useRowNavigation();
return <PageLayout nav={[{ name: "Roles" }]}>
@@ -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) => <Link to={`/roles/${role.id}/view`} style={{ color: "inherit" }}>
{role.name}
</Link>,
...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`)}
/>
<PluginExtra component="RolesPage" />
+9 -1
View File
@@ -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<File[]>([]);
const tableState = useTableQueryState<lib.SaveDetails>({
namespace: "save", defaultSortKey: "mtimeMs", defaultSortOrder: "descend",
});
let hostOffline = ["unassigned", "unknown"].includes(props.instance.status!);
const saveTable = <Table
@@ -219,6 +223,7 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
{save.loadByDefault && <Tooltip title="Save loaded by default"><LeftOutlined /></Tooltip>}
</>,
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,
@@ -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<boolean>(false);
let [banReasonDirty, setBanReasonDirty] = useState<boolean>(false);
let [applyingRoles, setApplyingRoles] = useState<boolean>(false);
@@ -305,20 +309,22 @@ export default function UserViewPage() {
render: (_, [id]) => <Link to={`/instances/${id}/view`} style={{ color: "inherit" }}>
{instanceName(id)}
</Link>,
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`)}
/>
+60 -23
View File
@@ -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<UserDetails>,
columnKey: string,
withStatus?: boolean,
) {
const searchInput = useRef<InputRef>(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<UserFilter>) {
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);
}
},
};
+42 -20
View File
@@ -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<lib.UserDetails>({
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,
<Username user={user} withStatus />
</Link>
),
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) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
@@ -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 (
<Table
columns={columns}
dataSource={data}
rowKey={(user) => user.name}
pagination={defaultPagination}
pagination={tablePagination}
onChange={tableState.onChange}
size={size}
scroll={{ x: "max-content" }}
loading={!usersSynced || !rolesSynced}
+2
View File
@@ -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";
+175
View File
@@ -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:
* <ns>.sort `<field>.<asc|desc>`, e.g. user.sort=name.desc
* <ns>.page 1-based page number
* <ns>.size rows per page
* <ns>.<column> 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<FilterValue> | null, prefix: string): void;
}
/** Suffixes reserved for table-level state; any other `<prefix><x>` 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 `<field>.<asc|desc>`; only the
* first `<prefix>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<string, string[]> {
const filters: Record<string, string[]> = {};
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<string, ReadonlyArray<FilterValue> | 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`);
}
}
@@ -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<T>(
tableState: TableQueryState<T>,
columnKey: string,
getText: (record: T) => string,
placeholder = "Search",
) {
const searchInput = useRef<InputRef>(null);
return {
filterIcon: (filtered: boolean) => (
<SearchOutlined style={{ color: filtered ? "#1677ff" : undefined }} />
),
filterDropdown: ({ selectedKeys, setSelectedKeys, close }: FilterDropdownProps) => (
<div style={{ padding: 8, width: 240 }} onKeyDown={e => e.stopPropagation()}>
<Input.Search
ref={searchInput}
placeholder={placeholder}
value={selectedKeys[0] as string | undefined}
allowClear
onChange={e => {
const values = e.target.value ? [e.target.value] : [];
setSelectedKeys(values);
tableState.setFilter(columnKey, values.length ? values : null);
}}
onPressEnter={e => {
close();
tableState.commitFilter(columnKey);
}}
/>
</div>
),
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())
),
};
}
@@ -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<string, FilterCodec>;
}
export interface TableQueryState<T> {
/** Spread onto `<Table onChange={...}>` to persist sort/filter/pagination (pushes history). */
onChange: (
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<T> | SorterResult<T>[],
) => 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<T>(options: TableQueryStateOptions): TableQueryState<T> {
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<Record<string, string[] | null>>({});
// 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<Record<string, string[] | null>>(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<string, FilterValue | null> = {};
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,
};
}
+32 -27
View File
@@ -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 | ItemFilter>(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 <PageLayout nav={[{ name: "Storage" }]}>
<PageHeader title="Storage" />
<Paragraph>
<Input
placeholder="Search"
onChange={(event) => {
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);
}));
}}
/>
</Paragraph>
<Table
className="subspace-storage-storage"
columns={[
{
title: "Resource",
key: "resource",
...resourceSearch,
onFilter: matchesResource,
filteredValue: tableState.filteredValue("resource"),
sortOrder: tableState.sortOrder("resource"),
sorter: (a, b) => {
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}
/>
</PageLayout>;
}