You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by bb...@apache.org on 2026/10/08 21:57:29 UTC
(airflow) branch main updated: Dag run breadcrumb switcher (#74471)
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 e82b196c065 Dag run breadcrumb switcher (#74471)
e82b196c065 is described below
commit e82b196c06515a232755e5fbfd1fb5b6f1cef49e
Author: Brent Bovenzi <br...@astronomer.io>
AuthorDate: Thu Oct 8 17:57:21 2026 -0400
Dag run breadcrumb switcher (#74471)
* UI: Let the breadcrumb's Dag Run level switch runs
Switching to a different run meant leaving the page you were looking at, or
digging the run picker out of the graph view's options popover. The run is
already named in the breadcrumb next to the Dag it belongs to, so that is
where picking a different one belongs — the same place, and the same two-part
control, the Dag level already uses.
* UI: Open the breadcrumb switchers on a list that is already loaded
Both panels asked react-select to load their options from a mount effect, and
both unmount when the panel closes, so every open was a round trip that started
as a spinner over an empty list — including when the same Dags or runs had just
been on screen a moment earlier.
What a panel lists is not the panel's own business: it belongs to the level that
opens it, which stays on the page. From there it is loaded before anyone opens
anything, and the run list keeps up with the page's auto-refresh.
Giving the run level a permanent place in the bar follows from the same idea: the
breadcrumb is where runs are switched, so every page of a Dag can reach the
latest run, not only the pages that already have one in the URL.
* UI: Take the loading affordance out of the breadcrumb searches
Neither panel has anything to wait for any more: what they list is loaded by the
breadcrumb level that opens them, and a typed search replaces the results once it
answers rather than blanking them first. A spinner over a list that is already
there only reads as the panel losing its place.
The run level also had to declare the `dag` namespace to name itself. It had not
needed to while it only appeared on task pages without a run; now that it is on
every page of a Dag, it was showing its translation key.
* UI: Put each breadcrumb search next to the level that opens it
Neither search is reusable — each has exactly one caller, the switcher button it
belongs to — so sitting in `components/` only put distance between the two halves
of one control. The query that feeds a panel was a hook of its own for the same
reason, read once, in a component short enough to hold it.
Only the combo-box chrome is genuinely shared, and a folder holding one file
behind a re-export is not a module.
* UI: Give the breadcrumb searches' shared values their own module
Folding the option builders and query constants into the panel components left
each one exporting things that are not components, which costs the file its fast
refresh. They are shared between a panel and the level that opens it, so they
belong beside both rather than inside either.
* UI: Address review on the Dag run breadcrumb switcher
Switching runs from a mapped task built its own path and sent `/tasks/<id>`,
which has no row once the task is expanded, so the page it landed on reported no
task instance found. It also dropped whichever tab was open. The shared builder
the grid and keyboard navigation already use handles both, and knows to stop at
the list of instances.
Suppressing react-select's loading message as well as its spinner went too far:
on the first search after the input is cleared react-select empties the list
itself, and with nothing to say in that window it rendered no menu at all.
`checkPendingRuns` does not scale the refresh interval back, it switches it off,
so the list stopped noticing runs once the current ones finished — and while any
run was active, every page of a Dag polled for a panel nobody had opened. The
panel is the only thing that reads this list, so it is also the only thing that
needs it kept current.
Rebuilding every option on each poll cost react-select the focused row, since it
tracks focus by reference: arrowing down and pressing Enter could open a
different run than the one highlighted.
---
.../airflow/ui/public/i18n/locales/en/common.json | 2 +
.../airflow/ui/public/i18n/locales/en/dags.json | 1 +
.../ui/src/components/Breadcrumb/CrumbSwitcher.tsx | 85 ++++++++
.../airflow/ui/src/components/Breadcrumb/index.ts | 1 +
.../components/SearchDags/SearchDagsControl.tsx | 37 ----
.../airflow/ui/src/components/SearchDags/index.ts | 20 --
.../src/airflow/ui/src/components/SearchSelect.tsx | 117 +++++++++++
.../ui/src/layouts/Details/DagBreadcrumb.test.tsx | 71 +++++++
.../ui/src/layouts/Details/DagBreadcrumb.tsx | 44 +++--
.../src/layouts/Details/DagRunSwitcherButton.tsx | 89 +++++++++
.../ui/src/layouts/Details/DagSwitcherButton.tsx | 75 +++----
.../ui/src/layouts/Details/SearchDagRuns.test.tsx | 215 +++++++++++++++++++++
.../ui/src/layouts/Details/SearchDagRuns.tsx | 129 +++++++++++++
.../Details}/SearchDags.test.tsx | 2 +-
.../SearchDags => layouts/Details}/SearchDags.tsx | 67 +++----
.../ui/src/layouts/Details/searchOptions.test.ts | 50 +++++
.../ui/src/layouts/Details/searchOptions.ts | 66 +++++++
airflow-core/src/airflow/ui/src/utils/option.ts | 4 +
18 files changed, 903 insertions(+), 172 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 4b17fbdca81..61141af84c6 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -168,6 +168,7 @@
"generateToken": "Generate Token",
"includedIn": "Included in",
"key": "Key",
+ "loading": "Loading…",
"logLevel": {
"hide": "Hide Log Levels",
"hotkey": "l",
@@ -419,6 +420,7 @@
"upstream_failed": "Upstream Failed"
},
"switchDag": "Switch $t(dag_one)",
+ "switchDagRun": "Switch $t(dagRun_one)",
"table": {
"completedAt": "Completed at",
"createdAt": "Created at",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index 8ab7d375ac3..e39a8194332 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -128,6 +128,7 @@
"search": {
"advanced": "Advanced Search",
"clear": "Clear search",
+ "dagRuns": "Search $t(common:dagRun_other)",
"dags": "Search $t(common:dag_other)",
"hotkey": "+K",
"tasks": "Search $t(common:task_other)"
diff --git a/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx b/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
new file mode 100644
index 00000000000..573c893e7a3
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
@@ -0,0 +1,85 @@
+/*!
+ * 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 type { ReactNode } from "react";
+
+import { Box } from "@chakra-ui/react";
+import { BsChevronExpand } from "react-icons/bs";
+
+import { IconButton, Popover, Tooltip } from "src/system-components";
+
+import { CrumbDivider, CrumbGroup, CrumbLink } from "./Crumb";
+import { CUT_PADDING, type CrumbShape, crumbButtonStyles, getWedgePadding } from "./segment";
+
+type Props = {
+ readonly children: ReactNode;
+ /** Names the chevron for the tooltip and for assistive technology. */
+ readonly label: string;
+ readonly onOpenChange: (open: boolean) => void;
+ readonly open: boolean;
+ readonly search: ReactNode;
+ readonly shape: CrumbShape;
+ readonly testId: string;
+ readonly to: string;
+};
+
+/**
+ * A breadcrumb level that also switches between its siblings: a two-part control whose left half
+ * navigates to the level and whose right half opens a search, so switching happens where the
+ * current one is named. Open state stays at the call site, which owns the keyboard shortcut (if
+ * the level has one) and the search the panel holds.
+ */
+export const CrumbSwitcher = ({ children, label, onOpenChange, open, search, shape, testId, to }: Props) => (
+ <Popover.Root
+ lazyMount
+ onOpenChange={(event) => onOpenChange(event.open)}
+ open={open}
+ positioning={{ placement: "bottom-start" }}
+ unmountOnExit
+ >
+ {/* Anchored to the whole level rather than the chevron: it shares the breadcrumb's start
+ edge and its bottom, so the panel drops clear of the bar and lines up with it. */}
+ <Popover.Anchor asChild>
+ <CrumbGroup shape={shape}>
+ <CrumbLink paddingInlineEnd={CUT_PADDING} to={to}>
+ {children}
+ </CrumbLink>
+ <CrumbDivider />
+ {/* The tooltip wraps the trigger rather than coming from IconButton's `label`: nesting it
+ inside `asChild` leaves its own trigger ref unset, and it renders away from the button. */}
+ <Tooltip content={label} disabled={open} portalled>
+ <Popover.Trigger asChild>
+ <IconButton
+ {...crumbButtonStyles}
+ {...getWedgePadding(shape)}
+ alignSelf="stretch"
+ aria-label={label}
+ data-testid={testId}
+ paddingInlineStart={2}
+ >
+ <BsChevronExpand />
+ </IconButton>
+ </Popover.Trigger>
+ </Tooltip>
+ </CrumbGroup>
+ </Popover.Anchor>
+ <Popover.Content data-testid={`${testId}-popover`} width="sm">
+ <Box p={2}>{search}</Box>
+ </Popover.Content>
+ </Popover.Root>
+);
diff --git a/airflow-core/src/airflow/ui/src/components/Breadcrumb/index.ts b/airflow-core/src/airflow/ui/src/components/Breadcrumb/index.ts
index b36621f0075..fb845f7b685 100644
--- a/airflow-core/src/airflow/ui/src/components/Breadcrumb/index.ts
+++ b/airflow-core/src/airflow/ui/src/components/Breadcrumb/index.ts
@@ -17,5 +17,6 @@
* under the License.
*/
export * from "./Crumb";
+export * from "./CrumbSwitcher";
export * from "./Row";
export * from "./segment";
diff --git a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsControl.tsx b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsControl.tsx
deleted file mode 100644
index 7687add6b8a..00000000000
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsControl.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-/*!
- * 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 { Box } from "@chakra-ui/react";
-import { chakraComponents } from "chakra-react-select";
-import type { ControlProps } from "chakra-react-select";
-import { FiSearch } from "react-icons/fi";
-
-import type { DagSearchOption } from "src/utils/option";
-
-/**
- * Leads the input with the search affordance. react-select only renders indicators after the value
- * container, so an icon on the start side has to come from the control itself.
- */
-export const Control = ({ children, ...props }: ControlProps<DagSearchOption, false>) => (
- <chakraComponents.Control {...props}>
- <Box alignItems="center" as="span" color="fg.muted" display="flex" flexShrink={0} pe={1.5}>
- <FiSearch />
- </Box>
- {children}
- </chakraComponents.Control>
-);
diff --git a/airflow-core/src/airflow/ui/src/components/SearchDags/index.ts b/airflow-core/src/airflow/ui/src/components/SearchDags/index.ts
deleted file mode 100644
index 7183a7405a9..00000000000
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/index.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/*!
- * 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.
- */
-
-export { SearchDags } from "./SearchDags";
diff --git a/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx b/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx
new file mode 100644
index 00000000000..61b4e2fdfef
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx
@@ -0,0 +1,117 @@
+/*!
+ * 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 type { ReactNode } from "react";
+
+import { Box, Field } from "@chakra-ui/react";
+import type {
+ ChakraStylesConfig,
+ ControlProps,
+ GroupBase,
+ OptionsOrGroups,
+ SingleValue,
+} from "chakra-react-select";
+import { AsyncSelect, chakraComponents } from "chakra-react-select";
+import { FiSearch } from "react-icons/fi";
+
+/**
+ * Leads the input with the search affordance. react-select only renders indicators after the value
+ * container, so an icon on the start side has to come from the control itself.
+ */
+const SearchControl = <Option,>({ children, ...props }: ControlProps<Option, false>) => (
+ <chakraComponents.Control {...props}>
+ <Box alignItems="center" as="span" color="fg.muted" display="flex" flexShrink={0} pe={1.5}>
+ <FiSearch />
+ </Box>
+ {children}
+ </chakraComponents.Control>
+);
+
+/** `DropdownIndicator` accepts `null` to opt out; `LoadingIndicator` only accepts a component. */
+const NoIndicator = () => null;
+
+type Props<Option> = {
+ /**
+ * The options to list before anything is typed. Always an array, never `true`: `true` makes
+ * react-select call `loadOptions` from a mount effect and start out `isLoading`, so every open
+ * of a panel that unmounts on close is a spinner over an empty list. A fresh array replaces what
+ * is listed, which is how a live query keeps an open panel current.
+ */
+ readonly defaultOptions: Array<Option>;
+ readonly formatOptionLabel: (option: Option) => ReactNode;
+ /**
+ * Shown while a search is in flight. react-select empties the list for the first search after
+ * the input is cleared, so without this the panel would render nothing at all in that window.
+ */
+ readonly loadingMessage: string;
+ readonly loadOptions: (
+ inputValue: string,
+ callback: (options: OptionsOrGroups<Option, GroupBase<Option>>) => void,
+ ) => void;
+ readonly onChange: (selected: SingleValue<Option>) => void;
+ readonly placeholder: string;
+};
+
+/**
+ * The search a breadcrumb switcher drops open. Its results are listed from the moment it mounts,
+ * since the panel exists only to show them.
+ */
+export const SearchSelect = <Option,>({
+ defaultOptions,
+ formatOptionLabel,
+ loadingMessage,
+ loadOptions,
+ onChange,
+ placeholder,
+}: Props<Option>) => {
+ // The popover is the card. Drop the floating menu's own positioning and chrome so the results
+ // flow inside it directly under the input, instead of reading as a second card.
+ const chakraStyles: ChakraStylesConfig<Option, false, GroupBase<Option>> = {
+ menu: () => ({ marginTop: 2, width: "100%" }),
+ menuList: (provided) => ({
+ ...provided,
+ background: "transparent",
+ borderRadius: 0,
+ boxShadow: "none",
+ paddingInline: 0,
+ zIndex: "auto",
+ }),
+ };
+
+ return (
+ <Field.Root>
+ <AsyncSelect<Option>
+ backspaceRemovesValue={true}
+ chakraStyles={chakraStyles}
+ // No spinner: the listed options are in hand before the panel opens, and a search
+ // replaces them once it answers. Only the window where react-select empties the list
+ // itself gets an affordance, and a line of text is enough for it.
+ components={{ Control: SearchControl, DropdownIndicator: null, LoadingIndicator: NoIndicator }}
+ defaultOptions={defaultOptions}
+ filterOption={undefined}
+ formatOptionLabel={formatOptionLabel}
+ loadingMessage={() => loadingMessage}
+ loadOptions={loadOptions}
+ menuIsOpen
+ onChange={onChange}
+ placeholder={placeholder}
+ value={null} // null is required https://github.com/JedWatson/react-select/issues/3066
+ />
+ </Field.Root>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
index 18d80b1b511..a495c8a94eb 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
@@ -23,6 +23,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
import { MemoryRouter, Route, Routes, useLocation, useMatches } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { DagRunService, DagService } from "openapi/requests/services.gen";
+
import { BaseWrapper } from "src/utils/Wrapper";
import { DagBreadcrumb } from "./DagBreadcrumb";
@@ -118,6 +120,75 @@ describe("DagBreadcrumb", () => {
);
});
+ it("keeps a run level on a Dag page with no run, so a run can be picked from anywhere", async () => {
+ render(<DagBreadcrumb />, { wrapper: createWrapper(`/dags/${DAG_ID}`, "/dags/:dagId") });
+
+ expect(await screen.findByRole("link", { name: /allRuns/u })).toHaveAttribute(
+ "href",
+ `/dags/${DAG_ID}/runs`,
+ );
+ expect(screen.getByTestId("switch-dag-run")).toBeInTheDocument();
+ });
+
+ it("loads what both dropdowns list before either one is opened", async () => {
+ const getDagsUi = vi.spyOn(DagService, "getDagsUi").mockResolvedValue({ dags: [], total_entries: 0 });
+ const getDagRuns = vi
+ .spyOn(DagRunService, "getDagRuns")
+ .mockResolvedValue({ dag_runs: [], total_entries: 0 });
+
+ render(<DagBreadcrumb />, { wrapper: createWrapper(`/dags/${DAG_ID}`, "/dags/:dagId") });
+
+ await waitFor(() => expect(getDagsUi).toHaveBeenCalled());
+ await waitFor(() =>
+ expect(getDagRuns).toHaveBeenCalledWith(
+ expect.objectContaining({ dagId: DAG_ID, limit: 10, orderBy: ["-run_after"] }),
+ ),
+ );
+
+ // Neither panel was ever opened: what they list is loaded by the breadcrumb level itself, so
+ // opening one is not a round trip and never starts out empty.
+ expect(screen.queryByTestId("switch-dag-popover")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("switch-dag-run-popover")).not.toBeInTheDocument();
+ });
+
+ it("asks for fresh runs when the panel opens, having listed them without polling for them", async () => {
+ const getDagRuns = vi
+ .spyOn(DagRunService, "getDagRuns")
+ .mockResolvedValue({ dag_runs: [], total_entries: 0 });
+
+ render(<DagBreadcrumb />, { wrapper: createWrapper(`/dags/${DAG_ID}`, "/dags/:dagId") });
+
+ const listCalls = () =>
+ getDagRuns.mock.calls.filter(([params]) => params.limit === 10 && params.orderBy !== undefined).length;
+
+ // Loaded up front, so the panel never opens onto an empty list.
+ await waitFor(() => expect(listCalls()).toBe(1));
+
+ fireEvent.click(await screen.findByTestId("switch-dag-run"));
+
+ // Opening catches up whatever changed while the panel was closed and not polling.
+ await waitFor(() => expect(listCalls()).toBe(2));
+ });
+
+ it("switches Dag run from the chevron dropdown, keeping the task in view", async () => {
+ render(<DagBreadcrumb />, {
+ wrapper: createWrapper(
+ `/dags/${DAG_ID}/runs/run_1/tasks/task_1`,
+ "/dags/:dagId/runs/:runId/tasks/:taskId",
+ ),
+ });
+
+ fireEvent.click(await screen.findByTestId("switch-dag-run"));
+
+ fireEvent.click(await screen.findByText("run_in_range", undefined, { timeout: 3000 }));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("location-pathname")).toHaveTextContent(
+ `/dags/${DAG_ID}/runs/run_in_range/tasks/task_1`,
+ ),
+ );
+ });
+
it("renders one level per URL segment, with only the current one unlinked", async () => {
render(<DagBreadcrumb />, {
wrapper: createWrapper(
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
index ac570efae33..a209a33fd88 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
@@ -32,8 +32,12 @@ import { BreadcrumbRow, CrumbLink, CrumbStack, CrumbText, type CrumbShape } from
import { isStatePending, useAutoRefresh } from "src/utils";
+import { DagRunSwitcherButton } from "./DagRunSwitcherButton";
import { DagSwitcherButton } from "./DagSwitcherButton";
+/** Levels that stand for a Dag run — both offer the run search. */
+const RUN_LEVEL_KEYS = new Set(["allRuns", "dagRun"]);
+
type Crumb = {
readonly caption: string;
/** Set alongside `state` so the badge keeps its room while the value is still loading. */
@@ -48,11 +52,13 @@ const BreadcrumbItem = ({
crumb,
dagId,
isLast,
+ isMapped,
shape,
}: {
readonly crumb: Crumb;
readonly dagId: string;
readonly isLast: boolean;
+ readonly isMapped: boolean;
readonly shape: CrumbShape;
}) => {
const content = (
@@ -73,6 +79,14 @@ const BreadcrumbItem = ({
);
}
+ if (RUN_LEVEL_KEYS.has(crumb.key) && crumb.to !== undefined) {
+ return (
+ <DagRunSwitcherButton dagId={dagId} isMapped={isMapped} shape={shape} to={crumb.to}>
+ {content}
+ </DagRunSwitcherButton>
+ );
+ }
+
if (isLast || crumb.to === undefined) {
return <CrumbText shape={shape}>{content}</CrumbText>;
}
@@ -85,7 +99,7 @@ const BreadcrumbItem = ({
};
export const DagBreadcrumb = () => {
- const { t: translate } = useTranslation();
+ const { t: translate } = useTranslation(["common", "dag"]);
const { dagId = "", groupId, mapIndex = "-1", runId, taskId } = useParams();
const { pathname } = useLocation();
const refetchInterval = useAutoRefresh({ dagId });
@@ -140,8 +154,16 @@ export const DagBreadcrumb = () => {
},
];
- // Add dag run breadcrumb
- if (runId !== undefined) {
+ // The run level is always present, even with no run in the URL: it is where runs are switched,
+ // so standing on "all runs" is what lets any page of the Dag jump straight to one.
+ if (runId === undefined) {
+ crumbs.push({
+ caption: translate("dagRun_one"),
+ key: "allRuns",
+ to: `/dags/${dagId}/runs`,
+ value: translate("dag:allRuns"),
+ });
+ } else {
crumbs.push({
caption: translate("dagRun_one"),
hasState: true,
@@ -154,15 +176,6 @@ export const DagBreadcrumb = () => {
// Add group breadcrumb
if (groupId !== undefined) {
- if (runId === undefined) {
- crumbs.push({
- caption: translate("dagRun_one"),
- key: "allRuns",
- to: `/dags/${dagId}/runs`,
- value: translate("allRuns", { ns: "dag" }),
- });
- }
-
crumbs.push({
caption: translate("taskGroup_one"),
key: "group",
@@ -192,12 +205,6 @@ export const DagBreadcrumb = () => {
}
if (runId === undefined && taskId !== undefined) {
- crumbs.push({
- caption: translate("dagRun_one"),
- key: "allRuns",
- to: `/dags/${dagId}/runs`,
- value: translate("allRuns", { ns: "dag" }),
- });
crumbs.push({
caption: translate("task_one"),
key: "task",
@@ -222,6 +229,7 @@ export const DagBreadcrumb = () => {
crumb={crumb}
dagId={dagId}
isLast={index === crumbs.length - 1}
+ isMapped={hasExpandedInstances}
key={crumb.key}
shape={{ hasNotch: index > 0, hasPoint: index < crumbs.length - 1 }}
/>
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagRunSwitcherButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DagRunSwitcherButton.tsx
new file mode 100644
index 00000000000..d7caa644e9c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagRunSwitcherButton.tsx
@@ -0,0 +1,89 @@
+/*!
+ * 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 { type ReactNode, useState } from "react";
+
+import { useTranslation } from "react-i18next";
+
+import { useDagRunServiceGetDagRuns } from "openapi/queries";
+
+import { CrumbSwitcher, type CrumbShape } from "src/components/Breadcrumb";
+
+import { useAutoRefresh } from "src/utils";
+import type { DagRunSearchOption } from "src/utils/option";
+
+import { SearchDagRuns } from "./SearchDagRuns";
+import { NEWEST_FIRST, SEARCH_LIMIT, buildDagRunOption } from "./searchOptions";
+
+const NO_RUNS: Array<DagRunSearchOption> = [];
+
+type Props = {
+ readonly children: ReactNode;
+ readonly dagId: string;
+ readonly isMapped: boolean;
+ readonly shape: CrumbShape;
+ readonly to: string;
+};
+
+/**
+ * The Dag run level of the breadcrumb, with a search over the Dag's runs behind its chevron. It
+ * stands in for both a named run and the "all runs" level, so `to` comes from the crumb itself.
+ *
+ * The runs the panel lists are loaded here rather than inside it: a query that only starts when
+ * the panel opens has nothing to show until it answers, so opening would mean a spinner over an
+ * empty list. Here it is loaded ahead of that, and opening only asks for a fresh answer over the
+ * one already on screen.
+ *
+ * Polling is held to while the panel is open. It cannot be held to while runs are pending the way
+ * the grid does, because this list exists to notice runs that do not exist yet; left on, it would
+ * poll from every page of a Dag for a panel nobody opened.
+ */
+export const DagRunSwitcherButton = ({ children, dagId, isMapped, shape, to }: Props) => {
+ const { t: translate } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const refetchInterval = useAutoRefresh({ dagId });
+ const { data, refetch } = useDagRunServiceGetDagRuns(
+ { dagId, limit: SEARCH_LIMIT, orderBy: NEWEST_FIRST },
+ undefined,
+ { refetchInterval: open && refetchInterval },
+ );
+ const runs = data === undefined ? NO_RUNS : data.dag_runs.map(buildDagRunOption);
+
+ const onOpenChange = (next: boolean) => {
+ setOpen(next);
+
+ // The cached runs are shown straight away; this only catches up what changed while closed.
+ if (next) {
+ void refetch();
+ }
+ };
+
+ return (
+ <CrumbSwitcher
+ label={translate("switchDagRun")}
+ onOpenChange={onOpenChange}
+ open={open}
+ search={<SearchDagRuns dagId={dagId} isMapped={isMapped} onClose={() => setOpen(false)} runs={runs} />}
+ shape={shape}
+ testId="switch-dag-run"
+ to={to}
+ >
+ {children}
+ </CrumbSwitcher>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagSwitcherButton.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DagSwitcherButton.tsx
index a975f616efa..0000b653c3e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagSwitcherButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagSwitcherButton.tsx
@@ -18,25 +18,20 @@
*/
import { type ReactNode, useState } from "react";
-import { Box } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
-import { BsChevronExpand } from "react-icons/bs";
-import { IconButton, Popover, Tooltip } from "src/system-components";
+import { useDagServiceGetDagsUi } from "openapi/queries";
-import {
- CUT_PADDING,
- CrumbDivider,
- CrumbGroup,
- CrumbLink,
- type CrumbShape,
- crumbButtonStyles,
- getWedgePadding,
-} from "src/components/Breadcrumb";
-import { SearchDags } from "src/components/SearchDags";
+import { CrumbSwitcher, type CrumbShape } from "src/components/Breadcrumb";
import { SHORTCUTS } from "src/context/keyboardShortcuts";
import { useShortcut } from "src/hooks/useShortcut";
+import type { DagSearchOption } from "src/utils/option";
+
+import { SearchDags } from "./SearchDags";
+import { SEARCH_LIMIT, buildDagOption } from "./searchOptions";
+
+const NO_DAGS: Array<DagSearchOption> = [];
type Props = {
readonly children: ReactNode;
@@ -45,12 +40,16 @@ type Props = {
};
/**
- * The Dag level of the breadcrumb: a two-part control whose left half navigates to the Dag and
- * whose right half opens the Dag search, so switching Dags happens where the current one is named.
+ * The Dag level of the breadcrumb, with the Dag search behind its chevron.
+ *
+ * The Dags the panel lists are loaded here rather than inside it: a query that only starts when
+ * the panel opens has nothing to show until it answers.
*/
export const DagSwitcherButton = ({ children, dagId, shape }: Props) => {
const { t: translate } = useTranslation();
const [open, setOpen] = useState(false);
+ const { data } = useDagServiceGetDagsUi({ dagRunsLimit: 1, limit: SEARCH_LIMIT });
+ const dags = data === undefined ? NO_DAGS : data.dags.map(buildDagOption);
useShortcut({
...SHORTCUTS.search.searchDags,
@@ -60,44 +59,16 @@ export const DagSwitcherButton = ({ children, dagId, shape }: Props) => {
});
return (
- <Popover.Root
- lazyMount
- onOpenChange={(event) => setOpen(event.open)}
+ <CrumbSwitcher
+ label={translate("switchDag")}
+ onOpenChange={setOpen}
open={open}
- positioning={{ placement: "bottom-start" }}
- unmountOnExit
+ search={<SearchDags dags={dags} onClose={() => setOpen(false)} />}
+ shape={shape}
+ testId="switch-dag"
+ to={`/dags/${dagId}`}
>
- {/* Anchored to the whole Dag level rather than the chevron: it shares the breadcrumb's start
- edge and its bottom, so the panel drops clear of the bar and lines up with it. */}
- <Popover.Anchor asChild>
- <CrumbGroup shape={shape}>
- <CrumbLink paddingInlineEnd={CUT_PADDING} to={`/dags/${dagId}`}>
- {children}
- </CrumbLink>
- <CrumbDivider />
- {/* The tooltip wraps the trigger rather than coming from IconButton's `label`: nesting it
- inside `asChild` leaves its own trigger ref unset, and it renders away from the button. */}
- <Tooltip content={translate("switchDag")} disabled={open} portalled>
- <Popover.Trigger asChild>
- <IconButton
- {...crumbButtonStyles}
- {...getWedgePadding(shape)}
- alignSelf="stretch"
- aria-label={translate("switchDag")}
- data-testid="switch-dag"
- paddingInlineStart={2}
- >
- <BsChevronExpand />
- </IconButton>
- </Popover.Trigger>
- </Tooltip>
- </CrumbGroup>
- </Popover.Anchor>
- <Popover.Content data-testid="switch-dag-popover" width="sm">
- <Box p={2}>
- <SearchDags onClose={() => setOpen(false)} />
- </Box>
- </Popover.Content>
- </Popover.Root>
+ {children}
+ </CrumbSwitcher>
);
};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.test.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.test.tsx
new file mode 100644
index 00000000000..f4d2a61df0e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.test.tsx
@@ -0,0 +1,215 @@
+/*!
+ * 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 { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { DagRunService } from "openapi/requests/services.gen";
+import type { DAGRunResponse } from "openapi/requests/types.gen";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+import type { DagRunSearchOption } from "src/utils/option";
+
+import { SearchDagRuns } from "./SearchDagRuns";
+
+const { searched } = vi.hoisted<{ searched: { current: Array<DagRunSearchOption> } }>(() => ({
+ searched: { current: [] },
+}));
+
+// Stands in for the real combo box: it lists whatever the panel was handed without a search, and
+// offers one button that runs a search, which is the split this component is built around.
+vi.mock("chakra-react-select", () => ({
+ AsyncSelect: ({
+ defaultOptions,
+ loadOptions,
+ onChange,
+ }: {
+ readonly defaultOptions: Array<DagRunSearchOption> | true;
+ readonly loadOptions: (input: string, callback: (options: Array<DagRunSearchOption>) => void) => void;
+ readonly onChange: (option: DagRunSearchOption) => void;
+ }) => (
+ <>
+ {(Array.isArray(defaultOptions) ? defaultOptions : []).map((option) => (
+ <button key={option.value} onClick={() => onChange(option)} type="button">
+ {`${option.label} (${option.state})`}
+ </button>
+ ))}
+ <button
+ onClick={() =>
+ loadOptions("older", (options) => {
+ searched.current = options;
+ })
+ }
+ type="button"
+ >
+ Search Runs
+ </button>
+ </>
+ ),
+}));
+
+const onClose = vi.fn();
+
+const LocationDisplay = () => <output data-testid="location">{useLocation().pathname}</output>;
+
+const renderSearch = (initialEntry: string, route: string, isMapped = false) =>
+ render(
+ <BaseWrapper>
+ <MemoryRouter initialEntries={[initialEntry]}>
+ <Routes>
+ <Route
+ element={
+ <SearchDagRuns
+ dagId="my_dag"
+ isMapped={isMapped}
+ onClose={onClose}
+ runs={[{ label: "run_2", state: "failed", value: "run_2" }]}
+ />
+ }
+ path={route}
+ />
+ </Routes>
+ {/* Outside the route, so it still reports where a switch landed when the destination no
+ longer matches the path the search was opened from. */}
+ <LocationDisplay />
+ </MemoryRouter>
+ </BaseWrapper>,
+ );
+
+const buildRun = (dagRunId: string): DAGRunResponse => ({
+ bundle_version: null,
+ conf: null,
+ dag_display_name: "my_dag",
+ dag_id: "my_dag",
+ dag_run_id: dagRunId,
+ dag_versions: [],
+ data_interval_end: null,
+ data_interval_start: null,
+ duration: null,
+ end_date: null,
+ last_scheduling_decision: null,
+ logical_date: null,
+ note: null,
+ partition_date: null,
+ partition_key: null,
+ queued_at: null,
+ run_after: "2025-01-15T00:00:00Z",
+ run_type: "manual",
+ start_date: null,
+ state: "failed",
+ team_name: null,
+ triggered_by: null,
+ triggering_user_name: null,
+});
+
+/** The listed run, as the mocked combo box renders it. */
+const RUN_2 = "run_2 (failed)";
+
+describe("SearchDagRuns", () => {
+ beforeEach(() => {
+ searched.current = [];
+ vi.spyOn(DagRunService, "getDagRuns").mockResolvedValue({
+ dag_runs: [buildRun("run_2")],
+ total_entries: 1,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("lists the runs it was handed, by run id and state, without asking for any", () => {
+ renderSearch("/dags/my_dag/runs/run_1", "/dags/:dagId/runs/:runId");
+
+ expect(screen.getByRole("button", { name: RUN_2 })).toBeInTheDocument();
+ // Opening the panel is not a reason to go to the server: the level above it already has these.
+ expect(DagRunService.getDagRuns).not.toHaveBeenCalled();
+ });
+
+ it("reaches past the listed runs when searched", async () => {
+ renderSearch("/dags/my_dag/runs/run_1", "/dags/:dagId/runs/:runId");
+
+ fireEvent.click(screen.getByRole("button", { name: "Search Runs" }));
+
+ await waitFor(() =>
+ expect(DagRunService.getDagRuns).toHaveBeenCalledWith(
+ expect.objectContaining({ dagId: "my_dag", runIdPattern: "older" }),
+ ),
+ );
+ expect(searched.current).toEqual([{ label: "run_2", state: "failed", value: "run_2" }]);
+ });
+
+ it("switches to the selected run and closes the panel", () => {
+ renderSearch("/dags/my_dag/runs/run_1", "/dags/:dagId/runs/:runId");
+
+ fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+ expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2");
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("keeps the task in view when switching runs from a task instance", () => {
+ renderSearch("/dags/my_dag/runs/run_1/tasks/task_1", "/dags/:dagId/runs/:runId/tasks/:taskId");
+
+ fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+ expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/task_1");
+ });
+
+ it("lands an expanded task on its list of instances, not on a map index", () => {
+ renderSearch(
+ "/dags/my_dag/runs/run_1/tasks/task_1/mapped/3",
+ "/dags/:dagId/runs/:runId/tasks/:taskId/mapped/:mapIndex",
+ true,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+ // `/tasks/task_1` would be a row the run picked is not guaranteed to have, and renders
+ // "No task instance found".
+ expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/task_1/mapped");
+ });
+
+ it("keeps the open task instance tab across the switch", () => {
+ renderSearch("/dags/my_dag/runs/run_1/tasks/task_1/xcom", "/dags/:dagId/runs/:runId/tasks/:taskId/xcom");
+
+ fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+ expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/task_1/xcom");
+ });
+
+ it("keeps the task group in view when switching runs from one", () => {
+ renderSearch(
+ "/dags/my_dag/runs/run_1/tasks/group/group_1",
+ "/dags/:dagId/runs/:runId/tasks/group/:groupId",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+ expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/group/group_1");
+ });
+
+ it("picks a run from a page that has none selected", () => {
+ renderSearch("/dags/my_dag/tasks/task_1", "/dags/:dagId/tasks/:taskId");
+
+ fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+ expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/task_1");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx
new file mode 100644
index 00000000000..a80bb23340d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx
@@ -0,0 +1,129 @@
+/*!
+ * 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 { Flex, Text } from "@chakra-ui/react";
+import { useQueryClient } from "@tanstack/react-query";
+import type { GroupBase, OptionsOrGroups, SingleValue } from "chakra-react-select";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { useDebouncedCallback } from "use-debounce";
+
+import { UseDagRunServiceGetDagRunsKeyFn } from "openapi/queries";
+import { DagRunService } from "openapi/requests/services.gen";
+import type { DAGRunCollectionResponse } from "openapi/requests/types.gen";
+
+import { SearchSelect } from "src/components/SearchSelect";
+import { StateBadge } from "src/components/StateBadge";
+
+import { buildTaskInstanceUrl } from "src/utils/links";
+import type { DagRunSearchOption } from "src/utils/option";
+
+import { NEWEST_FIRST, SEARCH_LIMIT, buildDagRunOption } from "./searchOptions";
+
+const formatOptionLabel = (option: DagRunSearchOption) => (
+ <Flex alignItems="center" gap={2} minW={0}>
+ <StateBadge flexShrink={0} state={option.state} />
+ <Text truncate>{option.label}</Text>
+ </Flex>
+);
+
+export const SearchDagRuns = ({
+ dagId,
+ isMapped,
+ onClose,
+ runs,
+}: {
+ readonly dagId: string;
+ /** Whether the task in view has expanded instances, so the switch lands on its list, not a row. */
+ readonly isMapped: boolean;
+ readonly onClose: () => void;
+ /** Already loaded by the breadcrumb level, so opening the panel shows them straight away. */
+ readonly runs: Array<DagRunSearchOption>;
+}) => {
+ const { t: translate } = useTranslation(["dags", "common"]);
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const { groupId, taskId } = useParams();
+ const { pathname } = useLocation();
+
+ // The task in view carries across the switch, through the same builder the grid and keyboard
+ // navigation use: it keeps the open tab, and sends an expanded task to its list of instances
+ // rather than to a map index that the run picked is not guaranteed to have.
+ const onSelect = (selected: SingleValue<DagRunSearchOption>) => {
+ if (!selected) {
+ return;
+ }
+
+ const target =
+ groupId === undefined && taskId === undefined
+ ? `/dags/${dagId}/runs/${selected.value}`
+ : buildTaskInstanceUrl({
+ currentPathname: pathname,
+ dagId,
+ isGroup: groupId !== undefined,
+ isMapped,
+ runId: selected.value,
+ taskId: groupId ?? taskId ?? "",
+ });
+
+ onClose();
+ void Promise.resolve(navigate(target));
+ };
+
+ const searchDagRunsDebounced = useDebouncedCallback(
+ (
+ inputValue: string,
+ callback: (options: OptionsOrGroups<DagRunSearchOption, GroupBase<DagRunSearchOption>>) => void,
+ ) => {
+ void queryClient.fetchQuery({
+ queryFn: () =>
+ DagRunService.getDagRuns({
+ dagId,
+ limit: SEARCH_LIMIT,
+ orderBy: NEWEST_FIRST,
+ runIdPattern: inputValue,
+ }).then((matches: DAGRunCollectionResponse) => {
+ const options = matches.dag_runs.map(buildDagRunOption);
+
+ callback(options);
+
+ return options;
+ }),
+ queryKey: UseDagRunServiceGetDagRunsKeyFn({
+ dagId,
+ limit: SEARCH_LIMIT,
+ orderBy: NEWEST_FIRST,
+ runIdPattern: inputValue,
+ }),
+ staleTime: 0,
+ });
+ },
+ 300,
+ );
+
+ return (
+ <SearchSelect
+ defaultOptions={runs}
+ formatOptionLabel={formatOptionLabel}
+ loadingMessage={translate("common:loading")}
+ loadOptions={searchDagRunsDebounced}
+ onChange={onSelect}
+ placeholder={translate("search.dagRuns")}
+ />
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
similarity index 99%
rename from airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx
rename to airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
index b427fdd6ebf..af4371a5875 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
@@ -103,7 +103,7 @@ const renderSearch = ({
render(
<BaseWrapper>
<MemoryRouter initialEntries={[initialEntry]}>
- <SearchDags onClose={onClose} />
+ <SearchDags dags={[]} onClose={onClose} />
<LocationDisplay />
</MemoryRouter>
</BaseWrapper>,
diff --git a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
similarity index 60%
rename from airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx
rename to airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
index 6130a2e8b21..257493c7a0d 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
@@ -16,28 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Field, Flex, Text } from "@chakra-ui/react";
+import { Flex, Text } from "@chakra-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import type { GroupBase, OptionsOrGroups, SingleValue } from "chakra-react-select";
-import { AsyncSelect } from "chakra-react-select";
import { useTranslation } from "react-i18next";
import { useMatches, useNavigate } from "react-router-dom";
import { useDebouncedCallback } from "use-debounce";
import { UseDagServiceGetDagsUiKeyFn } from "openapi/queries";
import { DagService } from "openapi/requests/services.gen";
-import type {
- DAGWithLatestDagRunsCollectionResponse,
- DAGWithLatestDagRunsResponse,
-} from "openapi/requests/types.gen";
+import type { DAGWithLatestDagRunsCollectionResponse } from "openapi/requests/types.gen";
+import { SearchSelect } from "src/components/SearchSelect";
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 { Control } from "./SearchDagsControl";
+import { SEARCH_LIMIT, buildDagOption } from "./searchOptions";
const formatOptionLabel = (option: DagSearchOption) => (
<Flex alignItems="center" gap={2} minW={0}>
@@ -46,12 +43,18 @@ const formatOptionLabel = (option: DagSearchOption) => (
</Flex>
);
-export const SearchDags = ({ onClose }: { readonly onClose: () => void }) => {
- const { t: translate } = useTranslation("dags");
+export const SearchDags = ({
+ dags,
+ onClose,
+}: {
+ /** Already loaded by the breadcrumb level, so opening the panel shows them straight away. */
+ readonly dags: Array<DagSearchOption>;
+ readonly onClose: () => void;
+}) => {
+ const { t: translate } = useTranslation(["dags", "common"]);
const queryClient = useQueryClient();
const matches = useMatches();
const navigate = useNavigate();
- const SEARCH_LIMIT = 10;
const onSelect = (selected: SingleValue<DagSearchOption>) => {
if (selected) {
@@ -74,13 +77,8 @@ export const SearchDags = ({ onClose }: { readonly onClose: () => void }) => {
dagDisplayNamePrefixPattern: inputValue,
dagRunsLimit: 1,
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,
- }));
+ }).then((found: DAGWithLatestDagRunsCollectionResponse) => {
+ const options = found.dags.map(buildDagOption);
callback(options);
@@ -97,32 +95,13 @@ export const SearchDags = ({ onClose }: { readonly onClose: () => void }) => {
);
return (
- <Field.Root>
- <AsyncSelect
- backspaceRemovesValue={true}
- // The popover is the card. Drop the floating menu's own positioning and chrome so the
- // results flow inside it directly under the input, instead of reading as a second card.
- chakraStyles={{
- menu: () => ({ marginTop: 2, width: "100%" }),
- menuList: (provided) => ({
- ...provided,
- background: "transparent",
- borderRadius: 0,
- boxShadow: "none",
- paddingInline: 0,
- zIndex: "auto",
- }),
- }}
- components={{ Control, DropdownIndicator: null }}
- defaultOptions
- filterOption={undefined}
- formatOptionLabel={formatOptionLabel}
- loadOptions={searchDagDebounced}
- menuIsOpen
- onChange={onSelect}
- placeholder={translate("search.dags")}
- value={null} // null is required https://github.com/JedWatson/react-select/issues/3066
- />
- </Field.Root>
+ <SearchSelect
+ defaultOptions={dags}
+ formatOptionLabel={formatOptionLabel}
+ loadingMessage={translate("common:loading")}
+ loadOptions={searchDagDebounced}
+ onChange={onSelect}
+ placeholder={translate("search.dags")}
+ />
);
};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.test.ts b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.test.ts
new file mode 100644
index 00000000000..4a47f818c8f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.test.ts
@@ -0,0 +1,50 @@
+/*!
+ * 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 type { DAGRunResponse, DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
+
+import { buildDagOption, buildDagRunOption } from "./searchOptions";
+
+const dagRun = { dag_run_id: "run_1", state: "success" } as DAGRunResponse;
+const dag = {
+ dag_display_name: "My Dag",
+ dag_id: "my_dag",
+ latest_dag_runs: [],
+} as unknown as DAGWithLatestDagRunsResponse;
+
+describe("searchOptions", () => {
+ // react-select keeps keyboard focus only while the focused option is still in `options` by
+ // reference. TanStack hands back the same row object for an unchanged row, so an option built
+ // from it has to come back unchanged too, or arrowing through the list resets on every poll.
+ it("gives a run row the same option object every time", () => {
+ expect(buildDagRunOption(dagRun)).toBe(buildDagRunOption(dagRun));
+ });
+
+ it("gives a Dag row the same option object every time", () => {
+ expect(buildDagOption(dag)).toBe(buildDagOption(dag));
+ });
+
+ it("builds a fresh option for a row that changed", () => {
+ const changed = { ...dagRun, state: "failed" } as DAGRunResponse;
+
+ expect(buildDagRunOption(changed)).not.toBe(buildDagRunOption(dagRun));
+ expect(buildDagRunOption(changed).state).toBe("failed");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts
new file mode 100644
index 00000000000..71e5c07d9a5
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts
@@ -0,0 +1,66 @@
+/*!
+ * 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 type { DAGRunResponse, DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
+
+import type { DagRunSearchOption, DagSearchOption } from "src/utils/option";
+
+/** How many Dags or runs a breadcrumb search offers before anything is typed, and finds per search. */
+export const SEARCH_LIMIT = 10;
+
+export const NEWEST_FIRST = ["-run_after"];
+
+// react-select keeps keyboard focus only while the focused option is still in `options` by
+// reference, so rebuilding every option on each poll would drop the user back to the first row
+// mid-arrow-key. TanStack hands back the same row object for a row that did not change, so
+// caching against that object keeps the options for unchanged rows stable too.
+const dagOptions = new WeakMap<DAGWithLatestDagRunsResponse, DagSearchOption>();
+const dagRunOptions = new WeakMap<DAGRunResponse, DagRunSearchOption>();
+
+export const buildDagOption = (dag: DAGWithLatestDagRunsResponse): DagSearchOption => {
+ const cached = dagOptions.get(dag);
+
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const option = {
+ isBackfillable: dag.is_backfillable,
+ label: dag.dag_display_name || dag.dag_id,
+ state: dag.latest_dag_runs[0]?.state ?? null,
+ value: dag.dag_id,
+ };
+
+ dagOptions.set(dag, option);
+
+ return option;
+};
+
+export const buildDagRunOption = (dagRun: DAGRunResponse): DagRunSearchOption => {
+ const cached = dagRunOptions.get(dagRun);
+
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const option = { label: dagRun.dag_run_id, state: dagRun.state, value: dagRun.dag_run_id };
+
+ dagRunOptions.set(dagRun, option);
+
+ return option;
+};
diff --git a/airflow-core/src/airflow/ui/src/utils/option.ts b/airflow-core/src/airflow/ui/src/utils/option.ts
index ed9f463f161..526fe3f39e7 100644
--- a/airflow-core/src/airflow/ui/src/utils/option.ts
+++ b/airflow-core/src/airflow/ui/src/utils/option.ts
@@ -24,6 +24,10 @@ export type Option = {
readonly value: string;
};
+export type DagRunSearchOption = {
+ readonly state: DagRunState;
+} & Option;
+
export type DagSearchOption = {
readonly isBackfillable: boolean;
readonly state: DagRunState | null;