Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -50,26 +50,38 @@ 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" },
// eslint-disable-next-line id-length
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();
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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>
Comment thread
pierrejeambrun marked this conversation as resolved.
Resolved
{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}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading