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 <[email protected]>
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;