feat: improve connection display with reusable component and instant actor type updates

Creates reusable ConnectionDisplay component and enhances connection
visualization in both Actor and Relation properties panels.

Changes:
- Add ConnectionDisplay component for consistent connection visualization
  - Shows source and target actors with icons, labels, and type names
  - Includes direction indicators (→, ↔, —) based on directionality
  - Provides tooltips with node IDs on hover

- Enhance Actor Properties connections section
  - Display full actor information instead of just node IDs
  - Show edge type badges with color indicators
  - Include custom labels when different from type labels
  - Use ConnectionDisplay component for rich connection details

- Refactor Relation Properties to use ConnectionDisplay
  - Eliminates duplicate connection rendering code
  - Maintains consistent UI across panels

- Change actor type updates to apply instantly
  - Remove debounce delay for actor type dropdown changes
  - Provides immediate visual feedback when changing types
  - Consistent with relation type and directionality behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-12 12:17:47 +02:00
parent 1646cfb0ce
commit fab5c035a5
2 changed files with 124 additions and 70 deletions

View file

@ -0,0 +1,79 @@
import { Tooltip } from '@mui/material';
import { getIconComponent } from '../../utils/iconUtils';
import type { Actor, NodeTypeConfig, EdgeDirectionality } from '../../types';
/**
* ConnectionDisplay - Reusable component for displaying actor connections
*
* Shows source and target actors with:
* - Node type icon
* - Node label
* - Node type label
* - Direction indicator based on directionality
*/
interface ConnectionDisplayProps {
sourceNode: Actor | undefined;
targetNode: Actor | undefined;
nodeTypes: NodeTypeConfig[];
directionality: EdgeDirectionality;
}
const ConnectionDisplay = ({
sourceNode,
targetNode,
nodeTypes,
directionality
}: ConnectionDisplayProps) => {
const sourceType = nodeTypes.find(nt => nt.id === sourceNode?.data?.type);
const targetType = nodeTypes.find(nt => nt.id === targetNode?.data?.type);
const SourceIcon = sourceType ? getIconComponent(sourceType.icon) : null;
const TargetIcon = targetType ? getIconComponent(targetType.icon) : null;
return (
<div className="flex items-center justify-between text-xs text-gray-600 py-2 bg-gray-50 rounded px-2 space-x-2">
{/* Source Actor */}
<Tooltip title={`ID: ${sourceNode?.id || 'Unknown'}`} placement="top">
<div className="flex items-center space-x-1 flex-1">
{SourceIcon && (
<div className="flex-shrink-0" style={{ color: sourceType?.color, fontSize: '14px' }}>
<SourceIcon fontSize="small" />
</div>
)}
<span className="font-medium truncate">
{sourceNode?.data?.label || sourceNode?.id || 'Unknown'}
</span>
<span className="text-gray-400 text-[10px] flex-shrink-0">
({sourceType?.label || 'Unknown'})
</span>
</div>
</Tooltip>
{/* Direction Indicator */}
<span className="flex-shrink-0 text-gray-500">
{directionality === 'directed' && '→'}
{directionality === 'bidirectional' && '↔'}
{directionality === 'undirected' && '—'}
</span>
{/* Target Actor */}
<Tooltip title={`ID: ${targetNode?.id || 'Unknown'}`} placement="top">
<div className="flex items-center space-x-1 flex-1 justify-end">
<span className="text-gray-400 text-[10px] flex-shrink-0">
({targetType?.label || 'Unknown'})
</span>
<span className="font-medium truncate">
{targetNode?.data?.label || targetNode?.id || 'Unknown'}
</span>
{TargetIcon && (
<div className="flex-shrink-0" style={{ color: targetType?.color, fontSize: '14px' }}>
<TargetIcon fontSize="small" />
</div>
)}
</div>
</Tooltip>
</div>
);
};
export default ConnectionDisplay;

View file

@ -12,7 +12,7 @@ import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useDocumentHistory } from '../../hooks/useDocumentHistory'; import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import GraphMetrics from '../Common/GraphMetrics'; import GraphMetrics from '../Common/GraphMetrics';
import { getIconComponent } from '../../utils/iconUtils'; import ConnectionDisplay from '../Common/ConnectionDisplay';
import type { Actor, Relation, EdgeDirectionality } from '../../types'; import type { Actor, Relation, EdgeDirectionality } from '../../types';
/** /**
@ -264,8 +264,18 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
<select <select
value={actorType} value={actorType}
onChange={(e) => { onChange={(e) => {
setActorType(e.target.value); const newType = e.target.value;
setHasNodeChanges(true); setActorType(newType);
// Apply actor type change instantly (no debounce)
if (selectedNode) {
updateNode(selectedNode.id, {
data: {
type: newType,
label: actorLabel,
description: actorDescription || undefined,
},
});
}
}} }}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
@ -331,24 +341,37 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
{connections.length === 0 ? ( {connections.length === 0 ? (
<p className="text-xs text-gray-500 italic">No connections</p> <p className="text-xs text-gray-500 italic">No connections</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-3">
{connections.map((edge) => { {connections.map((edge) => {
const edgeConfig = edgeTypes.find((et) => et.id === edge.data?.type); const edgeConfig = edgeTypes.find((et) => et.id === edge.data?.type);
const isOutgoing = edge.source === selectedNode.id; const sourceNode = nodes.find(n => n.id === edge.source);
const otherId = isOutgoing ? edge.target : edge.source; const targetNode = nodes.find(n => n.id === edge.target);
const edgeDirectionality = edge.data?.directionality || edgeConfig?.defaultDirectionality || 'directed';
return ( return (
<div <div key={edge.id} className="space-y-1">
key={edge.id} {/* Edge Type Badge */}
className="text-xs text-gray-600 flex items-center space-x-1" <div className="flex items-center space-x-1">
>
<span <span
className="inline-block w-2 h-2 rounded-full" className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: edgeConfig?.color || '#6b7280' }} style={{ backgroundColor: edgeConfig?.color || '#6b7280' }}
/> />
<span className="font-medium">{edgeConfig?.label || 'Unknown'}</span> <span className="text-xs font-medium text-gray-700">
<span>{isOutgoing ? '→' : '←'}</span> {edgeConfig?.label || 'Unknown'}
<span className="text-gray-500">{otherId}</span> </span>
{edge.data?.label && edge.data.label !== edgeConfig?.label && (
<span className="text-xs text-gray-500">
({edge.data.label})
</span>
)}
</div>
{/* Connection Display */}
<ConnectionDisplay
sourceNode={sourceNode}
targetNode={targetNode}
nodeTypes={nodeTypes}
directionality={edgeDirectionality}
/>
</div> </div>
); );
})} })}
@ -539,60 +562,12 @@ const RightPanel = ({ selectedNode, selectedEdge, onClose }: Props) => {
</Tooltip> </Tooltip>
)} )}
</div> </div>
<div className="flex items-center justify-between text-xs text-gray-600 py-2 bg-gray-50 rounded px-2 space-x-2"> <ConnectionDisplay
{/* Source Actor */} sourceNode={nodes.find(n => n.id === currentEdge.source)}
<Tooltip title={`ID: ${currentEdge.source}`} placement="top"> targetNode={nodes.find(n => n.id === currentEdge.target)}
<div className="flex items-center space-x-1 flex-1"> nodeTypes={nodeTypes}
{(() => { directionality={relationDirectionality}
const sourceNode = nodes.find(n => n.id === currentEdge.source); />
const sourceType = nodeTypes.find(nt => nt.id === sourceNode?.data?.type);
const IconComponent = sourceType ? getIconComponent(sourceType.icon) : null;
return (
<>
{IconComponent && (
<div className="flex-shrink-0" style={{ color: sourceType?.color, fontSize: '14px' }}>
<IconComponent fontSize="small" />
</div>
)}
<span className="font-medium truncate">{sourceNode?.data?.label || currentEdge.source}</span>
<span className="text-gray-400 text-[10px] flex-shrink-0">({sourceType?.label || 'Unknown'})</span>
</>
);
})()}
</div>
</Tooltip>
{/* Direction Indicator */}
<span className="flex-shrink-0 text-gray-500">
{relationDirectionality === 'directed' && '→'}
{relationDirectionality === 'bidirectional' && '↔'}
{relationDirectionality === 'undirected' && '—'}
</span>
{/* Target Actor */}
<Tooltip title={`ID: ${currentEdge.target}`} placement="top">
<div className="flex items-center space-x-1 flex-1 justify-end">
{(() => {
const targetNode = nodes.find(n => n.id === currentEdge.target);
const targetType = nodeTypes.find(nt => nt.id === targetNode?.data?.type);
const IconComponent = targetType ? getIconComponent(targetType.icon) : null;
return (
<>
<span className="text-gray-400 text-[10px] flex-shrink-0">({targetType?.label || 'Unknown'})</span>
<span className="font-medium truncate">{targetNode?.data?.label || currentEdge.target}</span>
{IconComponent && (
<div className="flex-shrink-0" style={{ color: targetType?.color, fontSize: '14px' }}>
<IconComponent fontSize="small" />
</div>
)}
</>
);
})()}
</div>
</Tooltip>
</div>
</div> </div>
</div> </div>