From 89cea3045a6484ae7c77b6bfb5cb994eaf68a6f7 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Mon, 27 May 2024 21:31:08 +1000
Subject: [PATCH] [PUI] Open links in new window (#7354)

* Breadcrumbs can be opened in a new tab

* Support tab nav for top header items

* Open panel tab in new window
---
 .../src/components/nav/BreadcrumbList.tsx     |  7 +++-
 src/frontend/src/components/nav/Header.tsx    | 12 ++++--
 .../src/components/nav/PanelGroup.tsx         | 38 +++++++++++++------
 3 files changed, 40 insertions(+), 17 deletions(-)

diff --git a/src/frontend/src/components/nav/BreadcrumbList.tsx b/src/frontend/src/components/nav/BreadcrumbList.tsx
index dbd5ca0f30..15c7374975 100644
--- a/src/frontend/src/components/nav/BreadcrumbList.tsx
+++ b/src/frontend/src/components/nav/BreadcrumbList.tsx
@@ -10,6 +10,8 @@ import { IconMenu2 } from '@tabler/icons-react';
 import { useMemo } from 'react';
 import { useNavigate } from 'react-router-dom';
 
+import { navigateToLink } from '../../functions/navigation';
+
 export type Breadcrumb = {
   name: string;
   url: string;
@@ -57,7 +59,10 @@ export function BreadcrumbList({
             return (
               <Anchor
                 key={index}
-                onClick={() => breadcrumb.url && navigate(breadcrumb.url)}
+                onClick={(event: any) =>
+                  breadcrumb.url &&
+                  navigateToLink(breadcrumb.url, navigate, event)
+                }
               >
                 <Text size="sm">{breadcrumb.name}</Text>
               </Anchor>
diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx
index f5cdd07865..af8c8b1126 100644
--- a/src/frontend/src/components/nav/Header.tsx
+++ b/src/frontend/src/components/nav/Header.tsx
@@ -8,6 +8,7 @@ import { useMatch, useNavigate } from 'react-router-dom';
 import { api } from '../../App';
 import { navTabs as mainNavTabs } from '../../defaults/links';
 import { ApiEndpoints } from '../../enums/ApiEndpoints';
+import { navigateToLink } from '../../functions/navigation';
 import * as classes from '../../main.css';
 import { apiUrl } from '../../states/ApiState';
 import { useLocalState } from '../../states/LocalState';
@@ -141,13 +142,16 @@ function NavTabs() {
         tab: classes.tab
       }}
       value={tabValue}
-      onChange={(value) =>
-        value == '/' ? navigate('/') : navigate(`/${value}`)
-      }
     >
       <Tabs.List>
         {mainNavTabs.map((tab) => (
-          <Tabs.Tab value={tab.name} key={tab.name}>
+          <Tabs.Tab
+            value={tab.name}
+            key={tab.name}
+            onClick={(event: any) =>
+              navigateToLink(`/${tab.name}`, navigate, event)
+            }
+          >
             {tab.text}
           </Tabs.Tab>
         ))}
diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx
index afc9be16d6..5523de022c 100644
--- a/src/frontend/src/components/nav/PanelGroup.tsx
+++ b/src/frontend/src/components/nav/PanelGroup.tsx
@@ -10,15 +10,17 @@ import {
   IconLayoutSidebarLeftCollapse,
   IconLayoutSidebarRightCollapse
 } from '@tabler/icons-react';
-import { ReactNode, useEffect, useMemo, useState } from 'react';
+import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
 import {
   Navigate,
   Route,
   Routes,
+  useLocation,
   useNavigate,
   useParams
 } from 'react-router-dom';
 
+import { navigateToLink } from '../../functions/navigation';
 import { useLocalState } from '../../states/LocalState';
 import { Boundary } from '../Boundary';
 import { PlaceholderPanel } from '../items/Placeholder';
@@ -52,6 +54,7 @@ function BasePanelGroup({
   selectedPanel,
   collapsible = true
 }: Readonly<PanelProps>): ReactNode {
+  const location = useLocation();
   const navigate = useNavigate();
   const { panel } = useParams();
 
@@ -72,19 +75,27 @@ function BasePanelGroup({
   }, [setLastUsedPanel]);
 
   // Callback when the active panel changes
-  function handlePanelChange(panel: string | null) {
-    if (activePanels.findIndex((p) => p.name === panel) === -1) {
-      setLastUsedPanel('');
-      return navigate('../');
-    }
+  const handlePanelChange = useCallback(
+    (panel: string | null, event?: any) => {
+      if (activePanels.findIndex((p) => p.name === panel) === -1) {
+        setLastUsedPanel('');
+        return navigate('../');
+      }
 
-    navigate(`../${panel}`);
+      if (event && (event?.ctrlKey || event?.shiftKey)) {
+        const url = `${location.pathname}/../${panel}`;
+        navigateToLink(url, navigate, event);
+      } else {
+        navigate(`../${panel}`);
+      }
 
-    // Optionally call external callback hook
-    if (panel && onPanelChange) {
-      onPanelChange(panel);
-    }
-  }
+      // Optionally call external callback hook
+      if (panel && onPanelChange) {
+        onPanelChange(panel);
+      }
+    },
+    [activePanels, setLastUsedPanel, navigate, location, onPanelChange]
+  );
 
   // if the selected panel state changes update the current panel
   useEffect(() => {
@@ -129,6 +140,9 @@ function BasePanelGroup({
                       hidden={panel.hidden}
                       disabled={panel.disabled}
                       style={{ cursor: panel.disabled ? 'unset' : 'pointer' }}
+                      onClick={(event: any) =>
+                        handlePanelChange(panel.name, event)
+                      }
                     >
                       {expanded && panel.label}
                     </Tabs.Tab>