feat: Wire up hq.tsx with query, live updates, loading/error states, and section rendering
Replaces the placeholder body in apps/web/src/routes/hq.tsx with the full Headquarters page. The component fetches from getHeadquartersDashboard, subscribes to live SSE invalidations via useLiveUpdates, and renders the four action sections or an empty state. Includes skeleton loading and error-with-retry states. Also adds apps/web/src/components/ui/skeleton.tsx (standard shadcn component not yet in the project). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
12
apps/web/src/components/ui/skeleton.tsx
Normal file
12
apps/web/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
156
apps/web/src/routes/hq.test.tsx
Normal file
156
apps/web/src/routes/hq.test.tsx
Normal file
@@ -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) => <div data-testid="waiting">{items.length}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/hq/HQNeedsReviewSection', () => ({
|
||||||
|
HQNeedsReviewSection: ({ initiatives, phases }: any) => (
|
||||||
|
<div data-testid="needs-review">{initiatives.length},{phases.length}</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/hq/HQNeedsApprovalSection', () => ({
|
||||||
|
HQNeedsApprovalSection: ({ items }: any) => <div data-testid="needs-approval">{items.length}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/hq/HQBlockedSection', () => ({
|
||||||
|
HQBlockedSection: ({ items }: any) => <div data-testid="blocked">{items.length}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/hq/HQEmptyState', () => ({
|
||||||
|
HQEmptyState: () => <div data-testid="empty-state">All clear</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
// 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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
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(<HeadquartersPage />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('needs-review')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,81 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { motion } from "motion/react";
|
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")({
|
export const Route = createFileRoute("/hq")({
|
||||||
component: HeadquartersPage,
|
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 (
|
||||||
|
<motion.div
|
||||||
|
className="mx-auto max-w-4xl space-y-6"
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Headquarters</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Items waiting for your attention.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="mx-auto max-w-4xl space-y-6"
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 gap-3 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Failed to load headquarters data.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => query.refetch()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mx-auto max-w-4xl space-y-6"
|
className="mx-auto max-w-4xl space-y-6"
|
||||||
@@ -19,7 +89,29 @@ function HeadquartersPage() {
|
|||||||
Items waiting for your attention.
|
Items waiting for your attention.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Sections implemented in next phase */}
|
|
||||||
|
{!hasAny ? (
|
||||||
|
<HQEmptyState />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{data.waitingForInput.length > 0 && (
|
||||||
|
<HQWaitingForInputSection items={data.waitingForInput} />
|
||||||
|
)}
|
||||||
|
{(data.pendingReviewInitiatives.length > 0 ||
|
||||||
|
data.pendingReviewPhases.length > 0) && (
|
||||||
|
<HQNeedsReviewSection
|
||||||
|
initiatives={data.pendingReviewInitiatives}
|
||||||
|
phases={data.pendingReviewPhases}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data.planningInitiatives.length > 0 && (
|
||||||
|
<HQNeedsApprovalSection items={data.planningInitiatives} />
|
||||||
|
)}
|
||||||
|
{data.blockedPhases.length > 0 && (
|
||||||
|
<HQBlockedSection items={data.blockedPhases} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user