This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new f3edf4d4c5c Allow multiple routes to show active NavButtons (#69884)
f3edf4d4c5c is described below

commit f3edf4d4c5c2784224acc45b91c259289c0becaf
Author: Brent Bovenzi <[email protected]>
AuthorDate: Tue Jul 21 13:53:24 2026 -0400

    Allow multiple routes to show active NavButtons (#69884)
    
    * Allow multiple routes to show active NavButtons
    
    * clean up securitybutton
---
 .../src/airflow/ui/src/layouts/Nav/AdminButton.tsx |  23 +++--
 .../airflow/ui/src/layouts/Nav/BrowseButton.tsx    |  23 +++--
 .../src/airflow/ui/src/layouts/Nav/Nav.tsx         |   1 +
 .../airflow/ui/src/layouts/Nav/NavButton.test.tsx  | 114 +++++++++++++++++++++
 .../src/airflow/ui/src/layouts/Nav/NavButton.tsx   |  37 ++++---
 .../src/airflow/ui/src/layouts/Nav/PluginMenus.tsx |   7 +-
 .../airflow/ui/src/layouts/Nav/SecurityButton.tsx  |  30 +++---
 7 files changed, 189 insertions(+), 46 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
index 427d994c55a..7037b3aaf1e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
@@ -62,15 +62,14 @@ export const AdminButton = ({
   readonly externalViews: Array<NavItemResponse>;
 }) => {
   const { t: translate } = useTranslation("common");
-  const menuItems = links
-    .filter(({ title }) => authorizedMenuItems.includes(title as MenuItem))
-    .map((link) => (
-      <Menu.Item asChild key={link.title} value={link.title}>
-        <RouterLink aria-label={translate(`admin.${link.title}`)} 
to={link.href}>
-          {translate(`admin.${link.title}`)}
-        </RouterLink>
-      </Menu.Item>
-    ));
+  const authorizedLinks = links.filter(({ title }) => 
authorizedMenuItems.includes(title as MenuItem));
+  const menuItems = authorizedLinks.map((link) => (
+    <Menu.Item asChild key={link.title} value={link.title}>
+      <RouterLink aria-label={translate(`admin.${link.title}`)} to={link.href}>
+        {translate(`admin.${link.title}`)}
+      </RouterLink>
+    </Menu.Item>
+  ));
 
   if (!menuItems.length && !externalViews.length) {
     return undefined;
@@ -79,7 +78,11 @@ export const AdminButton = ({
   return (
     <Menu.Root positioning={{ placement: "right" }}>
       <Menu.Trigger asChild>
-        <NavButton icon={FiSettings} title={translate("nav.admin")} />
+        <NavButton
+          icon={FiSettings}
+          title={translate("nav.admin")}
+          to={authorizedLinks.map(({ href }) => href)}
+        />
       </Menu.Trigger>
       <Menu.Content>
         {menuItems}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
index d95f9e0f318..9ebb236ab07 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
@@ -63,15 +63,14 @@ export const BrowseButton = ({
   readonly externalViews: Array<NavItemResponse>;
 }) => {
   const { t: translate } = useTranslation("common");
-  const menuItems = links
-    .filter(({ title }) => authorizedMenuItems.includes(title as MenuItem))
-    .map((link) => (
-      <Menu.Item asChild key={link.key} 
value={translate(`browse.${link.key}`)}>
-        <RouterLink aria-label={translate(`browse.${link.key}`)} 
to={link.href}>
-          {translate(`browse.${link.key}`)}
-        </RouterLink>
-      </Menu.Item>
-    ));
+  const authorizedLinks = links.filter(({ title }) => 
authorizedMenuItems.includes(title as MenuItem));
+  const menuItems = authorizedLinks.map((link) => (
+    <Menu.Item asChild key={link.key} value={translate(`browse.${link.key}`)}>
+      <RouterLink aria-label={translate(`browse.${link.key}`)} to={link.href}>
+        {translate(`browse.${link.key}`)}
+      </RouterLink>
+    </Menu.Item>
+  ));
 
   if (!menuItems.length && !externalViews.length) {
     return undefined;
@@ -80,7 +79,11 @@ export const BrowseButton = ({
   return (
     <Menu.Root positioning={{ placement: "right" }}>
       <Menu.Trigger asChild>
-        <NavButton icon={FiGlobe} title={translate("nav.browse")} />
+        <NavButton
+          icon={FiGlobe}
+          title={translate("nav.browse")}
+          to={authorizedLinks.map(({ href }) => href)}
+        />
       </Menu.Trigger>
       <Menu.Content>
         {menuItems}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
index 4e4e9e39b17..39924dc18d0 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
@@ -176,6 +176,7 @@ export const Nav = () => {
           data-testid="nav-dags-link"
           disabled={!authLinks?.authorized_menu_items.includes("Dags")}
           icon={DagIcon}
+          matchPaths={["dag_runs", "task_instances"]}
           title={translate("nav.dags")}
           to="dags"
         />
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.test.tsx
new file mode 100644
index 00000000000..6671f4a109a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.test.tsx
@@ -0,0 +1,114 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+import { FiHome } from "react-icons/fi";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it } from "vitest";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { NavButton } from "./NavButton";
+
+const wrapperAt = (path: string) => {
+  const wrapper = ({ children }: PropsWithChildren) => (
+    <BaseWrapper>
+      <MemoryRouter initialEntries={[path]}>{children}</MemoryRouter>
+    </BaseWrapper>
+  );
+
+  return wrapper;
+};
+
+describe("NavButton", () => {
+  describe("single `to`", () => {
+    it("renders as a link to that destination", () => {
+      render(<NavButton icon={FiHome} title="Dags" to="dags" />, { wrapper: 
wrapperAt("/") });
+
+      expect(screen.getByRole("link", { name: "Dags" 
})).toHaveAttribute("href", "/dags");
+    });
+
+    it("is active when the current route matches", () => {
+      render(<NavButton icon={FiHome} title="Dags" to="dags" />, { wrapper: 
wrapperAt("/dags") });
+
+      expect(screen.getByRole("link", { name: "Dags" 
})).toHaveAttribute("aria-current", "page");
+    });
+
+    it("is active on a nested route under the destination", () => {
+      render(<NavButton icon={FiHome} title="Dags" to="dags" />, {
+        wrapper: wrapperAt("/dags/my_dag/runs"),
+      });
+
+      expect(screen.getByRole("link", { name: "Dags" 
})).toHaveAttribute("aria-current", "page");
+    });
+
+    it("is not active on an unrelated route", () => {
+      render(<NavButton icon={FiHome} title="Dags" to="dags" />, { wrapper: 
wrapperAt("/assets") });
+
+      expect(screen.getByRole("link", { name: "Dags" 
})).not.toHaveAttribute("aria-current");
+    });
+  });
+
+  describe("multiple `to`", () => {
+    it("renders as a plain button, not a link", () => {
+      render(<NavButton icon={FiHome} title="Browse" to={["events", "jobs"]} 
/>, {
+        wrapper: wrapperAt("/"),
+      });
+
+      const button = screen.getByRole("button", { name: "Browse" });
+
+      expect(button).not.toHaveAttribute("href");
+    });
+
+    it("is active when the current route matches any of the destinations", () 
=> {
+      render(<NavButton icon={FiHome} title="Browse" to={["events", "jobs"]} 
/>, {
+        wrapper: wrapperAt("/jobs"),
+      });
+
+      expect(screen.getByRole("button", { name: "Browse" 
})).toHaveAttribute("aria-current", "page");
+    });
+
+    it("is not active when the current route matches none of the 
destinations", () => {
+      render(<NavButton icon={FiHome} title="Browse" to={["events", "jobs"]} 
/>, {
+        wrapper: wrapperAt("/xcoms"),
+      });
+
+      expect(screen.getByRole("button", { name: "Browse" 
})).not.toHaveAttribute("aria-current");
+    });
+  });
+
+  describe("matchPaths", () => {
+    it("is active on an extra match path even though `to` points elsewhere", 
() => {
+      render(<NavButton icon={FiHome} matchPaths={["dag_runs", 
"task_instances"]} title="Dags" to="dags" />, {
+        wrapper: wrapperAt("/dag_runs"),
+      });
+
+      expect(screen.getByRole("link", { name: "Dags" 
})).toHaveAttribute("aria-current", "page");
+    });
+
+    it("is not active on a route outside both `to` and matchPaths", () => {
+      render(<NavButton icon={FiHome} matchPaths={["dag_runs", 
"task_instances"]} title="Dags" to="dags" />, {
+        wrapper: wrapperAt("/assets"),
+      });
+
+      expect(screen.getByRole("link", { name: "Dags" 
})).not.toHaveAttribute("aria-current");
+    });
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx
index f943efc32aa..648ee43ce62 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx
@@ -19,7 +19,9 @@
 import { Box, type BoxProps, Button, Icon, type IconProps, Link, type 
ButtonProps } from "@chakra-ui/react";
 import type { ReactNode, ForwardRefExoticComponent, RefAttributes } from 
"react";
 import type { IconType } from "react-icons";
-import { Link as RouterLink, useMatch } from "react-router-dom";
+import { Link as RouterLink, matchPath, useLocation } from "react-router-dom";
+
+const noMatchPaths: Array<string> = [];
 
 const commonLabelProps: BoxProps = {
   fontSize: "2xs",
@@ -33,21 +35,29 @@ const commonLabelProps: BoxProps = {
 type NavButtonProps = {
   readonly icon: ForwardRefExoticComponent<IconProps & 
RefAttributes<SVGSVGElement>> | IconType;
   readonly isExternal?: boolean;
+  // Extra routes that should also mark this button active, on top of `to` 
(e.g. the Dags button
+  // should also highlight for the standalone dag runs and task instances 
routes).
+  readonly matchPaths?: Array<string>;
   readonly pluginIcon?: ReactNode;
   readonly title: string;
-  readonly to?: string;
+  // A single destination renders the button as a link; an array only affects 
isActive matching
+  // (used for buttons like menu triggers that should highlight for any of 
several routes).
+  readonly to?: Array<string> | string;
 } & ButtonProps;
 
-export const NavButton = ({ icon, isExternal = false, pluginIcon, title, to, 
...rest }: NavButtonProps) => {
-  // Use useMatch to determine if the current route matches the button's 
destination
-  // This provides the same functionality as NavLink's isActive prop
-  // Only applies to buttons with a to prop (but needs to be before any return 
statements)
-  const match = useMatch({
-    end: to === "/", // Only exact match for root path
-    path: to ?? "",
-  });
-  // Only applies to buttons with a to prop
-  const isActive = Boolean(to) ? Boolean(match) : false;
+export const NavButton = ({
+  icon,
+  isExternal = false,
+  matchPaths = noMatchPaths,
+  pluginIcon,
+  title,
+  to,
+  ...rest
+}: NavButtonProps) => {
+  const { pathname } = useLocation();
+
+  const activePaths = [...(to === undefined ? [] : Array.isArray(to) ? to : 
[to]), ...matchPaths];
+  const isActive = activePaths.some((path) => matchPath({ end: path === "/", 
path }, pathname) !== null);
 
   const commonButtonProps: ButtonProps = {
     _expanded: isActive
@@ -72,6 +82,7 @@ export const NavButton = ({ icon, isExternal = false, 
pluginIcon, title, to, ...
           color: "fg",
         },
     alignItems: "center",
+    "aria-current": isActive ? "page" : undefined,
     "aria-label": title,
     bg: isActive ? "brand.solid" : undefined,
     borderRadius: "md",
@@ -92,7 +103,7 @@ export const NavButton = ({ icon, isExternal = false, 
pluginIcon, title, to, ...
     ...rest,
   };
 
-  if (to === undefined) {
+  if (to === undefined || Array.isArray(to)) {
     return (
       <Button {...commonButtonProps}>
         {pluginIcon ?? <Icon as={icon} boxSize={5} />}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
index a7cacf8571d..63146715433 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
@@ -53,6 +53,11 @@ export const PluginMenus = ({ navItems }: { readonly 
navItems: Array<NavItemResp
   // A single remaining item is promoted to the toolbar to avoid a one-item 
submenu.
   const showRemainingInMenu = remainingItems.length >= 2;
 
+  // Only internal routes (backed by url_route) can match the current 
pathname; external views never do.
+  const remainingPaths = remainingItems
+    .filter((navItem) => navItem.url_route !== undefined && navItem.url_route 
!== null)
+    .map((navItem) => `plugin/${navItem.url_route}`);
+
   return (
     <>
       {promotedItems.map((navItem) => (
@@ -61,7 +66,7 @@ export const PluginMenus = ({ navItems }: { readonly 
navItems: Array<NavItemResp
       {showRemainingInMenu ? (
         <Menu.Root positioning={{ placement: "right" }}>
           <Menu.Trigger>
-            <NavButton as={Box} icon={LuPlug} title={translate("nav.plugins")} 
/>
+            <NavButton as={Box} icon={LuPlug} title={translate("nav.plugins")} 
to={remainingPaths} />
           </Menu.Trigger>
           <Menu.Content>
             {remainingButtons.map((navItem) => (
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
index 2955abce5ee..ccfa34b5af5 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
@@ -33,23 +33,29 @@ export const SecurityButton = () => {
     return undefined;
   }
 
+  const securityItems = authLinks.extra_menu_items.map(({ text }) => {
+    const securityKey = text.toLowerCase().replace(" ", "-");
+
+    return { path: `security/${securityKey}`, securityKey, text };
+  });
+
   return (
     <Menu.Root positioning={{ placement: "right" }}>
       <Menu.Trigger asChild>
-        <NavButton icon={FiLock} title={translate("nav.security")} />
+        <NavButton
+          icon={FiLock}
+          title={translate("nav.security")}
+          to={securityItems.map(({ path }) => path)}
+        />
       </Menu.Trigger>
       <Menu.Content>
-        {authLinks.extra_menu_items.map(({ text }) => {
-          const securityKey = text.toLowerCase().replace(" ", "-");
-
-          return (
-            <Menu.Item asChild key={text} value={text}>
-              <Link aria-label={text} to={`security/${securityKey}`}>
-                {translate(`security.${securityKey}`)}
-              </Link>
-            </Menu.Item>
-          );
-        })}
+        {securityItems.map(({ path, securityKey, text }) => (
+          <Menu.Item asChild key={text} value={text}>
+            <Link aria-label={text} to={path}>
+              {translate(`security.${securityKey}`)}
+            </Link>
+          </Menu.Item>
+        ))}
       </Menu.Content>
     </Menu.Root>
   );

Reply via email to