feat(graph): Implement Graph View with Dagre Layout and Epic Scope (bb-18e)
This commit is contained in:
parent
7ab23448f0
commit
8490cb1d8c
33 changed files with 4936 additions and 38 deletions
219
src/components/graph/dependency-flow-strip.tsx
Normal file
219
src/components/graph/dependency-flow-strip.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
'use client';
|
||||
|
||||
import type { GraphNode } from '../../lib/graph';
|
||||
import type { PathWorkspace } from '../../lib/graph-view';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Props for an individual flow card in the dependency strip. */
|
||||
interface FlowCardProps {
|
||||
/** The graph node data for this card. */
|
||||
node: GraphNode;
|
||||
/** Whether this card is the currently selected/focused task. */
|
||||
selected: boolean;
|
||||
/** Number of issues blocking this node. */
|
||||
blockedBy: number;
|
||||
/** Number of issues this node blocks. */
|
||||
blocks: number;
|
||||
/** Callback fired when the user clicks this card. */
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Props for the DependencyFlowStrip component. */
|
||||
interface DependencyFlowStripProps {
|
||||
/** The computed path workspace containing blockers, focus, and dependents. */
|
||||
workspace: PathWorkspace;
|
||||
/** ID of the currently selected task, or null. */
|
||||
selectedId: string | null;
|
||||
/** Map of issue ID to blocker/blocks counts. */
|
||||
signalById: Map<string, { blockedBy: number; blocks: number }>;
|
||||
/** Callback fired when the user selects a card. */
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Tailwind background color class for a status dot indicator.
|
||||
*/
|
||||
function statusDot(status: BeadIssue['status']): string {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-sky-400';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-400';
|
||||
case 'blocked':
|
||||
return 'bg-rose-500';
|
||||
case 'deferred':
|
||||
return 'bg-slate-400';
|
||||
case 'closed':
|
||||
return 'bg-emerald-400';
|
||||
case 'pinned':
|
||||
return 'bg-violet-400';
|
||||
case 'hooked':
|
||||
return 'bg-orange-400';
|
||||
default:
|
||||
return 'bg-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact card representing a single node in the dependency flow.
|
||||
* Shows ID, title, status, and blocker/blocks counts.
|
||||
*/
|
||||
function FlowCard({ node, selected, blockedBy, blocks, onSelect }: FlowCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id)}
|
||||
className={`workflow-card w-full rounded-xl px-3 py-2.5 text-left transition duration-200 ${selected
|
||||
? 'workflow-card-selected'
|
||||
: 'hover:border-sky-300/40 hover:bg-[linear-gradient(165deg,rgba(76,94,134,0.2),rgba(18,20,30,0.84))]'
|
||||
}`}
|
||||
>
|
||||
{/* Header: node ID + status dot */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[9px] tracking-[0.04em] text-text-muted/80">{node.id}</span>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${statusDot(node.status)}`} />
|
||||
</div>
|
||||
{/* Node title - truncates at 2 lines */}
|
||||
<p className="mt-1 text-[12px] font-semibold leading-tight text-text-strong line-clamp-2">{node.title}</p>
|
||||
{/* Dependency signal counts */}
|
||||
<p className="mt-1 text-[10px] text-text-body">
|
||||
{blockedBy} blockers • {blocks} dependents
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a section header with a count badge.
|
||||
*/
|
||||
function SectionHeader({ label, count, color }: { label: string; count: number; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-[0.15em] ${color}`}>{label}</span>
|
||||
<span className="rounded-md bg-white/5 px-1.5 py-0.5 text-[9px] font-bold text-text-muted/60">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dependency flow as three responsive sections stacked vertically:
|
||||
* Blocked By, Selected/Focus, and Blocks (Dependents).
|
||||
* Each section uses a responsive wrapping grid so cards never overflow.
|
||||
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
|
||||
// ... (FlowCardProps, DependencyFlowStripProps, statusDot, FlowCard, SectionHeader definitions remain unchanged)
|
||||
|
||||
/**
|
||||
* Renders the dependency flow as three responsive sections stacked vertically:
|
||||
* Blocked By, Selected/Focus, and Blocks (Dependents).
|
||||
* Each section uses a responsive wrapping grid so cards never overflow.
|
||||
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
|
||||
*/
|
||||
export function DependencyFlowStrip({ workspace, selectedId, signalById, onSelect }: DependencyFlowStripProps) {
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
|
||||
// Flatten the multi-hop blocker/dependent arrays for display
|
||||
const blockerNodes = workspace.blockers.flat();
|
||||
const dependentNodes = workspace.dependents.flat();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/5 bg-white/[0.01] px-5 py-4 ring-1 ring-white/5 transition-all duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">
|
||||
Dependency Flow
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setMinimized(!minimized)}
|
||||
className="rounded p-1 hover:bg-white/5 text-text-muted transition-colors"
|
||||
title={minimized ? "Expand" : "Minimize"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`transition-transform duration-200 ${minimized ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Responsive three-column layout: stacks on mobile, side-by-side on desktop */}
|
||||
{!minimized && (
|
||||
<div className="grid gap-4 md:grid-cols-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Blocked By section */}
|
||||
<div>
|
||||
<SectionHeader label="Blocked By" count={blockerNodes.length} color="text-rose-400/70" />
|
||||
{blockerNodes.length > 0 ? (
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
|
||||
{blockerNodes.map((node) => (
|
||||
<FlowCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
selected={selectedId === node.id}
|
||||
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(node.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">No blockers</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected / Focused task section */}
|
||||
<div>
|
||||
<SectionHeader label="Selected" count={workspace.focus ? 1 : 0} color="text-sky-400/70" />
|
||||
{workspace.focus ? (
|
||||
<FlowCard
|
||||
node={workspace.focus}
|
||||
selected
|
||||
blockedBy={signalById.get(workspace.focus.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(workspace.focus.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">Select a task</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blocks (Dependents) section */}
|
||||
<div>
|
||||
<SectionHeader label="Blocks" count={dependentNodes.length} color="text-amber-400/70" />
|
||||
{dependentNodes.length > 0 ? (
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
|
||||
{dependentNodes.map((node) => (
|
||||
<FlowCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
selected={selectedId === node.id}
|
||||
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(node.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">No dependents</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue