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";