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 */}
{showQuickstart && (
<WelcomeModal
onWorkflowGenerated={async (workflow) => {
await loadWorkflow(workflow);
onWorkflowGenerated={async (workflow, directoryPath) => {
await loadWorkflow(workflow, directoryPath);
setShowQuickstart(false);
}}
onClose={() => setShowQuickstart(false)}

212
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(
<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(
describe("File Loading via Directory Picker", () => {
it("should call browse-directory API when 'Load workflow' is clicked", () => {
render(
<WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated}
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"));
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<FileReader>) => void) | null; result: string }) {
setTimeout(() => {
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>);
}, 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;
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(
<WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose}
@ -253,46 +224,40 @@ describe("WelcomeModal", () => {
/>
);
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<FileReader>) => void) | null; result: string }) {
setTimeout(() => {
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>);
}, 0);
}),
onload: null as ((e: ProgressEvent<FileReader>) => 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(
<WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose}
@ -300,45 +265,39 @@ describe("WelcomeModal", () => {
/>
);
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<FileReader>) => void) | null; result: string }) {
setTimeout(() => {
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>);
}, 0);
}),
onload: null as ((e: ProgressEvent<FileReader>) => 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(
<WelcomeModal
onWorkflowGenerated={mockOnWorkflowGenerated}
onClose={mockOnClose}
@ -346,36 +305,11 @@ describe("WelcomeModal", () => {
/>
);
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(
<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();
});
});

67
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<QuickstartView>("initial");
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".json"
className="hidden"
/>
</div>
</div>
);

Loading…
Cancel
Save