Skip to content

Commit 663b6e7

Browse files
[v3-2-test] UI: Implement automatic link target detection for extra_links (#64404) (#64967)
(cherry picked from commit 35d76bc) Co-authored-by: Subham <subhamsangwan26@gmail.com>
1 parent 4b1f9ee commit 663b6e7

2 files changed

Lines changed: 145 additions & 5 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import "@testing-library/jest-dom";
20+
import { render, screen } from "@testing-library/react";
21+
import { useParams } from "react-router-dom";
22+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
23+
24+
import * as queries from "openapi/queries";
25+
import { Wrapper } from "src/utils/Wrapper";
26+
27+
import { ExtraLinks } from "./ExtraLinks";
28+
29+
vi.mock("openapi/queries");
30+
vi.mock("react-router-dom", async () => {
31+
const actual = await vi.importActual("react-router-dom");
32+
33+
return {
34+
...actual,
35+
useParams: vi.fn(),
36+
};
37+
});
38+
39+
describe("ExtraLinks Component", () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
vi.mocked(useParams).mockReturnValue({
43+
dagId: "test-dag",
44+
mapIndex: "-1",
45+
runId: "test-run",
46+
taskId: "test-task",
47+
});
48+
});
49+
50+
afterEach(() => {
51+
vi.unstubAllGlobals();
52+
});
53+
54+
it("renders internal links with target='_self'", () => {
55+
vi.mocked(queries.useTaskInstanceServiceGetExtraLinks).mockReturnValue({
56+
data: {
57+
extra_links: {
58+
"Internal Link": "/dags/test/runs/run1",
59+
"Same Origin": "http://localhost:3000/some-path",
60+
},
61+
},
62+
} as unknown as ReturnType<typeof queries.useTaskInstanceServiceGetExtraLinks>);
63+
64+
// Mock window.location.origin
65+
vi.stubGlobal("location", { origin: "http://localhost:3000" });
66+
67+
render(
68+
<Wrapper>
69+
<ExtraLinks refetchInterval={false} />
70+
</Wrapper>,
71+
);
72+
73+
const internalLink = screen.getByText("Internal Link");
74+
75+
expect(internalLink.closest("a")).toHaveAttribute("target", "_self");
76+
77+
const sameOriginLink = screen.getByText("Same Origin");
78+
79+
expect(sameOriginLink.closest("a")).toHaveAttribute("target", "_self");
80+
});
81+
82+
it("renders external links with target='_blank'", () => {
83+
vi.mocked(queries.useTaskInstanceServiceGetExtraLinks).mockReturnValue({
84+
data: {
85+
extra_links: {
86+
"External Link": "https://www.google.com",
87+
},
88+
},
89+
} as unknown as ReturnType<typeof queries.useTaskInstanceServiceGetExtraLinks>);
90+
91+
// Mock window.location.origin
92+
vi.stubGlobal("location", { origin: "http://localhost:3000" });
93+
94+
render(
95+
<Wrapper>
96+
<ExtraLinks refetchInterval={false} />
97+
</Wrapper>,
98+
);
99+
100+
const externalLink = screen.getByText("External Link");
101+
102+
expect(externalLink.closest("a")).toHaveAttribute("target", "_blank");
103+
});
104+
105+
it("filters out null urls", () => {
106+
vi.mocked(queries.useTaskInstanceServiceGetExtraLinks).mockReturnValue({
107+
data: {
108+
extra_links: {
109+
Invalid: null,
110+
Valid: "http://localhost:3000/valid",
111+
},
112+
},
113+
} as unknown as ReturnType<typeof queries.useTaskInstanceServiceGetExtraLinks>);
114+
115+
render(
116+
<Wrapper>
117+
<ExtraLinks refetchInterval={false} />
118+
</Wrapper>,
119+
);
120+
121+
expect(screen.getByText("Valid")).toBeInTheDocument();
122+
expect(screen.queryByText("Invalid")).not.toBeInTheDocument();
123+
});
124+
});

airflow-core/src/airflow/ui/src/pages/TaskInstance/ExtraLinks.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ type ExtraLinksProps = {
2626
readonly refetchInterval: number | false;
2727
};
2828

29+
const getTarget = (url: string) => {
30+
try {
31+
return new URL(url, globalThis.location.origin).origin === globalThis.location.origin
32+
? "_self"
33+
: "_blank";
34+
} catch {
35+
return "_blank";
36+
}
37+
};
38+
2939
export const ExtraLinks = ({ refetchInterval }: ExtraLinksProps) => {
3040
const { t: translate } = useTranslation("dag");
3141
const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
@@ -47,15 +57,21 @@ export const ExtraLinks = ({ refetchInterval }: ExtraLinksProps) => {
4757
<Box py={1}>
4858
<Heading size="sm">{translate("extraLinks")}</Heading>
4959
<HStack gap={2} py={2}>
50-
{Object.entries(data.extra_links).map(([key, value], _) =>
51-
value === null ? undefined : (
60+
{Object.entries(data.extra_links).map(([key, url]) => {
61+
if (url === null) {
62+
return undefined;
63+
}
64+
65+
const target = getTarget(url);
66+
67+
return (
5268
<Button asChild colorPalette="brand" key={key} variant="surface">
53-
<a href={value} rel="noopener noreferrer" target="_blank">
69+
<a href={url} rel="noopener noreferrer" target={target}>
5470
{key}
5571
</a>
5672
</Button>
57-
),
58-
)}
73+
);
74+
})}
5975
</HStack>
6076
</Box>
6177
) : undefined;

0 commit comments

Comments
 (0)