Merge branch 'cw/headquarters-task-i16XYnxexfeqf8VCUuTL3' into cw-merge-1772809739062
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 { 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"
|
||||
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 (
|
||||
<motion.div
|
||||
className="mx-auto max-w-4xl space-y-6"
|
||||
@@ -19,7 +89,29 @@ function HeadquartersPage() {
|
||||
Items waiting for your attention.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user