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:
136
apps/web/src/components/domains/DomainFilter.test.tsx
Normal file
136
apps/web/src/components/domains/DomainFilter.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
52
apps/web/src/components/domains/DomainFilter.tsx
Normal file
52
apps/web/src/components/domains/DomainFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/domains/DomainItem.tsx
Normal file
62
apps/web/src/components/domains/DomainItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/domains/DomainList.test.tsx
Normal file
93
apps/web/src/components/domains/DomainList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
51
apps/web/src/components/domains/DomainList.tsx
Normal file
51
apps/web/src/components/domains/DomainList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
apps/web/src/components/domains/DomainSelector.test.tsx
Normal file
127
apps/web/src/components/domains/DomainSelector.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
38
apps/web/src/components/domains/DomainSelector.tsx
Normal file
38
apps/web/src/components/domains/DomainSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
apps/web/src/components/filters/FilterBar.test.tsx
Normal file
140
apps/web/src/components/filters/FilterBar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
207
apps/web/src/components/filters/FilterBar.tsx
Normal file
207
apps/web/src/components/filters/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
apps/web/src/components/filters/index.ts
Normal file
1
apps/web/src/components/filters/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./FilterBar";
|
||||
195
apps/web/src/components/personalities/PersonalityForm.tsx
Normal file
195
apps/web/src/components/personalities/PersonalityForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
apps/web/src/components/personalities/PersonalityPreview.tsx
Normal file
121
apps/web/src/components/personalities/PersonalityPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user