This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 4e1f2aca67f Keep the Dag trigger button one-click when a run has
config (#74039)
4e1f2aca67f is described below
commit 4e1f2aca67f0131c5681ba40f0838f3a26d47f0f
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Mon Oct 5 12:16:17 2026 +0200
Keep the Dag trigger button one-click when a run has config (#74039)
Selecting a run that carried a config turned the whole Trigger button into
a dropdown, costing an extra click on the most common action. The trigger
should stay a single click; re-using a run's config is the rare case and
belongs behind a caret that does not slow down the common one.
---
.../ui/public/i18n/locales/en/components.json | 4 +-
.../TriggerDag/TriggerDAGButton.test.tsx | 102 +++++++++++--
.../src/components/TriggerDag/TriggerDAGButton.tsx | 167 ++++++++++++---------
.../ui/src/system-components/Menu/MenuTrigger.tsx | 2 +-
4 files changed, 192 insertions(+), 83 deletions(-)
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 fae528fc729..53fa5e0b261 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 15294659c4d..b3b0ba114f4 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 ? <div data-testid="trigger-modal">Trigger Modal</div> : null,
+ default: ({ open, prefillConfig }: { readonly open: boolean; readonly
prefillConfig: unknown }) =>
+ open ? (
+ <div data-prefilled={prefillConfig !== undefined}
data-testid="trigger-modal">
+ Trigger Modal
+ </div>
+ ) : 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(<TriggerDAGButton {...props} />, { 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(<TriggerDAGButton {...props} />, { 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(<TriggerDAGButton {...props} withText />, { wrapper });
+ render(<TriggerDAGButton {...props} />, { 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(<TriggerDAGButton {...props} withText />, { wrapper });
+ render(<TriggerDAGButton {...props} />, { 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(<TriggerDAGButton {...props} />, { 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(<TriggerDAGButton {...props} />, { 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(<TriggerDAGButton {...props} allowedRunTypes={["backfill"]}
withText />, { 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 45151cb7257..4dccad1e2d6 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 (
- <Box>
- <Menu.Root>
- <Tooltip content={translate("triggerDag.manualRunDenied")}
disabled={!isManualRunDenied}>
+ // 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 ? (
+ <Button
+ aria-label={translate("triggerDag.title")}
+ // Square off the shared edges by hand: the main button keeps its left
radius only
+ borderRightRadius={hasConfigOptions ? "none" : undefined}
+ data-testid="trigger-dag-button"
+ disabled={isManualRunDenied}
+ onClick={handleNormalTrigger}
+ variant={variant}
+ >
+ <FiPlay />
+ {translate("triggerDag.button")}
+ </Button>
+ ) : (
+ <IconButton
+ aria-label={translate("triggerDag.title")}
+ // Square off the shared edges by hand: the caret keeps its right radius
only.
+ borderRightRadius={hasConfigOptions ? "none" : undefined}
+ data-testid="trigger-dag-button"
+ disabled={isManualRunDenied}
+ onClick={handleNormalTrigger}
+ variant={variant}
+ >
+ <FiPlay />
+ </IconButton>
+ );
+
+ const triggerButtonWithTooltip = (
+ <Tooltip
+ content={isManualRunDenied ? translate("triggerDag.manualRunDenied") :
translate("triggerDag.button")}
+ disabled={withText ? !isManualRunDenied : undefined}
+ >
+ {triggerButton}
+ </Tooltip>
+ );
+
+ return (
+ <>
+ {hasConfigOptions ? (
+ <ButtonGroup attached>
+ {triggerButtonWithTooltip}
+ <Menu.Root
+ tooltipLabel={
+ isManualRunDenied
+ ? translate("triggerDag.manualRunDenied")
+ : translate("triggerDag.triggerOptions")
+ }
+ >
<Menu.Trigger asChild>
- <Button
- aria-label={translate("triggerDag.title")}
- data-testid="trigger-dag-button"
+ <IconButton
+ aria-label={translate("triggerDag.triggerOptions")}
+ // Drop the left radius and border so the shared edge is a
single divider, not a
+ // doubled 2px line from both buttons' borders stacking.
+ borderLeftRadius="none"
+ borderLeftWidth="0"
+ data-testid="trigger-dag-options-button"
disabled={isManualRunDenied}
variant={variant}
>
- <FiPlay />
- {translate("triggerDag.button")}
- </Button>
+ <FiChevronDown />
+ </IconButton>
</Menu.Trigger>
- </Tooltip>
- <Menu.Content>
- <Menu.Item onClick={handleNormalTrigger} value="trigger">
- {translate("triggerDag.button")}
- </Menu.Item>
- <Menu.Item onClick={handleTriggerWithConfig}
value="triggerWithConfig">
- {translate("triggerDag.triggerAgainWithConfig")}
- </Menu.Item>
- </Menu.Content>
- </Menu.Root>
-
- <TriggerDAGModal
- dagDisplayName={dagDisplayName}
- dagId={dagId}
- onClose={handleModalClose}
- open={open}
- prefillConfig={prefillConfig}
- />
- </Box>
- );
- }
-
- // Normal trigger button without menu
- return (
- <>
- <Tooltip
- content={isManualRunDenied ? translate("triggerDag.manualRunDenied") :
translate("triggerDag.button")}
- disabled={withText ? !isManualRunDenied : undefined}
- >
- {withText ? (
- <Button
- aria-label={translate("triggerDag.title")}
- data-testid="trigger-dag-button"
- disabled={isManualRunDenied}
- onClick={handleNormalTrigger}
- variant={variant}
- >
- <FiPlay />
- {translate("triggerDag.button")}
- </Button>
- ) : (
- <IconButton
- aria-label={translate("triggerDag.title")}
- data-testid="trigger-dag-button"
- disabled={isManualRunDenied}
- onClick={onOpen}
- variant={variant}
- >
- <FiPlay />
- </IconButton>
- )}
- </Tooltip>
+ <Menu.Content>
+ <Menu.Item onClick={handleTriggerAgainWithConfig}
value="triggerAgainWithConfig">
+ {translate("triggerDag.triggerAgainWithConfig")}
+ </Menu.Item>
+ <Menu.Item onClick={handleEditConfigAndTrigger}
value="editConfigAndTrigger">
+ {translate("triggerDag.editConfigAndTrigger")}
+ </Menu.Item>
+ </Menu.Content>
+ </Menu.Root>
+ </ButtonGroup>
+ ) : (
+ triggerButtonWithTooltip
+ )}
<TriggerDAGModal
dagDisplayName={dagDisplayName}
dagId={dagId}
onClose={handleModalClose}
open={open}
- prefillConfig={undefined}
+ prefillConfig={prefillConfig}
/>
</>
);
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 122d9b4e4b6..a8d9d8a4381 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<HTMLButtonElement,
MenuTriggerProps>((props, r
if (Boolean(tooltipLabel)) {
return (
- <Tooltip content={tooltipLabel} ids={Boolean(triggerId) ? { trigger:
triggerId } : undefined}>
+ <Tooltip content={tooltipLabel} ids={Boolean(triggerId) ? { trigger:
triggerId } : undefined} portalled>
<ChakraMenu.Trigger asChild ref={ref} {...rest}>
{children}
</ChakraMenu.Trigger>