Keep dashboard project selection in the URL so refreshing or sharing the page retains the active project. - Hydrate current project state from the existing ?project= query parameter before falling back to cached or global defaults. - Update project selection, setup completion, overview, and unregister flows to preserve or clear the project URL parameter without dropping other URL state. - Cover URL-driven project persistence and query/hash preservation with hook tests. - Document the refresh-safe project URL behavior and add a patch changeset. Files changed: .changeset/fn-7416-project-url-persistence.md | 7 +++ docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 2 +- .../app/hooks/__tests__/useCurrentProject.test.ts | 64 ++++++++++++++++++++++ .../app/hooks/__tests__/useProjectActions.test.ts | 40 +++++++++++++- packages/dashboard/app/hooks/useCurrentProject.ts | 54 ++++++++++++++++-- packages/dashboard/app/hooks/useProjectActions.ts | 5 ++ packages/dashboard/app/utils/projectUrlState.ts | 24 ++++++++ 8 files changed, 190 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7416 Fusion-Task-Lineage: 2d640ae6-629f-4632-9014-986320acfef5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
212 lines
8.1 KiB
TypeScript
212 lines
8.1 KiB
TypeScript
import { act, renderHook } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useProjectActions } from "../useProjectActions";
|
|
import * as api from "../../api";
|
|
import type { ProjectInfo } from "../../api";
|
|
|
|
vi.mock("../../api", () => ({
|
|
pauseProject: vi.fn(),
|
|
resumeProject: vi.fn(),
|
|
updateProject: vi.fn(),
|
|
unregisterProject: vi.fn(),
|
|
}));
|
|
|
|
const mockPauseProject = vi.mocked(api.pauseProject);
|
|
const mockResumeProject = vi.mocked(api.resumeProject);
|
|
const mockUpdateProject = vi.mocked(api.updateProject);
|
|
const mockUnregisterProject = vi.mocked(api.unregisterProject);
|
|
|
|
const PROJECT: ProjectInfo = {
|
|
id: "proj_123",
|
|
name: "Demo",
|
|
path: "/demo",
|
|
status: "active",
|
|
isolationMode: "in-process",
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
};
|
|
|
|
function createOptions(overrides: Partial<Parameters<typeof useProjectActions>[0]> = {}): Parameters<typeof useProjectActions>[0] {
|
|
return {
|
|
setCurrentProject: vi.fn(),
|
|
clearCurrentProject: vi.fn(),
|
|
setViewMode: vi.fn(),
|
|
currentProject: PROJECT,
|
|
refreshProjects: vi.fn().mockResolvedValue(undefined),
|
|
toggleFavoriteProvider: vi.fn().mockResolvedValue(undefined),
|
|
toggleFavoriteModel: vi.fn().mockResolvedValue(undefined),
|
|
addToast: vi.fn(),
|
|
openSettings: vi.fn(),
|
|
openSetupWizard: vi.fn(),
|
|
closeSetupWizard: vi.fn(),
|
|
closeModelOnboarding: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("useProjectActions", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
window.history.replaceState({ preserved: true }, "", "/");
|
|
mockPauseProject.mockResolvedValue(PROJECT);
|
|
mockResumeProject.mockResolvedValue(PROJECT);
|
|
mockUpdateProject.mockResolvedValue(PROJECT);
|
|
mockUnregisterProject.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it("handleSelectProject sets current project, view mode, and URL project state", () => {
|
|
window.history.replaceState({ preserved: "state" }, "", "/?task=FN-1&view=mailbox#message-1");
|
|
const options = createOptions();
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
act(() => {
|
|
result.current.handleSelectProject(PROJECT);
|
|
});
|
|
|
|
expect(options.setCurrentProject).toHaveBeenCalledWith(PROJECT);
|
|
expect(options.setViewMode).toHaveBeenCalledWith("project");
|
|
expect(window.location.pathname).toBe("/");
|
|
expect(window.location.search).toBe("?task=FN-1&view=mailbox&project=proj_123");
|
|
expect(window.location.hash).toBe("#message-1");
|
|
expect(window.history.state).toEqual({ preserved: "state" });
|
|
});
|
|
|
|
it("handleSelectProject URL-encodes project ids", () => {
|
|
const encodedProject: ProjectInfo = { ...PROJECT, id: "blendance/system id" };
|
|
const options = createOptions();
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
act(() => {
|
|
result.current.handleSelectProject(encodedProject);
|
|
});
|
|
|
|
expect(window.location.search).toBe("?project=blendance%2Fsystem+id");
|
|
expect(new URLSearchParams(window.location.search).get("project")).toBe("blendance/system id");
|
|
});
|
|
|
|
it("handleSelectProject writes project ids rather than duplicate display names", () => {
|
|
const duplicateNameProject: ProjectInfo = { ...PROJECT, id: "proj_unique_b", name: "Duplicate" };
|
|
const options = createOptions();
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
act(() => {
|
|
result.current.handleSelectProject(duplicateNameProject);
|
|
});
|
|
|
|
expect(new URLSearchParams(window.location.search).get("project")).toBe("proj_unique_b");
|
|
expect(window.location.search).not.toContain("Duplicate");
|
|
});
|
|
|
|
it("handleViewAllProjects clears current project, sets overview, and removes only URL project state", () => {
|
|
window.history.replaceState({ preserved: "state" }, "", "/?project=proj_123&task=FN-1&room=room-1#thread");
|
|
const options = createOptions();
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
act(() => {
|
|
result.current.handleViewAllProjects();
|
|
});
|
|
|
|
expect(options.clearCurrentProject).toHaveBeenCalledTimes(1);
|
|
expect(options.setViewMode).toHaveBeenCalledWith("overview");
|
|
expect(window.location.search).toBe("?task=FN-1&room=room-1");
|
|
expect(window.location.hash).toBe("#thread");
|
|
expect(window.history.state).toEqual({ preserved: "state" });
|
|
});
|
|
|
|
it("handleSetupComplete closes wizard, sets project, toasts, and refreshes", async () => {
|
|
const options = createOptions();
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
act(() => {
|
|
result.current.handleSetupComplete(PROJECT);
|
|
});
|
|
|
|
expect(options.closeSetupWizard).toHaveBeenCalledTimes(1);
|
|
expect(options.setCurrentProject).toHaveBeenCalledWith(PROJECT);
|
|
expect(options.setViewMode).toHaveBeenCalledWith("project");
|
|
expect(options.addToast).toHaveBeenCalledWith("Project Demo registered successfully", "success");
|
|
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("handlePauseProject calls pauseProject and shows success toast", async () => {
|
|
const options = createOptions();
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
await act(async () => {
|
|
await result.current.handlePauseProject(PROJECT);
|
|
});
|
|
|
|
expect(mockPauseProject).toHaveBeenCalledWith(PROJECT.id);
|
|
expect(options.addToast).toHaveBeenCalledWith("Project Demo paused", "success");
|
|
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("handleResumeProject calls resumeProject and shows success toast", async () => {
|
|
const options = createOptions();
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
await act(async () => {
|
|
await result.current.handleResumeProject(PROJECT);
|
|
});
|
|
|
|
expect(mockResumeProject).toHaveBeenCalledWith(PROJECT.id);
|
|
expect(options.addToast).toHaveBeenCalledWith("Project Demo resumed", "success");
|
|
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("handleRemoveProject unregisters, clears current selection, toasts, and refreshes", async () => {
|
|
const options = createOptions({ currentProject: PROJECT });
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
await act(async () => {
|
|
await result.current.handleRemoveProject(PROJECT);
|
|
});
|
|
|
|
expect(mockUnregisterProject).toHaveBeenCalledWith(PROJECT.id);
|
|
expect(options.addToast).toHaveBeenCalledWith("Project Demo removed", "success");
|
|
expect(options.clearCurrentProject).toHaveBeenCalledTimes(1);
|
|
expect(options.setViewMode).toHaveBeenCalledWith("overview");
|
|
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("handleRemoveProject shows error toast on API failure", async () => {
|
|
mockUnregisterProject.mockRejectedValueOnce(new Error("boom"));
|
|
const options = createOptions();
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
await act(async () => {
|
|
await result.current.handleRemoveProject(PROJECT);
|
|
});
|
|
|
|
expect(options.addToast).toHaveBeenCalledWith("Failed to remove project Demo", "error");
|
|
});
|
|
|
|
it("handleToggleFavorite delegates and shows error toast on failure", async () => {
|
|
const options = createOptions({
|
|
toggleFavoriteProvider: vi.fn().mockRejectedValue(new Error("boom")),
|
|
});
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
await act(async () => {
|
|
await result.current.handleToggleFavorite("anthropic");
|
|
});
|
|
|
|
expect(options.toggleFavoriteProvider).toHaveBeenCalledWith("anthropic");
|
|
expect(options.addToast).toHaveBeenCalledWith("Failed to update favorites", "error");
|
|
});
|
|
|
|
it("handleToggleModelFavorite delegates and shows error toast on failure", async () => {
|
|
const options = createOptions({
|
|
toggleFavoriteModel: vi.fn().mockRejectedValue(new Error("boom")),
|
|
});
|
|
const { result } = renderHook(() => useProjectActions(options));
|
|
|
|
await act(async () => {
|
|
await result.current.handleToggleModelFavorite("claude-sonnet-4-5");
|
|
});
|
|
|
|
expect(options.toggleFavoriteModel).toHaveBeenCalledWith("claude-sonnet-4-5");
|
|
expect(options.addToast).toHaveBeenCalledWith("Failed to update model favorites", "error");
|
|
});
|
|
});
|