diff --git a/src/components/PipelineRun/RunDetails.tsx b/src/components/PipelineRun/RunDetails.tsx
index abac66d75..c28a89f43 100644
--- a/src/components/PipelineRun/RunDetails.tsx
+++ b/src/components/PipelineRun/RunDetails.tsx
@@ -1,28 +1,14 @@
-import { useState } from "react";
-
import { CopyText } from "@/components/shared/CopyText/CopyText";
-import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Text } from "@/components/ui/typography";
-import { useCheckComponentSpecFromPath } from "@/hooks/useCheckComponentSpecFromPath";
-import { useUserDetails } from "@/hooks/useUserDetails";
import { useBackend } from "@/providers/BackendProvider";
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
import { useExecutionData } from "@/providers/ExecutionDataProvider";
-import {
- countTaskStatuses,
- getRunStatus,
- isStatusComplete,
- isStatusInProgress,
-} from "@/services/executionService";
+import { countTaskStatuses, getRunStatus } from "@/services/executionService";
import { componentSpecToText } from "@/utils/yaml";
-import TooltipButton from "../shared/Buttons/TooltipButton";
import { CodeViewer } from "../shared/CodeViewer";
-import {
- ActionBlock,
- type ActionOrReactNode,
-} from "../shared/ContextPanel/Blocks/ActionBlock";
+import { ActionBlock } from "../shared/ContextPanel/Blocks/ActionBlock";
import { ContentBlock } from "../shared/ContextPanel/Blocks/ContentBlock";
import { ListBlock } from "../shared/ContextPanel/Blocks/ListBlock";
import { TextBlock } from "../shared/ContextPanel/Blocks/TextBlock";
@@ -30,13 +16,11 @@ import PipelineIO from "../shared/Execution/PipelineIO";
import { InfoBox } from "../shared/InfoBox";
import { LoadingScreen } from "../shared/LoadingScreen";
import { StatusBar, StatusText } from "../shared/Status";
-import { CancelPipelineRunButton } from "./components/CancelPipelineRunButton";
-import { ClonePipelineButton } from "./components/ClonePipelineButton";
-import { InspectPipelineButton } from "./components/InspectPipelineButton";
-import { RerunPipelineButton } from "./components/RerunPipelineButton";
+import { useRunActions } from "./useRunActions";
export const RunDetails = () => {
const { configured } = useBackend();
+
const { componentSpec } = useComponentSpec();
const {
rootDetails: details,
@@ -46,21 +30,16 @@ export const RunDetails = () => {
isLoading,
error,
} = useExecutionData();
- const { data: currentUserDetails } = useUserDetails();
-
- const [isYamlFullscreen, setIsYamlFullscreen] = useState(false);
- const editorRoute = componentSpec.name
- ? `/editor/${encodeURIComponent(componentSpec.name)}`
- : "";
-
- const canAccessEditorSpec = useCheckComponentSpecFromPath(
- editorRoute,
- !componentSpec.name,
- );
+ const statusCounts = countTaskStatuses(details, state);
+ const runStatus = getRunStatus(statusCounts);
- const isRunCreator =
- currentUserDetails?.id && metadata?.created_by === currentUserDetails.id;
+ const { actions, isYamlFullscreen, handleCloseYaml } = useRunActions({
+ componentSpec,
+ runId,
+ createdBy: metadata?.created_by,
+ statusCounts,
+ });
if (error || !details || !state || !componentSpec) {
return (
@@ -86,50 +65,8 @@ export const RunDetails = () => {
);
}
- const statusCounts = countTaskStatuses(details, state);
- const runStatus = getRunStatus(statusCounts);
- const hasRunningTasks = statusCounts.running > 0;
- const isInProgress = isStatusInProgress(runStatus) || hasRunningTasks;
- const isComplete = isStatusComplete(runStatus);
-
const annotations = componentSpec.metadata?.annotations || {};
- const actions: ActionOrReactNode[] = [];
-
- actions.push(
- setIsYamlFullscreen(true)}
- >
-
- ,
- );
-
- if (canAccessEditorSpec && componentSpec.name) {
- actions.push(
- ,
- );
- }
-
- actions.push(
- ,
- );
-
- if (isInProgress && isRunCreator) {
- actions.push();
- }
-
- if (isComplete) {
- actions.push(
- ,
- );
- }
-
return (
<>
@@ -190,7 +127,7 @@ export const RunDetails = () => {
language="yaml"
filename={componentSpec.name ?? "pipeline.yaml"}
isFullscreen={isYamlFullscreen}
- onClose={() => setIsYamlFullscreen(false)}
+ onClose={handleCloseYaml}
/>
)}
>
diff --git a/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx b/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx
deleted file mode 100644
index 9b01cff53..000000000
--- a/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { screen } from "@testing-library/dom";
-import {
- act,
- cleanup,
- fireEvent,
- render,
- waitFor,
-} from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-
-import useToastNotification from "@/hooks/useToastNotification";
-import { useBackend } from "@/providers/BackendProvider";
-import * as pipelineRunService from "@/services/pipelineRunService";
-
-import { CancelPipelineRunButton } from "./CancelPipelineRunButton";
-
-// Mock the services and hooks
-vi.mock("@/services/pipelineRunService");
-vi.mock("@/hooks/useToastNotification");
-vi.mock("@/providers/BackendProvider");
-
-describe("", () => {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
- });
- const mockNotify: ReturnType = vi.fn();
-
- beforeEach(() => {
- // Reset all mocks
- vi.clearAllMocks();
-
- vi.mocked(useToastNotification).mockReturnValue(mockNotify);
-
- vi.mocked(useBackend).mockReturnValue({
- configured: true,
- available: true,
- ready: true,
- backendUrl: "http://localhost:8000",
- isConfiguredFromEnv: false,
- isConfiguredFromRelativePath: false,
- setEnvConfig: vi.fn(),
- setRelativePathConfig: vi.fn(),
- setBackendUrl: vi.fn(),
- ping: vi.fn(),
- });
- });
-
- afterEach(() => {
- cleanup();
- queryClient.clear();
- return new Promise((resolve) => setTimeout(resolve, 0));
- });
-
- const renderWithQueryClient = (component: React.ReactElement) => {
- return render(
-
- {component}
- ,
- );
- };
-
- describe("Rendering", () => {
- test("renders cancel button with correct icon", () => {
- renderWithQueryClient();
-
- const button = screen.getByTestId("cancel-pipeline-run-button");
- expect(button).toBeInTheDocument();
- expect(button).not.toBeDisabled();
- expect(button).toHaveClass("bg-destructive");
- });
-
- test("renders button when runId is null", () => {
- renderWithQueryClient();
-
- const button = screen.getByTestId("cancel-pipeline-run-button");
- expect(button).toBeDefined();
- });
- });
-
- describe("Confirmation Dialog", () => {
- test("opens confirmation dialog when button is clicked", async () => {
- // arrange
- renderWithQueryClient();
- const button = screen.getByTestId("cancel-pipeline-run-button");
-
- // act
- await act(() => fireEvent.click(button));
-
- // assert
- expect(screen.getByRole("heading", { name: "Cancel run" })).toBeDefined();
- expect(
- screen.getByText(
- "The run will be scheduled for cancellation. This action cannot be undone.",
- ),
- ).toBeDefined();
- });
-
- test("closes confirmation dialog when cancel is clicked", async () => {
- // arrange
- renderWithQueryClient();
- const button = screen.getByTestId("cancel-pipeline-run-button");
-
- // act
- await act(() => fireEvent.click(button));
- expect(screen.getByRole("heading", { name: "Cancel run" })).toBeDefined();
-
- const cancelButton = screen.getByRole("button", { name: "Cancel" });
- await act(() => fireEvent.click(cancelButton));
-
- // assert
- expect(screen.queryByRole("heading", { name: "Cancel run" })).toBeNull();
- });
- });
-
- describe("Pipeline Cancellation", () => {
- const cancelPipelineRun = vi.mocked(pipelineRunService.cancelPipelineRun);
-
- test("successfully cancels pipeline run", async () => {
- // arrange
- cancelPipelineRun.mockResolvedValue();
- renderWithQueryClient();
-
- // act
- const button = screen.getByTestId("cancel-pipeline-run-button");
- await act(() => fireEvent.click(button));
- const confirmButton = screen.getByText("Continue");
- await act(() => fireEvent.click(confirmButton));
-
- // assert
- expect(cancelPipelineRun).toHaveBeenCalledWith(
- "test-run-123",
- "http://localhost:8000",
- );
- expect(mockNotify).toHaveBeenCalledWith(
- "Pipeline run test-run-123 cancelled",
- "success",
- );
- expect(button).toBeDisabled();
- });
-
- test("handles cancellation error", async () => {
- // arrange
- const errorMessage = "Network error";
- cancelPipelineRun.mockRejectedValue(new Error(errorMessage));
- renderWithQueryClient();
-
- // act
- const button = screen.getByTestId("cancel-pipeline-run-button");
- await act(() => fireEvent.click(button));
- const confirmButton = screen.getByText("Continue");
- await act(() => fireEvent.click(confirmButton));
-
- // assert
- expect(mockNotify).toHaveBeenCalledWith(
- `Error cancelling run: Error: ${errorMessage}`,
- "error",
- );
- expect(button).toBeVisible();
- });
-
- test("shows warning when runId is null", async () => {
- // arrange
- renderWithQueryClient();
-
- // act
- const button = screen.getByTestId("cancel-pipeline-run-button");
- await act(() => fireEvent.click(button));
- const confirmButton = screen.getByText("Continue");
- await act(() => fireEvent.click(confirmButton));
-
- // assert
- expect(mockNotify).toHaveBeenCalledWith(
- "Failed to cancel run. No run ID found.",
- "warning",
- );
- expect(cancelPipelineRun).not.toHaveBeenCalled();
- });
-
- test("shows loading state during cancellation", async () => {
- // arrange
- let resolvePromise: () => void;
- const pendingPromise = new Promise((resolve) => {
- resolvePromise = resolve;
- });
-
- cancelPipelineRun.mockReturnValue(pendingPromise);
-
- renderWithQueryClient();
-
- // act
- const button = screen.getByTestId("cancel-pipeline-run-button");
- await act(() => fireEvent.click(button));
- const confirmButton = screen.getByText("Continue");
- await act(() => fireEvent.click(confirmButton));
-
- // assert
- expect(button).toBeDisabled();
- expect(button).toHaveTextContent("");
-
- // Resolve the promise
- resolvePromise!();
-
- await waitFor(() => {
- expect(mockNotify).toHaveBeenCalledWith(
- "Pipeline run test-run-123 cancelled",
- "success",
- );
- });
- });
- });
-});
diff --git a/src/components/PipelineRun/components/CancelPipelineRunButton.tsx b/src/components/PipelineRun/components/CancelPipelineRunButton.tsx
deleted file mode 100644
index 4be968e43..000000000
--- a/src/components/PipelineRun/components/CancelPipelineRunButton.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { CircleSlash, CircleX } from "lucide-react";
-import { useCallback, useState } from "react";
-
-import TooltipButton from "@/components/shared/Buttons/TooltipButton";
-import ConfirmationDialog from "@/components/shared/Dialogs/ConfirmationDialog";
-import { Spinner } from "@/components/ui/spinner";
-import useToastNotification from "@/hooks/useToastNotification";
-import { useBackend } from "@/providers/BackendProvider";
-import { cancelPipelineRun } from "@/services/pipelineRunService";
-
-interface CancelPipelineRunButtonProps {
- runId: string | null | undefined;
-}
-
-export const CancelPipelineRunButton = ({
- runId,
-}: CancelPipelineRunButtonProps) => {
- const { backendUrl, available } = useBackend();
- const notify = useToastNotification();
-
- const [isOpen, setIsOpen] = useState(false);
-
- const {
- mutate: cancelPipeline,
- isPending,
- isSuccess,
- } = useMutation({
- mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl),
- onSuccess: () => {
- notify(`Pipeline run ${runId} cancelled`, "success");
- },
- onError: (error) => {
- notify(`Error cancelling run: ${error}`, "error");
- },
- });
-
- const handleConfirm = useCallback(() => {
- setIsOpen(false);
-
- if (!runId) {
- notify(`Failed to cancel run. No run ID found.`, "warning");
- return;
- }
-
- if (!available) {
- notify(`Backend is not available. Cannot cancel run.`, "warning");
- return;
- }
-
- try {
- cancelPipeline(runId);
- } catch (error) {
- notify(`Error cancelling run: ${error}`, "error");
- }
- }, [runId, available]);
-
- const onClick = useCallback(() => {
- setIsOpen(true);
- }, []);
-
- const handleCancel = useCallback(() => {
- setIsOpen(false);
- }, []);
-
- if (isSuccess) {
- return (
-
-
-
- );
- }
-
- return (
- <>
-
- {isPending ? (
-
- ) : (
-
-
-
- )}
-
-
-
- >
- );
-};
diff --git a/src/components/PipelineRun/components/ClonePipelineButton.test.tsx b/src/components/PipelineRun/components/ClonePipelineButton.test.tsx
deleted file mode 100644
index f5412677a..000000000
--- a/src/components/PipelineRun/components/ClonePipelineButton.test.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { screen, waitFor } from "@testing-library/dom";
-import { act, fireEvent, render } from "@testing-library/react";
-import { beforeEach, describe, expect, test, vi } from "vitest";
-
-import useToastNotification from "@/hooks/useToastNotification";
-import * as pipelineRunService from "@/services/pipelineRunService";
-
-import { ClonePipelineButton } from "./ClonePipelineButton";
-
-vi.mock("@tanstack/react-router", async (importOriginal) => ({
- ...(await importOriginal()),
- useNavigate: () => vi.fn(),
-}));
-vi.mock("@/hooks/useToastNotification");
-vi.mock("@/services/pipelineRunService");
-
-describe("", () => {
- const queryClient = new QueryClient();
- const mockNotify = vi.fn();
-
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(useToastNotification).mockReturnValue(mockNotify);
- vi.mocked(pipelineRunService.copyRunToPipeline).mockResolvedValue({
- url: "/editor/cloned-pipeline",
- name: "Cloned Pipeline",
- });
- });
-
- const componentSpec = { name: "Test Pipeline" } as any;
-
- const renderWithClient = (component: React.ReactElement) =>
- render(
-
- {component}
- ,
- );
-
- test("renders clone button", () => {
- renderWithClient();
- expect(
- screen.queryByTestId("clone-pipeline-run-button"),
- ).toBeInTheDocument();
- });
-
- test("calls copyRunToPipeline and navigate on click", async () => {
- renderWithClient();
- const cloneButton = screen.getByTestId("clone-pipeline-run-button");
- act(() => fireEvent.click(cloneButton));
-
- await waitFor(() => {
- expect(pipelineRunService.copyRunToPipeline).toHaveBeenCalled();
- });
-
- expect(mockNotify).toHaveBeenCalledWith(
- expect.stringContaining("cloned"),
- "success",
- );
- });
-});
diff --git a/src/components/PipelineRun/components/ClonePipelineButton.tsx b/src/components/PipelineRun/components/ClonePipelineButton.tsx
deleted file mode 100644
index 863a06e04..000000000
--- a/src/components/PipelineRun/components/ClonePipelineButton.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { useNavigate } from "@tanstack/react-router";
-import { CopyPlus } from "lucide-react";
-import { useCallback } from "react";
-
-import TooltipButton from "@/components/shared/Buttons/TooltipButton";
-import useToastNotification from "@/hooks/useToastNotification";
-import { copyRunToPipeline } from "@/services/pipelineRunService";
-import type { ComponentSpec } from "@/utils/componentSpec";
-import { getInitialName } from "@/utils/getComponentName";
-
-type ClonePipelineButtonProps = {
- componentSpec: ComponentSpec;
- runId?: string | null;
-};
-
-export const ClonePipelineButton = ({
- componentSpec,
- runId,
-}: ClonePipelineButtonProps) => {
- const navigate = useNavigate();
- const notify = useToastNotification();
-
- const { isPending, mutate: clonePipeline } = useMutation({
- mutationFn: async () => {
- const name = getInitialName(componentSpec);
- return copyRunToPipeline(componentSpec, runId, name);
- },
- onSuccess: (result) => {
- if (result?.url) {
- notify(`Pipeline "${result.name}" cloned`, "success");
- navigate({ to: result.url });
- }
- },
- onError: (error) => {
- notify(`Error cloning pipeline: ${error}`, "error");
- },
- });
- const handleClone = useCallback(() => {
- clonePipeline();
- }, [clonePipeline]);
-
- return (
-
-
-
- );
-};
diff --git a/src/components/PipelineRun/components/InspectPipelineButton.test.tsx b/src/components/PipelineRun/components/InspectPipelineButton.test.tsx
deleted file mode 100644
index 5200e67e3..000000000
--- a/src/components/PipelineRun/components/InspectPipelineButton.test.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { screen } from "@testing-library/dom";
-import { act, fireEvent, render } from "@testing-library/react";
-import { describe, expect, test, vi } from "vitest";
-
-import { InspectPipelineButton } from "./InspectPipelineButton";
-
-const mockNavigate = vi.fn();
-
-vi.mock("@tanstack/react-router", async (importOriginal) => ({
- ...(await importOriginal()),
- useNavigate: () => mockNavigate,
-}));
-
-describe("", () => {
- test("renders and navigates on click", () => {
- render();
- const inspectButton = screen.getByTestId("inspect-pipeline-button");
- act(() => fireEvent.click(inspectButton));
- expect(mockNavigate).toHaveBeenCalledWith({ to: "/editor/foo" });
- });
-});
diff --git a/src/components/PipelineRun/components/InspectPipelineButton.tsx b/src/components/PipelineRun/components/InspectPipelineButton.tsx
deleted file mode 100644
index c9c332c99..000000000
--- a/src/components/PipelineRun/components/InspectPipelineButton.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useNavigate } from "@tanstack/react-router";
-import { useCallback } from "react";
-
-import TooltipButton from "@/components/shared/Buttons/TooltipButton";
-import { Icon } from "@/components/ui/icon";
-
-type InspectPipelineButtonProps = {
- pipelineName: string;
-};
-
-export const InspectPipelineButton = ({
- pipelineName,
-}: InspectPipelineButtonProps) => {
- const navigate = useNavigate();
-
- const handleInspect = useCallback(() => {
- navigate({ to: `/editor/${encodeURIComponent(pipelineName)}` });
- }, [pipelineName, navigate]);
-
- return (
-
-
-
- );
-};
diff --git a/src/components/PipelineRun/components/RerunPipelineButton.test.tsx b/src/components/PipelineRun/components/RerunPipelineButton.test.tsx
deleted file mode 100644
index 5eed25c0d..000000000
--- a/src/components/PipelineRun/components/RerunPipelineButton.test.tsx
+++ /dev/null
@@ -1,337 +0,0 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import {
- act,
- cleanup,
- fireEvent,
- render,
- screen,
- waitFor,
-} from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
-
-import { BackendProvider } from "@/providers/BackendProvider";
-
-import { RerunPipelineButton } from "./RerunPipelineButton";
-
-const {
- navigateMock,
- notifyMock,
- mockSubmitPipelineRun,
- mockIsAuthorizationRequired,
- mockAwaitAuthorization,
- mockIsAuthorized,
- mockGetToken,
- mockFetch,
-} = vi.hoisted(() => ({
- navigateMock: vi.fn(),
- notifyMock: vi.fn(),
- mockSubmitPipelineRun: vi.fn(),
- mockIsAuthorizationRequired: vi.fn(),
- mockAwaitAuthorization: vi.fn(),
- mockIsAuthorized: vi.fn(),
- mockGetToken: vi.fn(),
- mockFetch: vi.fn(),
-}));
-
-// Set up mocks
-global.fetch = mockFetch;
-
-vi.mock("@tanstack/react-router", async (importOriginal) => ({
- ...(await importOriginal()),
- useNavigate: () => navigateMock,
-}));
-
-vi.mock("@/hooks/useToastNotification", () => ({
- default: () => notifyMock,
-}));
-
-vi.mock("@/components/shared/Authentication/helpers", () => ({
- isAuthorizationRequired: mockIsAuthorizationRequired,
-}));
-vi.mock("@/components/shared/Authentication/useAwaitAuthorization", () => ({
- useAwaitAuthorization: () => ({
- awaitAuthorization: mockAwaitAuthorization,
- get isAuthorized() {
- return mockIsAuthorized();
- },
- }),
-}));
-
-vi.mock("@/components/shared/Authentication/useAuthLocalStorage", () => ({
- useAuthLocalStorage: () => ({
- getToken: mockGetToken,
- }),
-}));
-
-vi.mock("@/utils/submitPipeline", () => ({
- submitPipelineRun: mockSubmitPipelineRun,
-}));
-
-const testOrigin = import.meta.env.VITE_BASE_URL || "http://localhost:3000";
-
-Object.defineProperty(window, "location", {
- value: {
- origin: testOrigin,
- },
- writable: true,
-});
-
-describe("", () => {
- const componentSpec = { name: "Test Pipeline" } as any;
- let queryClient: QueryClient;
-
- const renderWithProviders = (ui: React.ReactElement) => {
- queryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- mutations: { retry: false },
- },
- });
-
- return render(
-
- {ui}
- ,
- );
- };
-
- beforeEach(() => {
- mockFetch.mockResolvedValue({
- ok: false,
- statusText: "Not Found",
- });
-
- navigateMock.mockClear();
- notifyMock.mockClear();
- mockSubmitPipelineRun.mockClear();
- mockIsAuthorizationRequired.mockReturnValue(false);
- mockIsAuthorized.mockReturnValue(true);
- mockGetToken.mockReturnValue("mock-token");
- mockAwaitAuthorization.mockClear();
- });
-
- afterEach(async () => {
- vi.clearAllMocks();
- cleanup();
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 0));
- });
- });
-
- test("renders rerun button", async () => {
- await act(async () => {
- renderWithProviders(
- ,
- );
- });
-
- expect(screen.getByTestId("rerun-pipeline-button")).toBeInTheDocument();
- });
-
- test("calls submitPipelineRun on click", async () => {
- mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
- onSuccess({ id: 123 });
- });
-
- await act(async () => {
- renderWithProviders(
- ,
- );
- });
-
- const rerunButton = screen.getByTestId("rerun-pipeline-button");
-
- await act(async () => {
- fireEvent.click(rerunButton);
- });
-
- await waitFor(() => {
- expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
- componentSpec,
- expect.any(String),
- expect.objectContaining({
- authorizationToken: "mock-token",
- onSuccess: expect.any(Function),
- onError: expect.any(Function),
- }),
- );
- });
- });
-
- test("handles successful rerun", async () => {
- mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
- onSuccess({ id: 123 });
- });
-
- await act(async () => {
- renderWithProviders(
- ,
- );
- });
-
- const rerunButton = screen.getByTestId("rerun-pipeline-button");
-
- await act(async () => {
- fireEvent.click(rerunButton);
- });
-
- await waitFor(() => {
- expect(navigateMock).toHaveBeenCalledWith({
- to: "/runs/123",
- });
- });
- });
-
- test("handles rerun error", async () => {
- const testError = new Error("Test error");
- mockSubmitPipelineRun.mockImplementation(async (_, __, { onError }) => {
- onError(testError);
- });
-
- await act(async () => {
- renderWithProviders(
- ,
- );
- });
-
- const rerunButton = screen.getByTestId("rerun-pipeline-button");
-
- await act(async () => {
- fireEvent.click(rerunButton);
- });
-
- await waitFor(() => {
- expect(notifyMock).toHaveBeenCalledWith(
- "Failed to submit pipeline. Test error",
- "error",
- );
- });
- });
-
- test("disables button while submitting", async () => {
- let resolveSubmit: (value: any) => void;
- const submitPromise = new Promise((resolve) => {
- resolveSubmit = resolve;
- });
-
- mockSubmitPipelineRun.mockImplementation(() => submitPromise);
-
- await act(async () => {
- renderWithProviders(
- ,
- );
- });
-
- const rerunButton = screen.getByTestId("rerun-pipeline-button");
-
- await act(async () => {
- fireEvent.click(rerunButton);
- });
-
- // Wait for the mutation to start
- await waitFor(() => {
- expect(rerunButton).toBeDisabled();
- });
-
- await act(async () => {
- resolveSubmit!({ id: 123 });
- });
- });
-
- test("handles authorization when required and not authorized", async () => {
- mockIsAuthorizationRequired.mockReturnValue(true);
- mockIsAuthorized.mockReturnValue(false);
- mockAwaitAuthorization.mockResolvedValue("new-token");
- mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
- onSuccess({ id: 123 });
- });
-
- await act(async () => {
- renderWithProviders(
- ,
- );
- });
-
- const rerunButton = screen.getByTestId("rerun-pipeline-button");
-
- await act(async () => {
- fireEvent.click(rerunButton);
- });
-
- await waitFor(() => {
- expect(mockAwaitAuthorization).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
- componentSpec,
- expect.any(String),
- expect.objectContaining({
- authorizationToken: "new-token",
- }),
- );
- });
- });
-
- test("handles authorization failure", async () => {
- mockIsAuthorizationRequired.mockReturnValue(true);
- mockIsAuthorized.mockReturnValue(false);
- mockAwaitAuthorization.mockResolvedValue(null);
- mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
- onSuccess({ id: 123 });
- });
-
- await act(async () => {
- renderWithProviders(
- ,
- );
- });
-
- const rerunButton = screen.getByTestId("rerun-pipeline-button");
-
- await act(async () => {
- fireEvent.click(rerunButton);
- });
-
- await waitFor(() => {
- expect(mockAwaitAuthorization).toHaveBeenCalled();
- });
-
- await waitFor(() => {
- expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
- componentSpec,
- expect.any(String),
- expect.objectContaining({
- authorizationToken: "mock-token",
- }),
- );
- });
- });
-
- test("handles string error", async () => {
- const stringError = "String error message";
- mockSubmitPipelineRun.mockImplementation(async (_, __, { onError }) => {
- onError(stringError);
- });
-
- await act(async () => {
- renderWithProviders(
- ,
- );
- });
-
- const rerunButton = screen.getByTestId("rerun-pipeline-button");
-
- await act(async () => {
- fireEvent.click(rerunButton);
- });
-
- await waitFor(() => {
- expect(notifyMock).toHaveBeenCalledWith(
- "Failed to submit pipeline. String error message",
- "error",
- );
- });
- });
-});
diff --git a/src/components/PipelineRun/components/RerunPipelineButton.tsx b/src/components/PipelineRun/components/RerunPipelineButton.tsx
deleted file mode 100644
index 27e01e039..000000000
--- a/src/components/PipelineRun/components/RerunPipelineButton.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { useNavigate } from "@tanstack/react-router";
-import { RefreshCcw } from "lucide-react";
-import { useCallback } from "react";
-
-import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
-import { useAuthLocalStorage } from "@/components/shared/Authentication/useAuthLocalStorage";
-import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwaitAuthorization";
-import TooltipButton from "@/components/shared/Buttons/TooltipButton";
-import useToastNotification from "@/hooks/useToastNotification";
-import { useBackend } from "@/providers/BackendProvider";
-import { APP_ROUTES } from "@/routes/router";
-import type { PipelineRun } from "@/types/pipelineRun";
-import type { ComponentSpec } from "@/utils/componentSpec";
-import { submitPipelineRun } from "@/utils/submitPipeline";
-
-type RerunPipelineButtonProps = {
- componentSpec: ComponentSpec;
-};
-
-export const RerunPipelineButton = ({
- componentSpec,
-}: RerunPipelineButtonProps) => {
- const { backendUrl } = useBackend();
- const navigate = useNavigate();
- const notify = useToastNotification();
-
- const { awaitAuthorization, isAuthorized } = useAwaitAuthorization();
- const { getToken } = useAuthLocalStorage();
-
- const onSuccess = useCallback((response: PipelineRun) => {
- navigate({ to: `${APP_ROUTES.RUNS}/${response.id}` });
- }, []);
-
- const onError = useCallback(
- (error: Error | string) => {
- const message = `Failed to submit pipeline. ${error instanceof Error ? error.message : String(error)}`;
- notify(message, "error");
- },
- [notify],
- );
-
- const getAuthToken = useCallback(async (): Promise => {
- const authorizationRequired = isAuthorizationRequired();
-
- if (authorizationRequired && !isAuthorized) {
- const token = await awaitAuthorization();
- if (token) {
- return token;
- }
- }
-
- return getToken();
- }, [awaitAuthorization, getToken, isAuthorized]);
-
- const { mutate, isPending } = useMutation({
- mutationFn: async () => {
- const authorizationToken = await getAuthToken();
-
- return new Promise((resolve, reject) => {
- submitPipelineRun(componentSpec, backendUrl, {
- authorizationToken,
- onSuccess: resolve,
- onError: reject,
- });
- });
- },
- onSuccess,
- onError,
- });
-
- return (
- mutate()}
- tooltip="Rerun pipeline"
- disabled={isPending}
- data-testid="rerun-pipeline-button"
- >
-
-
- );
-};
diff --git a/src/components/PipelineRun/useRunActions.test.ts b/src/components/PipelineRun/useRunActions.test.ts
new file mode 100644
index 000000000..5650035bc
--- /dev/null
+++ b/src/components/PipelineRun/useRunActions.test.ts
@@ -0,0 +1,651 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import { type ReactNode } from "react";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import type { ComponentSpec } from "@/utils/componentSpec";
+
+import { useRunActions } from "./useRunActions";
+
+const {
+ mockNotify,
+ mockNavigate,
+ mockAwaitAuthorization,
+ mockGetToken,
+ mockIsAuthorized,
+ mockCopyRunToPipeline,
+ mockCancelPipelineRun,
+ mockSubmitPipelineRun,
+ mockIsAuthorizationRequired,
+} = vi.hoisted(() => ({
+ mockNotify: vi.fn(),
+ mockNavigate: vi.fn(),
+ mockAwaitAuthorization: vi.fn(),
+ mockGetToken: vi.fn(),
+ mockIsAuthorized: vi.fn(),
+ mockCopyRunToPipeline: vi.fn(),
+ mockCancelPipelineRun: vi.fn(),
+ mockSubmitPipelineRun: vi.fn(),
+ mockIsAuthorizationRequired: vi.fn(),
+}));
+
+// Mock dependencies
+vi.mock("@tanstack/react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => mockNavigate,
+}));
+
+vi.mock("@/hooks/useToastNotification", () => ({
+ default: () => mockNotify,
+}));
+
+vi.mock("@/hooks/useUserDetails", () => ({
+ useUserDetails: () => ({
+ data: { id: "user-123", permissions: [] },
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+vi.mock("@/hooks/useCheckComponentSpecFromPath", () => ({
+ useCheckComponentSpecFromPath: () => true,
+}));
+
+vi.mock("@/providers/BackendProvider", () => ({
+ useBackend: () => ({
+ configured: true,
+ available: true,
+ ready: true,
+ backendUrl: "http://localhost:8000",
+ isConfiguredFromEnv: false,
+ isConfiguredFromRelativePath: false,
+ setEnvConfig: vi.fn(),
+ setRelativePathConfig: vi.fn(),
+ setBackendUrl: vi.fn(),
+ ping: vi.fn(),
+ }),
+}));
+
+vi.mock("@/services/pipelineRunService", () => ({
+ copyRunToPipeline: mockCopyRunToPipeline,
+ cancelPipelineRun: mockCancelPipelineRun,
+}));
+
+vi.mock("@/utils/submitPipeline", () => ({
+ submitPipelineRun: mockSubmitPipelineRun,
+}));
+
+vi.mock("@/components/shared/Authentication/helpers", () => ({
+ isAuthorizationRequired: mockIsAuthorizationRequired,
+}));
+
+vi.mock("@/components/shared/Authentication/useAwaitAuthorization", () => ({
+ useAwaitAuthorization: () => ({
+ awaitAuthorization: mockAwaitAuthorization,
+ get isAuthorized() {
+ return mockIsAuthorized();
+ },
+ isLoading: false,
+ isPopupOpen: false,
+ closePopup: vi.fn(),
+ bringPopupToFront: vi.fn(),
+ }),
+}));
+
+vi.mock("@/components/shared/Authentication/useAuthLocalStorage", () => ({
+ useAuthLocalStorage: () => ({
+ getToken: mockGetToken,
+ }),
+}));
+
+describe("useRunActions", () => {
+ let queryClient: QueryClient;
+
+ const defaultParams = {
+ componentSpec: { name: "Test Pipeline" } as ComponentSpec,
+ runId: "test-run-123",
+ createdBy: "user-123",
+ statusCounts: {
+ succeeded: 0,
+ failed: 0,
+ running: 1,
+ waiting: 0,
+ cancelled: 0,
+ unknown: 0,
+ skipped: 0,
+ total: 1,
+ },
+ };
+
+ const createWrapper = () => {
+ return ({ children }: { children: ReactNode }) =>
+ QueryClientProvider({ client: queryClient, children });
+ };
+
+ beforeEach(() => {
+ queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ vi.clearAllMocks();
+ mockIsAuthorizationRequired.mockReturnValue(false);
+ mockIsAuthorized.mockReturnValue(true);
+ mockGetToken.mockReturnValue("mock-token");
+ });
+
+ describe("Action array composition", () => {
+ test("includes View YAML action", () => {
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const viewYamlAction = result.current.actions.find(
+ (action) => action.label === "View YAML",
+ );
+
+ expect(viewYamlAction).toEqual({
+ label: "View YAML",
+ icon: "FileCodeCorner",
+ onClick: expect.any(Function),
+ });
+ });
+
+ test("includes Inspect Pipeline action", () => {
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const inspectAction = result.current.actions.find(
+ (action) => action.label === "Inspect Pipeline",
+ );
+
+ expect(inspectAction).toMatchObject({
+ label: "Inspect Pipeline",
+ icon: "SquareMousePointer",
+ hidden: false,
+ });
+ });
+
+ test("includes Clone Pipeline action", () => {
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cloneAction = result.current.actions.find(
+ (action) => action.label === "Clone Pipeline",
+ );
+
+ expect(cloneAction).toMatchObject({
+ label: "Clone Pipeline",
+ icon: "CopyPlus",
+ disabled: false,
+ });
+ });
+
+ test("shows Cancel Run action when run is in progress and user is creator", () => {
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cancelAction = result.current.actions.find(
+ (action) => action.label === "Cancel Run",
+ );
+
+ expect(cancelAction).toMatchObject({
+ label: "Cancel Run",
+ icon: "CircleX",
+ destructive: true,
+ disabled: false,
+ hidden: false,
+ confirmation: expect.any(String),
+ });
+ });
+
+ test("shows Rerun Pipeline action when run is complete", () => {
+ const completeParams = {
+ ...defaultParams,
+ statusCounts: {
+ succeeded: 1,
+ failed: 0,
+ running: 0,
+ waiting: 0,
+ cancelled: 0,
+ unknown: 0,
+ skipped: 0,
+ total: 1,
+ },
+ };
+
+ const { result } = renderHook(() => useRunActions(completeParams), {
+ wrapper: createWrapper(),
+ });
+
+ const rerunAction = result.current.actions.find(
+ (action) => action.label === "Rerun Pipeline",
+ );
+
+ expect(rerunAction).toMatchObject({
+ label: "Rerun Pipeline",
+ icon: "RefreshCcw",
+ disabled: false,
+ hidden: false,
+ });
+ });
+
+ test("hides Rerun Pipeline action when run is in progress", () => {
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const rerunAction = result.current.actions.find(
+ (action) => action.label === "Rerun Pipeline",
+ );
+
+ expect(rerunAction?.hidden).toBe(true);
+ });
+ });
+
+ describe("State management", () => {
+ test("isYamlFullscreen starts as false", () => {
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ expect(result.current.isYamlFullscreen).toBe(false);
+ });
+
+ test("handleCloseYaml sets isYamlFullscreen to false", () => {
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ result.current.handleCloseYaml();
+
+ expect(result.current.isYamlFullscreen).toBe(false);
+ });
+ });
+
+ describe("Clone Pipeline mutation", () => {
+ test("calls copyRunToPipeline with correct arguments", async () => {
+ mockCopyRunToPipeline.mockResolvedValue({
+ url: "/editor/cloned-pipeline",
+ name: "Cloned Pipeline",
+ });
+
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cloneAction = result.current.actions.find(
+ (action) => action.label === "Clone Pipeline",
+ );
+
+ cloneAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockCopyRunToPipeline).toHaveBeenCalledWith(
+ defaultParams.componentSpec,
+ expect.any(String),
+ );
+ });
+ });
+
+ test("shows success notification on successful clone", async () => {
+ mockCopyRunToPipeline.mockResolvedValue({
+ url: "/editor/cloned-pipeline",
+ name: "Cloned Pipeline",
+ });
+
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cloneAction = result.current.actions.find(
+ (action) => action.label === "Clone Pipeline",
+ );
+
+ cloneAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ 'Pipeline "Cloned Pipeline" cloned',
+ "success",
+ );
+ });
+ });
+
+ test("shows error notification on clone failure", async () => {
+ const errorMessage = "Clone failed";
+ mockCopyRunToPipeline.mockRejectedValue(new Error(errorMessage));
+
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cloneAction = result.current.actions.find(
+ (action) => action.label === "Clone Pipeline",
+ );
+
+ cloneAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.stringContaining("Error cloning pipeline"),
+ "error",
+ );
+ });
+ });
+
+ test("disables action while mutation is pending", async () => {
+ let resolveClone: (value: any) => void;
+ const clonePromise = new Promise((resolve) => {
+ resolveClone = resolve;
+ });
+
+ mockCopyRunToPipeline.mockReturnValue(clonePromise as any);
+
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cloneAction = result.current.actions.find(
+ (action) => action.label === "Clone Pipeline",
+ );
+
+ cloneAction?.onClick();
+
+ await waitFor(() => {
+ const currentCloneAction = result.current.actions.find(
+ (action) => action.label === "Clone Pipeline",
+ );
+ expect(currentCloneAction?.disabled).toBe(true);
+ });
+
+ resolveClone!({ url: "/editor/test", name: "Test" });
+ });
+ });
+
+ describe("Cancel Pipeline Run mutation", () => {
+ test("calls cancelPipelineRun with correct arguments", async () => {
+ mockCancelPipelineRun.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cancelAction = result.current.actions.find(
+ (action) => action.label === "Cancel Run",
+ );
+
+ cancelAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockCancelPipelineRun).toHaveBeenCalledWith(
+ "test-run-123",
+ "http://localhost:8000",
+ );
+ });
+ });
+
+ test("shows success notification on successful cancellation", async () => {
+ mockCancelPipelineRun.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cancelAction = result.current.actions.find(
+ (action) => action.label === "Cancel Run",
+ );
+
+ cancelAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ "Pipeline run test-run-123 cancelled",
+ "success",
+ );
+ });
+ });
+
+ test("shows error notification on cancellation failure", async () => {
+ const errorMessage = "Network error";
+ mockCancelPipelineRun.mockRejectedValue(new Error(errorMessage));
+
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cancelAction = result.current.actions.find(
+ (action) => action.label === "Cancel Run",
+ );
+
+ cancelAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ expect.stringContaining("Error cancelling run"),
+ "error",
+ );
+ });
+ });
+
+ test("shows warning when runId is null", async () => {
+ const { result } = renderHook(
+ () => useRunActions({ ...defaultParams, runId: null }),
+ { wrapper: createWrapper() },
+ );
+
+ const cancelAction = result.current.actions.find(
+ (action) => action.label === "Cancel Run",
+ );
+
+ cancelAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ "Failed to cancel run. No run ID found.",
+ "warning",
+ );
+ expect(mockCancelPipelineRun).not.toHaveBeenCalled();
+ });
+ });
+
+ test("changes to success state after cancellation", async () => {
+ mockCancelPipelineRun.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useRunActions(defaultParams), {
+ wrapper: createWrapper(),
+ });
+
+ const cancelAction = result.current.actions.find(
+ (action) => action.label === "Cancel Run",
+ );
+
+ cancelAction?.onClick();
+
+ await waitFor(() => {
+ const updatedCancelAction = result.current.actions.find(
+ (action) => action.label === "Cancel Run",
+ );
+ expect(updatedCancelAction?.icon).toBe("CircleSlash");
+ expect(updatedCancelAction?.destructive).toBe(false);
+ expect(updatedCancelAction?.disabled).toBe(true);
+ });
+ });
+ });
+
+ describe("Rerun Pipeline mutation", () => {
+ const completeParams = {
+ ...defaultParams,
+ statusCounts: {
+ succeeded: 1,
+ failed: 0,
+ running: 0,
+ waiting: 0,
+ cancelled: 0,
+ unknown: 0,
+ skipped: 0,
+ total: 1,
+ },
+ };
+
+ test("calls submitPipelineRun with correct arguments", async () => {
+ mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
+ onSuccess({ id: "new-run-123" } as any);
+ });
+
+ const { result } = renderHook(() => useRunActions(completeParams), {
+ wrapper: createWrapper(),
+ });
+
+ const rerunAction = result.current.actions.find(
+ (action) => action.label === "Rerun Pipeline",
+ );
+
+ rerunAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ completeParams.componentSpec,
+ "http://localhost:8000",
+ expect.objectContaining({
+ authorizationToken: "mock-token",
+ }),
+ );
+ });
+ });
+
+ test("navigates to new run on success", async () => {
+ mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
+ onSuccess({ id: "new-run-123" } as any);
+ });
+
+ const { result } = renderHook(() => useRunActions(completeParams), {
+ wrapper: createWrapper(),
+ });
+
+ const rerunAction = result.current.actions.find(
+ (action) => action.label === "Rerun Pipeline",
+ );
+
+ rerunAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith({ to: "/runs/new-run-123" });
+ });
+ });
+
+ test("shows error notification on rerun failure", async () => {
+ const testError = new Error("Test error");
+ mockSubmitPipelineRun.mockImplementation(async (_, __, { onError }) => {
+ onError(testError);
+ });
+
+ const { result } = renderHook(() => useRunActions(completeParams), {
+ wrapper: createWrapper(),
+ });
+
+ const rerunAction = result.current.actions.find(
+ (action) => action.label === "Rerun Pipeline",
+ );
+
+ rerunAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ "Failed to submit pipeline. Test error",
+ "error",
+ );
+ });
+ });
+
+ test("handles string error messages", async () => {
+ const stringError = "String error message";
+ mockSubmitPipelineRun.mockImplementation(async (_, __, { onError }) => {
+ onError(stringError);
+ });
+
+ const { result } = renderHook(() => useRunActions(completeParams), {
+ wrapper: createWrapper(),
+ });
+
+ const rerunAction = result.current.actions.find(
+ (action) => action.label === "Rerun Pipeline",
+ );
+
+ rerunAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockNotify).toHaveBeenCalledWith(
+ "Failed to submit pipeline. String error message",
+ "error",
+ );
+ });
+ });
+
+ test("awaits authorization when required", async () => {
+ mockIsAuthorizationRequired.mockReturnValue(true);
+ mockIsAuthorized.mockReturnValue(false);
+ mockAwaitAuthorization.mockResolvedValue("new-token");
+
+ mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
+ onSuccess({ id: "new-run-123" } as any);
+ });
+
+ const { result } = renderHook(() => useRunActions(completeParams), {
+ wrapper: createWrapper(),
+ });
+
+ const rerunAction = result.current.actions.find(
+ (action) => action.label === "Rerun Pipeline",
+ );
+
+ rerunAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockAwaitAuthorization).toHaveBeenCalled();
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ completeParams.componentSpec,
+ "http://localhost:8000",
+ expect.objectContaining({
+ authorizationToken: "new-token",
+ }),
+ );
+ });
+ });
+
+ test("falls back to getToken when authorization fails", async () => {
+ mockIsAuthorizationRequired.mockReturnValue(true);
+ mockIsAuthorized.mockReturnValue(false);
+ mockAwaitAuthorization.mockResolvedValue(null);
+
+ mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => {
+ onSuccess({ id: "new-run-123" } as any);
+ });
+
+ const { result } = renderHook(() => useRunActions(completeParams), {
+ wrapper: createWrapper(),
+ });
+
+ const rerunAction = result.current.actions.find(
+ (action) => action.label === "Rerun Pipeline",
+ );
+
+ rerunAction?.onClick();
+
+ await waitFor(() => {
+ expect(mockSubmitPipelineRun).toHaveBeenCalledWith(
+ completeParams.componentSpec,
+ "http://localhost:8000",
+ expect.objectContaining({
+ authorizationToken: "mock-token",
+ }),
+ );
+ });
+ });
+ });
+});
diff --git a/src/components/PipelineRun/useRunActions.ts b/src/components/PipelineRun/useRunActions.ts
new file mode 100644
index 000000000..d10fc7ebf
--- /dev/null
+++ b/src/components/PipelineRun/useRunActions.ts
@@ -0,0 +1,212 @@
+import { useMutation } from "@tanstack/react-query";
+import { useNavigate } from "@tanstack/react-router";
+import { useState } from "react";
+
+import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
+import { useAuthLocalStorage } from "@/components/shared/Authentication/useAuthLocalStorage";
+import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwaitAuthorization";
+import type { Action } from "@/components/shared/ContextPanel/Blocks/ActionBlock";
+import { useCheckComponentSpecFromPath } from "@/hooks/useCheckComponentSpecFromPath";
+import useToastNotification from "@/hooks/useToastNotification";
+import { useUserDetails } from "@/hooks/useUserDetails";
+import { useBackend } from "@/providers/BackendProvider";
+import { APP_ROUTES } from "@/routes/router";
+import {
+ getRunStatus,
+ isStatusComplete,
+ isStatusInProgress,
+} from "@/services/executionService";
+import {
+ cancelPipelineRun,
+ copyRunToPipeline,
+} from "@/services/pipelineRunService";
+import type { PipelineRun, TaskStatusCounts } from "@/types/pipelineRun";
+import type { ComponentSpec } from "@/utils/componentSpec";
+import { getInitialName } from "@/utils/getComponentName";
+import { submitPipelineRun } from "@/utils/submitPipeline";
+
+interface UseRunActionsParams {
+ componentSpec: ComponentSpec;
+ runId: string | null | undefined;
+ createdBy: string | null | undefined;
+ statusCounts: TaskStatusCounts;
+}
+
+export const useRunActions = ({
+ componentSpec,
+ runId,
+ createdBy,
+ statusCounts,
+}: UseRunActionsParams) => {
+ const navigate = useNavigate();
+ const notify = useToastNotification();
+ const { available, backendUrl } = useBackend();
+ const { awaitAuthorization, isAuthorized } = useAwaitAuthorization();
+ const { getToken } = useAuthLocalStorage();
+ const { data: currentUserDetails } = useUserDetails();
+
+ const [isYamlFullscreen, setIsYamlFullscreen] = useState(false);
+
+ const isRunCreator =
+ currentUserDetails?.id && createdBy === currentUserDetails.id;
+
+ const editorRoute = componentSpec.name
+ ? `/editor/${encodeURIComponent(componentSpec.name)}`
+ : "";
+
+ const canAccessEditorSpec = useCheckComponentSpecFromPath(
+ editorRoute,
+ !componentSpec.name,
+ );
+
+ const runStatus = getRunStatus(statusCounts);
+ const hasRunningTasks = statusCounts.running > 0;
+ const isInProgress = isStatusInProgress(runStatus) || hasRunningTasks;
+ const isComplete = isStatusComplete(runStatus);
+
+ const { isPending: isPendingClone, mutate: clonePipeline } = useMutation({
+ mutationFn: async () => {
+ const name = getInitialName(componentSpec);
+ return copyRunToPipeline(componentSpec, runId, name);
+ },
+ onSuccess: (result) => {
+ if (result?.url) {
+ notify(`Pipeline "${result.name}" cloned`, "success");
+ navigate({ to: result.url });
+ }
+ },
+ onError: (error) => {
+ notify(`Error cloning pipeline: ${error}`, "error");
+ },
+ });
+
+ const {
+ mutate: cancelPipeline,
+ isPending: isPendingCancel,
+ isSuccess: isSuccessCancel,
+ } = useMutation({
+ mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl),
+ onSuccess: () => {
+ notify(`Pipeline run ${runId} cancelled`, "success");
+ },
+ onError: (error) => {
+ notify(`Error cancelling run: ${error}`, "error");
+ },
+ });
+
+ const getAuthToken = async (): Promise => {
+ const authorizationRequired = isAuthorizationRequired();
+
+ if (authorizationRequired && !isAuthorized) {
+ const token = await awaitAuthorization();
+ if (token) {
+ return token;
+ }
+ }
+
+ return getToken();
+ };
+
+ const { mutate: rerunPipeline, isPending: isPendingRerun } = useMutation({
+ mutationFn: async () => {
+ const authorizationToken = await getAuthToken();
+
+ return new Promise((resolve, reject) => {
+ submitPipelineRun(componentSpec, backendUrl, {
+ authorizationToken,
+ onSuccess: resolve,
+ onError: reject,
+ });
+ });
+ },
+ onSuccess: (response: PipelineRun) => {
+ navigate({ to: `${APP_ROUTES.RUNS}/${response.id}` });
+ },
+ onError: (error: Error | string) => {
+ const message = `Failed to submit pipeline. ${error instanceof Error ? error.message : String(error)}`;
+ notify(message, "error");
+ },
+ });
+
+ const handleViewYaml = () => {
+ setIsYamlFullscreen(true);
+ };
+
+ const handleCloseYaml = () => {
+ setIsYamlFullscreen(false);
+ };
+
+ const handleInspect = () => {
+ navigate({ to: editorRoute });
+ };
+
+ const handleClone = () => {
+ clonePipeline();
+ };
+
+ const handleCancel = () => {
+ if (!runId) {
+ notify(`Failed to cancel run. No run ID found.`, "warning");
+ return;
+ }
+
+ if (!available) {
+ notify(`Backend is not available. Cannot cancel run.`, "warning");
+ return;
+ }
+
+ try {
+ cancelPipeline(runId);
+ } catch (error) {
+ notify(`Error cancelling run: ${error}`, "error");
+ }
+ };
+
+ const handleRerun = () => {
+ rerunPipeline();
+ };
+
+ const actions: Action[] = [
+ {
+ label: "View YAML",
+ icon: "FileCodeCorner",
+ onClick: handleViewYaml,
+ },
+ {
+ label: "Inspect Pipeline",
+ icon: "SquareMousePointer",
+ hidden: !canAccessEditorSpec,
+ onClick: handleInspect,
+ },
+ {
+ label: "Clone Pipeline",
+ icon: "CopyPlus",
+ disabled: isPendingClone,
+ onClick: handleClone,
+ },
+ {
+ label: "Cancel Run",
+ confirmation:
+ "The run will be scheduled for cancellation. This action cannot be undone.",
+ icon: isSuccessCancel ? "CircleSlash" : "CircleX",
+ className: isSuccessCancel ? "bg-primary text-primary-foreground" : "",
+ destructive: !isSuccessCancel,
+ disabled: isPendingCancel || isSuccessCancel,
+ hidden: !isInProgress || !isRunCreator,
+ onClick: handleCancel,
+ },
+ {
+ label: "Rerun Pipeline",
+ icon: "RefreshCcw",
+ disabled: isPendingRerun,
+ hidden: !isComplete,
+ onClick: handleRerun,
+ },
+ ];
+
+ return {
+ actions,
+ isYamlFullscreen,
+ handleCloseYaml,
+ };
+};
diff --git a/src/services/executionService.ts b/src/services/executionService.ts
index 0503ac42f..3894052af 100644
--- a/src/services/executionService.ts
+++ b/src/services/executionService.ts
@@ -234,8 +234,8 @@ const mapStatus = (status: string) => {
* using priority: CANCELLED > FAILED > RUNNING > SKIPPED > WAITING > SUCCEEDED
*/
export const countTaskStatuses = (
- details: GetExecutionInfoResponse,
- stateData: GetGraphExecutionStateResponse,
+ details: GetExecutionInfoResponse | undefined,
+ stateData: GetGraphExecutionStateResponse | undefined,
): TaskStatusCounts => {
const statusCounts = {
total: 0,
@@ -247,6 +247,10 @@ export const countTaskStatuses = (
cancelled: 0,
};
+ if (!details || !stateData) {
+ return statusCounts;
+ }
+
if (
details.child_task_execution_ids &&
stateData.child_execution_status_stats