feat: rewrite InitiativeCard to single-row compact layout with tests
Replaces the two-row card layout with a single horizontal flex row that fits within 48-56px height. Adds project badge overflow (max 2 + chip), hover-reveal menu (opacity-0/group-hover), phase counter (X / Y), active phase name with separator, and StatusDot pulse behavior. Removes ProgressBar, status label text, and second row entirely. Adds Vitest unit tests covering all 6 test groups from the spec. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
184
apps/web/src/components/InitiativeCard.test.tsx
Normal file
184
apps/web/src/components/InitiativeCard.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { vi, describe, it, expect } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/trpc', () => ({
|
||||
trpc: {
|
||||
useUtils: () => ({ listInitiatives: { invalidate: vi.fn() } }),
|
||||
updateInitiative: { useMutation: vi.fn(() => ({ mutate: vi.fn() })) },
|
||||
deleteInitiative: { useMutation: vi.fn(() => ({ mutate: vi.fn() })) },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/card', () => ({
|
||||
Card: ({ children, className, onClick }: any) => (
|
||||
<div data-testid="card" className={className} onClick={onClick}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { InitiativeCard, type SerializedInitiative } from './InitiativeCard'
|
||||
|
||||
function makeInitiative(overrides: Partial<SerializedInitiative> = {}): SerializedInitiative {
|
||||
return {
|
||||
id: 'init-1',
|
||||
name: 'Test Initiative',
|
||||
status: 'active',
|
||||
branch: null,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
projects: [],
|
||||
...overrides,
|
||||
activity: {
|
||||
state: 'planning',
|
||||
activePhase: null,
|
||||
phasesTotal: 0,
|
||||
phasesCompleted: 0,
|
||||
...(overrides.activity ?? {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('InitiativeCard — single-row structure', () => {
|
||||
it('renders no second-row div (no mt-1.5 class in DOM)', () => {
|
||||
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||
expect(document.querySelector('.mt-1\\.5')).toBeNull()
|
||||
})
|
||||
|
||||
it('flex container has items-center class', () => {
|
||||
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||
const flexContainer = document.querySelector('.flex.items-center')
|
||||
expect(flexContainer).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InitiativeCard — project badge overflow', () => {
|
||||
it('1 project → 1 badge with project name, no +N chip', () => {
|
||||
const initiative = makeInitiative({
|
||||
projects: [{ id: 'p1', name: 'Project Alpha' }],
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
expect(screen.getByText('Project Alpha')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^\+\d+$/)).toBeNull()
|
||||
})
|
||||
|
||||
it('3 projects → first 2 names present, third absent, +1 chip', () => {
|
||||
const initiative = makeInitiative({
|
||||
projects: [
|
||||
{ id: 'p1', name: 'Alpha' },
|
||||
{ id: 'p2', name: 'Beta' },
|
||||
{ id: 'p3', name: 'Gamma' },
|
||||
],
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Gamma')).toBeNull()
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('5 projects → first 2 names present, +3 chip', () => {
|
||||
const initiative = makeInitiative({
|
||||
projects: [
|
||||
{ id: 'p1', name: 'Alpha' },
|
||||
{ id: 'p2', name: 'Beta' },
|
||||
{ id: 'p3', name: 'Gamma' },
|
||||
{ id: 'p4', name: 'Delta' },
|
||||
{ id: 'p5', name: 'Epsilon' },
|
||||
],
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
expect(screen.getByText('+3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('0 projects → no badge elements rendered', () => {
|
||||
const initiative = makeInitiative({ projects: [] })
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
// No outline or secondary badges for projects
|
||||
expect(document.querySelectorAll('[class*="rounded-full"][class*="border"]').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('InitiativeCard — hover-reveal menu', () => {
|
||||
it('dropdown wrapper has opacity-0 class', () => {
|
||||
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||
const wrapper = document.querySelector('.opacity-0')
|
||||
expect(wrapper).not.toBeNull()
|
||||
})
|
||||
|
||||
it('dropdown wrapper has group-hover:opacity-100 class', () => {
|
||||
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||
const wrapper = document.querySelector('.opacity-0')
|
||||
expect(wrapper).toHaveClass('group-hover:opacity-100')
|
||||
})
|
||||
|
||||
it('card has group class', () => {
|
||||
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
||||
const card = screen.getByTestId('card')
|
||||
expect(card).toHaveClass('group')
|
||||
})
|
||||
})
|
||||
|
||||
describe('InitiativeCard — phase counter', () => {
|
||||
it('shows "2 / 5" when phasesCompleted=2 and phasesTotal=5', () => {
|
||||
const initiative = makeInitiative({
|
||||
activity: { state: 'planning', activePhase: null, phasesTotal: 5, phasesCompleted: 2 },
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
expect(screen.getByText('2 / 5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counter absent when phasesTotal=0', () => {
|
||||
const initiative = makeInitiative({
|
||||
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
expect(screen.queryByText(/\d+ \/ \d+/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InitiativeCard — activity dot pulse', () => {
|
||||
it('state="executing" → StatusDot has animate-status-pulse class', () => {
|
||||
const initiative = makeInitiative({
|
||||
activity: { state: 'executing', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
const dot = document.querySelector('[role="status"]')
|
||||
expect(dot).toHaveClass('animate-status-pulse')
|
||||
})
|
||||
|
||||
it('state="planning" → StatusDot does not have animate-status-pulse class', () => {
|
||||
const initiative = makeInitiative({
|
||||
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
const dot = document.querySelector('[role="status"]')
|
||||
expect(dot).not.toHaveClass('animate-status-pulse')
|
||||
})
|
||||
})
|
||||
|
||||
describe('InitiativeCard — active phase name', () => {
|
||||
it('activePhase present → phase name and separator visible', () => {
|
||||
const initiative = makeInitiative({
|
||||
activity: {
|
||||
state: 'planning',
|
||||
activePhase: { id: 'p1', name: 'Phase Alpha' },
|
||||
phasesTotal: 0,
|
||||
phasesCompleted: 0,
|
||||
},
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
expect(screen.getByText('Phase Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('activePhase=null → no separator or phase name rendered', () => {
|
||||
const initiative = makeInitiative({
|
||||
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
||||
})
|
||||
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
||||
expect(screen.queryByText('·')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { StatusDot, type StatusVariant } from "@/components/StatusDot";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
/** Initiative shape as returned by tRPC (Date serialized to string over JSON) */
|
||||
@@ -24,7 +23,7 @@ export interface SerializedInitiative {
|
||||
projects?: Array<{ id: string; name: string }>;
|
||||
activity: {
|
||||
state: string;
|
||||
activePhase?: { id: string; name: string };
|
||||
activePhase?: { id: string; name: string } | null;
|
||||
phasesTotal: number;
|
||||
phasesCompleted: number;
|
||||
};
|
||||
@@ -83,27 +82,56 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
||||
|
||||
const { activity } = initiative;
|
||||
const visual = activityVisual(activity.state);
|
||||
const projects = initiative.projects ?? [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
interactive
|
||||
className="p-4"
|
||||
className="px-4 py-2 group"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Row 1: Name + project pills + overflow menu */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-base font-bold">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={activity.state}
|
||||
variant={visual.variant}
|
||||
size="sm"
|
||||
pulse={visual.pulse}
|
||||
/>
|
||||
|
||||
<span className="truncate min-w-0 flex-1 font-semibold text-sm">
|
||||
{initiative.name}
|
||||
</span>
|
||||
{initiative.projects && initiative.projects.length > 0 &&
|
||||
initiative.projects.map((p) => (
|
||||
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
|
||||
|
||||
{activity.activePhase && (
|
||||
<>
|
||||
<span className="text-muted-foreground text-sm shrink-0">·</span>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{activity.activePhase.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activity.phasesTotal > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||
{activity.phasesCompleted} / {activity.phasesTotal}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{projects.slice(0, 2).map((p) => (
|
||||
<Badge key={p.id} variant="outline" size="xs" className="shrink-0">
|
||||
{p.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{projects.length > 2 && (
|
||||
<Badge variant="secondary" size="xs" className="shrink-0">
|
||||
+{projects.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
@@ -123,35 +151,6 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Activity dot + label + active phase + progress */}
|
||||
<div className="mt-1.5 flex items-center gap-3">
|
||||
<StatusDot
|
||||
status={activity.state}
|
||||
variant={visual.variant}
|
||||
size="sm"
|
||||
pulse={visual.pulse}
|
||||
label={visual.label}
|
||||
/>
|
||||
<span className="text-sm font-medium">{visual.label}</span>
|
||||
{activity.activePhase && (
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{activity.activePhase.name}
|
||||
</span>
|
||||
)}
|
||||
{activity.phasesTotal > 0 && (
|
||||
<>
|
||||
<ProgressBar
|
||||
completed={activity.phasesCompleted}
|
||||
total={activity.phasesTotal}
|
||||
className="ml-auto w-24"
|
||||
/>
|
||||
<span className="hidden text-xs text-muted-foreground md:inline">
|
||||
{activity.phasesCompleted}/{activity.phasesTotal}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user