This is an automated email from the ASF dual-hosted git repository.
shahar1 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 1b9eb2bb179 [v3-4-test] Preserve task instance bulk selections across
pages (#75905) (#75983)
1b9eb2bb179 is described below
commit 1b9eb2bb179cae854d06fb381bf9909bf95d8b66
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Nov 6 08:14:38 2026 +0000
[v3-4-test] Preserve task instance bulk selections across pages (#75905)
(#75983)
(cherry picked from commit 90628f4fbdc2243c6aa95e11e6ac0f62709078cd)
Co-authored-by: Anzin Mohammed <[email protected]>
---
.../components/DataTable/useRowSelection.test.tsx | 86 ++++++++++++++++++++++
.../src/components/DataTable/useRowSelection.tsx | 19 +++--
.../src/pages/TaskInstances/TaskInstances.test.tsx | 50 ++++++++++++-
.../ui/src/pages/TaskInstances/TaskInstances.tsx | 19 +++--
4 files changed, 156 insertions(+), 18 deletions(-)
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.test.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.test.tsx
new file mode 100644
index 00000000000..aea13ca8ca1
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.test.tsx
@@ -0,0 +1,86 @@
+/*!
+ * 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 { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { useRowSelection } from "./useRowSelection";
+
+const firstItem = { id: "first", state: "queued" };
+const secondItem = { id: "second", state: "success" };
+const getKey = (item: typeof firstItem) => item.id;
+
+describe("useRowSelection retained items", () => {
+ it.each(["row", "page"] as const)("retains items selected by %s across
pages", (selection) => {
+ const { rerender, result } = renderHook(({ data }) => useRowSelection({
data, getKey }), {
+ initialProps: { data: [firstItem] },
+ });
+ const select = (key: string, checked: boolean) => {
+ if (selection === "row") {
+ result.current.handleRowSelect(key, checked);
+ } else {
+ result.current.handleSelectAll(checked);
+ }
+ };
+
+ act(() => select(firstItem.id, true));
+ rerender({ data: [secondItem] });
+ act(() => select(secondItem.id, true));
+ expect(result.current.selectedItems).toEqual([firstItem, secondItem]);
+
+ act(() => select(secondItem.id, false));
+ expect(result.current.selectedItems).toEqual([firstItem]);
+ });
+
+ it.each(["clearSelections", "deselectKeys"] as const)("removes retained
items with %s", (action) => {
+ const { rerender, result } = renderHook(({ data }) => useRowSelection({
data, getKey }), {
+ initialProps: { data: [firstItem] },
+ });
+
+ act(() => result.current.handleRowSelect(firstItem.id, true));
+ rerender({ data: [secondItem] });
+ act(() => {
+ if (action === "clearSelections") {
+ result.current.clearSelections();
+ } else {
+ result.current.deselectKeys([firstItem.id]);
+ }
+ });
+ expect(result.current.selectedItems).toEqual([]);
+ });
+
+ it("uses refreshed data for selected items on the current page", () => {
+ const { rerender, result } = renderHook(({ data }) => useRowSelection({
data, getKey }), {
+ initialProps: { data: [firstItem] },
+ });
+
+ act(() => result.current.handleRowSelect(firstItem.id, true));
+ const updatedItem = { ...firstItem, state: "success" };
+
+ rerender({ data: [updatedItem] });
+ expect(result.current.selectedItems).toEqual([updatedItem]);
+ });
+
+ it("does not retain an item that is absent from the current data", () => {
+ const { result } = renderHook(() => useRowSelection({ data: [firstItem],
getKey }));
+
+ act(() => result.current.handleRowSelect("missing", true));
+ expect(result.current.selectedItems).toEqual([]);
+ expect(result.current.selectedRows.size).toBe(0);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.tsx
b/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.tsx
index ed41c48e43f..81d7376f999 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/useRowSelection.tsx
@@ -108,14 +108,18 @@ export const SelectionHeaderCheckbox = ({ colorPalette }:
SelectionHeaderCheckbo
};
export const useRowSelection = <T,>({ data = [], getKey }:
UseRowSelectionProps<T>) => {
- const [selectedRows, setSelectedRows] = useState<Map<string, boolean>>(new
Map());
+ const [selectedItems, setSelectedItems] = useState<Map<string, T>>(new
Map());
+ const currentItems = new Map(data.map((item) => [getKey(item), item]));
+ const selectedRows = new Map([...selectedItems.keys()].map((key) => [key,
true]));
const handleRowSelect = (key: string, isChecked: boolean) => {
- setSelectedRows((prev) => {
+ setSelectedItems((prev) => {
const isAlreadySelected = prev.has(key);
if (isChecked && !isAlreadySelected) {
- return new Map(prev).set(key, true);
+ const item = currentItems.get(key);
+
+ return item === undefined ? prev : new Map(prev).set(key, item);
} else if (!isChecked && isAlreadySelected) {
const newMap = new Map(prev);
@@ -129,11 +133,11 @@ export const useRowSelection = <T,>({ data = [], getKey
}: UseRowSelectionProps<
};
const handleSelectAll = (isChecked: boolean) => {
- setSelectedRows((prev) => {
+ setSelectedItems((prev) => {
const newMap = new Map(prev);
if (isChecked) {
- data.forEach((item) => newMap.set(getKey(item), true));
+ data.forEach((item) => newMap.set(getKey(item), item));
} else {
data.forEach((item) => newMap.delete(getKey(item)));
}
@@ -145,14 +149,14 @@ export const useRowSelection = <T,>({ data = [], getKey
}: UseRowSelectionProps<
const allRowsSelected = data.length > 0 && data.every((item) =>
selectedRows.has(getKey(item)));
const clearSelections = () => {
- setSelectedRows(new Map());
+ setSelectedItems(new Map());
};
const deselectKeys = (keys: Array<string>) => {
if (keys.length === 0) {
return;
}
- setSelectedRows((prev) => {
+ setSelectedItems((prev) => {
const newMap = new Map(prev);
keys.forEach((key) => newMap.delete(key));
@@ -167,6 +171,7 @@ export const useRowSelection = <T,>({ data = [], getKey }:
UseRowSelectionProps<
deselectKeys,
handleRowSelect,
handleSelectAll,
+ selectedItems: [...selectedItems].map(([key, item]) =>
currentItems.get(key) ?? item),
selectedRows,
};
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.test.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.test.tsx
index 3b0e26d1773..217213439fd 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.test.tsx
@@ -19,7 +19,7 @@
import type { ReactNode } from "react";
import "@testing-library/jest-dom/vitest";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor, within } from
"@testing-library/react";
import type * as ReactI18Next from "react-i18next";
import type * as ReactRouterDom from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -38,6 +38,16 @@ let mockParams: Record<string, string | undefined> = {
};
let mockSearchParams = new URLSearchParams();
+const { bulkClearMock, bulkDeleteMock, bulkMarkMock } = vi.hoisted(() => ({
+ bulkClearMock: vi.fn(() => null),
+ bulkDeleteMock: vi.fn(() => null),
+ bulkMarkMock: vi.fn(() => null),
+}));
+
+vi.mock("./BulkClearTaskInstancesButton", () => ({ default: bulkClearMock }));
+vi.mock("./BulkDeleteTaskInstancesButton", () => ({ default: bulkDeleteMock
}));
+vi.mock("./BulkMarkTaskInstancesAsButton", () => ({ default: bulkMarkMock }));
+
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof ReactI18Next>();
@@ -91,25 +101,30 @@ vi.mock("src/components/DataTable", () => ({
readonly columns: ReadonlyArray<{
accessorKey?: string;
cell?: (props: { row: { original: TaskInstanceResponse } }) => ReactNode;
- header?: unknown;
+ header?: (() => ReactNode) | ReactNode;
}>;
readonly data: ReadonlyArray<TaskInstanceResponse>;
}) => {
const renderedColumns = columns.filter(
- (column) => column.accessorKey === "rendered_map_index" ||
column.accessorKey === "dag_run_id",
+ (column) =>
+ column.accessorKey === "select" ||
+ column.accessorKey === "rendered_map_index" ||
+ column.accessorKey === "dag_run_id",
);
+ const selectionColumn = columns.find((column) => column.accessorKey ===
"select");
const iterationColumn = columns.find((column) => column.accessorKey ===
"loop_iteration");
return (
<table>
<thead>
<tr>
+ <th>{typeof selectionColumn?.header === "function" ?
selectionColumn.header() : undefined}</th>
{iterationColumn === undefined ? undefined :
<th>{iterationColumn.header as ReactNode}</th>}
</tr>
</thead>
<tbody>
{data.map((taskInstance) => (
- <tr key={`${taskInstance.task_id}-${taskInstance.map_index}`}>
+ <tr aria-label={taskInstance.task_id}
key={`${taskInstance.task_id}-${taskInstance.map_index}`}>
{renderedColumns.map((column) => (
<td key={column.accessorKey}>{column.cell?.({ row: { original:
taskInstance } })}</td>
))}
@@ -159,6 +174,33 @@ describe("TaskInstances", () => {
mockSearchParams = new URLSearchParams();
});
+ it.each([
+ { action: "clear", actionMock: bulkClearMock },
+ { action: "mark", actionMock: bulkMarkMock },
+ { action: "delete", actionMock: bulkDeleteMock },
+ ])("passes selections from both pages to the bulk $action action", async ({
actionMock }) => {
+ const firstTask = { ...mappedTaskInstance, id: "first-id", task_id:
"first_task" };
+ const secondTask = { ...mappedTaskInstance, id: "second-id", task_id:
"second_task" };
+
+
vi.mocked(useTaskInstanceServiceGetTaskInstances).mockReturnValue(getTaskInstancesResponse([firstTask]));
+ const { rerender } = render(<TaskInstances />, { wrapper: Wrapper });
+
+ fireEvent.click(within(screen.getByRole("row", { name: "first_task"
})).getByRole("checkbox"));
+ await screen.findByText("1 selected");
+
vi.mocked(useTaskInstanceServiceGetTaskInstances).mockReturnValue(getTaskInstancesResponse([secondTask]));
+ mockSearchParams = new URLSearchParams("cursor=second-page");
+ rerender(<TaskInstances />);
+ fireEvent.click(within(screen.getByRole("row", { name: "second_task"
})).getByRole("checkbox"));
+
+ await screen.findByText("2 selected");
+ await waitFor(() =>
+ expect(actionMock).toHaveBeenLastCalledWith(
+ expect.objectContaining({ selectedTaskInstances: [firstTask,
secondTask] }),
+ undefined,
+ ),
+ );
+ });
+
it.each([
{
description: "with a selected task but no run",
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index bda99d3ec09..b7b845cadde 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -450,16 +450,21 @@ export const TaskInstances = () => {
const nextCursor = data?.next_cursor ?? undefined;
const previousCursor = data?.previous_cursor ?? undefined;
- const { allRowsSelected, clearSelections, deselectKeys, handleRowSelect,
handleSelectAll, selectedRows } =
- useRowSelection({
- data: data?.task_instances,
- getKey: getRowKey,
- });
+ const {
+ allRowsSelected,
+ clearSelections,
+ deselectKeys,
+ handleRowSelect,
+ handleSelectAll,
+ selectedItems: selectedTaskInstances,
+ selectedRows,
+ } = useRowSelection({
+ data: data?.task_instances,
+ getKey: getRowKey,
+ });
const pluginActions = usePluginActions("task_instance");
- const selectedTaskInstances = (data?.task_instances ?? []).filter((ti) =>
selectedRows.has(getRowKey(ti)));
-
const columns = taskInstanceColumns({
dagId,
hasLoopIteration: (data?.task_instances ?? []).some(