diff --git a/src/routes/Import/Import.test.tsx b/src/routes/Import/Import.test.tsx new file mode 100644 index 000000000..91a580903 --- /dev/null +++ b/src/routes/Import/Import.test.tsx @@ -0,0 +1,158 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { ImportPage } from "./index"; + +const mockNavigate = vi.fn(); +let mockSearchParams: { url?: string } = { + url: "http://127.0.0.1:9999/pipeline.yaml", +}; + +vi.mock("@tanstack/react-router", async (importOriginal) => { + return { + ...(await importOriginal()), + useNavigate: () => mockNavigate, + useSearch: () => mockSearchParams, + useRouter: () => ({ navigate: vi.fn() }), + }; +}); + +vi.mock("@/services/pipelineService", () => ({ + importPipelineFromYaml: vi.fn(), +})); + +import { importPipelineFromYaml } from "@/services/pipelineService"; + +describe("ImportPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSearchParams = { url: "http://127.0.0.1:9999/pipeline.yaml" }; + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + cleanup(); + }); + + test("shows step indicator with all steps on initial render", () => { + (globalThis.fetch as ReturnType).mockReturnValue( + new Promise(() => {}), + ); + render(); + expect(screen.getByText("Importing Pipeline")).toBeInTheDocument(); + expect(screen.getByText("Fetching pipeline")).toBeInTheDocument(); + expect(screen.getByText("Importing into editor")).toBeInTheDocument(); + expect(screen.getByText("Opening editor")).toBeInTheDocument(); + }); + + test("progresses through steps and redirects on success", async () => { + const yamlContent = "name: Test Pipeline\nimplementation: {}"; + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + text: () => Promise.resolve(yamlContent), + }); + (importPipelineFromYaml as ReturnType).mockResolvedValue({ + successful: true, + name: "Test Pipeline", + }); + + render(); + + await waitFor(() => { + expect(importPipelineFromYaml).toHaveBeenCalledWith(yamlContent, true); + }); + + await waitFor(() => { + expect(screen.getByText(/Test Pipeline/)).toBeInTheDocument(); + }); + + await waitFor( + () => { + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/editor/Test%20Pipeline", + }); + }, + { timeout: 5000 }, + ); + }); + + test("shows error UI when fetch returns non-OK response", async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Import Failed")).toBeInTheDocument(); + expect( + screen.getByText(/Something went wrong importing the pipeline/), + ).toBeInTheDocument(); + expect( + screen.getByText(/Failed to fetch pipeline: 404 Not Found/), + ).toBeInTheDocument(); + expect(screen.getByText("← Back to Home")).toBeInTheDocument(); + }); + }); + + test("shows error when import returns failure", async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + text: () => Promise.resolve("invalid: yaml"), + }); + (importPipelineFromYaml as ReturnType).mockResolvedValue({ + successful: false, + name: "", + errorMessage: "Invalid pipeline format", + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Import Failed")).toBeInTheDocument(); + expect(screen.getByText(/Invalid pipeline format/)).toBeInTheDocument(); + }); + }); + + test("shows error for missing url parameter", async () => { + mockSearchParams = {}; + + render(); + + await waitFor(() => { + expect(screen.getByText("Import Failed")).toBeInTheDocument(); + expect(screen.getByText(/Missing 'url' parameter/)).toBeInTheDocument(); + }); + }); + + test("shows error when fetched content is empty", async () => { + (globalThis.fetch as ReturnType).mockResolvedValue({ + ok: true, + text: () => Promise.resolve(" "), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Import Failed")).toBeInTheDocument(); + expect( + screen.getByText(/The fetched pipeline content is empty/), + ).toBeInTheDocument(); + }); + }); + + test("shows error when fetch throws a network error", async () => { + (globalThis.fetch as ReturnType).mockRejectedValue( + new Error("Network failure"), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText("Import Failed")).toBeInTheDocument(); + expect(screen.getByText(/Network failure/)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/routes/Import/index.tsx b/src/routes/Import/index.tsx new file mode 100644 index 000000000..32a3f1084 --- /dev/null +++ b/src/routes/Import/index.tsx @@ -0,0 +1,210 @@ +import { useNavigate, useRouter, useSearch } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; + +import { InfoBox } from "@/components/shared/InfoBox"; +import { Button } from "@/components/ui/button"; +import { BlockStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { Paragraph, Text } from "@/components/ui/typography"; +import { EDITOR_PATH } from "@/routes/router"; +import { importPipelineFromYaml } from "@/services/pipelineService"; + +type ImportSearch = { url?: string }; + +/** + * Import route that fetches a pipeline YAML from a URL and imports it into the editor. + * + * Used by tangle-deploy CLI's `tangle-view-pipeline` command, which starts a temporary + * local HTTP server and opens: /#/import?url=http://localhost:PORT/pipeline.yaml + * + * Flow: + * 1. Read `url` from search params + * 2. Fetch YAML from the URL + * 3. Import into IndexedDB via importPipelineFromYaml (overwrite if same name exists) + * 4. Redirect to the editor + */ +enum Step { + Fetching = "fetching", + Importing = "importing", + Done = "done", +} + +const STEPS: { key: Step; label: string; emoji: string }[] = [ + { key: Step.Fetching, label: "Fetching pipeline", emoji: "📡" }, + { key: Step.Importing, label: "Importing into editor", emoji: "📦" }, + { key: Step.Done, label: "Opening editor", emoji: "🚀" }, +]; + +const StepIndicator = ({ + currentStep, + pipelineName, +}: { + currentStep: Step; + pipelineName: string | null; +}) => { + const currentIndex = STEPS.findIndex((s) => s.key === currentStep); + + return ( +
+ {STEPS.map((step, i) => { + const isActive = i === currentIndex; + const isComplete = i < currentIndex; + const isPending = i > currentIndex; + + return ( +
+ + {isComplete ? "✅" : isActive ? step.emoji : "⏳"} + +
+ + {step.label} + {step.key === Step.Done && pipelineName && ( + + “{pipelineName}” + + )} + +
+ {isActive && } +
+ ); + })} +
+ ); +}; + +export const ImportPage = () => { + const search = useSearch({ strict: false }) as ImportSearch; + const navigate = useNavigate(); + const router = useRouter(); + const [error, setError] = useState(null); + const [step, setStep] = useState(Step.Fetching); + const [pipelineName, setPipelineName] = useState(null); + const importedRef = useRef(false); + + useEffect(() => { + // React Strict Mode re-mounts components, firing useEffect twice. + // useRef persists across remounts to prevent a duplicate import. + if (importedRef.current) return; + + const importFromUrl = async () => { + const url = search.url; + + if (!url) { + setError("Missing 'url' parameter. Nothing to import."); + return; + } + + try { + setStep(Step.Fetching); + const response = await fetch(url); + + if (!response.ok) { + setError( + `Failed to fetch pipeline: ${response.status} ${response.statusText}`, + ); + return; + } + + const yamlContent = await response.text(); + + if (!yamlContent || yamlContent.trim().length === 0) { + setError("The fetched pipeline content is empty."); + return; + } + + setStep(Step.Importing); + const result = await importPipelineFromYaml(yamlContent, true); + + if (result.successful) { + importedRef.current = true; + setPipelineName(result.name); + setStep(Step.Done); + // TODO: remove this delay -- temporary for debugging + await new Promise((r) => setTimeout(r, 3000)); + navigate({ + to: `${EDITOR_PATH}/${encodeURIComponent(result.name)}`, + }); + } else { + setError( + result.errorMessage || "Failed to import pipeline from URL.", + ); + } + } catch (err) { + setError( + `Failed to import pipeline: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }; + + importFromUrl(); + }, [search.url, navigate]); + + if (error) { + return ( +
+
+ +
+ + + Import Failed + + + Something went wrong importing the pipeline. + +
+ + + + {error} + + + + +
+
+
+ ); + } + + return ( +
+
+ +
+ 🔧 + + Importing Pipeline + + + Setting up your pipeline in the editor... + +
+ + +
+
+
+ ); +}; diff --git a/src/routes/router.ts b/src/routes/router.ts index 212152935..552b1c003 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -14,6 +14,7 @@ import { BASE_URL, IS_GITHUB_PAGES } from "@/utils/constants"; import RootLayout from "../components/layout/RootLayout"; import Editor from "./Editor"; import Home from "./Home"; +import { ImportPage } from "./Import"; import NotFoundPage from "./NotFoundPage"; import PipelineRun from "./PipelineRun"; import { QuickStartPage } from "./QuickStart"; @@ -27,9 +28,11 @@ declare module "@tanstack/react-router" { export const EDITOR_PATH = "/editor"; export const RUNS_BASE_PATH = "/runs"; export const QUICK_START_PATH = "/quick-start"; +const IMPORT_PATH = "/import"; export const APP_ROUTES = { HOME: "/", QUICK_START: QUICK_START_PATH, + IMPORT: IMPORT_PATH, PIPELINE_EDITOR: `${EDITOR_PATH}/$name`, RUN_DETAIL: `${RUNS_BASE_PATH}/$id`, RUN_DETAIL_WITH_SUBGRAPH: `${RUNS_BASE_PATH}/$id/$subgraphExecutionId`, @@ -62,6 +65,12 @@ const quickStartRoute = createRoute({ component: QuickStartPage, }); +const importRoute = createRoute({ + getParentRoute: () => mainLayout, + path: APP_ROUTES.IMPORT, + component: ImportPage, +}); + const editorRoute = createRoute({ getParentRoute: () => mainLayout, path: APP_ROUTES.PIPELINE_EDITOR, @@ -99,6 +108,7 @@ const runDetailWithSubgraphRoute = createRoute({ const appRouteTree = mainLayout.addChildren([ indexRoute, quickStartRoute, + importRoute, editorRoute, runDetailRoute, runDetailWithSubgraphRoute,