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>

Reply via email to