From 36dbfbd962e03d832229f499e927702f6d584157 Mon Sep 17 00:00:00 2001 From: Vinol Fernandes Date: Tue, 29 Sep 2026 19:59:35 +0530 Subject: [PATCH 1/5] add param presets --- airflow-core/docs/core-concepts/params.rst | 50 +++++++++++ airflow-core/newsfragments/71285.feature.rst | 9 ++ .../api_fastapi/core_api/datamodels/dags.py | 2 + .../openapi/v2-rest-api-generated.yaml | 6 ++ .../example_dags/example_params_trigger_ui.py | 5 ++ .../airflow/serialization/definitions/dag.py | 1 + .../src/airflow/serialization/schema.json | 5 ++ .../serialization/serialized_objects.py | 5 ++ .../ui/openapi-gen/requests/schemas.gen.ts | 8 ++ .../ui/openapi-gen/requests/types.gen.ts | 5 ++ .../ui/public/i18n/locales/en/components.json | 5 +- .../airflow/ui/src/components/ConfigForm.tsx | 9 +- .../ui/src/components/ParamPresetSelect.tsx | 85 +++++++++++++++++++ .../TriggerDag/TriggerDAGForm.test.tsx | 68 +++++++++++++++ .../airflow/ui/src/queries/useDagParams.ts | 6 +- .../core_api/routes/public/test_dags.py | 2 + .../serialization/test_dag_serialization.py | 30 +++++++ .../airflowctl/api/datamodels/generated.py | 1 + task-sdk/src/airflow/sdk/definitions/dag.py | 35 ++++++++ .../tests/task_sdk/definitions/test_dag.py | 73 ++++++++++++++++ ts-sdk/schema/dag-schema.json | 5 ++ ts-sdk/scripts/generate-dag-schema.mjs | 2 + 22 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 airflow-core/newsfragments/71285.feature.rst create mode 100644 airflow-core/src/airflow/ui/src/components/ParamPresetSelect.tsx diff --git a/airflow-core/docs/core-concepts/params.rst b/airflow-core/docs/core-concepts/params.rst index c192ee639ec87..e1292387c69e8 100644 --- a/airflow-core/docs/core-concepts/params.rst +++ b/airflow-core/docs/core-concepts/params.rst @@ -452,6 +452,56 @@ Finally the fourth section shows advanced form elements. ``description_html`` is now superseded with the attribute ``description_md``. ``description_html`` is not supported anymore. Custom form elements using the attribute ``custom_html_form`` was deprecated in version 2.8.0 and support was removed in 3.0.0. +Offer Param Presets in the Trigger UI Form +------------------------------------------ + +.. versionadded:: 3.4.0 + +When one Dag serves several well-known configurations, declare them as ``param_presets`` so people +triggering the Dag can load a whole set of values at once instead of retyping them. Each preset maps +a name to the param values it applies: + +.. code-block:: python + + with DAG( + dag_id="train_model", + schedule=None, + params={ + "dataset": Param("cifar10", type="string", enum=["cifar10", "imagenet", "mnist"]), + "batch_size": Param(128, type="integer", minimum=1), + "augmentations": Param( + {"flip": True, "rotate": 15, "crop": None}, + type="object", + title="Augmentation settings", + ), + }, + param_presets={ + "ImageNet (heavy augmentation)": { + "dataset": "imagenet", + "batch_size": 512, + "augmentations": {"flip": True, "rotate": 45, "crop": 224}, + }, + "Smoke test": {"batch_size": 1}, + }, + ) as dag: ... + +A ``Preset`` drop-down then appears above the trigger form. Picking one loads the Dag's param +defaults and applies that preset's values on top, so a preset always produces the same starting +point no matter what was edited before it was picked. Every field stays editable afterwards, which +is the point: start from a known-good configuration and tweak the one or two values that differ for +this run. + +A preset only lists the params it changes; every other param keeps its default. Preset names are +shown verbatim in the drop-down, in the order you declare them. + +Presets are validated while the Dag is parsed, so a typo fails fast rather than at trigger time: + +- Each key must name a param declared in ``params``. +- Each value must satisfy that param's schema, exactly as if it were typed into the form. + +Presets seed the trigger form only. They do not change the Dag's param defaults, and a Dag run +started by a schedule or by the API without ``conf`` still uses the defaults declared in ``params``. + Disabling Runtime Param Modification ------------------------------------ diff --git a/airflow-core/newsfragments/71285.feature.rst b/airflow-core/newsfragments/71285.feature.rst new file mode 100644 index 0000000000000..0433758953bae --- /dev/null +++ b/airflow-core/newsfragments/71285.feature.rst @@ -0,0 +1,9 @@ +Dag authors can declare named param presets that pre-load the trigger form + +``DAG(param_presets=...)`` maps a preset name to the param values it applies, e.g. +``{"Smoke test": {"batch_size": 1}}``. The trigger form then shows a ``Preset`` drop-down above the +run parameters; picking one loads the Dag's param defaults with that preset's values applied on top, +and every field stays editable before the run is submitted. A preset only lists the params it +changes, and presets are validated at parse time, so a key that names no declared param, or a value +that fails that param's schema, fails Dag parsing instead of surfacing at trigger time. Presets seed +the form only: param defaults, scheduled runs, and API triggers without ``conf`` are unaffected. diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py index 17c8c52158f19..eebfd065639c6 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py @@ -28,6 +28,7 @@ from pydantic import ( AliasGenerator, ConfigDict, + Field, computed_field, field_serializer, field_validator, @@ -217,6 +218,7 @@ class DAGDetailsResponse(DAGResponse): end_date: datetime | None is_paused_upon_creation: bool | None params: Mapping | None + param_presets: dict[str, dict[str, Any]] = Field(default_factory=dict) render_template_as_native_obj: bool template_search_path: list[str] | None timezone: str | None diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml index 56cd607999978..17ff14bf7e515 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml @@ -14736,6 +14736,12 @@ components: type: object - type: 'null' title: Params + param_presets: + additionalProperties: + additionalProperties: true + type: object + type: object + title: Param Presets render_template_as_native_obj: type: boolean title: Render Template As Native Obj diff --git a/airflow-core/src/airflow/example_dags/example_params_trigger_ui.py b/airflow-core/src/airflow/example_dags/example_params_trigger_ui.py index 0b44f06042f3b..724fc5f62b9cb 100644 --- a/airflow-core/src/airflow/example_dags/example_params_trigger_ui.py +++ b/airflow-core/src/airflow/example_dags/example_params_trigger_ui.py @@ -49,6 +49,11 @@ "german": Param(True, type="boolean", title="German (Formal)"), "french": Param(True, type="boolean", title="French"), }, + param_presets={ + "English only": {"english": True, "german": False, "french": False}, + "Continental": {"english": False, "german": True, "french": True}, + "Just Linda, every language": {"names": ["Linda"]}, + }, ) as dag: @task(task_id="get_names", task_display_name="Get names") diff --git a/airflow-core/src/airflow/serialization/definitions/dag.py b/airflow-core/src/airflow/serialization/definitions/dag.py index 64ceeff7cccff..cb72ae51d582c 100644 --- a/airflow-core/src/airflow/serialization/definitions/dag.py +++ b/airflow-core/src/airflow/serialization/definitions/dag.py @@ -133,6 +133,7 @@ class SerializedDAG: max_consecutive_failed_dag_runs: int = 0 owner_links: dict[str, str] = attrs.field(factory=dict) params: SerializedParamsDict = attrs.field(factory=SerializedParamsDict) + param_presets: dict[str, dict[str, Any]] = attrs.field(factory=dict) partial: bool = False render_template_as_native_obj: bool = False start_date: datetime.datetime | None = None diff --git a/airflow-core/src/airflow/serialization/schema.json b/airflow-core/src/airflow/serialization/schema.json index ed2251ce3dac7..86be6e90120a3 100644 --- a/airflow-core/src/airflow/serialization/schema.json +++ b/airflow-core/src/airflow/serialization/schema.json @@ -177,6 +177,11 @@ "type": "object", "properties": { "params": { "$ref": "#/definitions/params" }, + "param_presets": { + "type": "object", + "additionalProperties": { "type": "object" }, + "default": {} + }, "dag_id": { "type": "string" }, "tasks": { "$ref": "#/definitions/tasks" }, "timezone": { "$ref": "#/definitions/timezone" }, diff --git a/airflow-core/src/airflow/serialization/serialized_objects.py b/airflow-core/src/airflow/serialization/serialized_objects.py index 73fc26e01147a..7e8289939cc48 100644 --- a/airflow-core/src/airflow/serialization/serialized_objects.py +++ b/airflow-core/src/airflow/serialization/serialized_objects.py @@ -1795,6 +1795,11 @@ def serialize_dag(cls, dag: DAG) -> dict: # Edge info in the JSON exactly matches our internal structure serialized_dag["edge_info"] = dag.edge_info serialized_dag["params"] = cls._serialize_params_dict(dag.params) + # Preset values are plain JSON already (the SDK validates them against the param + # schemas), so store them verbatim rather than with the nested type encoding the + # generic field loop would apply. + if dag.param_presets: + serialized_dag["param_presets"] = dag.param_presets # has_on_*_callback are only stored if the value is True, as the default is False for field in _DAG_CALLBACK_FIELDS: diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts index d121d52083678..89ce707e15017 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -3470,6 +3470,14 @@ export const $DAGDetailsResponse = { ], title: 'Params' }, + param_presets: { + additionalProperties: { + additionalProperties: true, + type: 'object' + }, + type: 'object', + title: 'Param Presets' + }, render_template_as_native_obj: { type: 'boolean', title: 'Render Template As Native Obj' diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts index 0a90bf284f5d4..6dc10d6fe8aac 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts @@ -978,6 +978,11 @@ export type DAGDetailsResponse = { params: { [key: string]: unknown; } | null; + param_presets?: { + [key: string]: { + [key: string]: unknown; + }; + }; render_template_as_native_obj: boolean; template_search_path: Array<(string)> | null; timezone: string | null; 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 40d5f6ffff6b1..3c278376a6f89 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 @@ -53,7 +53,10 @@ "close": "Close", "configForm": { "configJson": "Configuration JSON", - "invalidJson": "Invalid JSON format: {{errorMessage}}" + "invalidJson": "Invalid JSON format: {{errorMessage}}", + "paramPreset": "Preset", + "paramPresetHelp": "Loads the Dag defaults with this preset's values applied on top. You can still edit any field afterwards.", + "paramPresetPlaceholder": "Select a preset" }, "dagWarnings": { "error_one": "1 Error", diff --git a/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx b/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx index 753cea8f3699e..fdb6851779122 100644 --- a/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx +++ b/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx @@ -24,11 +24,12 @@ import { useTranslation } from "react-i18next"; import { Accordion } from "src/system-components"; -import type { ParamsSpec } from "src/queries/useDagParams"; +import type { ParamPresets, ParamsSpec } from "src/queries/useDagParams"; import { useParamStore } from "src/queries/useParamStore"; import { FlexibleForm, flexibleFormDefaultSection } from "./FlexibleForm"; import { JsonEditor } from "./JsonEditor"; +import { ParamPresetSelect } from "./ParamPresetSelect"; type ConfigFormProps = { readonly children?: ReactNode; @@ -37,7 +38,7 @@ type ConfigFormProps = { conf?: string; date?: unknown; }; - readonly initialParamsDict: { paramsDict: ParamsSpec }; + readonly initialParamsDict: { paramPresets?: ParamPresets; paramsDict: ParamsSpec }; readonly setErrors: Dispatch< SetStateAction<{ conf?: string; @@ -85,6 +86,10 @@ const ConfigForm = ({ return ( + { + const { t: translate } = useTranslation("components"); + const { disabled, setConf } = useParamStore(); + const [selected, setSelected] = useState diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx index 065ecf7ebbaf0..8ea5675fd9217 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.test.tsx @@ -502,4 +502,48 @@ describe("TriggerDAGForm", () => { expect(configJson.value).toContain('"Nightly message"'); }); }); + + it("clears a required-field error once a preset restores a valid value", async () => { + useDagParamsMock.mockReturnValue({ + paramPresets: { Nightly: { message: "Nightly message" } }, + paramsDict: dagParams.paramsDict, + }); + + const { container } = render( + , + { wrapper: Wrapper }, + ); + + await waitFor(() => + expect(container.querySelector('input[name="element_message"]')).toBeInTheDocument(), + ); + fireEvent.change(container.querySelector('input[name="element_message"]') as Element, { + target: { value: "" }, + }); + + await waitFor(() => expect(screen.getByText("flexibleForm.validationErrorRequired")).toBeInTheDocument()); + expect(screen.getByTestId("trigger-dag-submit")).toBeDisabled(); + + fireEvent.keyDown(within(screen.getByTestId("param-preset-select")).getByRole("combobox"), { + key: "ArrowDown", + }); + fireEvent.click(await screen.findByText("Nightly")); + + await waitFor(() => + expect(container.querySelector('input[name="element_message"]')).toHaveValue( + "Nightly message", + ), + ); + expect(screen.queryByText("flexibleForm.validationErrorRequired")).not.toBeInTheDocument(); + expect(screen.getByTestId("trigger-dag-submit")).toBeEnabled(); + }); }); diff --git a/airflow-core/src/airflow/ui/src/queries/useParamStore.ts b/airflow-core/src/airflow/ui/src/queries/useParamStore.ts index 38200ad76b5cf..cc75abdf729e8 100644 --- a/airflow-core/src/airflow/ui/src/queries/useParamStore.ts +++ b/airflow-core/src/airflow/ui/src/queries/useParamStore.ts @@ -43,6 +43,7 @@ export const paramPlaceholder: ParamSpec = { type FormStore = { conf: string; + confVersion: number; disabled: boolean; initialParamDict: ParamsSpec; paramsDict: ParamsSpec; @@ -55,6 +56,7 @@ type FormStore = { const createParamStore = () => create((set) => ({ conf: "{}", + confVersion: 0, disabled: false, initialParamDict: {}, paramsDict: {}, @@ -105,7 +107,7 @@ const createParamStore = () => const updatedParamsDict: ParamsSpec = Object.fromEntries(updatedParamsDictEntries); - return { conf: confString, paramsDict: updatedParamsDict }; + return { conf: confString, confVersion: state.confVersion + 1, paramsDict: updatedParamsDict }; }), setDisabled: (disabled: boolean) => set(() => ({ disabled })),