Merge feature/82-personality-module (#82) into develop

Implements Personality Module:
- Personality model and Prisma migration
- CRUD API with controller and service
- Comprehensive test suite
- Integration with workspace
This commit is contained in:
Jason Woltje
2026-01-29 17:59:28 -06:00
43 changed files with 4782 additions and 2 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,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,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,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;
}