feat: add BlockedTriageModal component with tests
- Create BlockedTriageModal component at src/components/shared/blocked-triage-modal.tsx - Implements modal with blocked task triage functionality - Uses deriveBlockedIds and buildBlockedByTree from kanban lib - Each row shows blocker chain and has inline archetype picker - Modal is scrollable and closes via Escape/close button - Add corresponding tests at tests/components/blocked-triage-modal.test.tsx - Register test in package.json test script
This commit is contained in:
parent
c71e3742d9
commit
29eefaf7ec
3 changed files with 263 additions and 1 deletions
|
|
@ -9,7 +9,7 @@
|
|||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx",
|
||||
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/blocked-triage-modal.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx",
|
||||
"video": "remotion preview src/video/index.ts",
|
||||
"video:render": "remotion render src/video/index.ts Main out/video.mp4",
|
||||
"video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60"
|
||||
|
|
|
|||
189
src/components/shared/blocked-triage-modal.tsx
Normal file
189
src/components/shared/blocked-triage-modal.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { deriveBlockedIds, buildBlockedByTree, type BlockedTreeNode } from '../../lib/kanban';
|
||||
import { useArchetypePicker } from '../../hooks/use-archetype-picker';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectContext } from '../../lib/types';
|
||||
import { Blocks, ChevronRight, UserPlus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BlockedTriageModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
issues: BeadIssue[];
|
||||
projectRoot: ProjectContext;
|
||||
}
|
||||
|
||||
export function BlockedTriageModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
issues,
|
||||
projectRoot,
|
||||
}: BlockedTriageModalProps) {
|
||||
const blockedIdsSet = useMemo(() => deriveBlockedIds(issues), [issues]);
|
||||
|
||||
const blockedTasks = useMemo(() => {
|
||||
return issues.filter((issue) => {
|
||||
const isExplicitlyBlocked = issue.status === 'blocked';
|
||||
const isDerivedBlocked = blockedIdsSet.has(issue.id);
|
||||
return isExplicitlyBlocked || isDerivedBlocked;
|
||||
});
|
||||
}, [issues, blockedIdsSet]);
|
||||
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
const archetypePicker = useArchetypePicker();
|
||||
|
||||
const toggleRow = (issueId: string) => {
|
||||
setExpandedRow((prev) => (prev === issueId ? null : issueId));
|
||||
};
|
||||
|
||||
const handleAssign = async (issueId: string) => {
|
||||
await archetypePicker.handleAssign(issueId);
|
||||
if (archetypePicker.assignSuccess) {
|
||||
setExpandedRow(null);
|
||||
archetypePicker.resetAssignState();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Blocks className="w-5 h-5 text-amber-500" />
|
||||
Blocked Tasks Triage
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{blockedTasks.length} blocked task{blockedTasks.length !== 1 ? 's' : ''} require attention.
|
||||
Click on a row to see the blocker chain and assign an archetype.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
||||
{blockedTasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No blocked tasks found.
|
||||
</div>
|
||||
) : (
|
||||
blockedTasks.map((issue) => {
|
||||
const blockerChain = buildBlockedByTree(issues, issue.id);
|
||||
const isExpanded = expandedRow === issue.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="border rounded-lg bg-card overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleRow(issue.id)}
|
||||
className="w-full flex items-center gap-3 p-3 text-left hover:bg-accent transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{issue.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{issue.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{issue.status === 'blocked' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-500/20 text-amber-500">
|
||||
explicit
|
||||
</span>
|
||||
)}
|
||||
{blockedIdsSet.has(issue.id) && issue.status !== 'blocked' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-orange-500/20 text-orange-500">
|
||||
derived
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t p-3 bg-muted/30">
|
||||
{blockerChain.nodes.length > 0 ? (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Blocked by:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{blockerChain.nodes.map((node: BlockedTreeNode) => (
|
||||
<span
|
||||
key={node.id}
|
||||
className="inline-flex items-center text-xs px-2 py-1 rounded bg-background border"
|
||||
style={{ marginLeft: `${node.level * 12}px` }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-400 mr-1.5" />
|
||||
{node.title}
|
||||
</span>
|
||||
))}
|
||||
{blockerChain.total > blockerChain.nodes.length && (
|
||||
<span className="text-xs text-muted-foreground py-1">
|
||||
+{blockerChain.total - blockerChain.nodes.length} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
No blocker chain found.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={archetypePicker.selectedArchetype || ''}
|
||||
onChange={(e) =>
|
||||
archetypePicker.setSelectedArchetype(e.target.value || null)
|
||||
}
|
||||
className="flex-1 text-sm px-3 py-1.5 rounded border bg-background"
|
||||
>
|
||||
<option value="">Select archetype...</option>
|
||||
<option value="arch-1">arch-1</option>
|
||||
<option value="arch-2">arch-2</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleAssign(issue.id)}
|
||||
disabled={!archetypePicker.selectedArchetype || archetypePicker.isAssigning}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{archetypePicker.isAssigning ? 'Assigning...' : 'Assign'}
|
||||
</button>
|
||||
</div>
|
||||
{archetypePicker.assignError && (
|
||||
<p className="text-xs text-red-500 mt-2">
|
||||
{archetypePicker.assignError}
|
||||
</p>
|
||||
)}
|
||||
{archetypePicker.assignSuccess && (
|
||||
<p className="text-xs text-green-500 mt-2">
|
||||
Assigned successfully!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
73
tests/components/blocked-triage-modal.test.tsx
Normal file
73
tests/components/blocked-triage-modal.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
test('BlockedTriageModal - file exists and exports', async () => {
|
||||
const filePath = path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx');
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
assert.ok(fileContent.includes('export function BlockedTriageModal'), 'Should export BlockedTriageModal function');
|
||||
assert.ok(fileContent.includes('export interface BlockedTriageModalProps'), 'Should export BlockedTriageModalProps interface');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - imports Dialog from shadcn', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('from "@/components/ui/dialog"') || fileContent.includes("from '@/components/ui/dialog'"), 'Should import Dialog components from shadcn');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - imports deriveBlockedIds and buildBlockedByTree from kanban', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('deriveBlockedIds'), 'Should import deriveBlockedIds');
|
||||
assert.ok(fileContent.includes('buildBlockedByTree'), 'Should import buildBlockedByTree');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - imports useArchetypePicker hook', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('useArchetypePicker'), 'Should import useArchetypePicker hook');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - imports BeadIssue type', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('BeadIssue'), 'Should import BeadIssue type');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - accepts isOpen, onClose, issues, projectRoot props', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('isOpen:'), 'Should have isOpen prop');
|
||||
assert.ok(fileContent.includes('onClose:'), 'Should have onClose prop');
|
||||
assert.ok(fileContent.includes('issues:'), 'Should have issues prop');
|
||||
assert.ok(fileContent.includes('projectRoot:'), 'Should have projectRoot prop');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - uses useMemo for blockedIds computation', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('useMemo'), 'Should use useMemo for computing blockedIds');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - computes blocked tasks using status=blocked AND deriveBlockedIds', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes("status === 'blocked'"), 'Should check explicit blocked status');
|
||||
assert.ok(fileContent.includes('deriveBlockedIds'), 'Should use deriveBlockedIds');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - uses buildBlockedByTree for blocker chain display', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('buildBlockedByTree'), 'Should use buildBlockedByTree for blocker chains');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - has local state for showing/hiding archetype picker per row', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('useState'), 'Should use useState');
|
||||
assert.ok(fileContent.includes('showPicker') || fileContent.includes('pickerOpen') || fileContent.includes('expandedRow'), 'Should have state for row picker visibility');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - modal content is scrollable', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
assert.ok(fileContent.includes('overflow-y-auto') || fileContent.includes('overflowAuto') || fileContent.includes('scrollable'), 'Should have scrollable content');
|
||||
});
|
||||
|
||||
test('BlockedTriageModal - reuses useArchetypePicker for row assignments', async () => {
|
||||
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/blocked-triage-modal.tsx'), 'utf-8');
|
||||
const useArchetypePickerCount = (fileContent.match(/useArchetypePicker/g) || []).length;
|
||||
assert.ok(useArchetypePickerCount >= 2, 'Should use useArchetypePicker hook');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue