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 4406537e36a UI: Preserve Dag tab when switching Dags (#70699)
4406537e36a is described below

commit 4406537e36a7f0ebac1597c024f4e4eb2698ff39
Author: Shivam Rastogi <[email protected]>
AuthorDate: Fri Aug 21 14:05:19 2026 -0700

    UI: Preserve Dag tab when switching Dags (#70699)
    
    * UI: Preserve Dag tab when switching Dags
    
    Keep users in the same Dag context while comparing Dags, without carrying 
entity-specific routes that may not exist on the destination Dag.
    
    * UI: Use route metadata when switching Dags
    
    Path parsing made unrelated and entity-specific pages difficult to 
distinguish reliably. Route-owned metadata keeps tab preservation aligned with 
the router's actual matches.
    
    * UI: Derive preserved tabs from route handles
    
    * UI: Reset plugin routes when switching Dags
    
    * UI: Type tab route handles
    
    * UI: Break Dag tab routing import cycle
    
    Keep route metadata selection independent from the application router so 
navigation modules do not depend on each other.
    
    ---------
    
    Co-authored-by: Shivam <[email protected]>
---
 .../src/components/SearchDags/SearchDags.test.tsx  | 271 +++++++++++++++++++++
 .../ui/src/components/SearchDags/SearchDags.tsx    |  11 +-
 .../ui/src/{utils/option.ts => constants/tab.ts}   |  24 +-
 airflow-core/src/airflow/ui/src/router.test.tsx    |  62 +++++
 airflow-core/src/airflow/ui/src/router.tsx         |  29 ++-
 airflow-core/src/airflow/ui/src/utils/option.ts    |   1 +
 airflow-core/src/airflow/ui/src/utils/tab.test.ts  |  54 ++++
 airflow-core/src/airflow/ui/src/utils/tab.ts       |  58 +++++
 8 files changed, 491 insertions(+), 19 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx 
b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx
new file mode 100644
index 00000000000..6886233515a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx
@@ -0,0 +1,271 @@
+/*!
+ * 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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { Dispatch, SetStateAction } from "react";
+import { MemoryRouter, useLocation, useMatches, useNavigate } from 
"react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { DagService } from "openapi/requests/services.gen";
+import type { DAGWithLatestDagRunsCollectionResponse } from 
"openapi/requests/types.gen";
+import { TabEntity, TabName } from "src/constants/tab";
+import { BaseWrapper } from "src/utils/Wrapper";
+import type { DagSearchOption } from "src/utils/option";
+
+import { SearchDags } from "./SearchDags";
+
+vi.mock("react-router-dom", async (importOriginal) => {
+  const original = await importOriginal();
+
+  return { ...(original as object), useMatches: vi.fn() };
+});
+
+const { loadedOption, selectedOption } = vi.hoisted<{
+  loadedOption: { current: DagSearchOption | undefined };
+  selectedOption: { current: DagSearchOption };
+}>(() => ({
+  loadedOption: { current: undefined },
+  selectedOption: {
+    current: {
+      isBackfillable: true,
+      label: "New Dag",
+      state: null,
+      value: "new_dag",
+    },
+  },
+}));
+
+vi.mock("chakra-react-select", () => ({
+  AsyncSelect: ({
+    loadOptions,
+    onChange,
+  }: {
+    readonly loadOptions: (input: string, callback: (options: 
Array<DagSearchOption>) => void) => void;
+    readonly onChange: (option: DagSearchOption) => void;
+  }) => (
+    <>
+      <button
+        onClick={() =>
+          loadOptions("", (options) => {
+            [loadedOption.current] = options;
+          })
+        }
+        type="button"
+      >
+        Load Dags
+      </button>
+      <button onClick={() => onChange(loadedOption.current ?? 
selectedOption.current)} type="button">
+        Select Dag
+      </button>
+    </>
+  ),
+}));
+
+const LocationDisplay = () => {
+  const { pathname } = useLocation();
+  const navigate = useNavigate();
+
+  return (
+    <>
+      <output data-testid="location">{pathname}</output>
+      <button onClick={() => void navigate(-1)} type="button">
+        Back
+      </button>
+      <button onClick={() => void navigate(1)} type="button">
+        Forward
+      </button>
+    </>
+  );
+};
+
+const renderSearch = ({
+  initialEntry,
+  setIsOpen = vi.fn(),
+}: {
+  initialEntry: string;
+  setIsOpen?: Dispatch<SetStateAction<boolean>>;
+}) => {
+  render(
+    <BaseWrapper>
+      <MemoryRouter initialEntries={[initialEntry]}>
+        <SearchDags setIsOpen={setIsOpen} />
+        <LocationDisplay />
+      </MemoryRouter>
+    </BaseWrapper>,
+  );
+
+  return setIsOpen;
+};
+
+describe("SearchDags", () => {
+  beforeEach(() => {
+    loadedOption.current = undefined;
+    selectedOption.current = { ...selectedOption.current, isBackfillable: true 
};
+    vi.mocked(useMatches).mockReturnValue([
+      {
+        data: undefined,
+        handle: { entity: TabEntity.Dag, tab: TabName.Details },
+        id: "dag-details",
+        loaderData: undefined,
+        params: { dagId: "old_dag" },
+        pathname: "/dags/old_dag/details",
+      },
+    ]);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("preserves the selected Dag tab when switching Dags", () => {
+    const setIsOpen = renderSearch({ initialEntry: "/dags/old_dag/details" });
+
+    fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag/details");
+    expect(setIsOpen).toHaveBeenCalledWith(false);
+  });
+
+  it("resets to the Dag overview from a deeper entity route", () => {
+    vi.mocked(useMatches).mockReturnValue([
+      {
+        data: undefined,
+        handle: undefined,
+        id: "task",
+        loaderData: undefined,
+        params: { dagId: "old_dag", runId: "run_1", taskId: "task_1" },
+        pathname: "/dags/old_dag/runs/run_1/tasks/task_1/details",
+      },
+    ]);
+    renderSearch({ initialEntry: 
"/dags/old_dag/runs/run_1/tasks/task_1/details" });
+
+    fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+
+    expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag");
+  });
+
+  it("preserves the backfills tab for a backfillable Dag", () => {
+    vi.mocked(useMatches).mockReturnValue([
+      {
+        data: undefined,
+        handle: { entity: TabEntity.Dag, tab: TabName.Backfills },
+        id: "dag-backfills",
+        loaderData: undefined,
+        params: { dagId: "old_dag" },
+        pathname: "/dags/old_dag/backfills",
+      },
+    ]);
+    renderSearch({ initialEntry: "/dags/old_dag/backfills" });
+
+    fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag/backfills");
+  });
+
+  it("maps API backfill support into the option used to preserve the backfills 
tab", async () => {
+    const response: DAGWithLatestDagRunsCollectionResponse = {
+      dags: [
+        {
+          allowed_run_types: null,
+          asset_expression: null,
+          bundle_name: null,
+          bundle_version: null,
+          dag_display_name: "New Dag",
+          dag_id: "new_dag",
+          description: null,
+          file_token: "",
+          fileloc: "/dags/new_dag.py",
+          has_import_errors: false,
+          has_task_concurrency_limits: false,
+          is_backfillable: false,
+          is_favorite: false,
+          is_paused: false,
+          is_stale: false,
+          last_expired: null,
+          last_parse_duration: null,
+          last_parsed_time: null,
+          latest_dag_runs: [],
+          max_active_runs: 16,
+          max_active_tasks: 16,
+          max_consecutive_failed_dag_runs: 0,
+          next_dagrun_data_interval_end: null,
+          next_dagrun_data_interval_start: null,
+          next_dagrun_logical_date: null,
+          next_dagrun_run_after: null,
+          owners: ["airflow"],
+          pending_actions: [],
+          relative_fileloc: "new_dag.py",
+          tags: [],
+          timetable_description: null,
+          timetable_partitioned: false,
+          timetable_periodic: false,
+          timetable_summary: null,
+        },
+      ],
+      total_entries: 1,
+    };
+
+    vi.spyOn(DagService, "getDagsUi").mockResolvedValue(response);
+    vi.mocked(useMatches).mockReturnValue([
+      {
+        data: undefined,
+        handle: { entity: TabEntity.Dag, tab: TabName.Backfills },
+        id: "dag-backfills",
+        loaderData: undefined,
+        params: { dagId: "old_dag" },
+        pathname: "/dags/old_dag/backfills",
+      },
+    ]);
+    renderSearch({ initialEntry: "/dags/old_dag/backfills" });
+
+    fireEvent.click(screen.getByRole("button", { name: "Load Dags" }));
+    await waitFor(() => 
expect(loadedOption.current?.isBackfillable).toBe(false));
+    fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+
+    expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag");
+  });
+
+  it("resets plugin routes when destination compatibility is unknown", () => {
+    vi.mocked(useMatches).mockReturnValue([
+      {
+        data: undefined,
+        handle: undefined,
+        id: "dag-plugin",
+        loaderData: undefined,
+        params: { "*": "nested/detail/42", dagId: "old_dag", page: "test" },
+        pathname: "/dags/old_dag/plugin/test/nested/detail/42",
+      },
+    ]);
+    renderSearch({ initialEntry: "/dags/old_dag/plugin/test/nested/detail/42" 
});
+
+    fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+
+    expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag");
+  });
+
+  it("keeps browser back and forward history after switching Dags", () => {
+    renderSearch({ initialEntry: "/dags/old_dag/details" });
+
+    fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+    fireEvent.click(screen.getByRole("button", { name: "Back" }));
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/old_dag/details");
+
+    fireEvent.click(screen.getByRole("button", { name: "Forward" }));
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag/details");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx 
b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx
index 96b9677bf1c..77e2e3a7462 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx
@@ -22,7 +22,7 @@ import { AsyncSelect } from "chakra-react-select";
 import type { OptionsOrGroups, GroupBase, SingleValue } from 
"chakra-react-select";
 import type { Dispatch, SetStateAction } from "react";
 import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router-dom";
+import { useMatches, useNavigate } from "react-router-dom";
 import { useDebouncedCallback } from "use-debounce";
 
 import { UseDagServiceGetDagsUiKeyFn } from "openapi/queries";
@@ -32,7 +32,9 @@ import type {
   DAGWithLatestDagRunsResponse,
 } from "openapi/requests/types.gen";
 import { StateBadge } from "src/components/StateBadge";
+import { TabEntity } from "src/constants/tab";
 import type { DagSearchOption } from "src/utils/option";
+import { getTabPath } from "src/utils/tab";
 
 import { DropdownIndicator } from "./SearchDagsDropdownIndicator";
 
@@ -46,13 +48,17 @@ const formatOptionLabel = (option: DagSearchOption) => (
 export const SearchDags = ({ setIsOpen }: { readonly setIsOpen: 
Dispatch<SetStateAction<boolean>> }) => {
   const { t: translate } = useTranslation("dags");
   const queryClient = useQueryClient();
+  const matches = useMatches();
   const navigate = useNavigate();
   const SEARCH_LIMIT = 10;
 
   const onSelect = (selected: SingleValue<DagSearchOption>) => {
     if (selected) {
+      const additionalPath = getTabPath(matches, TabEntity.Dag);
+      const targetPath = additionalPath === "/backfills" && 
!selected.isBackfillable ? "" : additionalPath;
+
       setIsOpen(false);
-      void Promise.resolve(navigate(`/dags/${selected.value}`));
+      void Promise.resolve(navigate(`/dags/${selected.value}${targetPath}`));
     }
   };
 
@@ -69,6 +75,7 @@ export const SearchDags = ({ setIsOpen }: { readonly 
setIsOpen: Dispatch<SetStat
             limit: SEARCH_LIMIT,
           }).then((data: DAGWithLatestDagRunsCollectionResponse) => {
             const options = data.dags.map((dag: DAGWithLatestDagRunsResponse) 
=> ({
+              isBackfillable: dag.is_backfillable,
               label: dag.dag_display_name || dag.dag_id,
               state: dag.latest_dag_runs[0]?.state ?? null,
               value: dag.dag_id,
diff --git a/airflow-core/src/airflow/ui/src/utils/option.ts 
b/airflow-core/src/airflow/ui/src/constants/tab.ts
similarity index 74%
copy from airflow-core/src/airflow/ui/src/utils/option.ts
copy to airflow-core/src/airflow/ui/src/constants/tab.ts
index 7251892a7e4..051148442ac 100644
--- a/airflow-core/src/airflow/ui/src/utils/option.ts
+++ b/airflow-core/src/airflow/ui/src/constants/tab.ts
@@ -16,14 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { DagRunState } from "openapi/requests/types.gen";
 
-export type Option = {
-  readonly disabled?: boolean;
-  readonly label: string;
-  readonly value: string;
-};
+export enum TabEntity {
+  Dag = "dag",
+  Task = "task",
+  TaskInstance = "task-instance",
+}
 
-export type DagSearchOption = {
-  readonly state: DagRunState | null;
-} & Option;
+export enum TabName {
+  Backfills = "backfills",
+  Calendar = "calendar",
+  Code = "code",
+  Details = "details",
+  Events = "events",
+  Overview = "",
+  Runs = "runs",
+  Tasks = "tasks",
+}
diff --git a/airflow-core/src/airflow/ui/src/router.test.tsx 
b/airflow-core/src/airflow/ui/src/router.test.tsx
new file mode 100644
index 00000000000..e34b5fa31a5
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/router.test.tsx
@@ -0,0 +1,62 @@
+/*!
+ * 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 { matchRoutes } from "react-router-dom";
+import { describe, expect, it } from "vitest";
+
+import { TabEntity } from "src/constants/tab";
+import { getTabPath } from "src/utils/tab";
+
+import { routerConfig } from "./router";
+
+const getAdditionalPath = (pathname: string) => {
+  const matches = matchRoutes(routerConfig, pathname) ?? [];
+
+  return getTabPath(
+    matches.map((match) => ({
+      handle: "handle" in match.route ? match.route.handle : undefined,
+    })),
+    TabEntity.Dag,
+  );
+};
+
+describe("Dag route handles", () => {
+  it.each(["runs", "tasks", "calendar", "backfills", "events", "code", 
"details"])(
+    "preserves the %s Dag tab",
+    (tab) => {
+      expect(getAdditionalPath(`/dags/example/${tab}`)).toBe(`/${tab}`);
+    },
+  );
+
+  it("does not preserve a plugin route when destination compatibility is 
unknown", () => {
+    
expect(getAdditionalPath("/dags/example/plugin/test/nested/detail/42")).toBe("");
+  });
+
+  it.each([
+    "/assets/1",
+    "/dags/example/required_actions",
+    "/dags/example/backfills/12",
+    "/dags/example/runs/run_1",
+    "/dags/example/tasks/task_1",
+    "/dags/example/tasks/group/group_1",
+    "/dags/example/runs/run_1/tasks/task_1/details",
+    "/dags/example/unknown",
+  ])("does not preserve a tab from %s", (pathname) => {
+    expect(getAdditionalPath(pathname)).toBe("");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/router.tsx 
b/airflow-core/src/airflow/ui/src/router.tsx
index fd3ea5ef30d..d342fbf5878 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -21,6 +21,7 @@ import { createBrowserRouter } from "react-router-dom";
 
 import { UseConfigServiceGetConfigsKeyFn } from "openapi/queries";
 import { ConfigService } from "openapi/requests/services.gen";
+import { TabEntity, TabName } from "src/constants/tab";
 import { BaseLayout } from "src/layouts/BaseLayout";
 import { DagsLayout } from "src/layouts/DagsLayout";
 import { Asset } from "src/pages/Asset";
@@ -190,18 +191,30 @@ export const routerConfig = [
       pluginRoute,
       {
         children: [
-          { element: <Overview />, index: true },
-          { element: <DagRuns />, path: "runs" },
-          { element: <Tasks />, path: "tasks" },
-          { element: <Calendar />, path: "calendar" },
+          { element: <Overview />, handle: { entity: TabEntity.Dag, tab: 
TabName.Overview }, index: true },
+          { element: <DagRuns />, handle: { entity: TabEntity.Dag, tab: 
TabName.Runs }, path: "runs" },
+          { element: <Tasks />, handle: { entity: TabEntity.Dag, tab: 
TabName.Tasks }, path: "tasks" },
+          {
+            element: <Calendar />,
+            handle: { entity: TabEntity.Dag, tab: TabName.Calendar },
+            path: "calendar",
+          },
           // The Required Actions tab is now a button + modal; this keeps old 
/required_actions
           // deep links alive by rendering the overview, where the route sync 
opens the modal.
           { element: <Overview />, path: "required_actions" },
-          { element: <Backfills />, path: "backfills" },
+          {
+            element: <Backfills />,
+            handle: { entity: TabEntity.Dag, tab: TabName.Backfills },
+            path: "backfills",
+          },
           { element: <Backfills />, path: "backfills/:backfillId" },
-          { element: <Events />, path: "events" },
-          { element: <Code />, path: "code" },
-          { element: <DagDetails />, path: "details" },
+          { element: <Events />, handle: { entity: TabEntity.Dag, tab: 
TabName.Events }, path: "events" },
+          { element: <Code />, handle: { entity: TabEntity.Dag, tab: 
TabName.Code }, path: "code" },
+          {
+            element: <DagDetails />,
+            handle: { entity: TabEntity.Dag, tab: TabName.Details },
+            path: "details",
+          },
           pluginRoute,
         ],
         element: <Dag />,
diff --git a/airflow-core/src/airflow/ui/src/utils/option.ts 
b/airflow-core/src/airflow/ui/src/utils/option.ts
index 7251892a7e4..ed9f463f161 100644
--- a/airflow-core/src/airflow/ui/src/utils/option.ts
+++ b/airflow-core/src/airflow/ui/src/utils/option.ts
@@ -25,5 +25,6 @@ export type Option = {
 };
 
 export type DagSearchOption = {
+  readonly isBackfillable: boolean;
   readonly state: DagRunState | null;
 } & Option;
diff --git a/airflow-core/src/airflow/ui/src/utils/tab.test.ts 
b/airflow-core/src/airflow/ui/src/utils/tab.test.ts
new file mode 100644
index 00000000000..e36c2a2b70c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/tab.test.ts
@@ -0,0 +1,54 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import { TabEntity, TabName } from "src/constants/tab";
+
+import { getTabPath } from "./tab";
+
+const getDagMatches = (tab: TabName) => [{ handle: { entity: TabEntity.Dag, 
tab } }];
+
+describe("getTabPath", () => {
+  it.each(Object.values(TabName))("preserves the %s Dag tab", (tab) => {
+    expect(getTabPath(getDagMatches(tab), TabEntity.Dag)).toBe(tab === 
TabName.Overview ? "" : `/${tab}`);
+  });
+
+  it("supports more than one compatible entity", () => {
+    expect(
+      getTabPath(
+        [{ handle: { entity: TabEntity.Task, tab: TabName.Events } }],
+        [TabEntity.Task, TabEntity.TaskInstance],
+      ),
+    ).toBe("/events");
+  });
+
+  it.each([
+    { matches: [] },
+    { matches: [{ handle: { entity: "asset", tab: "events" } }] },
+    { matches: [{ handle: { entity: TabEntity.Task, tab: TabName.Details } }] 
},
+    { matches: [{ handle: { entity: TabEntity.Dag } }] },
+    { matches: [{ handle: { entity: TabEntity.Dag, tab: 42 } }] },
+    { matches: [{ handle: { entity: TabEntity.Dag, tab: "unknown" } }] },
+    { matches: [{ handle: undefined }] },
+    { matches: [{ handle: { entity: 42, tab: TabName.Details } }] },
+    { matches: [{ handle: null }] },
+  ])("does not preserve unmatched or non-Dag routes", (matches) => {
+    expect(getTabPath(matches.matches, TabEntity.Dag)).toBe("");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/utils/tab.ts 
b/airflow-core/src/airflow/ui/src/utils/tab.ts
new file mode 100644
index 00000000000..b9a1f4eb5ac
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/tab.ts
@@ -0,0 +1,58 @@
+/*!
+ * 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 { TabEntity, TabName } from "src/constants/tab";
+
+type RouteMatch = {
+  readonly handle: unknown;
+};
+
+type TabRouteHandle = {
+  readonly entity: TabEntity;
+  readonly tab: TabName;
+};
+
+const tabEntities = new Set<string>(Object.values(TabEntity));
+const tabNames = new Set<string>(Object.values(TabName));
+
+const isTabRouteHandle = (handle: unknown): handle is TabRouteHandle =>
+  typeof handle === "object" &&
+  handle !== null &&
+  "entity" in handle &&
+  "tab" in handle &&
+  typeof handle.entity === "string" &&
+  tabEntities.has(handle.entity) &&
+  typeof handle.tab === "string" &&
+  tabNames.has(handle.tab);
+
+export const getTabPath = (matches: Array<RouteMatch>, entities: 
Array<TabEntity> | TabEntity): string => {
+  const targetEntities = new Set(Array.isArray(entities) ? entities : 
[entities]);
+  const tabMatch = [...matches]
+    .reverse()
+    .find((match) => isTabRouteHandle(match.handle) && 
targetEntities.has(match.handle.entity));
+
+  if (tabMatch?.handle === undefined || !isTabRouteHandle(tabMatch.handle)) {
+    return "";
+  }
+
+  if (tabMatch.handle.tab === TabName.Overview) {
+    return "";
+  }
+
+  return `/${tabMatch.handle.tab}`;
+};

Reply via email to