feat(#92): implement Aggregated Dashboard View
Implement unified dashboard to display tasks and events from multiple federated Mosaic Stack instances with clear provenance indicators. Backend Integration: - Extended federation API client with query support (sendFederatedQuery) - Added query message fetching functions - Integrated with existing QUERY message type from Phase 3 Components Created: - ProvenanceIndicator: Shows which instance data came from - FederatedTaskCard: Task display with provenance - FederatedEventCard: Event display with provenance - AggregatedDataGrid: Unified grid for multiple data types - Dashboard page at /federation/dashboard Key Features: - Query all ACTIVE federated connections on load - Display aggregated tasks and events in unified view - Clear provenance indicators (instance name badges) - PDA-friendly language throughout (no demanding terms) - Loading states and error handling - Empty state when no connections available Technical Implementation: - Uses POST /api/v1/federation/query to send queries - Queries each connection for tasks.list and events.list - Aggregates responses with provenance metadata - Handles connection failures gracefully - 86 tests passing with >85% coverage - TypeScript strict mode compliant - ESLint compliant PDA-Friendly Design: - "Unable to reach" instead of "Connection failed" - "No data available" instead of "No results" - "Loading data from instances..." instead of "Fetching..." - Calm color palette (soft blues, greens, grays) - Status indicators: 🟢 Active, 📋 No data, ⚠️ Error Files Added: - apps/web/src/lib/api/federation-queries.ts - apps/web/src/lib/api/federation-queries.test.ts - apps/web/src/components/federation/types.ts - apps/web/src/components/federation/ProvenanceIndicator.tsx - apps/web/src/components/federation/ProvenanceIndicator.test.tsx - apps/web/src/components/federation/FederatedTaskCard.tsx - apps/web/src/components/federation/FederatedTaskCard.test.tsx - apps/web/src/components/federation/FederatedEventCard.tsx - apps/web/src/components/federation/FederatedEventCard.test.tsx - apps/web/src/components/federation/AggregatedDataGrid.tsx - apps/web/src/components/federation/AggregatedDataGrid.test.tsx - apps/web/src/app/(authenticated)/federation/dashboard/page.tsx - docs/scratchpads/92-aggregated-dashboard.md Testing: - 86 total tests passing - Unit tests for all components - Integration tests for API client - PDA-friendly language verified - TypeScript type checking passing - ESLint passing Ready for code review and QA testing. Related Issues: - Depends on #85 (FED-005: QUERY Message Type) - COMPLETED - Depends on #91 (FED-008: Connection Manager UI) - COMPLETED - Uses #90 (FED-007: EVENT Subscriptions) infrastructure Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
156
apps/web/src/components/federation/AggregatedDataGrid.test.tsx
Normal file
156
apps/web/src/components/federation/AggregatedDataGrid.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
100
apps/web/src/components/federation/AggregatedDataGrid.tsx
Normal file
100
apps/web/src/components/federation/AggregatedDataGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
apps/web/src/components/federation/FederatedEventCard.test.tsx
Normal file
136
apps/web/src/components/federation/FederatedEventCard.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
94
apps/web/src/components/federation/FederatedEventCard.tsx
Normal file
94
apps/web/src/components/federation/FederatedEventCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/components/federation/FederatedTaskCard.test.tsx
Normal file
144
apps/web/src/components/federation/FederatedTaskCard.test.tsx
Normal 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 due date", () => {
|
||||
render(<FederatedTaskCard federatedTask={mockTask} />);
|
||||
|
||||
// Check for "Due:" text followed by a date
|
||||
expect(screen.getByText(/Due:/)).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 due date", () => {
|
||||
const taskNoDue: FederatedTask = {
|
||||
...mockTask,
|
||||
task: {
|
||||
...mockTask.task,
|
||||
dueDate: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(<FederatedTaskCard federatedTask={taskNoDue} />);
|
||||
|
||||
expect(screen.getByText("Review pull request")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Due:/)).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();
|
||||
});
|
||||
});
|
||||
113
apps/web/src/components/federation/FederatedTaskCard.tsx
Normal file
113
apps/web/src/components/federation/FederatedTaskCard.tsx
Normal 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>
|
||||
|
||||
{/* Due date */}
|
||||
{dueDate && <span className="text-xs text-gray-600">Due: {dueDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/federation/ProvenanceIndicator.test.tsx
Normal file
105
apps/web/src/components/federation/ProvenanceIndicator.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
36
apps/web/src/components/federation/ProvenanceIndicator.tsx
Normal file
36
apps/web/src/components/federation/ProvenanceIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/federation/types.ts
Normal file
36
apps/web/src/components/federation/types.ts
Normal 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;
|
||||
Reference in New Issue
Block a user