diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..2b4045c --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/apps/web/src/routes/hq.test.tsx b/apps/web/src/routes/hq.test.tsx new file mode 100644 index 0000000..818fc63 --- /dev/null +++ b/apps/web/src/routes/hq.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +const mockUseQuery = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/trpc', () => ({ + trpc: { + getHeadquartersDashboard: { useQuery: mockUseQuery }, + }, +})) + +vi.mock('@/hooks', () => ({ + useLiveUpdates: vi.fn(), + LiveUpdateRule: undefined, +})) + +vi.mock('@/components/hq/HQWaitingForInputSection', () => ({ + HQWaitingForInputSection: ({ items }: any) =>
{items.length}
, +})) + +vi.mock('@/components/hq/HQNeedsReviewSection', () => ({ + HQNeedsReviewSection: ({ initiatives, phases }: any) => ( +
{initiatives.length},{phases.length}
+ ), +})) + +vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({ + HQNeedsApprovalSection: ({ items }: any) =>
{items.length}
, +})) + +vi.mock('@/components/hq/HQBlockedSection', () => ({ + HQBlockedSection: ({ items }: any) =>
{items.length}
, +})) + +vi.mock('@/components/hq/HQEmptyState', () => ({ + HQEmptyState: () =>
All clear
, +})) + +// Import after mocks are set up +import { HeadquartersPage } from './hq' + +const emptyData = { + waitingForInput: [], + pendingReviewInitiatives: [], + pendingReviewPhases: [], + planningInitiatives: [], + blockedPhases: [], +} + +describe('HeadquartersPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders skeleton loading state', () => { + mockUseQuery.mockReturnValue({ isLoading: true, isError: false, data: undefined }) + render() + + // Should show heading + expect(screen.getByText('Headquarters')).toBeInTheDocument() + // Should show skeleton elements (there are 3) + const skeletons = document.querySelectorAll('[class*="skeleton"], [class*="h-16"]') + expect(skeletons.length).toBeGreaterThan(0) + // No section components + expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() + expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) + + it('renders error state with retry button', () => { + const mockRefetch = vi.fn() + mockUseQuery.mockReturnValue({ isLoading: false, isError: true, data: undefined, refetch: mockRefetch }) + render() + + expect(screen.getByText('Failed to load headquarters data.')).toBeInTheDocument() + const retryButton = screen.getByRole('button', { name: /retry/i }) + expect(retryButton).toBeInTheDocument() + + fireEvent.click(retryButton) + expect(mockRefetch).toHaveBeenCalledOnce() + }) + + it('renders empty state when all arrays are empty', () => { + mockUseQuery.mockReturnValue({ isLoading: false, isError: false, data: emptyData }) + render() + + expect(screen.getByTestId('empty-state')).toBeInTheDocument() + expect(screen.queryByTestId('waiting')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() + expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() + }) + + it('renders WaitingForInput section when items exist', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, waitingForInput: [{ id: '1' }] }, + }) + render() + + expect(screen.getByTestId('waiting')).toBeInTheDocument() + expect(screen.queryByTestId('needs-review')).not.toBeInTheDocument() + expect(screen.queryByTestId('needs-approval')).not.toBeInTheDocument() + expect(screen.queryByTestId('blocked')).not.toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) + + it('renders all four sections when all arrays have items', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { + waitingForInput: [{ id: '1' }], + pendingReviewInitiatives: [{ id: '2' }], + pendingReviewPhases: [{ id: '3' }], + planningInitiatives: [{ id: '4' }], + blockedPhases: [{ id: '5' }], + }, + }) + render() + + expect(screen.getByTestId('waiting')).toBeInTheDocument() + expect(screen.getByTestId('needs-review')).toBeInTheDocument() + expect(screen.getByTestId('needs-approval')).toBeInTheDocument() + expect(screen.getByTestId('blocked')).toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) + + it('renders NeedsReview section when only pendingReviewInitiatives has items', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, pendingReviewInitiatives: [{ id: '1' }] }, + }) + render() + + expect(screen.getByTestId('needs-review')).toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) + + it('renders NeedsReview section when only pendingReviewPhases has items', () => { + mockUseQuery.mockReturnValue({ + isLoading: false, + isError: false, + data: { ...emptyData, pendingReviewPhases: [{ id: '1' }] }, + }) + render() + + expect(screen.getByTestId('needs-review')).toBeInTheDocument() + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument() + }) +}) diff --git a/apps/web/src/routes/hq.tsx b/apps/web/src/routes/hq.tsx index 919f2dd..dea4865 100644 --- a/apps/web/src/routes/hq.tsx +++ b/apps/web/src/routes/hq.tsx @@ -1,11 +1,81 @@ import { createFileRoute } from "@tanstack/react-router"; import { motion } from "motion/react"; +import { trpc } from "@/lib/trpc"; +import { useLiveUpdates, type LiveUpdateRule } from "@/hooks"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { HQWaitingForInputSection } from "@/components/hq/HQWaitingForInputSection"; +import { HQNeedsReviewSection } from "@/components/hq/HQNeedsReviewSection"; +import { HQNeedsApprovalSection } from "@/components/hq/HQNeedsApprovalSection"; +import { HQBlockedSection } from "@/components/hq/HQBlockedSection"; +import { HQEmptyState } from "@/components/hq/HQEmptyState"; export const Route = createFileRoute("/hq")({ component: HeadquartersPage, }); -function HeadquartersPage() { +const HQ_LIVE_UPDATE_RULES: LiveUpdateRule[] = [ + { prefix: "initiative:", invalidate: ["getHeadquartersDashboard"] }, + { prefix: "phase:", invalidate: ["getHeadquartersDashboard"] }, + { prefix: "agent:", invalidate: ["getHeadquartersDashboard"] }, +]; + +export function HeadquartersPage() { + useLiveUpdates(HQ_LIVE_UPDATE_RULES); + const query = trpc.getHeadquartersDashboard.useQuery(); + + if (query.isLoading) { + return ( + +
+

Headquarters

+

+ Items waiting for your attention. +

+
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + + if (query.isError) { + return ( + +
+

+ Failed to load headquarters data. +

+ +
+
+ ); + } + + const data = query.data!; + + const hasAny = + data.waitingForInput.length > 0 || + data.pendingReviewInitiatives.length > 0 || + data.pendingReviewPhases.length > 0 || + data.planningInitiatives.length > 0 || + data.blockedPhases.length > 0; + return (
- {/* Sections implemented in next phase */} + + {!hasAny ? ( + + ) : ( +
+ {data.waitingForInput.length > 0 && ( + + )} + {(data.pendingReviewInitiatives.length > 0 || + data.pendingReviewPhases.length > 0) && ( + + )} + {data.planningInitiatives.length > 0 && ( + + )} + {data.blockedPhases.length > 0 && ( + + )} +
+ )} ); }