Browse Source

feat: use directory picker for load workflow in WelcomeModal

Same approach as Header.tsx — replace browser file picker with native OS
directory picker so loadWorkflow receives the filesystem path for media
hydration from sibling inputs/ and generations/ folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
3306e5319e
  1. 4
      src/components/WorkflowCanvas.tsx
  2. 212
      src/components/__tests__/WelcomeModal.test.tsx
  3. 67
      src/components/quickstart/WelcomeModal.tsx

4
src/components/WorkflowCanvas.tsx

@ -1933,8 +1933,8 @@ export function WorkflowCanvas() {
{/* Welcome Modal */} {/* Welcome Modal */}
{showQuickstart && ( {showQuickstart && (
<WelcomeModal <WelcomeModal
onWorkflowGenerated={async (workflow) => { onWorkflowGenerated={async (workflow, directoryPath) => {
await loadWorkflow(workflow); await loadWorkflow(workflow, directoryPath);
setShowQuickstart(false); setShowQuickstart(false);
}} }}
onClose={() => setShowQuickstart(false)} onClose={() => setShowQuickstart(false)}

212
src/components/__tests__/WelcomeModal.test.tsx

@ -184,24 +184,9 @@ describe("WelcomeModal", () => {
}); });
}); });
describe("File Loading", () => { describe("File Loading via Directory Picker", () => {
it("should render hidden file input for workflow loading", () => { it("should call browse-directory API when 'Load workflow' is clicked", () => {
const { container } = render( render(
<WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose}
onNewProject={mockOnNewProject}
/>
);
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(
<WelcomeModal <WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated} onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose} onClose={mockOnClose}
@ -209,43 +194,29 @@ describe("WelcomeModal", () => {
/> />
); );
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, "click");
fireEvent.click(screen.getByText("Load workflow")); 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 () => { it("should not call onWorkflowGenerated when directory picker is cancelled", async () => {
const validWorkflow: WorkflowFile = { mockFetch.mockImplementation((url: string) => {
id: "test-id", if (url === "/api/browse-directory") {
version: 1, return Promise.resolve({
name: "Test Workflow", ok: true,
edgeStyle: "curved", json: () => Promise.resolve({ success: true, cancelled: true }),
nodes: [], });
edges: [], }
}; if (url === "/api/community-workflows") {
return Promise.resolve({
// Create a mock FileReader class ok: true,
const mockFileReader = { json: () => Promise.resolve({ success: true, workflows: [] }),
readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent<FileReader>) => void) | null; result: string }) { });
setTimeout(() => { }
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>); return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
}, 0);
}),
onload: null as ((e: ProgressEvent<FileReader>) => 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;
}); });
const { container } = render( render(
<WelcomeModal <WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated} onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose} onClose={mockOnClose}
@ -253,46 +224,40 @@ describe("WelcomeModal", () => {
/> />
); );
const file = new File([JSON.stringify(validWorkflow)], "test.json", { fireEvent.click(screen.getByText("Load workflow"));
type: "application/json",
});
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});
await waitFor(() => { await waitFor(() => {
expect(mockOnWorkflowGenerated).toHaveBeenCalledWith(validWorkflow); expect(mockFetch).toHaveBeenCalledWith("/api/browse-directory");
}); });
expect(mockOnWorkflowGenerated).not.toHaveBeenCalled();
vi.unstubAllGlobals();
}); });
it("should show alert for invalid workflow file format", async () => { it("should call onWorkflowGenerated with workflow and directory path on success", async () => {
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {}); const mockWorkflow = { version: 1, nodes: [], edges: [], name: "Test" };
const invalidWorkflow = { foo: "bar" }; // Missing required fields mockFetch.mockImplementation((url: string) => {
if (url === "/api/browse-directory") {
const mockFileReader = { return Promise.resolve({
readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent<FileReader>) => void) | null; result: string }) { ok: true,
setTimeout(() => { json: () => Promise.resolve({ success: true, path: "/path/to/project" }),
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>); });
}, 0); }
}), if (typeof url === "string" && url.includes("/api/workflow?")) {
onload: null as ((e: ProgressEvent<FileReader>) => void) | null, return Promise.resolve({
onerror: null, ok: true,
result: JSON.stringify(invalidWorkflow), json: () => Promise.resolve({ success: true, workflow: mockWorkflow, filename: "Test" }),
}; });
}
vi.stubGlobal("FileReader", function FileReaderMock(this: typeof mockFileReader) { if (url === "/api/community-workflows") {
Object.assign(this, mockFileReader); return Promise.resolve({
this.readAsText = mockFileReader.readAsText.bind(this); ok: true,
return this; json: () => Promise.resolve({ success: true, workflows: [] }),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
}); });
const { container } = render( render(
<WelcomeModal <WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated} onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose} onClose={mockOnClose}
@ -300,45 +265,39 @@ describe("WelcomeModal", () => {
/> />
); );
const file = new File([JSON.stringify(invalidWorkflow)], "test.json", { fireEvent.click(screen.getByText("Load workflow"));
type: "application/json",
});
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});
await waitFor(() => { 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 alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {});
const mockFileReader = { mockFetch.mockImplementation((url: string) => {
readAsText: vi.fn(function (this: { onload: ((e: ProgressEvent<FileReader>) => void) | null; result: string }) { if (url === "/api/browse-directory") {
setTimeout(() => { return Promise.resolve({
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>); ok: true,
}, 0); json: () => Promise.resolve({ success: true, path: "/empty/dir" }),
}), });
onload: null as ((e: ProgressEvent<FileReader>) => void) | null, }
onerror: null, if (typeof url === "string" && url.includes("/api/workflow?")) {
result: "invalid json", return Promise.resolve({
}; ok: false,
json: () => Promise.resolve({ success: false, error: "No workflow file found in directory" }),
vi.stubGlobal("FileReader", function FileReaderMock(this: typeof mockFileReader) { });
Object.assign(this, mockFileReader); }
this.readAsText = mockFileReader.readAsText.bind(this); if (url === "/api/community-workflows") {
return this; 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(
<WelcomeModal <WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated} onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose} onClose={mockOnClose}
@ -346,36 +305,11 @@ describe("WelcomeModal", () => {
/> />
); );
const file = new File(["invalid json"], "test.json", { fireEvent.click(screen.getByText("Load workflow"));
type: "application/json",
});
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});
await waitFor(() => { 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(
<WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose}
onNewProject={mockOnNewProject}
/>
);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, { target: { files: [] } });
expect(mockOnWorkflowGenerated).not.toHaveBeenCalled(); expect(mockOnWorkflowGenerated).not.toHaveBeenCalled();
}); });
}); });

67
src/components/quickstart/WelcomeModal.tsx

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback, useRef } from "react"; import { useState, useCallback } from "react";
import { WorkflowFile } from "@/store/workflowStore"; import { WorkflowFile } from "@/store/workflowStore";
import { QuickstartView } from "@/types/quickstart"; import { QuickstartView } from "@/types/quickstart";
import { QuickstartInitialView } from "./QuickstartInitialView"; import { QuickstartInitialView } from "./QuickstartInitialView";
@ -8,7 +8,7 @@ import { TemplateExplorerView } from "./TemplateExplorerView";
import { PromptWorkflowView } from "./PromptWorkflowView"; import { PromptWorkflowView } from "./PromptWorkflowView";
interface WelcomeModalProps { interface WelcomeModalProps {
onWorkflowGenerated: (workflow: WorkflowFile) => void; onWorkflowGenerated: (workflow: WorkflowFile, directoryPath?: string) => void;
onClose: () => void; onClose: () => void;
onNewProject: () => void; onNewProject: () => void;
} }
@ -19,7 +19,6 @@ export function WelcomeModal({
onNewProject, onNewProject,
}: WelcomeModalProps) { }: WelcomeModalProps) {
const [currentView, setCurrentView] = useState<QuickstartView>("initial"); const [currentView, setCurrentView] = useState<QuickstartView>("initial");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleNewProject = useCallback(() => { const handleNewProject = useCallback(() => {
onNewProject(); onNewProject();
@ -33,37 +32,35 @@ export function WelcomeModal({
setCurrentView("vibe"); setCurrentView("vibe");
}, []); }, []);
const handleSelectLoad = useCallback(() => { const handleSelectLoad = useCallback(async () => {
fileInputRef.current?.click(); try {
}, []); const browseRes = await fetch("/api/browse-directory");
const browseResult = await browseRes.json();
const handleFileChange = useCallback( if (!browseResult.success || browseResult.cancelled || !browseResult.path) {
(e: React.ChangeEvent<HTMLInputElement>) => { if (!browseResult.success && !browseResult.cancelled) {
const file = e.target.files?.[0]; alert(browseResult.error || "Failed to open directory picker");
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");
} }
}; return;
reader.readAsText(file); }
// Reset input so same file can be loaded again const dirPath = browseResult.path;
e.target.value = "";
}, const loadRes = await fetch(`/api/workflow?path=${encodeURIComponent(dirPath)}&load=true`);
[onWorkflowGenerated] 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(() => { const handleBack = useCallback(() => {
setCurrentView("initial"); setCurrentView("initial");
@ -107,14 +104,6 @@ export function WelcomeModal({
onWorkflowGenerated={handleWorkflowSelected} onWorkflowGenerated={handleWorkflowSelected}
/> />
)} )}
{/* Hidden file input for loading workflows */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".json"
className="hidden"
/>
</div> </div>
</div> </div>
); );

Loading…
Cancel
Save