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