DEV Community

Rahul
Rahul

Posted on

Testcraft Studio frontend 29: src/pages/Graph.jsx

import React, { useMemo, useRef, useState } from 'react';
import Icon from '../Icon.jsx';
import { useStudio, eligibleDocuments, resolveSelection } from '../store.jsx';
import { Badge, Button, ContextBar, Heading, Note, Search, SourceDialog, WorkspaceRequired } from '../ui.jsx';

const colors = { Team: '#7a6bc3', Rule: '#5e89b9', Document: '#799a83', Contract: '#b78d50', CR: '#9474ae', Unresolved: '#c18f43' };
function buildGraph(s, w, hop) {
  if (!w) return { nodes: [], edges: [] };
  const selection = resolveSelection(s, w.baselineId, w.crIds), docs = eligibleDocuments(s, w), accessible = new Set(docs.map(d => d.id));
  const nodes = [{ id: 'team', label: w.team, type: 'Team', x: 80, y: 220, description: 'Owning team. Cross-team traversal ends at explicitly shared contracts.' }], edges = [];
  const rules = new Map((selection.baseline?.rules || []).map(r => [r.id, r]));
  selection.diff.forEach(c => { if (c.op === 'REMOVE') rules.delete(c.ruleId); else rules.set(c.ruleId, { id: c.ruleId, title: c.title, text: c.after, source: c.source }); });
  (w.customRules || []).forEach(r => rules.set(r.id, r));
  [...rules.values()].slice(0, 8).forEach((r, i) => { nodes.push({ id: r.id, label: r.title, type: 'Rule', x: 315, y: 75 + i * 100, description: r.text, source: r.source, revision: selection.baseline?.version }); edges.push({ from: r.id, to: 'team', label: 'APPLIES_TO', source: r.source }); });
  if (hop >= 2) {
    docs.slice(0, 8).forEach((d, i) => { nodes.push({ id: d.id, label: d.name, type: d.team === w.team ? 'Document' : 'Contract', x: 590, y: 55 + i * 88, description: d.excerpt || 'Open the source evidence to review this document.', source: d.id, revision: d.revision }); });
    [...rules.values()].forEach(r => { if (accessible.has(r.source)) edges.push({ from: r.id, to: r.source, label: 'EVIDENCED_BY', source: r.source }); });
  }
  if (hop >= 3) {
    selection.changes.slice(0, 4).forEach((cr, i) => { nodes.push({ id: cr.id, label: cr.ref, type: 'CR', x: 80, y: 65 + i * 100, description: `${cr.name} · Revision ${cr.revision}` }); cr.changes.forEach(c => { if (rules.has(c.ruleId)) edges.push({ from: cr.id, to: c.ruleId, label: 'AFFECTS', source: c.source }); }); });
    const q = s.questions.find(q => q.workspaceId === w.id && q.version === w.version && q.status !== 'resolved');
    if (q) { nodes.push({ id: q.id, label: 'Unresolved dependency', type: 'Unresolved', x: 315, y: 75 + Math.min(rules.size, 8) * 100, description: `${q.title} ${q.why}`, source: q.sourceId }); if (accessible.has(q.sourceId)) edges.push({ from: q.id, to: q.sourceId, label: 'EVIDENCED_BY', source: q.sourceId }); }
  }
  return { nodes: nodes.slice(0, 30), edges };
}
export default function Graph({ navigate }) {
  const { state: s, workspace: w } = useStudio();
  const [hop, setHop] = useState(3), [query, setQuery] = useState(''), [types, setTypes] = useState(Object.keys(colors)), [selected, setSelected] = useState(''), [zoom, setZoom] = useState(1), [pan, setPan] = useState({ x: 0, y: 0 }), [source, setSource] = useState('');
  const drag = useRef(null), svg = useRef(null);
  const graph = useMemo(() => buildGraph(s, w, hop), [s, w, hop]);
  if (!w) return <WorkspaceRequired navigate={navigate}/>;
  const nodes = graph.nodes.filter(n => types.includes(n.type)), ids = new Set(nodes.map(n => n.id)), edges = graph.edges.filter(e => ids.has(e.from) && ids.has(e.to)), current = graph.nodes.find(n => n.id === selected);
  const height = Math.max(490, Math.min(1000, Math.max(...graph.nodes.map(n => n.y)) + 75));
  const pointerDown = e => { if (e.button !== 0 || e.target.closest('[data-node]')) return; svg.current.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, pan }; };
  return <><Heading eyebrow={`${w.ref} · SOURCE-BACKED RELATIONSHIPS`} title="Dependency graph" description="Explore selected rules and evidence without crossing private team boundaries."/><ContextBar navigate={navigate}/><div className="graph-layout margin-top"><section className="card graph-card"><div className="graph-toolbar"><Search value={query} onChange={setQuery} placeholder="Highlight an entity…"/><label>Hops<select value={hop} onChange={e => setHop(Number(e.target.value))}>{[1, 2, 3].map(v => <option key={v}>{v}</option>)}</select></label><div className="row-actions"><Button aria-label="Zoom out" onClick={() => setZoom(z => Math.max(.45, z - .15))}>−</Button><span className="subtle">{Math.round(zoom * 100)}%</span><Button aria-label="Zoom in" onClick={() => setZoom(z => Math.min(1.8, z + .15))}>+</Button><Button onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}>Reset</Button></div></div><div className="graph-type-filters">{Object.entries(colors).map(([type, color]) => <label key={type}><input type="checkbox" checked={types.includes(type)} onChange={() => setTypes(t => t.includes(type) ? t.filter(v => v !== type) : [...t, type])}/><span style={{ background: color }}/>{type}</label>)}</div><div className="graph-surface"><svg ref={svg} viewBox={`0 0 790 ${height}`} aria-label="Interactive source-backed dependency graph" onPointerDown={pointerDown} onPointerMove={e => { if (drag.current) { const scale = 790 / svg.current.getBoundingClientRect().width; setPan({ x: drag.current.pan.x + (e.clientX - drag.current.x) * scale, y: drag.current.pan.y + (e.clientY - drag.current.y) * scale }); } }} onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }}><defs><marker id="graph-arrow" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto"><path d="M0 0 L6 3 L0 6" fill="#b8b6c9"/></marker><pattern id="dot-grid" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r=".7" fill="currentColor"/></pattern></defs><rect width="100%" height="100%" fill="url(#dot-grid)" className="graph-grid"/><g transform={`translate(${pan.x} ${pan.y}) scale(${zoom})`}>{edges.map((edge, i) => { const a = nodes.find(n => n.id === edge.from), b = nodes.find(n => n.id === edge.to); const reverse = b.x < a.x, ax = a.x + (reverse ? -65 : 65), bx = b.x + (reverse ? 65 : -65); return <g key={i}><path d={`M ${ax} ${a.y} C ${(ax + bx) / 2} ${a.y}, ${(ax + bx) / 2} ${b.y}, ${bx} ${b.y}`} fill="none" stroke="#b8b6c9" strokeWidth="1.2" markerEnd="url(#graph-arrow)"/><text x={(a.x + b.x) / 2} y={(a.y + b.y) / 2 - 7} textAnchor="middle" className="graph-edge-label">{edge.label}</text></g>; })}{nodes.map(node => <g key={node.id} data-node={node.id} transform={`translate(${node.x},${node.y})`} role="button" tabIndex={0} aria-label={`${node.type}: ${node.label}`} onClick={() => setSelected(node.id)} onKeyDown={e => { if (['Enter', ' '].includes(e.key)) { e.preventDefault(); setSelected(node.id); } }} className={`graph-node ${selected === node.id ? 'selected' : ''} ${query && !node.label.toLowerCase().includes(query.toLowerCase()) ? 'dimmed' : ''}`}><rect x="-65" y="-27" width="130" height="54" rx="8" fill="var(--surface)" stroke={colors[node.type]} strokeWidth={selected === node.id ? 2.7 : 1.3}/><circle cx="-51" cy="-10" r="3" fill={colors[node.type]}/><text x="-42" y="-7" className="graph-node-type">{node.type.toUpperCase()}</text><text x="-51" y="12" className="graph-node-title">{node.label.length > 20 ? `${node.label.slice(0, 18)}…` : node.label}</text></g>)}</g></svg></div><div className="table-footer">{nodes.length} nodes · {edges.length} links · Maximum 3 hops / 30 nodes<span>Drag background to pan</span></div></section><section className="card padded graph-inspector"><h2>{current ? current.label : 'Inspect a relationship'}</h2>{current ? <><div className="detail-summary"><Badge tone="purple">{current.type}</Badge>{current.revision && <span>Revision {current.revision}</span>}</div><p className="supporting-copy">{current.description}</p>{current.source && <Button className="margin-top full-width" icon="file" onClick={() => setSource(current.source)}>Open source evidence</Button>}<h3 className="form-section-label">Directed relationships</h3>{graph.edges.filter(e => e.from === current.id || e.to === current.id).map((e, i) => <div className="graph-relationship" key={i}><small>{e.label}</small><span>{graph.nodes.find(n => n.id === e.from)?.label} → {graph.nodes.find(n => n.id === e.to)?.label}</span></div>)}</> : <p className="supporting-copy">Select a node with a click or keyboard to inspect its type, version, relationships, and source evidence.</p>}</section></div>{source && <SourceDialog sourceId={source} close={() => setSource('')}/>}</>;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)