From f04fc685136ba24318e9c24abfe011106f1802ef Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:52:15 +0000 Subject: [PATCH] Use real links for navigation so they open in new tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-app navigation was done with JS onClick -> navigate() handlers instead of real 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) --- packages/web_ui/src/components/HostsPage.tsx | 14 ++--- .../web_ui/src/components/InstanceList.tsx | 13 +++-- .../src/components/InstanceViewPage.tsx | 7 +-- packages/web_ui/src/components/ModsPage.tsx | 16 +++--- .../web_ui/src/components/PluginsPage.tsx | 18 ++++--- packages/web_ui/src/components/RolesPage.tsx | 13 ++--- packages/web_ui/src/components/SiteLayout.tsx | 18 +++++-- .../web_ui/src/components/UserViewPage.tsx | 13 ++--- packages/web_ui/src/components/UsersTable.tsx | 17 +++--- packages/web_ui/src/index.ts | 2 + packages/web_ui/src/util/useRowNavigation.ts | 52 +++++++++++++++++++ 11 files changed, 123 insertions(+), 60 deletions(-) create mode 100644 packages/web_ui/src/util/useRowNavigation.ts 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