Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* under the License.
*/
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import i18n from "i18next";
import type { DagTagResponse, DAGWithLatestDagRunsResponse } from "openapi-gen/requests/types.gen";
import type { PropsWithChildren } from "react";
Expand Down Expand Up @@ -209,6 +209,36 @@ describe("DagCard", () => {
expect(latestRunElement).toHaveTextContent("2025-09-19 19:22:00");
});

it("DagCard should share one tooltip controller across recent runs", async () => {
vi.useFakeTimers();
renderCard(mockDag);

const recentRuns = screen.getAllByTestId("recent-run");
const tooltipOwners = new Set(recentRuns.map((run) => run.getAttribute("data-ownedby")));
const secondRecentRun = recentRuns.at(1);

try {
expect(recentRuns).toHaveLength(mockDag.latest_dag_runs.length);
expect(tooltipOwners.size).toBe(1);
expect(tooltipOwners.has(null)).toBe(false);
expect(secondRecentRun).toBeDefined();

if (secondRecentRun === undefined) {
throw new Error("Expected at least two recent runs");
}

await act(async () => {
fireEvent.focus(secondRecentRun);
fireEvent.pointerEnter(secondRecentRun);
await vi.advanceTimersByTimeAsync(500);
});

expect(screen.getByRole("tooltip")).toHaveTextContent("2025-09-19 19:21:00");
} finally {
vi.useRealTimers();
}
});

it("DagCard should render next run section with timestamp", () => {
renderCard(mockDag);
const nextRunElement = screen.getByTestId("next-run");
Expand Down
135 changes: 78 additions & 57 deletions airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Flex, Box, Text } from "@chakra-ui/react";
import { Flex, Box, Portal, Text, Tooltip } from "@chakra-ui/react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { useTranslation } from "react-i18next";
Expand All @@ -25,20 +25,47 @@ import { Link } from "react-router-dom";
import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
import { StateIcon } from "src/components/StateIcon";
import Time from "src/components/Time";
import { Tooltip } from "src/components/ui";
import { renderDuration } from "src/utils";

dayjs.extend(duration);

const BAR_HEIGHT = 65;

type LatestRun = DAGWithLatestDagRunsResponse["latest_dag_runs"][number];

const RecentRunTooltipContent = ({ run }: { readonly run: LatestRun }) => {
const { t: translate } = useTranslation();

return (
<Box>
<Text>
{translate("state")}: {translate(`common:states.${run.state}`)}
</Text>
<Text>
{translate("dagRun.runAfter")}: <Time datetime={run.run_after} />
</Text>
{run.start_date === null ? undefined : (
<Text>
{translate("startDate")}: <Time datetime={run.start_date} />
</Text>
)}
{run.end_date === null ? undefined : (
<Text>
{translate("endDate")}: <Time datetime={run.end_date} />
</Text>
)}
<Text>
{translate("duration")}: {renderDuration(run.duration)}
</Text>
</Box>
);
};

export const RecentRuns = ({
latestRuns,
}: {
readonly latestRuns: DAGWithLatestDagRunsResponse["latest_dag_runs"];
}) => {
const { t: translate } = useTranslation();

if (!latestRuns.length) {
return undefined;
}
Expand All @@ -49,58 +76,52 @@ export const RecentRuns = ({
);

return (
<Flex alignItems="flex-end" flexDirection="row-reverse" gap={[0.5, 0.5, 0.5, 1]} pb={1}>
{latestRuns.map((run) => (
<Tooltip
content={
<Box>
<Text>
{translate("state")}: {translate(`common:states.${run.state}`)}
</Text>
<Text>
{translate("dagRun.runAfter")}: <Time datetime={run.run_after} />
</Text>
{run.start_date === null ? undefined : (
<Text>
{translate("startDate")}: <Time datetime={run.start_date} />
</Text>
)}
{run.end_date === null ? undefined : (
<Text>
{translate("endDate")}: <Time datetime={run.end_date} />
</Text>
)}
<Text>
{translate("duration")}: {renderDuration(run.duration)}
</Text>
</Box>
}
key={run.run_id}
positioning={{
offset: {
crossAxis: 5,
mainAxis: 5,
},
placement: "bottom-start",
}}
>
<Link to={`/dags/${run.dag_id}/runs/${run.run_id}/`}>
<Flex
alignItems="center"
bg={`${run.state}.solid`}
borderRadius="4px"
flexDir="column"
fontSize="12px"
height={`${run.duration === null ? 1 : (run.duration / max) * BAR_HEIGHT}px`}
justifyContent="flex-end"
minHeight="12px"
width="12px"
>
<StateIcon color="white" state={run.state} />
</Flex>
</Link>
</Tooltip>
))}
</Flex>
<Tooltip.Root
positioning={{
offset: {
crossAxis: 5,
mainAxis: 5,
},
placement: "bottom-start",
}}
>
<Flex alignItems="flex-end" flexDirection="row-reverse" gap={[0.5, 0.5, 0.5, 1]} pb={1}>
{latestRuns.map((run) => (
<Tooltip.Trigger asChild key={run.run_id} value={run.run_id}>
<Link data-testid="recent-run" to={`/dags/${run.dag_id}/runs/${run.run_id}/`}>
<Flex
alignItems="center"
bg={`${run.state}.solid`}
borderRadius="4px"
flexDir="column"
fontSize="12px"
height={`${run.duration === null ? 1 : (run.duration / max) * BAR_HEIGHT}px`}
justifyContent="flex-end"
minHeight="12px"
width="12px"
>
<StateIcon color="white" state={run.state} />
</Flex>
</Link>
</Tooltip.Trigger>
))}
</Flex>
<Portal disabled>
<Tooltip.Positioner>
<Tooltip.Content>
<Tooltip.Arrow>
<Tooltip.ArrowTip />
</Tooltip.Arrow>
<Tooltip.Context>
{({ triggerValue }) => {
const run = latestRuns.find(({ run_id: runId }) => runId === triggerValue);

return run === undefined ? undefined : <RecentRunTooltipContent run={run} />;
}}
</Tooltip.Context>
</Tooltip.Content>
</Tooltip.Positioner>
</Portal>
</Tooltip.Root>
);
};
Loading