mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 07:04:53 +09:00
Merge pull request #930 from bbassie/feature/table-url-state
Persist table sort/filter state in the URL
This commit is contained in:
14 files changed
+644
-89
No files matched your search
@@ -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`)}
|
||||
/>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,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`)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user