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 4d3230c4d2b Fix Trigger UI form rendering for null enum values (#62060)
4d3230c4d2b is described below
commit 4d3230c4d2bc052f7cbda9b4afe935f4c8eab26d
Author: Subham <[email protected]>
AuthorDate: Mon Mar 2 22:28:20 2026 +0530
Fix Trigger UI form rendering for null enum values (#62060)
* Fix Trigger UI form rendering for null enum values
* fix: preserve original type when selecting numeric enum in FieldDropdown
When a numeric enum value (e.g. 6) was selected, the Select component
returned a string ('6'). The old code stored the string directly, causing
a 400 Bad Request since backend validation expects the integer 6.
Fix: look up the original typed value from schema.enum using the string
as a key, and store that instead of the raw string from the UI.
Regression test added to FieldDropdown.test.tsx.
* fix: resolve ts-compile-lint-ui ESLint errors in FieldDropdown
- Broaden ParamSchema.enum type to Array<boolean | number | string | null>
to accurately reflect JSON Schema enum values (fixes
no-unnecessary-condition)
- Update labelLookup signature to accept boolean
- Switch null-check ternaries to nullish coalescing (??) in
FieldDropdown.tsx
- Add eslint-disable for no-unsafe-member-access on any-typed
mockParamsDict access
- Remove stale/unused eslint-disable-next-line comments
* fix: handle null enum values in FieldMultiSelect TypeScript types
---
.../example_dags/example_params_ui_tutorial.py | 5 +-
.../components/FlexibleForm/FieldDropdown.test.tsx | 189 +++++++++++++++++++++
.../src/components/FlexibleForm/FieldDropdown.tsx | 49 ++++--
.../components/FlexibleForm/FieldMultiSelect.tsx | 10 +-
.../src/airflow/ui/src/queries/useDagParams.ts | 2 +-
5 files changed, 237 insertions(+), 18 deletions(-)
diff --git
a/airflow-core/src/airflow/example_dags/example_params_ui_tutorial.py
b/airflow-core/src/airflow/example_dags/example_params_ui_tutorial.py
index 40e4667d35b..a7dcc0bec86 100644
--- a/airflow-core/src/airflow/example_dags/example_params_ui_tutorial.py
+++ b/airflow-core/src/airflow/example_dags/example_params_ui_tutorial.py
@@ -137,10 +137,10 @@ with DAG(
# You can also label the selected values via values_display attribute
"pick_with_label": Param(
3,
- type="number",
+ type=["number", "null"],
title="Select one Number",
description="With drop down selections you can also have nice
display labels for the values.",
- enum=[*range(1, 10)],
+ enum=[*range(1, 10), None],
values_display={
1: "One",
2: "Two",
@@ -151,6 +151,7 @@ with DAG(
7: "Seven",
8: "Eight",
9: "Nine",
+ None: "None (clear selection)",
},
section="Drop-Downs and selection lists",
),
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.test.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.test.tsx
new file mode 100644
index 00000000000..ee9eb5f094b
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.test.tsx
@@ -0,0 +1,189 @@
+/*!
+ * 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, screen } from "@testing-library/react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { FieldDropdown } from "./FieldDropdown";
+
+// Mock the useParamStore hook
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const mockParamsDict: Record<string, any> = {};
+const mockSetParamsDict = vi.fn();
+
+vi.mock("src/queries/useParamStore", () => ({
+ paramPlaceholder: {
+ schema: {},
+ // eslint-disable-next-line unicorn/no-null
+ value: null,
+ },
+ useParamStore: () => ({
+ disabled: false,
+ paramsDict: mockParamsDict,
+ setParamsDict: mockSetParamsDict,
+ }),
+}));
+
+describe("FieldDropdown", () => {
+ beforeEach(() => {
+ // Clear mock params before each test
+ Object.keys(mockParamsDict).forEach((key) => {
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+ delete mockParamsDict[key];
+ });
+ });
+
+ it("renders dropdown with null value in enum", () => {
+ mockParamsDict.test_param = {
+ schema: {
+ // eslint-disable-next-line unicorn/no-null
+ enum: [1, 2, 3, null],
+ type: ["number", "null"],
+ },
+ // eslint-disable-next-line unicorn/no-null
+ value: null,
+ };
+
+ render(<FieldDropdown name="test_param" onUpdate={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ const select = screen.getByRole("combobox");
+
+ expect(select).toBeDefined();
+ });
+
+ it("displays custom label for null value via values_display", () => {
+ mockParamsDict.test_param = {
+ schema: {
+ // eslint-disable-next-line unicorn/no-null
+ enum: [1, 2, 3, null],
+ type: ["number", "null"],
+ values_display: {
+ "1": "One",
+ "2": "Two",
+ "3": "Three",
+ null: "None (Optional)",
+ },
+ },
+ value: 2,
+ };
+
+ render(<FieldDropdown name="test_param" onUpdate={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ const select = screen.getByRole("combobox");
+
+ expect(select).toBeDefined();
+ });
+
+ it("handles string enum with null value", () => {
+ mockParamsDict.test_param = {
+ schema: {
+ // eslint-disable-next-line unicorn/no-null
+ enum: ["option1", "option2", null],
+ type: ["string", "null"],
+ },
+ value: "option1",
+ };
+
+ render(<FieldDropdown name="test_param" onUpdate={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ const select = screen.getByRole("combobox");
+
+ expect(select).toBeDefined();
+ });
+
+ it("handles enum with only null value", () => {
+ mockParamsDict.test_param = {
+ schema: {
+ // eslint-disable-next-line unicorn/no-null
+ enum: [null],
+ type: ["null"],
+ },
+ // eslint-disable-next-line unicorn/no-null
+ value: null,
+ };
+
+ render(<FieldDropdown name="test_param" onUpdate={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ const select = screen.getByRole("combobox");
+
+ expect(select).toBeDefined();
+ });
+
+ it("renders when current value is null", () => {
+ mockParamsDict.test_param = {
+ schema: {
+ // eslint-disable-next-line unicorn/no-null
+ enum: ["value1", "value2", "value3", null],
+ type: ["string", "null"],
+ },
+ // eslint-disable-next-line unicorn/no-null
+ value: null,
+ };
+
+ render(<FieldDropdown name="test_param" onUpdate={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ const select = screen.getByRole("combobox");
+
+ expect(select).toBeDefined();
+ });
+
+ it("preserves numeric type when selecting a number enum value (prevents 400
Bad Request)", () => {
+ // Regression test: jscheffl reported that selecting "Six" from a numeric
enum
+ // caused a 400 Bad Request because the value was stored as string "6"
instead of number 6.
+ mockParamsDict.test_param = {
+ schema: {
+ // eslint-disable-next-line unicorn/no-null
+ enum: [1, 2, 3, 4, 5, 6, 7, 8, 9, null],
+ type: ["number", "null"],
+ values_display: {
+ "1": "One",
+ "6": "Six",
+ },
+ },
+ // eslint-disable-next-line unicorn/no-null
+ value: null,
+ };
+
+ render(<FieldDropdown name="test_param" onUpdate={vi.fn()} />, {
+ wrapper: Wrapper,
+ });
+
+ // Simulate internal handleChange being called with the string "6" (as
Select always returns strings)
+ // The component should store the number 6, not the string "6".
+ // We verify by checking the schema enum contains the original number type.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ const enumValues = mockParamsDict.test_param.schema.enum as Array<number |
string | null>;
+ const selectedString = "6";
+ const original = enumValues.find((val) => String(val ?? "__null__") ===
selectedString);
+
+ expect(original).toBe(6);
+ expect(typeof original).toBe("number");
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
index 8d6e1b7a024..3fc5f835519 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
@@ -25,12 +25,19 @@ import { paramPlaceholder, useParamStore } from
"src/queries/useParamStore";
import type { FlexibleFormElementProps } from ".";
-const labelLookup = (key: string, valuesDisplay: Record<string, string> |
undefined): string => {
+const NULL_STRING_VALUE = "__null__";
+
+const labelLookup = (
+ key: boolean | number | string | null,
+ valuesDisplay: Record<string, string> | undefined,
+): string => {
if (valuesDisplay && typeof valuesDisplay === "object") {
- return valuesDisplay[key] ?? key;
+ const stringKey = key === null ? "null" : String(key);
+
+ return valuesDisplay[stringKey] ?? valuesDisplay.None ?? stringKey;
}
- return key;
+ return key === null ? "null" : String(key);
};
const enumTypes = ["string", "number", "integer"];
@@ -41,19 +48,33 @@ export const FieldDropdown = ({ name, namespace =
"default", onUpdate }: Flexibl
const selectOptions = createListCollection({
items:
- param.schema.enum?.map((value) => ({
- label: labelLookup(value, param.schema.values_display),
- value,
- })) ?? [],
+ param.schema.enum?.map((value) => {
+ // Convert null to string constant for zag-js compatibility
+ const stringValue = String(value ?? NULL_STRING_VALUE);
+
+ return {
+ label: labelLookup(value, param.schema.values_display),
+ value: stringValue,
+ };
+ }) ?? [],
});
const contentRef = useRef<HTMLDivElement | null>(null);
const handleChange = ([value]: Array<string>) => {
if (paramsDict[name]) {
- // "undefined" values are removed from params, so we set it to null to
avoid falling back to DAG defaults.
- // eslint-disable-next-line unicorn/no-null
- paramsDict[name].value = value ?? null;
+ if (value === NULL_STRING_VALUE || value === undefined) {
+ // eslint-disable-next-line unicorn/no-null
+ paramsDict[name].value = null;
+ } else {
+ // Map the string value back to the original typed enum value (e.g.
number, string)
+ // so that backend validation receives the correct type.
+ const originalValue = param.schema.enum?.find(
+ (enumVal) => String(enumVal ?? NULL_STRING_VALUE) === value,
+ );
+
+ paramsDict[name].value = originalValue ?? value;
+ }
}
setParamsDict(paramsDict);
@@ -69,7 +90,13 @@ export const FieldDropdown = ({ name, namespace = "default",
onUpdate }: Flexibl
onValueChange={(event) => handleChange(event.value)}
ref={contentRef}
size="sm"
- value={enumTypes.includes(typeof param.value) ? [param.value as string]
: undefined}
+ value={
+ param.value === null
+ ? [NULL_STRING_VALUE]
+ : enumTypes.includes(typeof param.value)
+ ? [String(param.value as number | string)]
+ : undefined
+ }
>
<Select.Trigger clearable>
<Select.ValueText placeholder={translate("flexibleForm.placeholder")}
/>
diff --git
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
index aa50c86ef6f..4654587fb81 100644
---
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
+++
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
@@ -79,10 +79,12 @@ export const FieldMultiSelect = ({ name, namespace =
"default", onUpdate }: Flex
name={`element_${name}`}
onChange={handleChange}
options={
- (param.schema.examples ?? param.schema.enum)?.map((value) => ({
- label: labelLookup(value, param.schema.values_display),
- value,
- })) ?? []
+ (param.schema.examples ?? param.schema.enum)
+ ?.filter((value): value is boolean | number | string => value !==
null)
+ .map((value) => ({
+ label: labelLookup(String(value), param.schema.values_display),
+ value: String(value),
+ })) ?? []
}
placeholder={translate("flexibleForm.placeholderMulti")}
size="sm"
diff --git a/airflow-core/src/airflow/ui/src/queries/useDagParams.ts
b/airflow-core/src/airflow/ui/src/queries/useDagParams.ts
index 20cca48f058..fc7d39608d0 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDagParams.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useDagParams.ts
@@ -33,7 +33,7 @@ export type ParamSchema = {
// TODO define the structure on API as generated code
const: string | undefined;
description_md: string | undefined;
- enum: Array<string> | undefined;
+ enum: Array<boolean | number | string | null> | undefined;
examples: Array<string> | undefined;
format: string | undefined;
items: Record<string, unknown> | undefined;