diff --git a/airflow-core/docs/core-concepts/params.rst b/airflow-core/docs/core-concepts/params.rst index c192ee639ec87..5a0028fe73f69 100644 --- a/airflow-core/docs/core-concepts/params.rst +++ b/airflow-core/docs/core-concepts/params.rst @@ -452,6 +452,57 @@ 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.5.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/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/dag_version_diff.py b/airflow-core/src/airflow/serialization/dag_version_diff.py index 8935ddc20de83..b096b6b3e771a 100644 --- a/airflow-core/src/airflow/serialization/dag_version_diff.py +++ b/airflow-core/src/airflow/serialization/dag_version_diff.py @@ -340,6 +340,7 @@ "max_active_tasks": "schedule", "max_consecutive_failed_dag_runs": "schedule", "owner_links": "metadata", + "param_presets": "param", "params": "param", "relative_fileloc": "provenance", "render_template_as_native_obj": "task", 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..203215ab94403 100644 --- a/airflow-core/src/airflow/serialization/schema.json +++ b/airflow-core/src/airflow/serialization/schema.json @@ -177,6 +177,10 @@ "type": "object", "properties": { "params": { "$ref": "#/definitions/params" }, + "param_presets": { + "type": "object", + "additionalProperties": { "type": "object" } + }, "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 { paramsDict: params, setDisabled, setInitialParamDict, setParamsDict } = useParamStore(namespace); + const { + confVersion, + paramsDict: params, + setDisabled, + setInitialParamDict, + setParamsDict, + } = useParamStore(namespace); const processedSections = new Map(); const [sectionError, setSectionError] = useState>(new Map()); const [hasValidationError, setHasValidationError] = useState(false); @@ -125,6 +131,10 @@ export const FlexibleForm = ({ setDisabled(disabled ?? false); }, [disabled, setDisabled]); + useEffect(() => { + setHasValidationError(false); + }, [confVersion]); + const onUpdate = (_value?: string, error?: unknown) => { const newSectionError = computeSectionErrors(params, flexibleFormDefaultSection); @@ -165,7 +175,7 @@ export const FlexibleForm = ({ (currentSection === flexibleFormDefaultSection && !Boolean(param.schema.section)), ) .map(([name]) => ( - + ))} diff --git a/airflow-core/src/airflow/ui/src/components/ParamPresetSelect.tsx b/airflow-core/src/airflow/ui/src/components/ParamPresetSelect.tsx new file mode 100644 index 0000000000000..0154eac6ab5e7 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/ParamPresetSelect.tsx @@ -0,0 +1,85 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState } from "react"; + +import { Field } from "@chakra-ui/react"; +import { type SingleValue, Select as ReactSelect } from "chakra-react-select"; +import { useTranslation } from "react-i18next"; + +import type { ParamPresets, ParamsSpec } from "src/queries/useDagParams"; +import { useParamStore } from "src/queries/useParamStore"; + +type Props = { + readonly paramPresets?: ParamPresets; + readonly paramsDict: ParamsSpec; +}; + +type Option = { label: string; value: string }; + +/** + * Drop-down of the Dag author's named param presets. + * + * Picking one loads the Dag defaults and then applies the preset's values on top, so the same + * preset always produces the same run config no matter what was edited before it was picked. + */ +export const ParamPresetSelect = ({ paramPresets, paramsDict }: Props) => { + const { t: translate } = useTranslation("components"); + const { disabled, setConf } = useParamStore(); + const [selected, setSelected] = useState