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

pierrejeambrun 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 8cbbf7c62c5 Fix HITL form crashing on null values (#72375)
8cbbf7c62c5 is described below

commit 8cbbf7c62c5fac74525aeeed6394fe38e98f54a2
Author: Brent Bovenzi <[email protected]>
AuthorDate: Fri Sep 4 12:17:23 2026 -0400

    Fix HITL form crashing on null values (#72375)
---
 .../components/FlexibleForm/FieldDateTime.test.tsx | 26 +++++++++++
 .../src/components/FlexibleForm/FieldDateTime.tsx  |  6 ++-
 airflow-core/src/airflow/ui/src/utils/hitl.test.ts | 52 ++++++++++++++++++++++
 airflow-core/src/airflow/ui/src/utils/hitl.ts      | 13 ++++--
 4 files changed, 92 insertions(+), 5 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.test.tsx
index 4eeab50bd76..97766d91e76 100644
--- 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.test.tsx
@@ -151,3 +151,29 @@ describe("FieldDateTime — time field (issue #66492)", () 
=> {
     expect(getInputByName("cutoff_time").value).toBe("09:15:30");
   });
 });
+
+describe("FieldDateTime — non-string values", () => {
+  beforeEach(() => {
+    mockSetParamsDict.mockClear();
+    Object.keys(mockParamsDict).forEach((key) => {
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete mockParamsDict[key];
+    });
+  });
+
+  it.each([
+    ["a serialized param wrapper", { description: null, schema: { format: 
"date" }, value: null }],
+    ["a number", 20_260_528],
+    ["an array", ["2026-05-28"]],
+  ])("renders an empty date input instead of throwing for %s", (_label, value) 
=> {
+    mockParamsDict.cutoff_date = { schema: { format: "date", type: "string" }, 
value };
+
+    expect(() =>
+      render(<FieldDateTime name="cutoff_date" onUpdate={vi.fn()} type="date" 
/>, {
+        wrapper: Wrapper,
+      }),
+    ).not.toThrow();
+
+    expect(getInputByName("cutoff_date").value).toBe("");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
index f8596f64ad0..4efbeed3da6 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
@@ -51,7 +51,7 @@ export const FieldDateTime = ({
         name={`element_${name}`}
         onChange={(event) => handleChange(event.target.value)}
         size="sm"
-        value={(param.value as string) || ""}
+        value={typeof param.value === "string" ? param.value : ""}
       />
     );
   }
@@ -66,7 +66,9 @@ export const FieldDateTime = ({
       size="sm"
       step={isTime ? 1 : undefined}
       type={type}
-      value={((param.value ?? "") as string).slice(0, 16)}
+      // A non-string value (e.g. an object leaking in from a malformed param) 
must degrade to an
+      // empty input rather than throw and take the whole form down.
+      value={typeof param.value === "string" ? param.value.slice(0, 16) : ""}
     />
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/utils/hitl.test.ts 
b/airflow-core/src/airflow/ui/src/utils/hitl.test.ts
index 90dd1c7fdaf..60dbb52dc42 100644
--- a/airflow-core/src/airflow/ui/src/utils/hitl.test.ts
+++ b/airflow-core/src/airflow/ui/src/utils/hitl.test.ts
@@ -112,4 +112,56 @@ describe("getHITLParamsDict", () => {
     expect(paramsDict.objectParam?.schema.type).toBe("object");
     expect(paramsDict.objectParam?.value).toEqual({ key: "value", nested: { 
data: 123 } });
   });
+
+  it("resolves a serialized param whose default is null to null, not the 
wrapper object", () => {
+    const hitlDetail = createMockHITLDetail({
+      params: {
+        freeze_until: {
+          description: null,
+          schema: { format: "date", title: "Code freeze until", type: 
["string", "null"] },
+          value: null,
+        },
+      },
+    });
+
+    const paramsDict = getHITLParamsDict(hitlDetail, mockTranslate, new 
URLSearchParams());
+
+    expect(paramsDict.freeze_until?.value).toBeNull();
+    expect(paramsDict.freeze_until?.schema.format).toBe("date");
+    expect(paramsDict.freeze_until?.schema.type).toEqual(["string", "null"]);
+  });
+
+  it("keeps a serialized param's non-null default", () => {
+    const hitlDetail = createMockHITLDetail({
+      params: {
+        health_check_at: {
+          description: null,
+          schema: { format: "time", type: "string" },
+          value: "09:00:00",
+        },
+      },
+    });
+
+    const paramsDict = getHITLParamsDict(hitlDetail, mockTranslate, new 
URLSearchParams());
+
+    expect(paramsDict.health_check_at?.value).toBe("09:00:00");
+  });
+
+  it("prefers a submitted value over the serialized default", () => {
+    const hitlDetail = createMockHITLDetail({
+      params: {
+        freeze_until: {
+          description: null,
+          schema: { format: "date", type: ["string", "null"] },
+          value: null,
+        },
+      },
+      params_input: { freeze_until: "2026-09-03" },
+      response_received: true,
+    });
+
+    const paramsDict = getHITLParamsDict(hitlDetail, mockTranslate, new 
URLSearchParams());
+
+    expect(paramsDict.freeze_until?.value).toBe("2026-09-03");
+  });
 });
diff --git a/airflow-core/src/airflow/ui/src/utils/hitl.ts 
b/airflow-core/src/airflow/ui/src/utils/hitl.ts
index e47cd5298d6..573dab8c9d8 100644
--- a/airflow-core/src/airflow/ui/src/utils/hitl.ts
+++ b/airflow-core/src/airflow/ui/src/utils/hitl.ts
@@ -20,7 +20,7 @@ import type { TFunction } from "i18next";
 
 import type { HITLDetail, HITLDetailHistory, TaskInstanceState } from 
"openapi/requests/types.gen";
 
-import type { ParamSchema, ParamsSpec } from "src/queries/useDagParams";
+import type { ParamSchema, ParamSpec, ParamsSpec } from 
"src/queries/useDagParams";
 
 export type HITLResponseParams = {
   chosen_options?: Array<string>;
@@ -118,10 +118,17 @@ export const getHITLParamsDict = (
       if (!hitlDetail.params) {
         return;
       }
-      const paramData = hitlDetail.params[key] as ParamsSpec | undefined;
+      const paramData = hitlDetail.params[key] as ParamSpec | undefined;
+
+      // A serialized param arrives as a `{value, description, schema}` 
wrapper, but `params` may
+      // also carry a bare value. Only unwrap the former: reading 
`paramData.value` with `??` made a
+      // `null` default (nullish) fall through to `value`, which for a wrapper 
is the whole object —
+      // that then reached the widget and broke it. Probing for the key 
distinguishes "wrapper whose
+      // default is null" from "bare value".
+      const isWrapped = paramData !== undefined && "value" in paramData;
 
       // Check if there's a preloaded value from URL params
-      let finalValue = hitlDetail.params_input?.[key] ?? paramData?.value ?? 
value;
+      let finalValue = hitlDetail.params_input?.[key] ?? (isWrapped ? 
paramData.value : value);
 
       // If preloaded value is a string that might be JSON, try to parse it
       if (typeof finalValue === "string" && finalValue.trim().startsWith("{")) 
{

Reply via email to