Merge branch 'develop' into feature/52-active-projects-widget
Some checks failed
ci/woodpecker/pr/woodpecker Pipeline failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
2026-02-04 01:36:57 +00:00
495 changed files with 26723 additions and 2242 deletions

View File

@@ -0,0 +1,156 @@
/**
* AggregatedDataGrid Component Tests
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { AggregatedDataGrid } from "./AggregatedDataGrid";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
import type { FederatedTask, FederatedEvent } from "./types";
const mockTasks: FederatedTask[] = [
{
task: {
id: "task-1",
title: "Task from Work",
description: "Work task",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-05"),
creatorId: "user-1",
assigneeId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-02-03"),
updatedAt: new Date("2026-02-03"),
},
provenance: {
instanceId: "instance-work-001",
instanceName: "Work Instance",
instanceUrl: "https://mosaic.work.example.com",
timestamp: "2026-02-03T14:00:00Z",
},
},
{
task: {
id: "task-2",
title: "Task from Home",
description: "Home task",
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-06"),
creatorId: "user-1",
assigneeId: "user-1",
workspaceId: "workspace-2",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-02-03"),
updatedAt: new Date("2026-02-03"),
},
provenance: {
instanceId: "instance-home-001",
instanceName: "Home Instance",
instanceUrl: "https://mosaic.home.example.com",
timestamp: "2026-02-03T14:00:00Z",
},
},
];
const mockEvents: FederatedEvent[] = [
{
event: {
id: "event-1",
title: "Meeting from Work",
description: "Team standup",
startTime: new Date("2026-02-05T10:00:00"),
endTime: new Date("2026-02-05T10:30:00"),
allDay: false,
location: "Zoom",
recurrence: null,
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
metadata: {},
createdAt: new Date("2026-02-03"),
updatedAt: new Date("2026-02-03"),
},
provenance: {
instanceId: "instance-work-001",
instanceName: "Work Instance",
instanceUrl: "https://mosaic.work.example.com",
timestamp: "2026-02-03T14:00:00Z",
},
},
];
describe("AggregatedDataGrid", () => {
it("should render tasks", () => {
render(<AggregatedDataGrid tasks={mockTasks} events={[]} />);
expect(screen.getByText("Task from Work")).toBeInTheDocument();
expect(screen.getByText("Task from Home")).toBeInTheDocument();
});
it("should render events", () => {
render(<AggregatedDataGrid tasks={[]} events={mockEvents} />);
expect(screen.getByText("Meeting from Work")).toBeInTheDocument();
});
it("should render both tasks and events", () => {
render(<AggregatedDataGrid tasks={mockTasks} events={mockEvents} />);
expect(screen.getByText("Task from Work")).toBeInTheDocument();
expect(screen.getByText("Meeting from Work")).toBeInTheDocument();
});
it("should show loading state", () => {
render(<AggregatedDataGrid tasks={[]} events={[]} isLoading={true} />);
expect(screen.getByText("Loading data from instances...")).toBeInTheDocument();
});
it("should show empty state when no data", () => {
render(<AggregatedDataGrid tasks={[]} events={[]} />);
expect(screen.getByText("No data available from connected instances")).toBeInTheDocument();
});
it("should show error message", () => {
render(<AggregatedDataGrid tasks={[]} events={[]} error="Unable to reach work instance" />);
expect(screen.getByText("Unable to reach work instance")).toBeInTheDocument();
});
it("should render with custom className", () => {
const { container } = render(
<AggregatedDataGrid tasks={mockTasks} events={[]} className="custom-class" />
);
expect(container.querySelector(".custom-class")).toBeInTheDocument();
});
it("should show instance provenance indicators", () => {
render(<AggregatedDataGrid tasks={mockTasks} events={[]} />);
expect(screen.getByText("Work Instance")).toBeInTheDocument();
expect(screen.getByText("Home Instance")).toBeInTheDocument();
});
it("should render with compact mode", () => {
const { container } = render(
<AggregatedDataGrid tasks={mockTasks} events={[]} compact={true} />
);
// Check that cards have compact padding
const compactCards = container.querySelectorAll(".p-3");
expect(compactCards.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,100 @@
/**
* AggregatedDataGrid Component
* Displays aggregated tasks and events from multiple federated instances
*/
import type { FederatedTask, FederatedEvent } from "./types";
import { FederatedTaskCard } from "./FederatedTaskCard";
import { FederatedEventCard } from "./FederatedEventCard";
interface AggregatedDataGridProps {
tasks: FederatedTask[];
events: FederatedEvent[];
isLoading?: boolean;
error?: string;
compact?: boolean;
className?: string;
}
export function AggregatedDataGrid({
tasks,
events,
isLoading = false,
error,
compact = false,
className = "",
}: AggregatedDataGridProps): React.JSX.Element {
// Loading state
if (isLoading) {
return (
<div className={`flex items-center justify-center p-8 ${className}`}>
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
<p className="text-gray-600">Loading data from instances...</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className={`flex items-center justify-center p-8 ${className}`}>
<div className="text-center">
<span className="text-4xl mb-4 block"></span>
<p className="text-gray-900 font-medium mb-2">Unable to load data</p>
<p className="text-gray-600 text-sm">{error}</p>
</div>
</div>
);
}
// Empty state
if (tasks.length === 0 && events.length === 0) {
return (
<div className={`flex items-center justify-center p-8 ${className}`}>
<div className="text-center">
<span className="text-4xl mb-4 block">📋</span>
<p className="text-gray-900 font-medium mb-2">No data available</p>
<p className="text-gray-600 text-sm">No data available from connected instances</p>
</div>
</div>
);
}
return (
<div className={`space-y-4 ${className}`}>
{/* Tasks section */}
{tasks.length > 0 && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-3">Tasks ({tasks.length})</h3>
<div className="space-y-2">
{tasks.map((federatedTask) => (
<FederatedTaskCard
key={`task-${federatedTask.task.id}-${federatedTask.provenance.instanceId}`}
federatedTask={federatedTask}
compact={compact}
/>
))}
</div>
</div>
)}
{/* Events section */}
{events.length > 0 && (
<div>
<h3 className="text-lg font-medium text-gray-900 mb-3">Events ({events.length})</h3>
<div className="space-y-2">
{events.map((federatedEvent) => (
<FederatedEventCard
key={`event-${federatedEvent.event.id}-${federatedEvent.provenance.instanceId}`}
federatedEvent={federatedEvent}
compact={compact}
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,201 @@
/**
* ConnectionCard Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionCard } from "./ConnectionCard";
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
describe("ConnectionCard", (): void => {
const mockActiveConnection: ConnectionDetails = {
id: "conn-1",
workspaceId: "workspace-1",
remoteInstanceId: "instance-work-001",
remoteUrl: "https://mosaic.work.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: true,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.ACTIVE,
metadata: {
name: "Work Instance",
description: "Corporate Mosaic instance",
},
createdAt: new Date("2026-02-01").toISOString(),
updatedAt: new Date("2026-02-01").toISOString(),
connectedAt: new Date("2026-02-01").toISOString(),
disconnectedAt: null,
};
const mockPendingConnection: ConnectionDetails = {
...mockActiveConnection,
id: "conn-2",
status: FederationConnectionStatus.PENDING,
metadata: {
name: "Partner Instance",
description: "Awaiting acceptance",
},
connectedAt: null,
};
const mockOnAccept = vi.fn();
const mockOnReject = vi.fn();
const mockOnDisconnect = vi.fn();
it("should render connection name from metadata", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
expect(screen.getByText("Work Instance")).toBeInTheDocument();
});
it("should render connection URL", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
expect(screen.getByText("https://mosaic.work.example.com")).toBeInTheDocument();
});
it("should render connection description from metadata", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
expect(screen.getByText("Corporate Mosaic instance")).toBeInTheDocument();
});
it("should show Active status with green indicator for active connections", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("should show Pending status with blue indicator for pending connections", (): void => {
render(<ConnectionCard connection={mockPendingConnection} />);
expect(screen.getByText("Pending")).toBeInTheDocument();
});
it("should show Disconnected status for disconnected connections", (): void => {
const disconnectedConnection = {
...mockActiveConnection,
status: FederationConnectionStatus.DISCONNECTED,
disconnectedAt: new Date("2026-02-02").toISOString(),
};
render(<ConnectionCard connection={disconnectedConnection} />);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
});
it("should show Rejected status for rejected connections", (): void => {
const rejectedConnection = {
...mockActiveConnection,
status: FederationConnectionStatus.REJECTED,
};
render(<ConnectionCard connection={rejectedConnection} />);
expect(screen.getByText("Rejected")).toBeInTheDocument();
});
it("should show Disconnect button for active connections", (): void => {
render(<ConnectionCard connection={mockActiveConnection} onDisconnect={mockOnDisconnect} />);
expect(screen.getByRole("button", { name: /disconnect/i })).toBeInTheDocument();
});
it("should show Accept and Reject buttons for pending connections", (): void => {
render(
<ConnectionCard
connection={mockPendingConnection}
onAccept={mockOnAccept}
onReject={mockOnReject}
/>
);
expect(screen.getByRole("button", { name: /accept/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument();
});
it("should not show action buttons for disconnected connections", (): void => {
const disconnectedConnection = {
...mockActiveConnection,
status: FederationConnectionStatus.DISCONNECTED,
};
render(<ConnectionCard connection={disconnectedConnection} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should call onAccept when accept button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<ConnectionCard
connection={mockPendingConnection}
onAccept={mockOnAccept}
onReject={mockOnReject}
/>
);
const acceptButton = screen.getByRole("button", { name: /accept/i });
await user.click(acceptButton);
expect(mockOnAccept).toHaveBeenCalledWith(mockPendingConnection.id);
});
it("should call onReject when reject button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<ConnectionCard
connection={mockPendingConnection}
onAccept={mockOnAccept}
onReject={mockOnReject}
/>
);
const rejectButton = screen.getByRole("button", { name: /reject/i });
await user.click(rejectButton);
expect(mockOnReject).toHaveBeenCalledWith(mockPendingConnection.id);
});
it("should call onDisconnect when disconnect button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(<ConnectionCard connection={mockActiveConnection} onDisconnect={mockOnDisconnect} />);
const disconnectButton = screen.getByRole("button", { name: /disconnect/i });
await user.click(disconnectButton);
expect(mockOnDisconnect).toHaveBeenCalledWith(mockActiveConnection.id);
});
it("should display capabilities when showDetails is true", (): void => {
render(<ConnectionCard connection={mockActiveConnection} showDetails={true} />);
expect(screen.getByText(/Query/i)).toBeInTheDocument();
expect(screen.getByText(/Command/i)).toBeInTheDocument();
expect(screen.getByText(/Events/i)).toBeInTheDocument();
expect(screen.getByText(/Agent Spawn/i)).toBeInTheDocument();
});
it("should not display capabilities by default", (): void => {
render(<ConnectionCard connection={mockActiveConnection} />);
// Capabilities should not be visible without showDetails=true
const card = screen.getByText("Work Instance").closest("div");
expect(card?.textContent).not.toMatch(/Query.*Command.*Events/);
});
it("should use fallback name if metadata name is missing", (): void => {
const connectionWithoutName = {
...mockActiveConnection,
metadata: {},
};
render(<ConnectionCard connection={connectionWithoutName} />);
expect(screen.getByText("Remote Instance")).toBeInTheDocument();
});
it("should render with compact layout when compact prop is true", (): void => {
const { container } = render(
<ConnectionCard connection={mockActiveConnection} compact={true} />
);
// Verify compact class is applied
expect(container.querySelector(".p-3")).toBeInTheDocument();
});
it("should render with full layout by default", (): void => {
const { container } = render(<ConnectionCard connection={mockActiveConnection} />);
// Verify full padding is applied
expect(container.querySelector(".p-4")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,152 @@
/**
* ConnectionCard Component
* Displays a single federation connection with PDA-friendly design
*/
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
interface ConnectionCardProps {
connection: ConnectionDetails;
onAccept?: (connectionId: string) => void;
onReject?: (connectionId: string) => void;
onDisconnect?: (connectionId: string) => void;
showDetails?: boolean;
compact?: boolean;
}
/**
* Get PDA-friendly status text and color
*/
function getStatusDisplay(status: FederationConnectionStatus): {
text: string;
colorClass: string;
icon: string;
} {
switch (status) {
case FederationConnectionStatus.ACTIVE:
return {
text: "Active",
colorClass: "text-green-600 bg-green-50",
icon: "🟢",
};
case FederationConnectionStatus.PENDING:
return {
text: "Pending",
colorClass: "text-blue-600 bg-blue-50",
icon: "🔵",
};
case FederationConnectionStatus.DISCONNECTED:
return {
text: "Disconnected",
colorClass: "text-yellow-600 bg-yellow-50",
icon: "⏸️",
};
case FederationConnectionStatus.REJECTED:
return {
text: "Rejected",
colorClass: "text-gray-600 bg-gray-50",
icon: "⚪",
};
}
}
export function ConnectionCard({
connection,
onAccept,
onReject,
onDisconnect,
showDetails = false,
compact = false,
}: ConnectionCardProps): React.JSX.Element {
const status = getStatusDisplay(connection.status);
const name =
typeof connection.metadata.name === "string" ? connection.metadata.name : "Remote Instance";
const description = connection.metadata.description as string | undefined;
const paddingClass = compact ? "p-3" : "p-4";
return (
<div
className={`border border-gray-200 rounded-lg ${paddingClass} hover:border-gray-300 transition-colors`}
>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">{name}</h3>
<p className="text-sm text-gray-600 mt-1">{connection.remoteUrl}</p>
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
</div>
{/* Status Badge */}
<div
className={`flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium ${status.colorClass}`}
>
<span>{status.icon}</span>
<span>{status.text}</span>
</div>
</div>
{/* Capabilities (when showDetails is true) */}
{showDetails && (
<div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs font-medium text-gray-700 mb-2">Capabilities</p>
<div className="flex flex-wrap gap-2">
{connection.remoteCapabilities.supportsQuery && (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Query</span>
)}
{connection.remoteCapabilities.supportsCommand && (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Command</span>
)}
{connection.remoteCapabilities.supportsEvent && (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Events</span>
)}
{connection.remoteCapabilities.supportsAgentSpawn && (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">
Agent Spawn
</span>
)}
</div>
</div>
)}
{/* Actions */}
{connection.status === FederationConnectionStatus.PENDING && (onAccept ?? onReject) && (
<div className="mt-4 flex gap-2">
{onAccept && (
<button
onClick={() => {
onAccept(connection.id);
}}
className="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors"
>
Accept
</button>
)}
{onReject && (
<button
onClick={() => {
onReject(connection.id);
}}
className="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Reject
</button>
)}
</div>
)}
{connection.status === FederationConnectionStatus.ACTIVE && onDisconnect && (
<div className="mt-4">
<button
onClick={() => {
onDisconnect(connection.id);
}}
className="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Disconnect
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,120 @@
/**
* ConnectionList Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ConnectionList } from "./ConnectionList";
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
describe("ConnectionList", (): void => {
const mockConnections: ConnectionDetails[] = [
{
id: "conn-1",
workspaceId: "workspace-1",
remoteInstanceId: "instance-work-001",
remoteUrl: "https://mosaic.work.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: true,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.ACTIVE,
metadata: {
name: "Work Instance",
description: "Corporate Mosaic instance",
},
createdAt: new Date("2026-02-01").toISOString(),
updatedAt: new Date("2026-02-01").toISOString(),
connectedAt: new Date("2026-02-01").toISOString(),
disconnectedAt: null,
},
{
id: "conn-2",
workspaceId: "workspace-1",
remoteInstanceId: "instance-partner-001",
remoteUrl: "https://mosaic.partner.example.com",
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
remoteCapabilities: {
supportsQuery: true,
supportsCommand: false,
supportsEvent: true,
supportsAgentSpawn: false,
protocolVersion: "1.0",
},
status: FederationConnectionStatus.PENDING,
metadata: {
name: "Partner Instance",
description: "Awaiting acceptance",
},
createdAt: new Date("2026-02-02").toISOString(),
updatedAt: new Date("2026-02-02").toISOString(),
connectedAt: null,
disconnectedAt: null,
},
];
const mockOnAccept = vi.fn();
const mockOnReject = vi.fn();
const mockOnDisconnect = vi.fn();
it("should render loading state", (): void => {
render(<ConnectionList connections={[]} isLoading={true} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("should render empty state when no connections", (): void => {
render(<ConnectionList connections={[]} isLoading={false} />);
expect(screen.getByText(/no federation connections/i)).toBeInTheDocument();
});
it("should render PDA-friendly empty state message", (): void => {
render(<ConnectionList connections={[]} isLoading={false} />);
expect(screen.getByText(/ready to connect/i)).toBeInTheDocument();
});
it("should render all connections", (): void => {
render(<ConnectionList connections={mockConnections} isLoading={false} />);
expect(screen.getByText("Work Instance")).toBeInTheDocument();
expect(screen.getByText("Partner Instance")).toBeInTheDocument();
});
it("should group connections by status", (): void => {
render(<ConnectionList connections={mockConnections} isLoading={false} />);
expect(screen.getByRole("heading", { name: "Active" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Pending" })).toBeInTheDocument();
});
it("should pass handlers to connection cards", (): void => {
render(
<ConnectionList
connections={mockConnections}
isLoading={false}
onAccept={mockOnAccept}
onReject={mockOnReject}
onDisconnect={mockOnDisconnect}
/>
);
const acceptButtons = screen.getAllByRole("button", { name: /accept/i });
const disconnectButtons = screen.getAllByRole("button", { name: /disconnect/i });
expect(acceptButtons.length).toBeGreaterThan(0);
expect(disconnectButtons.length).toBeGreaterThan(0);
});
it("should handle null connections array", (): void => {
render(<ConnectionList connections={null} isLoading={false} />);
expect(screen.getByText(/no federation connections/i)).toBeInTheDocument();
});
it("should render with compact layout when compact prop is true", (): void => {
render(<ConnectionList connections={mockConnections} isLoading={false} compact={true} />);
// Verify connections are rendered
expect(screen.getByText("Work Instance")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,116 @@
/**
* ConnectionList Component
* Displays a list of federation connections grouped by status
*/
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
import { ConnectionCard } from "./ConnectionCard";
interface ConnectionListProps {
connections: ConnectionDetails[] | null;
isLoading: boolean;
onAccept?: (connectionId: string) => void;
onReject?: (connectionId: string) => void;
onDisconnect?: (connectionId: string) => void;
compact?: boolean;
}
export function ConnectionList({
connections,
isLoading,
onAccept,
onReject,
onDisconnect,
compact = false,
}: ConnectionListProps): React.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 connections...</span>
</div>
);
}
// Handle null/undefined connections gracefully
if (!connections || connections.length === 0) {
return (
<div className="text-center p-8 text-gray-500">
<p className="text-lg">No federation connections</p>
<p className="text-sm mt-2">Ready to connect to remote instances</p>
</div>
);
}
// Group connections by status
const groupedConnections: Record<FederationConnectionStatus, ConnectionDetails[]> = {
[FederationConnectionStatus.PENDING]: [],
[FederationConnectionStatus.ACTIVE]: [],
[FederationConnectionStatus.DISCONNECTED]: [],
[FederationConnectionStatus.REJECTED]: [],
};
connections.forEach((connection) => {
groupedConnections[connection.status].push(connection);
});
// Define group order with PDA-friendly labels
const groups: {
status: FederationConnectionStatus;
label: string;
description: string;
}[] = [
{
status: FederationConnectionStatus.ACTIVE,
label: "Active",
description: "Currently connected instances",
},
{
status: FederationConnectionStatus.PENDING,
label: "Pending",
description: "Connections awaiting acceptance",
},
{
status: FederationConnectionStatus.DISCONNECTED,
label: "Disconnected",
description: "Previously connected instances",
},
{
status: FederationConnectionStatus.REJECTED,
label: "Rejected",
description: "Connection requests that were declined",
},
];
return (
<div className="space-y-8">
{groups.map((group) => {
const groupConnections = groupedConnections[group.status];
if (groupConnections.length === 0) {
return null;
}
return (
<section key={group.status}>
<div className="mb-4">
<h2 className="text-lg font-semibold text-gray-700">{group.label}</h2>
<p className="text-sm text-gray-500 mt-1">{group.description}</p>
</div>
<div className="space-y-3">
{groupConnections.map((connection) => (
<ConnectionCard
key={connection.id}
connection={connection}
{...(onAccept && { onAccept })}
{...(onReject && { onReject })}
{...(onDisconnect && { onDisconnect })}
compact={compact}
/>
))}
</div>
</section>
);
})}
</div>
);
}

View File

@@ -0,0 +1,136 @@
/**
* FederatedEventCard Component Tests
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { FederatedEventCard } from "./FederatedEventCard";
import type { FederatedEvent } from "./types";
const mockEvent: FederatedEvent = {
event: {
id: "event-1",
title: "Team standup",
description: "Daily sync meeting",
startTime: new Date("2026-02-05T10:00:00"),
endTime: new Date("2026-02-05T10:30:00"),
allDay: false,
location: "Zoom",
recurrence: null,
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
metadata: {},
createdAt: new Date("2026-02-03"),
updatedAt: new Date("2026-02-03"),
},
provenance: {
instanceId: "instance-work-001",
instanceName: "Work Instance",
instanceUrl: "https://mosaic.work.example.com",
timestamp: "2026-02-03T14:00:00Z",
},
};
describe("FederatedEventCard", () => {
it("should render event title", () => {
render(<FederatedEventCard federatedEvent={mockEvent} />);
expect(screen.getByText("Team standup")).toBeInTheDocument();
});
it("should render event description", () => {
render(<FederatedEventCard federatedEvent={mockEvent} />);
expect(screen.getByText("Daily sync meeting")).toBeInTheDocument();
});
it("should render provenance indicator", () => {
render(<FederatedEventCard federatedEvent={mockEvent} />);
expect(screen.getByText("Work Instance")).toBeInTheDocument();
});
it("should render event time", () => {
render(<FederatedEventCard federatedEvent={mockEvent} />);
// Check for time components
expect(screen.getByText(/10:00/)).toBeInTheDocument();
});
it("should render event location", () => {
render(<FederatedEventCard federatedEvent={mockEvent} />);
expect(screen.getByText("Zoom")).toBeInTheDocument();
});
it("should render with compact mode", () => {
const { container } = render(<FederatedEventCard federatedEvent={mockEvent} compact={true} />);
const card = container.querySelector(".p-3");
expect(card).toBeInTheDocument();
});
it("should handle event without description", () => {
const eventNoDesc: FederatedEvent = {
...mockEvent,
event: {
...mockEvent.event,
description: null,
},
};
render(<FederatedEventCard federatedEvent={eventNoDesc} />);
expect(screen.getByText("Team standup")).toBeInTheDocument();
expect(screen.queryByText("Daily sync meeting")).not.toBeInTheDocument();
});
it("should handle event without location", () => {
const eventNoLocation: FederatedEvent = {
...mockEvent,
event: {
...mockEvent.event,
location: null,
},
};
render(<FederatedEventCard federatedEvent={eventNoLocation} />);
expect(screen.getByText("Team standup")).toBeInTheDocument();
expect(screen.queryByText("Zoom")).not.toBeInTheDocument();
});
it("should render all-day event", () => {
const allDayEvent: FederatedEvent = {
...mockEvent,
event: {
...mockEvent.event,
allDay: true,
},
};
render(<FederatedEventCard federatedEvent={allDayEvent} />);
expect(screen.getByText("All day")).toBeInTheDocument();
});
it("should handle onClick callback", () => {
let clicked = false;
const handleClick = (): void => {
clicked = true;
};
const { container } = render(
<FederatedEventCard federatedEvent={mockEvent} onClick={handleClick} />
);
const card = container.querySelector(".cursor-pointer");
expect(card).toBeInTheDocument();
if (card instanceof HTMLElement) {
card.click();
}
expect(clicked).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
/**
* FederatedEventCard Component
* Displays an event from a federated instance with provenance indicator
*/
import type { FederatedEvent } from "./types";
import { ProvenanceIndicator } from "./ProvenanceIndicator";
interface FederatedEventCardProps {
federatedEvent: FederatedEvent;
compact?: boolean;
onClick?: () => void;
}
/**
* Format time for display
*/
function formatTime(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(new Date(date));
}
/**
* Format date for display
*/
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
}).format(new Date(date));
}
export function FederatedEventCard({
federatedEvent,
compact = false,
onClick,
}: FederatedEventCardProps): React.JSX.Element {
const { event, provenance } = federatedEvent;
const paddingClass = compact ? "p-3" : "p-4";
const clickableClass = onClick ? "cursor-pointer hover:border-gray-300" : "";
const startTime = formatTime(event.startTime);
const endTime = event.endTime !== null ? formatTime(event.endTime) : "";
const startDate = formatDate(event.startTime);
return (
<div
className={`border border-gray-200 rounded-lg ${paddingClass} ${clickableClass} transition-colors`}
onClick={onClick}
>
{/* Header with title and provenance */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="text-base font-medium text-gray-900">{event.title}</h3>
{event.description && <p className="text-sm text-gray-600 mt-1">{event.description}</p>}
</div>
<ProvenanceIndicator
instanceId={provenance.instanceId}
instanceName={provenance.instanceName}
instanceUrl={provenance.instanceUrl}
compact={compact}
/>
</div>
{/* Metadata row */}
<div className="flex items-center gap-3 text-sm flex-wrap">
{/* Date */}
<span className="text-xs text-gray-700 font-medium">{startDate}</span>
{/* Time */}
{event.allDay ? (
<span className="text-xs text-gray-600">All day</span>
) : (
<span className="text-xs text-gray-600">
{startTime} - {endTime}
</span>
)}
{/* Location */}
{event.location && (
<span className="text-xs text-gray-600">
<span className="mr-1">📍</span>
{event.location}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
/**
* FederatedTaskCard Component Tests
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { FederatedTaskCard } from "./FederatedTaskCard";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
import type { FederatedTask } from "./types";
const mockTask: FederatedTask = {
task: {
id: "task-1",
title: "Review pull request",
description: "Review and provide feedback on frontend PR",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-05"),
creatorId: "user-1",
assigneeId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-02-03"),
updatedAt: new Date("2026-02-03"),
},
provenance: {
instanceId: "instance-work-001",
instanceName: "Work Instance",
instanceUrl: "https://mosaic.work.example.com",
timestamp: "2026-02-03T14:00:00Z",
},
};
describe("FederatedTaskCard", () => {
it("should render task title", () => {
render(<FederatedTaskCard federatedTask={mockTask} />);
expect(screen.getByText("Review pull request")).toBeInTheDocument();
});
it("should render task description", () => {
render(<FederatedTaskCard federatedTask={mockTask} />);
expect(screen.getByText("Review and provide feedback on frontend PR")).toBeInTheDocument();
});
it("should render provenance indicator", () => {
render(<FederatedTaskCard federatedTask={mockTask} />);
expect(screen.getByText("Work Instance")).toBeInTheDocument();
});
it("should render status badge", () => {
render(<FederatedTaskCard federatedTask={mockTask} />);
expect(screen.getByText("In Progress")).toBeInTheDocument();
});
it("should render priority indicator for high priority", () => {
render(<FederatedTaskCard federatedTask={mockTask} />);
expect(screen.getByText("High")).toBeInTheDocument();
});
it("should render target date", () => {
render(<FederatedTaskCard federatedTask={mockTask} />);
// Check for "Target:" text followed by a date
expect(screen.getByText(/Target:/)).toBeInTheDocument();
expect(screen.getByText(/2026/)).toBeInTheDocument();
});
it("should render with compact mode", () => {
const { container } = render(<FederatedTaskCard federatedTask={mockTask} compact={true} />);
const card = container.querySelector(".p-3");
expect(card).toBeInTheDocument();
});
it("should render completed task with completed status", () => {
const completedTask: FederatedTask = {
...mockTask,
task: {
...mockTask.task,
status: TaskStatus.COMPLETED,
completedAt: new Date("2026-02-04"),
},
};
render(<FederatedTaskCard federatedTask={completedTask} />);
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("should handle task without description", () => {
const taskNoDesc: FederatedTask = {
...mockTask,
task: {
...mockTask.task,
description: null,
},
};
render(<FederatedTaskCard federatedTask={taskNoDesc} />);
expect(screen.getByText("Review pull request")).toBeInTheDocument();
expect(
screen.queryByText("Review and provide feedback on frontend PR")
).not.toBeInTheDocument();
});
it("should handle task without target date", () => {
const taskNoTarget: FederatedTask = {
...mockTask,
task: {
...mockTask.task,
dueDate: null,
},
};
render(<FederatedTaskCard federatedTask={taskNoTarget} />);
expect(screen.getByText("Review pull request")).toBeInTheDocument();
expect(screen.queryByText(/Target:/)).not.toBeInTheDocument();
});
it("should use PDA-friendly language for status", () => {
const pausedTask: FederatedTask = {
...mockTask,
task: {
...mockTask.task,
status: TaskStatus.PAUSED,
},
};
render(<FederatedTaskCard federatedTask={pausedTask} />);
expect(screen.getByText("Paused")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,113 @@
/**
* FederatedTaskCard Component
* Displays a task from a federated instance with provenance indicator
*/
import { TaskStatus, TaskPriority } from "@mosaic/shared";
import type { FederatedTask } from "./types";
import { ProvenanceIndicator } from "./ProvenanceIndicator";
interface FederatedTaskCardProps {
federatedTask: FederatedTask;
compact?: boolean;
onClick?: () => void;
}
/**
* Get PDA-friendly status text and color
*/
function getStatusDisplay(status: TaskStatus): { text: string; colorClass: string } {
switch (status) {
case TaskStatus.NOT_STARTED:
return { text: "Not Started", colorClass: "bg-gray-100 text-gray-700" };
case TaskStatus.IN_PROGRESS:
return { text: "In Progress", colorClass: "bg-blue-100 text-blue-700" };
case TaskStatus.COMPLETED:
return { text: "Completed", colorClass: "bg-green-100 text-green-700" };
case TaskStatus.PAUSED:
return { text: "Paused", colorClass: "bg-yellow-100 text-yellow-700" };
case TaskStatus.ARCHIVED:
return { text: "Archived", colorClass: "bg-gray-100 text-gray-600" };
default:
return { text: "Unknown", colorClass: "bg-gray-100 text-gray-700" };
}
}
/**
* Get priority text and color
*/
function getPriorityDisplay(priority: TaskPriority): { text: string; colorClass: string } {
switch (priority) {
case TaskPriority.LOW:
return { text: "Low", colorClass: "text-gray-600" };
case TaskPriority.MEDIUM:
return { text: "Medium", colorClass: "text-blue-600" };
case TaskPriority.HIGH:
return { text: "High", colorClass: "text-orange-600" };
default:
return { text: "Unknown", colorClass: "text-gray-600" };
}
}
/**
* Format date for display
*/
function formatDate(date: Date | null): string | null {
if (!date) {
return null;
}
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
}
export function FederatedTaskCard({
federatedTask,
compact = false,
onClick,
}: FederatedTaskCardProps): React.JSX.Element {
const { task, provenance } = federatedTask;
const status = getStatusDisplay(task.status);
const priority = getPriorityDisplay(task.priority);
const dueDate = formatDate(task.dueDate);
const paddingClass = compact ? "p-3" : "p-4";
const clickableClass = onClick ? "cursor-pointer hover:border-gray-300" : "";
return (
<div
className={`border border-gray-200 rounded-lg ${paddingClass} ${clickableClass} transition-colors`}
onClick={onClick}
>
{/* Header with title and provenance */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="text-base font-medium text-gray-900">{task.title}</h3>
{task.description && <p className="text-sm text-gray-600 mt-1">{task.description}</p>}
</div>
<ProvenanceIndicator
instanceId={provenance.instanceId}
instanceName={provenance.instanceName}
instanceUrl={provenance.instanceUrl}
compact={compact}
/>
</div>
{/* Metadata row */}
<div className="flex items-center gap-3 text-sm flex-wrap">
{/* Status badge */}
<span className={`px-2 py-1 rounded-full text-xs font-medium ${status.colorClass}`}>
{status.text}
</span>
{/* Priority */}
<span className={`text-xs font-medium ${priority.colorClass}`}>{priority.text}</span>
{/* Target date */}
{dueDate && <span className="text-xs text-gray-600">Target: {dueDate}</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
/**
* InitiateConnectionDialog Component Tests
* Following TDD - write tests first!
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InitiateConnectionDialog } from "./InitiateConnectionDialog";
describe("InitiateConnectionDialog", (): void => {
const mockOnInitiate = vi.fn();
const mockOnCancel = vi.fn();
it("should render when open is true", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByText(/connect to remote instance/i)).toBeInTheDocument();
});
it("should not render when open is false", (): void => {
const { container } = render(
<InitiateConnectionDialog open={false} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(container.firstChild).toBeNull();
});
it("should render PDA-friendly title", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByText(/connect to remote instance/i)).toBeInTheDocument();
});
it("should render PDA-friendly description", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByText(/enter the url/i)).toBeInTheDocument();
});
it("should render URL input field", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByLabelText(/instance url/i)).toBeInTheDocument();
});
it("should render Connect button", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByRole("button", { name: /connect/i })).toBeInTheDocument();
});
it("should render Cancel button", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
it("should call onCancel when cancel button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it("should call onInitiate with URL when connect button clicked", async (): Promise<void> => {
const user = userEvent.setup();
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const urlInput = screen.getByLabelText(/instance url/i);
await user.type(urlInput, "https://mosaic.example.com");
const connectButton = screen.getByRole("button", { name: /connect/i });
await user.click(connectButton);
expect(mockOnInitiate).toHaveBeenCalledWith("https://mosaic.example.com");
});
it("should disable connect button when URL is empty", (): void => {
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const connectButton = screen.getByRole("button", { name: /connect/i });
expect(connectButton).toBeDisabled();
});
it("should enable connect button when URL is entered", async (): Promise<void> => {
const user = userEvent.setup();
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const urlInput = screen.getByLabelText(/instance url/i);
await user.type(urlInput, "https://mosaic.example.com");
const connectButton = screen.getByRole("button", { name: /connect/i });
expect(connectButton).not.toBeDisabled();
});
it("should show validation error for invalid URL", async (): Promise<void> => {
const user = userEvent.setup();
render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const urlInput = screen.getByLabelText(/instance url/i);
await user.type(urlInput, "not-a-valid-url");
const connectButton = screen.getByRole("button", { name: /connect/i });
await user.click(connectButton);
expect(screen.getByText(/please enter a valid url/i)).toBeInTheDocument();
});
it("should clear input when dialog is closed", async (): Promise<void> => {
const user = userEvent.setup();
const { rerender } = render(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const urlInput = screen.getByLabelText(/instance url/i);
await user.type(urlInput, "https://mosaic.example.com");
// Close dialog
rerender(
<InitiateConnectionDialog open={false} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
// Reopen dialog
rerender(
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
);
const newUrlInput = screen.getByLabelText(/instance url/i);
expect(newUrlInput).toHaveValue("");
});
it("should show loading state when isLoading is true", (): void => {
render(
<InitiateConnectionDialog
open={true}
onInitiate={mockOnInitiate}
onCancel={mockOnCancel}
isLoading={true}
/>
);
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
});
it("should disable buttons when isLoading is true", (): void => {
render(
<InitiateConnectionDialog
open={true}
onInitiate={mockOnInitiate}
onCancel={mockOnCancel}
isLoading={true}
/>
);
const connectButton = screen.getByRole("button", { name: /connecting/i });
const cancelButton = screen.getByRole("button", { name: /cancel/i });
expect(connectButton).toBeDisabled();
expect(cancelButton).toBeDisabled();
});
it("should display error message when error prop is provided", (): void => {
render(
<InitiateConnectionDialog
open={true}
onInitiate={mockOnInitiate}
onCancel={mockOnCancel}
error="Unable to connect to remote instance"
/>
);
expect(screen.getByText("Unable to connect to remote instance")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,129 @@
/**
* InitiateConnectionDialog Component
* Dialog for initiating a new federation connection
*/
import { useState, useEffect } from "react";
interface InitiateConnectionDialogProps {
open: boolean;
onInitiate: (url: string) => void;
onCancel: () => void;
isLoading?: boolean;
error?: string;
}
/**
* Validate if a string is a valid URL
*/
function isValidUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
} catch {
return false;
}
}
export function InitiateConnectionDialog({
open,
onInitiate,
onCancel,
isLoading = false,
error,
}: InitiateConnectionDialogProps): React.JSX.Element | null {
const [url, setUrl] = useState("");
const [validationError, setValidationError] = useState("");
// Clear input when dialog closes
useEffect(() => {
if (!open) {
setUrl("");
setValidationError("");
}
}, [open]);
if (!open) {
return null;
}
const handleConnect = (): void => {
// Validate URL
if (!url.trim()) {
setValidationError("Please enter a URL");
return;
}
if (!isValidUrl(url)) {
setValidationError("Please enter a valid URL (must start with http:// or https://)");
return;
}
setValidationError("");
onInitiate(url);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === "Enter" && url.trim() && !isLoading) {
handleConnect();
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6">
{/* Header */}
<h2 className="text-xl font-semibold text-gray-900 mb-2">Connect to Remote Instance</h2>
<p className="text-sm text-gray-600 mb-6">
Enter the URL of the Mosaic Stack instance you&apos;d like to connect to
</p>
{/* URL Input */}
<div className="mb-4">
<label htmlFor="instance-url" className="block text-sm font-medium text-gray-700 mb-2">
Instance URL
</label>
<input
id="instance-url"
type="url"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setValidationError("");
}}
onKeyDown={handleKeyPress}
disabled={isLoading}
placeholder="https://mosaic.example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
{validationError && <p className="text-sm text-red-600 mt-1">{validationError}</p>}
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={handleConnect}
disabled={!url.trim() || isLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Connecting..." : "Connect"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
/**
* ProvenanceIndicator Component Tests
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProvenanceIndicator } from "./ProvenanceIndicator";
describe("ProvenanceIndicator", () => {
it("should render instance name", () => {
render(
<ProvenanceIndicator
instanceId="instance-work-001"
instanceName="Work Instance"
instanceUrl="https://mosaic.work.example.com"
/>
);
expect(screen.getByText("Work Instance")).toBeInTheDocument();
});
it("should render with compact mode", () => {
const { container } = render(
<ProvenanceIndicator
instanceId="instance-work-001"
instanceName="Work Instance"
instanceUrl="https://mosaic.work.example.com"
compact={true}
/>
);
// Compact mode should have smaller padding
const badge = container.querySelector(".px-2");
expect(badge).toBeInTheDocument();
});
it("should render with custom color", () => {
const { container } = render(
<ProvenanceIndicator
instanceId="instance-work-001"
instanceName="Work Instance"
instanceUrl="https://mosaic.work.example.com"
color="bg-blue-100"
/>
);
const badge = container.querySelector(".bg-blue-100");
expect(badge).toBeInTheDocument();
});
it("should render with default color when not provided", () => {
const { container } = render(
<ProvenanceIndicator
instanceId="instance-work-001"
instanceName="Work Instance"
instanceUrl="https://mosaic.work.example.com"
/>
);
const badge = container.querySelector(".bg-gray-100");
expect(badge).toBeInTheDocument();
});
it("should show tooltip with instance details on hover", () => {
render(
<ProvenanceIndicator
instanceId="instance-work-001"
instanceName="Work Instance"
instanceUrl="https://mosaic.work.example.com"
/>
);
// Check for title attribute (tooltip)
const badge = screen.getByText("Work Instance");
expect(badge.closest("div")).toHaveAttribute(
"title",
"From: Work Instance (https://mosaic.work.example.com)"
);
});
it("should render with icon when showIcon is true", () => {
render(
<ProvenanceIndicator
instanceId="instance-work-001"
instanceName="Work Instance"
instanceUrl="https://mosaic.work.example.com"
showIcon={true}
/>
);
expect(screen.getByText("🔗")).toBeInTheDocument();
});
it("should not render icon by default", () => {
render(
<ProvenanceIndicator
instanceId="instance-work-001"
instanceName="Work Instance"
instanceUrl="https://mosaic.work.example.com"
/>
);
expect(screen.queryByText("🔗")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
/**
* ProvenanceIndicator Component
* Shows which instance data came from with PDA-friendly design
*/
interface ProvenanceIndicatorProps {
instanceId: string;
instanceName: string;
instanceUrl: string;
compact?: boolean;
color?: string;
showIcon?: boolean;
}
export function ProvenanceIndicator({
instanceId,
instanceName,
instanceUrl,
compact = false,
color = "bg-gray-100",
showIcon = false,
}: ProvenanceIndicatorProps): React.JSX.Element {
const paddingClass = compact ? "px-2 py-0.5 text-xs" : "px-3 py-1 text-sm";
const tooltipText = `From: ${instanceName} (${instanceUrl})`;
return (
<div
className={`inline-flex items-center gap-1 ${paddingClass} ${color} text-gray-700 rounded-full font-medium`}
title={tooltipText}
data-instance-id={instanceId}
>
{showIcon && <span>🔗</span>}
<span>{instanceName}</span>
</div>
);
}

View File

@@ -0,0 +1,170 @@
/**
* Tests for SpokeConfigurationForm Component
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { SpokeConfigurationForm } from "./SpokeConfigurationForm";
import type { PublicInstanceIdentity } from "@/lib/api/federation";
describe("SpokeConfigurationForm", () => {
const mockInstance: PublicInstanceIdentity = {
id: "instance-123",
instanceId: "test-instance-001",
name: "Test Instance",
url: "https://test.example.com",
publicKey: "-----BEGIN PUBLIC KEY-----\nMOCKPUBLICKEY\n-----END PUBLIC KEY-----",
capabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: false,
protocolVersion: "1.0",
},
metadata: {
description: "Test instance description",
},
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
};
it("should render instance identity information", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
expect(screen.getByDisplayValue("Test Instance")).toBeInTheDocument();
expect(screen.getByText("test-instance-001")).toBeInTheDocument();
expect(screen.getByText("https://test.example.com")).toBeInTheDocument();
});
it("should render capability toggles with correct initial state", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
const queryToggle = screen.getByLabelText(/Query Support/i);
const commandToggle = screen.getByLabelText(/Command Support/i);
const eventToggle = screen.getByLabelText(/Event Support/i);
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
expect(queryToggle).toBeChecked();
expect(commandToggle).toBeChecked();
expect(eventToggle).toBeChecked();
expect(agentToggle).not.toBeChecked();
});
it("should allow editing instance name", async () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
const nameInput = screen.getByDisplayValue("Test Instance");
fireEvent.change(nameInput, { target: { value: "Updated Instance" } });
await waitFor(() => {
expect(screen.getByDisplayValue("Updated Instance")).toBeInTheDocument();
});
});
it("should toggle capabilities", async () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
expect(agentToggle).not.toBeChecked();
fireEvent.click(agentToggle);
await waitFor(() => {
expect(agentToggle).toBeChecked();
});
});
it("should call onSave with updated configuration", async () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
// Change name
const nameInput = screen.getByDisplayValue("Test Instance");
fireEvent.change(nameInput, { target: { value: "Updated Instance" } });
// Toggle agent spawn
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
fireEvent.click(agentToggle);
// Click save
const saveButton = screen.getByText("Save Configuration");
fireEvent.click(saveButton);
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith({
name: "Updated Instance",
capabilities: {
supportsQuery: true,
supportsCommand: true,
supportsEvent: true,
supportsAgentSpawn: true,
protocolVersion: "1.0",
},
metadata: {
description: "Test instance description",
},
});
});
});
it("should display loading state when saving", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} isLoading={true} />);
const saveButton = screen.getByText("Saving...");
expect(saveButton).toBeDisabled();
});
it("should display error message when provided", () => {
const onSave = vi.fn();
render(
<SpokeConfigurationForm
instance={mockInstance}
onSave={onSave}
error="Unable to save configuration"
/>
);
expect(screen.getByText("Unable to save configuration")).toBeInTheDocument();
});
it("should use PDA-friendly language in help text", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
// Should NOT use demanding language
expect(screen.queryByText(/must/i)).not.toBeInTheDocument();
expect(screen.queryByText(/required/i)).not.toBeInTheDocument();
expect(screen.queryByText(/critical/i)).not.toBeInTheDocument();
// Should use friendly language (multiple instances expected)
const friendlyText = screen.getAllByText(/Allows connected instances/i);
expect(friendlyText.length).toBeGreaterThan(0);
});
it("should truncate public key and show copy button", () => {
const onSave = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
// Public key should be truncated
expect(screen.getByText(/-----BEGIN PUBLIC KEY-----/)).toBeInTheDocument();
expect(screen.getByText(/Copy/i)).toBeInTheDocument();
});
it("should handle cancel action", async () => {
const onSave = vi.fn();
const onCancel = vi.fn();
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} onCancel={onCancel} />);
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
await waitFor(() => {
expect(onCancel).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,276 @@
/**
* SpokeConfigurationForm Component
* Allows administrators to configure local instance federation settings
*/
"use client";
import { useState } from "react";
import type { PublicInstanceIdentity, UpdateInstanceRequest } from "@/lib/api/federation";
interface SpokeConfigurationFormProps {
instance: PublicInstanceIdentity;
onSave: (updates: UpdateInstanceRequest) => void;
onCancel?: () => void;
isLoading?: boolean;
error?: string;
}
export function SpokeConfigurationForm({
instance,
onSave,
onCancel,
isLoading = false,
error,
}: SpokeConfigurationFormProps): React.JSX.Element {
const [name, setName] = useState(instance.name);
const [description, setDescription] = useState((instance.metadata.description as string) || "");
const [capabilities, setCapabilities] = useState(instance.capabilities);
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
const updates: UpdateInstanceRequest = {
name,
capabilities,
metadata: {
...instance.metadata,
description,
},
};
onSave(updates);
};
const handleCapabilityToggle = (capability: keyof typeof capabilities): void => {
if (capability === "protocolVersion") return; // Can't toggle protocol version
setCapabilities((prev) => ({
...prev,
[capability]: !prev[capability],
}));
};
const copyPublicKey = async (): Promise<void> => {
await navigator.clipboard.writeText(instance.publicKey);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Instance Identity Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Instance Identity</h3>
{/* Instance ID (Read-only) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Instance ID</label>
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
{instance.instanceId}
</div>
</div>
{/* Instance URL (Read-only) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Instance URL</label>
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
{instance.url}
</div>
</div>
{/* Instance Name (Editable) */}
<div>
<label htmlFor="instance-name" className="block text-sm font-medium text-gray-700 mb-1">
Instance Name
</label>
<input
id="instance-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
disabled={isLoading}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
/>
<p className="text-sm text-gray-500 mt-1">
This name helps identify your instance in federation connections
</p>
</div>
{/* Description (Editable) */}
<div>
<label
htmlFor="instance-description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description
</label>
<textarea
id="instance-description"
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
disabled={isLoading}
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
placeholder="Optional description for this instance"
/>
</div>
{/* Public Key (Read-only with copy button) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Public Key</label>
<div className="relative">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600 font-mono text-xs overflow-hidden">
{instance.publicKey.substring(0, 100)}...
</div>
<button
type="button"
onClick={copyPublicKey}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-white border border-gray-300 rounded hover:bg-gray-50"
>
Copy
</button>
</div>
</div>
</div>
{/* Federation Capabilities Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Federation Capabilities</h3>
<p className="text-sm text-gray-600">
Configure which federation features this instance supports. These settings determine what
connected instances can do with your instance.
</p>
{/* Query Support */}
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
<input
type="checkbox"
id="supports-query"
checked={capabilities.supportsQuery}
onChange={() => {
handleCapabilityToggle("supportsQuery");
}}
disabled={isLoading}
className="mt-1"
/>
<div className="flex-1">
<label htmlFor="supports-query" className="font-medium text-gray-900 cursor-pointer">
Query Support
</label>
<p className="text-sm text-gray-600 mt-1">
Allows connected instances to query data from this instance (tasks, events, projects)
</p>
</div>
</div>
{/* Command Support */}
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
<input
type="checkbox"
id="supports-command"
checked={capabilities.supportsCommand}
onChange={() => {
handleCapabilityToggle("supportsCommand");
}}
disabled={isLoading}
className="mt-1"
/>
<div className="flex-1">
<label htmlFor="supports-command" className="font-medium text-gray-900 cursor-pointer">
Command Support
</label>
<p className="text-sm text-gray-600 mt-1">
Allows connected instances to send commands to this instance
</p>
</div>
</div>
{/* Event Support */}
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
<input
type="checkbox"
id="supports-event"
checked={capabilities.supportsEvent}
onChange={() => {
handleCapabilityToggle("supportsEvent");
}}
disabled={isLoading}
className="mt-1"
/>
<div className="flex-1">
<label htmlFor="supports-event" className="font-medium text-gray-900 cursor-pointer">
Event Support
</label>
<p className="text-sm text-gray-600 mt-1">
Allows connected instances to subscribe to events from this instance
</p>
</div>
</div>
{/* Agent Spawn Support */}
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
<input
type="checkbox"
id="supports-agent-spawn"
checked={capabilities.supportsAgentSpawn}
onChange={() => {
handleCapabilityToggle("supportsAgentSpawn");
}}
disabled={isLoading}
className="mt-1"
/>
<div className="flex-1">
<label
htmlFor="supports-agent-spawn"
className="font-medium text-gray-900 cursor-pointer"
>
Agent Spawn Support
</label>
<p className="text-sm text-gray-600 mt-1">
Allows connected instances to spawn and manage agents on this instance
</p>
</div>
</div>
{/* Protocol Version (Read-only) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Protocol Version</label>
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
{capabilities.protocolVersion}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center space-x-3 pt-4 border-t border-gray-200">
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Saving..." : "Save Configuration"}
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
>
Cancel
</button>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,36 @@
/**
* Federation Component Types
*/
import type { Task, Event } from "@mosaic/shared";
/**
* Provenance information - where the data came from
*/
export interface ProvenanceInfo {
instanceId: string;
instanceName: string;
instanceUrl: string;
timestamp: string;
}
/**
* Federated task with provenance
*/
export interface FederatedTask {
task: Task;
provenance: ProvenanceInfo;
}
/**
* Federated event with provenance
*/
export interface FederatedEvent {
event: Event;
provenance: ProvenanceInfo;
}
/**
* Federated data item (union type)
*/
export type FederatedItem = FederatedTask | FederatedEvent;