diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 43ff94ea..0ad8d6d1 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -256,22 +256,18 @@ const PieChart = ({ data, title }: { data: Array<{label: string, value: number, const Dashboard: React.FC = ({ filteredNodes, stats }) => { return (
- {/* Total Tasks - Full Width Card */} -
-
-
- -
-
-
Total Tasks
-
{stats.total}
-
-
+ {/* Summary strip — compact + content-sized (was a tall, near-empty banner) */} +
+ + Total Tasks + {stats.total}
- {/* Stats Cards - Second Row */} -
-
+ {/* Status breakdown — one compact, dense grid (was 3 sparse rows of 3 with + single-digit numbers in oversized boxes). 2 cols on phones up to 5 on + desktop so the 9 statuses read as a tidy panel, not a wall of whitespace. */} +
+
{React.createElement(getStatusConfig('NOT_STARTED').icon!, { className: `h-8 w-8 ${getStatusConfig('NOT_STARTED').color}` })} @@ -283,7 +279,7 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
-
+
{React.createElement(getStatusConfig('PROPOSED').icon!, { className: `h-8 w-8 ${getStatusConfig('PROPOSED').color}` })} @@ -295,7 +291,7 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
-
+
{React.createElement(getStatusConfig('PLANNED').icon!, { className: `h-8 w-8 ${getStatusConfig('PLANNED').color}` })} @@ -306,11 +302,7 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
-
- - {/* Stats Cards - Third Row */} -
-
+
{React.createElement(getStatusConfig('IN_PROGRESS').icon!, { className: `h-8 w-8 ${getStatusConfig('IN_PROGRESS').color}` })} @@ -322,7 +314,7 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
-
+
{React.createElement(getStatusConfig('IN_REVIEW').icon!, { className: `h-8 w-8 ${getStatusConfig('IN_REVIEW').color}` })} @@ -334,7 +326,7 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
-
+
{React.createElement(getStatusConfig('BLOCKED').icon!, { className: `h-8 w-8 ${getStatusConfig('BLOCKED').color}` })} @@ -345,11 +337,7 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
-
- - {/* Stats Cards - Fourth Row */} -
-
+
{React.createElement(getStatusConfig('ON_HOLD').icon!, { className: `h-8 w-8 ${getStatusConfig('ON_HOLD').color}` })} @@ -361,7 +349,7 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
-
+
{React.createElement(getStatusConfig('COMPLETED').icon!, { className: `h-8 w-8 ${getStatusConfig('COMPLETED').color}` })} @@ -373,7 +361,7 @@ const Dashboard: React.FC = ({ filteredNodes, stats }) => {
-
+
{React.createElement(getStatusConfig('CANCELLED').icon!, { className: `h-8 w-8 ${getStatusConfig('CANCELLED').color}` })} diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 65cb483c..5685fa44 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -38,6 +38,7 @@ import { DeleteGraphModal } from './DeleteGraphModal'; import { ConnectWorkItemModal } from './ConnectWorkItemModal'; import { WorkItemDetailsModal } from './WorkItemDetailsModal'; import { NodeInspector } from './NodeInspector'; +import { NodeQuickEdit, type QuickEditCommit } from './NodeQuickEdit'; import { WorkItem, WorkItemEdge } from '../types/graph'; import { RelationshipType, RELATIONSHIP_OPTIONS, getRelationshipConfig } from '../constants/workItemConstants'; @@ -429,6 +430,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const [selectedNodes, setSelectedNodes] = useState>(new Set()); // Inline rename: an input floats over the node — no modal (W2) const [inlineEdit, setInlineEdit] = useState<{ nodeId: string; value: string; original: string; graphX: number; graphY: number } | null>(null); + // PR #87: quick in-context node editor (double-click a node) — edits every + // field without the heavy details modal. Holds the work item being edited. + const [quickEditNode, setQuickEditNode] = useState(null); + const quickEditNodeId = quickEditNode?.id ?? null; // D3 handlers are bound once at init and the init effect intentionally // avoids re-initialising on mode changes — so handlers would capture STALE @@ -2363,10 +2368,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i clickableEdges.classed('dim-for-hover', false); edgeLabelGroups.classed('dim-for-hover', false); }) - // Double-click a node → rename in place, no modal (W2) + // Double-click a node → quick in-context editor (title, description, type, + // priority, status) anchored to the node, no heavy modal (#87, W2). Single + // click+drag still moves the node (when unlocked); only a genuine + // double-click opens this. .on('dblclick.rename', (event: MouseEvent, d: any) => { event.stopPropagation(); - setInlineEdit({ nodeId: d.id, value: d.title || '', original: d.title || '', graphX: d.x || 0, graphY: d.y || 0 }); + setInlineEdit(null); + setQuickEditNode(d); }); @@ -4405,6 +4414,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }, [expandedNodeId]); useEffect(() => { setExpandedNode(null); }, [currentGraphId]); + // Esc closes the quick editor; switching graphs dismisses it (node is gone). + useEffect(() => { + if (!quickEditNodeId) return undefined; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setQuickEditNode(null); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [quickEditNodeId]); + useEffect(() => { setQuickEditNode(null); }, [currentGraphId]); + // Expose reset function to parent component useEffect(() => { if (onResetLayout) { @@ -4924,6 +4942,34 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i ); })()} + {/* #87: quick in-context node editor — anchored to the node, edits every + field with immediate optimistic saves + undo, no heavy modal. */} + {quickEditNode && (() => { + const simNode = (simulationRef.current?.nodes() as any[])?.find((n: any) => n.id === quickEditNode.id); + const node = simNode || quickEditNode; + const gx = node.x ?? 0; + const gy = node.y ?? 0; + const left = gx * currentTransform.scale + currentTransform.x; + const top = gy * currentTransform.scale + currentTransform.y; + const onCommit = (c: QuickEditCommit) => { + updateWorkItemMutation({ variables: { where: { id: quickEditNode.id }, update: c.update } }) + .then(() => { + undoStackRef.current.push({ + label: c.label, + undo: async () => { + await updateWorkItemMutation({ variables: { where: { id: quickEditNode.id }, update: c.prev } }); + }, + }); + }) + .catch(() => showError(`Could not ${c.label.toLowerCase()}`)); + }; + return ( +
+ setQuickEditNode(null)} /> +
+ ); + })()} + {/* PR-3: expand-in-place peek — a readable Card/Contents/Diagram panel anchored to its node on the canvas, on top of everything. Position is driven each frame by the rAF sync effect above. */} diff --git a/packages/web/src/components/NodeQuickEdit.tsx b/packages/web/src/components/NodeQuickEdit.tsx new file mode 100644 index 00000000..cf2a3107 --- /dev/null +++ b/packages/web/src/components/NodeQuickEdit.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { X } from 'lucide-react'; +import { + getTypeConfig, getStatusConfig, TYPE_OPTIONS, STATUS_OPTIONS, + type WorkItemType, type WorkItemStatus, +} from '../constants/workItemConstants'; + +export interface QuickEditCommit { + update: Record; + prev: Record; + label: string; +} + +interface NodeQuickEditProps { + node: any; + /** Persist one field change (optimistic); parent wires the mutation + undo. */ + onCommit: (c: QuickEditCommit) => void; + onClose: () => void; + rootTestId?: string; +} + +const TYPES = TYPE_OPTIONS.filter((t) => t.value !== 'all'); +const STATUSES = STATUS_OPTIONS.filter((s) => s.value !== 'all'); + +/** + * Quick in-context node editor (PR #87): a compact popover anchored to a node on + * the canvas that edits every field — title, description, type, priority, + * status — without opening the heavy details modal. Each field saves immediately + * (optimistic) via onCommit, which the parent turns into an updateWorkItems + * mutation + an undo entry. Positioning is handled by the parent (like the inline + * rename box). + */ +export function NodeQuickEdit({ node, onCommit, onClose, rootTestId = 'node-quick-edit' }: NodeQuickEditProps) { + const [title, setTitle] = useState(node.title || ''); + const [description, setDescription] = useState(node.description || ''); + const [priority, setPriority] = useState(Math.round(((node.priority ?? 0) as number) * 100)); + + const typeCfg = getTypeConfig(node.type as WorkItemType); + + const commitTitle = () => { + const t = title.trim(); + if (t && t !== (node.title || '')) onCommit({ update: { title: t }, prev: { title: node.title || '' }, label: 'Edit title' }); + }; + const commitDescription = () => { + if (description !== (node.description || '')) onCommit({ update: { description }, prev: { description: node.description || '' }, label: 'Edit description' }); + }; + const commitPriority = () => { + const p = priority / 100; + if (Math.abs(p - (node.priority ?? 0)) > 0.001) onCommit({ update: { priority: p }, prev: { priority: node.priority ?? 0 }, label: 'Edit priority' }); + }; + const commitType = (value: string) => { + if (value !== node.type) onCommit({ update: { type: value }, prev: { type: node.type }, label: 'Change type' }); + }; + const commitStatus = (value: string) => { + if (value !== node.status) onCommit({ update: { status: value }, prev: { status: node.status }, label: 'Change status' }); + }; + + return ( +
e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + > +
+ {typeCfg.label} + Quick edit + +
+ +
+ {/* Title */} + + + {/* Description */} +