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(

Reply via email to