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

kaxilnaik 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 a48d8a5b21a Fix per-option button rendering for 4 options (#64453)
a48d8a5b21a is described below

commit a48d8a5b21a56a0e148e0fef203ba643f9465b05
Author: Shrividya Hegde <[email protected]>
AuthorDate: Mon Mar 30 14:01:20 2026 -0400

    Fix per-option button rendering for 4 options (#64453)
    
    The condition \`options.length < 4\` caused exactly 4 options to render
    nothing — shouldRenderOptionButton was false but no fallback UI was
    triggered either. Changed to \`options.length <= 4\` so 4 options render
    as buttons.
    
    Fixes #64413
    
    * add ci fixes
    
    ---------
    
    Co-authored-by: Rahul Vats <[email protected]>
---
 .../HITLTaskInstances/HITLResponseForm.test.tsx    | 144 +++++++++++++++++++++
 .../pages/HITLTaskInstances/HITLResponseForm.tsx   |   2 +-
 2 files changed, 145 insertions(+), 1 deletion(-)

diff --git 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.test.tsx
new file mode 100644
index 00000000000..ef670430f48
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.test.tsx
@@ -0,0 +1,144 @@
+/* eslint-disable unicorn/no-null */
+
+/*!
+ * 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.
+ */
+/// <reference types="@testing-library/jest-dom" />
+import "@testing-library/jest-dom/vitest";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { HITLDetailHistory, TaskInstanceHistoryResponse } from 
"openapi/requests/types.gen";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { HITLResponseForm } from "./HITLResponseForm";
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+vi.mock("react-i18next", () => ({
+  useTranslation: () => ({
+    // eslint-disable-next-line id-length
+    t: (key: string) => key,
+  }),
+}));
+
+vi.mock("src/queries/useUpdateHITLDetail", () => ({
+  useUpdateHITLDetail: () => ({ updateHITLResponse: vi.fn() }),
+}));
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+const MOCK_TASK_INSTANCE = {
+  dag_id: "test_dag",
+  dag_run_id: "run_1",
+  map_index: -1,
+  state: "deferred",
+  task_id: "test_task",
+} as TaskInstanceHistoryResponse;
+
+const makeHITLDetail = (
+  options: Array<string>,
+  overrides: Partial<HITLDetailHistory> = {},
+): { task_instance: TaskInstanceHistoryResponse } & Omit<HITLDetailHistory, 
"task_instance"> => ({
+  assigned_users: [],
+  body: "Please pick one.",
+  chosen_options: [],
+  created_at: new Date().toISOString(),
+  defaults: null,
+  multiple: false,
+  options,
+  params: {},
+  params_input: {},
+  responded_at: null,
+  responded_by_user: null,
+  response_received: false,
+  subject: "Test subject",
+  task_instance: MOCK_TASK_INSTANCE,
+  ...overrides,
+});
+
+const renderForm = (options: Array<string>, overrides?: 
Partial<HITLDetailHistory>) =>
+  render(<HITLResponseForm hitlDetail={makeHITLDetail(options, overrides)} />, 
{
+    wrapper: Wrapper,
+  });
+
+// ---------------------------------------------------------------------------
+// Tests — option-button rendering boundary
+//
+// HITLResponseForm renders options one of two ways:
+//   shouldRenderOptionButton=true  → one Button per option 
(data-testid="hitl-option-<n>")
+//   shouldRenderOptionButton=false → a generic "Respond" button, no 
per-option buttons
+//
+// Bug (#64413): condition was `options.length < 4`, so with exactly 4 options
+// shouldRenderOptionButton was false and the footer rendered nothing at all.
+// Fix: change to `options.length <= 4`.
+// ---------------------------------------------------------------------------
+describe("HITLResponseForm – option button rendering boundary", () => {
+  it("renders per-option buttons for 1 option", () => {
+    renderForm(["Only"]);
+    expect(screen.getByTestId("hitl-option-Only")).toBeInTheDocument();
+  });
+
+  it("renders per-option buttons for 2 options", () => {
+    renderForm(["Yes", "No"]);
+    expect(screen.getByTestId("hitl-option-Yes")).toBeInTheDocument();
+    expect(screen.getByTestId("hitl-option-No")).toBeInTheDocument();
+  });
+
+  it("renders per-option buttons for 3 options", () => {
+    const opts = ["Creator", "Explorer", "Viewer"];
+
+    renderForm(opts);
+    for (const opt of opts) {
+      expect(screen.getByTestId(`hitl-option-${opt}`)).toBeInTheDocument();
+    }
+  });
+
+  // Regression test for #64413 — exactly 4 options previously rendered 
nothing.
+  it("renders per-option buttons for exactly 4 options", () => {
+    const opts = ["Creator", "Explorer", "ExplorerCanPublish", "Viewer"];
+
+    renderForm(opts);
+    for (const opt of opts) {
+      expect(screen.getByTestId(`hitl-option-${opt}`)).toBeInTheDocument();
+    }
+  });
+
+  it("does NOT render per-option buttons for 5 options", () => {
+    const opts = ["A", "B", "C", "D", "E"];
+
+    renderForm(opts);
+    for (const opt of opts) {
+      
expect(screen.queryByTestId(`hitl-option-${opt}`)).not.toBeInTheDocument();
+    }
+  });
+
+  it("does NOT render per-option buttons when multiple=true", () => {
+    renderForm(["A", "B"], { multiple: true });
+    expect(screen.queryByTestId("hitl-option-A")).not.toBeInTheDocument();
+    expect(screen.queryByTestId("hitl-option-B")).not.toBeInTheDocument();
+  });
+
+  it("renders Approve and Reject buttons for a 2-option approval task", () => {
+    renderForm(["Approve", "Reject"]);
+    expect(screen.getByTestId("hitl-option-Approve")).toBeInTheDocument();
+    expect(screen.getByTestId("hitl-option-Reject")).toBeInTheDocument();
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
index 057cb032abe..b12f78a4080 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLResponseForm.tsx
@@ -68,7 +68,7 @@ export const HITLResponseForm = ({ hitlDetail }: 
HITLResponseFormProps) => {
     hitlDetail.options.length === 2;
 
   const shouldRenderOptionButton =
-    hitlDetail.options.length < 4 && !hitlDetail.multiple && 
preloadedHITLOptions.length === 0;
+    hitlDetail.options.length <= 4 && !hitlDetail.multiple && 
preloadedHITLOptions.length === 0;
 
   const isPending = hitlDetail.task_instance.state === "deferred";
 

Reply via email to