Skip to content

Commit 620536e

Browse files
committed
feat: refine tab preservation logic and fix url parsing contexts
1 parent f5b15d9 commit 620536e

4 files changed

Lines changed: 156 additions & 38 deletions

File tree

airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
2121

2222
import type { GridRunsResponse } from "openapi/requests";
2323
import type { GridTask } from "src/layouts/Details/Grid/utils";
24-
import { buildTaskInstanceUrl } from "src/utils/links";
24+
import { buildTaskInstanceUrl, getTaskAdditionalPath } from "src/utils/links";
2525

2626
import {
2727
NavigationModes,
@@ -77,8 +77,11 @@ const buildPath = (params: {
7777
switch (mode) {
7878
case NavigationModes.RUN:
7979
return `/dags/${dagId}/runs/${run.run_id}`;
80-
case NavigationModes.TASK:
81-
return `/dags/${dagId}/tasks/${groupPath}${task.id}`;
80+
case NavigationModes.TASK: {
81+
const additionalPath = getTaskAdditionalPath(pathname);
82+
83+
return `/dags/${dagId}/tasks/${groupPath}${task.id}${additionalPath}`;
84+
}
8285
case NavigationModes.TI:
8386
return buildTaskInstanceUrl({
8487
currentPathname: pathname,

airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
import { useEffect } from "react";
19+
import { useEffect, useRef } from "react";
2020
import { useNavigate } from "react-router-dom";
2121
import { useLocalStorage } from "usehooks-ts";
2222

@@ -43,50 +43,55 @@ type UseTabMemoryOptions = {
4343
export const useTabMemory = (options: UseTabMemoryOptions) => {
4444
const { currentPath, enabled = true, storageKey, tabs } = options;
4545
const navigate = useNavigate();
46+
const defaultTab = tabs[0]?.value ?? "";
47+
const [savedTab, setSavedTab] = useLocalStorage<string>(storageKey, defaultTab);
4648

47-
const normalizedPath = currentPath.replace(/\/$/u, "");
48-
const segments = normalizedPath.split("/");
49-
const lastSegment = segments[segments.length - 1] ?? "";
50-
const isTabSegment = tabs.some((tab) => tab.value === lastSegment);
51-
const baseUrl = isTabSegment && lastSegment !== "" ? segments.slice(0, -1).join("/") : normalizedPath;
52-
53-
// Use entity-based storage key instead of URL-based key
54-
const [lastTab, setLastTab] = useLocalStorage<string>(storageKey, tabs[0]?.value ?? "");
49+
// Track previous baseUrl to detect entity changes
50+
const previousBaseUrlRef = useRef<string | null>(null);
5551

5652
useEffect(() => {
5753
if (!enabled || tabs.length === 0) {
5854
return;
5955
}
6056

61-
const normalizedCurrentPath = currentPath.replace(/\/$/u, "");
62-
const normalizedBase = baseUrl.replace(/\/$/u, "");
57+
const normalizedPath = currentPath.replace(/\/$/u, "");
58+
const pathSegments = normalizedPath.split("/");
59+
const lastSegment = pathSegments[pathSegments.length - 1] ?? "";
60+
const isTabSegment = tabs.some((tab) => tab.value === lastSegment);
61+
62+
const baseUrl = isTabSegment && lastSegment !== "" ? pathSegments.slice(0, -1).join("/") : normalizedPath;
63+
const currentTab = isTabSegment ? lastSegment : "";
64+
65+
const isAtBase = normalizedPath === baseUrl;
66+
const isSavedTabValid = Boolean(savedTab) && tabs.some((tab) => tab.value === savedTab);
6367

64-
const pathSegments = normalizedCurrentPath.split("/");
65-
const baseSegments = normalizedBase.split("/");
66-
const currentTab = pathSegments[baseSegments.length] ?? "";
68+
// Check if we've switched to a different entity (different baseUrl)
69+
const hasEntityChanged = previousBaseUrlRef.current !== null && previousBaseUrlRef.current !== baseUrl;
70+
const isFirstVisit = previousBaseUrlRef.current === null;
6771

68-
const isValidTab = tabs.some((tab) => tab.value === currentTab);
69-
const isLastTabInCurrentTabs = !lastTab || tabs.some((tab) => tab.value === lastTab);
72+
previousBaseUrlRef.current = baseUrl;
7073

71-
// Only update localStorage if both currentTab is valid AND lastTab is also in current tabs
72-
// This prevents different page types (e.g., Task Instance vs Task Group) from overwriting each other's tab memory
73-
if (isValidTab && isLastTabInCurrentTabs) {
74-
setLastTab(currentTab);
74+
// Check if current tab is valid (including empty string for default tab)
75+
const isCurrentTabValid = tabs.some((tab) => tab.value === currentTab);
76+
77+
// Update saved preference when viewing a valid tab
78+
if (isCurrentTabValid) {
79+
const isPreviousSavedTabValid = !savedTab || tabs.some((tab) => tab.value === savedTab);
80+
81+
if (isPreviousSavedTabValid && currentTab !== savedTab) {
82+
setSavedTab(currentTab);
83+
}
7584
}
7685

77-
const isAtBaseUrl =
78-
normalizedCurrentPath === normalizedBase || normalizedCurrentPath === `${normalizedBase}/`;
79-
const hasValidSavedTab = Boolean(lastTab) && tabs.some((tab) => tab.value === lastTab);
80-
const shouldRedirect = isAtBaseUrl && hasValidSavedTab && lastTab !== tabs[0]?.value;
86+
const shouldRedirect =
87+
isAtBase && isSavedTabValid && savedTab !== defaultTab && (isFirstVisit || hasEntityChanged);
8188

8289
if (shouldRedirect) {
83-
const redirectPath = lastTab ? `${normalizedBase}/${lastTab}` : normalizedBase;
84-
8590
void Promise.resolve(
86-
navigate(redirectPath, {
91+
navigate(`${baseUrl}/${savedTab}`, {
8792
replace: true,
8893
}),
8994
);
9095
}
91-
}, [baseUrl, currentPath, enabled, lastTab, navigate, setLastTab, tabs]);
96+
}, [currentPath, defaultTab, enabled, navigate, savedTab, setSavedTab, tabs]);
9297
};

airflow-core/src/airflow/ui/src/utils/links.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19+
20+
/* eslint-disable max-lines */
1921
import { describe, it, expect } from "vitest";
2022

2123
import type { TaskInstanceResponse } from "openapi/requests/types.gen";
2224

23-
import { buildTaskInstanceUrl, getTaskInstanceAdditionalPath, getTaskInstanceLink } from "./links";
25+
import {
26+
buildTaskInstanceUrl,
27+
getTaskAdditionalPath,
28+
getTaskInstanceAdditionalPath,
29+
getTaskInstanceLink,
30+
} from "./links";
2431

2532
describe("getTaskInstanceLink", () => {
2633
const testCases = [
@@ -148,6 +155,59 @@ describe("getTaskInstanceAdditionalPath", () => {
148155
getTaskInstanceAdditionalPath("/dags/my-dag_v2/runs/run_1-test/tasks/task.1/rendered_templates"),
149156
).toBe("/rendered_templates");
150157
});
158+
159+
it("should NOT preserve sub-routes from Task pages (without /runs/)", () => {
160+
// Task page with task_instances tab - should NOT be preserved
161+
expect(getTaskInstanceAdditionalPath("/dags/my_dag/tasks/task_1/task_instances")).toBe("");
162+
163+
// Task page with overview/events tab - should NOT be preserved
164+
expect(getTaskInstanceAdditionalPath("/dags/my_dag/tasks/task_1/events")).toBe("");
165+
166+
// Task group page - should NOT be preserved
167+
expect(getTaskInstanceAdditionalPath("/dags/my_dag/tasks/group/my_group/task_instances")).toBe("");
168+
169+
// Only TaskInstance pages (with /runs/) should preserve sub-routes
170+
expect(getTaskInstanceAdditionalPath("/dags/my_dag/runs/run_1/tasks/task_1/task_instances")).toBe(
171+
"/task_instances",
172+
);
173+
});
174+
});
175+
176+
describe("getTaskAdditionalPath", () => {
177+
it("should return empty string for basic task path", () => {
178+
const result = getTaskAdditionalPath("/dags/my_dag/tasks/task_1");
179+
180+
expect(result).toBe("");
181+
});
182+
183+
it("should extract sub-route from task path", () => {
184+
const result = getTaskAdditionalPath("/dags/my_dag/tasks/task_1/task_instances");
185+
186+
expect(result).toBe("/task_instances");
187+
});
188+
189+
it("should extract sub-route from group task path", () => {
190+
const result = getTaskAdditionalPath("/dags/my_dag/tasks/group/my_group/events");
191+
192+
expect(result).toBe("/events");
193+
});
194+
195+
it("should handle all known task routes", () => {
196+
const knownRoutes = ["task_instances", "required_actions", "events"];
197+
198+
for (const route of knownRoutes) {
199+
const result = getTaskAdditionalPath(`/dags/test/tasks/task_1/${route}`);
200+
201+
expect(result).toBe(`/${route}`);
202+
}
203+
});
204+
205+
it("should also work for TaskInstance paths (backward compatibility)", () => {
206+
// getTaskAdditionalPath is more permissive and will extract from any /tasks/ path
207+
// This provides backward compatibility if needed
208+
expect(getTaskAdditionalPath("/dags/my_dag/runs/run_1/tasks/task_1/details")).toBe("/details");
209+
expect(getTaskAdditionalPath("/dags/my_dag/tasks/task_1/task_instances")).toBe("/task_instances");
210+
});
151211
});
152212

153213
describe("buildTaskInstanceUrl", () => {
@@ -255,5 +315,41 @@ describe("buildTaskInstanceUrl", () => {
255315
taskId: "new_group",
256316
}),
257317
).toBe("/dags/new_dag/runs/new_run/tasks/group/new_group/mapped/3");
318+
319+
// Mapped task overview (no mapIndex) should never preserve tabs
320+
expect(
321+
buildTaskInstanceUrl({
322+
currentPathname: "/dags/old/runs/old/tasks/old_task/xcom",
323+
dagId: "new_dag",
324+
isMapped: true,
325+
mapIndex: undefined,
326+
runId: "new_run",
327+
taskId: "mapped_task",
328+
}),
329+
).toBe("/dags/new_dag/runs/new_run/tasks/mapped_task/mapped");
330+
331+
// Mapped task overview with mapIndex="-1" should also not preserve
332+
expect(
333+
buildTaskInstanceUrl({
334+
currentPathname: "/dags/old/runs/old/tasks/old_task/details",
335+
dagId: "new_dag",
336+
isMapped: true,
337+
mapIndex: "-1",
338+
runId: "new_run",
339+
taskId: "mapped_task",
340+
}),
341+
).toBe("/dags/new_dag/runs/new_run/tasks/mapped_task/mapped");
342+
343+
// But specific mapped instance (with mapIndex) SHOULD preserve tabs
344+
expect(
345+
buildTaskInstanceUrl({
346+
currentPathname: "/dags/old/runs/old/tasks/old_task/mapped/0/xcom",
347+
dagId: "new_dag",
348+
isMapped: true,
349+
mapIndex: "5",
350+
runId: "new_run",
351+
taskId: "mapped_task",
352+
}),
353+
).toBe("/dags/new_dag/runs/new_run/tasks/mapped_task/mapped/5/xcom");
258354
});
259355
});

airflow-core/src/airflow/ui/src/utils/links.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,16 @@ export const getRedirectPath = (targetPath: string): string => {
4949

5050
export const getTaskInstanceAdditionalPath = (pathname: string): string => {
5151
const subRoutes = taskInstanceRoutes.filter((route) => route.path !== undefined).map((route) => route.path);
52-
// Look for patterns like /tasks/{taskId}/mapped/{mapIndex}/{sub-route}
53-
const mappedRegex = /\/tasks\/[^/]+\/mapped\/[^/]+\/(?<subRoute>.+)$/u;
52+
53+
const mappedRegex = /\/runs\/[^/]+\/tasks\/[^/]+\/mapped\/[^/]+\/(?<subRoute>.+)$/u;
5454
const mappedMatch = mappedRegex.exec(pathname);
5555

5656
if (mappedMatch?.groups?.subRoute !== undefined) {
5757
return `/${mappedMatch.groups.subRoute}`;
5858
}
5959

60-
// Look for patterns like /tasks/{taskId}/{sub-route} or /tasks/group/{groupId}/{sub-route}
61-
const taskRegex = /\/tasks\/(?:group\/)?[^/]+\/(?<subRoute>.+)$/u;
60+
// Must include /runs/ to avoid matching Task pages (/dags/xxx/tasks/yyy/task_instances)
61+
const taskRegex = /\/runs\/[^/]+\/tasks\/(?:group\/)?[^/]+\/(?<subRoute>.+)$/u;
6262
const taskMatch = taskRegex.exec(pathname);
6363

6464
if (taskMatch?.groups?.subRoute !== undefined) {
@@ -73,6 +73,17 @@ export const getTaskInstanceAdditionalPath = (pathname: string): string => {
7373
return "";
7474
};
7575

76+
export const getTaskAdditionalPath = (pathname: string): string => {
77+
const taskPageRegex = /\/dags\/[^/]+\/tasks\/(?:group\/)?[^/]+\/(?<subRoute>.+)$/u;
78+
const match = taskPageRegex.exec(pathname);
79+
80+
if (match?.groups?.subRoute !== undefined) {
81+
return `/${match.groups.subRoute}`;
82+
}
83+
84+
return "";
85+
};
86+
7687
export const buildTaskInstanceUrl = (params: {
7788
currentPathname: string;
7889
dagId: string;
@@ -84,8 +95,11 @@ export const buildTaskInstanceUrl = (params: {
8495
}): string => {
8596
const { currentPathname, dagId, isGroup = false, isMapped = false, mapIndex, runId, taskId } = params;
8697
const groupPath = isGroup ? "group/" : "";
87-
// Task groups only have "Task Instances" tab, so never preserve tabs for groups
88-
const additionalPath = isGroup ? "" : getTaskInstanceAdditionalPath(currentPathname);
98+
99+
// Only preserve tabs for specific task instances, excluding groups and mapped lists
100+
const isMappedOverview = isMapped && (mapIndex === undefined || mapIndex === "-1");
101+
const shouldPreserveTabs = !isGroup && !isMappedOverview;
102+
const additionalPath = shouldPreserveTabs ? getTaskInstanceAdditionalPath(currentPathname) : "";
89103

90104
let basePath = `/dags/${dagId}/runs/${runId}/tasks/${groupPath}${taskId}`;
91105

0 commit comments

Comments
 (0)