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:
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user