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}`;
+};