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("{"))
{