This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new e3e3f6fad2b fix(hitl): show mapped task instances on /required_actions 
listing (#66433)
e3e3f6fad2b is described below

commit e3e3f6fad2b37ba054070ff3ebcff5296f57fc9d
Author: Paul Mathew <[email protected]>
AuthorDate: Wed May 6 11:38:50 2026 -0400

    fix(hitl): show mapped task instances on /required_actions listing (#66433)
    
    Fixes #66428.
    
    The Required Actions listing component hard-coded
    ``mapIndex: parseInt(searchParams.get(MAP_INDEX) ?? "-1", 10)``, which
    always sent ``map_index=-1`` and silently filtered every page (the global
    ``/required_actions`` view as well as the per-DAG / per-Run / per-Task
    Required Actions tabs) down to non-mapped task instances. Mapped HITL
    rows were therefore counted by the home-page badge but invisible in the
    listing pages.
    
    * Pass ``mapIndex`` to ``useTaskInstanceServiceGetHitlDetails`` only when
      the user has explicitly set the ``map_index`` URL search param. The
      default behaviour is now "all map indexes" (no filter) instead of
      "non-mapped only", and ``map_index=-1`` continues to work as an opt-in
      filter for non-mapped task instances.
    * Fix the auto-refresh predicate that compared ``responded_at === 
undefined``
      instead of ``=== null``. The API serialises ``responded_at`` as JSON
      ``null``, never an omitted field, so the predicate never matched and
      the listing never polled for new pending actions.
    * Add ``HITLTaskInstances.test.tsx`` covering the three ``map_index``
      URL branches (absent / set to integer / set to ``-1``) and the refetch
      predicate for both ``responded_at: null`` and a populated value.
    
    Signed-off-by: Paul Mathew <[email protected]>
    Co-authored-by: Cursor <[email protected]>
---
 .../HITLTaskInstances/HITLTaskInstances.test.tsx   | 222 +++++++++++++++++++++
 .../pages/HITLTaskInstances/HITLTaskInstances.tsx  |   7 +-
 2 files changed, 225 insertions(+), 4 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
new file mode 100644
index 00000000000..c149eac187f
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.test.tsx
@@ -0,0 +1,222 @@
+/*!
+ * 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 { render } 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";
+
+import type * as OpenapiQueries from "openapi/queries";
+import type * as SrcUtils from "src/utils";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { HITLTaskInstances } from "./HITLTaskInstances";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+// `useSearchParams` is replaced with a function that returns a mutable
+// reference; tests update `mockSearchParams` before each `render` call.
+let mockSearchParams = new URLSearchParams();
+
+vi.mock("react-i18next", async (importOriginal) => {
+  const actual = await importOriginal<typeof ReactI18Next>();
+
+  return {
+    ...actual,
+    useTranslation: () => ({
+      // eslint-disable-next-line id-length
+      t: (key: string) => key,
+    }),
+  };
+});
+
+vi.mock("react-router-dom", async (importOriginal) => {
+  const actual = await importOriginal<typeof ReactRouterDom>();
+
+  return {
+    ...actual,
+    useParams: () => ({}),
+    useSearchParams: () => [mockSearchParams, vi.fn()] as const,
+  };
+});
+
+// Only `useTaskInstanceServiceGetHitlDetails` is overridden — the rest of the
+// generated query module is preserved so unrelated hooks (e.g. `useConfig`'s
+// `useConfigServiceGetConfigs`) keep working through the global MSW handlers.
+vi.mock("openapi/queries", async (importOriginal) => {
+  const actual = await importOriginal<typeof OpenapiQueries>();
+
+  return {
+    ...actual,
+    useTaskInstanceServiceGetHitlDetails: vi.fn(),
+  };
+});
+
+// `useAutoRefresh` is the only `src/utils` export that needs a stable, 
definite
+// return value for the refetch predicate test. Other exports are passed 
through
+// untouched.
+vi.mock("src/utils", async (importOriginal) => {
+  const actual = await importOriginal<typeof SrcUtils>();
+
+  return {
+    ...actual,
+    useAutoRefresh: () => 5000,
+  };
+});
+
+// Children of HITLTaskInstances are stubbed: the tests are about which params
+// the page sends to the listing API, not how the table or filter bar render.
+vi.mock("./HITLFilters", () => ({
+  HITLFilters: () => null,
+}));
+
+vi.mock("src/components/DataTable", () => ({
+  DataTable: () => null,
+}));
+
+const { useTaskInstanceServiceGetHitlDetails } = await 
import("openapi/queries");
+
+const emptyHitlResponse = {
+  data: { hitl_details: [], total_entries: 0 },
+  error: null,
+  isLoading: false,
+};
+
+const lastListingCall = () => {
+  const { calls } = vi.mocked(useTaskInstanceServiceGetHitlDetails).mock;
+
+  return calls.at(-1);
+};
+
+beforeEach(() => {
+  vi.mocked(useTaskInstanceServiceGetHitlDetails).mockReturnValue(
+    emptyHitlResponse as ReturnType<typeof 
useTaskInstanceServiceGetHitlDetails>,
+  );
+});
+
+// ---------------------------------------------------------------------------
+// Regression tests for #66428 — the listing previously hard-coded
+// `mapIndex: parseInt(searchParams.get(MAP_INDEX) ?? "-1", 10)`, which 
silently
+// dropped every mapped HITL row. The fix only sends `mapIndex` when the user
+// has explicitly set the URL search param.
+// ---------------------------------------------------------------------------
+describe("HITLTaskInstances – mapIndex URL param handling (#66428)", () => {
+  it("does not send mapIndex when map_index URL param is absent", () => {
+    mockSearchParams = new URLSearchParams();
+
+    render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+    const args = lastListingCall()?.[0] as { mapIndex?: number } | undefined;
+
+    expect(args?.mapIndex).toBeUndefined();
+  });
+
+  it("sends mapIndex=2 when map_index=2 is set in the URL", () => {
+    mockSearchParams = new URLSearchParams("map_index=2");
+
+    render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+    const args = lastListingCall()?.[0] as { mapIndex?: number } | undefined;
+
+    expect(args?.mapIndex).toBe(2);
+  });
+
+  it("sends mapIndex=-1 when the user explicitly filters on non-mapped tasks", 
() => {
+    mockSearchParams = new URLSearchParams("map_index=-1");
+
+    render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+    const args = lastListingCall()?.[0] as { mapIndex?: number } | undefined;
+
+    expect(args?.mapIndex).toBe(-1);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Refetch predicate — the API serializes `responded_at` as JSON `null`, not an
+// omitted field, so `=== undefined` never matched and the page never polled
+// for new pending actions.
+// ---------------------------------------------------------------------------
+describe("HITLTaskInstances – auto-refresh predicate", () => {
+  type RefetchPredicate = (query: {
+    state: {
+      data?: {
+        hitl_details: Array<{
+          responded_at: string | null;
+          task_instance: { state: string };
+        }>;
+      };
+    };
+  }) => number | false;
+
+  const getRefetchInterval = (): RefetchPredicate => {
+    const args = lastListingCall();
+    const options = args?.[2] as { refetchInterval?: RefetchPredicate } | 
undefined;
+
+    if (options?.refetchInterval === undefined) {
+      throw new Error("refetchInterval predicate not registered on the listing 
query");
+    }
+
+    return options.refetchInterval;
+  };
+
+  it("triggers refetch when a deferred row has responded_at: null", () => {
+    mockSearchParams = new URLSearchParams();
+
+    render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+    const refetchInterval = getRefetchInterval();
+    const result = refetchInterval({
+      state: {
+        data: {
+          hitl_details: [
+            {
+              responded_at: null,
+              task_instance: { state: "deferred" },
+            },
+          ],
+        },
+      },
+    });
+
+    expect(result).toBe(5000);
+  });
+
+  it("does not refetch when every row already has a responded_at value", () => 
{
+    mockSearchParams = new URLSearchParams();
+
+    render(<HITLTaskInstances />, { wrapper: Wrapper });
+
+    const refetchInterval = getRefetchInterval();
+    const result = refetchInterval({
+      state: {
+        data: {
+          hitl_details: [
+            {
+              responded_at: "2026-05-05T10:00:00Z",
+              task_instance: { state: "deferred" },
+            },
+          ],
+        },
+      },
+    });
+
+    expect(result).toBe(false);
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
index 9b9bfb518b4..4538bc204af 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
@@ -182,7 +182,7 @@ export const HITLTaskInstances = () => {
   const createdAtLte = searchParams.get(CREATED_AT_LTE) ?? undefined;
   const dagIdPattern = searchParams.get(DAG_DISPLAY_NAME_PATTERN) ?? undefined;
   const taskIdPattern = searchParams.get(TASK_ID_PATTERN) ?? undefined;
-  const mapIndex = searchParams.get(MAP_INDEX) ?? "-1";
+  const mapIndexParam = searchParams.get(MAP_INDEX);
   const filterResponseReceived = searchParams.get(RESPONSE_RECEIVED_PARAM) ?? 
undefined;
   const respondedByUserName = searchParams.get(RESPONDED_BY_USER_NAME) ?? 
undefined;
   const subjectSearch = searchParams.get(SUBJECT_SEARCH) ?? undefined;
@@ -212,7 +212,7 @@ export const HITLTaskInstances = () => {
       ...dagIdArg,
       dagRunId: runId ?? "~",
       limit: pagination.pageSize,
-      mapIndex: parseInt(mapIndex, 10),
+      mapIndex: mapIndexParam === null ? undefined : parseInt(mapIndexParam, 
10),
       offset: pagination.pageIndex * pagination.pageSize,
       orderBy: sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : [],
       respondedByUserName: respondedByUserName === undefined ? undefined : 
[respondedByUserName],
@@ -232,8 +232,7 @@ export const HITLTaskInstances = () => {
       refetchInterval: (query) => {
         const hasDeferredWithoutResponse = Boolean(
           query.state.data?.hitl_details.some(
-            (detail: HITLDetail) =>
-              detail.responded_at === undefined && detail.task_instance.state 
=== "deferred",
+            (detail: HITLDetail) => detail.responded_at === null && 
detail.task_instance.state === "deferred",
           ),
         );
 

Reply via email to