Browse Source

fix: save-as behavior and project path handling

handoff-20260429-1057
Marquis Boyd 5 months ago
parent
commit
cf36c3b13f
  1. 45
      src/components/Header.tsx
  2. 2
      src/components/ProjectSetupModal.tsx
  3. 40
      src/components/__tests__/Header.test.tsx
  4. 67
      src/components/__tests__/ProjectSetupModal.test.tsx
  5. 23
      src/store/workflowStore.ts

45
src/components/Header.tsx

@ -67,6 +67,7 @@ export function Header() {
isSaving,
setWorkflowMetadata,
saveToFile,
saveAsFile,
loadWorkflow,
previousWorkflowSnapshot,
revertToSnapshot,
@ -162,6 +163,32 @@ export function Header() {
}
};
const handleSaveAs = async () => {
if (!workflowName || !saveDirectoryPath) {
handleOpenSettings();
return;
}
const value = window.prompt("Save project as", workflowName);
if (value === null) return;
const newName = value.trim();
if (!newName) {
alert("Project name cannot be empty.");
return;
}
try {
const success = await saveAsFile(newName);
if (!success) {
alert("Save As failed. Please try again.");
}
} catch (error) {
console.error("Failed to save project as:", error);
alert("Save As failed. Please try again.");
}
};
const handleRevertAIChanges = useCallback(() => {
const confirmed = window.confirm(
"Are you sure? This will restore your previous workflow."
@ -254,6 +281,24 @@ export function Header() {
<span className="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-red-500 ring-2 ring-neutral-900" />
)}
</button>
{canSave && (
<button
onClick={handleSaveAs}
disabled={isSaving}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors disabled:opacity-50"
title="Save as new workflow"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 3.75h6a3.75 3.75 0 013.75 3.75v6M16.5 3.75L20.25 7.5M16.5 12.75h-9a3.75 3.75 0 00-3.75 3.75v3.75h12v-3.75a3.75 3.75 0 00-3.75-3.75z" />
</svg>
</button>
)}
{saveDirectoryPath && (
<button
onClick={handleOpenDirectory}

2
src/components/ProjectSetupModal.tsx

@ -238,7 +238,7 @@ export function ProjectSetupModal({
}
if (result.path) {
setDirectoryPath(ensureProjectSubfolderPath(result.path, name));
setDirectoryPath(result.path);
}
} catch (err) {
setError(

40
src/components/__tests__/Header.test.tsx

@ -5,6 +5,7 @@ import { Header } from "@/components/Header";
// Mock the workflow store
const mockSetWorkflowMetadata = vi.fn();
const mockSaveToFile = vi.fn();
const mockSaveAsFile = vi.fn();
const mockLoadWorkflow = vi.fn();
const mockUseWorkflowStore = vi.fn();
@ -45,6 +46,7 @@ const createDefaultState = (overrides = {}) => ({
isSaving: false,
setWorkflowMetadata: mockSetWorkflowMetadata,
saveToFile: mockSaveToFile,
saveAsFile: mockSaveAsFile,
loadWorkflow: mockLoadWorkflow,
getNodesWithComments: mockGetNodesWithComments,
getUnviewedCommentCount: mockGetUnviewedCommentCount,
@ -57,6 +59,7 @@ const createDefaultState = (overrides = {}) => ({
describe("Header", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSaveAsFile.mockResolvedValue(true);
// Default mock implementation - unconfigured project
mockGetNodesWithComments.mockReturnValue([]);
mockGetUnviewedCommentCount.mockReturnValue(0);
@ -324,6 +327,43 @@ describe("Header", () => {
});
});
describe("Save As Button", () => {
beforeEach(() => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
workflowName: "My Project",
workflowId: "project-123",
saveDirectoryPath: "/path/to/project",
}));
});
});
it("should render Save As button for configured project", () => {
render(<Header />);
expect(screen.getByTitle("Save as new workflow")).toBeInTheDocument();
});
it("should call saveAsFile with the prompted name", () => {
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Version 2");
render(<Header />);
fireEvent.click(screen.getByTitle("Save as new workflow"));
expect(mockSaveAsFile).toHaveBeenCalledWith("Version 2");
promptSpy.mockRestore();
});
it("should not call saveAsFile when prompt is cancelled", () => {
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue(null);
render(<Header />);
fireEvent.click(screen.getByTitle("Save as new workflow"));
expect(mockSaveAsFile).not.toHaveBeenCalled();
promptSpy.mockRestore();
});
});
describe("Settings Button", () => {
it("should open ProjectSetupModal in 'settings' mode when settings button clicked", () => {
mockUseWorkflowStore.mockImplementation((selector) => {

67
src/components/__tests__/ProjectSetupModal.test.tsx

@ -658,7 +658,7 @@ describe("ProjectSetupModal", () => {
});
});
it("should append project name to selected path in new mode", async () => {
it("should keep selected parent path in input after browse", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/env-status") {
return Promise.resolve({
@ -684,14 +684,11 @@ describe("ProjectSetupModal", () => {
/>
);
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "My Project" },
});
fireEvent.click(screen.getByText("Browse"));
await waitFor(() => {
const directoryInput = screen.getByPlaceholderText("/Users/username/projects/my-project") as HTMLInputElement;
expect(directoryInput.value).toBe("/selected/path/My Project");
expect(directoryInput.value).toBe("/selected/path");
});
});
@ -802,6 +799,66 @@ describe("ProjectSetupModal", () => {
expect(directoryInput.value).toBe("/original/path");
});
});
it("should save using latest project name when renamed after browse", async () => {
mockFetch.mockImplementation((url: string) => {
if (url === "/api/env-status") {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ gemini: false, openai: false, replicate: false, fal: false }),
});
}
if (url === "/api/browse-directory") {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ success: true, path: "/selected/path" }),
});
}
if (url.startsWith("/api/workflow")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ exists: false, isDirectory: false }),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
});
const onSave = vi.fn();
render(
<ProjectSetupModal
isOpen={true}
onClose={vi.fn()}
onSave={onSave}
mode="new"
/>
);
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "Foo" },
});
fireEvent.click(screen.getByText("Browse"));
await waitFor(() => {
const directoryInput = screen.getByPlaceholderText("/Users/username/projects/my-project") as HTMLInputElement;
expect(directoryInput.value).toBe("/selected/path");
});
fireEvent.change(screen.getByPlaceholderText("my-project"), {
target: { value: "Bar" },
});
fireEvent.click(screen.getByText("Create"));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
"mock-workflow-id",
"Bar",
"/selected/path/Bar"
);
});
});
});
describe("Cancel Button", () => {

23
src/store/workflowStore.ts

@ -205,6 +205,7 @@ interface WorkflowStore {
setUseExternalImageStorage: (enabled: boolean) => void;
markAsUnsaved: () => void;
saveToFile: () => Promise<boolean>;
saveAsFile: (name: string) => Promise<boolean>;
initializeAutoSave: () => void;
cleanupAutoSave: () => void;
@ -1685,6 +1686,28 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
}
},
saveAsFile: async (name: string) => {
const trimmedName = name.trim();
if (!trimmedName) {
return false;
}
const { saveDirectoryPath } = get();
if (!saveDirectoryPath) {
return false;
}
// Save As creates another workflow JSON in the same project folder.
const newWorkflowId = generateWorkflowId();
set({
workflowId: newWorkflowId,
workflowName: trimmedName,
hasUnsavedChanges: true,
});
return await get().saveToFile();
},
initializeAutoSave: () => {
if (autoSaveIntervalId) return;

Loading…
Cancel
Save