Merge branch 'cw/headquarters-task-i16XYnxexfeqf8VCUuTL3' into cw-merge-1772809739062

This commit is contained in:
Lukas May
2026-03-06 16:08:59 +01:00
3 changed files with 262 additions and 2 deletions

View 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 }

View 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()
})
})

View File

@@ -1,11 +1,30 @@
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 (
<motion.div
className="mx-auto max-w-4xl space-y-6"
@@ -19,7 +38,80 @@ function HeadquartersPage() {
Items waiting for your attention.
</p>
</div>
{/* Sections implemented in next phase */}
<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 (
<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>
{!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>
);
}