Repository: apache/airflow
Pull request #49981: Added validation in flexibleform
Author: login: aritra24
Labels: name: area:UI
description: Related to UI/UX. For Frontend Developers.
Created: 2025-04-29T18:23:31Z | Merged: 2025-05-01T15:07:25Z

closes: #49567

<!--
 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.
 -->

<!--
Thank you for contributing! Please make sure that your code changes
are covered with tests. And in case of new features or big changes
remember to adjust the documentation.

Feel free to ping committers for the review!

In case of an existing issue, reference it using one of the following:

closes: #ISSUE
related: #ISSUE

How to write a good git commit message:
http://chris.beams.io/posts/git-commit/
-->



<!-- Please keep an empty line above the dashes. -->
---
**^ Add meaningful description above**
Read the **[Pull Request Guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-guidelines)** for more information.
In case of fundamental code changes, an Airflow Improvement Proposal ([AIP](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvement+Proposals)) is needed.
In case of a new dependency, check compliance with the [ASF 3rd Party License Policy](https://www.apache.org/legal/resolved.html#category-x).
In case of backwards incompatible changes please leave a note in a newsfragment file, named `{pr_number}.significant.rst` or `{issue_number}.significant.rst`, in [airflow-core/newsfragments](https://github.com/apache/airflow/tree/main/airflow-core/newsfragments).



Diff:
diff --git a/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx b/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx
index a216b22de60c6..c8e75edfa816b 100644
--- a/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx
@@ -39,6 +39,7 @@ type ConfigFormProps<T extends FieldValues = FieldValues> = {
       date?: unknown;
     }>
   >;
+  readonly setFormError: (error: boolean) => void;
 };
 
 const ConfigForm = <T extends FieldValues = FieldValues>({
@@ -47,6 +48,7 @@ const ConfigForm = <T extends FieldValues = FieldValues>({
   errors,
   initialParamsDict,
   setErrors,
+  setFormError,
 }: ConfigFormProps<T>) => {
   const { conf, setConf } = useParamStore();
 
@@ -86,6 +88,7 @@ const ConfigForm = <T extends FieldValues = FieldValues>({
       <FlexibleForm
         flexibleFormDefaultSection={flexibleFormDefaultSection}
         initialParamsDict={initialParamsDict}
+        setError={setFormError}
       />
       <Accordion.Item key="advancedOptions" value="advancedOptions">
         <Accordion.ItemTrigger cursor="button">Advanced Options</Accordion.ItemTrigger>
diff --git a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
index 2079de6c60479..69974519361e9 100644
--- a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx
@@ -48,6 +48,7 @@ type BackfillFormProps = DagRunTriggerParams & Omit<BackfillPostBody, "dag_run_c
 const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => {
   const [errors, setErrors] = useState<{ conf?: string; date?: unknown }>({});
   const [unpause, setUnpause] = useState(true);
+  const [formError, setFormError] = useState(false);
   const initialParamsDict = useDagParams(dag.dag_id, true);
   const { conf } = useParamStore();
   const { control, handleSubmit, reset, watch } = useForm<BackfillFormProps>({
@@ -259,6 +260,7 @@ const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => {
           errors={errors}
           initialParamsDict={initialParamsDict}
           setErrors={setErrors}
+          setFormError={setFormError}
         />
       </VStack>
       <Box as="footer" display="flex" justifyContent="flex-end" mt={4}>
@@ -267,7 +269,9 @@ const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => {
           <Button onClick={() => void handleSubmit(onCancel)()}>Cancel</Button>
           <Button
             colorPalette="blue"
-            disabled={Boolean(errors.date) || isPendingDryRun || affectedTasks.total_entries === 0}
+            disabled={
+              Boolean(errors.date) || isPendingDryRun || formError || affectedTasks.total_entries === 0
+            }
             loading={isPending}
             onClick={() => void handleSubmit(onSubmit)()}
           >
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
index 80cb46dbdbc61..2722120466557 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
@@ -16,23 +16,18 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
-import { useState } from "react";
-
 import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 import { JsonEditor } from "../JsonEditor";
 
-export const FieldAdvancedArray = ({ name }: FlexibleFormElementProps) => {
+export const FieldAdvancedArray = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
-  const [error, setError] = useState<unknown>(undefined);
   // Determine the expected type based on schema
   const expectedType = param.schema.items?.type ?? "object";
 
   const handleChange = (value: string) => {
-    setError(undefined);
     if (value === "") {
       if (paramsDict[name]) {
         // "undefined" values are removed from params, so we set it to null to avoid falling back to DAG defaults.
@@ -64,24 +59,18 @@ export const FieldAdvancedArray = ({ name }: FlexibleFormElementProps) => {
         }
 
         setParamsDict(paramsDict);
+        onUpdate(String(parsedValue));
       } catch (_error) {
-        setError(expectedType === "number" ? String(_error).replace("JSON", "Array") : _error);
+        onUpdate(undefined, expectedType === "number" ? String(_error).replace("JSON", "Array") : _error);
       }
     }
   };
 
   return (
-    <>
-      <JsonEditor
-        id={`element_${name}`}
-        onChange={handleChange}
-        value={JSON.stringify(param.value ?? [], undefined, 2)}
-      />
-      {Boolean(error) ? (
-        <Text color="fg.error" fontSize="xs">
-          {String(error)}
-        </Text>
-      ) : undefined}
-    </>
+    <JsonEditor
+      id={`element_${name}`}
+      onChange={handleChange}
+      value={JSON.stringify(param.value ?? [], undefined, 2)}
+    />
   );
 };
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 079118379960e..cc8d23184b380 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx
@@ -23,7 +23,7 @@ import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 import type { FlexibleFormElementProps } from ".";
 import { DateTimeInput } from "../DateTimeInput";
 
-export const FieldDateTime = ({ name, ...rest }: FlexibleFormElementProps & InputProps) => {
+export const FieldDateTime = ({ name, onUpdate, ...rest }: FlexibleFormElementProps & InputProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const handleChange = (value: string) => {
@@ -40,6 +40,7 @@ export const FieldDateTime = ({ name, ...rest }: FlexibleFormElementProps & Inpu
     }
 
     setParamsDict(paramsDict);
+    onUpdate(value);
   };
 
   if (rest.type === "datetime-local") {
@@ -59,6 +60,7 @@ export const FieldDateTime = ({ name, ...rest }: FlexibleFormElementProps & Inpu
       id={`element_${name}`}
       name={`element_${name}`}
       onChange={(event) => handleChange(event.target.value)}
+      required={rest.required}
       size="sm"
       type={rest.type}
       value={((param.value ?? "") as string).slice(0, 16)}
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 a12d4dc366d3f..de15568c39cfc 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx
@@ -33,7 +33,7 @@ const labelLookup = (key: string, valuesDisplay: Record<string, string> | undefi
 };
 const enumTypes = ["string", "number", "integer"];
 
-export const FieldDropdown = ({ name }: FlexibleFormElementProps) => {
+export const FieldDropdown = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
 
@@ -55,6 +55,7 @@ export const FieldDropdown = ({ name }: FlexibleFormElementProps) => {
     }
 
     setParamsDict(paramsDict);
+    onUpdate(value);
   };
 
   return (
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 aa4a4e112f9f1..e34241037e19a 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx
@@ -31,7 +31,7 @@ const labelLookup = (key: string, valuesDisplay: Record<string, string> | undefi
   return key;
 };
 
-export const FieldMultiSelect = ({ name }: FlexibleFormElementProps) => {
+export const FieldMultiSelect = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
 
@@ -64,6 +64,7 @@ export const FieldMultiSelect = ({ name }: FlexibleFormElementProps) => {
       paramsDict[name].value = newValueArray;
     }
     setParamsDict(paramsDict);
+    onUpdate(String(newValueArray));
   };
 
   return (
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
index bfefcb276d820..93c7410bb82bd 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx
@@ -22,7 +22,7 @@ import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 
-export const FieldMultilineText = ({ name }: FlexibleFormElementProps) => {
+export const FieldMultilineText = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const handleChange = (value: string) => {
@@ -33,6 +33,7 @@ export const FieldMultilineText = ({ name }: FlexibleFormElementProps) => {
     }
 
     setParamsDict(paramsDict);
+    onUpdate(value);
   };
 
   return (
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
index dcaaf37cf5649..635f461335cdc 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldNumber.tsx
@@ -21,7 +21,7 @@ import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 import type { FlexibleFormElementProps } from ".";
 import { NumberInputField, NumberInputRoot } from "../ui/NumberInput";
 
-export const FieldNumber = ({ name }: FlexibleFormElementProps) => {
+export const FieldNumber = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const handleChange = (value: string) => {
@@ -40,6 +40,7 @@ export const FieldNumber = ({ name }: FlexibleFormElementProps) => {
     }
 
     setParamsDict(paramsDict);
+    onUpdate(value);
   };
 
   return (
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
index fcd7d7d2a16b0..ce5bf30731165 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldObject.tsx
@@ -16,21 +16,16 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
-import { useState } from "react";
-
 import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 import { JsonEditor } from "../JsonEditor";
 
-export const FieldObject = ({ name }: FlexibleFormElementProps) => {
+export const FieldObject = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
-  const [error, setError] = useState<unknown>(undefined);
 
   const handleChange = (value: string) => {
-    setError(undefined);
     try {
       // "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
@@ -41,19 +36,17 @@ export const FieldObject = ({ name }: FlexibleFormElementProps) => {
       }
 
       setParamsDict(paramsDict);
+      onUpdate(value);
     } catch (_error) {
-      setError(_error);
+      onUpdate("", _error);
     }
   };
 
   return (
-    <>
-      <JsonEditor
-        id={`element_${name}`}
-        onChange={handleChange}
-        value={JSON.stringify(param.value ?? [], undefined, 2)}
-      />
-      {Boolean(error) ? <Text color="fg,.error">{String(error)}</Text> : undefined}
-    </>
+    <JsonEditor
+      id={`element_${name}`}
+      onChange={handleChange}
+      value={JSON.stringify(param.value ?? [], undefined, 2)}
+    />
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRow.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRow.tsx
index e41d4643f6b23..406dbaf18fe8b 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRow.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRow.tsx
@@ -17,35 +17,52 @@
  * under the License.
  */
 import { Field, Stack } from "@chakra-ui/react";
+import { useState } from "react";
 import Markdown from "react-markdown";
 import remarkGfm from "remark-gfm";
 
-import type { ParamSpec } from "src/queries/useDagParams";
 import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 import { FieldSelector } from "./FieldSelector";
-
-const isRequired = (param: ParamSpec) =>
-  // The field is required if the schema type is defined.
-  // But if the type "null" is included, then the field is not required.
-  // We assume that "null" is only defined if the type is an array.
-  Boolean(param.schema.type) && (!Array.isArray(param.schema.type) || !param.schema.type.includes("null"));
+import { isRequired } from "./isParamRequired";
 
 /** Render a normal form row with a field that is auto-selected */
-export const FieldRow = ({ name }: FlexibleFormElementProps) => {
+export const FieldRow = ({ name, onUpdate: rowOnUpdate }: FlexibleFormElementProps) => {
   const { paramsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
+  const [error, setError] = useState<unknown>(
+    isRequired(param) && param.value === null ? "This field is required" : undefined,
+  );
+  const [isValid, setIsValid] = useState(!(isRequired(param) && param.value === null));
+
+  // console.log(param);
+
+  const onUpdate = (value?: string, _error?: unknown) => {
+    if (Boolean(_error)) {
+      setIsValid(false);
+      setError(_error);
+      rowOnUpdate(undefined, _error);
+    } else if (isRequired(param) && (!Boolean(value) || value === "")) {
+      setIsValid(false);
+      setError("This field is required");
+      rowOnUpdate(undefined, "This field is required");
+    } else {
+      setIsValid(true);
+      setError(undefined);
+      rowOnUpdate();
+    }
+  };
 
   return (
-    <Field.Root orientation="horizontal" required={isRequired(param)}>
+    <Field.Root invalid={!isValid} orientation="horizontal" required={isRequired(param)}>
       <Stack>
         <Field.Label fontSize="md" style={{ flexBasis: "30%" }}>
           {param.schema.title ?? name} <Field.RequiredIndicator />
         </Field.Label>
       </Stack>
       <Stack css={{ flexBasis: "70%" }}>
-        <FieldSelector name={name} />
+        <FieldSelector name={name} onUpdate={onUpdate} />
         {param.description === null ? (
           param.schema.description_md === undefined ? undefined : (
             <Field.HelperText>
@@ -55,6 +72,7 @@ export const FieldRow = ({ name }: FlexibleFormElementProps) => {
         ) : (
           <Field.HelperText>{param.description}</Field.HelperText>
         )}
+        {isValid ? undefined : <Field.ErrorText>{String(error)}</Field.ErrorText>}
       </Stack>
     </Field.Root>
   );
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 8665b41aa2b85..cd0061987a6cc 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
@@ -87,35 +87,35 @@ const isFieldStringArray = (fieldType: string, fieldSchema: ParamSchema) =>
 const isFieldTime = (fieldType: string, fieldSchema: ParamSchema) =>
   fieldType === "string" && fieldSchema.format === "time";
 
-export const FieldSelector = ({ name }: FlexibleFormElementProps) => {
+export const FieldSelector = ({ name, onUpdate }: FlexibleFormElementProps) => {
   // FUTURE: Add support for other types as described in AIP-68 via Plugins
   const { initialParamDict } = useParamStore();
   const param = initialParamDict[name] ?? paramPlaceholder;
   const fieldType = inferType(param);
 
   if (isFieldBool(fieldType)) {
-    return <FieldBool name={name} />;
+    return <FieldBool name={name} onUpdate={onUpdate} />;
   } else if (isFieldDateTime(fieldType, param.schema)) {
-    return <FieldDateTime name={name} type="datetime-local" />;
+    return <FieldDateTime name={name} onUpdate={onUpdate} type="datetime-local" />;
   } else if (isFieldDate(fieldType, param.schema)) {
-    return <FieldDateTime name={name} type="date" />;
+    return <FieldDateTime name={name} onUpdate={onUpdate} type="date" />;
   } else if (isFieldTime(fieldType, param.schema)) {
-    return <FieldDateTime name={name} type="time" />;
+    return <FieldDateTime name={name} onUpdate={onUpdate} type="time" />;
   } else if (isFieldDropdown(fieldType, param.schema)) {
-    return <FieldDropdown name={name} />;
+    return <FieldDropdown name={name} onUpdate={onUpdate} />;
   } else if (isFieldMultiSelect(fieldType, param.schema)) {
-    return <FieldMultiSelect name={name} />;
+    return <FieldMultiSelect name={name} onUpdate={onUpdate} />;
   } else if (isFieldStringArray(fieldType, param.schema)) {
-    return <FieldStringArray name={name} />;
+    return <FieldStringArray name={name} onUpdate={onUpdate} />;
   } else if (isFieldAdvancedArray(fieldType, param.schema)) {
-    return <FieldAdvancedArray name={name} />;
+    return <FieldAdvancedArray name={name} onUpdate={onUpdate} />;
   } else if (isFieldObject(fieldType)) {
-    return <FieldObject name={name} />;
+    return <FieldObject name={name} onUpdate={onUpdate} />;
   } else if (isFieldNumber(fieldType)) {
-    return <FieldNumber name={name} />;
+    return <FieldNumber name={name} onUpdate={onUpdate} />;
   } else if (isFieldMultilineText(fieldType, param.schema)) {
-    return <FieldMultilineText name={name} />;
+    return <FieldMultilineText name={name} onUpdate={onUpdate} />;
   } else {
-    return <FieldString name={name} />;
+    return <FieldString name={name} onUpdate={onUpdate} />;
   }
 };
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
index e8cd018570638..8d49e604452cc 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldString.tsx
@@ -22,7 +22,7 @@ import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 
-export const FieldString = ({ name }: FlexibleFormElementProps) => {
+export const FieldString = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
   const handleChange = (value: string) => {
@@ -33,6 +33,7 @@ export const FieldString = ({ name }: FlexibleFormElementProps) => {
     }
 
     setParamsDict(paramsDict);
+    onUpdate(value);
   };
 
   return (
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
index f5fc6ac69f5da..2078d95276d0e 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldStringArray.tsx
@@ -22,7 +22,7 @@ import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 
-export const FieldStringArray = ({ name }: FlexibleFormElementProps) => {
+export const FieldStringArray = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
 
@@ -34,6 +34,7 @@ export const FieldStringArray = ({ name }: FlexibleFormElementProps) => {
     }
 
     setParamsDict(paramsDict);
+    onUpdate(newValue);
   };
 
   const handleBlur = () => {
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
index 165e9d6dd1999..ae9e60c812fc0 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
@@ -17,23 +17,44 @@
  * under the License.
  */
 import { Box, Stack, StackSeparator } from "@chakra-ui/react";
-import { useEffect } from "react";
+import { useCallback, useEffect, useState } from "react";
 
 import type { ParamsSpec } from "src/queries/useDagParams";
 import { useParamStore } from "src/queries/useParamStore";
 
 import { Accordion } from "../ui";
 import { Row } from "./Row";
+import { isRequired } from "./isParamRequired";
 
 export type FlexibleFormProps = {
   flexibleFormDefaultSection: string;
   initialParamsDict: { paramsDict: ParamsSpec };
   key?: string;
+  setError: (error: boolean) => void;
 };
 
-export const FlexibleForm = ({ flexibleFormDefaultSection, initialParamsDict }: FlexibleFormProps) => {
+export const FlexibleForm = ({
+  flexibleFormDefaultSection,
+  initialParamsDict,
+  setError,
+}: FlexibleFormProps) => {
   const { paramsDict: params, setinitialParamDict, setParamsDict } = useParamStore();
   const processedSections = new Map();
+  const [sectionError, setSectionError] = useState<Map<string, boolean>>(new Map());
+
+  const recheckSection = useCallback(() => {
+    sectionError.clear();
+    Object.entries(params).forEach(([, element]) => {
+      if (
+        isRequired(element) &&
+        (element.value === null || element.value === undefined || element.value === "")
+      ) {
+        sectionError.set(element.schema.section ?? flexibleFormDefaultSection, true);
+        setSectionError(sectionError);
+      }
+    });
+    console.log("errors1", sectionError);
+  }, [flexibleFormDefaultSection, params, sectionError]);
 
   useEffect(() => {
     // Initialize paramsDict and initialParamDict when modal opens
@@ -54,6 +75,24 @@ export const FlexibleForm = ({ flexibleFormDefaultSection, initialParamsDict }:
     [setParamsDict, setinitialParamDict],
   );
 
+  useEffect(
+    () => () => {
+      recheckSection();
+    },
+    [params, recheckSection],
+  );
+
+  const onUpdate = (_value?: string, error?: unknown) => {
+    recheckSection();
+    if (!Boolean(error) && sectionError.size === 0) {
+      setError(false);
+    } else {
+      setError(true);
+    }
+  };
+
+  console.log(sectionError);
+
   return Object.entries(params).some(([, param]) => typeof param.schema.section !== "string")
     ? Object.entries(params).map(([, secParam]) => {
         const currentSection = secParam.schema.section ?? flexibleFormDefaultSection;
@@ -65,7 +104,12 @@ export const FlexibleForm = ({ flexibleFormDefaultSection, initialParamsDict }:
 
           return (
             <Accordion.Item key={currentSection} value={currentSection}>
-              <Accordion.ItemTrigger cursor="button">{currentSection}</Accordion.ItemTrigger>
+              <Accordion.ItemTrigger
+                color={sectionError.get(currentSection) ? "red" : undefined}
+                cursor="button"
+              >
+                {currentSection}
+              </Accordion.ItemTrigger>
               <Accordion.ItemContent paddingTop={0}>
                 <Box p={5}>
                   <Stack separator={<StackSeparator />}>
@@ -76,7 +120,7 @@ export const FlexibleForm = ({ flexibleFormDefaultSection, initialParamsDict }:
                           (currentSection === flexibleFormDefaultSection && !Boolean(param.schema.section)),
                       )
                       .map(([name]) => (
-                        <Row key={name} name={name} />
+                        <Row key={name} name={name} onUpdate={onUpdate} />
                       ))}
                   </Stack>
                 </Box>
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/Row.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/Row.tsx
index 1f1186124a231..cc71fedb6ee91 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/Row.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/Row.tsx
@@ -26,9 +26,13 @@ import { HiddenInput } from "./HiddenInput";
 const isHidden = (fieldSchema: ParamSchema) => Boolean(fieldSchema.const);
 
 /** Generates a form row */
-export const Row = ({ name }: FlexibleFormElementProps) => {
+export const Row = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
 
-  return isHidden(param.schema) ? <HiddenInput name={name} /> : <FieldRow name={name} />;
+  return isHidden(param.schema) ? (
+    <HiddenInput name={name} onUpdate={onUpdate} />
+  ) : (
+    <FieldRow name={name} onUpdate={onUpdate} />
+  );
 };
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/index.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/index.tsx
index f7e8ab0d8c420..704b5d956ae2a 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/index.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/index.tsx
@@ -19,6 +19,7 @@
 
 export type FlexibleFormElementProps = {
   readonly name: string;
+  readonly onUpdate: (value?: string, error?: unknown) => void;
 };
 
 export const flexibleFormDefaultSection = "Run Parameters";
diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/isParamRequired.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/isParamRequired.tsx
new file mode 100644
index 0000000000000..7a9a0668855a4
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/isParamRequired.tsx
@@ -0,0 +1,25 @@
+/*!
+ * 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 type { ParamSpec } from "src/queries/useDagParams";
+
+export const isRequired = (param: ParamSpec) =>
+  // The field is required if the schema type is defined.
+  // But if the type "null" is included, then the field is not required.
+  // We assume that "null" is only defined if the type is an array.
+  Boolean(param.schema.type) && (!Array.isArray(param.schema.type) || !param.schema.type.includes("null"));
diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
index fdeae4bdb6958..e1337f530f658 100644
--- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx
@@ -49,6 +49,7 @@ export type DagRunTriggerParams = {
 
 const TriggerDAGForm = ({ dagId, isPaused, onClose, open }: TriggerDAGFormProps) => {
   const [errors, setErrors] = useState<{ conf?: string; date?: unknown }>({});
+  const [formError, setFormError] = useState(false);
   const initialParamsDict = useDagParams(dagId, open);
   const { error: errorTrigger, isPending, triggerDagRun } = useTrigger({ dagId, onSuccessConfirm: onClose });
   const { conf } = useParamStore();
@@ -96,6 +97,7 @@ const TriggerDAGForm = ({ dagId, isPaused, onClose, open }: TriggerDAGFormProps)
         errors={errors}
         initialParamsDict={initialParamsDict}
         setErrors={setErrors}
+        setFormError={setFormError}
       >
         <Controller
           control={control}
@@ -153,7 +155,7 @@ const TriggerDAGForm = ({ dagId, isPaused, onClose, open }: TriggerDAGFormProps)
           <Spacer />
           <Button
             colorPalette="blue"
-            disabled={Boolean(errors.conf) || Boolean(errors.date) || isPending}
+            disabled={Boolean(errors.conf) || Boolean(errors.date) || formError || isPending}
             onClick={() => void handleSubmit(onSubmit)()}
           >
             <FiPlay /> Trigger
diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionForm.tsx b/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionForm.tsx
index f2a47c47b154a..6a17f1794f6bf 100644
--- a/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionForm.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/ConnectionForm.tsx
@@ -68,6 +68,8 @@ const ConnectionForm = ({
   const standardFields = connectionTypeMeta[selectedConnType]?.standard_fields ?? {};
   const paramsDic = { paramsDict: connectionTypeMeta[selectedConnType]?.extra_fields ?? ({} as ParamsSpec) };
 
+  const [formErrors, setFormErrors] = useState(false);
+
   useEffect(() => {
     reset((prevValues) => ({
       ...initialConnection,
@@ -198,6 +200,7 @@ const ConnectionForm = ({
               flexibleFormDefaultSection={flexibleFormExtraFieldSection}
               initialParamsDict={paramsDic}
               key={selectedConnType}
+              setError={setFormErrors}
             />
             <Accordion.Item key="extraJson" value="extraJson">
               <Accordion.ItemTrigger cursor="button">Extra Fields JSON</Accordion.ItemTrigger>
@@ -228,7 +231,7 @@ const ConnectionForm = ({
           <Spacer />
           <Button
             colorPalette="blue"
-            disabled={Boolean(errors.conf) || isPending || !isValid}
+            disabled={Boolean(errors.conf) || formErrors || isPending || !isValid}
             onClick={() => void handleSubmit(onSubmit)()}
           >
             <FiSave /> Save


Reviews:
user: login: jscheffl
body: Cool that you add better validation into the form pre-trigger!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T20:34:59Z
user: login: aritra24
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T07:50:35Z
user: login: jscheffl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T13:50:47Z
user: login: aritra24
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T14:10:46Z
user: login: jscheffl
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T14:14:49Z
user: login: jscheffl
body: I am good and I like the validation!

Just re-opened last thing you changed, not 100% sure if it is intuitive enough to understand that red-text means that you need to open the section
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T14:16:23Z
user: login: aritra24
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T14:42:52Z

Review comments:
diff_hunk: @@ -16,23 +16,19 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
-import { useState } from "react";
-
 import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 import { JsonEditor } from "../JsonEditor";
 
-export const FieldAdvancedArray = ({ name }: FlexibleFormElementProps) => {
+export const FieldAdvancedArray = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
-  const [error, setError] = useState<unknown>(undefined);
+  // const [error, setError] = useState<unknown>(undefined);
path: airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
user: login: jscheffl
body: Some cleanup needed?
created_at: 2025-04-30T20:34:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,23 +16,19 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text } from "@chakra-ui/react";
-import { useState } from "react";
-
 import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
 
 import type { FlexibleFormElementProps } from ".";
 import { JsonEditor } from "../JsonEditor";
 
-export const FieldAdvancedArray = ({ name }: FlexibleFormElementProps) => {
+export const FieldAdvancedArray = ({ name, onUpdate }: FlexibleFormElementProps) => {
   const { paramsDict, setParamsDict } = useParamStore();
   const param = paramsDict[name] ?? paramPlaceholder;
-  const [error, setError] = useState<unknown>(undefined);
+  // const [error, setError] = useState<unknown>(undefined);
path: airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx
user: login: aritra24
body: Ack
created_at: 2025-05-01T07:50:35Z
author_association: COLLABORATOR
diff_hunk: @@ -64,7 +92,12 @@ export const FlexibleForm = ({ flexibleFormDefaultSection, initialParamsDict }:
           processedSections.set(currentSection, true);
 
           return (
-            <Accordion.Item key={currentSection} value={currentSection}>
+            <Accordion.Item
+              borderColor={sectionError.get(currentSection) ? "red" : undefined}
+              borderStyle="solid"
+              key={currentSection}
+              value={currentSection}
+            >
               <Accordion.ItemTrigger cursor="button">{currentSection}</Accordion.ItemTrigger>
path: airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
user: login: jscheffl
body: The red boder reduces to a single line which might not make it obvious in which section the problem is. Also the red border is only in between sections. How about making the test and adding an error indicator in the headline?
```suggestion
            <Accordion.Item key={currentSection} value={currentSection} >
              <Accordion.ItemTrigger
                color={sectionError.get(currentSection) ? "red" : undefined}
                cursor="button"
              >{sectionError.get(currentSection) ? `${currentSection} (with errors in section)` : currentSection}</Accordion.ItemTrigger>
```
That would come-out as:
![image](https://github.com/user-attachments/assets/2dfd9d3b-ace4-4b1d-9a95-a59b314428f6)

created_at: 2025-05-01T13:50:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,7 +92,12 @@ export const FlexibleForm = ({ flexibleFormDefaultSection, initialParamsDict }:
           processedSections.set(currentSection, true);
 
           return (
-            <Accordion.Item key={currentSection} value={currentSection}>
+            <Accordion.Item
+              borderColor={sectionError.get(currentSection) ? "red" : undefined}
+              borderStyle="solid"
+              key={currentSection}
+              value={currentSection}
+            >
               <Accordion.ItemTrigger cursor="button">{currentSection}</Accordion.ItemTrigger>
path: airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
user: login: aritra24
body: done
created_at: 2025-05-01T14:10:46Z
author_association: COLLABORATOR
diff_hunk: @@ -64,7 +92,12 @@ export const FlexibleForm = ({ flexibleFormDefaultSection, initialParamsDict }:
           processedSections.set(currentSection, true);
 
           return (
-            <Accordion.Item key={currentSection} value={currentSection}>
+            <Accordion.Item
+              borderColor={sectionError.get(currentSection) ? "red" : undefined}
+              borderStyle="solid"
+              key={currentSection}
+              value={currentSection}
+            >
               <Accordion.ItemTrigger cursor="button">{currentSection}</Accordion.ItemTrigger>
path: airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
user: login: jscheffl
body: You added the text coloring now - I am not fully sure - is it sufficient for users to understand that "red color" just means error and is it intuitive enough to understand that the section needs to be opened? Or should we add some kind of validation text marker?
created_at: 2025-05-01T14:14:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,7 +92,12 @@ export const FlexibleForm = ({ flexibleFormDefaultSection, initialParamsDict }:
           processedSections.set(currentSection, true);
 
           return (
-            <Accordion.Item key={currentSection} value={currentSection}>
+            <Accordion.Item
+              borderColor={sectionError.get(currentSection) ? "red" : undefined}
+              borderStyle="solid"
+              key={currentSection}
+              value={currentSection}
+            >
               <Accordion.ItemTrigger cursor="button">{currentSection}</Accordion.ItemTrigger>
path: airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
user: login: aritra24
body: I think it should be understood as error, but I can add something more... I can see if I can add a squiggly underline? what do you think?
created_at: 2025-05-01T14:42:52Z
author_association: COLLABORATOR

PR comments:
user: login: aritra24
created_at: 2025-04-29T18:24:37Z
body: This still has eslint errors and I'm not too confident this is the way we want to add validation. If anyone has any initial thoughts let me know, if not I'll proceed in this direction unless I find some cleaner way of doing this. 
author_association: COLLABORATOR
user: login: guan404ming
created_at: 2025-04-30T02:34:32Z
body: Hi, I think we could show the inline error by using `field` components https://www.chakra-ui.com/docs/components/field and then we could prevent to show all error message in one place.

Also, I think the way to bubble up the message is right at first glance. Maybe we could start from small piece (like 1 or 2 components first?) and let some reviewers to check that is right then we keep expand them to the other components. If there is anything I could help please let me know, thanks!
author_association: MEMBER
user: login: aritra24
created_at: 2025-04-30T03:48:27Z
body: Thanks for the comment @guan404ming, I am indeed already using the field.error component for showing the errors. Will update if I need any help on anything. 
author_association: COLLABORATOR
user: login: aritra24
created_at: 2025-04-30T15:19:08Z
body: Hmm, on first glance it works but behaves weirdly on closing/opening the modals

https://github.com/user-attachments/assets/4fcb65f2-36c1-4cd8-9274-d9f387097589

@bbovenzi any ideas?



author_association: COLLABORATOR
user: login: bbovenzi
created_at: 2025-04-30T18:29:15Z
body: From watching your gif. A few UX changes:
- Maybe we can try to only change the border for the accordion that contains an error? If its too complicated then don't worry
- We may also want to include space in each form element so the error text doesnt shift the positioning of everything

author_association: CONTRIBUTOR
user: login: aritra24
created_at: 2025-04-30T18:42:47Z
body: I can try it out (might try to do a section specific error counter approach?), but another thing that breaks right now is when a different field changes it undoes the trigger button getting disabled and also I think switching sections in the accordion does that too. So will have to figure out why those happen. 🤔
author_association: COLLABORATOR
user: login: aritra24
created_at: 2025-05-01T13:18:32Z
body: <img width="1081" alt="Screenshot 2025-05-01 at 6 46 43 PM" src="https://github.com/user-attachments/assets/b66f1cad-6363-4d6b-9889-a26233e80d92" />
The border doesn't render well, but this is the current view
author_association: COLLABORATOR
user: login: jscheffl
created_at: 2025-05-01T13:27:10Z
body: There is a bug: If a boolean value is used, then marking the value as "false" makes validation block trigger:
![image](https://github.com/user-attachments/assets/084a9180-e7d5-42ab-82ec-15cee61d437f)

author_association: CONTRIBUTOR
user: login: jscheffl
created_at: 2025-05-01T13:53:07Z
body: I just realized that only basic "required field" validation is being made, min length of text fields is not checked. Not critical as the backend validates, I'd propose to make this to a separate PR:
![image](https://github.com/user-attachments/assets/1a5a7d67-c43c-482c-8930-592837c63647)

author_association: CONTRIBUTOR
user: login: aritra24
created_at: 2025-05-01T13:58:49Z
body: Ack, I can make that in a followup. Will add in the coloring of text part. 

author_association: COLLABORATOR
user: login: aritra24
created_at: 2025-05-01T14:10:25Z
body: Done @jscheffl, let me know how it looks to you now?
author_association: COLLABORATOR
user: login: aritra24
created_at: 2025-05-01T14:52:27Z
body: <img width="859" alt="Screenshot 2025-05-01 at 8 21 16 PM" src="https://github.com/user-attachments/assets/d9dcd4df-93f5-40cf-9018-5b14a1d0b627" />

@jscheffl what do you think of this? With the underlines? If it looks good, I'll push the change. If not can try something different. 
author_association: COLLABORATOR
user: login: jscheffl
created_at: 2025-05-01T14:55:42Z
body: > @jscheffl what do you think of this? With the underlines? If it looks good, I'll push the change. If not can try something different.

I was considering rather textual hints, not additional coloring. In the example above I had extended it with `(with errors in section)`. But am also not 100% sure if this text helps.
author_association: CONTRIBUTOR
user: login: aritra24
created_at: 2025-05-01T15:03:24Z
body: Hmm, I'm not a big fan of the additional text, but I can make the change if you think it's better than way?
author_association: COLLABORATOR
user: login: jscheffl
created_at: 2025-05-01T15:06:50Z
body: > Hmm, I'm not a big fan of the additional text, but I can make the change if you think it's better than way?

That was my question... so if you have a different opinion... I think we can leave it as it is.
author_association: CONTRIBUTOR
user: login: bbovenzi
created_at: 2025-05-01T16:36:17Z
body: > > @jscheffl what do you think of this? With the underlines? If it looks good, I'll push the change. If not can try something different.
> 
> I was considering rather textual hints, not additional coloring. In the example above I had extended it with `(with errors in section)`. But am also not 100% sure if this text helps.

We really want to avoid adding just color for the state of something. Also, underlined text is usually a link, so it would be better to add something like a Warning icon or text
author_association: CONTRIBUTOR
user: login: aritra24
created_at: 2025-05-01T17:12:04Z
body: Hmm, alright. I'll add an error icon as well. 
author_association: COLLABORATOR
user: login: jscheffl
created_at: 2025-05-01T20:38:21Z
body: @aritra24 As you are not a fan of additional text... icon might be an option... but how about adding a validation erro marker similar like it is rendered on the fields? Like this?
![image](https://github.com/user-attachments/assets/14d8aa79-ee9f-42c4-bd8b-7c3398bb15bb)
(Unfortunately I was not able to add a line break... an alert icon might be beneficial as well...)

By the way also just found a small glitch: When you open the trigger form initially it is not validating. The section header does not show the error directly after open, only if you start changing data:
![image](https://github.com/user-attachments/assets/4fe5213d-6e88-4ed7-a52c-88ee7f940be8)

author_association: CONTRIBUTOR
user: login: aritra24
created_at: 2025-05-02T05:27:26Z
body: I'll check this out, I had seen that issue before, but I thought I'd fixed it in one of the follow-up commits. 🤔
author_association: COLLABORATOR