feat(FN-674): add multi-project dashboard support
- Add Project Overview page with responsive grid and health status cards - Add Project Selector dropdown in header for quick context switching - Add project drill-down navigation with task board/list views - Add Setup Wizard components for first-run project registration - Add global activity feed with project attribution and filtering - Add project-aware task fetching to backend API - Add project health polling and status badges - Update ActivityLogModal with project filter and useActivityLog hook
This commit is contained in:
@@ -6,11 +6,11 @@ import type { ActivityLogEntry } from "@fusion/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchActivityLog: vi.fn(),
|
||||
fetchActivityFeed: vi.fn(),
|
||||
clearActivityLog: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
|
||||
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
|
||||
const mockClearActivityLog = vi.mocked(apiModule.clearActivityLog);
|
||||
|
||||
describe("ActivityLogModal", () => {
|
||||
@@ -53,7 +53,7 @@ describe("ActivityLogModal", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchActivityLog.mockResolvedValue(mockActivityEntries);
|
||||
mockFetchActivityFeed.mockResolvedValue(mockActivityEntries);
|
||||
mockClearActivityLog.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("ActivityLogModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ describe("ActivityLogModal", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "task:created" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:created" })
|
||||
);
|
||||
});
|
||||
@@ -164,19 +164,19 @@ describe("ActivityLogModal", () => {
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByTestId("activity-refresh");
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no entries", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
@@ -193,7 +193,7 @@ describe("ActivityLogModal", () => {
|
||||
});
|
||||
|
||||
it("shows error state when API fails", async () => {
|
||||
mockFetchActivityLog.mockRejectedValue(new Error("API Error"));
|
||||
mockFetchActivityFeed.mockRejectedValue(new Error("API Error"));
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
@@ -250,4 +250,100 @@ describe("ActivityLogModal", () => {
|
||||
// Check that confirmation dialog appears
|
||||
expect(screen.getByText(/Clear Activity Log/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Project Filter Tests ─────────────────────────────────────────
|
||||
|
||||
it("shows project filter when projects provided", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectFilter = await screen.findByTestId("activity-project-filter");
|
||||
expect(projectFilter).toBeTruthy();
|
||||
|
||||
// Should have "All Projects" option
|
||||
expect(screen.getByText("All Projects")).toBeDefined();
|
||||
// Should have project options
|
||||
expect(screen.getByText("Project One")).toBeDefined();
|
||||
expect(screen.getByText("Project Two")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show project filter when no projects provided", async () => {
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("activity-filter")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Project filter should not exist
|
||||
expect(screen.queryByTestId("activity-project-filter")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onProjectFilterChange when project filter changed", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onProjectFilterChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
onProjectFilterChange={onProjectFilterChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectFilter = await screen.findByTestId("activity-project-filter");
|
||||
fireEvent.change(projectFilter, { target: { value: "proj_1" } });
|
||||
|
||||
expect(onProjectFilterChange).toHaveBeenCalledWith("proj_1");
|
||||
});
|
||||
|
||||
it("shows empty state message mentioning filters when filter is active", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("activity-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Change the filter to trigger filtered empty state
|
||||
const projectFilter = screen.getByTestId("activity-project-filter");
|
||||
fireEvent.change(projectFilter, { target: { value: "proj_1" } });
|
||||
|
||||
// Should show filter-specific message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No activity matches the current filters/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -547,4 +547,107 @@ describe("Header", () => {
|
||||
const btn = screen.getByTestId("agents-btn");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Multi-Project Selector ────────────────────────────────────
|
||||
|
||||
it("shows ProjectSelector when 2+ projects provided", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} />);
|
||||
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show ProjectSelector with single project", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const { container } = render(<Header projects={projects} />);
|
||||
expect(container.querySelector(".project-selector")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show ProjectSelector when no projects", () => {
|
||||
const { container } = render(<Header projects={[]} />);
|
||||
expect(container.querySelector(".project-selector")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows 'Back to All Projects' button when currentProject is set", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onViewAllProjects when 'Back to All Projects' clicked", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onViewAllProjects = vi.fn();
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onViewAllProjects={onViewAllProjects}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("back-to-projects-btn"));
|
||||
expect(onViewAllProjects).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show 'Back to All Projects' when no currentProject", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(<Header projects={projects} currentProject={null} />);
|
||||
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onSelectProject when project selected from selector", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
const onSelectProject = vi.fn();
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={onSelectProject}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open selector
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
// Click on a project in the dropdown
|
||||
fireEvent.click(screen.getByText("Project Two"));
|
||||
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
|
||||
});
|
||||
|
||||
it("shows current project name in selector trigger", () => {
|
||||
const projects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
render(
|
||||
<Header
|
||||
projects={projects}
|
||||
currentProject={projects[0]}
|
||||
onSelectProject={vi.fn()}
|
||||
onViewAllProjects={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Project One")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user