diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 9e1ee978..2b2349d1 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -1933,8 +1933,8 @@ export function WorkflowCanvas() { {/* Welcome Modal */} {showQuickstart && ( { - await loadWorkflow(workflow); + onWorkflowGenerated={async (workflow, directoryPath) => { + await loadWorkflow(workflow, directoryPath); setShowQuickstart(false); }} onClose={() => setShowQuickstart(false)} diff --git a/src/components/__tests__/WelcomeModal.test.tsx b/src/components/__tests__/WelcomeModal.test.tsx index deb5560c..533740ea 100644 --- a/src/components/__tests__/WelcomeModal.test.tsx +++ b/src/components/__tests__/WelcomeModal.test.tsx @@ -184,24 +184,9 @@ describe("WelcomeModal", () => { }); }); - describe("File Loading", () => { - it("should render hidden file input for workflow loading", () => { - const { container } = render( - - ); - - const fileInput = container.querySelector('input[type="file"]'); - expect(fileInput).toBeInTheDocument(); - expect(fileInput).toHaveAttribute("accept", ".json"); - expect(fileInput).toHaveClass("hidden"); - }); - - it("should trigger file input when 'Load workflow' is clicked", () => { - const { container } = render( + describe("File Loading via Directory Picker", () => { + it("should call browse-directory API when 'Load workflow' is clicked", () => { + render( { /> ); - const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; - const clickSpy = vi.spyOn(fileInput, "click"); - fireEvent.click(screen.getByText("Load workflow")); - expect(clickSpy).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith("/api/browse-directory"); }); - it("should call onWorkflowGenerated when valid workflow file is loaded", async () => { - const validWorkflow: WorkflowFile = { - id: "test-id", - version: 1, - name: "Test Workflow", - edgeStyle: "curved", - nodes: [], - edges: [], - }; - - // Create a mock FileReader class - const mockFileReader = { - readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent) => void) | null; result: string }) { - setTimeout(() => { - this.onload?.({ target: { result: this.result } } as ProgressEvent); - }, 0); - }), - onload: null as ((e: ProgressEvent) => void) | null, - onerror: null, - result: JSON.stringify(validWorkflow), - }; - - vi.stubGlobal("FileReader", function FileReaderMock(this: typeof mockFileReader) { - Object.assign(this, mockFileReader); - this.readAsText = mockFileReader.readAsText.bind(this); - return this; + it("should not call onWorkflowGenerated when directory picker is cancelled", async () => { + mockFetch.mockImplementation((url: string) => { + if (url === "/api/browse-directory") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, cancelled: true }), + }); + } + if (url === "/api/community-workflows") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, workflows: [] }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); - const { container } = render( + render( { /> ); - const file = new File([JSON.stringify(validWorkflow)], "test.json", { - type: "application/json", - }); - - const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; - - await act(async () => { - fireEvent.change(fileInput, { target: { files: [file] } }); - }); + fireEvent.click(screen.getByText("Load workflow")); await waitFor(() => { - expect(mockOnWorkflowGenerated).toHaveBeenCalledWith(validWorkflow); + expect(mockFetch).toHaveBeenCalledWith("/api/browse-directory"); }); - - vi.unstubAllGlobals(); + expect(mockOnWorkflowGenerated).not.toHaveBeenCalled(); }); - it("should show alert for invalid workflow file format", async () => { - const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); - - const invalidWorkflow = { foo: "bar" }; // Missing required fields - - const mockFileReader = { - readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent) => void) | null; result: string }) { - setTimeout(() => { - this.onload?.({ target: { result: this.result } } as ProgressEvent); - }, 0); - }), - onload: null as ((e: ProgressEvent) => void) | null, - onerror: null, - result: JSON.stringify(invalidWorkflow), - }; - - vi.stubGlobal("FileReader", function FileReaderMock(this: typeof mockFileReader) { - Object.assign(this, mockFileReader); - this.readAsText = mockFileReader.readAsText.bind(this); - return this; + it("should call onWorkflowGenerated with workflow and directory path on success", async () => { + const mockWorkflow = { version: 1, nodes: [], edges: [], name: "Test" }; + + mockFetch.mockImplementation((url: string) => { + if (url === "/api/browse-directory") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, path: "/path/to/project" }), + }); + } + if (typeof url === "string" && url.includes("/api/workflow?")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, workflow: mockWorkflow, filename: "Test" }), + }); + } + if (url === "/api/community-workflows") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, workflows: [] }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); - const { container } = render( + render( { /> ); - const file = new File([JSON.stringify(invalidWorkflow)], "test.json", { - type: "application/json", - }); - - const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; - - await act(async () => { - fireEvent.change(fileInput, { target: { files: [file] } }); - }); + fireEvent.click(screen.getByText("Load workflow")); await waitFor(() => { - expect(alertSpy).toHaveBeenCalledWith("Invalid workflow file format"); + expect(mockOnWorkflowGenerated).toHaveBeenCalledWith(mockWorkflow, "/path/to/project"); }); - expect(mockOnWorkflowGenerated).not.toHaveBeenCalled(); - - vi.unstubAllGlobals(); }); - it("should show alert when file parsing fails", async () => { + it("should show alert when no workflow found in directory", async () => { const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); - const mockFileReader = { - readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent) => void) | null; result: string }) { - setTimeout(() => { - this.onload?.({ target: { result: this.result } } as ProgressEvent); - }, 0); - }), - onload: null as ((e: ProgressEvent) => void) | null, - onerror: null, - result: "invalid json", - }; - - vi.stubGlobal("FileReader", function FileReaderMock(this: typeof mockFileReader) { - Object.assign(this, mockFileReader); - this.readAsText = mockFileReader.readAsText.bind(this); - return this; + mockFetch.mockImplementation((url: string) => { + if (url === "/api/browse-directory") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, path: "/empty/dir" }), + }); + } + if (typeof url === "string" && url.includes("/api/workflow?")) { + return Promise.resolve({ + ok: false, + json: () => Promise.resolve({ success: false, error: "No workflow file found in directory" }), + }); + } + if (url === "/api/community-workflows") { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ success: true, workflows: [] }), + }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); }); - const { container } = render( + render( { /> ); - const file = new File(["invalid json"], "test.json", { - type: "application/json", - }); - - const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; - - await act(async () => { - fireEvent.change(fileInput, { target: { files: [file] } }); - }); + fireEvent.click(screen.getByText("Load workflow")); await waitFor(() => { - expect(alertSpy).toHaveBeenCalledWith("Failed to parse workflow file"); + expect(alertSpy).toHaveBeenCalledWith("No workflow file found in directory"); }); - - vi.unstubAllGlobals(); - }); - - it("should not process if no file is selected", () => { - const { container } = render( - - ); - - const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; - - fireEvent.change(fileInput, { target: { files: [] } }); - expect(mockOnWorkflowGenerated).not.toHaveBeenCalled(); }); }); diff --git a/src/components/quickstart/WelcomeModal.tsx b/src/components/quickstart/WelcomeModal.tsx index 47df63c8..09b083fd 100644 --- a/src/components/quickstart/WelcomeModal.tsx +++ b/src/components/quickstart/WelcomeModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback } from "react"; import { WorkflowFile } from "@/store/workflowStore"; import { QuickstartView } from "@/types/quickstart"; import { QuickstartInitialView } from "./QuickstartInitialView"; @@ -8,7 +8,7 @@ import { TemplateExplorerView } from "./TemplateExplorerView"; import { PromptWorkflowView } from "./PromptWorkflowView"; interface WelcomeModalProps { - onWorkflowGenerated: (workflow: WorkflowFile) => void; + onWorkflowGenerated: (workflow: WorkflowFile, directoryPath?: string) => void; onClose: () => void; onNewProject: () => void; } @@ -19,7 +19,6 @@ export function WelcomeModal({ onNewProject, }: WelcomeModalProps) { const [currentView, setCurrentView] = useState("initial"); - const fileInputRef = useRef(null); const handleNewProject = useCallback(() => { onNewProject(); @@ -33,37 +32,35 @@ export function WelcomeModal({ setCurrentView("vibe"); }, []); - const handleSelectLoad = useCallback(() => { - fileInputRef.current?.click(); - }, []); + const handleSelectLoad = useCallback(async () => { + try { + const browseRes = await fetch("/api/browse-directory"); + const browseResult = await browseRes.json(); - const handleFileChange = useCallback( - (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (event) => { - try { - const workflow = JSON.parse( - event.target?.result as string - ) as WorkflowFile; - if (workflow.version && workflow.nodes && workflow.edges) { - onWorkflowGenerated(workflow); - } else { - alert("Invalid workflow file format"); - } - } catch { - alert("Failed to parse workflow file"); + if (!browseResult.success || browseResult.cancelled || !browseResult.path) { + if (!browseResult.success && !browseResult.cancelled) { + alert(browseResult.error || "Failed to open directory picker"); } - }; - reader.readAsText(file); + return; + } - // Reset input so same file can be loaded again - e.target.value = ""; - }, - [onWorkflowGenerated] - ); + const dirPath = browseResult.path; + + const loadRes = await fetch(`/api/workflow?path=${encodeURIComponent(dirPath)}&load=true`); + const loadResult = await loadRes.json(); + + if (!loadResult.success) { + alert(loadResult.error || "No workflow file found in directory"); + return; + } + + const workflow = loadResult.workflow as WorkflowFile; + onWorkflowGenerated(workflow, dirPath); + } catch (error) { + console.error("Failed to open workflow:", error); + alert("Failed to open workflow. Please try again."); + } + }, [onWorkflowGenerated]); const handleBack = useCallback(() => { setCurrentView("initial"); @@ -107,14 +104,6 @@ export function WelcomeModal({ onWorkflowGenerated={handleWorkflowSelected} /> )} - {/* Hidden file input for loading workflows */} - );