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 fae528fc729b1..53fa5e0b26178 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
@@ -166,6 +166,7 @@
"dataInterval": "Data Interval",
"dataIntervalAuto": "Inferred from Logical Date and Timetable",
"dataIntervalManual": "Specify Manually",
+ "editConfigAndTrigger": "Edit config and trigger",
"intervalEnd": "End",
"intervalStart": "Start",
"loading": "Loading Dag information...",
@@ -185,7 +186,8 @@
"title": "Dag Run Triggered"
}
},
- "triggerAgainWithConfig": "Trigger again with this config"
+ "triggerAgainWithConfig": "Trigger again with this config",
+ "triggerOptions": "Trigger options"
},
"trimText": {
"details": "Details",
diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.test.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.test.tsx
index 15294659c4d81..b3b0ba114f4a8 100644
--- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.test.tsx
@@ -19,7 +19,7 @@
import type { PropsWithChildren } from "react";
import "@testing-library/jest-dom";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -50,6 +50,12 @@ vi.mock("openapi/queries", () => ({
useDagRunServiceGetDagRun: useDagRunServiceGetDagRunMock,
}));
+const triggerDagRunMock = vi.hoisted(() => vi.fn());
+
+vi.mock("src/queries/useTrigger", () => ({
+ useTrigger: () => ({ error: undefined, isPending: false, triggerDagRun: triggerDagRunMock }),
+}));
+
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: { language: "en" },
@@ -57,19 +63,25 @@ vi.mock("react-i18next", () => ({
t: (translationKey: string) =>
({
"triggerDag.button": "Trigger",
+ "triggerDag.editConfigAndTrigger": "Edit config and trigger",
"triggerDag.manualRunDenied": "Manual runs are not allowed for this Dag",
"triggerDag.title": "Trigger Dag",
"triggerDag.triggerAgainWithConfig": "Trigger again with this config",
+ "triggerDag.triggerOptions": "Trigger options",
})[translationKey] ?? translationKey,
}),
}));
vi.mock("./TriggerDAGModal", () => ({
- default: ({ open }: { readonly open: boolean }) =>
- open ?
Trigger Modal
: null,
+ default: ({ open, prefillConfig }: { readonly open: boolean; readonly prefillConfig: unknown }) =>
+ open ? (
+
+ Trigger Modal
+
+ ) : null,
}));
-const props = { dagDisplayName: "My Dag", dagId: "my_dag", isPaused: false };
+const props = { dagDisplayName: "My Dag", dagId: "my_dag" };
afterEach(() => {
cleanup();
@@ -79,34 +91,100 @@ afterEach(() => {
beforeEach(() => {
useDagRunServiceGetDagRunMock.mockReset();
useDagRunServiceGetDagRunMock.mockReturnValue({ data: undefined });
+ triggerDagRunMock.mockReset();
});
describe("TriggerDAGButton", () => {
- it("opens the form directly, with no config menu, when the selected run has an empty config", () => {
+ it("has no options caret when no run with a config is selected", () => {
+ render(, { wrapper });
+
+ expect(screen.getByTestId("trigger-dag-button")).toBeInTheDocument();
+ expect(screen.queryByTestId("trigger-dag-options-button")).not.toBeInTheDocument();
+ });
+
+ it("opens the trigger form in one click, without a prefilled config", () => {
+ render(, { wrapper });
+
+ fireEvent.click(screen.getByTestId("trigger-dag-button"));
+
+ const modal = screen.getByTestId("trigger-modal");
+
+ expect(modal).toBeInTheDocument();
+ expect(modal).toHaveAttribute("data-prefilled", "false");
+ });
+
+ it("treats an empty config as no config: one click, no options caret", () => {
routeParams.runId = "run_empty_conf";
useDagRunServiceGetDagRunMock.mockReturnValue({
data: { conf: {}, dag_run_id: "run_empty_conf", logical_date: "2026-01-01T00:00:00Z" },
});
- render(, { wrapper });
+ render(, { wrapper });
+ expect(screen.queryByTestId("trigger-dag-options-button")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("trigger-dag-button"));
-
- expect(screen.getByTestId("trigger-modal")).toBeInTheDocument();
- expect(screen.queryByText("Trigger again with this config")).not.toBeInTheDocument();
+ expect(screen.getByTestId("trigger-modal")).toHaveAttribute("data-prefilled", "false");
});
- it("shows the config menu when the selected run has a non-empty config", async () => {
+ it("shows the options caret when the selected run carried a config", () => {
routeParams.runId = "run_with_conf";
useDagRunServiceGetDagRunMock.mockReturnValue({
data: { conf: { country: "FR" }, dag_run_id: "run_with_conf", logical_date: "2026-01-01T00:00:00Z" },
});
- render(, { wrapper });
+ render(, { wrapper });
+ expect(screen.getByTestId("trigger-dag-options-button")).toBeInTheDocument();
+ // The main button still triggers in one click with no prefill, rather than opening the menu.
fireEvent.click(screen.getByTestId("trigger-dag-button"));
+ expect(screen.getByTestId("trigger-modal")).toHaveAttribute("data-prefilled", "false");
+ });
+
+ it("re-triggers directly with the selected run's config, bypassing the form", async () => {
+ routeParams.runId = "run_with_conf";
+ useDagRunServiceGetDagRunMock.mockReturnValue({
+ data: { conf: { country: "FR" }, dag_run_id: "run_with_conf", logical_date: "2026-01-01T00:00:00Z" },
+ });
- expect(await screen.findByText("Trigger again with this config")).toBeInTheDocument();
+ render(, { wrapper });
+
+ fireEvent.click(screen.getByTestId("trigger-dag-options-button"));
+ fireEvent.click(await screen.findByText("Trigger again with this config"));
+
+ await waitFor(() =>
+ expect(triggerDagRunMock).toHaveBeenCalledWith(
+ expect.objectContaining({ conf: JSON.stringify({ country: "FR" }) }),
+ ),
+ );
expect(screen.queryByTestId("trigger-modal")).not.toBeInTheDocument();
});
+
+ it("opens the prefilled form from the edit-config option", async () => {
+ routeParams.runId = "run_with_conf";
+ useDagRunServiceGetDagRunMock.mockReturnValue({
+ data: { conf: { country: "FR" }, dag_run_id: "run_with_conf", logical_date: "2026-01-01T00:00:00Z" },
+ });
+
+ render(, { wrapper });
+
+ fireEvent.click(screen.getByTestId("trigger-dag-options-button"));
+ fireEvent.click(await screen.findByText("Edit config and trigger"));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("trigger-modal")).toHaveAttribute("data-prefilled", "true"),
+ );
+ expect(triggerDagRunMock).not.toHaveBeenCalled();
+ });
+
+ it("disables both the trigger and the options caret when manual runs are denied", () => {
+ routeParams.runId = "run_with_conf";
+ useDagRunServiceGetDagRunMock.mockReturnValue({
+ data: { conf: { country: "FR" }, dag_run_id: "run_with_conf", logical_date: "2026-01-01T00:00:00Z" },
+ });
+
+ render(, { wrapper });
+
+ expect(screen.getByTestId("trigger-dag-button")).toBeDisabled();
+ expect(screen.getByTestId("trigger-dag-options-button")).toBeDisabled();
+ });
});
diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
index 45151cb7257fa..4dccad1e2d694 100644
--- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
@@ -18,16 +18,17 @@
*/
import { useState } from "react";
-import { Box, Button, useDisclosure } from "@chakra-ui/react";
+import { Button, ButtonGroup, useDisclosure } from "@chakra-ui/react";
import type { DagRunType } from "openapi-gen/requests/types.gen";
import { useTranslation } from "react-i18next";
-import { FiPlay } from "react-icons/fi";
+import { FiChevronDown, FiPlay } from "react-icons/fi";
import { useParams } from "react-router-dom";
import { useDagRunServiceGetDagRun } from "openapi/queries";
import { IconButton, Menu, Tooltip } from "src/system-components";
+import { useTrigger } from "src/queries/useTrigger";
import { hasDagRunConfig } from "src/utils";
import TriggerDAGModal from "./TriggerDAGModal";
@@ -71,7 +72,26 @@ export const TriggerDAGButton = ({
{ enabled: Boolean(dagId) && Boolean(runId) },
);
- const handleTriggerWithConfig = () => {
+ const { triggerDagRun } = useTrigger({ dagId, onSuccessConfirm: () => undefined });
+
+ // Re-run immediately with the selected run's config, without opening the form. A fresh run id and
+ // logical date are generated server-side, so only the config carries over.
+ const handleTriggerAgainWithConfig = () => {
+ if (selectedDagRun) {
+ triggerDagRun({
+ conf: JSON.stringify(selectedDagRun.conf ?? {}),
+ dagRunId: "",
+ dataIntervalEnd: "",
+ dataIntervalMode: "auto",
+ dataIntervalStart: "",
+ logicalDate: "",
+ note: "",
+ partitionKey: undefined,
+ });
+ }
+ };
+
+ const handleEditConfigAndTrigger = () => {
if (selectedDagRun) {
setPrefillConfig({
conf: selectedDagRun.conf ?? undefined,
@@ -92,83 +112,92 @@ export const TriggerDAGButton = ({
onClose();
};
- // If the selected Dag run carries a non-empty config, show the menu with options. A run triggered
- // without a config comes back as an empty object `{}`, which must not count as "has config".
- if (hasDagRunConfig(selectedDagRun?.conf)) {
- return (
-
-
-
+ // A run triggered without a config comes back as an empty object `{}`, which must not count as
+ // "has config", so the trigger stays a single click; `hasDagRunConfig` ignores an empty config.
+ const hasConfigOptions = hasDagRunConfig(selectedDagRun?.conf);
+
+ const triggerButton = withText ? (
+
+ ) : (
+
+
+
+ );
+
+ const triggerButtonWithTooltip = (
+
+ {triggerButton}
+
+ );
+
+ return (
+ <>
+ {hasConfigOptions ? (
+
+ {triggerButtonWithTooltip}
+
-
+
+
-
-
-
- {translate("triggerDag.button")}
-
-
- {translate("triggerDag.triggerAgainWithConfig")}
-
-
-
-
-
-
- );
- }
-
- // Normal trigger button without menu
- return (
- <>
-
- {withText ? (
-
- ) : (
-
-
-
- )}
-
+
+
+ {translate("triggerDag.triggerAgainWithConfig")}
+
+
+ {translate("triggerDag.editConfigAndTrigger")}
+
+
+
+
+ ) : (
+ triggerButtonWithTooltip
+ )}
>
);
diff --git a/airflow-core/src/airflow/ui/src/system-components/Menu/MenuTrigger.tsx b/airflow-core/src/airflow/ui/src/system-components/Menu/MenuTrigger.tsx
index 122d9b4e4b6ee..a8d9d8a43815c 100644
--- a/airflow-core/src/airflow/ui/src/system-components/Menu/MenuTrigger.tsx
+++ b/airflow-core/src/airflow/ui/src/system-components/Menu/MenuTrigger.tsx
@@ -31,7 +31,7 @@ export const Trigger = forwardRef((props, r
if (Boolean(tooltipLabel)) {
return (
-
+
{children}