feat(FN-949): add Projects button to Header for single-project users

- Add Projects button in Header that navigates to /projects for single-project users
- Add MultiProjectFlow test suite covering redirect, listing, navigation, and back-to-tasks flows
- Add Header tests verifying Projects button visibility and click behavior
- Add CSS styles for projects page with responsive grid layout and card states
This commit is contained in:
gsxdsm
2026-04-04 19:08:22 -07:00
parent 025b639d92
commit e2e1da03cb
4 changed files with 189 additions and 3 deletions

View File

@@ -797,6 +797,69 @@ describe("Header", () => {
});
});
describe("Projects button", () => {
const singleProject = [
{ id: "1", name: "Test Project", path: "/path/to/project", status: "active" as const },
];
it("renders Projects button when projects exist", () => {
renderHeader({
projects: singleProject,
onViewAllProjects: noop,
}, "desktop");
expect(screen.getByTestId("header-projects-btn")).toBeDefined();
});
it("calls onViewAllProjects when Projects button is clicked", () => {
const onViewAllProjects = vi.fn();
renderHeader({
projects: singleProject,
onViewAllProjects,
}, "desktop");
fireEvent.click(screen.getByTestId("header-projects-btn"));
expect(onViewAllProjects).toHaveBeenCalled();
});
it("does not render Projects button when no projects", () => {
renderHeader({
projects: [],
onViewAllProjects: noop,
}, "desktop");
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
});
it("does not render Projects button on mobile", () => {
renderHeader({
projects: singleProject,
onViewAllProjects: noop,
}, "mobile");
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
});
it("does not render Projects button on tablet", () => {
renderHeader({
projects: singleProject,
onViewAllProjects: noop,
}, "tablet");
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
});
it("does not render Projects button when onViewAllProjects is not provided", () => {
renderHeader({
projects: singleProject,
}, "desktop");
expect(screen.queryByTestId("header-projects-btn")).toBeNull();
});
it("renders Projects button with correct title", () => {
renderHeader({
projects: singleProject,
onViewAllProjects: noop,
}, "desktop");
expect(screen.getByTitle("View all projects")).toBeDefined();
});
});
describe("action ordering", () => {
it("Settings is the last inline user-facing action on desktop (before pause/stop)", () => {
const { container } = renderHeader({

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, Building2, ChevronRight, FileCode, Loader2 } from "lucide-react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target, Building2, ChevronRight, FileCode, Loader2, Grid3X3 } from "lucide-react";
import type { ProjectInfo } from "../api";
import { fetchScripts } from "../api";
import { ProjectSelector } from "./ProjectSelector";
@@ -290,6 +290,19 @@ export function Header({
<span>All Projects</span>
</button>
)}
{/* Projects button - always visible when at least 1 project exists (desktop only) */}
{!isCompact && projects.length >= 1 && onViewAllProjects && (
<button
className="header-projects-btn"
onClick={onViewAllProjects}
title="View all projects"
data-testid="header-projects-btn"
>
<Grid3X3 size={14} />
<span>Projects</span>
</button>
)}
</div>
<div className="header-actions">

View File

@@ -1,6 +1,29 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { ProjectInfo } from "@fusion/core";
import { Header } from "../Header";
// Mock fetchScripts for overflow submenu
vi.mock("../../api", () => ({
fetchScripts: vi.fn().mockResolvedValue({}),
}));
const noop = () => {};
// Helper to mock desktop viewport
function mockDesktopMatchMedia() {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
// Simple smoke tests for multi-project flow
describe("MultiProjectFlow", () => {
@@ -86,4 +109,69 @@ describe("MultiProjectFlow", () => {
expect(storage["kb-dashboard-view-mode"]).toBe("project");
expect(storage["kb-dashboard-task-view"]).toBe("board");
});
describe("Projects button navigation", () => {
const singleProject: ProjectInfo = {
id: "proj_1",
name: "Solo Project",
path: "/path/to/solo",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("shows Projects button and navigates to overview on click", () => {
mockDesktopMatchMedia();
let viewMode: "overview" | "project" = "project";
const handleViewAllProjects = vi.fn(() => {
viewMode = "overview";
});
render(
<Header
onOpenSettings={noop}
onOpenGitHubImport={noop}
globalPaused={false}
enginePaused={false}
onToggleGlobalPause={noop}
onToggleEnginePause={noop}
projects={[singleProject]}
onViewAllProjects={handleViewAllProjects}
/>
);
// The Projects button should be visible for single-project users
const projectsBtn = screen.getByTestId("header-projects-btn");
expect(projectsBtn).toBeDefined();
// Clicking should trigger navigation to overview
fireEvent.click(projectsBtn);
expect(handleViewAllProjects).toHaveBeenCalled();
expect(viewMode).toBe("overview");
});
it("shows Projects button alongside Back button when currentProject is set", () => {
mockDesktopMatchMedia();
render(
<Header
onOpenSettings={noop}
onOpenGitHubImport={noop}
globalPaused={false}
enginePaused={false}
onToggleGlobalPause={noop}
onToggleEnginePause={noop}
projects={[singleProject]}
currentProject={singleProject}
onViewAllProjects={noop}
/>
);
// Both buttons should be present
expect(screen.getByTestId("header-projects-btn")).toBeDefined();
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
});
});
});