This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new a219bf5ac8a Add Map Index search to dag breadcrumb (#74763) (#74893)
a219bf5ac8a is described below
commit a219bf5ac8a5c769ae51f9a2285adda32b677b92
Author: Brent Bovenzi <[email protected]>
AuthorDate: Sat Oct 17 09:22:52 2026 -0400
Add Map Index search to dag breadcrumb (#74763) (#74893)
* UI: Search Dag names anywhere, and switch map index from the breadcrumb
The Dag search only ever matched the start of a name, which is the wrong
default
for anyone who names Dags by team or domain and remembers the end of the
name
rather than its beginning. The Dags list already offers the choice, so the
breadcrumb now offers the same one, on its own key: turning it on here is
not a
reason to change what that page does.
An expanded task's instances were the one level of the breadcrumb that named
where you were without letting you go anywhere else from it, even though a
task
with hundreds of instances is exactly where moving between them by hand is
worst.
* UI: Keep the map index level on an expanded task's summary
The level only appeared once an instance was picked, so the one page listing
every instance of a task was the one page with no way to jump to one. It now
stands on "all map indexes" there, the way the run level stands on "all
runs".
* UI: Re-run a breadcrumb search when the match changes, not only the text
Turning match-anywhere on left the results from the prefix search on screen
until
the text was edited, which read as the option doing nothing. react-select's
async
mode only reloads from its own input handler, so nothing outside the text
could
ask it for a new answer.
The caller now owns the input and what is listed for it, which is also what
lets
a search in flight leave the previous answer up rather than emptying the
panel,
and makes the "no results" case say so.
The panel width that was added here in passing is now upstream, so this
uses that
instead.
* UI: Bound the map index search to the expansion set the page is in
A looped task is expanded once per iteration, so the same map indexes exist
several times over in a run. Listing them unscoped offered the user two rows
called "0" and two called "1", and picking one led to a path that could not
say
which of them was meant.
A task's expansions share one region and a loop mints one per iteration, so
the
region alone bounds the list to the instances the page is actually among —
pinning the index as well would narrow it to the single instance already on
screen. Each option carries its own coordinate so the link names an instance
rather than a map index.
* UI: Offer every map index once, and seat the match toggle in the input
The map index level had been bounded to one pass, which the iteration level
already decides; all this level owes the user is each index once, whichever
pass
expanded it. The row kept for an index is the one from the pass on screen,
so
moving between indexes stays where the user is rather than quietly moving
them.
The match-anywhere control also sat as a loose button beside the text
instead of
filling the end of the input, which is where the Dags list puts the same
control.
* UI: Stop the map index level navigating, and offer the whole expansion in
its panel
Clicking the level led to the page it was already on, and got there without
the
region the url carries, so a looped task arrived unable to say which pass it
meant. Nothing was gained for it to cost that, so the naming half no longer
navigates.
Leaving the expansion was the one trip that click was good for, so the
panel now
offers it: it keeps the pass and lets go only of the instance within it.
Backport notes: v3-4-test has no iteration level in the breadcrumb, so the
loop
pass crumb is left out and only the map index level is backported. The panel
width prop the original relied on came from a later main-only change to
CrumbSwitcher and is carried along here, since the map index panel sizes
itself
to a map index rather than a run id.
(cherry picked from commit 028a4da8672d22e4467359467eb77bf0c339bc24)
Co-authored-by: Claude <[email protected]>
---
.../airflow/ui/public/i18n/locales/en/common.json | 1 +
.../src/airflow/ui/public/i18n/locales/en/dag.json | 1 +
.../airflow/ui/public/i18n/locales/en/dags.json | 1 +
.../airflow/ui/src/components/Breadcrumb/Crumb.tsx | 22 ++-
.../ui/src/components/Breadcrumb/CrumbSwitcher.tsx | 36 +++-
.../src/airflow/ui/src/components/SearchSelect.tsx | 113 ++++++------
.../ui/src/layouts/Details/DagBreadcrumb.test.tsx | 29 +++-
.../ui/src/layouts/Details/DagBreadcrumb.tsx | 32 +++-
.../src/layouts/Details/MapIndexSwitcherButton.tsx | 98 +++++++++++
.../ui/src/layouts/Details/SearchDagRuns.test.tsx | 38 ++--
.../ui/src/layouts/Details/SearchDagRuns.tsx | 49 +++---
.../ui/src/layouts/Details/SearchDags.test.tsx | 135 ++++++++++-----
.../airflow/ui/src/layouts/Details/SearchDags.tsx | 74 +++++---
.../src/layouts/Details/SearchMapIndexes.test.tsx | 192 +++++++++++++++++++++
.../ui/src/layouts/Details/SearchMapIndexes.tsx | 168 ++++++++++++++++++
.../ui/src/layouts/Details/searchOptions.test.ts | 41 ++++-
.../ui/src/layouts/Details/searchOptions.ts | 59 ++++++-
airflow-core/src/airflow/ui/src/utils/option.ts | 10 +-
18 files changed, 912 insertions(+), 187 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 745a8b54a79..fe349c645b5 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
@@ -421,6 +421,7 @@
},
"switchDag": "Switch $t(dag_one)",
"switchDagRun": "Switch $t(dagRun_one)",
+ "switchMapIndex": "Switch $t(mapIndex)",
"table": {
"completedAt": "Completed at",
"createdAt": "Created at",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index a5d0da02be8..2df5f48e96f 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -1,4 +1,5 @@
{
+ "allMapIndexes": "All Map Indexes",
"allRuns": "All Runs",
"blockingDeps": {
"dependency": "Dependency",
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 6771603141f..0973f824765 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
@@ -133,6 +133,7 @@
"dagRuns": "Search $t(common:dagRun_other)",
"dags": "Search $t(common:dag_other)",
"hotkey": "+K",
+ "mapIndexes": "Search $t(common:mapIndex)",
"tasks": "Search $t(common:task_other)"
},
"sort": {
diff --git a/airflow-core/src/airflow/ui/src/components/Breadcrumb/Crumb.tsx
b/airflow-core/src/airflow/ui/src/components/Breadcrumb/Crumb.tsx
index dde21d29577..dff07c5133d 100644
--- a/airflow-core/src/airflow/ui/src/components/Breadcrumb/Crumb.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Breadcrumb/Crumb.tsx
@@ -18,7 +18,7 @@
*/
import { type ReactNode, forwardRef } from "react";
-import { Box, Button, type ButtonProps, HStack, type StackProps } from
"@chakra-ui/react";
+import { Box, type BoxProps, Button, type ButtonProps, HStack, type StackProps
} from "@chakra-ui/react";
import { Link } from "react-router-dom";
import type { TaskInstanceState } from "openapi/requests/types.gen";
@@ -98,6 +98,26 @@ export const CrumbLink = ({
</Button>
);
+/**
+ * The naming half of a switcher whose level has nowhere to send you. It
matches `CrumbLink`'s
+ * layout so the two halves sit the same, without the hover or the focus a
link would offer for a
+ * trip that would only reload the page the user is on.
+ */
+export const CrumbStatic = ({ children, ...rest }: { readonly children:
ReactNode } & BoxProps) => (
+ <Box
+ alignItems="flex-start"
+ display="flex"
+ flexDirection="column"
+ gap={0}
+ justifyContent="center"
+ py={1.5}
+ {...getWedgePadding({ hasNotch: false, hasPoint: false })}
+ {...rest}
+ >
+ {children}
+ </Box>
+);
+
/** The level the page is already on: it goes nowhere, so it is not a button.
*/
export const CrumbText = ({
children,
diff --git
a/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
b/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
index 573c893e7a3..05703225b1b 100644
--- a/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
@@ -18,16 +18,18 @@
*/
import type { ReactNode } from "react";
-import { Box } from "@chakra-ui/react";
+import { Box, type BoxProps } 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 { CrumbDivider, CrumbGroup, CrumbLink, CrumbStatic } from "./Crumb";
import { CUT_PADDING, type CrumbShape, crumbButtonStyles, getWedgePadding }
from "./segment";
type Props = {
readonly children: ReactNode;
+ /** Width of the panel. The default suits a search; a short fixed list wants
to shrink to fit. */
+ readonly contentWidth?: BoxProps["width"];
/** Names the chevron for the tooltip and for assistive technology. */
readonly label: string;
readonly onOpenChange: (open: boolean) => void;
@@ -35,16 +37,28 @@ type Props = {
readonly search: ReactNode;
readonly shape: CrumbShape;
readonly testId: string;
- readonly to: string;
+ /** Where the naming half goes. Omitted for a level whose only destination
is the page it is on. */
+ 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
+ * names the level (and navigates to it, where that leads anywhere) 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) => (
+export const CrumbSwitcher = ({
+ children,
+ contentWidth = "sm",
+ label,
+ onOpenChange,
+ open,
+ search,
+ shape,
+ testId,
+ to,
+}: Props) => (
<Popover.Root
lazyMount
onOpenChange={(event) => onOpenChange(event.open)}
@@ -56,9 +70,13 @@ export const CrumbSwitcher = ({ children, label,
onOpenChange, open, search, sha
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>
+ {to === undefined ? (
+ <CrumbStatic paddingInlineEnd={CUT_PADDING}>{children}</CrumbStatic>
+ ) : (
+ <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. */}
@@ -78,7 +96,7 @@ export const CrumbSwitcher = ({ children, label,
onOpenChange, open, search, sha
</Tooltip>
</CrumbGroup>
</Popover.Anchor>
- <Popover.Content data-testid={`${testId}-popover`} width="sm">
+ <Popover.Content data-testid={`${testId}-popover`} width={contentWidth}>
<Box p={2}>{search}</Box>
</Popover.Content>
</Popover.Root>
diff --git a/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx
b/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx
index 61b4e2fdfef..408448edecc 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx
@@ -16,22 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { ReactNode } from "react";
+import { type ReactNode, createContext, use } 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 type { ChakraStylesConfig, ControlProps, GroupBase, SingleValue } from
"chakra-react-select";
+import { Select, chakraComponents } from "chakra-react-select";
+import { useTranslation } from "react-i18next";
import { FiSearch } from "react-icons/fi";
+// react-select builds its components from a stable map, so anything a caller
wants inside the
+// control has to reach it some other way than a prop: rebuilding the control
to close over one
+// would remount the input on every render and drop focus mid-keystroke.
+const ControlEndContext = createContext<ReactNode>(undefined);
+
/**
- * 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.
+ * Leads the input with the search affordance and closes it with whatever the
caller put at the end.
+ * 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}>
@@ -39,49 +40,49 @@ const SearchControl = <Option,>({ children, ...props }:
ControlProps<Option, fal
<FiSearch />
</Box>
{children}
+ {use(ControlEndContext)}
</chakraComponents.Control>
);
-/** `DropdownIndicator` accepts `null` to opt out; `LoadingIndicator` only
accepts a component. */
-const NoIndicator = () => null;
+/** The server decides what matches, so react-select must not filter the
answer again. */
+const keepEveryOption = () => true;
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>;
+ /** Rendered at the end of the input, where an addon sits on a search field
elsewhere. */
+ readonly controlEnd?: ReactNode;
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 inputValue: string;
readonly onChange: (selected: SingleValue<Option>) => void;
+ readonly onInputChange: (value: string) => void;
+ readonly options: Array<Option>;
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.
+ * The search a breadcrumb switcher drops open.
+ *
+ * The caller owns the input and what is listed for it, rather than
react-select's async mode
+ * owning both: that only reloads from its own input handler, so a search
could not be re-run when
+ * something other than the text changed it — such as turning match-anywhere
on. Holding the text
+ * here also means the options on screen are whatever the caller last had, so
a search in flight
+ * leaves the previous answer up instead of emptying the panel.
*/
export const SearchSelect = <Option,>({
- defaultOptions,
+ controlEnd,
formatOptionLabel,
- loadingMessage,
- loadOptions,
+ inputValue,
onChange,
+ onInputChange,
+ options,
placeholder,
}: Props<Option>) => {
+ const { t: translate } = useTranslation();
// 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>> = {
+ // Whatever the caller puts at the end of the control is an addon: it
fills the control's
+ // height and reaches its edge, so the control itself gives up its end
padding.
+ control: (provided) => ({ ...provided, overflow: "hidden",
paddingInlineEnd: 0 }),
menu: () => ({ marginTop: 2, width: "100%" }),
menuList: (provided) => ({
...provided,
@@ -94,24 +95,30 @@ export const SearchSelect = <Option,>({
};
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>
+ <ControlEndContext value={controlEnd}>
+ <Field.Root>
+ <Select<Option>
+ backspaceRemovesValue={true}
+ chakraStyles={chakraStyles}
+ components={{ Control: SearchControl, DropdownIndicator: null }}
+ filterOption={keepEveryOption}
+ formatOptionLabel={formatOptionLabel}
+ inputValue={inputValue}
+ menuIsOpen
+ noOptionsMessage={() => translate("table.noResultsFound")}
+ onChange={onChange}
+ // Only a keystroke is a new search; react-select also reports blur
and menu close here,
+ // which would otherwise wipe the text while the panel is still open.
+ onInputChange={(value, { action }) => {
+ if (action === "input-change") {
+ onInputChange(value);
+ }
+ }}
+ options={options}
+ placeholder={placeholder}
+ value={null} // null is required
https://github.com/JedWatson/react-select/issues/3066
+ />
+ </Field.Root>
+ </ControlEndContext>
);
};
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 f104faf5cba..180c55222f0 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
@@ -189,6 +189,30 @@ describe("DagBreadcrumb", () => {
);
});
+ it("offers the instance search at the map index level", async () => {
+ render(<DagBreadcrumb />, {
+ wrapper: createWrapper(
+ `/dags/${DAG_ID}/runs/run_1/tasks/task_1/mapped/0`,
+ "/dags/:dagId/runs/:runId/tasks/:taskId/mapped/:mapIndex",
+ ),
+ });
+
+ expect(await screen.findByTestId("switch-map-index")).toBeInTheDocument();
+ });
+
+ it("keeps the map index level on the expanded task's summary, so an instance
is one click away", async () => {
+ render(<DagBreadcrumb />, {
+ wrapper: createWrapper(
+ `/dags/${DAG_ID}/runs/run_1/tasks/task_1/mapped`,
+ "/dags/:dagId/runs/:runId/tasks/:taskId/mapped",
+ ),
+ });
+
+ // No instance is picked here, so the level stands on "all" rather than
dropping off the bar.
+ expect(await screen.findByText("allMapIndexes")).toBeInTheDocument();
+ expect(screen.getByTestId("switch-map-index")).toBeInTheDocument();
+ });
+
it("renders one level per URL segment, with only the current one unlinked",
async () => {
render(<DagBreadcrumb />, {
wrapper: createWrapper(
@@ -215,8 +239,9 @@ describe("DagBreadcrumb", () => {
"href",
`/dags/${DAG_ID}/runs/run_1/tasks/task_1/mapped`,
);
- // The map index is the page's own level, so it is text rather than a link.
- expect(screen.queryByRole("link", { name: "0" })).not.toBeInTheDocument();
+ // The map index names itself but is not a link: every destination it
could have is this page,
+ // and the trip would drop the region the url carries.
+ expect(screen.queryByRole("link", { name: /mapIndex/u
})).not.toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
});
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 63c4284bba9..ddfa55ed24b 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
@@ -35,6 +35,7 @@ import { isStatePending, useAutoRefresh } from "src/utils";
import { DagRunSwitcherButton } from "./DagRunSwitcherButton";
import { DagSwitcherButton } from "./DagSwitcherButton";
+import { MapIndexSwitcherButton } from "./MapIndexSwitcherButton";
/** Levels that stand for a Dag run — both offer the run search. */
const RUN_LEVEL_KEYS = new Set(["allRuns", "dagRun"]);
@@ -88,6 +89,14 @@ const BreadcrumbItem = ({
);
}
+ if (crumb.key === "mapIndex") {
+ return (
+ <MapIndexSwitcherButton dagId={dagId} shape={shape}>
+ {content}
+ </MapIndexSwitcherButton>
+ );
+ }
+
if (isLast || crumb.to === undefined) {
return <CrumbText shape={shape}>{content}</CrumbText>;
}
@@ -214,14 +223,21 @@ export const DagBreadcrumb = () => {
});
}
- if (mapIndex !== "-1") {
- crumbs.push({
- caption: translate("mapIndex"),
- hasState: true,
- key: "mapIndex",
- state: mappedTaskInstance?.state,
- value: mappedTaskInstance?.rendered_map_index ?? mapIndex,
- });
+ // The map index level stays on the bar for the whole of an expanded task,
standing on "all
+ // instances" when none is picked, so the switch between instances is in the
same place whether
+ // you are looking at one of them or at the summary of all of them.
+ if (hasExpandedInstances && runId !== undefined && taskId !== undefined) {
+ crumbs.push(
+ mapIndex === "-1"
+ ? { caption: translate("mapIndex"), key: "mapIndex", value:
translate("dag:allMapIndexes") }
+ : {
+ caption: translate("mapIndex"),
+ hasState: true,
+ key: "mapIndex",
+ state: mappedTaskInstance?.state,
+ value: mappedTaskInstance?.rendered_map_index ?? mapIndex,
+ },
+ );
}
return (
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/MapIndexSwitcherButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/MapIndexSwitcherButton.tsx
new file mode 100644
index 00000000000..fd5777143f0
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/MapIndexSwitcherButton.tsx
@@ -0,0 +1,98 @@
+/*!
+ * 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 { useParams } from "react-router-dom";
+
+import { useTaskInstanceServiceGetMappedTaskInstances } from "openapi/queries";
+
+import { CrumbSwitcher, type CrumbShape } from "src/components/Breadcrumb";
+
+import { useTaskInstanceCoordinates } from
"src/hooks/useTaskInstanceCoordinates";
+import { useAutoRefresh } from "src/utils";
+import type { MapIndexSearchOption } from "src/utils/option";
+
+import { SearchMapIndexes } from "./SearchMapIndexes";
+import { BY_MAP_INDEX, SEARCH_LIMIT, buildMapIndexOptions } from
"./searchOptions";
+
+const NO_INSTANCES: Array<MapIndexSearchOption> = [];
+
+type Props = {
+ readonly children: ReactNode;
+ readonly dagId: string;
+ readonly shape: CrumbShape;
+};
+
+/**
+ * The map index level of the breadcrumb, with a search over the task's
expanded instances behind
+ * its chevron. Like the run level, what the panel lists is loaded here so
that opening it shows
+ * the instances straight away, and polling is held to while it is open.
+ *
+ * The naming half goes nowhere: every destination it could have is the page
already on screen,
+ * and the link would reach it without the region the url carries, which a
looped task needs to
+ * say which pass it means.
+ */
+export const MapIndexSwitcherButton = ({ children, dagId, shape }: Props) => {
+ const { t: translate } = useTranslation();
+ const { runId = "", taskId = "" } = useParams();
+ // Not a filter: the iteration level decides which pass, so every index is
offered whatever pass
+ // it came from. This only says which pass to keep a row from when an index
spans several.
+ const { regionId } = useTaskInstanceCoordinates();
+ const [open, setOpen] = useState(false);
+ const refetchInterval = useAutoRefresh({ dagId });
+ const { data, refetch } = useTaskInstanceServiceGetMappedTaskInstances(
+ { dagId, dagRunId: runId, limit: SEARCH_LIMIT, orderBy: BY_MAP_INDEX,
taskId },
+ undefined,
+ { refetchInterval: open && refetchInterval },
+ );
+ const instances = data === undefined ? NO_INSTANCES :
buildMapIndexOptions(data.task_instances, regionId);
+
+ const onOpenChange = (next: boolean) => {
+ setOpen(next);
+
+ if (next) {
+ void refetch();
+ }
+ };
+
+ return (
+ <CrumbSwitcher
+ // A rendered map index and its badge are a fraction of a run id; the
default suits those.
+ contentWidth="3xs"
+ label={translate("switchMapIndex")}
+ onOpenChange={onOpenChange}
+ open={open}
+ search={
+ <SearchMapIndexes
+ currentRegionId={regionId}
+ dagId={dagId}
+ instances={instances}
+ onClose={() => setOpen(false)}
+ runId={runId}
+ taskId={taskId}
+ />
+ }
+ shape={shape}
+ testId="switch-map-index"
+ >
+ {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
index f4d2a61df0e..1f4f2867312 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.test.tsx
@@ -28,38 +28,31 @@ 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,
+ Select: ({
+ inputValue,
onChange,
+ onInputChange,
+ options,
}: {
- readonly defaultOptions: Array<DagRunSearchOption> | true;
- readonly loadOptions: (input: string, callback: (options:
Array<DagRunSearchOption>) => void) => void;
+ readonly inputValue: string;
readonly onChange: (option: DagRunSearchOption) => void;
+ readonly onInputChange: (value: string, meta: { action: string }) => void;
+ readonly options: Array<DagRunSearchOption>;
}) => (
<>
- {(Array.isArray(defaultOptions) ? defaultOptions : []).map((option) => (
+ <input
+ aria-label="Search"
+ onChange={(event) => onInputChange(event.target.value, { action:
"input-change" })}
+ value={inputValue}
+ />
+ {options.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>
</>
),
}));
@@ -123,7 +116,6 @@ const RUN_2 = "run_2 (failed)";
describe("SearchDagRuns", () => {
beforeEach(() => {
- searched.current = [];
vi.spyOn(DagRunService, "getDagRuns").mockResolvedValue({
dag_runs: [buildRun("run_2")],
total_entries: 1,
@@ -145,14 +137,14 @@ describe("SearchDagRuns", () => {
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" }));
+ fireEvent.change(screen.getByLabelText("Search"), { target: { value:
"older" } });
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" }]);
+ expect(await screen.findByRole("button", { name: "run_2 (failed)"
})).toBeInTheDocument();
});
it("switches to the selected run and closes the panel", () => {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx
index a80bb23340d..eac3d19113c 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx
@@ -16,9 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { useState } from "react";
+
import { Flex, Text } from "@chakra-ui/react";
import { useQueryClient } from "@tanstack/react-query";
-import type { GroupBase, OptionsOrGroups, SingleValue } from
"chakra-react-select";
+import type { SingleValue } from "chakra-react-select";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useDebouncedCallback } from "use-debounce";
@@ -56,6 +58,8 @@ export const SearchDagRuns = ({
readonly runs: Array<DagRunSearchOption>;
}) => {
const { t: translate } = useTranslation(["dags", "common"]);
+ const [text, setText] = useState("");
+ const [found, setFound] = useState<Array<DagRunSearchOption>>();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { groupId, taskId } = useParams();
@@ -85,44 +89,45 @@ export const SearchDagRuns = ({
void Promise.resolve(navigate(target));
};
- const searchDagRunsDebounced = useDebouncedCallback(
- (
- inputValue: string,
- callback: (options: OptionsOrGroups<DagRunSearchOption,
GroupBase<DagRunSearchOption>>) => void,
- ) => {
- void queryClient.fetchQuery({
+ const search = useDebouncedCallback((value: string) => {
+ 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;
+ runIdPattern: value,
}),
queryKey: UseDagRunServiceGetDagRunsKeyFn({
dagId,
limit: SEARCH_LIMIT,
orderBy: NEWEST_FIRST,
- runIdPattern: inputValue,
+ runIdPattern: value,
}),
staleTime: 0,
- });
- },
- 300,
- );
+ })
+ .then((matched: DAGRunCollectionResponse) =>
setFound(matched.dag_runs.map(buildDagRunOption)));
+ }, 300);
+
+ const onInputChange = (value: string) => {
+ setText(value);
+
+ if (value === "") {
+ search.cancel();
+ setFound(undefined);
+ } else {
+ search(value);
+ }
+ };
return (
<SearchSelect
- defaultOptions={runs}
formatOptionLabel={formatOptionLabel}
- loadingMessage={translate("common:loading")}
- loadOptions={searchDagRunsDebounced}
+ inputValue={text}
onChange={onSelect}
+ onInputChange={onInputChange}
+ options={text === "" ? runs : (found ?? runs)}
placeholder={translate("search.dagRuns")}
/>
);
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
index af4371a5875..dc73c800595 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+import type { ReactNode } from "react";
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, useLocation, useMatches, useNavigate } from
"react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -35,43 +37,37 @@ vi.mock("react-router-dom", async (importOriginal) => {
return { ...(original as object), useMatches: vi.fn() };
});
-const { loadedOption, selectedOption } = vi.hoisted<{
- loadedOption: { current: DagSearchOption | undefined };
- selectedOption: { current: DagSearchOption };
-}>(() => ({
- loadedOption: { current: undefined },
- selectedOption: {
- current: {
- isBackfillable: true,
- label: "New Dag",
- state: null,
- value: "new_dag",
- },
- },
-}));
+const NEW_DAG: DagSearchOption = { isBackfillable: true, label: "New Dag",
state: null, value: "new_dag" };
+// Renders the pieces this component actually drives: the control (which
carries whatever the
+// caller put at its end, such as the match-anywhere toggle), the input, and
the listed options.
vi.mock("chakra-react-select", () => ({
- AsyncSelect: ({
- loadOptions,
+ chakraComponents: { Control: ({ children }: { readonly children: ReactNode
}) => <div>{children}</div> },
+ Select: ({
+ components,
+ inputValue,
onChange,
+ onInputChange,
+ options,
}: {
- readonly loadOptions: (input: string, callback: (options:
Array<DagSearchOption>) => void) => void;
+ readonly components: { Control: (props: { readonly children: ReactNode })
=> ReactNode };
+ readonly inputValue: string;
readonly onChange: (option: DagSearchOption) => void;
+ readonly onInputChange: (value: string, meta: { action: string }) => void;
+ readonly options: Array<DagSearchOption>;
}) => (
<>
- <button
- onClick={() =>
- loadOptions("", (options) => {
- [loadedOption.current] = options;
- })
- }
- type="button"
- >
- Load Dags
- </button>
- <button onClick={() => onChange(loadedOption.current ??
selectedOption.current)} type="button">
- Select Dag
- </button>
+ <components.Control>{undefined}</components.Control>
+ <input
+ aria-label="Search"
+ onChange={(event) => onInputChange(event.target.value, { action:
"input-change" })}
+ value={inputValue}
+ />
+ {options.map((option) => (
+ <button key={option.value} onClick={() => onChange(option)}
type="button">
+ {option.label}
+ </button>
+ ))}
</>
),
}));
@@ -94,16 +90,18 @@ const LocationDisplay = () => {
};
const renderSearch = ({
+ dags = [NEW_DAG],
initialEntry,
onClose = vi.fn(),
}: {
+ dags?: Array<DagSearchOption>;
initialEntry: string;
onClose?: () => void;
}) => {
render(
<BaseWrapper>
<MemoryRouter initialEntries={[initialEntry]}>
- <SearchDags dags={[]} onClose={onClose} />
+ <SearchDags dags={dags} onClose={onClose} />
<LocationDisplay />
</MemoryRouter>
</BaseWrapper>,
@@ -112,10 +110,13 @@ const renderSearch = ({
return onClose;
};
+const typeSearch = (value: string) =>
+ fireEvent.change(screen.getByLabelText("Search"), { target: { value } });
+
+const selectDag = (name = "New Dag") =>
fireEvent.click(screen.getByRole("button", { name }));
+
describe("SearchDags", () => {
beforeEach(() => {
- loadedOption.current = undefined;
- selectedOption.current = { ...selectedOption.current, isBackfillable: true
};
vi.mocked(useMatches).mockReturnValue([
{
data: undefined,
@@ -132,10 +133,59 @@ describe("SearchDags", () => {
vi.restoreAllMocks();
});
+ it("matches the start of the name by default", async () => {
+ const getDagsUi = vi.spyOn(DagService, "getDagsUi").mockResolvedValue({
dags: [], total_entries: 0 });
+
+ renderSearch({ initialEntry: "/dags/old_dag" });
+
+ typeSearch("cons");
+
+ await waitFor(() =>
+ expect(getDagsUi).toHaveBeenCalledWith(
+ expect.objectContaining({ dagDisplayNamePrefixPattern: "cons" }),
+ ),
+ );
+
expect(getDagsUi.mock.calls[0]?.[0]).not.toHaveProperty("dagDisplayNamePattern");
+ });
+
+ it("matches anywhere in the name once the option is on", async () => {
+ const getDagsUi = vi.spyOn(DagService, "getDagsUi").mockResolvedValue({
dags: [], total_entries: 0 });
+
+ // The toggle mirrors its choice into the URL, which is also how a shared
link carries it.
+ renderSearch({ initialEntry:
"/dags/old_dag?advanced_search=breadcrumb-dags" });
+
+ typeSearch("cons");
+
+ await waitFor(() =>
+ expect(getDagsUi).toHaveBeenCalledWith(expect.objectContaining({
dagDisplayNamePattern: "cons" })),
+ );
+
expect(getDagsUi.mock.calls[0]?.[0]).not.toHaveProperty("dagDisplayNamePrefixPattern");
+ });
+
+ it("re-runs the search it is showing when match-anywhere is turned on",
async () => {
+ const getDagsUi = vi.spyOn(DagService, "getDagsUi").mockResolvedValue({
dags: [], total_entries: 0 });
+
+ renderSearch({ initialEntry: "/dags/old_dag" });
+
+ typeSearch("cons");
+ await waitFor(() =>
+ expect(getDagsUi).toHaveBeenCalledWith(
+ expect.objectContaining({ dagDisplayNamePrefixPattern: "cons" }),
+ ),
+ );
+
+ fireEvent.click(screen.getByTestId("advanced-search-toggle"));
+
+ // The text did not change, so only the toggle can have asked for this one.
+ await waitFor(() =>
+ expect(getDagsUi).toHaveBeenCalledWith(expect.objectContaining({
dagDisplayNamePattern: "cons" })),
+ );
+ });
+
it("preserves the selected Dag tab when switching Dags", () => {
const onClose = renderSearch({ initialEntry: "/dags/old_dag/details" });
- fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+ selectDag();
expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag/details");
expect(onClose).toHaveBeenCalled();
@@ -154,7 +204,7 @@ describe("SearchDags", () => {
]);
renderSearch({ initialEntry:
"/dags/old_dag/runs/run_1/tasks/task_1/details" });
- fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+ selectDag();
expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag");
});
@@ -172,7 +222,7 @@ describe("SearchDags", () => {
]);
renderSearch({ initialEntry: "/dags/old_dag/backfills" });
- fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+ selectDag();
expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag/backfills");
});
@@ -232,11 +282,12 @@ describe("SearchDags", () => {
pathname: "/dags/old_dag/backfills",
},
]);
- renderSearch({ initialEntry: "/dags/old_dag/backfills" });
+ // Nothing listed up front, so the only option on screen is the one the
search returned.
+ renderSearch({ dags: [], initialEntry: "/dags/old_dag/backfills" });
- fireEvent.click(screen.getByRole("button", { name: "Load Dags" }));
- await waitFor(() =>
expect(loadedOption.current?.isBackfillable).toBe(false));
- fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+ typeSearch("new");
+ await screen.findByRole("button", { name: "New Dag" });
+ selectDag();
expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag");
});
@@ -254,7 +305,7 @@ describe("SearchDags", () => {
]);
renderSearch({ initialEntry: "/dags/old_dag/plugin/test/nested/detail/42"
});
- fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+ selectDag();
expect(screen.getByTestId("location").textContent).toBe("/dags/new_dag");
});
@@ -262,7 +313,7 @@ describe("SearchDags", () => {
it("keeps browser back and forward history after switching Dags", () => {
renderSearch({ initialEntry: "/dags/old_dag/details" });
- fireEvent.click(screen.getByRole("button", { name: "Select Dag" }));
+ selectDag();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByTestId("location").textContent).toBe("/dags/old_dag/details");
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
index 257493c7a0d..6b3fb5fbd29 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
@@ -16,9 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { useState } from "react";
+
import { Flex, Text } from "@chakra-ui/react";
import { useQueryClient } from "@tanstack/react-query";
-import type { GroupBase, OptionsOrGroups, SingleValue } from
"chakra-react-select";
+import type { SingleValue } from "chakra-react-select";
import { useTranslation } from "react-i18next";
import { useMatches, useNavigate } from "react-router-dom";
import { useDebouncedCallback } from "use-debounce";
@@ -27,15 +29,23 @@ import { UseDagServiceGetDagsUiKeyFn } from
"openapi/queries";
import { DagService } from "openapi/requests/services.gen";
import type { DAGWithLatestDagRunsCollectionResponse } from
"openapi/requests/types.gen";
+import { AdvancedSearchToggle } from "src/components/AdvancedSearchToggle";
import { SearchSelect } from "src/components/SearchSelect";
import { StateBadge } from "src/components/StateBadge";
import { TabEntity } from "src/constants/tab";
+import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
import type { DagSearchOption } from "src/utils/option";
import { getTabPath } from "src/utils/tab";
import { SEARCH_LIMIT, buildDagOption } from "./searchOptions";
+const ADVANCED_SEARCH_KEY = "breadcrumb-dags";
+
+/** Match anywhere in the name, or only at the start — the same choice the
Dags list offers. */
+const buildNameArg = (value: string, matchAnywhere: boolean) =>
+ matchAnywhere ? { dagDisplayNamePattern: value } : {
dagDisplayNamePrefixPattern: value };
+
const formatOptionLabel = (option: DagSearchOption) => (
<Flex alignItems="center" gap={2} minW={0}>
<StateBadge flexShrink={0} state={option.state} />
@@ -52,6 +62,11 @@ export const SearchDags = ({
readonly onClose: () => void;
}) => {
const { t: translate } = useTranslation(["dags", "common"]);
+ const [text, setText] = useState("");
+ const [found, setFound] = useState<Array<DagSearchOption>>();
+ // Its own key rather than the Dags list's: toggling here should not quietly
change what that
+ // page does. A user who wants it everywhere has the global default setting
for that.
+ const { enabled: matchAnywhere, onToggle } =
useAdvancedSearch(ADVANCED_SEARCH_KEY);
const queryClient = useQueryClient();
const matches = useMatches();
const navigate = useNavigate();
@@ -66,41 +81,56 @@ export const SearchDags = ({
}
};
- const searchDagDebounced = useDebouncedCallback(
- (
- inputValue: string,
- callback: (options: OptionsOrGroups<DagSearchOption,
GroupBase<DagSearchOption>>) => void,
- ) => {
- void queryClient.fetchQuery({
+ // The mode is an argument rather than read from the closure, so toggling
can re-run the search
+ // it is already showing without waiting for the state it just set.
+ const search = useDebouncedCallback((value: string, anywhere: boolean) => {
+ void queryClient
+ .fetchQuery({
queryFn: () =>
DagService.getDagsUi({
- dagDisplayNamePrefixPattern: inputValue,
+ ...buildNameArg(value, anywhere),
dagRunsLimit: 1,
limit: SEARCH_LIMIT,
- }).then((found: DAGWithLatestDagRunsCollectionResponse) => {
- const options = found.dags.map(buildDagOption);
-
- callback(options);
-
- return options;
}),
queryKey: UseDagServiceGetDagsUiKeyFn({
- dagDisplayNamePrefixPattern: inputValue,
+ ...buildNameArg(value, anywhere),
dagRunsLimit: 1,
}),
staleTime: 0,
- });
- },
- 300,
- );
+ })
+ .then((matched: DAGWithLatestDagRunsCollectionResponse) =>
setFound(matched.dags.map(buildDagOption)));
+ }, 300);
+
+ const onInputChange = (value: string) => {
+ setText(value);
+
+ if (value === "") {
+ search.cancel();
+ setFound(undefined);
+ } else {
+ search(value, matchAnywhere);
+ }
+ };
+
+ // Switching how the text is matched is a new search for the same text, not
a different text.
+ const onToggleMatchAnywhere = (next: boolean) => {
+ onToggle(next);
+
+ if (text !== "") {
+ search(text, next);
+ }
+ };
return (
<SearchSelect
- defaultOptions={dags}
+ controlEnd={
+ <AdvancedSearchToggle enabled={matchAnywhere}
onToggle={onToggleMatchAnywhere} variant="addon" />
+ }
formatOptionLabel={formatOptionLabel}
- loadingMessage={translate("common:loading")}
- loadOptions={searchDagDebounced}
+ inputValue={text}
onChange={onSelect}
+ onInputChange={onInputChange}
+ options={text === "" ? dags : (found ?? dags)}
placeholder={translate("search.dags")}
/>
);
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/SearchMapIndexes.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchMapIndexes.test.tsx
new file mode 100644
index 00000000000..a1cf7408e3d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchMapIndexes.test.tsx
@@ -0,0 +1,192 @@
+/*!
+ * 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, describe, expect, it, vi } from "vitest";
+
+import { TaskInstanceService } from "openapi/requests/services.gen";
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+import type { MapIndexSearchOption } from "src/utils/option";
+
+import { SearchMapIndexes } from "./SearchMapIndexes";
+
+vi.mock("chakra-react-select", () => ({
+ Select: ({
+ inputValue,
+ onChange,
+ onInputChange,
+ options,
+ }: {
+ readonly inputValue: string;
+ readonly onChange: (option: MapIndexSearchOption) => void;
+ readonly onInputChange: (value: string, meta: { action: string }) => void;
+ readonly options: Array<MapIndexSearchOption>;
+ }) => (
+ <>
+ <input
+ aria-label="Search"
+ onChange={(event) => onInputChange(event.target.value, { action:
"input-change" })}
+ value={inputValue}
+ />
+ {options.map((option) => (
+ <button key={option.value} onClick={() => onChange(option)}
type="button">
+ {option.state === undefined ? option.label : `${option.label}
(${option.state})`}
+ </button>
+ ))}
+ </>
+ ),
+}));
+
+const REGION_ID = "01a140b7-c277-7330-a771-56be0f2440f6";
+
+const onClose = vi.fn();
+
+const LocationDisplay = () => {
+ const { pathname, search } = useLocation();
+
+ return (
+ <>
+ <output data-testid="location">{pathname}</output>
+ <output data-testid="search">{search}</output>
+ </>
+ );
+};
+
+const INSTANCES: Array<MapIndexSearchOption> = [
+ { label: "alpha", regionId: REGION_ID, regionIndex: 0, state: "success",
value: "0" },
+ { label: "2", regionId: REGION_ID, regionIndex: 2, state: "failed", value:
"2" },
+];
+
+const renderSearch = (initialEntry: string, route: string) =>
+ render(
+ <BaseWrapper>
+ <MemoryRouter initialEntries={[initialEntry]}>
+ <Routes>
+ <Route
+ element={
+ <SearchMapIndexes
+ currentRegionId={REGION_ID}
+ dagId="my_dag"
+ instances={INSTANCES}
+ onClose={onClose}
+ runId="run_1"
+ taskId="task_1"
+ />
+ }
+ path={route}
+ />
+ </Routes>
+ <LocationDisplay />
+ </MemoryRouter>
+ </BaseWrapper>,
+ );
+
+const MAPPED_ROUTE = "/dags/:dagId/runs/:runId/tasks/:taskId/mapped/:mapIndex";
+
+describe("SearchMapIndexes", () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it("names each instance by its rendered map index, with its state", () => {
+ renderSearch("/dags/my_dag/runs/run_1/tasks/task_1/mapped/2",
MAPPED_ROUTE);
+
+ expect(screen.getByRole("button", { name: "alpha (success)"
})).toBeInTheDocument();
+ // An instance with nothing rendered still has to be nameable, so it falls
back to its number.
+ expect(screen.getByRole("button", { name: "2 (failed)"
})).toBeInTheDocument();
+ });
+
+ it("offers the whole expansion as a way back, with no state of its own", ()
=> {
+ renderSearch("/dags/my_dag/runs/run_1/tasks/task_1/mapped/2",
MAPPED_ROUTE);
+
+ // Labelled without a state, since it stands for every instance rather
than one of them.
+ expect(screen.getByRole("button", { name: "allMapIndexes"
})).toBeInTheDocument();
+ });
+
+ it("leaves the pass behind only the instance when all map indexes is
picked", () => {
+ renderSearch(
+
`/dags/my_dag/runs/run_1/tasks/task_1/mapped/2?region_id=${REGION_ID}®ion_index=2`,
+ MAPPED_ROUTE,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "allMapIndexes" }));
+
+
expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_1/tasks/task_1/mapped");
+
expect(screen.getByTestId("search").textContent).toBe(`?region_id=${REGION_ID}`);
+ });
+
+ it("switches to the selected instance and closes the panel", () => {
+ renderSearch("/dags/my_dag/runs/run_1/tasks/task_1/mapped/2",
MAPPED_ROUTE);
+
+ fireEvent.click(screen.getByRole("button", { name: "alpha (success)" }));
+
+
expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_1/tasks/task_1/mapped/0");
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it("keeps the open tab across the switch", () => {
+ renderSearch("/dags/my_dag/runs/run_1/tasks/task_1/mapped/2/xcom",
`${MAPPED_ROUTE}/xcom`);
+
+ fireEvent.click(screen.getByRole("button", { name: "alpha (success)" }));
+
+ expect(screen.getByTestId("location").textContent).toBe(
+ "/dags/my_dag/runs/run_1/tasks/task_1/mapped/0/xcom",
+ );
+ });
+
+ it("searches the task's instances by rendered map index", async () => {
+ const getMapped = vi
+ .spyOn(TaskInstanceService, "getMappedTaskInstances")
+ .mockResolvedValue({ task_instances: [], total_entries: 0 });
+
+ renderSearch("/dags/my_dag/runs/run_1/tasks/task_1/mapped/2",
MAPPED_ROUTE);
+
+ fireEvent.change(screen.getByLabelText("Search"), { target: { value:
"alpha" } });
+
+ await waitFor(() =>
+ expect(getMapped).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dagId: "my_dag",
+ dagRunId: "run_1",
+ renderedMapIndexPattern: "alpha",
+ taskId: "task_1",
+ }),
+ ),
+ );
+ });
+
+ it("offers what the search found", async () => {
+ const instance = {
+ map_index: 3,
+ rendered_map_index: "alpha",
+ state: "running",
+ } as TaskInstanceResponse;
+
+ vi.spyOn(TaskInstanceService, "getMappedTaskInstances").mockResolvedValue({
+ task_instances: [instance],
+ total_entries: 1,
+ });
+
+ renderSearch("/dags/my_dag/runs/run_1/tasks/task_1/mapped/2",
MAPPED_ROUTE);
+
+ fireEvent.change(screen.getByLabelText("Search"), { target: { value:
"alpha" } });
+
+ expect(await screen.findByRole("button", { name: "alpha (running)"
})).toBeInTheDocument();
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/SearchMapIndexes.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchMapIndexes.tsx
new file mode 100644
index 00000000000..93f1a59df44
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchMapIndexes.tsx
@@ -0,0 +1,168 @@
+/*!
+ * 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 { useState } from "react";
+
+import { Flex, Text } from "@chakra-ui/react";
+import { useQueryClient } from "@tanstack/react-query";
+import type { SingleValue } from "chakra-react-select";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
+import { useDebouncedCallback } from "use-debounce";
+
+import { UseTaskInstanceServiceGetMappedTaskInstancesKeyFn } from
"openapi/queries";
+import { TaskInstanceService } from "openapi/requests/services.gen";
+import type { TaskInstanceCollectionResponse } from
"openapi/requests/types.gen";
+
+import { SearchSelect } from "src/components/SearchSelect";
+import { StateBadge } from "src/components/StateBadge";
+
+import { SearchParamsKeys } from "src/constants/searchParams";
+import { buildTaskInstanceUrl } from "src/utils/links";
+import type { MapIndexSearchOption } from "src/utils/option";
+
+import { BY_MAP_INDEX, SEARCH_LIMIT, buildMapIndexOptions } from
"./searchOptions";
+
+/** Stands for the whole expansion rather than one of them, so it is not a map
index. */
+const ALL_MAP_INDEXES = "all";
+
+const formatOptionLabel = (option: MapIndexSearchOption) => (
+ <Flex alignItems="center" gap={2} minW={0}>
+ {option.state === undefined ? undefined : <StateBadge flexShrink={0}
state={option.state} />}
+ <Text truncate>{option.label}</Text>
+ </Flex>
+);
+
+export const SearchMapIndexes = ({
+ currentRegionId,
+ dagId,
+ instances,
+ onClose,
+ runId,
+ taskId,
+}: {
+ /** The pass on screen, used to pick which row to keep for an index that
spans several. */
+ readonly currentRegionId: string | undefined;
+ readonly dagId: string;
+ /** Already loaded by the breadcrumb level, so opening the panel shows them
straight away. */
+ readonly instances: Array<MapIndexSearchOption>;
+ readonly onClose: () => void;
+ readonly runId: string;
+ readonly taskId: string;
+}) => {
+ const { t: translate } = useTranslation(["dags", "common"]);
+ const [text, setText] = useState("");
+ const [found, setFound] = useState<Array<MapIndexSearchOption>>();
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const [searchParams] = useSearchParams();
+
+ const onSelect = (selected: SingleValue<MapIndexSearchOption>) => {
+ if (!selected) {
+ return;
+ }
+
+ if (selected.value === ALL_MAP_INDEXES) {
+ // The pass stays; only the instance within it is being let go of.
+ const params = new URLSearchParams(searchParams);
+
+ params.delete(SearchParamsKeys.REGION_INDEX);
+ const query = params.toString();
+
+ onClose();
+ void Promise.resolve(
+ navigate(`/dags/${dagId}/runs/${runId}/tasks/${taskId}/mapped${query
=== "" ? "" : `?${query}`}`),
+ );
+
+ return;
+ }
+
+ const path = buildTaskInstanceUrl({
+ currentPathname: pathname,
+ dagId,
+ isMapped: true,
+ mapIndex: selected.value,
+ runId,
+ taskId,
+ });
+ // Inside a loop the same map index exists once per iteration, so the path
alone does not say
+ // which instance was picked.
+ const coordinate =
+ selected.regionId === undefined
+ ? ""
+ : `?${new URLSearchParams({ region_id: selected.regionId,
region_index: String(selected.regionIndex) })}`;
+
+ onClose();
+ void Promise.resolve(navigate(`${path}${coordinate}`));
+ };
+
+ const search = useDebouncedCallback((value: string) => {
+ void queryClient
+ .fetchQuery({
+ queryFn: () =>
+ TaskInstanceService.getMappedTaskInstances({
+ dagId,
+ dagRunId: runId,
+ limit: SEARCH_LIMIT,
+ orderBy: BY_MAP_INDEX,
+ renderedMapIndexPattern: value,
+ taskId,
+ }),
+ queryKey: UseTaskInstanceServiceGetMappedTaskInstancesKeyFn({
+ dagId,
+ dagRunId: runId,
+ limit: SEARCH_LIMIT,
+ orderBy: BY_MAP_INDEX,
+ renderedMapIndexPattern: value,
+ taskId,
+ }),
+ staleTime: 0,
+ })
+ .then((matched: TaskInstanceCollectionResponse) =>
+ setFound(buildMapIndexOptions(matched.task_instances,
currentRegionId)),
+ );
+ }, 300);
+
+ const onInputChange = (value: string) => {
+ setText(value);
+
+ if (value === "") {
+ search.cancel();
+ setFound(undefined);
+ } else {
+ search(value);
+ }
+ };
+
+ return (
+ <SearchSelect
+ formatOptionLabel={formatOptionLabel}
+ inputValue={text}
+ onChange={onSelect}
+ onInputChange={onInputChange}
+ // Only offered when nothing is typed: it is the way back out, not a
search result.
+ options={
+ text === ""
+ ? [{ label: translate("dag:allMapIndexes"), value: ALL_MAP_INDEXES
}, ...instances]
+ : (found ?? instances)
+ }
+ placeholder={translate("search.mapIndexes")}
+ />
+ );
+};
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
index 4a47f818c8f..e8866b87183 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.test.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.test.ts
@@ -18,9 +18,13 @@
*/
import { describe, expect, it } from "vitest";
-import type { DAGRunResponse, DAGWithLatestDagRunsResponse } from
"openapi/requests/types.gen";
+import type {
+ DAGRunResponse,
+ DAGWithLatestDagRunsResponse,
+ TaskInstanceResponse,
+} from "openapi/requests/types.gen";
-import { buildDagOption, buildDagRunOption } from "./searchOptions";
+import { buildDagOption, buildDagRunOption, buildMapIndexOptions } from
"./searchOptions";
const dagRun = { dag_run_id: "run_1", state: "success" } as DAGRunResponse;
const dag = {
@@ -29,6 +33,15 @@ const dag = {
latest_dag_runs: [],
} as unknown as DAGWithLatestDagRunsResponse;
+const pass = (regionId: string, mapIndex: number, state: string) =>
+ ({ map_index: mapIndex, region_id: regionId, region_index: mapIndex, state
}) as TaskInstanceResponse;
+
+const mappedInstance = {
+ map_index: 0,
+ rendered_map_index: "alpha",
+ state: "success",
+} as TaskInstanceResponse;
+
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
@@ -41,6 +54,30 @@ describe("searchOptions", () => {
expect(buildDagOption(dag)).toBe(buildDagOption(dag));
});
+ it("gives a mapped instance the same option object every time", () => {
+ expect(buildMapIndexOptions([mappedInstance], undefined)[0]).toBe(
+ buildMapIndexOptions([mappedInstance], undefined)[0],
+ );
+ });
+
+ it("falls back to the map index itself when nothing is rendered", () => {
+ const unrendered = { map_index: 4, rendered_map_index: null, state: null }
as TaskInstanceResponse;
+
+ expect(buildMapIndexOptions([unrendered], undefined)[0]).toEqual(
+ expect.objectContaining({ label: "4", state: null, value: "4" }),
+ );
+ });
+
+ it("offers a map index once however many passes expanded it", () => {
+ const instances = [pass("a", 0, "failed"), pass("b", 0, "success"),
pass("a", 1, "failed")];
+
+ // One row per index, and the row kept is the one from the pass on screen,
so moving between
+ // indexes does not quietly move the user to another pass.
+ expect(buildMapIndexOptions(instances, "b").map((option) =>
option.value)).toEqual(["0", "1"]);
+ expect(buildMapIndexOptions(instances, "b")[0]?.regionId).toBe("b");
+ expect(buildMapIndexOptions(instances, "a")[0]?.regionId).toBe("a");
+ });
+
it("builds a fresh option for a row that changed", () => {
const changed = { ...dagRun, state: "failed" } as DAGRunResponse;
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts
index 71e5c07d9a5..80d55d5b35a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts
@@ -16,14 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { DAGRunResponse, DAGWithLatestDagRunsResponse } from
"openapi/requests/types.gen";
+import type {
+ DAGRunResponse,
+ DAGWithLatestDagRunsResponse,
+ TaskInstanceResponse,
+} from "openapi/requests/types.gen";
-import type { DagRunSearchOption, DagSearchOption } from "src/utils/option";
+import type { DagRunSearchOption, DagSearchOption, MapIndexSearchOption } 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"];
+export const BY_MAP_INDEX = ["map_index"];
// 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
@@ -31,6 +36,7 @@ export const NEWEST_FIRST = ["-run_after"];
// caching against that object keeps the options for unchanged rows stable too.
const dagOptions = new WeakMap<DAGWithLatestDagRunsResponse,
DagSearchOption>();
const dagRunOptions = new WeakMap<DAGRunResponse, DagRunSearchOption>();
+const mapIndexOptions = new WeakMap<TaskInstanceResponse,
MapIndexSearchOption>();
export const buildDagOption = (dag: DAGWithLatestDagRunsResponse):
DagSearchOption => {
const cached = dagOptions.get(dag);
@@ -64,3 +70,52 @@ export const buildDagRunOption = (dagRun: DAGRunResponse):
DagRunSearchOption =>
return option;
};
+
+const buildMapIndexOption = (taskInstance: TaskInstanceResponse):
MapIndexSearchOption => {
+ const cached = mapIndexOptions.get(taskInstance);
+
+ if (cached !== undefined) {
+ return cached;
+ }
+
+ const option = {
+ // An unrendered index still has to be nameable, and its own number is
what the URL carries.
+ label: taskInstance.rendered_map_index ?? String(taskInstance.map_index),
+ regionId: taskInstance.region_id,
+ regionIndex: taskInstance.region_index,
+ state: taskInstance.state,
+ value: String(taskInstance.map_index),
+ };
+
+ mapIndexOptions.set(taskInstance, option);
+
+ return option;
+};
+
+/**
+ * One row per map index, whatever iteration it came from.
+ *
+ * A looped task is expanded once per pass, so the same indexes come back
several times over. Which
+ * pass is the iteration level's business, not this one's; all this level
needs to offer is each
+ * index once. The row kept for an index is the one from the pass on screen
where there is one, so
+ * moving between indexes stays in the pass the user is looking at.
+ */
+export const buildMapIndexOptions = (
+ taskInstances: Array<TaskInstanceResponse>,
+ currentRegionId: string | undefined,
+): Array<MapIndexSearchOption> => {
+ const perIndex = new Map<number, TaskInstanceResponse>();
+
+ for (const taskInstance of taskInstances) {
+ const kept = perIndex.get(taskInstance.map_index);
+
+ if (
+ kept === undefined ||
+ (kept.region_id !== currentRegionId && taskInstance.region_id ===
currentRegionId)
+ ) {
+ perIndex.set(taskInstance.map_index, taskInstance);
+ }
+ }
+
+ return [...perIndex.values()].map(buildMapIndexOption);
+};
diff --git a/airflow-core/src/airflow/ui/src/utils/option.ts
b/airflow-core/src/airflow/ui/src/utils/option.ts
index 526fe3f39e7..f27abc8b9cf 100644
--- a/airflow-core/src/airflow/ui/src/utils/option.ts
+++ b/airflow-core/src/airflow/ui/src/utils/option.ts
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { DagRunState } from "openapi/requests/types.gen";
+import type { DagRunState, TaskInstanceState } from
"openapi/requests/types.gen";
export type Option = {
readonly disabled?: boolean;
@@ -28,6 +28,14 @@ export type DagRunSearchOption = {
readonly state: DagRunState;
} & Option;
+export type MapIndexSearchOption = {
+ /** The expansion's own coordinate, which the link has to carry to be
unambiguous in a loop. */
+ readonly regionId?: string;
+ readonly regionIndex?: number;
+ /** Absent on the row standing for all of them, which is no instance and so
has no state. */
+ readonly state?: TaskInstanceState | null;
+} & Option;
+
export type DagSearchOption = {
readonly isBackfillable: boolean;
readonly state: DagRunState | null;