From cc32e425a20edd2439c98838df0a714fd2fa8b15 Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Thu, 1 Oct 2026 16:04:38 +0200 Subject: [PATCH] Keep the Dag trigger button one-click when a run has config 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 +++++++++-- .../TriggerDag/TriggerDAGButton.tsx | 167 ++++++++++-------- .../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 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}