From ef46bf3947b956c86a45c7e350f0eb4ee2e0bd62 Mon Sep 17 00:00:00 2001 From: Anshu Singh Date: Mon, 8 Dec 2025 17:11:19 +0530 Subject: [PATCH 1/6] Feat: Support URL params for pre-filling Trigger and Backfill forms --- airflow-core/docs/core-concepts/params.rst | 47 +++++++++++++++++++ .../airflow/ui/src/components/ConfigForm.tsx | 6 ++- .../components/DagActions/RunBackfillForm.tsx | 45 +++++++++++------- .../components/TriggerDag/TriggerDAGForm.tsx | 21 ++++++--- .../components/TriggerDag/TriggerDAGModal.tsx | 26 ++++++++-- airflow-core/src/airflow/ui/src/router.tsx | 1 + .../src/airflow/ui/src/utils/trigger.ts | 46 ++++++++++++++++++ 7 files changed, 164 insertions(+), 28 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/utils/trigger.ts diff --git a/airflow-core/docs/core-concepts/params.rst b/airflow-core/docs/core-concepts/params.rst index cdcd150a73b5a..6ccc7055d8904 100644 --- a/airflow-core/docs/core-concepts/params.rst +++ b/airflow-core/docs/core-concepts/params.rst @@ -409,3 +409,50 @@ Disabling Runtime Param Modification The ability to update params while triggering a Dag depends on the flag ``core.dag_run_conf_overrides_params``. Setting this config to ``False`` will effectively turn your default params into constants. + +To pre-populate values in the form when publishing a link to the trigger form you can call the trigger URL ``/dags//trigger/single`` or ``/dags//trigger/backfill``, +and add query parameters to the URL. + +There are two trigger form URLs available, each supporting a different set of query parameters: + +* ``/trigger/single``: + - ``conf`` - JSON configuration. + - ``run_id`` - run identifier. + - ``logical_date`` - execution date in ``YYYY-MM-DDTHH:mm:ss.SSS`` format. Defaults to the current timestamp if not provided. + - ``note`` - note attached to the DAG run. + +* ``/trigger/backfill``: + - ``conf`` - JSON configuration, applied to all runs. + - ``from_date`` - start of the backfill window in ``YYYY-MM-DDTHH:mm:ss`` format. + - ``to_date`` - end of the backfill window in ``YYYY-MM-DDTHH:mm:ss`` format. + - ``max_active_runs`` - maximum concurrent runs. Defaults to ``1``. + - ``reprocess_behavior`` - determines how existing runs are reprocessed. Supported values are: + * ``failed`` - Missing and Errored Runs + * ``completed`` - All Runs + * ``none`` - Missing Runs + - ``run_backwards`` - if set to true, the backfill is scheduled in reverse order. Defaults to ``false``. + +The trigger form now supports two different ways of providing ``conf`` values. The available input methods are summarized in the table below: + +.. list-table:: ``conf`` parameter usage + :header-rows: 1 + :widths: 15 35 55 + + * - Form + - Usage + - Example + * - JSON (explicit) + - Provide the entire configuration as a JSON object. + This form has higher priority if present. + - ``/dags/{dag_id}/trigger/single?conf={"foo":"bar","x":123}`` + * - Key-value (implicit) + - If ``conf`` is not specified, any query parameter that is not a reserved keyword + will be automatically collected into ``conf``. + - ``/dags/{dag_id}/trigger/single?run_id=myrun&foo=bar&x=123`` + results in ``conf={"foo":"bar","x":"123"}`` + +For example, you can pass the pathname and query like below: + +``/dags/{dag_id}/trigger/single?run_id=my_run_dag&logical_date=2025-09-06T12:34:56.789&conf={"foo":"bar"}¬e=run_note`` + +``/dags/{dag_id}/trigger/backfill?from_date=2025-09-01T00:00:00&to_date=2025-09-03T23:59:59&conf={"abc":"loo"}&max_active_runs=2&reprocess_behavior=failed&run_backwards=true`` diff --git a/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx b/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx index 65adf1bfca302..b8c5210658644 100644 --- a/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx +++ b/airflow-core/src/airflow/ui/src/components/ConfigForm.tsx @@ -35,6 +35,7 @@ type ConfigFormProps = { date?: unknown; }; readonly initialParamsDict: { paramsDict: ParamsSpec }; + readonly openAdvanced?: boolean; readonly setErrors: React.Dispatch< React.SetStateAction<{ conf?: string; @@ -49,6 +50,7 @@ const ConfigForm = ({ control, errors, initialParamsDict, + openAdvanced = false, setErrors, setFormError, }: ConfigFormProps) => { @@ -83,7 +85,9 @@ const ConfigForm = ({ return ( { const [formError, setFormError] = useState(false); const initialParamsDict = useDagParams(dag.dag_id, true); const { conf } = useParamStore(); - const { control, handleSubmit, reset, watch } = useForm({ + const [searchParams] = useSearchParams(); + const reservedKeys = ["from_date", "to_date", "max_active_runs", "reprocess_behavior", "run_backwards"]; + const urlConf = getTriggerConf(searchParams, reservedKeys); + const { control, handleSubmit, reset } = useForm({ defaultValues: { - conf, + conf: urlConf === "{}" ? conf || "{}" : urlConf, dag_id: dag.dag_id, - from_date: "", - max_active_runs: 1, - reprocess_behavior: "none", - run_backwards: false, + from_date: searchParams.get("from_date") ?? "", + max_active_runs: parseInt(searchParams.get("max_active_runs") ?? "1", 10) || 1, + reprocess_behavior: (searchParams.get("reprocess_behavior") ?? "none") as ReprocessBehavior, + run_backwards: searchParams.get("run_backwards") === "true", run_on_latest_version: true, - to_date: "", + to_date: searchParams.get("to_date") ?? "", }, mode: "onBlur", }); @@ -91,16 +101,13 @@ const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => { if (Boolean(dateValidationError)) { setErrors((prev) => ({ ...prev, date: dateValidationError })); } - }, [dateValidationError]); - useEffect(() => { - if (conf) { - reset((prevValues) => ({ ...prevValues, conf })); + if (Boolean(conf) && urlConf === "{}") { + reset((prev) => ({ ...prev, conf })); } - }, [conf, reset]); - const dataIntervalStart = watch("from_date"); - const dataIntervalEnd = watch("to_date"); - const noDataInterval = !Boolean(dataIntervalStart) || !Boolean(dataIntervalEnd); - const dataIntervalInvalid = dayjs(dataIntervalStart).isAfter(dayjs(dataIntervalEnd)); + }, [dateValidationError, conf, reset, urlConf]); + + const noDataInterval = !Boolean(values.from_date) || !Boolean(values.to_date); + const dataIntervalInvalid = dayjs(values.from_date).isAfter(dayjs(values.to_date)); const onSubmit = (fdata: BackfillFormProps) => { if (unpause && dag.is_paused) { @@ -239,6 +246,10 @@ const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => { control={control} errors={errors} initialParamsDict={initialParamsDict} + openAdvanced={ + urlConf !== "{}" || + ["max_active_runs", "reprocess_behavior", "run_backwards"].some((key) => searchParams.has(key)) + } setErrors={setErrors} setFormError={setFormError} /> 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 cd291acb09e8a..4eccfb94fb6b1 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx @@ -22,12 +22,14 @@ import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { FiPlay } from "react-icons/fi"; +import { useSearchParams } from "react-router-dom"; import { useDagParams } from "src/queries/useDagParams"; import { useParamStore } from "src/queries/useParamStore"; import { useTogglePause } from "src/queries/useTogglePause"; import { useTrigger } from "src/queries/useTrigger"; import { DEFAULT_DATETIME_FORMAT } from "src/utils/datetimeUtils"; +import { getTriggerConf } from "src/utils/trigger"; import ConfigForm from "../ConfigForm"; import { DateTimeInput } from "../DateTimeInput"; @@ -78,32 +80,38 @@ const TriggerDAGForm = ({ const { error: errorTrigger, isPending, triggerDagRun } = useTrigger({ dagId, onSuccessConfirm: onClose }); const { conf } = useParamStore(); const [unpause, setUnpause] = useState(true); + const [searchParams] = useSearchParams(); + const reservedKeys = ["run_id", "logical_date", "note"]; + const urlConf = getTriggerConf(searchParams, reservedKeys); + const urlRunId = searchParams.get("run_id") ?? ""; + const urlDate = searchParams.get("logical_date"); + const urlNote = searchParams.get("note") ?? ""; const { mutate: togglePause } = useTogglePause({ dagId }); const { control, handleSubmit, reset, watch } = useForm({ defaultValues: { - conf, - dagRunId: "", + conf: urlConf === "{}" ? conf || "{}" : urlConf, + dagRunId: urlRunId, dataIntervalEnd: "", dataIntervalMode: "auto", dataIntervalStart: "", // Default logical date to now, show it in the selected timezone - logicalDate: dayjs().format(DEFAULT_DATETIME_FORMAT), - note: "", + logicalDate: urlDate ?? dayjs().format(DEFAULT_DATETIME_FORMAT), + note: urlNote, partitionKey: undefined, }, }); // Automatically reset form when conf is fetched useEffect(() => { - if (conf) { + if (conf && urlConf === "{}") { reset((prevValues) => ({ ...prevValues, conf, })); } - }, [conf, reset]); + }, [conf, reset, urlConf]); const resetDateError = () => { setErrors((prev) => ({ ...prev, date: undefined })); @@ -219,6 +227,7 @@ const TriggerDAGForm = ({ control={control} errors={errors} initialParamsDict={initialParamsDict} + openAdvanced={urlConf !== "{}" || Boolean(urlRunId) || Boolean(urlDate) || Boolean(urlNote)} setErrors={setErrors} setFormError={setFormError} > diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGModal.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGModal.tsx index 9eb3e00a0e112..aaf9e22db527f 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGModal.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGModal.tsx @@ -17,8 +17,9 @@ * under the License. */ import { Heading, VStack, HStack, Spinner, Center, Text } from "@chakra-ui/react"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { useParams, useNavigate } from "react-router-dom"; import { useDagServiceGetDag } from "openapi/queries"; import { Dialog, Tooltip } from "src/components/ui"; @@ -48,7 +49,11 @@ const TriggerDAGModal: React.FC = ({ open, }) => { const { t: translate } = useTranslation("components"); - const [runMode, setRunMode] = useState(RunMode.SINGLE); + const { mode } = useParams(); + const navigate = useNavigate(); + const [runMode, setRunMode] = useState( + mode === RunMode.BACKFILL ? RunMode.BACKFILL : RunMode.SINGLE, + ); const { data: dag, isError, @@ -63,6 +68,17 @@ const TriggerDAGModal: React.FC = ({ }, ); + useEffect(() => { + if (mode === RunMode.BACKFILL) { + setRunMode(RunMode.BACKFILL); + } else { + setRunMode(RunMode.SINGLE); + } + }, [mode]); + const handleModeChange = (value: string) => { + setRunMode(value as RunMode); + navigate(`/dags/${dagId}/trigger/${value}`, { replace: true }); + }; const hasSchedule = dag?.timetable_summary !== null; const maxDisplayLength = 59; // hard-coded length to prevent dag name overflowing the modal const nameOverflowing = dagDisplayName.length > maxDisplayLength; @@ -98,8 +114,10 @@ const TriggerDAGModal: React.FC = ({ {dag ? ( { - setRunMode((event.target as HTMLInputElement).value as RunMode); + onValueChange={(details) => { + if (details.value !== null) { + handleModeChange(details.value); + } }} value={runMode} > diff --git a/airflow-core/src/airflow/ui/src/router.tsx b/airflow-core/src/airflow/ui/src/router.tsx index 5c3ddc76174e2..faaff2bfa960d 100644 --- a/airflow-core/src/airflow/ui/src/router.tsx +++ b/airflow-core/src/airflow/ui/src/router.tsx @@ -160,6 +160,7 @@ export const routerConfig = [ { children: [ { element: , index: true }, + { element: , path: "trigger/:mode?" }, { element: , path: "runs" }, { element: , path: "tasks" }, { element: , path: "calendar" }, diff --git a/airflow-core/src/airflow/ui/src/utils/trigger.ts b/airflow-core/src/airflow/ui/src/utils/trigger.ts new file mode 100644 index 0000000000000..89259f8d24b8f --- /dev/null +++ b/airflow-core/src/airflow/ui/src/utils/trigger.ts @@ -0,0 +1,46 @@ +/*! + * 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. + */ + +// Helper to extract configuration from URL search params +export const getTriggerConf = (searchParams: URLSearchParams, reservedKeys: Array) => { + const confParam = searchParams.get("conf"); + + // 1. If the user provided direct JSON 'conf' param (e.g., ?conf={"foo":"bar"}) + if (confParam !== null) { + try { + const parsed = JSON.parse(confParam) as unknown; + + return JSON.stringify(parsed, undefined, 2); + } catch { + // Ignore parsing errors + } + } + + // 2. If the user provided individual key-value params (e.g., ?foo=bar&run_id=123) + const collected: Record = {}; + + searchParams.forEach((value, key) => { + // Do not include reserved keys (like run_id, date) in the config, as they belong to specific form fields + if (!reservedKeys.includes(key) && key !== "conf") { + collected[key] = value; + } + }); + + return Object.keys(collected).length > 0 ? JSON.stringify(collected, undefined, 2) : "{}"; +}; From 8f61d04c32f7a4b761654c84aace0566136c39b6 Mon Sep 17 00:00:00 2001 From: Anshu Singh Date: Tue, 9 Dec 2025 15:28:46 +0530 Subject: [PATCH 2/6] Fix docs build: Correct RST formatting and add 'reprocessed' to wordlist --- airflow-core/docs/core-concepts/params.rst | 2 ++ docs/spelling_wordlist.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/airflow-core/docs/core-concepts/params.rst b/airflow-core/docs/core-concepts/params.rst index 6ccc7055d8904..2862788b880d2 100644 --- a/airflow-core/docs/core-concepts/params.rst +++ b/airflow-core/docs/core-concepts/params.rst @@ -427,9 +427,11 @@ There are two trigger form URLs available, each supporting a different set of qu - ``to_date`` - end of the backfill window in ``YYYY-MM-DDTHH:mm:ss`` format. - ``max_active_runs`` - maximum concurrent runs. Defaults to ``1``. - ``reprocess_behavior`` - determines how existing runs are reprocessed. Supported values are: + * ``failed`` - Missing and Errored Runs * ``completed`` - All Runs * ``none`` - Missing Runs + - ``run_backwards`` - if set to true, the backfill is scheduled in reverse order. Defaults to ``false``. The trigger form now supports two different ways of providing ``conf`` values. The available input methods are summarized in the table below: diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index a3e10044b5788..9cd8c459c19cc 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -1523,6 +1523,7 @@ replicaSet repo repos repr +reprocessed req reqs requeue From 7931b89307d838f5b2d2a7afe8b77fccfd5dc3a4 Mon Sep 17 00:00:00 2001 From: Anshu Singh Date: Wed, 10 Dec 2025 12:09:54 +0530 Subject: [PATCH 3/6] Fix auto-open modal and update docs --- airflow-core/docs/core-concepts/params.rst | 5 ++++- .../ui/src/pages/Dag/Overview/Overview.tsx | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/airflow-core/docs/core-concepts/params.rst b/airflow-core/docs/core-concepts/params.rst index 2862788b880d2..ca3fb6849b31a 100644 --- a/airflow-core/docs/core-concepts/params.rst +++ b/airflow-core/docs/core-concepts/params.rst @@ -410,6 +410,9 @@ Disabling Runtime Param Modification The ability to update params while triggering a Dag depends on the flag ``core.dag_run_conf_overrides_params``. Setting this config to ``False`` will effectively turn your default params into constants. +Pre-populating Trigger Form via URL +----------------------------------- + To pre-populate values in the form when publishing a link to the trigger form you can call the trigger URL ``/dags//trigger/single`` or ``/dags//trigger/backfill``, and add query parameters to the URL. @@ -434,7 +437,7 @@ There are two trigger form URLs available, each supporting a different set of qu - ``run_backwards`` - if set to true, the backfill is scheduled in reverse order. Defaults to ``false``. -The trigger form now supports two different ways of providing ``conf`` values. The available input methods are summarized in the table below: +The trigger form supports two different ways of providing ``conf`` values. The available input methods are summarized in the table below: .. list-table:: ``conf`` parameter usage :header-rows: 1 diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx index 8cebcc4ba0b8e..5dd2e2fa9490c 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx @@ -20,12 +20,13 @@ import { Box, HStack, Skeleton } from "@chakra-ui/react"; import dayjs from "dayjs"; import { lazy, useState, Suspense } from "react"; import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { useLocalStorage } from "usehooks-ts"; import { useAssetServiceGetAssetEvents, useDagRunServiceGetDagRuns, + useDagServiceGetDag, useTaskInstanceServiceGetTaskInstances, } from "openapi/queries"; import { AssetEvents } from "src/components/Assets/AssetEvents"; @@ -33,6 +34,7 @@ import { DurationChart } from "src/components/DurationChart"; import { NeedsReviewButton } from "src/components/NeedsReviewButton"; import TimeRangeSelector from "src/components/TimeRangeSelector"; import { TrendCountButton } from "src/components/TrendCountButton"; +import TriggerDAGModal from "src/components/TriggerDag/TriggerDAGModal"; import { SearchParamsKeys } from "src/constants/searchParams"; import { useGridRuns } from "src/queries/useGridRuns.ts"; @@ -42,7 +44,10 @@ const defaultHour = "24"; export const Overview = () => { const { t: translate } = useTranslation("dag"); - const { dagId } = useParams(); + const { dagId, mode } = useParams(); + const navigate = useNavigate(); + + const { data: dagData } = useDagServiceGetDag({ dagId: dagId ?? "" }); const now = dayjs(); const [startDate, setStartDate] = useState(now.subtract(Number(defaultHour), "hour").toISOString()); @@ -140,6 +145,16 @@ export const Overview = () => { }> + + {dagData ? ( + navigate(`/dags/${dagId}`)} + open={Boolean(mode)} + /> + ) : null} ); }; From 9890b0a53cf187ddbdb7da441b64c46f5eb24252 Mon Sep 17 00:00:00 2001 From: Anshu Singh Date: Thu, 11 Dec 2025 16:59:47 +0530 Subject: [PATCH 4/6] Fix: Sync URL params with defaults and update UI store to prevent data loss --- .../components/TriggerDag/TriggerDAGForm.tsx | 125 +++++++++++++++--- 1 file changed, 103 insertions(+), 22 deletions(-) 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 4eccfb94fb6b1..82ce108af5800 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx @@ -18,7 +18,7 @@ */ import { Button, Box, Spacer, HStack, Field, Stack, Text, VStack } from "@chakra-ui/react"; import dayjs from "dayjs"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { FiPlay } from "react-icons/fi"; @@ -57,8 +57,23 @@ export type DagRunTriggerParams = { dataIntervalStart: string; logicalDate: string; note: string; + params?: Record; partitionKey: string | undefined; }; +const extractParamValues = (obj: Record) => { + const out: Record = {}; + Object.entries(obj).forEach(([key, val]) => { + if (val !== null && typeof val === "object" && "value" in val) { + out[key] = (val as { value: unknown }).value; + } else if (val !== null && typeof val === "object" && "default" in val) { + out[key] = (val as { default: unknown }).default; + } else { + out[key] = val; + } + }); + + return out; +}; const dataIntervalModeOptions: Array<{ label: string; value: DataIntervalMode }> = [ { label: "components:triggerDag.dataIntervalAuto", value: "auto" }, @@ -78,19 +93,26 @@ const TriggerDAGForm = ({ const [formError, setFormError] = useState(false); const initialParamsDict = useDagParams(dagId, open); const { error: errorTrigger, isPending, triggerDagRun } = useTrigger({ dagId, onSuccessConfirm: onClose }); - const { conf } = useParamStore(); + const { conf, setParamsDict } = useParamStore(); const [unpause, setUnpause] = useState(true); const [searchParams] = useSearchParams(); - const reservedKeys = ["run_id", "logical_date", "note"]; - const urlConf = getTriggerConf(searchParams, reservedKeys); + const urlConf = getTriggerConf(searchParams, ["run_id", "logical_date", "note"]); const urlRunId = searchParams.get("run_id") ?? ""; const urlDate = searchParams.get("logical_date"); const urlNote = searchParams.get("note") ?? ""; const { mutate: togglePause } = useTogglePause({ dagId }); - const { control, handleSubmit, reset, watch } = useForm({ + const defaultsRef = useRef(undefined); + const isSyncedRef = useRef(false); + + const cleanInitialParams = useMemo( + () => extractParamValues(initialParamsDict.paramsDict as Record), + [initialParamsDict.paramsDict], + ); + const { control, getValues, handleSubmit, reset, watch } = useForm({ defaultValues: { + ...initialParamsDict, conf: urlConf === "{}" ? conf || "{}" : urlConf, dagRunId: urlRunId, dataIntervalEnd: "", @@ -99,23 +121,74 @@ const TriggerDAGForm = ({ // Default logical date to now, show it in the selected timezone logicalDate: urlDate ?? dayjs().format(DEFAULT_DATETIME_FORMAT), note: urlNote, + params: cleanInitialParams, partitionKey: undefined, }, }); // Automatically reset form when conf is fetched useEffect(() => { - if (conf && urlConf === "{}") { - reset((prevValues) => ({ - ...prevValues, - conf, - })); + if (defaultsRef.current === undefined && Object.keys(cleanInitialParams).length > 0) { + const current = getValues(); + + defaultsRef.current = { + ...current, + params: cleanInitialParams, + }; } - }, [conf, reset, urlConf]); + }, [getValues, cleanInitialParams]); - const resetDateError = () => { - setErrors((prev) => ({ ...prev, date: undefined })); - }; + useEffect(() => { + if (defaultsRef.current === undefined) { + return; + } + + if (isSyncedRef.current) { + return; + } + + if (urlConf === "{}") { + if (conf) { + reset((prev) => ({ ...prev, conf })); + } + isSyncedRef.current = true; + + return; + } + + let parsed: Record = {}; + + try { + parsed = JSON.parse(urlConf) as Record; + } catch { + /* empty */ + } + const mergedValues = { ...defaultsRef.current.params, ...parsed }; + const mergedConfJson = JSON.stringify(mergedValues, undefined, 2); + + reset({ + ...defaultsRef.current, + conf: mergedConfJson, + dagRunId: Boolean(urlRunId) ? urlRunId : defaultsRef.current.dagRunId, + logicalDate: urlDate ?? defaultsRef.current.logicalDate, + note: Boolean(urlNote) ? urlNote : defaultsRef.current.note, + partitionKey: undefined, + }); + + const updatedParamsDict = structuredClone(initialParamsDict.paramsDict); + + Object.entries(mergedValues).forEach(([key, val]) => { + if (updatedParamsDict[key]) { + updatedParamsDict[key].value = val; + } + }); + setParamsDict(updatedParamsDict); + + isSyncedRef.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlConf, urlRunId, urlDate, urlNote, initialParamsDict, reset, setParamsDict]); + + const resetDateError = () => setErrors((prev) => ({ ...prev, date: undefined })); const dataIntervalMode = watch("dataIntervalMode"); const dataIntervalStart = watch("dataIntervalStart"); @@ -124,17 +197,25 @@ const TriggerDAGForm = ({ const dataIntervalInvalid = dataIntervalMode === "manual" && (noDataInterval || dayjs(dataIntervalStart).isAfter(dayjs(dataIntervalEnd))); - const onSubmit = (data: DagRunTriggerParams) => { if (unpause && isPaused) { - togglePause({ - dagId, - requestBody: { - is_paused: false, - }, - }); + togglePause({ dagId, requestBody: { is_paused: false } }); } - triggerDagRun(data); + + const finalParams = { ...data.params }; + + try { + const manualJson = JSON.parse(data.conf) as Record; + + Object.assign(finalParams, manualJson); + } catch { + /* empty */ + } + + triggerDagRun({ + ...data, + conf: JSON.stringify(finalParams), + }); }; return ( From ac3a01915ca6d7a1918d6794fa1db057af48b133 Mon Sep 17 00:00:00 2001 From: Anshu Singh Date: Fri, 12 Dec 2025 13:22:51 +0530 Subject: [PATCH 5/6] Merge main branch and resolve conflicts --- .../airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 82ce108af5800..9c097e4a81180 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx @@ -185,8 +185,7 @@ const TriggerDAGForm = ({ setParamsDict(updatedParamsDict); isSyncedRef.current = true; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlConf, urlRunId, urlDate, urlNote, initialParamsDict, reset, setParamsDict]); + }, [urlConf, urlRunId, urlDate, urlNote, initialParamsDict, reset, setParamsDict, conf]); const resetDateError = () => setErrors((prev) => ({ ...prev, date: undefined })); From a67f3794af9542a4f2f11f5610f869dd30f3018d Mon Sep 17 00:00:00 2001 From: Anshu Singh Date: Mon, 22 Dec 2025 18:19:20 +0530 Subject: [PATCH 6/6] Refactor: Move TriggerDag types and logic to utils to satisfy line limit --- .../components/DagActions/RunBackfillForm.tsx | 2 +- .../TriggerDag/TriggerDAGAdvancedOptions.tsx | 3 +- .../components/TriggerDag/TriggerDAGForm.tsx | 89 ++++--------------- .../src/airflow/ui/src/queries/useTrigger.ts | 4 +- .../src/airflow/ui/src/utils/trigger.ts | 68 ++++++++++++++ 5 files changed, 88 insertions(+), 78 deletions(-) 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 14486643dbc33..0be91753c2216 100644 --- a/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx +++ b/airflow-core/src/airflow/ui/src/components/DagActions/RunBackfillForm.tsx @@ -37,11 +37,11 @@ import { useDagParams } from "src/queries/useDagParams"; import { useParamStore } from "src/queries/useParamStore"; import { useTogglePause } from "src/queries/useTogglePause"; import { getTriggerConf } from "src/utils/trigger"; +import type { DagRunTriggerParams } from "src/utils/trigger"; import ConfigForm from "../ConfigForm"; import { DateTimeInput } from "../DateTimeInput"; import { ErrorAlert } from "../ErrorAlert"; -import type { DagRunTriggerParams } from "../TriggerDag/TriggerDAGForm"; import { Checkbox } from "../ui/Checkbox"; import { getInlineMessage } from "./inlineMessage"; diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGAdvancedOptions.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGAdvancedOptions.tsx index b8d3fe2b9f4c5..70c0b2a91b26b 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGAdvancedOptions.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGAdvancedOptions.tsx @@ -20,8 +20,9 @@ import { Input, Field, Stack } from "@chakra-ui/react"; import { Controller, type Control } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import type { DagRunTriggerParams } from "src/utils/trigger"; + import EditableMarkdown from "./EditableMarkdown"; -import type { DagRunTriggerParams } from "./TriggerDAGForm"; type TriggerDAGAdvancedOptionsProps = { readonly control: Control; 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 9c097e4a81180..4bad37b767b4d 100644 --- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx +++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGForm.tsx @@ -29,7 +29,15 @@ import { useParamStore } from "src/queries/useParamStore"; import { useTogglePause } from "src/queries/useTogglePause"; import { useTrigger } from "src/queries/useTrigger"; import { DEFAULT_DATETIME_FORMAT } from "src/utils/datetimeUtils"; -import { getTriggerConf } from "src/utils/trigger"; +import { + getTriggerConf, + mergeUrlParams, + getUpdatedParamsDict, + type DagRunTriggerParams, + dataIntervalModeOptions, + extractParamValues, + type TriggerDAGFormProps, +} from "src/utils/trigger"; import ConfigForm from "../ConfigForm"; import { DateTimeInput } from "../DateTimeInput"; @@ -38,48 +46,6 @@ import { Checkbox } from "../ui/Checkbox"; import { RadioCardItem, RadioCardRoot } from "../ui/RadioCard"; import TriggerDAGAdvancedOptions from "./TriggerDAGAdvancedOptions"; -type TriggerDAGFormProps = { - readonly dagDisplayName: string; - readonly dagId: string; - readonly hasSchedule: boolean; - readonly isPaused: boolean; - readonly onClose: () => void; - readonly open: boolean; -}; - -type DataIntervalMode = "auto" | "manual"; - -export type DagRunTriggerParams = { - conf: string; - dagRunId: string; - dataIntervalEnd: string; - dataIntervalMode: DataIntervalMode; - dataIntervalStart: string; - logicalDate: string; - note: string; - params?: Record; - partitionKey: string | undefined; -}; -const extractParamValues = (obj: Record) => { - const out: Record = {}; - Object.entries(obj).forEach(([key, val]) => { - if (val !== null && typeof val === "object" && "value" in val) { - out[key] = (val as { value: unknown }).value; - } else if (val !== null && typeof val === "object" && "default" in val) { - out[key] = (val as { default: unknown }).default; - } else { - out[key] = val; - } - }); - - return out; -}; - -const dataIntervalModeOptions: Array<{ label: string; value: DataIntervalMode }> = [ - { label: "components:triggerDag.dataIntervalAuto", value: "auto" }, - { label: "components:triggerDag.dataIntervalManual", value: "manual" }, -]; - const TriggerDAGForm = ({ dagDisplayName, dagId, @@ -156,34 +122,17 @@ const TriggerDAGForm = ({ return; } - let parsed: Record = {}; - - try { - parsed = JSON.parse(urlConf) as Record; - } catch { - /* empty */ - } - const mergedValues = { ...defaultsRef.current.params, ...parsed }; - const mergedConfJson = JSON.stringify(mergedValues, undefined, 2); + const mergedValues = mergeUrlParams(urlConf, defaultsRef.current.params ?? {}); reset({ ...defaultsRef.current, - conf: mergedConfJson, - dagRunId: Boolean(urlRunId) ? urlRunId : defaultsRef.current.dagRunId, + conf: JSON.stringify(mergedValues, undefined, 2), + dagRunId: urlRunId || defaultsRef.current.dagRunId, logicalDate: urlDate ?? defaultsRef.current.logicalDate, - note: Boolean(urlNote) ? urlNote : defaultsRef.current.note, - partitionKey: undefined, - }); - - const updatedParamsDict = structuredClone(initialParamsDict.paramsDict); - - Object.entries(mergedValues).forEach(([key, val]) => { - if (updatedParamsDict[key]) { - updatedParamsDict[key].value = val; - } + note: urlNote || defaultsRef.current.note, }); - setParamsDict(updatedParamsDict); + setParamsDict(getUpdatedParamsDict(initialParamsDict.paramsDict, mergedValues)); isSyncedRef.current = true; }, [urlConf, urlRunId, urlDate, urlNote, initialParamsDict, reset, setParamsDict, conf]); @@ -201,15 +150,7 @@ const TriggerDAGForm = ({ togglePause({ dagId, requestBody: { is_paused: false } }); } - const finalParams = { ...data.params }; - - try { - const manualJson = JSON.parse(data.conf) as Record; - - Object.assign(finalParams, manualJson); - } catch { - /* empty */ - } + const finalParams = mergeUrlParams(data.conf, data.params ?? {}); triggerDagRun({ ...data, diff --git a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts index de2baede2482d..f395da3ca1c38 100644 --- a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts +++ b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts @@ -29,8 +29,8 @@ import { UseGridServiceGetGridRunsKeyFn, } from "openapi/queries"; import type { TriggerDagRunResponse } from "openapi/requests/types.gen"; -import type { DagRunTriggerParams } from "src/components/TriggerDag/TriggerDAGForm"; import { toaster } from "src/components/ui"; +import type { DagRunTriggerParams } from "src/utils/trigger"; export const useTrigger = ({ dagId, onSuccessConfirm }: { dagId: string; onSuccessConfirm: () => void }) => { const queryClient = useQueryClient(); @@ -107,7 +107,7 @@ export const useTrigger = ({ dagId, onSuccessConfirm }: { dagId: string; onSucce data_interval_start: formattedDataIntervalStart, logical_date: formattedLogicalDate, note: checkNote, - partition_key: dagRunRequestBody.partitionKey ?? null, + partition_key: dagRunRequestBody.partitionKey ?? undefined, }, }); }; diff --git a/airflow-core/src/airflow/ui/src/utils/trigger.ts b/airflow-core/src/airflow/ui/src/utils/trigger.ts index 89259f8d24b8f..1fd7c963b91a8 100644 --- a/airflow-core/src/airflow/ui/src/utils/trigger.ts +++ b/airflow-core/src/airflow/ui/src/utils/trigger.ts @@ -44,3 +44,71 @@ export const getTriggerConf = (searchParams: URLSearchParams, reservedKeys: Arra return Object.keys(collected).length > 0 ? JSON.stringify(collected, undefined, 2) : "{}"; }; + +export type DataIntervalMode = "auto" | "manual"; + +export type DagRunTriggerParams = { + conf: string; + dagRunId: string; + dataIntervalEnd: string; + dataIntervalMode: DataIntervalMode; + dataIntervalStart: string; + logicalDate: string; + note: string; + params?: Record; + partitionKey: string | undefined; +}; +export type TriggerDAGFormProps = { + readonly dagDisplayName: string; + readonly dagId: string; + readonly hasSchedule: boolean; + readonly isPaused: boolean; + readonly onClose: () => void; + readonly open: boolean; +}; +export const dataIntervalModeOptions: Array<{ label: string; value: DataIntervalMode }> = [ + { label: "components:triggerDag.dataIntervalAuto", value: "auto" }, + { label: "components:triggerDag.dataIntervalManual", value: "manual" }, +]; + +export const extractParamValues = (obj: Record) => { + const out: Record = {}; + + Object.entries(obj).forEach(([key, val]) => { + if (val !== null && typeof val === "object" && "value" in val) { + out[key] = (val as { value: unknown }).value; + } else if (val !== null && typeof val === "object" && "default" in val) { + out[key] = (val as { default: unknown }).default; + } else { + out[key] = val; + } + }); + + return out; +}; + +export const mergeUrlParams = (urlConf: string, baseParams: Record) => { + try { + const parsed = urlConf === "{}" ? {} : (JSON.parse(urlConf) as Record); + + return { ...baseParams, ...parsed }; + } catch { + return baseParams; + } +}; +export type ParamEntry = { + [key: string]: unknown; + value: unknown; +}; +export const getUpdatedParamsDict = (paramsDict: T, mergedValues: Record): T => { + const updated = structuredClone(paramsDict); + const record = updated as Record; + + Object.entries(mergedValues).forEach(([key, val]) => { + if (record[key] !== undefined) { + record[key].value = val; + } + }); + + return updated; +};