Use real links for navigation so they open in new tabs

In-app navigation was done with JS onClick -> navigate() handlers instead of
real <a href> anchors, so the browser's right-click "Open in new tab" (and
middle-click / ctrl-click) was unavailable. (closes #929)

- Sidebar nav items and the account "user" entry now render their label as the
  existing Link component (a real anchor via react-router useHref), keeping the
  Menu onClick for plain left-clicks; stopPropagation avoids double navigation.
- The "back to instances" alert action becomes a Link.
- Table rows (Hosts, Roles, Plugins, Instances, Users, the user instance-stats
  table, Mod Packs): the name cell becomes a real Link (color inherit, so it
  looks unchanged) which carries the right-click context menu, and a new
  useRowNavigation hook handles the whole row — left-click navigates in place,
  middle-click / ctrl-click open the target in a new tab. The hook ignores
  clicks landing on a real link inside the row so the anchor handles those.

Genuine actions (download/delete/modify/logout), post-create navigation and
external links are left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 4.8 committed 2026-06-24 23:52:15 +00:00
1 parent 9e727f9a13
commit f04fc68513
11 files changed
+123 -60

No files matched your search

+7 -7
View File
@@ -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 <PageLayout nav={[{ name: "Hosts" }]}>
<PageHeader
@@ -219,6 +220,9 @@ export default function HostsPage() {
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
render: (_, host) => <Link to={`/hosts/${host.id}/view`} style={{ color: "inherit" }}>
{host.name}
</Link>,
},
{
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`)}
/>
<Flex wrap="wrap" style={{ margin: "16px 0 0 0" }} gap="small">
<label style={{ width: "14em", display: "flex", justifyContent: "space-between", marginRight: 16 }}>
@@ -1,5 +1,4 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { message, Button, Table, Space } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import type { SizeType } from "antd/es/config-provider/SizeContext";
@@ -14,6 +13,7 @@ import InstanceControlButton, { InstanceControlButtonPermissions } from "./Insta
import * as lib from "@clusterio/lib";
import Link from "./Link";
import { instancePublicAddress } from "../util/instance";
import useRowNavigation from "../util/useRowNavigation";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -25,9 +25,9 @@ type InstanceListProps = {
export default function InstanceList(props: InstanceListProps) {
let account = useAccount();
let navigate = useNavigate();
let [hosts] = useHosts();
const [systems] = useSystems();
const rowNav = useRowNavigation();
function hostName(hostId?: number) {
if (hostId === undefined) {
@@ -49,6 +49,9 @@ export default function InstanceList(props: InstanceListProps) {
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
render: (_, instance) => <Link to={`/instances/${instance.id}/view`} style={{ color: "inherit" }}>
{instance.name}
</Link>,
},
{
title: "Assigned Host",
@@ -124,10 +127,6 @@ export default function InstanceList(props: InstanceListProps) {
dataSource={[...props.instances.values()]}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
navigate(`/instances/${record.id}/view`);
},
})}
onRow={record => rowNav(`/instances/${record.id}/view`)}
/>;
}
@@ -198,12 +198,7 @@ export default function InstanceViewPage() {
description={<>Instance with id {instanceId} was not found on the controller.</>}
type="warning"
action={
<Button
type="text"
onClick={() => { navigate("/instances"); }}
>
Go back to instances list
</Button>
<Link to="/instances">Go back to instances list</Link>
}
/>
</PageLayout>;
+10 -6
View File
@@ -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 <Space>
@@ -577,6 +579,12 @@ export default function ModsPage() {
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
render: (_, modPack) => <Link
to={`/mods/mod-packs/${modPack.id}/view`}
style={{ color: "inherit" }}
>
{modPack.name}
</Link>,
},
{
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`)}
/>
<SectionHeader title="Stored Mods" extra={<Space wrap>
<SearchModsButton />
+10 -8
View File
@@ -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<PluginWebApi[]>([]);
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) => <Link
to={`/plugins/${plugin.meta.name}/view`}
style={{ color: "inherit" }}
>
{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),
},
@@ -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`)}
/>
</PageLayout>;
}
+7 -6
View File
@@ -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 <PageLayout nav={[{ name: "Roles" }]}>
<PageHeader
@@ -75,6 +77,9 @@ export default function RolesPage() {
title: "Name",
dataIndex: "name",
sorter: (a, b) => strcmp(a.name, b.name),
render: (_, role) => <Link to={`/roles/${role.id}/view`} style={{ color: "inherit" }}>
{role.name}
</Link>,
},
{
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`)}
/>
<PluginExtra component="RolesPage" />
</PageLayout>;
+15 -3
View File
@@ -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<MenuProps>["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 <Link to={path} onClick={e => e.stopPropagation()}>{label}</Link>;
}
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: <Link to={`/users/${account.name}/view`} onClick={e => e.stopPropagation()}>
{account.name}
</Link>,
key: "user",
},
{
label: <Tooltip title="Download credentials and configuration file for cli interface">
Ctl Config <Button size="small" icon={<DownloadOutlined />} />
@@ -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) {
@@ -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]) => <Link to={`/instances/${id}/view`} style={{ color: "inherit" }}>
{instanceName(id)}
</Link>,
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`)}
/>
<PluginExtra component="UserViewPage" user={user} />
</PageLayout>;
+6 -11
View File
@@ -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) => (
<Username user={user} withStatus />
<Link to={`/users/${user.name}/view`} style={{ color: "inherit" }}>
<Username user={user} withStatus />
</Link>
),
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`)}
/>
);
}
+2
View File
@@ -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";
@@ -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<HTMLElement> {
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();
}
},
};
};
}