This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 527f92e15ca Allow selecting task groups in the Grid view (#75856)
527f92e15ca is described below
commit 527f92e15ca88deef08f6f6313dbe31f0865f538
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Oct 30 16:33:59 2026 +0100
Allow selecting task groups in the Grid view (#75856)
* Allow selecting task groups in the Grid view
Clearing or marking a whole task group across several runs still meant
selecting each of its tasks one by one, although the group's cell already
stands for all of them when clicked. Loops stay out, as a looped task
does: their cell sums up every iteration while it opens only the newest.
* Outline the cells inside a selected task group
A selected group cell acts on everything inside it, but once the group was
expanded its rows showed nothing, so the grid did not show what an action
would touch.
---
airflow-core/docs/ui.rst | 5 +-
.../airflow/ui/public/i18n/locales/en/common.json | 4 +-
.../airflow/ui/src/layouts/Details/Grid/Grid.tsx | 12 +++-
.../Details/Grid/GridSelectionActionBar.test.tsx | 69 ++++++++++++++++++
.../Details/Grid/GridSelectionActionBar.tsx | 25 +++++--
.../layouts/Details/Grid/TaskInstancesColumn.tsx | 11 +--
.../Grid/useGridSelectedTaskInstances.test.tsx | 44 +++++++++++-
.../Details/Grid/useGridSelectedTaskInstances.ts | 22 +++---
.../layouts/Details/Grid/useGridSelection.test.tsx | 83 ++++++++++++++++++++--
.../src/layouts/Details/Grid/useGridSelection.ts | 82 ++++++++++++++++++---
10 files changed, 315 insertions(+), 42 deletions(-)
diff --git a/airflow-core/docs/ui.rst b/airflow-core/docs/ui.rst
index 4cad0fa691f..c228f7b0052 100644
--- a/airflow-core/docs/ui.rst
+++ b/airflow-core/docs/ui.rst
@@ -170,8 +170,9 @@ Use Grid View to:
- **Act on several task instances at once**: Ctrl + click (Cmd + click on
macOS) adds or removes a cell, and
Shift + click selects every cell between the last one picked and the clicked
one. The selected task instances can
then be cleared, marked, or deleted together, and the actions of the details
panel are hidden meanwhile. A mapped
- task's cell stands for all of its mapped task instances. Task groups and
tasks inside a loop cannot be selected
- this way.
+ task's cell stands for all of its mapped task instances, and a task group's
cell for every task instance inside the
+ group in that run. Loops, the tasks inside them, and task groups that
contain or sit inside a loop cannot be
+ selected this way.
- **Filter tasks** by name or partial ID
- **Select a run range**, like "last 25 runs" using the dropdown above the grid
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 9b1ebd0d156..e938db6b4c7 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
@@ -381,12 +381,12 @@
"scrollBottom": "Scroll to bottom",
"scrollTop": "Scroll to top",
"searchDags": "Search $t(dag_other)",
- "selectGridCellRange": "Select a range of $t(taskInstance_other) (not
$t(taskGroup_other) or loop tasks)",
+ "selectGridCellRange": "Select a range of $t(taskInstance_other) or
$t(taskGroup_other) (not loop tasks)",
"showHelp": "Show $t(shortcuts.title)",
"toggleExpand": "Expand or collapse all groups",
"toggleFullscreen": "Toggle fullscreen",
"toggleGraphGrid": "Toggle graph / grid view",
- "toggleGridCellSelection": "Add or remove a $t(taskInstance_one) from
the selection (not $t(taskGroup_other) or loop tasks)",
+ "toggleGridCellSelection": "Add or remove a $t(taskInstance_one) or
$t(taskGroup_one) from the selection (not loop tasks)",
"toggleLogLevel": "Toggle log levels",
"toggleSource": "Toggle source",
"toggleTaskGroup": "Expand or collapse $t(taskGroup_one)",
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
index c8a827eaac5..fb089570e88 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
@@ -46,7 +46,7 @@ import { GANTT_ROW_OFFSET_PX, GRID_HEADER_HEIGHT_PX,
GRID_HEADER_PADDING_PX, ROW
import { useGridPagination } from "./useGridPagination";
import { useGridRunsWithVersionFlags } from "./useGridRunsWithVersionFlags";
import { useGridScrollRestore } from "./useGridScrollRestore";
-import { useGridSelection } from "./useGridSelection";
+import { buildSelectableGroups, getCoveredCellKeys, useGridSelection } from
"./useGridSelection";
import { estimateTaskNameColumnWidthPx, flattenNodes } from "./utils";
dayjs.extend(dayjsDuration);
@@ -161,6 +161,7 @@ export const Grid = ({
// and on the click handlers below, `flatNodes` and the handlers get fresh
references every render, so
// each TI-summaries stream line re-renders every column instead of only the
run whose summary changed.
const { flatNodes } = useMemo(() => flattenNodes(dagStructure,
openGroupIds), [dagStructure, openGroupIds]);
+ const selectableGroups = useMemo(() => buildSelectableGroups(dagStructure),
[dagStructure]);
const taskNameColumnWidthPx = showGantt ?
estimateTaskNameColumnWidthPx(flatNodes) : undefined;
@@ -188,9 +189,15 @@ export const Grid = ({
const { clearSelection, deselectCells, handleSelectionClick, selection } =
useGridSelection({
flatNodes,
runIds: gridRuns?.map((dr: GridRunsResponse) => dr.run_id) ?? [],
+ selectableGroups,
summariesByRunId,
});
+ const selectedCellKeys = useMemo(
+ () => getCoveredCellKeys(selection, selectableGroups),
+ [selection, selectableGroups],
+ );
+
const handleRowClick = useCallback(() => setMode(NavigationModes.TASK),
[setMode]);
const handleCellClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>, cell: GridCell) => {
@@ -304,7 +311,7 @@ export const Grid = ({
nodes={flatNodes}
onCellClick={handleCellClick}
run={dr}
- selection={selection}
+ selectedCellKeys={selectedCellKeys}
showVersionIndicatorMode={showVersionIndicatorMode}
tiSummaries={summariesByRunId.get(dr.run_id)}
virtualItems={virtualItems}
@@ -339,6 +346,7 @@ export const Grid = ({
clearSelection={clearSelection}
dagId={dagId}
deselectCells={deselectCells}
+ selectableGroups={selectableGroups}
selection={selection}
/>
</Flex>
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.test.tsx
new file mode 100644
index 00000000000..0e38eaa8275
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.test.tsx
@@ -0,0 +1,69 @@
+/*!
+ * 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 "@testing-library/jest-dom/vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { GridCell } from "src/context/gridSelection";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { GridSelectionActionBar } from "./GridSelectionActionBar";
+import { getCellKey } from "./useGridSelection";
+
+vi.mock("./useGridSelectedTaskInstances", () => ({
+ useGridSelectedTaskInstances: () => ({ data: [], isLoading: false }),
+}));
+vi.mock("src/pages/TaskInstances/BulkClearTaskInstancesButton", () => ({
default: () => undefined }));
+vi.mock("src/pages/TaskInstances/BulkDeleteTaskInstancesButton", () => ({
default: () => undefined }));
+// Reports `group.b` in run r1 as updated, the way the bulk actions name task
instances.
+vi.mock("src/pages/TaskInstances/BulkMarkTaskInstancesAsButton", () => ({
+ default: ({ deselectKeys }: { readonly deselectKeys: (keys: Array<string>)
=> void }) => (
+ <button onClick={() => deselectKeys(["dag.r1.group.b[-1]"])} type="button">
+ mark
+ </button>
+ ),
+}));
+
+const selectionOf = (cells: Array<GridCell>) =>
+ new Map(cells.map((cell) => [getCellKey(cell.runId, cell.taskId), cell]));
+
+describe("GridSelectionActionBar", () => {
+ it("deselects a group cell once one of its task instances is updated", () =>
{
+ const deselectCells = vi.fn();
+
+ render(
+ <GridSelectionActionBar
+ clearSelection={vi.fn()}
+ dagId="dag"
+ deselectCells={deselectCells}
+ selectableGroups={new Map([["group", ["group.a", "group.b"]]])}
+ selection={selectionOf([
+ { runId: "r1", taskId: "group" },
+ { runId: "r2", taskId: "group" },
+ { runId: "r1", taskId: "group.a" },
+ ])}
+ />,
+ { wrapper: Wrapper },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "mark" }));
+
+ expect(deselectCells).toHaveBeenCalledWith([getCellKey("r1", "group")]);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.tsx
index 72599d67a3b..3c22e17e930 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.tsx
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.tsx
@@ -29,23 +29,40 @@ import type { GridSelection } from
"src/context/gridSelection";
import { formatNumber } from "src/utils";
import { useGridSelectedTaskInstances } from "./useGridSelectedTaskInstances";
+import { getCoveredNodeIds, type SelectableGroups } from "./useGridSelection";
type Props = {
readonly clearSelection: VoidFunction;
readonly dagId: string;
readonly deselectCells: (keys: Array<string>) => void;
+ readonly selectableGroups: SelectableGroups;
readonly selection: GridSelection;
};
-export const GridSelectionActionBar = ({ clearSelection, dagId, deselectCells,
selection }: Props) => {
+export const GridSelectionActionBar = ({
+ clearSelection,
+ dagId,
+ deselectCells,
+ selectableGroups,
+ selection,
+}: Props) => {
const { i18n, t: translate } = useTranslation();
- const { data: selectedTaskInstances = [], isLoading } =
useGridSelectedTaskInstances(dagId, selection);
+ const { data: selectedTaskInstances = [], isLoading } =
useGridSelectedTaskInstances(
+ dagId,
+ selection,
+ selectableGroups,
+ );
- // Bulk actions report each updated task instance as
`dag_id.run_id.task_id[map_index]`.
+ // Bulk actions report each updated task instance as
`dag_id.run_id.task_id[map_index]`. A group cell
+ // is deselected as soon as one of its task instances is updated.
const deselectKeys = (keys: Array<string>) =>
deselectCells(
[...selection.entries()]
- .filter(([, cell]) => keys.some((key) =>
key.startsWith(`${dagId}.${cell.runId}.${cell.taskId}[`)))
+ .filter(([, cell]) =>
+ getCoveredNodeIds(cell, selectableGroups).some((taskId) =>
+ keys.some((key) =>
key.startsWith(`${dagId}.${cell.runId}.${taskId}[`)),
+ ),
+ )
.map(([cellKey]) => cellKey),
);
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
index 3f3d7cee61d..b9b4a1088e4 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
@@ -26,7 +26,7 @@ import type { GridRunsResponse, GridTISummaries } from
"openapi/requests";
import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
import { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
-import type { GridCell, GridSelection } from "src/context/gridSelection";
+import type { GridCell } from "src/context/gridSelection";
import { GridTI } from "./GridTI";
import { DagVersionIndicator } from "./VersionIndicator";
@@ -38,7 +38,8 @@ type Props = {
readonly nodes: Array<GridTask>;
readonly onCellClick?: (event: MouseEvent<HTMLAnchorElement>, cell:
GridCell) => void;
readonly run: GridRunsResponse;
- readonly selection?: GridSelection;
+ // Keys of the selected cells and of the cells inside the selected groups.
+ readonly selectedCellKeys?: ReadonlySet<string>;
readonly showVersionIndicatorMode?: VersionIndicatorOptions;
readonly tiSummaries?: GridTISummaries;
readonly virtualItems?: Array<VirtualItem>;
@@ -59,7 +60,7 @@ export const TaskInstancesColumn = ({
nodes,
onCellClick,
run,
- selection,
+ selectedCellKeys,
showVersionIndicatorMode,
tiSummaries,
virtualItems,
@@ -157,9 +158,9 @@ export const TaskInstancesColumn = ({
isGroup={node.isGroup}
isMapped={node.is_mapped}
isMultiSelected={
- selection === undefined || selection.size === 0
+ selectedCellKeys === undefined || selectedCellKeys.size === 0
? undefined
- : selection.has(getCellKey(run.run_id, node.id))
+ : selectedCellKeys.has(getCellKey(run.run_id, node.id))
}
label={node.label}
loopMaxIterations={node.loop_max_iterations}
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.test.tsx
index d2c2e58713d..90e5d3648cc 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.test.tsx
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.test.tsx
@@ -69,6 +69,7 @@ it("loads every page and keeps only the selected cells, with
all map indexes of
{ runId: "r1", taskId: "mapped" },
{ runId: "r2", taskId: "plain" },
]),
+ new Map(),
),
{ wrapper: Wrapper },
);
@@ -88,10 +89,51 @@ it("loads every page and keeps only the selected cells,
with all map indexes of
]);
});
+it("expands a group cell to the task instances inside it in that run, once
each", async () => {
+ const batch = vi.spyOn(TaskInstanceService,
"getTaskInstancesBatch").mockImplementation(
+ () =>
+ new CancelablePromise((resolve) =>
+ resolve({
+ task_instances: [
+ taskInstance("r1", "group.a"),
+ taskInstance("r1", "group.b"),
+ taskInstance("r2", "group.a"),
+ taskInstance("r2", "group.b"),
+ ],
+ total_entries: 4,
+ }),
+ ),
+ );
+
+ const { result } = renderHook(
+ () =>
+ useGridSelectedTaskInstances(
+ "dag",
+ selectionOf([
+ { runId: "r1", taskId: "group" },
+ { runId: "r1", taskId: "group.a" },
+ { runId: "r2", taskId: "group.b" },
+ ]),
+ new Map([["group", ["group.a", "group.b"]]]),
+ ),
+ { wrapper: Wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(batch.mock.calls[0]?.[0].requestBody.task_ids).toEqual(["group",
"group.a", "group.b"]);
+ expect(result.current.data?.map((ti) => ti.id)).toEqual([
+ "r1-group.a--1",
+ "r1-group.b--1",
+ "r2-group.b--1",
+ ]);
+});
+
it("does not load anything without a selection", () => {
const batch = vi.spyOn(TaskInstanceService, "getTaskInstancesBatch");
- renderHook(() => useGridSelectedTaskInstances("dag", new Map()), { wrapper:
Wrapper });
+ renderHook(() => useGridSelectedTaskInstances("dag", new Map(), new Map()), {
+ wrapper: Wrapper,
+ });
expect(batch).not.toHaveBeenCalled();
});
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.ts
index 7e1c7c587c6..eb6a3d13992 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.ts
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.ts
@@ -24,7 +24,7 @@ import type { TaskInstanceResponse } from
"openapi/requests/types.gen";
import type { GridSelection } from "src/context/gridSelection";
-import { getCellKey } from "./useGridSelection";
+import { getCellKey, getCoveredCellKeys, getCoveredNodeIds, type
SelectableGroups } from "./useGridSelection";
const PAGE_LIMIT = 100;
@@ -32,10 +32,17 @@ const PAGE_LIMIT = 100;
* Load the task instances behind the selected grid cells. A mapped task's
cell stands for all of
* its map indexes, as it does when the cell is clicked.
*/
-export const useGridSelectedTaskInstances = (dagId: string, selection:
GridSelection) => {
+export const useGridSelectedTaskInstances = (
+ dagId: string,
+ selection: GridSelection,
+ selectableGroups: SelectableGroups,
+) => {
const cells = [...selection.values()];
const runIds = [...new Set(cells.map((cell) => cell.runId))].sort();
- const taskIds = [...new Set(cells.map((cell) => cell.taskId))].sort();
+ // Group ids are sent along with the tasks they hold; no task instance
carries one.
+ const taskIds = [...new Set(cells.flatMap((cell) => getCoveredNodeIds(cell,
selectableGroups)))].sort();
+ // A group cell and a cell inside it can both be selected; each task
instance is kept once.
+ const selectedKeys = getCoveredCellKeys(selection, selectableGroups);
return useQuery({
enabled: selection.size > 0,
@@ -64,14 +71,9 @@ export const useGridSelectedTaskInstances = (dagId: string,
selection: GridSelec
done = page.task_instances.length === 0 || taskInstances.length >=
(page.total_entries ?? Infinity);
}
- return taskInstances.filter((ti) =>
selection.has(getCellKey(ti.dag_run_id, ti.task_id)));
+ return taskInstances.filter((ti) =>
selectedKeys.has(getCellKey(ti.dag_run_id, ti.task_id)));
},
// Under the task instances key so the bulk actions' invalidation
refreshes the selection too.
- queryKey: [
- useTaskInstanceServiceGetTaskInstancesKey,
- "gridSelection",
- dagId,
- [...selection.keys()].sort(),
- ],
+ queryKey: [useTaskInstanceServiceGetTaskInstancesKey, "gridSelection",
dagId, [...selectedKeys].sort()],
});
};
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.test.tsx
index f5faa463e9e..e69270ab87a 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.test.tsx
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.test.tsx
@@ -22,12 +22,22 @@ import { act, renderHook } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { describe, expect, it } from "vitest";
-import type { GridTISummaries, LightGridTaskInstanceSummary } from
"openapi/requests/types.gen";
+import type {
+ GridNodeResponse,
+ GridTISummaries,
+ LightGridTaskInstanceSummary,
+} from "openapi/requests/types.gen";
import { GridSelectionProvider, useHasGridSelection } from
"src/context/gridSelection";
import { ShortcutRegistryProvider, useShortcutRegistry } from
"src/context/keyboardShortcuts";
-import { getCellKey, isSelectableCell, useGridSelection } from
"./useGridSelection";
+import {
+ buildSelectableGroups,
+ getCellKey,
+ getCoveredCellKeys,
+ isSelectableCell,
+ useGridSelection,
+} from "./useGridSelection";
import type { GridTask } from "./utils";
const node = (id: string, isGroup = false): GridTask => ({
@@ -51,6 +61,7 @@ const instance = (taskId: string, overrides:
Partial<LightGridTaskInstanceSummar
const RUN_IDS = ["r1", "r2", "r3"];
const FLAT_NODES = [node("group", true), node("a"), node("b"), node("c")];
+const SELECTABLE_GROUPS = new Map([["group", ["a", "b"]]]);
const LOOPED = { latest_region_id: "11111111-1111-4111-8111-111111111111",
latest_region_index: 2 };
// "c" is looped in r2 and "b" has no task instance in r3, so neither can be
selected.
@@ -76,7 +87,12 @@ const click = (modifiers: { ctrlKey?: boolean; metaKey?:
boolean; shiftKey?: boo
const renderSelection = (route = "/dags/dag") =>
renderHook(
() => ({
- ...useGridSelection({ flatNodes: FLAT_NODES, runIds: RUN_IDS,
summariesByRunId: SUMMARIES }),
+ ...useGridSelection({
+ flatNodes: FLAT_NODES,
+ runIds: RUN_IDS,
+ selectableGroups: SELECTABLE_GROUPS,
+ summariesByRunId: SUMMARIES,
+ }),
hasGridSelection: useHasGridSelection(),
shortcuts: useShortcutRegistry().shortcuts,
}),
@@ -88,6 +104,7 @@ const renderSelection = (route = "/dags/dag") =>
<Routes>
<Route element={children} path="/dags/:dagId" />
<Route element={children}
path="/dags/:dagId/runs/:runId/tasks/:taskId" />
+ <Route element={children}
path="/dags/:dagId/runs/:runId/tasks/group/:groupId" />
</Routes>
</MemoryRouter>
</GridSelectionProvider>
@@ -99,10 +116,56 @@ const renderSelection = (route = "/dags/dag") =>
const selectedKeys = (result: ReturnType<typeof renderSelection>["result"]) =>
[...result.current.selection.keys()].sort();
+const groupNode = (id: string, children: Array<GridNodeResponse>, isLoop =
false): GridNodeResponse => ({
+ children,
+ id,
+ is_loop: isLoop,
+ is_mapped: false,
+ label: id,
+});
+const leafNode = (id: string): GridNodeResponse => ({ id, is_mapped: false,
label: id });
+
+describe("buildSelectableGroups", () => {
+ it("lists the members of each group, leaving out loops and the groups around
or inside them", () => {
+ const structure = [
+ groupNode("outer", [leafNode("outer.a"), groupNode("outer.inner",
[leafNode("outer.inner.b")])]),
+ groupNode("with_loop", [leafNode("with_loop.c"),
groupNode("with_loop.loop", [leafNode("d")], true)]),
+ groupNode("loop", [groupNode("loop.group", [leafNode("loop.group.e")])],
true),
+ leafNode("f"),
+ ];
+
+ expect(buildSelectableGroups(structure)).toEqual(
+ new Map([
+ ["outer.inner", ["outer.inner.b"]],
+ ["outer", ["outer.a", "outer.inner", "outer.inner.b"]],
+ ]),
+ );
+ });
+});
+
+describe("getCoveredCellKeys", () => {
+ it("covers the cells inside a selected group in its own run only", () => {
+ const selection = new Map([
+ [getCellKey("r1", "group"), { runId: "r1", taskId: "group" }],
+ [getCellKey("r2", "c"), { runId: "r2", taskId: "c" }],
+ ]);
+
+ expect([...getCoveredCellKeys(selection,
SELECTABLE_GROUPS)].sort()).toEqual(
+ [getCellKey("r1", "group"), getCellKey("r1", "a"), getCellKey("r1",
"b"), getCellKey("r2", "c")].sort(),
+ );
+ });
+});
+
describe("isSelectableCell", () => {
it.each([
{ expected: true, name: "a plain task instance", taskInstance:
instance("a"), taskNode: node("a") },
- { expected: false, name: "a group", taskInstance: instance("group"),
taskNode: node("group", true) },
+ { expected: true, name: "a group", taskInstance: instance("group"),
taskNode: node("group", true) },
+ {
+ expected: false,
+ name: "a group that is or holds a loop",
+ taskInstance: instance("loop"),
+ taskNode: node("loop", true),
+ },
{ expected: false, name: "a cell without task instance", taskInstance:
undefined, taskNode: node("a") },
{
expected: false,
@@ -112,7 +175,7 @@ describe("isSelectableCell", () => {
},
{ expected: false, name: "a looped task", taskInstance: instance("c",
LOOPED), taskNode: node("c") },
])("returns $expected for $name", ({ expected, taskInstance, taskNode }) => {
- expect(isSelectableCell(taskNode, taskInstance)).toBe(expected);
+ expect(isSelectableCell(taskNode, taskInstance,
SELECTABLE_GROUPS)).toBe(expected);
});
});
@@ -147,6 +210,16 @@ describe("useGridSelection", () => {
expect(selectedKeys(result)).toEqual([getCellKey("r1", "a"),
getCellKey("r2", "b")].sort());
});
+ it("starts a selection from the opened task group", () => {
+ const { result } = renderSelection("/dags/dag/runs/r1/tasks/group/group");
+
+ act(() => {
+ result.current.handleSelectionClick(click({ ctrlKey: true }), { runId:
"r2", taskId: "group" });
+ });
+
+ expect(selectedKeys(result)).toEqual([getCellKey("r1", "group"),
getCellKey("r2", "group")].sort());
+ });
+
it("selects only the opened task instance when it is the one Ctrl +
clicked", () => {
const { result } = renderSelection("/dags/dag/runs/r1/tasks/a");
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.ts
index fea210f4b61..66e30256c5b 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.ts
@@ -22,19 +22,70 @@ import { useCallback, useEffect, useId, useRef } from
"react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
-import type { GridTISummaries, LightGridTaskInstanceSummary } from
"openapi/requests/types.gen";
+import type {
+ GridNodeResponse,
+ GridTISummaries,
+ LightGridTaskInstanceSummary,
+} from "openapi/requests/types.gen";
-import { type GridCell, useGridSelectionState } from
"src/context/gridSelection";
+import { type GridCell, type GridSelection, useGridSelectionState } from
"src/context/gridSelection";
import { SHORTCUTS, useShortcutRegistry } from "src/context/keyboardShortcuts";
import type { GridTask } from "./utils";
+/** The nodes (tasks and nested groups) inside each task group whose cell can
be selected, keyed by group id. */
+export type SelectableGroups = ReadonlyMap<string, Array<string>>;
+
export const getCellKey = (runId: string, taskId: string) =>
JSON.stringify([runId, taskId]);
+// A selected group cell acts on everything inside it. A loop group, a group
that contains a loop
+// or one inside a loop is left out, for the same reason as a looped task.
+export const buildSelectableGroups = (nodes: Array<GridNodeResponse> |
undefined): SelectableGroups => {
+ const groups = new Map<string, Array<string>>();
+ const visit = (node: GridNodeResponse, inLoop: boolean): { hasLoop: boolean;
memberIds: Array<string> } => {
+ if (!node.children) {
+ return { hasLoop: false, memberIds: [] };
+ }
+
+ const isLoop = node.is_loop === true;
+ const children = node.children.map((child) => ({ id: child.id,
...visit(child, inLoop || isLoop) }));
+ const hasLoop = isLoop || children.some((child) => child.hasLoop);
+ const memberIds = children.flatMap((child) => [child.id,
...child.memberIds]);
+
+ if (!inLoop && !hasLoop) {
+ groups.set(node.id, memberIds);
+ }
+
+ return { hasLoop, memberIds };
+ };
+
+ nodes?.forEach((node) => visit(node, false));
+
+ return groups;
+};
+
+/** The node ids a cell stands for: its own, plus everything inside it for a
group. */
+export const getCoveredNodeIds = (cell: GridCell, selectableGroups:
SelectableGroups) => [
+ cell.taskId,
+ ...(selectableGroups.get(cell.taskId) ?? []),
+];
+
+/** The keys of the cells the selection acts on, including those inside the
selected groups. */
+export const getCoveredCellKeys = (selection: GridSelection, selectableGroups:
SelectableGroups) =>
+ new Set(
+ [...selection.values()].flatMap((cell) =>
+ getCoveredNodeIds(cell, selectableGroups).map((nodeId) =>
getCellKey(cell.runId, nodeId)),
+ ),
+ );
+
// A looped task's cell aggregates every pass while its link opens only the
newest one, so a bulk
-// action on it would be ambiguous. Groups and cells without a task instance
have nothing to act on.
-export const isSelectableCell = (node: GridTask, taskInstance:
LightGridTaskInstanceSummary | undefined) =>
- !node.isGroup &&
+// action on it would be ambiguous. Cells without a task instance have nothing
to act on.
+export const isSelectableCell = (
+ node: GridTask,
+ taskInstance: LightGridTaskInstanceSummary | undefined,
+ selectableGroups: SelectableGroups,
+) =>
+ (node.isGroup !== true || selectableGroups.has(node.id)) &&
taskInstance?.dag_version_number !== null &&
taskInstance?.dag_version_number !== undefined &&
(taskInstance.latest_region_id === null || taskInstance.latest_region_id ===
undefined);
@@ -42,6 +93,7 @@ export const isSelectableCell = (node: GridTask,
taskInstance: LightGridTaskInst
type GridData = {
readonly flatNodes: Array<GridTask>;
readonly runIds: Array<string>;
+ readonly selectableGroups: SelectableGroups;
readonly summariesByRunId: ReadonlyMap<string, GridTISummaries>;
};
@@ -61,7 +113,7 @@ export const buildRangeSelection = (data: GridData, from:
GridCell, to: GridCell
for (const node of data.flatNodes.slice(Math.min(...rows), Math.max(...rows)
+ 1)) {
for (const runId of data.runIds.slice(Math.min(...columns),
Math.max(...columns) + 1)) {
- if (isSelectableCell(node, findTaskInstance(data, runId, node.id))) {
+ if (isSelectableCell(node, findTaskInstance(data, runId, node.id),
data.selectableGroups)) {
selection.set(getCellKey(runId, node.id), { runId, taskId: node.id });
}
}
@@ -76,7 +128,7 @@ export const buildRangeSelection = (data: GridData, from:
GridCell, to: GridCell
* plain click clears the selection and navigates as before.
*/
export const useGridSelection = (data: GridData) => {
- const { dagId = "", runId: openedRunId, taskId: openedTaskId } = useParams();
+ const { dagId = "", groupId: openedGroupId, runId: openedRunId, taskId:
openedTaskId } = useParams();
const [selection, setSelection] = useGridSelectionState();
const anchorRef = useRef<GridCell | undefined>(undefined);
// The click handler is passed to every run column; reading the grid data
through a ref keeps it
@@ -140,11 +192,19 @@ export const useGridSelection = (data: GridData) => {
const isSelectable = ({ runId, taskId }: GridCell) => {
const node = dataRef.current.flatNodes.find((flatNode) => flatNode.id
=== taskId);
- return node !== undefined && isSelectableCell(node,
findTaskInstance(dataRef.current, runId, taskId));
+ return (
+ node !== undefined &&
+ isSelectableCell(
+ node,
+ findTaskInstance(dataRef.current, runId, taskId),
+ dataRef.current.selectableGroups,
+ )
+ );
};
+ const openedId = openedTaskId ?? openedGroupId;
const openedCell =
- openedRunId !== undefined && openedTaskId !== undefined
- ? { runId: openedRunId, taskId: openedTaskId }
+ openedRunId !== undefined && openedId !== undefined
+ ? { runId: openedRunId, taskId: openedId }
: undefined;
if (event.shiftKey && isSelectable(cell)) {
@@ -191,7 +251,7 @@ export const useGridSelection = (data: GridData) => {
return false;
},
- [clearSelection, dagId, openedRunId, openedTaskId, setSelection],
+ [clearSelection, dagId, openedGroupId, openedRunId, openedTaskId,
setSelection],
);
return { clearSelection, deselectCells, handleSelectionClick, selection:
cells };