diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx
index 5b1dfa15..5a9d9986 100644
--- a/packages/web_ui/src/components/HostsPage.tsx
+++ b/packages/web_ui/src/components/HostsPage.tsx
@@ -1,5 +1,4 @@
import React, { useContext, useRef, useState, useEffect } from "react";
-import { useNavigate } from "react-router-dom";
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
@@ -20,6 +19,8 @@ import {
import { useHosts } from "../model/host";
import { useSystems } from "../model/system";
import notify, { notifyErrorHandler } from "../util/notify";
+import useRowNavigation from "../util/useRowNavigation";
+import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -199,12 +200,12 @@ function CopyButton({ text, message }: { text:string, message:string }) {
export default function HostsPage() {
let account = useAccount();
- let navigate = useNavigate();
let [hosts] = useHosts();
const [systems] = useSystems();
const [showRatios, setShowRatios] = useState(true);
const [showNumbers, setShowNumbers] = useState(false);
const [showCpuModel, setShowCpuModel] = useState(false);
+ const rowNav = useRowNavigation();
return
strcmp(a.name, b.name),
+ render: (_, host) =>
+ {host.name}
+ ,
},
{
title: "CPU Model",
@@ -306,11 +310,7 @@ export default function HostsPage() {
dataSource={[...hosts.values()]}
rowKey={host => host.id}
pagination={false}
- onRow={(record, rowIndex) => ({
- onClick: event => {
- navigate(`/hosts/${record.id}/view`);
- },
- })}
+ onRow={record => rowNav(`/hosts/${record.id}/view`)}
/>
;
diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx
index a6ece310..8cbfe1a2 100644
--- a/packages/web_ui/src/components/ModsPage.tsx
+++ b/packages/web_ui/src/components/ModsPage.tsx
@@ -18,6 +18,8 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
+import useRowNavigation from "../util/useRowNavigation";
+import Link from "./Link";
import ModDetails from "./ModDetails";
import { Dropzone } from "./Dropzone";
import UploadButton from "./UploadButton";
@@ -507,9 +509,9 @@ function SearchModsButton() {
export default function ModsPage() {
let account = useAccount();
let control = useContext(ControlContext);
- let navigate = useNavigate();
let [mods] = useMods();
let [modPacks] = useModPacks();
+ const rowNav = useRowNavigation();
function actions(mod: lib.ModInfo) {
return
@@ -577,6 +579,12 @@ export default function ModsPage() {
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
+ render: (_, modPack) =>
+ {modPack.name}
+ ,
},
{
title: "Factorio Version",
@@ -592,11 +600,7 @@ export default function ModsPage() {
dataSource={[...modPacks.values()]}
pagination={false}
rowKey={modPack => Number(modPack.id)}
- onRow={(modPack, rowIndex) => ({
- onClick: event => {
- navigate(`/mods/mod-packs/${modPack.id}/view`);
- },
- })}
+ onRow={modPack => rowNav(`/mods/mod-packs/${modPack.id}/view`)}
/>
diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx
index bea8cdab..38eed49f 100644
--- a/packages/web_ui/src/components/PluginsPage.tsx
+++ b/packages/web_ui/src/components/PluginsPage.tsx
@@ -1,5 +1,4 @@
import React, { useContext, useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
import { Table } from "antd";
import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled";
import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled";
@@ -10,14 +9,16 @@ import notify from "../util/notify";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import PageHeader from "./PageHeader";
+import useRowNavigation from "../util/useRowNavigation";
+import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
export default function PluginsPage() {
const control = useContext(ControlContext);
- let navigate = useNavigate();
let [pluginList, setPluginList] = useState([]);
+ const rowNav = useRowNavigation();
useEffect(() => {
(async () => {
@@ -54,7 +55,12 @@ export default function PluginsPage() {
{
title: "Name",
key: "name",
- render: (_, plugin) => (plugin.info ? plugin.info.title : plugin.meta.name),
+ render: (_, plugin) =>
+ {plugin.info ? plugin.info.title : plugin.meta.name}
+ ,
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.info ? a.info.title : a.meta.name, b.info ? b.info.title : b.meta.name),
},
@@ -86,11 +92,7 @@ export default function PluginsPage() {
dataSource={tableContents}
rowKey={plugin => plugin.meta.name}
pagination={false}
- onRow={plugin => ({
- onClick: event => {
- navigate(`/plugins/${plugin.meta.name}/view`);
- },
- })}
+ onRow={plugin => rowNav(`/plugins/${plugin.meta.name}/view`)}
/>
;
}
diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx
index 4e392c27..f1b62fb0 100644
--- a/packages/web_ui/src/components/RolesPage.tsx
+++ b/packages/web_ui/src/components/RolesPage.tsx
@@ -11,6 +11,8 @@ import { notifyErrorHandler } from "../util/notify";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
+import useRowNavigation from "../util/useRowNavigation";
+import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -61,8 +63,8 @@ function CreateRoleButton() {
export default function RolesPage() {
let account = useAccount();
- let navigate = useNavigate();
const [roles] = useRoles();
+ const rowNav = useRowNavigation();
return
strcmp(a.name, b.name),
+ render: (_, role) =>
+ {role.name}
+ ,
},
{
title: "Description",
@@ -84,11 +89,7 @@ export default function RolesPage() {
dataSource={[...roles.values()]}
pagination={false}
rowKey={role => role.id}
- onRow={(role, rowIndex) => ({
- onClick: event => {
- navigate(`/roles/${role.id}/view`);
- },
- })}
+ onRow={role => rowNav(`/roles/${role.id}/view`)}
/>
;
diff --git a/packages/web_ui/src/components/SiteLayout.tsx b/packages/web_ui/src/components/SiteLayout.tsx
index 76338056..7d2a77c4 100644
--- a/packages/web_ui/src/components/SiteLayout.tsx
+++ b/packages/web_ui/src/components/SiteLayout.tsx
@@ -8,6 +8,7 @@ import ErrorPage from "./ErrorPage";
import ControlContext from "./ControlContext";
import ChangeLogModal from "./ChangeLogModal";
import AboutModal from "./AboutModal";
+import Link from "./Link";
import { pages } from "../pages";
import { saveJson } from "../util/save_file";
@@ -21,6 +22,12 @@ type MenuItem = Required["items"][number];
const { Header, Sider } = Layout;
+// Render a sidebar menu label as a real link so right-click/middle-click can
+// open it in a new tab; stopPropagation keeps the Menu onClick from also firing.
+function navLink(path: string, label: string) {
+ return e.stopPropagation()}>{label};
+}
+
function isActiveDropzone(element: HTMLElement | null): boolean {
if (!element) {
return false;
@@ -74,7 +81,12 @@ export default function SiteLayout() {
}
},
items: [
- { label: account.name, key: "user" },
+ {
+ label: e.stopPropagation()}>
+ {account.name}
+ ,
+ key: "user",
+ },
{
label:
Ctl Config } />
@@ -110,9 +122,9 @@ export default function SiteLayout() {
group = [];
menuGroups.set(sidebarGroup, group);
}
- group.push({ label: sidebarName, key: path });
+ group.push({ label: navLink(path, sidebarName), key: path });
} else {
- menuItems.push({ label: sidebarName, key: path });
+ menuItems.push({ label: navLink(path, sidebarName), key: path });
}
}
for (let [name, group] of menuGroups) {
diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx
index 694f1bf8..4aed4b05 100644
--- a/packages/web_ui/src/components/UserViewPage.tsx
+++ b/packages/web_ui/src/components/UserViewPage.tsx
@@ -16,6 +16,8 @@ import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import PluginExtra from "./PluginExtra";
import SectionHeader from "./SectionHeader";
+import useRowNavigation from "../util/useRowNavigation";
+import Link from "./Link";
import notify, { notifyErrorHandler } from "../util/notify";
import { formatDuration } from "../util/time_format";
import { formatFirstSeen, formatLastSeen, sortFirstSeen, sortLastSeen, useUser } from "../model/user";
@@ -31,6 +33,7 @@ export default function UserViewPage() {
let userName = params.name as string;
let navigate = useNavigate();
+ const rowNav = useRowNavigation();
let account = useAccount();
let control = useContext(ControlContext);
@@ -298,7 +301,9 @@ export default function UserViewPage() {
{
title: "Instance",
key: "instance",
- render: (_, [id]) => instanceName(id),
+ render: (_, [id]) =>
+ {instanceName(id)}
+ ,
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(instanceName(a[0]), instanceName(b[0])),
},
@@ -331,11 +336,7 @@ export default function UserViewPage() {
dataSource={[...(user.instanceStats || []).entries()]}
pagination={false}
rowKey={([id]) => id}
- onRow={([id], rowIndex) => ({
- onClick: event => {
- navigate(`/instances/${id}/view`);
- },
- })}
+ onRow={([id]) => rowNav(`/instances/${id}/view`)}
/>
;
diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx
index d776bd50..48f26c03 100644
--- a/packages/web_ui/src/components/UsersTable.tsx
+++ b/packages/web_ui/src/components/UsersTable.tsx
@@ -1,7 +1,6 @@
import React from "react";
import { Table, Tag } from "antd";
import { SearchOutlined } from "@ant-design/icons";
-import { useNavigate } from "react-router-dom";
import * as lib from "@clusterio/lib";
@@ -15,6 +14,7 @@ import {
import { onFilterUser, Username, useUserFilter } from "./UsersFilters";
import Link from "./Link";
+import useRowNavigation from "../util/useRowNavigation";
export interface UsersTableProps {
/** Optional instance id. If provided, stats will be filtered to that instance only */
@@ -32,7 +32,7 @@ const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base"
export default function UsersTable({ instanceId, onlyOnline = false, pagination, size }: UsersTableProps) {
const [roles, rolesSynced] = useRoles();
const [users, usersSynced] = useUsers();
- const navigate = useNavigate();
+ const rowNav = useRowNavigation();
const {filterDropdown, filterDropdownProps} = useUserFilter(true);
@@ -85,7 +85,9 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
title: "Name",
key: "name",
render: (_: any, user: lib.UserDetails) => (
-
+
+
+
),
defaultSortOrder: "ascend",
sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name),
@@ -178,14 +180,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination,
size={size}
scroll={{ x: "max-content" }}
loading={!usersSynced || !rolesSynced}
- onRow={(user) => ({
- onClick: (event) => {
- if ((event.target as HTMLElement).closest("a")) {
- return;
- }
- navigate(`/users/${user.name}/view`);
- },
- })}
+ onRow={(user) => rowNav(`/users/${user.name}/view`)}
/>
);
}
diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts
index 8ced5c7c..1715f79b 100644
--- a/packages/web_ui/src/index.ts
+++ b/packages/web_ui/src/index.ts
@@ -42,3 +42,5 @@ export { default as SectionHeader } from "./components/SectionHeader";
export { default as Link } from "./components/Link";
export { default as FactorioIcon } from "./components/FactorioIcon";
+
+export { default as useRowNavigation } from "./util/useRowNavigation";
diff --git a/packages/web_ui/src/util/useRowNavigation.ts b/packages/web_ui/src/util/useRowNavigation.ts
new file mode 100644
index 00000000..125b33c6
--- /dev/null
+++ b/packages/web_ui/src/util/useRowNavigation.ts
@@ -0,0 +1,52 @@
+import type React from "react";
+import { useNavigate } from "react-router-dom";
+
+/** Resolve an in-app path to a full path including the router basename. */
+function resolvePath(to: string): string {
+ const base = webRoot.replace(/\/$/, "");
+ return base + to;
+}
+
+function isInsideLink(target: EventTarget | null): boolean {
+ return target instanceof HTMLElement && target.closest("a") !== null;
+}
+
+/**
+ * Navigation handlers for a whole table row that also support "open in new
+ * tab": left-click navigates in place, middle-click or ctrl/cmd-click opens the
+ * target in a new tab. Spread the result into the object returned from antd's
+ * `onRow`, e.g. `onRow={record => rowNav(`/hosts/${record.id}/view`)}`. Clicks
+ * that land on a real link inside the row are ignored so the anchor handles
+ * them (and shows the native context menu / new-tab affordances itself).
+ */
+export default function useRowNavigation() {
+ const navigate = useNavigate();
+ return function rowNavigation(to: string): React.HTMLAttributes {
+ return {
+ style: { cursor: "pointer" },
+ onClick: (event) => {
+ if (event.defaultPrevented || isInsideLink(event.target)) {
+ return;
+ }
+ if (event.ctrlKey || event.metaKey || event.shiftKey) {
+ window.open(resolvePath(to), "_blank", "noopener");
+ } else {
+ navigate(to);
+ }
+ },
+ onAuxClick: (event) => {
+ // Middle click opens the row in a new tab.
+ if (event.button === 1 && !isInsideLink(event.target)) {
+ event.preventDefault();
+ window.open(resolvePath(to), "_blank", "noopener");
+ }
+ },
+ onMouseDown: (event) => {
+ // Suppress the middle-click autoscroll cursor.
+ if (event.button === 1) {
+ event.preventDefault();
+ }
+ },
+ };
+ };
+}