diff --git a/airflow/ui/package.json b/airflow/ui/package.json index a2dd20065256c..0526f20d4d040 100644 --- a/airflow/ui/package.json +++ b/airflow/ui/package.json @@ -46,7 +46,8 @@ "react-syntax-highlighter": "^15.5.6", "remark-gfm": "^4.0.0", "use-debounce": "^10.0.3", - "usehooks-ts": "^3.1.0" + "usehooks-ts": "^3.1.0", + "zustand": "^5.0.3" }, "devDependencies": { "@7nohe/openapi-react-query-codegen": "^1.6.0", diff --git a/airflow/ui/pnpm-lock.yaml b/airflow/ui/pnpm-lock.yaml index 99663d535edd5..5b89d9517c81f 100644 --- a/airflow/ui/pnpm-lock.yaml +++ b/airflow/ui/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: usehooks-ts: specifier: ^3.1.0 version: 3.1.0(react@18.3.1) + zustand: + specifier: ^5.0.3 + version: 5.0.3(@types/react@18.3.5)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)) devDependencies: '@7nohe/openapi-react-query-codegen': specifier: ^1.6.0 @@ -4169,6 +4172,24 @@ packages: react: optional: true + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9299,4 +9320,10 @@ snapshots: '@types/react': 18.3.5 react: 18.3.1 + zustand@5.0.3(@types/react@18.3.5)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1)): + optionalDependencies: + '@types/react': 18.3.5 + react: 18.3.1 + use-sync-external-store: 1.2.2(react@18.3.1) + zwitch@2.0.4: {} diff --git a/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx b/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx index ac995c8ca1332..ef3e7dfdc1c01 100644 --- a/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx +++ b/airflow/ui/src/components/FlexibleForm/FieldAdvancedArray.tsx @@ -16,37 +16,92 @@ * specific language governing permissions and limitations * under the License. */ +import { Text } from "@chakra-ui/react"; import { json } from "@codemirror/lang-json"; import { githubLight, githubDark } from "@uiw/codemirror-themes-all"; import CodeMirror from "@uiw/react-codemirror"; +import { useState } from "react"; import { useColorMode } from "src/context/colorMode"; import type { FlexibleFormElementProps } from "."; +import { paramPlaceholder, useParamStore } from "../TriggerDag/useParamStore"; -export const FieldAdvancedArray = ({ name, param }: FlexibleFormElementProps) => { +export const FieldAdvancedArray = ({ name }: FlexibleFormElementProps) => { const { colorMode } = useColorMode(); + const { paramsDict, setParamsDict } = useParamStore(); + const param = paramsDict[name] ?? paramPlaceholder; + const [error, setError] = useState(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. + // eslint-disable-next-line unicorn/no-null + paramsDict[name].value = null; + } + setParamsDict(paramsDict); + } else { + try { + const parsedValue = JSON.parse(value) as unknown; + + if (!Array.isArray(parsedValue)) { + throw new TypeError("Value must be an array."); + } + + if (expectedType === "number" && !parsedValue.every((item) => typeof item === "number")) { + // Ensure all elements in the array are numbers + throw new TypeError("All elements in the array must be numbers."); + } else if ( + expectedType === "object" && + !parsedValue.every((item) => typeof item === "object" && item !== null) + ) { + // Ensure all elements in the array are objects + throw new TypeError("All elements in the array must be objects."); + } + + if (paramsDict[name]) { + paramsDict[name].value = parsedValue; + } + + setParamsDict(paramsDict); + } catch (_error) { + setError(expectedType === "number" ? String(_error).replace("JSON", "Array") : _error); + } + } + }; return ( - + <> + + {Boolean(error) ? ( + + {String(error)} + + ) : undefined} + ); }; diff --git a/airflow/ui/src/components/FlexibleForm/FieldBool.tsx b/airflow/ui/src/components/FlexibleForm/FieldBool.tsx index 609e7ade5a1c9..a92bf833d880e 100644 --- a/airflow/ui/src/components/FlexibleForm/FieldBool.tsx +++ b/airflow/ui/src/components/FlexibleForm/FieldBool.tsx @@ -17,13 +17,27 @@ * under the License. */ import type { FlexibleFormElementProps } from "."; +import { paramPlaceholder, useParamStore } from "../TriggerDag/useParamStore"; import { Switch } from "../ui"; -export const FieldBool = ({ name, param }: FlexibleFormElementProps) => ( - -); +export const FieldBool = ({ name }: FlexibleFormElementProps) => { + const { paramsDict, setParamsDict } = useParamStore(); + const param = paramsDict[name] ?? paramPlaceholder; + const onCheck = (value: boolean) => { + if (paramsDict[name]) { + paramsDict[name].value = value; + } + + setParamsDict(paramsDict); + }; + + return ( + onCheck(event.checked)} + /> + ); +}; diff --git a/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx b/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx index f109363988a6b..d887e2d4b4cd6 100644 --- a/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx +++ b/airflow/ui/src/components/FlexibleForm/FieldDateTime.tsx @@ -19,13 +19,35 @@ import { Input, type InputProps } from "@chakra-ui/react"; import type { FlexibleFormElementProps } from "."; +import { paramPlaceholder, useParamStore } from "../TriggerDag/useParamStore"; -export const FieldDateTime = ({ name, param, ...rest }: FlexibleFormElementProps & InputProps) => ( - -); +export const FieldDateTime = ({ name, ...rest }: FlexibleFormElementProps & InputProps) => { + const { paramsDict, setParamsDict } = useParamStore(); + const param = paramsDict[name] ?? paramPlaceholder; + const handleChange = (value: string) => { + if (paramsDict[name]) { + if (rest.type === "datetime-local") { + // "undefined" values are removed from params, so we set it to null to avoid falling back to DAG defaults. + // eslint-disable-next-line unicorn/no-null + paramsDict[name].value = value === "" ? null : `${value}:00+00:00`; // Need to suffix to make it UTC like + } else { + // "undefined" values are removed from params, so we set it to null to avoid falling back to DAG defaults. + // eslint-disable-next-line unicorn/no-null + paramsDict[name].value = value === "" ? null : value; + } + } + + setParamsDict(paramsDict); + }; + + return ( + handleChange(event.target.value)} + size="sm" + type={rest.type} + value={param.value !== null && param.value !== undefined ? String(param.value).slice(0, 16) : ""} + /> + ); +}; diff --git a/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx b/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx index bbea023168b45..c2d30ae79618a 100644 --- a/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx +++ b/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx @@ -22,6 +22,7 @@ import { useRef } from "react"; import { Select } from "src/components/ui"; import type { FlexibleFormElementProps } from "."; +import { paramPlaceholder, useParamStore } from "../TriggerDag/useParamStore"; const labelLookup = (key: string, valuesDisplay: Record | undefined): string => { if (valuesDisplay && typeof valuesDisplay === "object") { @@ -32,7 +33,10 @@ const labelLookup = (key: string, valuesDisplay: Record | undefi }; const enumTypes = ["string", "number", "integer"]; -export const FieldDropdown = ({ name, param }: FlexibleFormElementProps) => { +export const FieldDropdown = ({ name }: FlexibleFormElementProps) => { + const { paramsDict, setParamsDict } = useParamStore(); + const param = paramsDict[name] ?? paramPlaceholder; + const selectOptions = createListCollection({ items: param.schema.enum?.map((value) => ({ @@ -40,18 +44,30 @@ export const FieldDropdown = ({ name, param }: FlexibleFormElementProps) => { value, })) ?? [], }); + const contentRef = useRef(null); + const handleChange = ([value]: Array) => { + if (paramsDict[name]) { + // "undefined" values are removed from params, so we set it to null to avoid falling back to DAG defaults. + // eslint-disable-next-line unicorn/no-null + paramsDict[name].value = value ?? null; + } + + setParamsDict(paramsDict); + }; + return ( handleChange(event.value)} ref={contentRef} size="sm" + value={enumTypes.includes(typeof param.value) ? [param.value as string] : undefined} > - + diff --git a/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx b/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx index 02ae473324cfe..5a6a9fbf08370 100644 --- a/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx +++ b/airflow/ui/src/components/FlexibleForm/FieldMultiSelect.tsx @@ -16,10 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { Select as ReactSelect } from "chakra-react-select"; +import { type MultiValue, Select as ReactSelect } from "chakra-react-select"; import { useState } from "react"; import type { FlexibleFormElementProps } from "."; +import { paramPlaceholder, useParamStore } from "../TriggerDag/useParamStore"; const labelLookup = (key: string, valuesDisplay: Record | undefined): string => { if (valuesDisplay && typeof valuesDisplay === "object") { @@ -29,7 +30,11 @@ const labelLookup = (key: string, valuesDisplay: Record | undefi return key; }; -export const FieldMultiSelect = ({ name, param }: FlexibleFormElementProps) => { +export const FieldMultiSelect = ({ name }: FlexibleFormElementProps) => { + const { paramsDict, setParamsDict } = useParamStore(); + const param = paramsDict[name] ?? paramPlaceholder; + + // Initialize `selectedOptions` directly from `paramsDict` const [selectedOptions, setSelectedOptions] = useState( Array.isArray(param.value) ? (param.value as Array).map((value) => ({ @@ -39,6 +44,27 @@ export const FieldMultiSelect = ({ name, param }: FlexibleFormElementProps) => { : [], ); + // Handle changes to the select field + const handleChange = ( + newValue: MultiValue<{ + label: string; + value: string; + }>, + ) => { + const updatedOptions = [...newValue]; + + setSelectedOptions(updatedOptions); + + // "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 + const newValueArray = updatedOptions.length ? updatedOptions.map((option) => option.value) : null; + + if (paramsDict[name]) { + paramsDict[name].value = newValueArray; + } + setParamsDict(paramsDict); + }; + return ( { isClearable isMulti name={`element_${name}`} - onChange={(newValue) => setSelectedOptions([...newValue])} + onChange={handleChange} options={ param.schema.examples?.map((value) => ({ label: labelLookup(value, param.schema.values_display), diff --git a/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx b/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx index 70ee631cd3c99..3198dd9f9cad6 100644 --- a/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx +++ b/airflow/ui/src/components/FlexibleForm/FieldMultilineText.tsx @@ -19,13 +19,29 @@ import { Textarea } from "@chakra-ui/react"; import type { FlexibleFormElementProps } from "."; +import { paramPlaceholder, useParamStore } from "../TriggerDag/useParamStore"; -export const FieldMultilineText = ({ name, param }: FlexibleFormElementProps) => ( -