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();
});
});
});

View File

@@ -5183,7 +5183,8 @@ body {
/* Hide project selector and back button on mobile (belt-and-suspenders with conditional rendering) */
.header-project-selector,
.header-back-button {
.header-back-button,
.header-projects-btn {
display: none;
}
@@ -5348,7 +5349,8 @@ body {
/* Hide project selector and back button on tablet (controlled by React rendering too) */
.header-project-selector,
.header-back-button {
.header-back-button,
.header-projects-btn {
display: none;
}
@@ -15277,6 +15279,26 @@ html .column.drag-over * {
color: var(--text);
}
.header-projects-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.header-projects-btn:hover {
background: var(--card-hover);
border-color: var(--text-dim);
color: var(--text);
}
.project-selector {
position: relative;
}