From fc315408ba4551c05c3f1341725737d53eb9bd7f Mon Sep 17 00:00:00 2001 From: Vincent Hsiao <124506982+fat-catTW@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:08:29 +0000 Subject: [PATCH] Add radio button rendering for enum Dag Params --- airflow-core/docs/core-concepts/params.rst | 10 +- .../newsfragments/71180.improvement.rst | 1 + .../example_params_ui_interfaces.ini | 25 +++++ .../example_params_ui_tutorial.py | 25 +++++ .../components/FlexibleForm/FieldDropdown.tsx | 37 ++----- .../FlexibleForm/FieldRadio.test.tsx | 96 +++++++++++++++++++ .../components/FlexibleForm/FieldRadio.tsx | 79 +++++++++++++++ .../FlexibleForm/FieldSelector.test.tsx | 88 +++++++++++++++++ .../components/FlexibleForm/FieldSelector.tsx | 6 ++ .../src/components/FlexibleForm/enumUtils.ts | 48 ++++++++++ .../airflow/ui/src/queries/useDagParams.ts | 1 + 11 files changed, 387 insertions(+), 29 deletions(-) create mode 100644 airflow-core/newsfragments/71180.improvement.rst create mode 100644 airflow-core/src/airflow/example_dags/example_params_ui_interfaces.ini create mode 100644 airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRadio.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRadio.tsx create mode 100644 airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/components/FlexibleForm/enumUtils.ts diff --git a/airflow-core/docs/core-concepts/params.rst b/airflow-core/docs/core-concepts/params.rst index 2f70611e88c29..f17c7623c319f 100644 --- a/airflow-core/docs/core-concepts/params.rst +++ b/airflow-core/docs/core-concepts/params.rst @@ -252,7 +252,10 @@ The following features are supported in the Trigger UI Form: | strings (e.g. ``P1D``, ``PT15M``, ``PT2H``) * ``format="multiline"``: Generate a multi-line textarea * | ``enum=["a", "b", "c"]``: Generates a - | drop-down select list for scalar values. + | drop-down select list for scalar values by default. + | To render the same fixed choices as radio buttons, + | set ``"x-airflow-ui": {"widget": "radio"}`` + | in the Param schema. | As of JSON validation, a value must be | selected or the field must be marked as | optional explicit. See also details inside @@ -262,6 +265,11 @@ The following features are supported in the Trigger UI Form: | ``enum`` you can add the attribute | ``values_display`` with a dict and map data | values to display labels. + | If the choices come from an external config file, + | read that file while the Dag is parsed and pass the + | resulting list to ``enum``; the trigger form reads + | the serialized Dag Params and does not call dynamic + | choice providers when the form opens. * | ``examples=["One", "Two", "Three"]``: If you | want to present proposals for values | (not restricting the user to a fixed ``enum`` diff --git a/airflow-core/newsfragments/71180.improvement.rst b/airflow-core/newsfragments/71180.improvement.rst new file mode 100644 index 0000000000000..fc28c47462b50 --- /dev/null +++ b/airflow-core/newsfragments/71180.improvement.rst @@ -0,0 +1 @@ +Added support for rendering enum Dag Params as radio buttons in the trigger Dag form. diff --git a/airflow-core/src/airflow/example_dags/example_params_ui_interfaces.ini b/airflow-core/src/airflow/example_dags/example_params_ui_interfaces.ini new file mode 100644 index 0000000000000..b24944d4ec644 --- /dev/null +++ b/airflow-core/src/airflow/example_dags/example_params_ui_interfaces.ini @@ -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. + +[InterfaceA] +TYPE = Script + +[InterfaceB] +TYPE = EBICS + +[InterfaceC] +TYPE = Script 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 279ddf82c71af..a782873667682 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 @@ -23,12 +23,23 @@ from __future__ import annotations +import configparser import datetime import json from pathlib import Path from airflow.sdk import DAG, Param, task + +def _get_script_interfaces_from_config() -> list[str]: + config = configparser.ConfigParser() + config.read(Path(__file__).with_name("example_params_ui_interfaces.ini")) + + return [section for section in config.sections() if config[section].get("TYPE") == "Script"] + + +SCRIPT_INTERFACES = _get_script_interfaces_from_config() + with DAG( dag_id=Path(__file__).stem, dag_display_name="Params UI tutorial", @@ -75,6 +86,20 @@ enum=[f"value {i}" for i in range(16, 64)], section="Typed parameters with Param object", ), + "script_interface": Param( + SCRIPT_INTERFACES[0], + schema={ + "type": "string", + "title": "Script interface", + "description": "Choices are generated from example_params_ui_interfaces.ini at Dag parse time.", + "enum": SCRIPT_INTERFACES, + "values_display": { + name: name.replace("Interface", "Interface ") for name in SCRIPT_INTERFACES + }, + "x-airflow-ui": {"widget": "radio"}, + "section": "Typed parameters with Param object", + }, + ), # [END section_1] # Boolean as proper parameter with description "bool": Param( 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 f5cf4fe769057..3235aafc4915d 100644 --- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx +++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDropdown.tsx @@ -22,21 +22,7 @@ import { useTranslation } from "react-i18next"; import { paramPlaceholder, useParamStore } from "src/queries/useParamStore"; import type { FlexibleFormElementProps } from "."; - -const NULL_STRING_VALUE = "__null__"; - -const labelLookup = ( - key: boolean | number | string | null, - valuesDisplay: Record | undefined, -): string => { - if (valuesDisplay && typeof valuesDisplay === "object") { - const stringKey = key === null ? "null" : String(key); - - return valuesDisplay[stringKey] ?? valuesDisplay.None ?? stringKey; - } - - return key === null ? "null" : String(key); -}; +import { getEnumOptionLabel, getEnumOptionValue, getOriginalEnumValue, NULL_STRING_VALUE } from "./enumUtils"; const enumTypes = ["string", "number", "integer"]; @@ -47,8 +33,8 @@ export const FieldDropdown = ({ name, namespace = "default", onUpdate }: Flexibl const options = param.schema.enum?.map((value) => ({ - label: labelLookup(value, param.schema.values_display), - value: String(value ?? NULL_STRING_VALUE), + label: getEnumOptionLabel(value, param.schema.values_display), + value: getEnumOptionValue(value), })) ?? []; const currentValue = @@ -65,17 +51,12 @@ export const FieldDropdown = ({ name, namespace = "default", onUpdate }: Flexibl }>, ) => { if (paramsDict[name]) { - if (!selected || selected.value === NULL_STRING_VALUE) { - paramsDict[name].value = null; - } else { - // Map the string value back to the original typed enum value (e.g. number, string) - // so that backend validation receives the correct type. - const originalValue = param.schema.enum?.find( - (enumVal) => String(enumVal ?? NULL_STRING_VALUE) === selected.value, - ); - - paramsDict[name].value = originalValue ?? selected.value; - } + // Map the string value back to the original typed enum value (e.g. number, string) + // so that backend validation receives the correct type. + paramsDict[name].value = + !selected || selected.value === NULL_STRING_VALUE + ? null + : getOriginalEnumValue(param.schema, selected.value); } setParamsDict(paramsDict); diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRadio.test.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRadio.test.tsx new file mode 100644 index 0000000000000..051027dde074c --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRadio.test.tsx @@ -0,0 +1,96 @@ +/*! + * 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, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Wrapper } from "src/utils/Wrapper"; + +import { FieldRadio } from "./FieldRadio"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockParamsDict: Record = {}; +const mockSetParamsDict = vi.fn(); + +vi.mock("src/queries/useParamStore", () => ({ + paramPlaceholder: { + schema: {}, + value: null, + }, + useParamStore: () => ({ + disabled: false, + paramsDict: mockParamsDict, + setParamsDict: mockSetParamsDict, + }), +})); + +describe("FieldRadio", () => { + beforeEach(() => { + Object.keys(mockParamsDict).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete mockParamsDict[key]; + }); + mockSetParamsDict.mockClear(); + }); + + it("renders enum values with display labels", () => { + mockParamsDict.test_param = { + schema: { + enum: ["InterfaceA", "InterfaceC"], + type: "string", + values_display: { + InterfaceA: "Interface A", + InterfaceC: "Interface C", + }, + }, + value: "InterfaceA", + }; + + render(, { + wrapper: Wrapper, + }); + + expect(screen.getByRole("radio", { name: "Interface A" })).toHaveProperty("checked", true); + expect(screen.getByRole("radio", { name: "Interface C" })).toBeDefined(); + }); + + it("preserves numeric enum values when selecting an option", async () => { + const onUpdate = vi.fn(); + + mockParamsDict.test_param = { + schema: { + enum: [1, 2, 3], + type: "number", + }, + value: 1, + }; + + render(, { + wrapper: Wrapper, + }); + + fireEvent.click(screen.getByText("2")); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockParamsDict.test_param.value).toBe(2); + }); + expect(mockSetParamsDict).toHaveBeenCalledWith(mockParamsDict); + expect(onUpdate).toHaveBeenCalledWith("2"); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRadio.tsx b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRadio.tsx new file mode 100644 index 0000000000000..ac02dac23fc5a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldRadio.tsx @@ -0,0 +1,79 @@ +/*! + * 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 { Flex, RadioGroup } from "@chakra-ui/react"; + +import { paramPlaceholder, useParamStore } from "src/queries/useParamStore"; + +import type { FlexibleFormElementProps } from "."; +import { getEnumOptionLabel, getEnumOptionValue, getOriginalEnumValue } from "./enumUtils"; + +const enumTypes = ["string", "number", "integer"]; + +export const FieldRadio = ({ name, namespace = "default", onUpdate }: FlexibleFormElementProps) => { + const { disabled, paramsDict, setParamsDict } = useParamStore(namespace); + const param = paramsDict[name] ?? paramPlaceholder; + + const options = + param.schema.enum?.map((value) => ({ + label: getEnumOptionLabel(value, param.schema.values_display), + value: getEnumOptionValue(value), + })) ?? []; + + const currentValue = + param.value === null + ? getEnumOptionValue(null) + : enumTypes.includes(typeof param.value) + ? getEnumOptionValue(param.value as number | string) + : undefined; + + const handleValueChange = ({ value }: RadioGroup.ValueChangeDetails) => { + if (value === null) { + return; + } + + if (paramsDict[name]) { + paramsDict[name].value = getOriginalEnumValue(param.schema, value); + } + + setParamsDict(paramsDict); + onUpdate(value); + }; + + return ( + + + {options.map((option) => ( + + + + + + {option.label} + + ))} + + + ); +}; 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 0000000000000..33a73eec76964 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.test.tsx @@ -0,0 +1,88 @@ +/*! + * 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 { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Wrapper } from "src/utils/Wrapper"; + +import { FieldSelector } from "./FieldSelector"; + +type MockParam = { + schema: { + enum: Array; + type: string; + values_display: Record; + "x-airflow-ui": { widget: "radio" }; + }; + value: string; +}; + +const mockInitialParamDict: Record = {}; +const mockParamsDict: Record = {}; + +vi.mock("src/queries/useParamStore", () => ({ + paramPlaceholder: { + schema: {}, + value: null, + }, + useParamStore: () => ({ + disabled: false, + initialParamDict: mockInitialParamDict, + paramsDict: mockParamsDict, + setParamsDict: vi.fn(), + }), +})); + +describe("FieldSelector", () => { + beforeEach(() => { + Object.keys(mockInitialParamDict).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete mockInitialParamDict[key]; + }); + Object.keys(mockParamsDict).forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete mockParamsDict[key]; + }); + }); + + it("renders enum params with radio UI metadata as radio buttons", () => { + const radioParam: MockParam = { + schema: { + enum: ["InterfaceA", "InterfaceC"], + type: "string", + values_display: { + InterfaceA: "Interface A", + InterfaceC: "Interface C", + }, + "x-airflow-ui": { widget: "radio" }, + }, + value: "InterfaceA", + }; + + mockInitialParamDict.test_param = radioParam; + mockParamsDict.test_param = radioParam; + + render(, { + wrapper: Wrapper, + }); + + expect(screen.getByRole("radio", { name: "Interface A" })).toHaveProperty("checked", true); + 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 173289a06d8e7..902ee81ebd816 100644 --- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx +++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx @@ -30,6 +30,7 @@ import { FieldMultiType } from "./FieldMultiType"; import { FieldMultilineText } from "./FieldMultilineText"; import { FieldNumber } from "./FieldNumber"; import { FieldObject } from "./FieldObject"; +import { FieldRadio } from "./FieldRadio"; import { FieldString } from "./FieldString"; import { FieldStringArray } from "./FieldStringArray"; @@ -76,6 +77,9 @@ const enumTypes = ["null", "string", "number", "integer"]; const isFieldDropdown = (fieldType: string, fieldSchema: ParamSchema) => enumTypes.includes(fieldType) && Array.isArray(fieldSchema.enum); +const isFieldRadio = (fieldType: string, fieldSchema: ParamSchema) => + isFieldDropdown(fieldType, fieldSchema) && fieldSchema["x-airflow-ui"]?.widget === "radio"; + const isFieldMultilineText = (fieldType: string, fieldSchema: ParamSchema) => fieldType === "string" && fieldSchema.format === "multiline"; @@ -132,6 +136,8 @@ export const FieldSelector = ({ name, namespace = "default", onUpdate }: Flexibl return ; } else if (isFieldTime(fieldType, param.schema)) { return ; + } else if (isFieldRadio(fieldType, param.schema)) { + return ; } else if (isFieldDropdown(fieldType, param.schema)) { return ; } else if (isFieldMultiSelect(fieldType, param.schema)) { diff --git a/airflow-core/src/airflow/ui/src/components/FlexibleForm/enumUtils.ts b/airflow-core/src/airflow/ui/src/components/FlexibleForm/enumUtils.ts new file mode 100644 index 0000000000000..72ca6aa6be629 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/enumUtils.ts @@ -0,0 +1,48 @@ +/*! + * 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 { ParamSchema } from "src/queries/useDagParams"; + +export const NULL_STRING_VALUE = "__null__"; + +export type EnumValue = boolean | number | string | null; + +export const getEnumOptionLabel = ( + key: EnumValue, + valuesDisplay: Record | undefined, +): string => { + if (valuesDisplay && typeof valuesDisplay === "object") { + const stringKey = key === null ? "null" : String(key); + + return valuesDisplay[stringKey] ?? valuesDisplay.None ?? stringKey; + } + + return key === null ? "null" : String(key); +}; + +export const getEnumOptionValue = (value: EnumValue) => String(value ?? NULL_STRING_VALUE); + +export const getOriginalEnumValue = (schema: ParamSchema, selectedValue: string) => { + if (selectedValue === NULL_STRING_VALUE) { + return null; + } + + const originalValue = schema.enum?.find((enumVal) => getEnumOptionValue(enumVal) === selectedValue); + + return originalValue === undefined ? selectedValue : originalValue; +}; diff --git a/airflow-core/src/airflow/ui/src/queries/useDagParams.ts b/airflow-core/src/airflow/ui/src/queries/useDagParams.ts index fc7d39608d0d3..92de3cf803420 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDagParams.ts +++ b/airflow-core/src/airflow/ui/src/queries/useDagParams.ts @@ -45,6 +45,7 @@ export type ParamSchema = { title: string | undefined; type: Array | string | undefined; values_display: Record | undefined; + "x-airflow-ui"?: { widget?: "dropdown" | "radio" }; }; export const useDagParams = (dagId: string, open: boolean) => {