Merge branch 'develop' into feature/15-gantt-chart

This commit is contained in:
2026-01-30 01:41:23 +00:00
80 changed files with 11271 additions and 4 deletions

View File

@@ -0,0 +1,80 @@
"use client";
import { useState, useEffect } from "react";
import type { Domain } from "@mosaic/shared";
import { DomainList } from "@/components/domains/DomainList";
import { fetchDomains, createDomain, updateDomain, deleteDomain } from "@/lib/api/domains";
export default function DomainsPage(): JSX.Element {
const [domains, setDomains] = useState<Domain[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadDomains();
}, []);
async function loadDomains(): Promise<void> {
try {
setIsLoading(true);
const response = await fetchDomains();
setDomains(response.data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load domains");
} finally {
setIsLoading(false);
}
}
function handleEdit(domain: Domain): void {
// TODO: Open edit modal/form
console.log("Edit domain:", domain);
}
async function handleDelete(domain: Domain): Promise<void> {
if (!confirm(`Are you sure you want to delete "${domain.name}"?`)) {
return;
}
try {
await deleteDomain(domain.id);
await loadDomains();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete domain");
}
}
return (
<div className="max-w-6xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Domains</h1>
<p className="text-gray-600">
Organize your tasks and projects by life areas
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded text-red-700">
{error}
</div>
)}
<div className="mb-6">
<button
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
onClick={() => console.log("TODO: Open create modal")}
>
Create Domain
</button>
</div>
<DomainList
domains={domains}
isLoading={isLoading}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
);
}

View File

@@ -0,0 +1,263 @@
"use client";
import { useState, useEffect } from "react";
import type { Personality } from "@mosaic/shared";
import { PersonalityPreview } from "@/components/personalities/PersonalityPreview";
import { PersonalityForm, PersonalityFormData } from "@/components/personalities/PersonalityForm";
import {
fetchPersonalities,
createPersonality,
updatePersonality,
deletePersonality,
} from "@/lib/api/personalities";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Pencil, Trash2, Eye } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function PersonalitiesPage(): JSX.Element {
const [personalities, setPersonalities] = useState<Personality[]>([]);
const [selectedPersonality, setSelectedPersonality] = useState<Personality | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<"list" | "create" | "edit" | "preview">("list");
const [deleteTarget, setDeleteTarget] = useState<Personality | null>(null);
useEffect(() => {
loadPersonalities();
}, []);
async function loadPersonalities(): Promise<void> {
try {
setIsLoading(true);
const response = await fetchPersonalities();
setPersonalities(response.data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load personalities");
} finally {
setIsLoading(false);
}
}
async function handleCreate(data: PersonalityFormData): Promise<void> {
try {
await createPersonality(data);
await loadPersonalities();
setMode("list");
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create personality");
throw err;
}
}
async function handleUpdate(data: PersonalityFormData): Promise<void> {
if (!selectedPersonality) return;
try {
await updatePersonality(selectedPersonality.id, data);
await loadPersonalities();
setMode("list");
setSelectedPersonality(null);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update personality");
throw err;
}
}
async function confirmDelete(): Promise<void> {
if (!deleteTarget) return;
try {
await deletePersonality(deleteTarget.id);
await loadPersonalities();
setDeleteTarget(null);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete personality");
}
}
if (mode === "create") {
return (
<div className="max-w-4xl mx-auto p-6">
<PersonalityForm
onSubmit={handleCreate}
onCancel={() => setMode("list")}
/>
</div>
);
}
if (mode === "edit" && selectedPersonality) {
return (
<div className="max-w-4xl mx-auto p-6">
<PersonalityForm
personality={selectedPersonality}
onSubmit={handleUpdate}
onCancel={() => {
setMode("list");
setSelectedPersonality(null);
}}
/>
</div>
);
}
if (mode === "preview" && selectedPersonality) {
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-4">
<Button
variant="outline"
onClick={() => {
setMode("list");
setSelectedPersonality(null);
}}
>
Back to List
</Button>
</div>
<PersonalityPreview personality={selectedPersonality} />
</div>
);
}
return (
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">AI Personalities</h1>
<p className="text-muted-foreground mt-1">
Customize how the AI assistant communicates and responds
</p>
</div>
<Button onClick={() => setMode("create")}>
<Plus className="mr-2 h-4 w-4" />
New Personality
</Button>
</div>
</div>
{/* Error Display */}
{error && (
<div className="mb-4 p-4 bg-destructive/10 text-destructive rounded-md">
{error}
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="text-center py-12">
<p className="text-muted-foreground">Loading personalities...</p>
</div>
) : personalities.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground mb-4">No personalities found</p>
<Button onClick={() => setMode("create")}>
<Plus className="mr-2 h-4 w-4" />
Create First Personality
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{personalities.map((personality) => (
<Card key={personality.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2">
{personality.name}
{personality.isDefault && (
<Badge variant="secondary">Default</Badge>
)}
{!personality.isActive && (
<Badge variant="outline">Inactive</Badge>
)}
</CardTitle>
<CardDescription>{personality.description}</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedPersonality(personality);
setMode("preview");
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedPersonality(personality);
setMode("edit");
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(personality)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-4 text-sm">
<div>
<span className="text-muted-foreground">Tone:</span>
<Badge variant="outline" className="ml-2">
{personality.tone}
</Badge>
</div>
<div>
<span className="text-muted-foreground">Formality:</span>
<Badge variant="outline" className="ml-2">
{personality.formalityLevel.replace(/_/g, " ")}
</Badge>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Personality</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{deleteTarget?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
import { useState } from "react";
import { KanbanBoard } from "@/components/kanban";
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
const initialTasks: Task[] = [
{
id: "task-1",
title: "Design homepage wireframes",
description: "Create wireframes for the new homepage design",
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-01"),
assigneeId: "user-1",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-2",
title: "Implement authentication flow",
description: "Add OAuth support with Google and GitHub",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-01-30"),
assigneeId: "user-2",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-3",
title: "Write comprehensive unit tests",
description: "Achieve 85% test coverage for all components",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-05"),
assigneeId: "user-3",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 1,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-4",
title: "Research state management libraries",
description: "Evaluate Zustand vs Redux Toolkit",
status: TaskStatus.PAUSED,
priority: TaskPriority.LOW,
dueDate: new Date("2026-02-10"),
assigneeId: "user-1",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-5",
title: "Deploy to production",
description: "Set up CI/CD pipeline with GitHub Actions",
status: TaskStatus.COMPLETED,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-01-25"),
assigneeId: "user-1",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: new Date("2026-01-25"),
createdAt: new Date("2026-01-20"),
updatedAt: new Date("2026-01-25"),
},
{
id: "task-6",
title: "Update API documentation",
description: "Document all REST endpoints with OpenAPI",
status: TaskStatus.COMPLETED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-01-27"),
assigneeId: "user-2",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 1,
metadata: {},
completedAt: new Date("2026-01-27"),
createdAt: new Date("2026-01-25"),
updatedAt: new Date("2026-01-27"),
},
{
id: "task-7",
title: "Setup database migrations",
description: "Configure Prisma migrations for production",
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-03"),
assigneeId: "user-3",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 1,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-8",
title: "Performance optimization",
description: "Improve page load time by 30%",
status: TaskStatus.PAUSED,
priority: TaskPriority.LOW,
dueDate: null,
assigneeId: "user-2",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 1,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
export default function KanbanDemoPage() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const handleStatusChange = (taskId: string, newStatus: TaskStatus) => {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === taskId
? {
...task,
status: newStatus,
updatedAt: new Date(),
completedAt:
newStatus === TaskStatus.COMPLETED ? new Date() : null,
}
: task
)
);
};
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-950 p-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm border border-gray-200 dark:border-gray-800 p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Kanban Board Demo
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Drag and drop tasks between columns to update their status.
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-500">
{tasks.length} total tasks {tasks.filter((t) => t.status === TaskStatus.COMPLETED).length} completed
</p>
</div>
{/* Kanban Board */}
<KanbanBoard tasks={tasks} onStatusChange={handleStatusChange} />
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DomainFilter } from "./DomainFilter";
import type { Domain } from "@mosaic/shared";
describe("DomainFilter", () => {
const mockDomains: Domain[] = [
{
id: "domain-1",
workspaceId: "workspace-1",
name: "Work",
slug: "work",
description: "Work-related tasks",
color: "#3B82F6",
icon: "💼",
sortOrder: 0,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "domain-2",
workspaceId: "workspace-1",
name: "Personal",
slug: "personal",
description: null,
color: "#10B981",
icon: "🏠",
sortOrder: 1,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
it("should render All button", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
expect(screen.getByRole("button", { name: /all/i })).toBeInTheDocument();
});
it("should render domain filter buttons", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
expect(screen.getByRole("button", { name: /filter by work/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /filter by personal/i })).toBeInTheDocument();
});
it("should highlight All when no domain selected", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
const allButton = screen.getByRole("button", { name: /all/i });
expect(allButton.getAttribute("aria-pressed")).toBe("true");
});
it("should highlight selected domain", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain="domain-1"
onFilterChange={onFilterChange}
/>
);
const workButton = screen.getByRole("button", { name: /filter by work/i });
expect(workButton.getAttribute("aria-pressed")).toBe("true");
});
it("should call onFilterChange when All clicked", async () => {
const user = userEvent.setup();
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain="domain-1"
onFilterChange={onFilterChange}
/>
);
const allButton = screen.getByRole("button", { name: /all/i });
await user.click(allButton);
expect(onFilterChange).toHaveBeenCalledWith(null);
});
it("should call onFilterChange when domain clicked", async () => {
const user = userEvent.setup();
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
const workButton = screen.getByRole("button", { name: /filter by work/i });
await user.click(workButton);
expect(onFilterChange).toHaveBeenCalledWith("domain-1");
});
it("should display domain icons", () => {
const onFilterChange = vi.fn();
render(
<DomainFilter
domains={mockDomains}
selectedDomain={null}
onFilterChange={onFilterChange}
/>
);
expect(screen.getByText("💼")).toBeInTheDocument();
expect(screen.getByText("🏠")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,52 @@
"use client";
import type { Domain } from "@mosaic/shared";
interface DomainFilterProps {
domains: Domain[];
selectedDomain: string | null;
onFilterChange: (domainId: string | null) => void;
}
export function DomainFilter({
domains,
selectedDomain,
onFilterChange,
}: DomainFilterProps): JSX.Element {
return (
<div className="flex gap-2 flex-wrap">
<button
onClick={() => onFilterChange(null)}
className={`px-3 py-1 rounded-full text-sm ${
selectedDomain === null
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
aria-label="Show all domains"
aria-pressed={selectedDomain === null}
>
All
</button>
{domains.map((domain) => (
<button
key={domain.id}
onClick={() => onFilterChange(domain.id)}
className={`px-3 py-1 rounded-full text-sm flex items-center gap-1 ${
selectedDomain === domain.id
? "text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
style={{
backgroundColor:
selectedDomain === domain.id ? domain.color || "#374151" : undefined,
}}
aria-label={`Filter by ${domain.name}`}
aria-pressed={selectedDomain === domain.id}
>
{domain.icon && <span>{domain.icon}</span>}
<span>{domain.name}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import type { Domain } from "@mosaic/shared";
interface DomainItemProps {
domain: Domain;
onEdit?: (domain: Domain) => void;
onDelete?: (domain: Domain) => void;
}
export function DomainItem({
domain,
onEdit,
onDelete,
}: DomainItemProps): JSX.Element {
return (
<div className="border rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{domain.icon && <span className="text-2xl">{domain.icon}</span>}
{domain.color && (
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: domain.color }}
/>
)}
<h3 className="font-semibold text-lg">{domain.name}</h3>
</div>
{domain.description && (
<p className="text-sm text-gray-600">{domain.description}</p>
)}
<div className="mt-2">
<span className="text-xs text-gray-500 font-mono">
{domain.slug}
</span>
</div>
</div>
<div className="flex gap-2 ml-4">
{onEdit && (
<button
onClick={() => onEdit(domain)}
className="text-sm px-3 py-1 border rounded hover:bg-gray-50"
aria-label={`Edit ${domain.name}`}
>
Edit
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(domain)}
className="text-sm px-3 py-1 border border-red-300 text-red-600 rounded hover:bg-red-50"
aria-label={`Delete ${domain.name}`}
>
Delete
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { DomainList } from "./DomainList";
import type { Domain } from "@mosaic/shared";
describe("DomainList", () => {
const mockDomains: Domain[] = [
{
id: "domain-1",
workspaceId: "workspace-1",
name: "Work",
slug: "work",
description: "Work-related tasks",
color: "#3B82F6",
icon: "💼",
sortOrder: 0,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "domain-2",
workspaceId: "workspace-1",
name: "Personal",
slug: "personal",
description: "Personal tasks and projects",
color: "#10B981",
icon: "🏠",
sortOrder: 1,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
it("should render empty state when no domains", () => {
render(<DomainList domains={[]} isLoading={false} />);
expect(screen.getByText(/no domains created yet/i)).toBeInTheDocument();
});
it("should render loading state", () => {
render(<DomainList domains={[]} isLoading={true} />);
expect(screen.getByText(/loading domains/i)).toBeInTheDocument();
});
it("should render domains list", () => {
render(<DomainList domains={mockDomains} isLoading={false} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
it("should call onEdit when edit button clicked", () => {
const onEdit = vi.fn();
render(
<DomainList
domains={mockDomains}
isLoading={false}
onEdit={onEdit}
/>
);
const editButtons = screen.getAllByRole("button", { name: /edit/i });
editButtons[0].click();
expect(onEdit).toHaveBeenCalledWith(mockDomains[0]);
});
it("should call onDelete when delete button clicked", () => {
const onDelete = vi.fn();
render(
<DomainList
domains={mockDomains}
isLoading={false}
onDelete={onDelete}
/>
);
const deleteButtons = screen.getAllByRole("button", { name: /delete/i });
deleteButtons[0].click();
expect(onDelete).toHaveBeenCalledWith(mockDomains[0]);
});
it("should handle undefined domains gracefully", () => {
// @ts-expect-error Testing error state
render(<DomainList domains={undefined} isLoading={false} />);
expect(screen.getByText(/no domains created yet/i)).toBeInTheDocument();
});
it("should handle null domains gracefully", () => {
// @ts-expect-error Testing error state
render(<DomainList domains={null} isLoading={false} />);
expect(screen.getByText(/no domains created yet/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,51 @@
"use client";
import type { Domain } from "@mosaic/shared";
import { DomainItem } from "./DomainItem";
interface DomainListProps {
domains: Domain[];
isLoading: boolean;
onEdit?: (domain: Domain) => void;
onDelete?: (domain: Domain) => void;
}
export function DomainList({
domains,
isLoading,
onEdit,
onDelete,
}: DomainListProps): JSX.Element {
if (isLoading) {
return (
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading domains...</span>
</div>
);
}
if (!domains || domains.length === 0) {
return (
<div className="text-center p-8 text-gray-500">
<p className="text-lg">No domains created yet</p>
<p className="text-sm mt-2">
Create domains to organize your tasks and projects
</p>
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{domains.map((domain) => (
<DomainItem
key={domain.id}
domain={domain}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DomainSelector } from "./DomainSelector";
import type { Domain } from "@mosaic/shared";
describe("DomainSelector", () => {
const mockDomains: Domain[] = [
{
id: "domain-1",
workspaceId: "workspace-1",
name: "Work",
slug: "work",
description: "Work-related tasks",
color: "#3B82F6",
icon: "💼",
sortOrder: 0,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "domain-2",
workspaceId: "workspace-1",
name: "Personal",
slug: "personal",
description: null,
color: "#10B981",
icon: null,
sortOrder: 1,
metadata: {},
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
it("should render with default placeholder", () => {
const onChange = vi.fn();
render(
<DomainSelector domains={mockDomains} value={null} onChange={onChange} />
);
expect(screen.getByText("Select a domain")).toBeInTheDocument();
});
it("should render with custom placeholder", () => {
const onChange = vi.fn();
render(
<DomainSelector
domains={mockDomains}
value={null}
onChange={onChange}
placeholder="Choose domain"
/>
);
expect(screen.getByText("Choose domain")).toBeInTheDocument();
});
it("should render all domains as options", () => {
const onChange = vi.fn();
render(
<DomainSelector domains={mockDomains} value={null} onChange={onChange} />
);
expect(screen.getByText("💼 Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
it("should call onChange when selection changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<DomainSelector domains={mockDomains} value={null} onChange={onChange} />
);
const select = screen.getByRole("combobox");
await user.selectOptions(select, "domain-1");
expect(onChange).toHaveBeenCalledWith("domain-1");
});
it("should call onChange with null when cleared", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<DomainSelector
domains={mockDomains}
value="domain-1"
onChange={onChange}
/>
);
const select = screen.getByRole("combobox");
await user.selectOptions(select, "");
expect(onChange).toHaveBeenCalledWith(null);
});
it("should show selected value", () => {
const onChange = vi.fn();
render(
<DomainSelector
domains={mockDomains}
value="domain-1"
onChange={onChange}
/>
);
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("domain-1");
});
it("should apply custom className", () => {
const onChange = vi.fn();
render(
<DomainSelector
domains={mockDomains}
value={null}
onChange={onChange}
className="custom-class"
/>
);
const select = screen.getByRole("combobox");
expect(select.className).toContain("custom-class");
});
});

View File

@@ -0,0 +1,38 @@
"use client";
import type { Domain } from "@mosaic/shared";
interface DomainSelectorProps {
domains: Domain[];
value: string | null;
onChange: (domainId: string | null) => void;
placeholder?: string;
className?: string;
}
export function DomainSelector({
domains,
value,
onChange,
placeholder = "Select a domain",
className = "",
}: DomainSelectorProps): JSX.Element {
return (
<select
value={value ?? ""}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange(e.target.value || null)
}
className={`border rounded px-3 py-2 ${className}`}
aria-label="Domain selector"
>
<option value="">{placeholder}</option>
{domains.map((domain) => (
<option key={domain.id} value={domain.id}>
{domain.icon ? `${domain.icon} ` : ""}
{domain.name}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FilterBar } from "./FilterBar";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
describe("FilterBar", () => {
const mockOnFilterChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render search input", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
it("should render status filter", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByRole("button", { name: /status/i })).toBeInTheDocument();
});
it("should render priority filter", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByRole("button", { name: /priority/i })).toBeInTheDocument();
});
it("should render date range picker", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.getByPlaceholderText(/from date/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/to date/i)).toBeInTheDocument();
});
it("should render clear filters button when filters applied", () => {
render(
<FilterBar
onFilterChange={mockOnFilterChange}
initialFilters={{ search: "test" }}
/>
);
expect(screen.getByRole("button", { name: /clear filters/i })).toBeInTheDocument();
});
it("should not render clear filters button when no filters applied", () => {
render(<FilterBar onFilterChange={mockOnFilterChange} />);
expect(screen.queryByRole("button", { name: /clear filters/i })).not.toBeInTheDocument();
});
it("should debounce search input", async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} debounceMs={300} />);
const searchInput = screen.getByPlaceholderText(/search/i);
await user.type(searchInput, "test query");
// Should not call immediately
expect(mockOnFilterChange).not.toHaveBeenCalled();
// Should call after debounce delay
await waitFor(
() => {
expect(mockOnFilterChange).toHaveBeenCalledWith(
expect.objectContaining({ search: "test query" })
);
},
{ timeout: 500 }
);
});
it("should clear all filters when clear button clicked", async () => {
const user = userEvent.setup();
render(
<FilterBar
onFilterChange={mockOnFilterChange}
initialFilters={{
search: "test",
status: [TaskStatus.IN_PROGRESS],
priority: [TaskPriority.HIGH],
}}
/>
);
const clearButton = screen.getByRole("button", { name: /clear filters/i });
await user.click(clearButton);
expect(mockOnFilterChange).toHaveBeenCalledWith({});
});
it("should handle status selection", async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} />);
const statusButton = screen.getByRole("button", { name: /status/i });
await user.click(statusButton);
// Note: Actual multi-select implementation would need to open a dropdown
// This is a simplified test
});
it("should handle priority selection", async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} />);
const priorityButton = screen.getByRole("button", { name: /priority/i });
await user.click(priorityButton);
// Note: Actual implementation would need to open a dropdown
});
it("should handle date range selection", async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={mockOnFilterChange} />);
const fromDate = screen.getByPlaceholderText(/from date/i);
const toDate = screen.getByPlaceholderText(/to date/i);
await user.type(fromDate, "2024-01-01");
await user.type(toDate, "2024-12-31");
await waitFor(() => {
expect(mockOnFilterChange).toHaveBeenCalled();
});
});
it("should display active filter count", () => {
render(
<FilterBar
onFilterChange={mockOnFilterChange}
initialFilters={{
status: [TaskStatus.IN_PROGRESS, TaskStatus.NOT_STARTED],
priority: [TaskPriority.HIGH],
}}
/>
);
// Should show 3 active filters (2 statuses + 1 priority)
expect(screen.getByText(/3/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,207 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
export interface FilterValues {
search?: string;
status?: TaskStatus[];
priority?: TaskPriority[];
dateFrom?: string;
dateTo?: string;
sortBy?: string;
sortOrder?: "asc" | "desc";
}
interface FilterBarProps {
onFilterChange: (filters: FilterValues) => void;
initialFilters?: FilterValues;
debounceMs?: number;
}
export function FilterBar({
onFilterChange,
initialFilters = {},
debounceMs = 300,
}: FilterBarProps) {
const [filters, setFilters] = useState<FilterValues>(initialFilters);
const [searchValue, setSearchValue] = useState(initialFilters.search || "");
const [showStatusDropdown, setShowStatusDropdown] = useState(false);
const [showPriorityDropdown, setShowPriorityDropdown] = useState(false);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (searchValue !== filters.search) {
const newFilters = { ...filters, search: searchValue || undefined };
setFilters(newFilters);
onFilterChange(newFilters);
}
}, debounceMs);
return () => clearTimeout(timer);
}, [searchValue, debounceMs]);
const handleFilterChange = useCallback(
(key: keyof FilterValues, value: any) => {
const newFilters = { ...filters, [key]: value };
if (!value || (Array.isArray(value) && value.length === 0)) {
delete newFilters[key];
}
setFilters(newFilters);
onFilterChange(newFilters);
},
[filters, onFilterChange]
);
const handleStatusToggle = (status: TaskStatus) => {
const currentStatuses = filters.status || [];
const newStatuses = currentStatuses.includes(status)
? currentStatuses.filter((s) => s !== status)
: [...currentStatuses, status];
handleFilterChange("status", newStatuses.length > 0 ? newStatuses : undefined);
};
const handlePriorityToggle = (priority: TaskPriority) => {
const currentPriorities = filters.priority || [];
const newPriorities = currentPriorities.includes(priority)
? currentPriorities.filter((p) => p !== priority)
: [...currentPriorities, priority];
handleFilterChange("priority", newPriorities.length > 0 ? newPriorities : undefined);
};
const clearAllFilters = () => {
setFilters({});
setSearchValue("");
onFilterChange({});
};
const activeFilterCount =
(filters.status?.length || 0) +
(filters.priority?.length || 0) +
(filters.search ? 1 : 0) +
(filters.dateFrom ? 1 : 0) +
(filters.dateTo ? 1 : 0);
const hasActiveFilters = activeFilterCount > 0;
return (
<div className="flex flex-wrap items-center gap-2 p-4 bg-gray-50 rounded-lg">
{/* Search Input */}
<div className="relative flex-1 min-w-[200px]">
<input
type="text"
placeholder="Search tasks..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Status Filter */}
<div className="relative">
<button
onClick={() => setShowStatusDropdown(!showStatusDropdown)}
className="px-3 py-2 border border-gray-300 rounded-md hover:bg-gray-100 flex items-center gap-2"
aria-label="Status filter"
>
Status
{filters.status && filters.status.length > 0 && (
<span className="bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full">
{filters.status.length}
</span>
)}
</button>
{showStatusDropdown && (
<div className="absolute top-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg z-10 min-w-[150px]">
{Object.values(TaskStatus).map((status) => (
<label
key={status}
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
>
<input
type="checkbox"
checked={filters.status?.includes(status) || false}
onChange={() => handleStatusToggle(status)}
className="mr-2"
/>
{status.replace(/_/g, " ")}
</label>
))}
</div>
)}
</div>
{/* Priority Filter */}
<div className="relative">
<button
onClick={() => setShowPriorityDropdown(!showPriorityDropdown)}
className="px-3 py-2 border border-gray-300 rounded-md hover:bg-gray-100 flex items-center gap-2"
aria-label="Priority filter"
>
Priority
{filters.priority && filters.priority.length > 0 && (
<span className="bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full">
{filters.priority.length}
</span>
)}
</button>
{showPriorityDropdown && (
<div className="absolute top-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg z-10 min-w-[150px]">
{Object.values(TaskPriority).map((priority) => (
<label
key={priority}
className="flex items-center px-3 py-2 hover:bg-gray-100 cursor-pointer"
>
<input
type="checkbox"
checked={filters.priority?.includes(priority) || false}
onChange={() => handlePriorityToggle(priority)}
className="mr-2"
/>
{priority}
</label>
))}
</div>
)}
</div>
{/* Date Range */}
<div className="flex items-center gap-2">
<input
type="date"
placeholder="From date"
value={filters.dateFrom || ""}
onChange={(e) => handleFilterChange("dateFrom", e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-gray-500">to</span>
<input
type="date"
placeholder="To date"
value={filters.dateTo || ""}
onChange={(e) => handleFilterChange("dateTo", e.target.value || undefined)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Clear Filters */}
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="ml-auto px-3 py-2 text-gray-700 hover:bg-gray-100 rounded-md"
aria-label="Clear filters"
>
Clear filters
</button>
)}
{/* Active Filter Count Badge */}
{activeFilterCount > 0 && (
<span className="bg-blue-500 text-white text-sm px-3 py-1 rounded-full">
{activeFilterCount}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./FilterBar";

View File

@@ -0,0 +1,3 @@
export { KanbanBoard } from "./kanban-board";
export { KanbanColumn } from "./kanban-column";
export { TaskCard } from "./task-card";

View File

@@ -0,0 +1,355 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { KanbanBoard } from "./kanban-board";
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
// Mock @dnd-kit modules
vi.mock("@dnd-kit/core", async () => {
const actual = await vi.importActual("@dnd-kit/core");
return {
...actual,
DndContext: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dnd-context">{children}</div>
),
};
});
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sortable-context">{children}</div>
),
verticalListSortingStrategy: {},
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: () => {},
transform: null,
transition: null,
}),
}));
const mockTasks: Task[] = [
{
id: "task-1",
title: "Design homepage",
description: "Create wireframes for the new homepage",
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-01"),
assigneeId: "user-1",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-2",
title: "Implement authentication",
description: "Add OAuth support",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-01-30"),
assigneeId: "user-2",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 1,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-3",
title: "Write unit tests",
description: "Achieve 85% coverage",
status: TaskStatus.PAUSED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-05"),
assigneeId: "user-3",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 2,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-4",
title: "Deploy to production",
description: "Set up CI/CD pipeline",
status: TaskStatus.COMPLETED,
priority: TaskPriority.LOW,
dueDate: new Date("2026-01-25"),
assigneeId: "user-1",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 3,
metadata: {},
completedAt: new Date("2026-01-25"),
createdAt: new Date("2026-01-20"),
updatedAt: new Date("2026-01-25"),
},
];
describe("KanbanBoard", () => {
const mockOnStatusChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe("Rendering", () => {
it("should render all four status columns", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
expect(screen.getByText("Not Started")).toBeInTheDocument();
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("Paused")).toBeInTheDocument();
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("should use PDA-friendly language in column headers", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
const columnHeaders = screen.getAllByRole("heading", { level: 3 });
const headerTexts = columnHeaders.map((h) => h.textContent?.toLowerCase() || "");
// Should NOT contain demanding/harsh words
headerTexts.forEach((text) => {
expect(text).not.toMatch(/must|required|urgent|critical|error/);
});
});
it("should organize tasks by status into correct columns", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
const notStartedColumn = screen.getByTestId("column-NOT_STARTED");
const inProgressColumn = screen.getByTestId("column-IN_PROGRESS");
const pausedColumn = screen.getByTestId("column-PAUSED");
const completedColumn = screen.getByTestId("column-COMPLETED");
expect(within(notStartedColumn).getByText("Design homepage")).toBeInTheDocument();
expect(within(inProgressColumn).getByText("Implement authentication")).toBeInTheDocument();
expect(within(pausedColumn).getByText("Write unit tests")).toBeInTheDocument();
expect(within(completedColumn).getByText("Deploy to production")).toBeInTheDocument();
});
it("should render empty state when no tasks provided", () => {
render(<KanbanBoard tasks={[]} onStatusChange={mockOnStatusChange} />);
// All columns should be empty but visible
expect(screen.getByText("Not Started")).toBeInTheDocument();
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("Paused")).toBeInTheDocument();
expect(screen.getByText("Completed")).toBeInTheDocument();
});
});
describe("Task Cards", () => {
it("should display task title on each card", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
expect(screen.getByText("Design homepage")).toBeInTheDocument();
expect(screen.getByText("Implement authentication")).toBeInTheDocument();
expect(screen.getByText("Write unit tests")).toBeInTheDocument();
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
});
it("should display task priority", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
// Priority badges should be visible
const highPriorityElements = screen.getAllByText("High");
const mediumPriorityElements = screen.getAllByText("Medium");
const lowPriorityElements = screen.getAllByText("Low");
expect(highPriorityElements.length).toBeGreaterThan(0);
expect(mediumPriorityElements.length).toBeGreaterThan(0);
expect(lowPriorityElements.length).toBeGreaterThan(0);
});
it("should display due date when available", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
// Check for formatted dates
expect(screen.getByText(/Feb 1/)).toBeInTheDocument();
expect(screen.getByText(/Jan 30/)).toBeInTheDocument();
});
it("should have accessible task cards", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
const taskCards = screen.getAllByRole("article");
expect(taskCards.length).toBe(mockTasks.length);
});
it("should show visual priority indicators with calm colors", () => {
const { container } = render(
<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />
);
// High priority should not use aggressive red
const priorityBadges = container.querySelectorAll('[data-priority]');
priorityBadges.forEach((badge) => {
const className = badge.className;
// Should avoid harsh red colors (bg-red-500, text-red-600, etc.)
expect(className).not.toMatch(/bg-red-[5-9]00|text-red-[5-9]00/);
});
});
});
describe("Drag and Drop", () => {
it("should initialize DndContext for drag-and-drop", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
expect(screen.getByTestId("dnd-context")).toBeInTheDocument();
});
it("should have droppable columns", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
const columns = screen.getAllByTestId(/^column-/);
expect(columns.length).toBe(4); // NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED
});
it("should call onStatusChange when task is moved between columns", async () => {
// This is a simplified test - full drag-and-drop would need more complex mocking
const { rerender } = render(
<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />
);
// Simulate status change
mockOnStatusChange("task-1", TaskStatus.IN_PROGRESS);
expect(mockOnStatusChange).toHaveBeenCalledWith("task-1", TaskStatus.IN_PROGRESS);
});
it("should provide visual feedback during drag (aria-grabbed)", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
const taskCards = screen.getAllByRole("article");
// Task cards should be draggable (checked via data attributes or aria)
expect(taskCards.length).toBeGreaterThan(0);
});
});
describe("Accessibility", () => {
it("should have proper heading hierarchy", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
const h3Headings = screen.getAllByRole("heading", { level: 3 });
expect(h3Headings.length).toBe(4); // One for each column
});
it("should have keyboard-navigable task cards", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
const taskCards = screen.getAllByRole("article");
taskCards.forEach((card) => {
// Cards should be keyboard accessible
expect(card).toBeInTheDocument();
});
});
it("should announce column changes to screen readers", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
const columns = screen.getAllByRole("region");
columns.forEach((column) => {
expect(column).toHaveAttribute("aria-label");
});
});
});
describe("Responsive Design", () => {
it("should apply responsive grid classes", () => {
const { container } = render(
<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />
);
const boardGrid = container.querySelector('[data-testid="kanban-grid"]');
expect(boardGrid).toBeInTheDocument();
// Should have responsive classes like grid, grid-cols-1, md:grid-cols-2, lg:grid-cols-4
const className = boardGrid?.className || "";
expect(className).toMatch(/grid/);
});
});
describe("PDA-Friendly Language", () => {
it("should not use demanding or harsh words in UI", () => {
const { container } = render(
<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />
);
const allText = container.textContent?.toLowerCase() || "";
// Should avoid demanding language
expect(allText).not.toMatch(/must|required|urgent|critical|error|alert|warning/);
});
it("should use encouraging language in empty states", () => {
render(<KanbanBoard tasks={[]} onStatusChange={mockOnStatusChange} />);
// Empty columns should have gentle messaging
const emptyMessages = screen.queryAllByText(/no tasks/i);
emptyMessages.forEach((msg) => {
const text = msg.textContent?.toLowerCase() || "";
expect(text).not.toMatch(/must|required|need to/);
});
});
});
describe("Task Count Badges", () => {
it("should display task count for each column", () => {
render(<KanbanBoard tasks={mockTasks} onStatusChange={mockOnStatusChange} />);
// Each column should show how many tasks it contains
expect(screen.getByText(/1/)).toBeInTheDocument(); // Each status has 1 task
});
});
describe("Error Handling", () => {
it("should handle undefined tasks gracefully", () => {
// @ts-expect-error Testing error case
render(<KanbanBoard tasks={undefined} onStatusChange={mockOnStatusChange} />);
// Should still render columns
expect(screen.getByText("Not Started")).toBeInTheDocument();
});
it("should handle missing onStatusChange callback", () => {
// @ts-expect-error Testing error case
const { container } = render(<KanbanBoard tasks={mockTasks} />);
expect(container).toBeInTheDocument();
});
it("should handle tasks with missing properties gracefully", () => {
const incompleteTasks = [
{
...mockTasks[0],
dueDate: null,
description: null,
},
];
render(<KanbanBoard tasks={incompleteTasks} onStatusChange={mockOnStatusChange} />);
expect(screen.getByText("Design homepage")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,125 @@
"use client";
import { useState, useMemo } from "react";
import type { Task } from "@mosaic/shared";
import { TaskStatus } from "@mosaic/shared";
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { KanbanColumn } from "./kanban-column";
import { TaskCard } from "./task-card";
interface KanbanBoardProps {
tasks: Task[];
onStatusChange: (taskId: string, newStatus: TaskStatus) => void;
}
const columns = [
{ status: TaskStatus.NOT_STARTED, title: "Not Started" },
{ status: TaskStatus.IN_PROGRESS, title: "In Progress" },
{ status: TaskStatus.PAUSED, title: "Paused" },
{ status: TaskStatus.COMPLETED, title: "Completed" },
] as const;
export function KanbanBoard({ tasks = [], onStatusChange }: KanbanBoardProps) {
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 8px of movement required before drag starts
},
})
);
// Group tasks by status
const tasksByStatus = useMemo(() => {
const grouped: Record<TaskStatus, Task[]> = {
[TaskStatus.NOT_STARTED]: [],
[TaskStatus.IN_PROGRESS]: [],
[TaskStatus.PAUSED]: [],
[TaskStatus.COMPLETED]: [],
[TaskStatus.ARCHIVED]: [],
};
(tasks || []).forEach((task) => {
if (grouped[task.status]) {
grouped[task.status].push(task);
}
});
// Sort tasks by sortOrder within each column
Object.keys(grouped).forEach((status) => {
grouped[status as TaskStatus].sort((a, b) => a.sortOrder - b.sortOrder);
});
return grouped;
}, [tasks]);
const activeTask = useMemo(
() => (tasks || []).find((task) => task.id === activeTaskId),
[tasks, activeTaskId]
);
function handleDragStart(event: DragStartEvent) {
setActiveTaskId(event.active.id as string);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) {
setActiveTaskId(null);
return;
}
const taskId = active.id as string;
const newStatus = over.id as TaskStatus;
// Find the task and check if status actually changed
const task = (tasks || []).find((t) => t.id === taskId);
if (task && task.status !== newStatus && onStatusChange) {
onStatusChange(taskId, newStatus);
}
setActiveTaskId(null);
}
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div
data-testid="kanban-grid"
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
>
{columns.map(({ status, title }) => (
<KanbanColumn
key={status}
status={status}
title={title}
tasks={tasksByStatus[status]}
/>
))}
</div>
{/* Drag Overlay - shows a copy of the dragged task */}
<DragOverlay>
{activeTask ? (
<div className="rotate-3 scale-105">
<TaskCard task={activeTask} />
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -0,0 +1,415 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { KanbanColumn } from "./kanban-column";
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
// Mock @dnd-kit modules
vi.mock("@dnd-kit/core", () => ({
useDroppable: () => ({
setNodeRef: vi.fn(),
isOver: false,
}),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sortable-context">{children}</div>
),
verticalListSortingStrategy: {},
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
}),
}));
const mockTasks: Task[] = [
{
id: "task-1",
title: "Design homepage",
description: "Create wireframes",
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-01"),
assigneeId: "user-1",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
{
id: "task-2",
title: "Setup database",
description: "Configure PostgreSQL",
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-03"),
assigneeId: "user-2",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 1,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
},
];
describe("KanbanColumn", () => {
describe("Rendering", () => {
it("should render column with title", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={mockTasks}
/>
);
expect(screen.getByText("Not Started")).toBeInTheDocument();
});
it("should render column as a region for accessibility", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={mockTasks}
/>
);
const column = screen.getByRole("region");
expect(column).toBeInTheDocument();
expect(column).toHaveAttribute("aria-label", "Not Started tasks");
});
it("should display task count badge", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={mockTasks}
/>
);
expect(screen.getByText("2")).toBeInTheDocument();
});
it("should render all tasks in the column", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={mockTasks}
/>
);
expect(screen.getByText("Design homepage")).toBeInTheDocument();
expect(screen.getByText("Setup database")).toBeInTheDocument();
});
it("should render empty column with zero count", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
expect(screen.getByText("Not Started")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
});
});
describe("Column Header", () => {
it("should have semantic heading", () => {
render(
<KanbanColumn
status={TaskStatus.IN_PROGRESS}
title="In Progress"
tasks={[]}
/>
);
const heading = screen.getByRole("heading", { level: 3 });
expect(heading).toHaveTextContent("In Progress");
});
it("should have distinct visual styling based on status", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.COMPLETED}
title="Completed"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-COMPLETED"]');
expect(column).toBeInTheDocument();
});
});
describe("Task Count Badge", () => {
it("should show 0 when no tasks", () => {
render(
<KanbanColumn
status={TaskStatus.PAUSED}
title="Paused"
tasks={[]}
/>
);
expect(screen.getByText("0")).toBeInTheDocument();
});
it("should show correct count for multiple tasks", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={mockTasks}
/>
);
expect(screen.getByText("2")).toBeInTheDocument();
});
it("should update count dynamically", () => {
const { rerender } = render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={mockTasks}
/>
);
expect(screen.getByText("2")).toBeInTheDocument();
rerender(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[mockTasks[0]]}
/>
);
expect(screen.getByText("1")).toBeInTheDocument();
});
});
describe("Empty State", () => {
it("should show empty state message when no tasks", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
// Should have some empty state indication
const column = screen.getByRole("region");
expect(column).toBeInTheDocument();
});
it("should use PDA-friendly language in empty state", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
const allText = container.textContent?.toLowerCase() || "";
// Should not have demanding language
expect(allText).not.toMatch(/must|required|need to|urgent/);
});
});
describe("Drag and Drop", () => {
it("should be a droppable area", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={mockTasks}
/>
);
expect(screen.getByTestId("column-NOT_STARTED")).toBeInTheDocument();
});
it("should initialize SortableContext for draggable tasks", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={mockTasks}
/>
);
expect(screen.getByTestId("sortable-context")).toBeInTheDocument();
});
});
describe("Visual Design", () => {
it("should have rounded corners and padding", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-NOT_STARTED"]');
const className = column?.className || "";
expect(className).toMatch(/rounded|p-/);
});
it("should have background color", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-NOT_STARTED"]');
const className = column?.className || "";
expect(className).toMatch(/bg-/);
});
it("should use gentle colors (not harsh reds)", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-NOT_STARTED"]');
const className = column?.className || "";
// Should avoid aggressive red backgrounds
expect(className).not.toMatch(/bg-red-[5-9]00/);
});
});
describe("Accessibility", () => {
it("should have aria-label for screen readers", () => {
render(
<KanbanColumn
status={TaskStatus.IN_PROGRESS}
title="In Progress"
tasks={mockTasks}
/>
);
const column = screen.getByRole("region");
expect(column).toHaveAttribute("aria-label", "In Progress tasks");
});
it("should have proper heading hierarchy", () => {
render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
const heading = screen.getByRole("heading", { level: 3 });
expect(heading).toBeInTheDocument();
});
});
describe("Status-Based Styling", () => {
it("should apply different styles for NOT_STARTED", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-NOT_STARTED"]');
expect(column).toBeInTheDocument();
});
it("should apply different styles for IN_PROGRESS", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.IN_PROGRESS}
title="In Progress"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-IN_PROGRESS"]');
expect(column).toBeInTheDocument();
});
it("should apply different styles for PAUSED", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.PAUSED}
title="Paused"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-PAUSED"]');
expect(column).toBeInTheDocument();
});
it("should apply different styles for COMPLETED", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.COMPLETED}
title="Completed"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-COMPLETED"]');
expect(column).toBeInTheDocument();
});
});
describe("Responsive Design", () => {
it("should have minimum height to maintain layout", () => {
const { container } = render(
<KanbanColumn
status={TaskStatus.NOT_STARTED}
title="Not Started"
tasks={[]}
/>
);
const column = container.querySelector('[data-testid="column-NOT_STARTED"]');
const className = column?.className || "";
// Should have min-height class
expect(className).toMatch(/min-h-/);
});
});
});

View File

@@ -0,0 +1,86 @@
"use client";
import type { Task } from "@mosaic/shared";
import { TaskStatus } from "@mosaic/shared";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { TaskCard } from "./task-card";
interface KanbanColumnProps {
status: TaskStatus;
title: string;
tasks: Task[];
}
const statusColors = {
[TaskStatus.NOT_STARTED]: "border-gray-300 dark:border-gray-600",
[TaskStatus.IN_PROGRESS]: "border-blue-300 dark:border-blue-600",
[TaskStatus.PAUSED]: "border-amber-300 dark:border-amber-600",
[TaskStatus.COMPLETED]: "border-green-300 dark:border-green-600",
[TaskStatus.ARCHIVED]: "border-gray-400 dark:border-gray-500",
};
const statusBadgeColors = {
[TaskStatus.NOT_STARTED]: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
[TaskStatus.IN_PROGRESS]: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
[TaskStatus.PAUSED]: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
[TaskStatus.COMPLETED]: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
[TaskStatus.ARCHIVED]: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
};
export function KanbanColumn({ status, title, tasks }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: status,
});
const taskIds = tasks.map((task) => task.id);
return (
<section
ref={setNodeRef}
role="region"
aria-label={`${title} tasks`}
data-testid={`column-${status}`}
className={`
flex flex-col
bg-gray-50 dark:bg-gray-900
rounded-lg border-2
p-4 space-y-4
min-h-[500px]
transition-colors duration-200
${statusColors[status]}
${isOver ? "bg-gray-100 dark:bg-gray-800 border-opacity-100" : "border-opacity-50"}
`}
>
{/* Column Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{title}
</h3>
<span
className={`
inline-flex items-center justify-center
w-6 h-6 rounded-full text-xs font-medium
${statusBadgeColors[status]}
`}
>
{tasks.length}
</span>
</div>
{/* Tasks */}
<div className="flex-1 space-y-3 overflow-y-auto">
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{tasks.length > 0 ? (
tasks.map((task) => <TaskCard key={task.id} task={task} />)
) : (
<div className="flex items-center justify-center h-32 text-sm text-gray-500 dark:text-gray-400">
{/* Empty state - gentle, PDA-friendly */}
<p>No tasks here yet</p>
</div>
)}
</SortableContext>
</div>
</section>
);
}

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskCard } from "./task-card";
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
// Mock @dnd-kit/sortable
vi.mock("@dnd-kit/sortable", () => ({
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
const mockTask: Task = {
id: "task-1",
title: "Complete project documentation",
description: "Write comprehensive docs for the API",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-01"),
assigneeId: "user-1",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
};
describe("TaskCard", () => {
describe("Rendering", () => {
it("should render task title", () => {
render(<TaskCard task={mockTask} />);
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
});
it("should render as an article element for semantic HTML", () => {
render(<TaskCard task={mockTask} />);
const card = screen.getByRole("article");
expect(card).toBeInTheDocument();
});
it("should display task priority", () => {
render(<TaskCard task={mockTask} />);
expect(screen.getByText("High")).toBeInTheDocument();
});
it("should display due date when available", () => {
render(<TaskCard task={mockTask} />);
// Check for formatted date (format: "Feb 1" or similar)
const dueDateElement = screen.getByText(/Feb 1/);
expect(dueDateElement).toBeInTheDocument();
});
it("should not display due date when null", () => {
const taskWithoutDueDate = { ...mockTask, dueDate: null };
render(<TaskCard task={taskWithoutDueDate} />);
// Should not show any date
expect(screen.queryByText(/Feb/)).not.toBeInTheDocument();
});
it("should truncate long titles gracefully", () => {
const longTask = {
...mockTask,
title: "This is a very long task title that should be truncated to prevent layout issues",
};
const { container } = render(<TaskCard task={longTask} />);
const titleElement = container.querySelector("h4");
expect(titleElement).toBeInTheDocument();
// Should have text truncation classes
expect(titleElement?.className).toMatch(/truncate|line-clamp/);
});
});
describe("Priority Display", () => {
it("should display HIGH priority with appropriate styling", () => {
render(<TaskCard task={mockTask} />);
const priorityBadge = screen.getByText("High");
expect(priorityBadge).toBeInTheDocument();
});
it("should display MEDIUM priority", () => {
const mediumTask = { ...mockTask, priority: TaskPriority.MEDIUM };
render(<TaskCard task={mediumTask} />);
expect(screen.getByText("Medium")).toBeInTheDocument();
});
it("should display LOW priority", () => {
const lowTask = { ...mockTask, priority: TaskPriority.LOW };
render(<TaskCard task={lowTask} />);
expect(screen.getByText("Low")).toBeInTheDocument();
});
it("should use calm colors for priority badges (not aggressive red)", () => {
const { container } = render(<TaskCard task={mockTask} />);
const priorityBadge = screen.getByText("High").closest("span");
const className = priorityBadge?.className || "";
// Should not use harsh red for high priority
expect(className).not.toMatch(/bg-red-[5-9]00|text-red-[5-9]00/);
});
});
describe("Due Date Display", () => {
it("should format due date in a human-readable way", () => {
render(<TaskCard task={mockTask} />);
// Should show month abbreviation and day
expect(screen.getByText(/Feb 1/)).toBeInTheDocument();
});
it("should show overdue indicator with calm styling", () => {
const overdueTask = {
...mockTask,
dueDate: new Date("2025-01-01"), // Past date
};
render(<TaskCard task={overdueTask} />);
// Should indicate overdue but not in harsh red
const dueDateElement = screen.getByText(/Jan 1/);
const className = dueDateElement.className;
// Should avoid aggressive red
expect(className).not.toMatch(/bg-red-[5-9]00|text-red-[5-9]00/);
});
it("should show due soon indicator for tasks due within 3 days", () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const soonTask = {
...mockTask,
dueDate: tomorrow,
};
const { container } = render(<TaskCard task={soonTask} />);
// Should have some visual indicator (checked via data attribute or aria label)
expect(container).toBeInTheDocument();
});
});
describe("Drag and Drop", () => {
it("should be draggable", () => {
const { container } = render(<TaskCard task={mockTask} />);
const card = container.querySelector('[role="article"]');
expect(card).toBeInTheDocument();
});
it("should have appropriate cursor style for dragging", () => {
const { container } = render(<TaskCard task={mockTask} />);
const card = container.querySelector('[role="article"]');
const className = card?.className || "";
// Should have cursor-grab or cursor-move
expect(className).toMatch(/cursor-(grab|move)/);
});
});
describe("Accessibility", () => {
it("should have accessible task card", () => {
render(<TaskCard task={mockTask} />);
const card = screen.getByRole("article");
expect(card).toBeInTheDocument();
});
it("should have semantic heading for task title", () => {
render(<TaskCard task={mockTask} />);
const heading = screen.getByRole("heading", { level: 4 });
expect(heading).toHaveTextContent("Complete project documentation");
});
it("should provide aria-label for due date icon", () => {
const { container } = render(<TaskCard task={mockTask} />);
// Icons should have proper aria labels
const icons = container.querySelectorAll("svg");
icons.forEach((icon) => {
const ariaLabel = icon.getAttribute("aria-label");
const parentAriaLabel = icon.parentElement?.getAttribute("aria-label");
// Either the icon or its parent should have an aria-label
expect(ariaLabel || parentAriaLabel || icon.getAttribute("aria-hidden")).toBeTruthy();
});
});
});
describe("PDA-Friendly Design", () => {
it("should not use harsh or demanding language", () => {
const { container } = render(<TaskCard task={mockTask} />);
const allText = container.textContent?.toLowerCase() || "";
// Should avoid demanding words
expect(allText).not.toMatch(/must|required|urgent|critical|error|alert/);
});
it("should use gentle visual design", () => {
const { container } = render(<TaskCard task={mockTask} />);
const card = container.querySelector('[role="article"]');
const className = card?.className || "";
// Should have rounded corners and soft shadows
expect(className).toMatch(/rounded/);
});
});
describe("Compact Mode", () => {
it("should handle missing description gracefully", () => {
const taskWithoutDescription = { ...mockTask, description: null };
render(<TaskCard task={taskWithoutDescription} />);
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
// Description should not be rendered
});
});
describe("Error Handling", () => {
it("should handle task with minimal data", () => {
const minimalTask: Task = {
id: "task-minimal",
title: "Minimal task",
description: null,
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: null,
assigneeId: null,
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
render(<TaskCard task={minimalTask} />);
expect(screen.getByText("Minimal task")).toBeInTheDocument();
});
});
describe("Visual Feedback", () => {
it("should show hover state with subtle transition", () => {
const { container } = render(<TaskCard task={mockTask} />);
const card = container.querySelector('[role="article"]');
const className = card?.className || "";
// Should have hover transition
expect(className).toMatch(/transition|hover:/);
});
});
});

View File

@@ -0,0 +1,113 @@
"use client";
import type { Task } from "@mosaic/shared";
import { TaskPriority } from "@mosaic/shared";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Calendar, Flag } from "lucide-react";
import { format } from "date-fns";
interface TaskCardProps {
task: Task;
}
const priorityConfig = {
[TaskPriority.HIGH]: {
label: "High",
className: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
},
[TaskPriority.MEDIUM]: {
label: "Medium",
className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
},
[TaskPriority.LOW]: {
label: "Low",
className: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400",
},
};
export function TaskCard({ task }: TaskCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isOverdue =
task.dueDate &&
new Date(task.dueDate) < new Date() &&
task.status !== "COMPLETED";
const isDueSoon =
task.dueDate &&
!isOverdue &&
new Date(task.dueDate).getTime() - new Date().getTime() <
3 * 24 * 60 * 60 * 1000; // 3 days
const priorityInfo = priorityConfig[task.priority];
return (
<article
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`
bg-white dark:bg-gray-800
rounded-lg shadow-sm border border-gray-200 dark:border-gray-700
p-4 space-y-3
cursor-grab active:cursor-grabbing
transition-all duration-200
hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600
${isDragging ? "opacity-50" : "opacity-100"}
`}
>
{/* Task Title */}
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 line-clamp-2">
{task.title}
</h4>
{/* Task Metadata */}
<div className="flex items-center gap-2 flex-wrap">
{/* Priority Badge */}
<span
data-priority={task.priority}
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
${priorityInfo.className}
`}
>
<Flag className="w-3 h-3" aria-hidden="true" />
{priorityInfo.label}
</span>
{/* Due Date */}
{task.dueDate && (
<span
className={`
inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs
${
isOverdue
? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
: isDueSoon
? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
}
`}
>
<Calendar className="w-3 h-3" aria-label="Due date" />
{format(new Date(task.dueDate), "MMM d")}
</span>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,195 @@
"use client";
import { useState } from "react";
import type { Personality, FormalityLevel } from "@mosaic/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export interface PersonalityFormData {
name: string;
description?: string;
tone: string;
formalityLevel: FormalityLevel;
systemPromptTemplate: string;
isDefault?: boolean;
isActive?: boolean;
}
interface PersonalityFormProps {
personality?: Personality;
onSubmit: (data: PersonalityFormData) => Promise<void>;
onCancel?: () => void;
}
const FORMALITY_OPTIONS = [
{ value: "VERY_CASUAL", label: "Very Casual" },
{ value: "CASUAL", label: "Casual" },
{ value: "NEUTRAL", label: "Neutral" },
{ value: "FORMAL", label: "Formal" },
{ value: "VERY_FORMAL", label: "Very Formal" },
];
export function PersonalityForm({ personality, onSubmit, onCancel }: PersonalityFormProps): JSX.Element {
const [formData, setFormData] = useState<PersonalityFormData>({
name: personality?.name || "",
description: personality?.description || "",
tone: personality?.tone || "",
formalityLevel: personality?.formalityLevel || "NEUTRAL",
systemPromptTemplate: personality?.systemPromptTemplate || "",
isDefault: personality?.isDefault || false,
isActive: personality?.isActive ?? true,
});
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formData);
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>{personality ? "Edit Personality" : "Create New Personality"}</CardTitle>
<CardDescription>
Customize how the AI assistant communicates and responds
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Professional, Casual, Friendly"
required
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of this personality style"
rows={2}
/>
</div>
{/* Tone */}
<div className="space-y-2">
<Label htmlFor="tone">Tone *</Label>
<Input
id="tone"
value={formData.tone}
onChange={(e) => setFormData({ ...formData, tone: e.target.value })}
placeholder="e.g., professional, friendly, enthusiastic"
required
/>
</div>
{/* Formality Level */}
<div className="space-y-2">
<Label htmlFor="formality">Formality Level *</Label>
<Select
value={formData.formalityLevel}
onValueChange={(value) =>
setFormData({ ...formData, formalityLevel: value as FormalityLevel })
}
>
<SelectTrigger id="formality">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* System Prompt Template */}
<div className="space-y-2">
<Label htmlFor="systemPrompt">System Prompt Template *</Label>
<Textarea
id="systemPrompt"
value={formData.systemPromptTemplate}
onChange={(e) =>
setFormData({ ...formData, systemPromptTemplate: e.target.value })
}
placeholder="You are a helpful AI assistant..."
rows={6}
required
/>
<p className="text-xs text-muted-foreground">
This template guides the AI's communication style and behavior
</p>
</div>
{/* Switches */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="isDefault">Set as Default</Label>
<p className="text-xs text-muted-foreground">
Use this personality by default for new conversations
</p>
</div>
<Switch
id="isDefault"
checked={formData.isDefault}
onCheckedChange={(checked) => setFormData({ ...formData, isDefault: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="isActive">Active</Label>
<p className="text-xs text-muted-foreground">
Make this personality available for selection
</p>
</div>
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : personality ? "Update" : "Create"}
</Button>
</div>
</CardContent>
</Card>
</form>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useState } from "react";
import type { Personality } from "@mosaic/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Sparkles } from "lucide-react";
interface PersonalityPreviewProps {
personality: Personality;
}
const SAMPLE_PROMPTS = [
"Explain quantum computing in simple terms",
"What's the best way to organize my tasks?",
"Help me brainstorm ideas for a new project",
];
const FORMALITY_LABELS: Record<string, string> = {
VERY_CASUAL: "Very Casual",
CASUAL: "Casual",
NEUTRAL: "Neutral",
FORMAL: "Formal",
VERY_FORMAL: "Very Formal",
};
export function PersonalityPreview({ personality }: PersonalityPreviewProps): JSX.Element {
const [selectedPrompt, setSelectedPrompt] = useState<string>(SAMPLE_PROMPTS[0]);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5" />
{personality.name}
</CardTitle>
<CardDescription>{personality.description}</CardDescription>
</div>
{personality.isDefault && (
<Badge variant="secondary">Default</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Personality Attributes */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Tone:</span>
<Badge variant="outline" className="ml-2">
{personality.tone}
</Badge>
</div>
<div>
<span className="text-muted-foreground">Formality:</span>
<Badge variant="outline" className="ml-2">
{FORMALITY_LABELS[personality.formalityLevel]}
</Badge>
</div>
</div>
{/* Sample Interaction */}
<div className="space-y-2">
<label className="text-sm font-medium">Preview with Sample Prompt:</label>
<div className="flex flex-wrap gap-2">
{SAMPLE_PROMPTS.map((prompt) => (
<Button
key={prompt}
variant={selectedPrompt === prompt ? "default" : "outline"}
size="sm"
onClick={() => setSelectedPrompt(prompt)}
>
{prompt.substring(0, 30)}...
</Button>
))}
</div>
</div>
{/* System Prompt Template */}
<div className="space-y-2">
<label className="text-sm font-medium">System Prompt Template:</label>
<Textarea
value={personality.systemPromptTemplate}
readOnly
className="min-h-[100px] bg-muted"
/>
</div>
{/* Mock Response Preview */}
<div className="space-y-2">
<label className="text-sm font-medium">Sample Response Style:</label>
<div className="rounded-md border bg-muted/50 p-4 text-sm">
<p className="italic text-muted-foreground">
"{selectedPrompt}"
</p>
<div className="mt-2 text-foreground">
{personality.formalityLevel === "VERY_CASUAL" && (
<p>Hey! So quantum computing is like... imagine if your computer could be in multiple places at once. Pretty wild, right? 🤯</p>
)}
{personality.formalityLevel === "CASUAL" && (
<p>Sure! Think of quantum computing like a super-powered calculator that can try lots of solutions at the same time.</p>
)}
{personality.formalityLevel === "NEUTRAL" && (
<p>Quantum computing uses quantum mechanics principles to process information differently from classical computers, enabling parallel computation.</p>
)}
{personality.formalityLevel === "FORMAL" && (
<p>Quantum computing represents a paradigm shift in computational methodology, leveraging quantum mechanical phenomena to perform calculations.</p>
)}
{personality.formalityLevel === "VERY_FORMAL" && (
<p>Quantum computing constitutes a fundamental departure from classical computational architectures, employing quantum superposition and entanglement principles.</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useState, useEffect } from "react";
import type { Personality } from "@mosaic/shared";
import { fetchPersonalities } from "@/lib/api/personalities";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
interface PersonalitySelectorProps {
value?: string;
onChange?: (personalityId: string) => void;
label?: string;
className?: string;
}
export function PersonalitySelector({
value,
onChange,
label = "Select Personality",
className,
}: PersonalitySelectorProps): JSX.Element {
const [personalities, setPersonalities] = useState<Personality[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
loadPersonalities();
}, []);
async function loadPersonalities(): Promise<void> {
try {
setIsLoading(true);
const response = await fetchPersonalities();
setPersonalities(response.data);
} catch (err) {
console.error("Failed to load personalities:", err);
} finally {
setIsLoading(false);
}
}
return (
<div className={className}>
{label && (
<Label htmlFor="personality-select" className="mb-2">
{label}
</Label>
)}
<Select value={value} onValueChange={onChange} disabled={isLoading}>
<SelectTrigger id="personality-select">
<SelectValue placeholder={isLoading ? "Loading..." : "Choose a personality"} />
</SelectTrigger>
<SelectContent>
{personalities.map((personality) => (
<SelectItem key={personality.id} value={personality.id}>
<div className="flex items-center gap-2">
<span>{personality.name}</span>
{personality.isDefault && (
<Badge variant="secondary" className="ml-2">
Default
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,99 @@
/**
* BaseWidget - Wrapper component for all widgets
* Provides consistent styling, controls, and error/loading states
*/
import type { ReactNode } from "react";
import { Settings, X } from "lucide-react";
import { cn } from "@mosaic/ui/lib/utils";
export interface BaseWidgetProps {
id: string;
title: string;
description?: string;
children: ReactNode;
onEdit?: () => void;
onRemove?: () => void;
className?: string;
isLoading?: boolean;
error?: string;
}
export function BaseWidget({
id,
title,
description,
children,
onEdit,
onRemove,
className,
isLoading = false,
error,
}: BaseWidgetProps) {
return (
<div
data-widget-id={id}
className={cn(
"flex flex-col h-full bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden",
className
)}
>
{/* Widget Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
{description && (
<p className="text-xs text-gray-500 truncate mt-0.5">{description}</p>
)}
</div>
{/* Control buttons - only show if handlers provided */}
{(onEdit || onRemove) && (
<div className="flex items-center gap-1 ml-2">
{onEdit && (
<button
onClick={onEdit}
aria-label="Edit widget"
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
title="Edit widget"
>
<Settings className="w-4 h-4" />
</button>
)}
{onRemove && (
<button
onClick={onRemove}
aria-label="Remove widget"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Remove widget"
>
<X className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
{/* Widget Content */}
<div className="flex-1 p-4 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-2">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-500">Loading...</span>
</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="text-red-500 text-sm font-medium mb-1">Error</div>
<div className="text-xs text-gray-600">{error}</div>
</div>
</div>
) : (
children
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
/**
* WidgetGrid - Draggable grid layout for widgets
* Uses react-grid-layout for drag-and-drop functionality
*/
import { useCallback, useMemo } from "react";
import GridLayout from "react-grid-layout";
import type { Layout } from "react-grid-layout";
import type { WidgetPlacement } from "@mosaic/shared";
import { cn } from "@mosaic/ui/lib/utils";
import { getWidgetByName } from "./WidgetRegistry";
import { BaseWidget } from "./BaseWidget";
import "react-grid-layout/css/styles.css";
export interface WidgetGridProps {
layout: WidgetPlacement[];
onLayoutChange: (layout: WidgetPlacement[]) => void;
onRemoveWidget?: (widgetId: string) => void;
isEditing?: boolean;
className?: string;
}
export function WidgetGrid({
layout,
onLayoutChange,
onRemoveWidget,
isEditing = false,
className,
}: WidgetGridProps) {
// Convert WidgetPlacement to react-grid-layout Layout format
const gridLayout: Layout[] = useMemo(
() =>
layout.map((item) => ({
i: item.i,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: item.minW,
maxW: item.maxW,
minH: item.minH,
maxH: item.maxH,
static: !isEditing || item.static,
isDraggable: isEditing && (item.isDraggable !== false),
isResizable: isEditing && (item.isResizable !== false),
})),
[layout, isEditing]
);
const handleLayoutChange = useCallback(
(newLayout: Layout[]) => {
const updatedLayout: WidgetPlacement[] = newLayout.map((item) => ({
i: item.i,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: item.minW,
maxW: item.maxW,
minH: item.minH,
maxH: item.maxH,
static: item.static,
isDraggable: item.isDraggable,
isResizable: item.isResizable,
}));
onLayoutChange(updatedLayout);
},
[onLayoutChange]
);
const handleRemoveWidget = useCallback(
(widgetId: string) => {
if (onRemoveWidget) {
onRemoveWidget(widgetId);
}
},
[onRemoveWidget]
);
// Empty state
if (layout.length === 0) {
return (
<div className="flex items-center justify-center h-full min-h-[400px] bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
<div className="text-center">
<p className="text-gray-500 text-lg font-medium">No widgets yet</p>
<p className="text-gray-400 text-sm mt-1">
Add widgets to customize your dashboard
</p>
</div>
</div>
);
}
return (
<div className={cn("widget-grid-container", className)}>
<GridLayout
className="layout"
layout={gridLayout}
onLayoutChange={handleLayoutChange}
cols={12}
rowHeight={100}
width={1200}
isDraggable={isEditing}
isResizable={isEditing}
compactType="vertical"
preventCollision={false}
data-testid="grid-layout"
>
{layout.map((item) => {
// Extract widget type from widget ID (format: "WidgetType-uuid")
const widgetType = item.i.split("-")[0];
const widgetDef = getWidgetByName(widgetType);
if (!widgetDef) {
return (
<div key={item.i} data-testid={`widget-${item.i}`}>
<BaseWidget id={item.i} title="Unknown Widget" error="Widget not found" />
</div>
);
}
const WidgetComponent = widgetDef.component;
return (
<div key={item.i} data-testid={`widget-${item.i}`}>
<BaseWidget
id={item.i}
title={widgetDef.displayName}
description={widgetDef.description}
onEdit={isEditing ? undefined : undefined} // TODO: Implement edit
onRemove={
isEditing && onRemoveWidget
? () => handleRemoveWidget(item.i)
: undefined
}
>
<WidgetComponent id={item.i} />
</BaseWidget>
</div>
);
})}
</GridLayout>
</div>
);
}

View File

@@ -0,0 +1,95 @@
/**
* Widget Registry - Central registry for all available widgets
*/
import type { ComponentType } from "react";
import type { WidgetProps } from "@mosaic/shared";
import { TasksWidget } from "./TasksWidget";
import { CalendarWidget } from "./CalendarWidget";
import { QuickCaptureWidget } from "./QuickCaptureWidget";
import { AgentStatusWidget } from "./AgentStatusWidget";
export interface WidgetDefinition {
name: string;
displayName: string;
description: string;
component: ComponentType<WidgetProps>;
defaultWidth: number;
defaultHeight: number;
minWidth: number;
minHeight: number;
maxWidth?: number;
maxHeight?: number;
}
/**
* Registry of all available widgets
*/
export const widgetRegistry: Record<string, WidgetDefinition> = {
TasksWidget: {
name: "TasksWidget",
displayName: "Tasks",
description: "View and manage your tasks",
component: TasksWidget,
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 4,
},
CalendarWidget: {
name: "CalendarWidget",
displayName: "Calendar",
description: "View upcoming events and schedule",
component: CalendarWidget,
defaultWidth: 2,
defaultHeight: 2,
minWidth: 2,
minHeight: 2,
maxWidth: 4,
},
QuickCaptureWidget: {
name: "QuickCaptureWidget",
displayName: "Quick Capture",
description: "Quickly capture notes and tasks",
component: QuickCaptureWidget,
defaultWidth: 2,
defaultHeight: 1,
minWidth: 2,
minHeight: 1,
maxWidth: 4,
maxHeight: 2,
},
AgentStatusWidget: {
name: "AgentStatusWidget",
displayName: "Agent Status",
description: "Monitor agent activity and status",
component: AgentStatusWidget,
defaultWidth: 2,
defaultHeight: 2,
minWidth: 1,
minHeight: 2,
maxWidth: 3,
},
};
/**
* Get widget definition by name
*/
export function getWidgetByName(name: string): WidgetDefinition | undefined {
return widgetRegistry[name];
}
/**
* Get all available widgets as an array
*/
export function getAllWidgets(): WidgetDefinition[] {
return Object.values(widgetRegistry);
}
/**
* Check if a widget name is valid
*/
export function isValidWidget(name: string): boolean {
return name in widgetRegistry;
}

View File

@@ -0,0 +1,145 @@
/**
* BaseWidget Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BaseWidget } from "../BaseWidget";
describe("BaseWidget", () => {
const mockOnEdit = vi.fn();
const mockOnRemove = vi.fn();
it("should render children content", () => {
render(
<BaseWidget
id="test-widget"
title="Test Widget"
onEdit={mockOnEdit}
onRemove={mockOnRemove}
>
<div>Widget Content</div>
</BaseWidget>
);
expect(screen.getByText("Widget Content")).toBeInTheDocument();
});
it("should render title", () => {
render(
<BaseWidget
id="test-widget"
title="My Custom Widget"
onEdit={mockOnEdit}
onRemove={mockOnRemove}
>
<div>Content</div>
</BaseWidget>
);
expect(screen.getByText("My Custom Widget")).toBeInTheDocument();
});
it("should call onEdit when edit button clicked", async () => {
const user = userEvent.setup();
render(
<BaseWidget
id="test-widget"
title="Test Widget"
onEdit={mockOnEdit}
onRemove={mockOnRemove}
>
<div>Content</div>
</BaseWidget>
);
const editButton = screen.getByRole("button", { name: /edit/i });
await user.click(editButton);
expect(mockOnEdit).toHaveBeenCalledTimes(1);
});
it("should call onRemove when remove button clicked", async () => {
const user = userEvent.setup();
render(
<BaseWidget
id="test-widget"
title="Test Widget"
onEdit={mockOnEdit}
onRemove={mockOnRemove}
>
<div>Content</div>
</BaseWidget>
);
const removeButton = screen.getByRole("button", { name: /remove/i });
await user.click(removeButton);
expect(mockOnRemove).toHaveBeenCalledTimes(1);
});
it("should not show control buttons when handlers not provided", () => {
render(
<BaseWidget id="test-widget" title="Test Widget">
<div>Content</div>
</BaseWidget>
);
expect(screen.queryByRole("button", { name: /edit/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /remove/i })).not.toBeInTheDocument();
});
it("should render with description when provided", () => {
render(
<BaseWidget
id="test-widget"
title="Test Widget"
description="This is a test description"
>
<div>Content</div>
</BaseWidget>
);
expect(screen.getByText("This is a test description")).toBeInTheDocument();
});
it("should apply custom className", () => {
const { container } = render(
<BaseWidget
id="test-widget"
title="Test Widget"
className="custom-class"
>
<div>Content</div>
</BaseWidget>
);
expect(container.querySelector(".custom-class")).toBeInTheDocument();
});
it("should render loading state", () => {
render(
<BaseWidget id="test-widget" title="Test Widget" isLoading={true}>
<div>Content</div>
</BaseWidget>
);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render error state", () => {
render(
<BaseWidget
id="test-widget"
title="Test Widget"
error="Something went wrong"
>
<div>Content</div>
</BaseWidget>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,117 @@
/**
* CalendarWidget Component Tests
* Following TDD principles
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { CalendarWidget } from "../CalendarWidget";
global.fetch = vi.fn();
describe("CalendarWidget", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render loading state initially", () => {
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
render(<CalendarWidget id="calendar-1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render upcoming events", async () => {
const mockEvents = [
{
id: "1",
title: "Team Meeting",
startTime: new Date(Date.now() + 3600000).toISOString(),
endTime: new Date(Date.now() + 7200000).toISOString(),
},
{
id: "2",
title: "Project Review",
startTime: new Date(Date.now() + 86400000).toISOString(),
endTime: new Date(Date.now() + 90000000).toISOString(),
},
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockEvents,
});
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
expect(screen.getByText("Project Review")).toBeInTheDocument();
});
});
it("should handle empty event list", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
expect(screen.getByText(/no upcoming events/i)).toBeInTheDocument();
});
});
it("should handle API errors gracefully", async () => {
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it("should format event times correctly", async () => {
const now = new Date();
const startTime = new Date(now.getTime() + 3600000); // 1 hour from now
const mockEvents = [
{
id: "1",
title: "Meeting",
startTime: startTime.toISOString(),
endTime: new Date(startTime.getTime() + 3600000).toISOString(),
},
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockEvents,
});
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
expect(screen.getByText("Meeting")).toBeInTheDocument();
// Should show time in readable format
});
});
it("should display current date", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
render(<CalendarWidget id="calendar-1" />);
await waitFor(() => {
const currentDate = new Date().toLocaleDateString();
// Widget should display current date or month
expect(screen.getByTestId("calendar-header")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,148 @@
/**
* QuickCaptureWidget Component Tests
* Following TDD principles
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QuickCaptureWidget } from "../QuickCaptureWidget";
global.fetch = vi.fn();
describe("QuickCaptureWidget", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render input field", () => {
render(<QuickCaptureWidget id="quick-capture-1" />);
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("should render submit button", () => {
render(<QuickCaptureWidget id="quick-capture-1" />);
expect(screen.getByRole("button", { name: /add|capture|submit/i })).toBeInTheDocument();
});
it("should allow text input", async () => {
const user = userEvent.setup();
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
await user.type(input, "Quick note for later");
expect(input).toHaveValue("Quick note for later");
});
it("should submit note when button clicked", async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.type(input, "New quick note");
await user.click(button);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/api"),
expect.objectContaining({
method: "POST",
})
);
});
});
it("should clear input after successful submission", async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.type(input, "Test note");
await user.click(button);
await waitFor(() => {
expect(input).toHaveValue("");
});
});
it("should handle submission errors", async () => {
const user = userEvent.setup();
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.type(input, "Test note");
await user.click(button);
await waitFor(() => {
expect(screen.getByText(/error|failed/i)).toBeInTheDocument();
});
});
it("should not submit empty notes", async () => {
const user = userEvent.setup();
render(<QuickCaptureWidget id="quick-capture-1" />);
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.click(button);
expect(global.fetch).not.toHaveBeenCalled();
});
it("should support keyboard shortcut (Enter)", async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
await user.type(input, "Quick note{Enter}");
await waitFor(() => {
expect(global.fetch).toHaveBeenCalled();
});
});
it("should show success feedback after submission", async () => {
const user = userEvent.setup();
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<QuickCaptureWidget id="quick-capture-1" />);
const input = screen.getByRole("textbox");
const button = screen.getByRole("button", { name: /add|capture|submit/i });
await user.type(input, "Test note");
await user.click(button);
await waitFor(() => {
expect(screen.getByText(/success|saved|captured/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,127 @@
/**
* TasksWidget Component Tests
* Following TDD principles
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { TasksWidget } from "../TasksWidget";
// Mock fetch for API calls
global.fetch = vi.fn();
describe("TasksWidget", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should render loading state initially", () => {
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
render(<TasksWidget id="tasks-1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render task statistics", async () => {
const mockTasks = [
{ id: "1", title: "Task 1", status: "IN_PROGRESS", priority: "HIGH" },
{ id: "2", title: "Task 2", status: "COMPLETED", priority: "MEDIUM" },
{ id: "3", title: "Task 3", status: "NOT_STARTED", priority: "LOW" },
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockTasks,
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText("3")).toBeInTheDocument(); // Total
expect(screen.getByText("1")).toBeInTheDocument(); // In Progress
expect(screen.getByText("1")).toBeInTheDocument(); // Completed
});
});
it("should render task list", async () => {
const mockTasks = [
{ id: "1", title: "Complete documentation", status: "IN_PROGRESS", priority: "HIGH" },
{ id: "2", title: "Review PRs", status: "NOT_STARTED", priority: "MEDIUM" },
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockTasks,
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText("Complete documentation")).toBeInTheDocument();
expect(screen.getByText("Review PRs")).toBeInTheDocument();
});
});
it("should handle empty task list", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => [],
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText(/no tasks/i)).toBeInTheDocument();
});
});
it("should handle API errors gracefully", async () => {
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it("should display priority indicators", async () => {
const mockTasks = [
{ id: "1", title: "High priority task", status: "IN_PROGRESS", priority: "HIGH" },
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockTasks,
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
expect(screen.getByText("High priority task")).toBeInTheDocument();
// Priority icon should be rendered (high priority = red)
});
});
it("should limit displayed tasks to 5", async () => {
const mockTasks = Array.from({ length: 10 }, (_, i) => ({
id: `${i + 1}`,
title: `Task ${i + 1}`,
status: "NOT_STARTED",
priority: "MEDIUM",
}));
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockTasks,
});
render(<TasksWidget id="tasks-1" />);
await waitFor(() => {
const taskElements = screen.getAllByText(/Task \d+/);
expect(taskElements.length).toBeLessThanOrEqual(5);
});
});
});

View File

@@ -0,0 +1,135 @@
/**
* WidgetGrid Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { WidgetGrid } from "../WidgetGrid";
import type { WidgetPlacement } from "@mosaic/shared";
// Mock react-grid-layout
vi.mock("react-grid-layout", () => ({
default: ({ children }: any) => <div data-testid="grid-layout">{children}</div>,
Responsive: ({ children }: any) => <div data-testid="responsive-grid-layout">{children}</div>,
}));
describe("WidgetGrid", () => {
const mockLayout: WidgetPlacement[] = [
{ i: "tasks-1", x: 0, y: 0, w: 2, h: 2 },
{ i: "calendar-1", x: 2, y: 0, w: 2, h: 2 },
];
const mockOnLayoutChange = vi.fn();
it("should render grid layout", () => {
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
/>
);
expect(screen.getByTestId("grid-layout")).toBeInTheDocument();
});
it("should render widgets from layout", () => {
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
/>
);
// Should render correct number of widgets
const widgets = screen.getAllByTestId(/widget-/);
expect(widgets).toHaveLength(mockLayout.length);
});
it("should call onLayoutChange when layout changes", () => {
const { rerender } = render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
/>
);
const newLayout: WidgetPlacement[] = [
{ i: "tasks-1", x: 1, y: 0, w: 2, h: 2 },
{ i: "calendar-1", x: 2, y: 0, w: 2, h: 2 },
];
rerender(
<WidgetGrid
layout={newLayout}
onLayoutChange={mockOnLayoutChange}
/>
);
// Layout change handler should be set up (actual calls handled by react-grid-layout)
expect(mockOnLayoutChange).toBeDefined();
});
it("should support edit mode", () => {
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
isEditing={true}
/>
);
// In edit mode, widgets should have edit controls
expect(screen.getByTestId("grid-layout")).toBeInTheDocument();
});
it("should support read-only mode", () => {
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
isEditing={false}
/>
);
expect(screen.getByTestId("grid-layout")).toBeInTheDocument();
});
it("should render empty state when no widgets", () => {
render(
<WidgetGrid
layout={[]}
onLayoutChange={mockOnLayoutChange}
/>
);
expect(screen.getByText(/no widgets/i)).toBeInTheDocument();
});
it("should handle widget removal", async () => {
const mockOnRemoveWidget = vi.fn();
render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
onRemoveWidget={mockOnRemoveWidget}
isEditing={true}
/>
);
// Widget removal should be supported
expect(mockOnRemoveWidget).toBeDefined();
});
it("should apply custom className", () => {
const { container } = render(
<WidgetGrid
layout={mockLayout}
onLayoutChange={mockOnLayoutChange}
className="custom-grid"
/>
);
expect(container.querySelector(".custom-grid")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,91 @@
/**
* Widget Registry Tests
* Following TDD - write tests first!
*/
import { describe, it, expect } from "vitest";
import { widgetRegistry } from "../WidgetRegistry";
import { TasksWidget } from "../TasksWidget";
import { CalendarWidget } from "../CalendarWidget";
import { QuickCaptureWidget } from "../QuickCaptureWidget";
describe("WidgetRegistry", () => {
it("should have a registry of widgets", () => {
expect(widgetRegistry).toBeDefined();
expect(typeof widgetRegistry).toBe("object");
});
it("should include TasksWidget in registry", () => {
expect(widgetRegistry.TasksWidget).toBeDefined();
expect(widgetRegistry.TasksWidget.component).toBe(TasksWidget);
});
it("should include CalendarWidget in registry", () => {
expect(widgetRegistry.CalendarWidget).toBeDefined();
expect(widgetRegistry.CalendarWidget.component).toBe(CalendarWidget);
});
it("should include QuickCaptureWidget in registry", () => {
expect(widgetRegistry.QuickCaptureWidget).toBeDefined();
expect(widgetRegistry.QuickCaptureWidget.component).toBe(QuickCaptureWidget);
});
it("should have correct metadata for TasksWidget", () => {
const tasksWidget = widgetRegistry.TasksWidget;
expect(tasksWidget.name).toBe("TasksWidget");
expect(tasksWidget.displayName).toBe("Tasks");
expect(tasksWidget.description).toBeDefined();
expect(tasksWidget.defaultWidth).toBeGreaterThan(0);
expect(tasksWidget.defaultHeight).toBeGreaterThan(0);
expect(tasksWidget.minWidth).toBeGreaterThan(0);
expect(tasksWidget.minHeight).toBeGreaterThan(0);
});
it("should have correct metadata for CalendarWidget", () => {
const calendarWidget = widgetRegistry.CalendarWidget;
expect(calendarWidget.name).toBe("CalendarWidget");
expect(calendarWidget.displayName).toBe("Calendar");
expect(calendarWidget.description).toBeDefined();
expect(calendarWidget.defaultWidth).toBeGreaterThan(0);
expect(calendarWidget.defaultHeight).toBeGreaterThan(0);
});
it("should have correct metadata for QuickCaptureWidget", () => {
const quickCaptureWidget = widgetRegistry.QuickCaptureWidget;
expect(quickCaptureWidget.name).toBe("QuickCaptureWidget");
expect(quickCaptureWidget.displayName).toBe("Quick Capture");
expect(quickCaptureWidget.description).toBeDefined();
expect(quickCaptureWidget.defaultWidth).toBeGreaterThan(0);
expect(quickCaptureWidget.defaultHeight).toBeGreaterThan(0);
});
it("should export getWidgetByName helper", async () => {
const { getWidgetByName } = await import("../WidgetRegistry");
expect(typeof getWidgetByName).toBe("function");
});
it("getWidgetByName should return correct widget", async () => {
const { getWidgetByName } = await import("../WidgetRegistry");
const widget = getWidgetByName("TasksWidget");
expect(widget).toBeDefined();
expect(widget?.component).toBe(TasksWidget);
});
it("getWidgetByName should return undefined for invalid name", async () => {
const { getWidgetByName } = await import("../WidgetRegistry");
const widget = getWidgetByName("InvalidWidget");
expect(widget).toBeUndefined();
});
it("should export getAllWidgets helper", async () => {
const { getAllWidgets } = await import("../WidgetRegistry");
expect(typeof getAllWidgets).toBe("function");
});
it("getAllWidgets should return array of all widgets", async () => {
const { getAllWidgets } = await import("../WidgetRegistry");
const widgets = getAllWidgets();
expect(Array.isArray(widgets)).toBe(true);
expect(widgets.length).toBeGreaterThanOrEqual(3);
});
});

View File

@@ -0,0 +1,215 @@
/**
* useLayouts Hook Tests
* Following TDD principles
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
// We'll implement this hook
import { useLayouts, useCreateLayout, useUpdateLayout, useDeleteLayout } from "../useLayouts";
global.fetch = vi.fn();
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe("useLayouts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should fetch layouts on mount", async () => {
const mockLayouts = [
{ id: "1", name: "Default", isDefault: true, layout: [] },
{ id: "2", name: "Custom", isDefault: false, layout: [] },
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockLayouts,
});
const { result } = renderHook(() => useLayouts(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.data).toEqual(mockLayouts);
});
});
it("should handle fetch errors", async () => {
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
const { result } = renderHook(() => useLayouts(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it("should show loading state", () => {
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
const { result } = renderHook(() => useLayouts(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
});
});
describe("useCreateLayout", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should create a new layout", async () => {
const mockLayout = {
id: "3",
name: "New Layout",
isDefault: false,
layout: [],
};
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockLayout,
});
const { result } = renderHook(() => useCreateLayout(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: "New Layout",
layout: [],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).toEqual(mockLayout);
});
});
it("should handle creation errors", async () => {
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
const { result } = renderHook(() => useCreateLayout(), {
wrapper: createWrapper(),
});
result.current.mutate({
name: "New Layout",
layout: [],
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});
describe("useUpdateLayout", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should update an existing layout", async () => {
const mockLayout = {
id: "1",
name: "Updated Layout",
isDefault: false,
layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }],
};
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockLayout,
});
const { result } = renderHook(() => useUpdateLayout(), {
wrapper: createWrapper(),
});
result.current.mutate({
id: "1",
name: "Updated Layout",
layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).toEqual(mockLayout);
});
});
it("should handle update errors", async () => {
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
const { result } = renderHook(() => useUpdateLayout(), {
wrapper: createWrapper(),
});
result.current.mutate({
id: "1",
name: "Updated Layout",
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});
describe("useDeleteLayout", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should delete a layout", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
const { result } = renderHook(() => useDeleteLayout(), {
wrapper: createWrapper(),
});
result.current.mutate("1");
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it("should handle deletion errors", async () => {
(global.fetch as any).mockRejectedValueOnce(new Error("API Error"));
const { result } = renderHook(() => useDeleteLayout(), {
wrapper: createWrapper(),
});
result.current.mutate("1");
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});

View File

@@ -0,0 +1,176 @@
/**
* React Query hooks for layout management
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
const LAYOUTS_KEY = ["layouts"];
interface CreateLayoutData {
name: string;
layout: WidgetPlacement[];
isDefault?: boolean;
metadata?: Record<string, unknown>;
}
interface UpdateLayoutData {
id: string;
name?: string;
layout?: WidgetPlacement[];
isDefault?: boolean;
metadata?: Record<string, unknown>;
}
/**
* Fetch all layouts for the current user
*/
export function useLayouts() {
return useQuery<UserLayout[]>({
queryKey: LAYOUTS_KEY,
queryFn: async () => {
const response = await fetch("/api/layouts");
if (!response.ok) {
throw new Error("Failed to fetch layouts");
}
return response.json();
},
});
}
/**
* Fetch a single layout by ID
*/
export function useLayout(id: string) {
return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, id],
queryFn: async () => {
const response = await fetch(`/api/layouts/${id}`);
if (!response.ok) {
throw new Error("Failed to fetch layout");
}
return response.json();
},
enabled: !!id,
});
}
/**
* Fetch the default layout
*/
export function useDefaultLayout() {
return useQuery<UserLayout>({
queryKey: [...LAYOUTS_KEY, "default"],
queryFn: async () => {
const response = await fetch("/api/layouts/default");
if (!response.ok) {
throw new Error("Failed to fetch default layout");
}
return response.json();
},
});
}
/**
* Create a new layout
*/
export function useCreateLayout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateLayoutData) => {
const response = await fetch("/api/layouts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Failed to create layout");
}
return response.json();
},
onSuccess: () => {
// Invalidate layouts cache to refetch
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
},
});
}
/**
* Update an existing layout
*/
export function useUpdateLayout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: UpdateLayoutData) => {
const response = await fetch(`/api/layouts/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("Failed to update layout");
}
return response.json();
},
onSuccess: (_, variables) => {
// Invalidate affected queries
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
queryClient.invalidateQueries({ queryKey: [...LAYOUTS_KEY, variables.id] });
},
});
}
/**
* Delete a layout
*/
export function useDeleteLayout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const response = await fetch(`/api/layouts/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete layout");
}
return response.json();
},
onSuccess: () => {
// Invalidate layouts cache to refetch
queryClient.invalidateQueries({ queryKey: LAYOUTS_KEY });
},
});
}
/**
* Helper hook to save layout changes with debouncing
*/
export function useSaveLayout(layoutId: string) {
const updateLayout = useUpdateLayout();
const saveLayout = (layout: WidgetPlacement[]) => {
updateLayout.mutate({
id: layoutId,
layout,
});
};
return {
saveLayout,
isSaving: updateLayout.isPending,
error: updateLayout.error,
};
}

View File

@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useWebSocket } from './useWebSocket';
import { io, Socket } from 'socket.io-client';
// Mock socket.io-client
vi.mock('socket.io-client');
describe('useWebSocket', () => {
let mockSocket: Partial<Socket>;
let eventHandlers: Record<string, (data: unknown) => void>;
beforeEach(() => {
eventHandlers = {};
mockSocket = {
on: vi.fn((event: string, handler: (data: unknown) => void) => {
eventHandlers[event] = handler;
return mockSocket as Socket;
}),
off: vi.fn((event: string) => {
delete eventHandlers[event];
return mockSocket as Socket;
}),
connect: vi.fn(),
disconnect: vi.fn(),
connected: false,
};
(io as unknown as ReturnType<typeof vi.fn>).mockReturnValue(mockSocket);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should connect to WebSocket server on mount', () => {
const workspaceId = 'workspace-123';
const token = 'auth-token';
renderHook(() => useWebSocket(workspaceId, token));
expect(io).toHaveBeenCalledWith(expect.any(String), {
auth: { token },
query: { workspaceId },
});
});
it('should disconnect on unmount', () => {
const { unmount } = renderHook(() => useWebSocket('workspace-123', 'token'));
unmount();
expect(mockSocket.disconnect).toHaveBeenCalled();
});
it('should update connection status on connect event', async () => {
mockSocket.connected = false;
const { result } = renderHook(() => useWebSocket('workspace-123', 'token'));
expect(result.current.isConnected).toBe(false);
act(() => {
mockSocket.connected = true;
eventHandlers['connect']?.(undefined);
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
});
it('should update connection status on disconnect event', async () => {
mockSocket.connected = true;
const { result } = renderHook(() => useWebSocket('workspace-123', 'token'));
act(() => {
eventHandlers['connect']?.(undefined);
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
act(() => {
mockSocket.connected = false;
eventHandlers['disconnect']?.(undefined);
});
await waitFor(() => {
expect(result.current.isConnected).toBe(false);
});
});
it('should handle task:created events', async () => {
const onTaskCreated = vi.fn();
renderHook(() => useWebSocket('workspace-123', 'token', { onTaskCreated }));
const task = { id: 'task-1', title: 'New Task' };
act(() => {
eventHandlers['task:created']?.(task);
});
await waitFor(() => {
expect(onTaskCreated).toHaveBeenCalledWith(task);
});
});
it('should handle task:updated events', async () => {
const onTaskUpdated = vi.fn();
renderHook(() => useWebSocket('workspace-123', 'token', { onTaskUpdated }));
const task = { id: 'task-1', title: 'Updated Task' };
act(() => {
eventHandlers['task:updated']?.(task);
});
await waitFor(() => {
expect(onTaskUpdated).toHaveBeenCalledWith(task);
});
});
it('should handle task:deleted events', async () => {
const onTaskDeleted = vi.fn();
renderHook(() => useWebSocket('workspace-123', 'token', { onTaskDeleted }));
const payload = { id: 'task-1' };
act(() => {
eventHandlers['task:deleted']?.(payload);
});
await waitFor(() => {
expect(onTaskDeleted).toHaveBeenCalledWith(payload);
});
});
it('should handle event:created events', async () => {
const onEventCreated = vi.fn();
renderHook(() => useWebSocket('workspace-123', 'token', { onEventCreated }));
const event = { id: 'event-1', title: 'New Event' };
act(() => {
eventHandlers['event:created']?.(event);
});
await waitFor(() => {
expect(onEventCreated).toHaveBeenCalledWith(event);
});
});
it('should handle event:updated events', async () => {
const onEventUpdated = vi.fn();
renderHook(() => useWebSocket('workspace-123', 'token', { onEventUpdated }));
const event = { id: 'event-1', title: 'Updated Event' };
act(() => {
eventHandlers['event:updated']?.(event);
});
await waitFor(() => {
expect(onEventUpdated).toHaveBeenCalledWith(event);
});
});
it('should handle event:deleted events', async () => {
const onEventDeleted = vi.fn();
renderHook(() => useWebSocket('workspace-123', 'token', { onEventDeleted }));
const payload = { id: 'event-1' };
act(() => {
eventHandlers['event:deleted']?.(payload);
});
await waitFor(() => {
expect(onEventDeleted).toHaveBeenCalledWith(payload);
});
});
it('should handle project:updated events', async () => {
const onProjectUpdated = vi.fn();
renderHook(() => useWebSocket('workspace-123', 'token', { onProjectUpdated }));
const project = { id: 'project-1', name: 'Updated Project' };
act(() => {
eventHandlers['project:updated']?.(project);
});
await waitFor(() => {
expect(onProjectUpdated).toHaveBeenCalledWith(project);
});
});
it('should reconnect with new workspace ID', () => {
const { rerender } = renderHook(
({ workspaceId }: { workspaceId: string }) => useWebSocket(workspaceId, 'token'),
{ initialProps: { workspaceId: 'workspace-1' } }
);
expect(io).toHaveBeenCalledTimes(1);
rerender({ workspaceId: 'workspace-2' });
expect(mockSocket.disconnect).toHaveBeenCalled();
expect(io).toHaveBeenCalledTimes(2);
});
it('should clean up all event listeners on unmount', () => {
const { unmount } = renderHook(() =>
useWebSocket('workspace-123', 'token', {
onTaskCreated: vi.fn(),
onTaskUpdated: vi.fn(),
onTaskDeleted: vi.fn(),
})
);
unmount();
expect(mockSocket.off).toHaveBeenCalledWith('connect', expect.any(Function));
expect(mockSocket.off).toHaveBeenCalledWith('disconnect', expect.any(Function));
expect(mockSocket.off).toHaveBeenCalledWith('task:created', expect.any(Function));
expect(mockSocket.off).toHaveBeenCalledWith('task:updated', expect.any(Function));
expect(mockSocket.off).toHaveBeenCalledWith('task:deleted', expect.any(Function));
});
});

View File

@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
interface Task {
id: string;
[key: string]: unknown;
}
interface Event {
id: string;
[key: string]: unknown;
}
interface Project {
id: string;
[key: string]: unknown;
}
interface DeletePayload {
id: string;
}
interface WebSocketCallbacks {
onTaskCreated?: (task: Task) => void;
onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: (payload: DeletePayload) => void;
onEventCreated?: (event: Event) => void;
onEventUpdated?: (event: Event) => void;
onEventDeleted?: (payload: DeletePayload) => void;
onProjectUpdated?: (project: Project) => void;
}
interface UseWebSocketReturn {
isConnected: boolean;
socket: Socket | null;
}
/**
* Hook for managing WebSocket connections and real-time updates
*
* @param workspaceId - The workspace ID to subscribe to
* @param token - Authentication token
* @param callbacks - Event callbacks for real-time updates
* @returns Connection status and socket instance
*/
export function useWebSocket(
workspaceId: string,
token: string,
callbacks: WebSocketCallbacks = {}
): UseWebSocketReturn {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState<boolean>(false);
const {
onTaskCreated,
onTaskUpdated,
onTaskDeleted,
onEventCreated,
onEventUpdated,
onEventDeleted,
onProjectUpdated,
} = callbacks;
useEffect(() => {
// Get WebSocket URL from environment or default to API URL
const wsUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// Create socket connection
const newSocket = io(wsUrl, {
auth: { token },
query: { workspaceId },
});
setSocket(newSocket);
// Connection event handlers
const handleConnect = (): void => {
setIsConnected(true);
};
const handleDisconnect = (): void => {
setIsConnected(false);
};
newSocket.on('connect', handleConnect);
newSocket.on('disconnect', handleDisconnect);
// Real-time event handlers
if (onTaskCreated) {
newSocket.on('task:created', onTaskCreated);
}
if (onTaskUpdated) {
newSocket.on('task:updated', onTaskUpdated);
}
if (onTaskDeleted) {
newSocket.on('task:deleted', onTaskDeleted);
}
if (onEventCreated) {
newSocket.on('event:created', onEventCreated);
}
if (onEventUpdated) {
newSocket.on('event:updated', onEventUpdated);
}
if (onEventDeleted) {
newSocket.on('event:deleted', onEventDeleted);
}
if (onProjectUpdated) {
newSocket.on('project:updated', onProjectUpdated);
}
// Cleanup on unmount or dependency change
return (): void => {
newSocket.off('connect', handleConnect);
newSocket.off('disconnect', handleDisconnect);
if (onTaskCreated) newSocket.off('task:created', onTaskCreated);
if (onTaskUpdated) newSocket.off('task:updated', onTaskUpdated);
if (onTaskDeleted) newSocket.off('task:deleted', onTaskDeleted);
if (onEventCreated) newSocket.off('event:created', onEventCreated);
if (onEventUpdated) newSocket.off('event:updated', onEventUpdated);
if (onEventDeleted) newSocket.off('event:deleted', onEventDeleted);
if (onProjectUpdated) newSocket.off('project:updated', onProjectUpdated);
newSocket.disconnect();
};
}, [
workspaceId,
token,
onTaskCreated,
onTaskUpdated,
onTaskDeleted,
onEventCreated,
onEventUpdated,
onEventDeleted,
onProjectUpdated,
]);
return {
isConnected,
socket,
};
}

View File

@@ -0,0 +1,81 @@
/**
* Personality API Client
* Handles personality-related API requests
*/
import type { Personality, FormalityLevel } from "@mosaic/shared";
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
/**
* Create personality DTO
*/
export interface CreatePersonalityDto {
name: string;
description?: string;
tone: string;
formalityLevel: FormalityLevel;
systemPromptTemplate: string;
isDefault?: boolean;
isActive?: boolean;
}
/**
* Update personality DTO
*/
export interface UpdatePersonalityDto {
name?: string;
description?: string;
tone?: string;
formalityLevel?: FormalityLevel;
systemPromptTemplate?: string;
isDefault?: boolean;
isActive?: boolean;
}
/**
* Fetch all personalities
*/
export async function fetchPersonalities(
isActive: boolean = true
): Promise<ApiResponse<Personality[]>> {
const endpoint = `/api/personalities?isActive=${isActive}`;
return apiGet<ApiResponse<Personality[]>>(endpoint);
}
/**
* Fetch the default personality
*/
export async function fetchDefaultPersonality(): Promise<Personality> {
return apiGet<Personality>("/api/personalities/default");
}
/**
* Fetch a single personality by ID
*/
export async function fetchPersonality(id: string): Promise<Personality> {
return apiGet<Personality>(`/api/personalities/${id}`);
}
/**
* Create a new personality
*/
export async function createPersonality(data: CreatePersonalityDto): Promise<Personality> {
return apiPost<Personality>("/api/personalities", data);
}
/**
* Update a personality
*/
export async function updatePersonality(
id: string,
data: UpdatePersonalityDto
): Promise<Personality> {
return apiPatch<Personality>(`/api/personalities/${id}`, data);
}
/**
* Delete a personality
*/
export async function deletePersonality(id: string): Promise<void> {
return apiDelete<void>(`/api/personalities/${id}`);
}

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { WebSocketProvider, useWebSocketContext } from './WebSocketProvider';
import * as useWebSocketModule from '../hooks/useWebSocket';
// Mock the useWebSocket hook
vi.mock('../hooks/useWebSocket');
describe('WebSocketProvider', () => {
it('should provide WebSocket context to children', () => {
const mockUseWebSocket = vi.spyOn(useWebSocketModule, 'useWebSocket');
mockUseWebSocket.mockReturnValue({
isConnected: true,
socket: null,
});
function TestComponent(): React.JSX.Element {
const { isConnected } = useWebSocketContext();
return <div>{isConnected ? 'Connected' : 'Disconnected'}</div>;
}
render(
<WebSocketProvider workspaceId="workspace-123" token="auth-token">
<TestComponent />
</WebSocketProvider>
);
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('should pass callbacks to useWebSocket hook', () => {
const mockUseWebSocket = vi.spyOn(useWebSocketModule, 'useWebSocket');
mockUseWebSocket.mockReturnValue({
isConnected: false,
socket: null,
});
const onTaskCreated = vi.fn();
const onTaskUpdated = vi.fn();
const onTaskDeleted = vi.fn();
render(
<WebSocketProvider
workspaceId="workspace-123"
token="auth-token"
onTaskCreated={onTaskCreated}
onTaskUpdated={onTaskUpdated}
onTaskDeleted={onTaskDeleted}
>
<div>Test</div>
</WebSocketProvider>
);
expect(mockUseWebSocket).toHaveBeenCalledWith(
'workspace-123',
'auth-token',
{
onTaskCreated,
onTaskUpdated,
onTaskDeleted,
onEventCreated: undefined,
onEventUpdated: undefined,
onEventDeleted: undefined,
onProjectUpdated: undefined,
}
);
});
it('should throw error when useWebSocketContext is used outside provider', () => {
function TestComponent(): React.JSX.Element {
useWebSocketContext();
return <div>Test</div>;
}
// Suppress console.error for this test
const originalError = console.error;
console.error = vi.fn();
expect(() => {
render(<TestComponent />);
}).toThrow('useWebSocketContext must be used within WebSocketProvider');
console.error = originalError;
});
it('should update context when connection status changes', () => {
const mockUseWebSocket = vi.spyOn(useWebSocketModule, 'useWebSocket');
// Initially disconnected
mockUseWebSocket.mockReturnValue({
isConnected: false,
socket: null,
});
function TestComponent(): React.JSX.Element {
const { isConnected } = useWebSocketContext();
return <div data-testid="status">{isConnected ? 'Connected' : 'Disconnected'}</div>;
}
const { rerender } = render(
<WebSocketProvider workspaceId="workspace-123" token="auth-token">
<TestComponent />
</WebSocketProvider>
);
expect(screen.getByTestId('status')).toHaveTextContent('Disconnected');
// Update to connected
mockUseWebSocket.mockReturnValue({
isConnected: true,
socket: null,
});
rerender(
<WebSocketProvider workspaceId="workspace-123" token="auth-token">
<TestComponent />
</WebSocketProvider>
);
expect(screen.getByTestId('status')).toHaveTextContent('Connected');
});
});

View File

@@ -0,0 +1,94 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { Socket } from 'socket.io-client';
interface Task {
id: string;
[key: string]: unknown;
}
interface Event {
id: string;
[key: string]: unknown;
}
interface Project {
id: string;
[key: string]: unknown;
}
interface DeletePayload {
id: string;
}
interface WebSocketContextValue {
isConnected: boolean;
socket: Socket | null;
}
interface WebSocketProviderProps {
workspaceId: string;
token: string;
onTaskCreated?: (task: Task) => void;
onTaskUpdated?: (task: Task) => void;
onTaskDeleted?: (payload: DeletePayload) => void;
onEventCreated?: (event: Event) => void;
onEventUpdated?: (event: Event) => void;
onEventDeleted?: (payload: DeletePayload) => void;
onProjectUpdated?: (project: Project) => void;
children: ReactNode;
}
const WebSocketContext = createContext<WebSocketContextValue | undefined>(undefined);
/**
* WebSocket Provider component
* Manages WebSocket connection and provides context to children
*/
export function WebSocketProvider({
workspaceId,
token,
onTaskCreated,
onTaskUpdated,
onTaskDeleted,
onEventCreated,
onEventUpdated,
onEventDeleted,
onProjectUpdated,
children,
}: WebSocketProviderProps): React.JSX.Element {
const { isConnected, socket } = useWebSocket(workspaceId, token, {
onTaskCreated: onTaskCreated ?? undefined,
onTaskUpdated: onTaskUpdated ?? undefined,
onTaskDeleted: onTaskDeleted ?? undefined,
onEventCreated: onEventCreated ?? undefined,
onEventUpdated: onEventUpdated ?? undefined,
onEventDeleted: onEventDeleted ?? undefined,
onProjectUpdated: onProjectUpdated ?? undefined,
});
const value: WebSocketContextValue = {
isConnected,
socket,
};
return (
<WebSocketContext.Provider value={value}>
{children}
</WebSocketContext.Provider>
);
}
/**
* Hook to access WebSocket context
* @throws Error if used outside WebSocketProvider
*/
export function useWebSocketContext(): WebSocketContextValue {
const context = useContext(WebSocketContext);
if (context === undefined) {
throw new Error('useWebSocketContext must be used within WebSocketProvider');
}
return context;
}