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

jscheffl 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 19d80a886bf Use one chips editor for all simple array params (#76018)
19d80a886bf is described below

commit 19d80a886bf1eb990d119658fee48c2234d28e1a
Author: Software Developer <[email protected]>
AuthorDate: Mon Nov 2 17:25:29 2026 +0100

    Use one chips editor for all simple array params (#76018)
    
    * use one chips editor for all simple array params.
    
    * fixing PR remarks.
---
 .../ui/public/i18n/locales/en/components.json      |   3 +-
 .../components/FlexibleForm/FieldArray.test.tsx    | 182 +++++++++++++++++++++
 .../ui/src/components/FlexibleForm/FieldArray.tsx  | 141 ++++++++++++++++
 .../components/FlexibleForm/FieldMultiSelect.tsx   |   4 +
 .../components/FlexibleForm/FieldSelector.test.tsx |  87 ++++++++++
 .../src/components/FlexibleForm/FieldSelector.tsx  |  17 +-
 .../components/FlexibleForm/FieldStringArray.tsx   |  70 --------
 .../components/FlexibleForm/parseArrayElement.ts   |  62 +++++++
 8 files changed, 488 insertions(+), 78 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 19790cb7bc2..52977dcc516 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -95,9 +95,10 @@
   "flexibleForm": {
     "durationPlaceholder": "Enter Duration in ISO 8601 format",
     "placeholder": "Select Value",
-    "placeholderArray": "Enter each string on a new line",
+    "placeholderArray": "Add a value and press Enter",
     "placeholderExamples": "Start typing to see options",
     "placeholderMulti": "Select one or multiple values",
+    "validationErrorArrayElement": "Invalid {{type}} value(s): {{values}}",
     "validationErrorArrayNotArray": "Value must be an array.",
     "validationErrorArrayNotNumbers": "All elements in the array must be 
numbers.",
     "validationErrorArrayNotObject": "All elements in the array must be 
objects.",
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldArray.test.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldArray.test.tsx
new file mode 100644
index 00000000000..4cb7a170c9d
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldArray.test.tsx
@@ -0,0 +1,182 @@
+/*!
+ * 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 { describe, it, expect, beforeEach, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { FieldArray } from "./FieldArray";
+import { parseArrayElement } from "./parseArrayElement";
+
+type MockParam = { schema: Record<string, unknown>; value: unknown };
+const mockParamsDict: Record<string, MockParam> = {};
+const mockSetParamsDict = vi.fn();
+
+vi.mock("src/queries/useParamStore", () => ({
+  paramPlaceholder: {
+    schema: {},
+    value: null,
+  },
+  useParamStore: () => ({
+    disabled: false,
+    paramsDict: mockParamsDict,
+    setParamsDict: mockSetParamsDict,
+  }),
+}));
+
+describe("parseArrayElement", () => {
+  it.each([
+    ["string", "abc", "abc"],
+    ["string", " padded ", "padded"],
+    ["string", "123", "123"],
+    ["number", "1.5", 1.5],
+    ["number", "abc", undefined],
+    ["integer", "42", 42],
+    ["integer", "1.5", undefined],
+    ["boolean", "true", true],
+    ["boolean", "false", false],
+    ["boolean", "yes", undefined],
+    [undefined, "1", 1],
+    [undefined, "true", true],
+    [undefined, "abc", "abc"],
+    [undefined, '"1"', "1"],
+  ])("items.type=%s: %s -> %s", (itemsType, raw, expected) => {
+    expect(parseArrayElement(raw, itemsType)).toStrictEqual(expected);
+  });
+
+  it("rejects empty entries", () => {
+    expect(parseArrayElement("   ", "string")).toBeUndefined();
+  });
+});
+
+describe("FieldArray", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    Object.keys(mockParamsDict).forEach((key) => {
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete mockParamsDict[key];
+    });
+  });
+
+  it("renders one chip per element", () => {
+    mockParamsDict.test_param = {
+      schema: { items: { type: "string" }, type: "array" },
+      value: ["schema1", "schema2"],
+    };
+
+    render(<FieldArray name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    expect(screen.getByText("schema1")).toBeDefined();
+    expect(screen.getByText("schema2")).toBeDefined();
+  });
+
+  it("adds an element on Enter and keeps the number type", () => {
+    mockParamsDict.test_param = {
+      schema: { items: { type: "number" }, type: "array" },
+      value: [1, 2],
+    };
+
+    render(<FieldArray name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    const input = screen.getByRole("combobox");
+
+    fireEvent.change(input, { target: { value: "3" } });
+    fireEvent.keyDown(input, { key: "Enter" });
+
+    expect(mockParamsDict.test_param.value).toStrictEqual([1, 2, 3]);
+    expect(mockSetParamsDict).toHaveBeenCalled();
+  });
+
+  it("reports a validation error for an invalid element and does not add it", 
() => {
+    mockParamsDict.test_param = {
+      schema: { items: { type: "number" }, type: "array" },
+      value: [1],
+    };
+    const onUpdate = vi.fn();
+
+    render(<FieldArray name="test_param" onUpdate={onUpdate} />, { wrapper: 
Wrapper });
+
+    const input = screen.getByRole("combobox");
+
+    fireEvent.change(input, { target: { value: "abc" } });
+    fireEvent.keyDown(input, { key: "Enter" });
+
+    expect(mockParamsDict.test_param.value).toStrictEqual([1]);
+    expect(onUpdate).toHaveBeenCalledWith(undefined, 
expect.stringContaining("validationErrorArrayElement"));
+  });
+
+  it("splits multiline paste into one element per line", () => {
+    mockParamsDict.test_param = {
+      schema: { items: { type: "string" }, type: "array" },
+      value: [],
+    };
+
+    render(<FieldArray name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    const input = screen.getByRole("combobox");
+
+    fireEvent.paste(input, { clipboardData: { getData: () => 
"one\ntwo\n\nthree\n" } });
+
+    expect(mockParamsDict.test_param.value).toStrictEqual(["one", "two", 
"three"]);
+  });
+
+  it("preserves element types for untyped arrays", () => {
+    mockParamsDict.test_param = {
+      schema: { type: "array" },
+      value: [1, 2],
+    };
+
+    render(<FieldArray name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    const input = screen.getByRole("combobox");
+
+    fireEvent.change(input, { target: { value: "3" } });
+    fireEvent.keyDown(input, { key: "Enter" });
+
+    expect(mockParamsDict.test_param.value).toStrictEqual([1, 2, 3]);
+  });
+
+  it("removes only the clicked chip when two chips have the same value", () => 
{
+    mockParamsDict.test_param = {
+      schema: { items: { type: "string" }, type: "array" },
+      value: ["dup", "dup", "other"],
+    };
+
+    render(<FieldArray name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    fireEvent.click(screen.getAllByLabelText("Remove dup")[0] as HTMLElement);
+
+    expect(mockParamsDict.test_param.value).toStrictEqual(["dup", "other"]);
+  });
+
+  it("stores null when the last chip is removed", () => {
+    mockParamsDict.test_param = {
+      schema: { items: { type: "string" }, type: "array" },
+      value: ["only"],
+    };
+
+    render(<FieldArray name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    const input = screen.getByRole("combobox");
+
+    fireEvent.keyDown(input, { key: "Backspace" });
+
+    expect(mockParamsDict.test_param.value).toBeNull();
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldArray.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldArray.tsx
new file mode 100644
index 00000000000..aedee87647a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldArray.tsx
@@ -0,0 +1,141 @@
+/*!
+ * 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 { useState, type ClipboardEvent, type KeyboardEvent } from "react";
+
+import { CreatableSelect, type MultiValue } from "chakra-react-select";
+import { useTranslation } from "react-i18next";
+
+import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
+
+import type { FlexibleFormElementProps } from ".";
+import { parseArrayElement, type ArrayElement } from "./parseArrayElement";
+
+type ChipOption = { index: number; label: string; value: ArrayElement };
+
+const toChip = (value: ArrayElement, index: number): ChipOption => ({ index, 
label: String(value), value });
+
+const getChipIdentity = (chip: ChipOption): string => String(chip.index);
+
+export const FieldArray = ({ name, namespace = "default", onUpdate }: 
FlexibleFormElementProps) => {
+  const { t: translate } = useTranslation("components");
+  const { disabled, paramsDict, setParamsDict } = useParamStore(namespace);
+  const [inputValue, setInputValue] = useState("");
+  const param = paramsDict[name] ?? paramPlaceholder;
+  const itemsType = param.schema.items?.type;
+
+  const elements: Array<ArrayElement> = Array.isArray(param.value)
+    ? (param.value as Array<ArrayElement>)
+    : [];
+
+  const setElements = (newElements: Array<ArrayElement>) => {
+    if (paramsDict[name]) {
+      // "undefined" values are removed from params, so we set it to null to 
avoid falling back to DAG defaults.
+      paramsDict[name].value = newElements.length ? newElements : null;
+    }
+    setParamsDict(paramsDict);
+  };
+
+  const addEntries = (rawText: string) => {
+    const entries = rawText.split("\n").filter((entry) => entry.trim() !== "");
+    const coerced = entries.map((entry) => ({ entry, value: 
parseArrayElement(entry, itemsType) }));
+    const invalid = coerced.filter(({ value }) => value === undefined).map(({ 
entry }) => entry.trim());
+    const valid = coerced
+      .map(({ value }) => value)
+      .filter((value): value is ArrayElement => value !== undefined);
+    const newElements = [...elements, ...valid];
+
+    if (valid.length) {
+      setElements(newElements);
+    }
+    if (invalid.length) {
+      onUpdate(
+        undefined,
+        translate("flexibleForm.validationErrorArrayElement", {
+          type: typeof itemsType === "string" ? itemsType : "string",
+          values: invalid.join(", "),
+        }),
+      );
+    } else {
+      onUpdate(String(newElements));
+    }
+  };
+
+  const commitInput = () => {
+    if (inputValue.trim() !== "") {
+      addEntries(inputValue);
+      setInputValue("");
+    }
+  };
+
+  const handleKeyDown = (event: KeyboardEvent) => {
+    if ((event.key === "Enter" || event.key === "Tab") && inputValue.trim() 
!== "") {
+      event.preventDefault();
+      commitInput();
+    }
+  };
+
+  const handlePaste = (event: ClipboardEvent) => {
+    const text = event.clipboardData.getData("text");
+
+    if (text.includes("\n")) {
+      event.preventDefault();
+      addEntries(text);
+      setInputValue("");
+    }
+  };
+
+  const handleChange = (chips: MultiValue<ChipOption>) => {
+    const newElements = chips.map((chip) => chip.value);
+
+    setElements(newElements);
+    onUpdate(String(newElements));
+  };
+
+  return (
+    <div onPasteCapture={handlePaste}>
+      <CreatableSelect
+        aria-label={translate("flexibleForm.placeholderArray")}
+        chakraStyles={{
+          // Optical centering: the icon reads as sitting high next to 
lowercase labels without the nudge.
+          multiValueRemove: (provided) => ({
+            ...provided,
+            alignSelf: "center",
+            transform: "translateY(1px)",
+          }),
+        }}
+        components={{ DropdownIndicator: null }}
+        getOptionValue={getChipIdentity}
+        id={`element_${name}`}
+        inputValue={inputValue}
+        isClearable
+        isDisabled={disabled}
+        isMulti
+        menuIsOpen={false}
+        name={`element_${name}`}
+        onBlur={commitInput}
+        onChange={handleChange}
+        onInputChange={setInputValue}
+        onKeyDown={handleKeyDown}
+        placeholder={translate("flexibleForm.placeholderArray")}
+        size="sm"
+        value={elements.map(toChip)}
+      />
+    </div>
+  );
+};
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 714f5c20459..ecc236c3cfd 100644
--- 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
@@ -67,6 +67,10 @@ export const FieldMultiSelect = ({ name, namespace = 
"default", onUpdate }: Flex
   return (
     <ReactSelect
       aria-label={translate("flexibleForm.placeholderMulti")}
+      chakraStyles={{
+        // Optical centering: the icon reads as sitting high next to lowercase 
labels without the nudge.
+        multiValueRemove: (provided) => ({ ...provided, alignSelf: "center", 
transform: "translateY(1px)" }),
+      }}
       id={`element_${name}`}
       isClearable
       isDisabled={disabled}
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.test.tsx
new file mode 100644
index 00000000000..8d6efe6a04e
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.test.tsx
@@ -0,0 +1,87 @@
+/*!
+ * 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 { FieldSelector } from "./FieldSelector";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const mockParamsDict: Record<string, any> = {};
+
+vi.mock("src/queries/useParamStore", () => ({
+  paramPlaceholder: {
+    schema: {},
+    value: null,
+  },
+  useParamStore: () => ({
+    disabled: false,
+    initialParamDict: mockParamsDict,
+    paramsDict: mockParamsDict,
+    setParamsDict: vi.fn(),
+  }),
+}));
+
+describe("FieldSelector array routing", () => {
+  beforeEach(() => {
+    Object.keys(mockParamsDict).forEach((key) => {
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete mockParamsDict[key];
+    });
+  });
+
+  it.each([
+    ["string", ["a"]],
+    ["number", [1]],
+    ["integer", [1]],
+    ["boolean", [true]],
+  ])("renders the chips editor for items.type=%s", (itemsType, value) => {
+    mockParamsDict.test_param = {
+      schema: { items: { type: itemsType }, type: "array" },
+      value,
+    };
+
+    render(<FieldSelector name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    expect(screen.getByRole("combobox")).toBeDefined();
+  });
+
+  it("renders the chips editor for untyped arrays", () => {
+    mockParamsDict.test_param = {
+      schema: { type: "array" },
+      value: [1, 2, 3],
+    };
+
+    render(<FieldSelector name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    expect(screen.getByRole("combobox")).toBeDefined();
+  });
+
+  it("renders the JSON editor for object arrays", () => {
+    mockParamsDict.test_param = {
+      schema: { items: { type: "object" }, type: "array" },
+      value: [{ key: "value" }],
+    };
+
+    render(<FieldSelector name="test_param" onUpdate={vi.fn()} />, { wrapper: 
Wrapper });
+
+    expect(screen.queryByRole("combobox")).toBeNull();
+  });
+});
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 f2341c16247..59e5fe67202 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
@@ -21,6 +21,7 @@ import { paramPlaceholder, useParamStore } from 
"src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 import { FieldAdvancedArray } from "./FieldAdvancedArray";
+import { FieldArray } from "./FieldArray";
 import { FieldBool } from "./FieldBool";
 import { FieldDateTime } from "./FieldDateTime";
 import { FieldDropdown } from "./FieldDropdown";
@@ -32,7 +33,6 @@ import { FieldNumber } from "./FieldNumber";
 import { FieldObject } from "./FieldObject";
 import { FieldPassword } from "./FieldPassword";
 import { FieldString } from "./FieldString";
-import { FieldStringArray } from "./FieldStringArray";
 
 const inferType = (param: ParamSpec) => {
   if (Boolean(param.schema.type)) {
@@ -52,8 +52,14 @@ const inferType = (param: ParamSpec) => {
   return param.value === null ? "null" : typeof param.value;
 };
 
+const simpleArrayItemTypes: Array<unknown> = ["string", "number", "integer", 
"boolean"];
+
+const isFieldArray = (fieldType: string, fieldSchema: ParamSchema) =>
+  fieldType === "array" &&
+  (fieldSchema.items?.type === undefined || 
simpleArrayItemTypes.includes(fieldSchema.items.type));
+
 const isFieldAdvancedArray = (fieldType: string, fieldSchema: ParamSchema) =>
-  fieldType === "array" && fieldSchema.items?.type !== "string";
+  fieldType === "array" && !isFieldArray(fieldType, fieldSchema);
 
 const isFieldBool = (fieldType: string) => fieldType === "boolean";
 
@@ -88,9 +94,6 @@ const isFieldObject = (fieldType: string) => fieldType === 
"object";
 const isFieldPassword = (fieldType: string, fieldSchema: ParamSchema) =>
   fieldType === "string" && fieldSchema.format === "password";
 
-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 &&
@@ -134,8 +137,8 @@ export const FieldSelector = ({ name, namespace = 
"default", onUpdate }: Flexibl
     return <FieldDropdown name={name} namespace={namespace} 
onUpdate={onUpdate} />;
   } else if (isFieldMultiSelect(fieldType, param.schema)) {
     return <FieldMultiSelect name={name} namespace={namespace} 
onUpdate={onUpdate} />;
-  } else if (isFieldStringArray(fieldType, param.schema)) {
-    return <FieldStringArray name={name} namespace={namespace} 
onUpdate={onUpdate} />;
+  } else if (isFieldArray(fieldType, param.schema)) {
+    return <FieldArray name={name} namespace={namespace} onUpdate={onUpdate} 
/>;
   } else if (isFieldAdvancedArray(fieldType, param.schema)) {
     return <FieldAdvancedArray name={name} namespace={namespace} 
onUpdate={onUpdate} />;
   } else if (isFieldObject(fieldType)) {
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
deleted file mode 100644
index d06645c1ea7..00000000000
--- 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-/*!
- * 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 { useTranslation } from "react-i18next";
-
-import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
-
-import type { FlexibleFormElementProps } from ".";
-
-export const FieldStringArray = ({ name, namespace = "default", onUpdate }: 
FlexibleFormElementProps) => {
-  const { t: translate } = useTranslation("components");
-  const { disabled, paramsDict, setParamsDict } = useParamStore(namespace);
-  const param = paramsDict[name] ?? paramPlaceholder;
-
-  const handleChange = (newValue: string) => {
-    const newValueArray = newValue.split("\n");
-
-    if (paramsDict[name]) {
-      paramsDict[name].value = newValueArray;
-    }
-
-    setParamsDict(paramsDict);
-    onUpdate(newValue);
-  };
-
-  const handleBlur = () => {
-    const currentValue = paramsDict[name]?.value;
-
-    if (Array.isArray(currentValue) && currentValue.length === 1 && 
currentValue[0] === "") {
-      if (paramsDict[name]) {
-        // "undefined" values are removed from params, so we set it to null to 
avoid falling back to DAG defaults.
-        paramsDict[name].value = null;
-      }
-
-      setParamsDict(paramsDict);
-    }
-  };
-
-  const value = Array.isArray(param.value) ? param.value.join("\n") : [];
-
-  return (
-    <Textarea
-      disabled={disabled}
-      id={`element_${name}`}
-      name={`element_${name}`}
-      onBlur={handleBlur}
-      onChange={(event) => handleChange(event.target.value)}
-      placeholder={translate("flexibleForm.placeholderArray")}
-      rows={6}
-      size="sm"
-      value={value}
-    />
-  );
-};
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/parseArrayElement.ts 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/parseArrayElement.ts
new file mode 100644
index 00000000000..4ce726229f8
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/parseArrayElement.ts
@@ -0,0 +1,62 @@
+/*!
+ * 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.
+ */
+
+export type ArrayElement = boolean | number | string;
+
+/**
+ * Parse one typed-in entry to the element type from `items.type`; `undefined` 
means invalid.
+ * Without an `items.type`, JSON primitives keep their type ("1" stays a 
number) and
+ * everything else is taken as a string.
+ */
+export const parseArrayElement = (raw: string, itemsType: unknown): 
ArrayElement | undefined => {
+  const text = raw.trim();
+
+  if (text === "") {
+    return undefined;
+  }
+  switch (itemsType) {
+    case "boolean":
+      return text === "true" || text === "false" ? text === "true" : undefined;
+    case "integer": {
+      const num = Number(text);
+
+      return Number.isInteger(num) ? num : undefined;
+    }
+    case "number": {
+      const num = Number(text);
+
+      return Number.isFinite(num) ? num : undefined;
+    }
+    case "string":
+      return text;
+    default: {
+      try {
+        const parsed = JSON.parse(text) as unknown;
+
+        if (["boolean", "number", "string"].includes(typeof parsed)) {
+          return parsed as ArrayElement;
+        }
+      } catch {
+        // Not valid JSON, keep the raw text as a string.
+      }
+
+      return text;
+    }
+  }
+};

Reply via email to