Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion airflow-core/docs/core-concepts/params.rst
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,61 @@ The following features are supported in the Trigger UI Form:
- If you want to have params not being displayed, use the ``const`` attribute. These Params will be submitted but hidden in the Form.
The ``const`` value must match the default value to pass `JSON Schema validation <https://json-schema.org/understanding-json-schema/reference/generic.html#constant-values>`_.
- On the bottom of the form the generated JSON configuration can be expanded.
If you want to change values manually, the JSON configuration can be adjusted. Changes in the JSON will be reflected in the form fields.
If you want to change values manually, the JSON configuration can be adjusted. Changes are overridden when form fields change.
- To pre-populate values in the form when publishing a link to the trigger form you can call the trigger URL ``/dags/<dag_name>/trigger/single`` or ``/dags/<dag_name>/trigger/backfill`` (default is single mode),
and add query parameter to the URL in the form, you can check the parameters and examples below.
- Fields can be required or optional. Typed fields are required by default to ensure they pass JSON schema validation. To make typed fields optional, you must allow the "null" type.
- Fields without a "section" will be rendered in the default area. Additional sections will be collapsed by default.

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.
Copy link
Member

@guan404ming guan404ming Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fields in the form would dynamically change based on user's config. Thus, we may not only need to handle these specific fields but we should handle all fields passed. The implementation is quite like #54783


* ``/trigger/backfill``:

- ``conf`` – JSON configuration, applied to all runs.
- ``start_date`` – start of the backfill window in ``YYYY-MM-DDTHH:mm:ss`` format.
- ``end_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"}&note=run_note``

``/dags/{dag_id}/trigger/backfill?start_date=2025-09-01T00:00:00&end_date=2025-09-03T23:59:59&conf={"abc":"loo"}&max_active_runs=2&reprocess_behavior=failed&run_backwards=true``


.. note::
If the field is required the default value must be valid according to the schema as well. If the Dag is defined with
``schedule=None`` the parameter value validation is made at time of trigger.
Expand Down
6 changes: 5 additions & 1 deletion airflow-core/src/airflow/ui/src/components/ConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type ConfigFormProps<T extends FieldValues = FieldValues> = {
date?: unknown;
};
readonly initialParamsDict: { paramsDict: ParamsSpec };
readonly openAdvanced?: boolean;
readonly setErrors: React.Dispatch<
React.SetStateAction<{
conf?: string;
Expand All @@ -48,6 +49,7 @@ const ConfigForm = <T extends FieldValues = FieldValues>({
control,
errors,
initialParamsDict,
openAdvanced = false,
setErrors,
setFormError,
}: ConfigFormProps<T>) => {
Expand Down Expand Up @@ -82,7 +84,9 @@ const ConfigForm = <T extends FieldValues = FieldValues>({
return (
<Accordion.Root
collapsible
defaultValue={[flexibleFormDefaultSection]}
defaultValue={
openAdvanced ? [flexibleFormDefaultSection, "advancedOptions"] : [flexibleFormDefaultSection]
}
mb={4}
overflow="visible"
size="lg"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useForm, Controller, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";

import type { DAGResponse, DAGWithLatestDagRunsResponse, BackfillPostBody } from "openapi/requests/types.gen";
import { Button } from "src/components/ui";
Expand All @@ -30,6 +31,7 @@ import { useCreateBackfillDryRun } from "src/queries/useCreateBackfillDryRun";
import { useDagParams } from "src/queries/useDagParams";
import { useParamStore } from "src/queries/useParamStore";
import { useTogglePause } from "src/queries/useTogglePause";
import { getPreloadBackfillFormData } from "src/utils/trigger";

import ConfigForm from "../ConfigForm";
import { DateTimeInput } from "../DateTimeInput";
Expand All @@ -43,6 +45,7 @@ type RunBackfillFormProps = {
readonly dag: DAGResponse | DAGWithLatestDagRunsResponse;
readonly onClose: () => void;
};

const today = new Date().toISOString().slice(0, 16);

type BackfillFormProps = DagRunTriggerParams & Omit<BackfillPostBody, "dag_run_conf">;
Expand All @@ -54,15 +57,26 @@ const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => {
const [formError, setFormError] = useState(false);
const initialParamsDict = useDagParams(dag.dag_id, true);
const { conf } = useParamStore();
const { control, handleSubmit, reset, watch } = useForm<BackfillFormProps>({
const { search } = useLocation();
const params = new URLSearchParams(search);
const {
conf: urlConf,
from_date: urlFromDate,
max_active_runs: urlMaxActiveRuns,
reprocess_behavior: urlReprocessBehavior,
run_backwards: urlRunBackwards,
to_date: urlToDate,
} = getPreloadBackfillFormData(params);
Comment on lines +61 to +69
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use the searchParam hook there.


const { control, handleSubmit, reset } = useForm<BackfillFormProps>({
defaultValues: {
conf,
conf: urlConf ?? (conf || "{}"),
dag_id: dag.dag_id,
from_date: "",
max_active_runs: 1,
reprocess_behavior: "none",
run_backwards: false,
to_date: "",
from_date: urlFromDate,
max_active_runs: urlMaxActiveRuns,
reprocess_behavior: urlReprocessBehavior,
run_backwards: urlRunBackwards,
to_date: urlToDate,
},
mode: "onBlur",
});
Expand Down Expand Up @@ -90,22 +104,16 @@ const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => {
useEffect(() => {
if (Boolean(dateValidationError)) {
setErrors((prev) => ({ ...prev, date: dateValidationError }));
}
}, [dateValidationError]);

useEffect(() => {
if (conf) {
} else if (urlConf === null && conf) {
reset((prevValues) => ({
...prevValues,
conf,
}));
}
}, [conf, reset]);
}, [dateValidationError, urlConf, 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));
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) {
Expand Down Expand Up @@ -248,6 +256,7 @@ const RunBackfillForm = ({ dag, onClose }: RunBackfillFormProps) => {
control={control}
errors={errors}
initialParamsDict={initialParamsDict}
openAdvanced={Boolean(urlConf ?? conf)}
setErrors={setErrors}
setFormError={setFormError}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import { Box } from "@chakra-ui/react";
import { useDisclosure } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { FiPlay } from "react-icons/fi";
import { useLocation, useNavigate } from "react-router-dom";

import { normalizeTriggerPath, RunMode } from "src/utils/trigger";

import ActionButton from "../ui/ActionButton";
import TriggerDAGModal from "./TriggerDAGModal";
Expand All @@ -32,15 +35,34 @@ type Props = {
};

const TriggerDAGButton: React.FC<Props> = ({ dagDisplayName, dagId, isPaused, withText = true }) => {
const { onClose, onOpen, open } = useDisclosure();
const { pathname } = useLocation();
const navigate = useNavigate();
const defaultOpen =
pathname.endsWith("/trigger") ||
pathname.endsWith("/trigger/single") ||
pathname.endsWith("/trigger/backfill");
const { onClose, onOpen, open } = useDisclosure({ defaultOpen });
const { t: translate } = useTranslation("components");

const handleOpen = () => {
// Default to single mode when opening from button
const singlePath = normalizeTriggerPath(pathname, RunMode.SINGLE);

navigate(singlePath, { replace: true });
onOpen();
};

const handleClose = () => {
navigate(normalizeTriggerPath(pathname, null), { replace: true });
onClose();
};

return (
<Box>
<ActionButton
actionName={translate("triggerDag.title")}
icon={<FiPlay />}
onClick={onOpen}
onClick={handleOpen}
text={translate("triggerDag.button")}
variant="outline"
withText={withText}
Expand All @@ -50,7 +72,7 @@ const TriggerDAGButton: React.FC<Props> = ({ dagDisplayName, dagId, isPaused, wi
dagDisplayName={dagDisplayName}
dagId={dagId}
isPaused={isPaused}
onClose={onClose}
onClose={handleClose}
open={open}
/>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@
* under the License.
*/
import { Button, Box, Spacer, HStack, Input, Field, Stack } from "@chakra-ui/react";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FiPlay } from "react-icons/fi";
import { useLocation } 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 { getPreloadTriggerFormData } from "src/utils/trigger";
import { DEFAULT_DATETIME_FORMAT } from "src/utils/datetimeUtils";

import ConfigForm from "../ConfigForm";
Expand Down Expand Up @@ -56,30 +57,38 @@ const TriggerDAGForm = ({ dagDisplayName, dagId, isPaused, onClose, open }: Trig
const [formError, setFormError] = useState(false);
const initialParamsDict = useDagParams(dagId, open);
const { error: errorTrigger, isPending, triggerDagRun } = useTrigger({ dagId, onSuccessConfirm: onClose });
const { search } = useLocation();
const { conf } = useParamStore();
const [unpause, setUnpause] = useState(true);

const { mutate: togglePause } = useTogglePause({ dagId });

const searchParams = new URLSearchParams(search);
const {
conf: urlConf,
dagRunId: urlDagRunId,
logicalDate: urlLogicalDate,
note: urlNote,
} = getPreloadTriggerFormData(searchParams);

const { control, handleSubmit, reset } = useForm<DagRunTriggerParams>({
defaultValues: {
conf,
dagRunId: "",
conf: urlConf ?? (conf || "{}"),
dagRunId: urlDagRunId,
// Default logical date to now, show it in the selected timezone
logicalDate: dayjs().format(DEFAULT_DATETIME_FORMAT),
note: "",
logicalDate: urlLogicalDate ?? dayjs().format(DEFAULT_DATETIME_FORMAT),
note: urlNote
},
});

// Automatically reset form when conf is fetched
useEffect(() => {
if (conf) {
if (urlConf === null && conf) {
reset((prevValues) => ({
...prevValues,
conf,
}));
}
}, [conf, reset]);
}, [urlConf, conf, reset]);

const resetDateError = () => {
setErrors((prev) => ({ ...prev, date: undefined }));
Expand All @@ -103,6 +112,7 @@ const TriggerDAGForm = ({ dagDisplayName, dagId, isPaused, onClose, open }: Trig
control={control}
errors={errors}
initialParamsDict={initialParamsDict}
openAdvanced={Boolean(urlConf ?? (urlDagRunId || urlLogicalDate || urlNote))}
setErrors={setErrors}
setFormError={setFormError}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
* under the License.
*/
import { Heading, VStack, HStack, Spinner, Center, Text } from "@chakra-ui/react";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";

import { useDagServiceGetDag } from "openapi/queries";
import { Dialog, Tooltip } from "src/components/ui";
import { RadioCardItem, RadioCardRoot } from "src/components/ui/RadioCard";
import { getRunModeFromPathname, normalizeTriggerPath } from "src/utils/trigger";

import RunBackfillForm from "../DagActions/RunBackfillForm";
import TriggerDAGForm from "./TriggerDAGForm";
Expand All @@ -48,7 +50,11 @@ const TriggerDAGModal: React.FC<TriggerDAGModalProps> = ({
open,
}) => {
const { t: translate } = useTranslation("components");
const [runMode, setRunMode] = useState<RunMode>(RunMode.SINGLE);
const { pathname, search } = useLocation();
const navigate = useNavigate();

const [runMode, setRunMode] = useState<RunMode>(getRunModeFromPathname(pathname));

const {
data: dag,
isError,
Expand All @@ -67,6 +73,19 @@ const TriggerDAGModal: React.FC<TriggerDAGModalProps> = ({
const maxDisplayLength = 59; // hard-coded length to prevent dag name overflowing the modal
const nameOverflowing = dagDisplayName.length > maxDisplayLength;

useEffect(() => {
// Only sync URL when already within trigger route to avoid interfering with close navigation
if (!open || !/\/trigger(\/(single|backfill))?$/u.test(pathname)) {
return;
}

const targetPath = normalizeTriggerPath(pathname, runMode);

if (pathname !== targetPath) {
navigate({ pathname: targetPath, search }, { replace: true });
}
}, [runMode, pathname, search, navigate, open]);

Comment on lines +77 to +88
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can probably add router entries on ..../trigger/{mode} that also maps to the same page, and handle the path param mode if in the URL.

return (
<Dialog.Root lazyMount onOpenChange={onClose} open={open} size="xl" unmountOnExit>
<Dialog.Content backdrop>
Expand All @@ -79,7 +98,7 @@ const TriggerDAGModal: React.FC<TriggerDAGModalProps> = ({
</VStack>
</Dialog.Header>

<Dialog.CloseTrigger />
<Dialog.CloseTrigger onClick={onClose} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this change


<Dialog.Body>
{isLoading ? (
Expand Down
9 changes: 9 additions & 0 deletions airflow-core/src/airflow/ui/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,15 @@ export const routerConfig = [
element: <Dag />,
path: "dags/:dagId",
},
{
children: [
{ element: <Overview />, index: true },
{ element: <Overview />, path: "single" },
{ element: <Overview />, path: "backfill" },
Comment on lines +179 to +180
Copy link
Contributor

@bbovenzi bbovenzi Sep 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should pass trigger={"single" | "backfill" | undefined} as props from here instead of trying to manually parse the url string?

],
element: <Dag />,
path: "dags/:dagId/trigger",
},
{
children: [
{ element: <TaskInstances />, index: true },
Expand Down
Loading
Loading