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

shubhamraj-git 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 1a019979335 Add UI support to render multi-type params (#66278)
1a019979335 is described below

commit 1a019979335ac68696ababbe71f021b1447cf4cb
Author: Shubham Raj <[email protected]>
AuthorDate: Mon May 18 15:29:58 2026 +0530

    Add UI support to render multi-type params (#66278)
---
 airflow-core/docs/core-concepts/params.rst         |  36 +++
 .../example_dags/example_params_ui_tutorial.py     |  40 ++++
 .../FlexibleForm/FieldMultiType.test.tsx           | 263 +++++++++++++++++++++
 .../src/components/FlexibleForm/FieldMultiType.tsx | 118 +++++++++
 .../src/components/FlexibleForm/FieldSelector.tsx  |  13 +
 5 files changed, 470 insertions(+)

diff --git a/airflow-core/docs/core-concepts/params.rst 
b/airflow-core/docs/core-concepts/params.rst
index cdcd150a73b..de2598b95b4 100644
--- a/airflow-core/docs/core-concepts/params.rst
+++ b/airflow-core/docs/core-concepts/params.rst
@@ -341,6 +341,42 @@ The following features are supported in the Trigger UI 
Form:
           -
           - ``Param(None, type=["null", "string"])``
 
+        * - Multiple non-null types
+
+            e.g. ``["string", "object"]``,
+            ``["integer", "string"]``
+          - | Generates a plain multi-line textarea.
+            | The stored value type is resolved at
+            | input time: the input is first parsed as
+            | JSON; if the parsed type matches one of
+            | the declared schema types it is stored as
+            | that type, otherwise the raw string is
+            | stored. This means:
+            |
+            | * ``"nightly"`` → always stored as a string
+            |   (JSON parse fails).
+            | * ``"45"`` with ``["string", "object"]`` →
+            |   stored as the string ``"45"`` (number is
+            |   not in the schema).
+            | * ``"45"`` with ``["integer", "string"]`` →
+            |   stored as the integer ``45`` (number
+            |   matches ``"integer"``).
+            | * ``'{"key": "val"}'`` with
+            |   ``["string", "object"]`` → stored as an
+            |   object.
+
+            .. note::
+
+               If the schema also defines ``enum`` or
+               ``examples``, the normal dropdown or
+               multi-select widget is used instead of
+               the textarea, because the set of valid
+               values is already constrained.
+          - none.
+          - ``Param("nightly", type=["string", "object"])``
+
+            ``Param(5, type=["integer", "string"])``
+
 - If a form field is left empty, it is passed as ``None`` value to the params 
dict.
 - Form fields are rendered in the order of definition of ``params`` in the Dag.
 - If you want to add sections to the Form, add the attribute ``section`` to 
each field. The text will be used as section label.
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 a7dcc0bec86..df872f72133 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
@@ -235,6 +235,46 @@ with DAG(
             },
             section="Special advanced stuff with form fields",
         ),
+        # Multi-type parameters
+        "batch_size": Param(
+            1000,
+            type=["integer", "string"],
+            title="Batch size (int or 'all')",
+            description_md=(
+                "Number of rows per batch as an integer, **or** the string 
``all`` to process "
+                "everything in one pass. Try `500` → integer, `all` → string."
+            ),
+            section="Multi-type parameters",
+        ),
+        "notify": Param(
+            True,
+            type=["boolean", "string"],
+            title="Notification target",
+            description=(
+                "Set true/false to toggle the default recipient, or enter an 
email address "
+                "(string) to override it."
+            ),
+            section="Multi-type parameters",
+        ),
+        "pipeline_config": Param(
+            "nightly-export",
+            type=["string", "object"],
+            title="Pipeline (name or full config)",
+            description_md=(
+                "Pipeline shorthand name, or a JSON object with the full 
config, e.g. "
+                '`{"name": "nightly", "retries": 2}`'
+            ),
+            section="Multi-type parameters",
+        ),
+        "priority": Param(
+            5,
+            type=["integer", "string"],
+            title="Priority (number or label)",
+            description=(
+                "Numeric priority as an integer, or one of the named levels: 
'low', 'normal', 'high'."
+            ),
+            section="Multi-type parameters",
+        ),
         # If you want to have static parameters which are always passed and 
not editable by the user
         # then you can use the JSON schema option of passing constant values. 
These parameters
         # will not be displayed but passed to the DAG
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiType.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiType.test.tsx
new file mode 100644
index 00000000000..ce4be3c22bb
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiType.test.tsx
@@ -0,0 +1,263 @@
+/*!
+ * 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 { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { FieldMultiType } from "./FieldMultiType";
+
+type ParamEntry = {
+  schema: { type: Array<string> | string | undefined };
+  value: unknown;
+};
+const mockParamsDict: Record<string, ParamEntry> = {};
+const mockSetParamsDict = vi.fn();
+
+vi.mock("src/queries/useParamStore", () => ({
+  paramPlaceholder: {
+    schema: { type: undefined },
+    value: null,
+  },
+  useParamStore: () => ({
+    disabled: false,
+    paramsDict: mockParamsDict,
+    setParamsDict: mockSetParamsDict,
+  }),
+}));
+
+describe("FieldMultiType", () => {
+  beforeEach(() => {
+    Object.keys(mockParamsDict).forEach((key) => {
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete mockParamsDict[key];
+    });
+    mockSetParamsDict.mockClear();
+  });
+
+  describe("display", () => {
+    it("renders a textarea", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["string", "object"] },
+        value: "nightly",
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      expect(screen.getByRole("textbox")).toBeDefined();
+    });
+
+    it("displays an object default as pretty-printed JSON", () => {
+      const obj = { name: "my_pipeline", retries: 3 };
+
+      mockParamsDict.test_param = {
+        schema: { type: ["string", "object"] },
+        value: obj,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      expect(screen.getByRole("textbox")).toHaveProperty("value", 
JSON.stringify(obj, undefined, 2));
+    });
+
+    it("displays a string default as-is", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["string", "object"] },
+        value: "my_pipeline",
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      expect(screen.getByRole("textbox")).toHaveProperty("value", 
"my_pipeline");
+    });
+
+    it("displays a number default as a string", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["integer", "string"] },
+        value: 42,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      expect(screen.getByRole("textbox")).toHaveProperty("value", "42");
+    });
+
+    it("displays a boolean default as a string", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["boolean", "string"] },
+        value: true,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      expect(screen.getByRole("textbox")).toHaveProperty("value", "true");
+    });
+  });
+
+  describe("type resolution on change", () => {
+    it("stores a valid JSON object when schema includes 'object'", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["string", "object"] },
+        value: {},
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: 
'{"key": "val"}' } });
+      expect(mockParamsDict.test_param.value).toEqual({ key: "val" });
+    });
+
+    it("stores a plain string when JSON parse fails", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["string", "object"] },
+        value: {},
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: 
"nightly" } });
+      expect(mockParamsDict.test_param.value).toBe("nightly");
+    });
+
+    it("stores '45' as a string for type=['string','object'] — number not in 
schema", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["string", "object"] },
+        value: {},
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: "45" } 
});
+      expect(mockParamsDict.test_param.value).toBe("45");
+      expect(typeof mockParamsDict.test_param.value).toBe("string");
+    });
+
+    it("stores 45 as a number for type=['integer','string']", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["integer", "string"] },
+        value: "nightly",
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: "45" } 
});
+      expect(mockParamsDict.test_param.value).toBe(45);
+      expect(typeof mockParamsDict.test_param.value).toBe("number");
+    });
+
+    it("stores a string for type=['integer','string'] when input is 
non-numeric text", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["integer", "string"] },
+        value: 0,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: 
"nightly" } });
+      expect(mockParamsDict.test_param.value).toBe("nightly");
+    });
+
+    it("stores true as boolean for type=['boolean','string']", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["boolean", "string"] },
+        value: "pending",
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: "true" 
} });
+      expect(mockParamsDict.test_param.value).toBe(true);
+    });
+
+    it("stores a string for type=['number','string'] when input is not a 
number", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["number", "string"] },
+        value: 0,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: 
"nightly" } });
+      expect(mockParamsDict.test_param.value).toBe("nightly");
+    });
+
+    it("stores null on empty input", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["string", "object"] },
+        value: "something",
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: "" } });
+      expect(mockParamsDict.test_param.value).toBeNull();
+    });
+
+    it("calls onUpdate with the raw input string", () => {
+      const onUpdate = vi.fn();
+
+      mockParamsDict.test_param = {
+        schema: { type: ["string", "object"] },
+        value: {},
+      };
+      render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: 
"nightly" } });
+      expect(onUpdate).toHaveBeenCalledWith("nightly");
+    });
+  });
+
+  describe("validation errors for schemas without 'string'", () => {
+    it("signals error and preserves old value when input doesn't match 
type=['integer','object']", () => {
+      const onUpdate = vi.fn();
+
+      mockParamsDict.test_param = {
+        schema: { type: ["integer", "object"] },
+        value: 0,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: 
"nightly" } });
+      expect(onUpdate).toHaveBeenCalledWith("", 
expect.stringContaining("integer"));
+      expect(mockParamsDict.test_param.value).toBe(0);
+    });
+
+    it("accepts a valid integer for type=['integer','object']", () => {
+      const onUpdate = vi.fn();
+
+      mockParamsDict.test_param = {
+        schema: { type: ["integer", "object"] },
+        value: 0,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: "42" } 
});
+      expect(onUpdate).toHaveBeenCalledWith("42");
+      expect(mockParamsDict.test_param.value).toBe(42);
+    });
+
+    it("accepts a valid JSON object for type=['integer','object']", () => {
+      const onUpdate = vi.fn();
+
+      mockParamsDict.test_param = {
+        schema: { type: ["integer", "object"] },
+        value: 0,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: 
'{"key": 1}' } });
+      expect(onUpdate).toHaveBeenCalledWith('{"key": 1}');
+      expect(mockParamsDict.test_param.value).toEqual({ key: 1 });
+    });
+
+    it("signals error for type=['boolean','object'] when input is neither", () 
=> {
+      const onUpdate = vi.fn();
+
+      mockParamsDict.test_param = {
+        schema: { type: ["boolean", "object"] },
+        value: true,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: 
"nightly" } });
+      expect(onUpdate).toHaveBeenCalledWith("", 
expect.stringContaining("boolean"));
+      expect(mockParamsDict.test_param.value).toBe(true);
+    });
+
+    it("stores '4.5' as a string for type=['integer','string'] — float is not 
a valid integer", () => {
+      mockParamsDict.test_param = {
+        schema: { type: ["integer", "string"] },
+        value: 0,
+      };
+      render(<FieldMultiType name="test_param" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+      fireEvent.change(screen.getByRole("textbox"), { target: { value: "4.5" } 
});
+      expect(mockParamsDict.test_param.value).toBe("4.5");
+      expect(typeof mockParamsDict.test_param.value).toBe("string");
+    });
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiType.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiType.tsx
new file mode 100644
index 00000000000..77afcaeb2f1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiType.tsx
@@ -0,0 +1,118 @@
+/*!
+ * 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 { Textarea } from "@chakra-ui/react";
+import { useState } from "react";
+
+import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
+
+import type { FlexibleFormElementProps } from ".";
+
+const matchesSchemaType = (parsed: unknown, types: Array<string | undefined>): 
boolean => {
+  const valueType = Array.isArray(parsed) ? "array" : parsed === null ? "null" 
: typeof parsed;
+
+  return types.some(
+    (type) =>
+      type === valueType ||
+      // JSON Schema "integer" is a subtype of "number"; exclude floats that 
JSON.parse accepts.
+      (type === "integer" && valueType === "number" && 
Number.isInteger(parsed)),
+  );
+};
+
+export const FieldMultiType = ({ name, namespace = "default", onUpdate }: 
FlexibleFormElementProps) => {
+  const { disabled, paramsDict, setParamsDict } = useParamStore(namespace);
+  const param = paramsDict[name] ?? paramPlaceholder;
+
+  // Tracks raw user input so the textarea doesn't snap back to the last valid 
value while the
+  // user is typing something invalid. Cleared on valid input so external 
updates (e.g. prefill) show through.
+  const [inputText, setInputText] = useState<string | null>(null);
+
+  const schemaTypes = Array.isArray(param.schema.type) ? param.schema.type : 
[param.schema.type];
+  const nonNullTypes = schemaTypes.filter((type): type is string => 
Boolean(type));
+  const stringIsAllowed = nonNullTypes.includes("string");
+
+  const storedDisplay =
+    param.value === null || param.value === undefined
+      ? ""
+      : typeof param.value === "string"
+        ? param.value
+        : JSON.stringify(param.value, undefined, 2);
+
+  const displayValue = inputText ?? storedDisplay;
+
+  const handleChange = (value: string) => {
+    if (!paramsDict[name]) {
+      onUpdate(value);
+
+      return;
+    }
+
+    if (value === "") {
+      // "undefined" values are removed from params, so we set it to null to 
avoid falling back to DAG defaults.
+      paramsDict[name].value = null;
+      setInputText(null);
+      setParamsDict(paramsDict);
+      onUpdate(value);
+
+      return;
+    }
+
+    let resolved: unknown;
+    let matched = false;
+
+    try {
+      const parsed = JSON.parse(value) as unknown;
+
+      if (matchesSchemaType(parsed, nonNullTypes)) {
+        resolved = parsed;
+        matched = true;
+      }
+    } catch {
+      // not valid JSON — fall through
+    }
+
+    // String in schema means raw text is always a valid fallback.
+    if (!matched && stringIsAllowed) {
+      resolved = value;
+      matched = true;
+    }
+
+    if (matched) {
+      paramsDict[name].value = resolved;
+      setInputText(null);
+      setParamsDict(paramsDict);
+      onUpdate(value);
+    } else {
+      // Don't overwrite the last valid stored value; keep the typed text 
visible and signal the error.
+      setInputText(value);
+      onUpdate("", `Value must be one of: ${nonNullTypes.join(", ")}`);
+    }
+  };
+
+  return (
+    <Textarea
+      disabled={disabled}
+      id={`element_${name}`}
+      name={`element_${name}`}
+      onChange={(event) => handleChange(event.target.value)}
+      rows={6}
+      size="sm"
+      value={displayValue}
+    />
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
index 716b7c48be0..e43c006b5cb 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
@@ -25,6 +25,7 @@ import { FieldBool } from "./FieldBool";
 import { FieldDateTime } from "./FieldDateTime";
 import { FieldDropdown } from "./FieldDropdown";
 import { FieldMultiSelect } from "./FieldMultiSelect";
+import { FieldMultiType } from "./FieldMultiType";
 import { FieldMultilineText } from "./FieldMultilineText";
 import { FieldNumber } from "./FieldNumber";
 import { FieldObject } from "./FieldObject";
@@ -88,6 +89,12 @@ const isFieldObject = (fieldType: string) => fieldType === 
"object";
 const isFieldStringArray = (fieldType: string, fieldSchema: ParamSchema) =>
   fieldType === "array" && (fieldSchema.items?.type === undefined || 
fieldSchema.items.type === "string");
 
+const isFieldMultiType = (fieldSchema: ParamSchema) =>
+  Array.isArray(fieldSchema.type) &&
+  fieldSchema.type.filter((type) => type !== "null").length > 1 &&
+  !Array.isArray(fieldSchema.enum) &&
+  !Array.isArray(fieldSchema.examples);
+
 const isFieldTime = (fieldType: string, fieldSchema: ParamSchema) =>
   fieldType === "string" && fieldSchema.format === "time";
 
@@ -105,6 +112,12 @@ export const FieldSelector = ({ name, namespace = 
"default", onUpdate }: Flexibl
     value: currentParam?.value ?? initialParam.value,
   };
 
+  // Check for multiple non-null types before inferring a single type —
+  // inferType picks only the first, discarding the others.
+  if (isFieldMultiType(param.schema)) {
+    return <FieldMultiType name={name} namespace={namespace} 
onUpdate={onUpdate} />;
+  }
+
   const fieldType = inferType(param);
 
   if (isFieldBool(fieldType)) {

Reply via email to