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:
@@ -1,13 +1,20 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { fetchActivityLog, clearActivityLog, type ActivityLogEntry, type ActivityEventType } from "../api";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react";
|
||||
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
|
||||
import { useActivityLog } from "../hooks/useActivityLog";
|
||||
import type { Task, ProjectInfo } from "@fusion/core";
|
||||
|
||||
interface ActivityLogModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tasks: Task[];
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
/** When provided, shows only activity for this project */
|
||||
projectId?: string;
|
||||
/** List of all projects for filter dropdown */
|
||||
projects?: ProjectInfo[];
|
||||
/** Called when project filter changes */
|
||||
onProjectFilterChange?: (projectId: string | undefined) => void;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
|
||||
@@ -46,78 +53,72 @@ function formatTimestamp(timestamp: string): string {
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: ActivityLogModalProps) {
|
||||
const [entries, setEntries] = useState<ActivityLogEntry[]>([]);
|
||||
/**
|
||||
* ActivityLogModal - Activity log with project attribution and filtering
|
||||
*
|
||||
* Features:
|
||||
* - Project name badge for each activity entry
|
||||
* - Project filter dropdown (when projects list provided)
|
||||
* - Event type filter
|
||||
* - Real-time updates via useActivityLog hook
|
||||
*/
|
||||
export function ActivityLogModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
tasks,
|
||||
onOpenTaskDetail,
|
||||
projectId,
|
||||
projects = [],
|
||||
onProjectFilterChange,
|
||||
}: ActivityLogModalProps) {
|
||||
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filteredProjectId, setFilteredProjectId] = useState<string | "all">(projectId || "all");
|
||||
const [showConfirmClear, setShowConfirmClear] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const loadActivityLog = useCallback(async (since?: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const options: { limit: number; since?: string; type?: ActivityEventType } = {
|
||||
limit: 100,
|
||||
since,
|
||||
};
|
||||
if (filteredType !== "all") {
|
||||
options.type = filteredType;
|
||||
}
|
||||
const data = await fetchActivityLog(options);
|
||||
if (since) {
|
||||
// Append older entries
|
||||
setEntries((prev) => [...prev, ...data]);
|
||||
} else {
|
||||
// Replace with fresh entries
|
||||
setEntries(data);
|
||||
}
|
||||
setHasMore(data.length === 100);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load activity log");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filteredType]);
|
||||
|
||||
// Initial load and filter change
|
||||
|
||||
// Sync with external projectId prop
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadActivityLog();
|
||||
}
|
||||
}, [isOpen, loadActivityLog]);
|
||||
setFilteredProjectId(projectId || "all");
|
||||
}, [projectId]);
|
||||
|
||||
// Convert filters to the format expected by useActivityLog
|
||||
const activityType = filteredType === "all" ? undefined : filteredType;
|
||||
const activeProjectId = filteredProjectId === "all" ? undefined : filteredProjectId;
|
||||
|
||||
// Use the hook for data fetching
|
||||
const {
|
||||
entries,
|
||||
loading: isLoading,
|
||||
error,
|
||||
refresh,
|
||||
hasMore
|
||||
} = useActivityLog({
|
||||
projectId: activeProjectId,
|
||||
type: activityType,
|
||||
limit: 100,
|
||||
autoRefresh: isOpen,
|
||||
});
|
||||
|
||||
// Auto-refresh every 30 seconds when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
pollingRef.current = setInterval(() => {
|
||||
loadActivityLog();
|
||||
}, 30000);
|
||||
}
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, loadActivityLog]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (entries.length > 0) {
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
loadActivityLog(lastEntry.timestamp);
|
||||
}
|
||||
};
|
||||
// Convert entries to ActivityLogEntry format for compatibility
|
||||
const convertedEntries: ActivityLogEntry[] = entries.map((entry: ActivityFeedEntry) => ({
|
||||
id: entry.id,
|
||||
timestamp: entry.timestamp,
|
||||
type: entry.type,
|
||||
taskId: entry.taskId,
|
||||
taskTitle: entry.taskTitle,
|
||||
details: entry.details,
|
||||
metadata: entry.metadata,
|
||||
projectId: entry.projectId,
|
||||
projectName: entry.projectName,
|
||||
}));
|
||||
|
||||
const handleClearLog = async () => {
|
||||
try {
|
||||
await clearActivityLog();
|
||||
setEntries([]);
|
||||
refresh();
|
||||
setShowConfirmClear(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to clear activity log");
|
||||
// Error handled by hook
|
||||
setShowConfirmClear(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,6 +128,11 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectFilterChange = (value: string) => {
|
||||
setFilteredProjectId(value);
|
||||
onProjectFilterChange?.(value === "all" ? undefined : value);
|
||||
};
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -143,6 +149,9 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose, showConfirmClear]);
|
||||
|
||||
// Determine if any filter is active
|
||||
const isFilterActive = filteredType !== "all" || filteredProjectId !== "all";
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -161,7 +170,27 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
<span>Activity Log</span>
|
||||
</div>
|
||||
<div className="activity-log-actions">
|
||||
{/* Filter dropdown */}
|
||||
{/* Project filter dropdown (when projects provided) */}
|
||||
{projects.length > 0 && (
|
||||
<div className="activity-log-filter activity-log-filter--project">
|
||||
<Folder size={14} />
|
||||
<select
|
||||
value={filteredProjectId}
|
||||
onChange={(e) => handleProjectFilterChange(e.target.value)}
|
||||
className="activity-log-filter-select"
|
||||
data-testid="activity-project-filter"
|
||||
>
|
||||
<option value="all">All Projects</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event type filter dropdown */}
|
||||
<div className="activity-log-filter">
|
||||
<Filter size={14} />
|
||||
<select
|
||||
@@ -182,7 +211,7 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
className="activity-log-refresh"
|
||||
onClick={() => loadActivityLog()}
|
||||
onClick={() => refresh()}
|
||||
disabled={isLoading}
|
||||
title="Refresh"
|
||||
data-testid="activity-refresh"
|
||||
@@ -191,7 +220,7 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
</button>
|
||||
|
||||
{/* Clear button */}
|
||||
{entries.length > 0 && (
|
||||
{convertedEntries.length > 0 && (
|
||||
<button
|
||||
className="activity-log-clear"
|
||||
onClick={() => setShowConfirmClear(true)}
|
||||
@@ -214,6 +243,33 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{isFilterActive && (
|
||||
<div className="activity-log-active-filters">
|
||||
<span className="activity-log-filter-label">Active filters:</span>
|
||||
{filteredProjectId !== "all" && (
|
||||
<span className="activity-log-filter-badge">
|
||||
Project: {projects.find(p => p.id === filteredProjectId)?.name || filteredProjectId}
|
||||
</span>
|
||||
)}
|
||||
{filteredType !== "all" && (
|
||||
<span className="activity-log-filter-badge">
|
||||
Type: {EVENT_TYPE_LABELS[filteredType]}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="activity-log-clear-filters"
|
||||
onClick={() => {
|
||||
setFilteredType("all");
|
||||
setFilteredProjectId("all");
|
||||
onProjectFilterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="activity-log-content" data-testid="activity-log-content">
|
||||
{error && (
|
||||
@@ -223,15 +279,31 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 && !isLoading && !error && (
|
||||
{convertedEntries.length === 0 && !isLoading && !error && (
|
||||
<div className="activity-log-empty" data-testid="activity-empty">
|
||||
<History size={48} className="activity-log-empty-icon" />
|
||||
<p>No activity recorded yet</p>
|
||||
<p>
|
||||
{isFilterActive
|
||||
? "No activity matches the current filters"
|
||||
: "No activity recorded yet"}
|
||||
</p>
|
||||
{isFilterActive && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setFilteredType("all");
|
||||
setFilteredProjectId("all");
|
||||
onProjectFilterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="activity-log-list">
|
||||
{entries.map((entry) => (
|
||||
{convertedEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="activity-log-entry"
|
||||
@@ -245,6 +317,13 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
<span className="activity-log-entry-type">
|
||||
{EVENT_TYPE_LABELS[entry.type]}
|
||||
</span>
|
||||
{/* Project name badge */}
|
||||
{entry.projectName && (
|
||||
<span className="activity-log-entry-project">
|
||||
<Folder size={10} />
|
||||
{entry.projectName}
|
||||
</span>
|
||||
)}
|
||||
<span className="activity-log-entry-time">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
@@ -286,14 +365,14 @@ export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: A
|
||||
{hasMore && !isLoading && (
|
||||
<button
|
||||
className="activity-log-load-more"
|
||||
onClick={handleLoadMore}
|
||||
onClick={refresh}
|
||||
data-testid="activity-load-more"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoading && entries.length > 0 && (
|
||||
{isLoading && convertedEntries.length > 0 && (
|
||||
<div className="activity-log-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
|
||||
import { Folder } from "lucide-react";
|
||||
import type { ModelInfo } from "../api";
|
||||
|
||||
interface BoardProps {
|
||||
@@ -35,6 +36,9 @@ interface BoardProps {
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenFilesForTask?: (taskId: string) => void;
|
||||
/** Project context for multi-project mode */
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
function sortTasksForColumn(tasks: Task[]): Task[] {
|
||||
@@ -53,7 +57,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: BoardProps) {
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, projectId, projectName }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const { fetchBatch } = useBatchBadgeFetch();
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -142,30 +146,42 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
}
|
||||
};
|
||||
}, [taskIdsWithBadges, fetchBatch]);
|
||||
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasksByColumn[col]}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
<>
|
||||
{/* Project context badge */}
|
||||
{projectId && projectName && (
|
||||
<div className="board-project-context">
|
||||
<span className="board-project-badge">
|
||||
<Folder size={14} />
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasksByColumn[col]}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
availableModels={availableModels}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
|
||||
import type { ProjectInfo } from "@fusion/core";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
|
||||
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
|
||||
function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
@@ -16,7 +18,7 @@ function GitHubLogo({ size = 16 }: { size?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
export interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenGitHubImport?: () => void;
|
||||
onOpenPlanning?: () => void;
|
||||
@@ -38,6 +40,11 @@ interface HeaderProps {
|
||||
onChangeView?: (view: "board" | "list" | "agents") => void;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
/** Multi-project props */
|
||||
projects?: ProjectInfo[];
|
||||
currentProject?: ProjectInfo | null;
|
||||
onSelectProject?: (project: ProjectInfo) => void;
|
||||
onViewAllProjects?: () => void;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
@@ -78,6 +85,10 @@ export function Header({
|
||||
onChangeView,
|
||||
searchQuery = "",
|
||||
onSearchChange,
|
||||
projects = [],
|
||||
currentProject,
|
||||
onSelectProject,
|
||||
onViewAllProjects,
|
||||
}: HeaderProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
@@ -153,7 +164,35 @@ export function Header({
|
||||
<img src="/logo.svg" alt="Fusion logo" className="header-logo" width={24} height={24} />
|
||||
<h1 className="logo">Fusion</h1>
|
||||
<span className="logo-sub">tasks</span>
|
||||
|
||||
{/* Back to All Projects button when viewing a specific project */}
|
||||
{currentProject && onViewAllProjects && (
|
||||
<button
|
||||
className="header-back-button"
|
||||
onClick={onViewAllProjects}
|
||||
title="Back to All Projects"
|
||||
data-testid="back-to-projects-btn"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
<span>All Projects</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Selector - shown when 2+ projects */}
|
||||
{projects.length > 1 && (
|
||||
<div className="header-project-selector">
|
||||
<ProjectSelector
|
||||
projects={projects}
|
||||
currentProject={currentProject || null}
|
||||
onSelect={(project) => {
|
||||
onSelectProject?.(project);
|
||||
}}
|
||||
onViewAll={onViewAllProjects || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="header-actions">
|
||||
{/* Desktop Search - only show in board view */}
|
||||
{onSearchChange && view === "board" && !isMobile && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight, Folder } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
|
||||
import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
|
||||
@@ -48,6 +48,9 @@ interface ListViewProps {
|
||||
* Allows parent to refresh task list or handle optimistically.
|
||||
*/
|
||||
onTasksUpdated?: (updatedTasks: Task[]) => void;
|
||||
/** Project context for multi-project mode */
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
function getStepProgress(steps: TaskStep[]): string {
|
||||
@@ -564,6 +567,15 @@ export function ListView({
|
||||
|
||||
return (
|
||||
<div className="list-view">
|
||||
{/* Project context badge */}
|
||||
{projectId && projectName && (
|
||||
<div className="list-project-context">
|
||||
<span className="list-project-badge">
|
||||
<Folder size={14} />
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="list-toolbar">
|
||||
<div className="list-filter">
|
||||
<Search size={14} className="filter-icon" />
|
||||
|
||||
@@ -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