feat: add edge search functionality to filter section

Extends the search and filter system to include edge/relation searching
alongside the existing actor search functionality.

Changes:
- Search input now searches both actors AND relations
- Edges are filtered by custom label or relation type name
- Updated placeholder text to "Search actors and relations..."
- Results summary now shows separate counts for actors and relations
- Non-matching edges are dimmed to 20% opacity when filters are active
- Search logic applies consistently across CustomEdge and LeftPanel

The same search text field is used for both actors and relations,
providing a unified search experience across the entire graph.

🤖 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-16 14:19:58 +02:00
parent ba6606d8b9
commit f9c208d7ac
3 changed files with 70 additions and 14 deletions

View file

@ -33,7 +33,7 @@ const CustomEdge = ({
selected, selected,
}: EdgeProps<RelationData>) => { }: EdgeProps<RelationData>) => {
const edgeTypes = useGraphStore((state) => state.edgeTypes); const edgeTypes = useGraphStore((state) => state.edgeTypes);
const { visibleRelationTypes } = useSearchStore(); const { searchText, visibleRelationTypes } = useSearchStore();
// Calculate the bezier path // Calculate the bezier path
const [edgePath, labelX, labelY] = getBezierPath({ const [edgePath, labelX, labelY] = getBezierPath({
@ -64,16 +64,33 @@ const CustomEdge = ({
const directionality = data?.directionality || edgeTypeConfig?.defaultDirectionality || 'directed'; const directionality = data?.directionality || edgeTypeConfig?.defaultDirectionality || 'directed';
// Check if this edge matches the filter criteria // Check if this edge matches the filter criteria
const isVisible = useMemo(() => { const isMatch = useMemo(() => {
// Check type visibility
const edgeType = data?.type || ''; const edgeType = data?.type || '';
return visibleRelationTypes[edgeType] !== false; const isTypeVisible = visibleRelationTypes[edgeType] !== false;
}, [data?.type, visibleRelationTypes]); if (!isTypeVisible) {
return false;
}
// Check search text match
if (searchText.trim()) {
const searchLower = searchText.toLowerCase();
const label = data?.label?.toLowerCase() || '';
const typeName = edgeTypeConfig?.label?.toLowerCase() || '';
return label.includes(searchLower) || typeName.includes(searchLower);
}
return true;
}, [searchText, visibleRelationTypes, data?.type, data?.label, edgeTypeConfig?.label]);
// Determine if filters are active // Determine if filters are active
const hasActiveFilters = Object.values(visibleRelationTypes).some(v => v === false); const hasActiveFilters =
searchText.trim() !== '' ||
Object.values(visibleRelationTypes).some(v => v === false);
// Calculate opacity based on visibility // Calculate opacity based on visibility
const edgeOpacity = hasActiveFilters && !isVisible ? 0.2 : 1.0; const edgeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
// Create unique marker IDs based on color (for reusability) // Create unique marker IDs based on color (for reusability)
const safeColor = edgeColor.replace('#', ''); const safeColor = edgeColor.replace('#', '');

View file

@ -50,7 +50,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
expandLeftPanel, expandLeftPanel,
} = usePanelStore(); } = usePanelStore();
const { nodeTypes, edgeTypes, addNode, nodes } = useGraphWithHistory(); const { nodeTypes, edgeTypes, addNode, nodes, edges } = useGraphWithHistory();
const { selectedRelationType, setSelectedRelationType } = useEditorStore(); const { selectedRelationType, setSelectedRelationType } = useEditorStore();
const [showNodeConfig, setShowNodeConfig] = useState(false); const [showNodeConfig, setShowNodeConfig] = useState(false);
const [showEdgeConfig, setShowEdgeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false);
@ -142,6 +142,38 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
}); });
}, [nodes, searchText, visibleActorTypes, nodeTypes]); }, [nodes, searchText, visibleActorTypes, nodeTypes]);
// Calculate matching edges based on search and filters
const matchingEdges = useMemo(() => {
const searchLower = searchText.toLowerCase().trim();
return edges.filter((edge) => {
const edgeType = edge.data?.type || '';
// Filter by edge type visibility
const isTypeVisible = visibleRelationTypes[edgeType] !== false;
if (!isTypeVisible) {
return false;
}
// Filter by search text
if (searchLower) {
const label = edge.data?.label?.toLowerCase() || '';
const edgeTypeConfig = edgeTypes.find((et) => et.id === edgeType);
const typeName = edgeTypeConfig?.label?.toLowerCase() || '';
const matches =
label.includes(searchLower) ||
typeName.includes(searchLower);
if (!matches) {
return false;
}
}
return true;
});
}, [edges, searchText, visibleRelationTypes, edgeTypes]);
const handleAddNode = useCallback( const handleAddNode = useCallback(
(nodeTypeId: string) => { (nodeTypeId: string) => {
@ -389,7 +421,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
type="text" type="text"
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
placeholder="Search actors..." placeholder="Search actors and relations..."
className="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
{searchText && ( {searchText && (
@ -490,11 +522,18 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
{/* Results Summary */} {/* Results Summary */}
<div className="pt-2 border-t border-gray-100"> <div className="pt-2 border-t border-gray-100">
<div className="text-xs text-gray-600"> <div className="text-xs text-gray-600 space-y-1">
<span className="font-medium">Results:</span>{' '} <div>
{matchingNodes.length} actor{matchingNodes.length !== 1 ? 's' : ''} <span className="font-medium">Actors:</span>{' '}
{matchingNodes.length}
{searchText || hasActiveFilters() ? ` of ${nodes.length}` : ''} {searchText || hasActiveFilters() ? ` of ${nodes.length}` : ''}
</div> </div>
<div>
<span className="font-medium">Relations:</span>{' '}
{matchingEdges.length}
{searchText || hasActiveFilters() ? ` of ${edges.length}` : ''}
</div>
</div>
</div> </div>
</div> </div>
)} )}

View file

@ -4,14 +4,14 @@ import { create } from 'zustand';
* SearchStore - Manages search and filter state * SearchStore - Manages search and filter state
* *
* Features: * Features:
* - Search text for filtering nodes by label, description, or type * - Search text for filtering both actors (by label, description, or type) and relations (by label or type)
* - Filter by actor types (show/hide specific node types) * - Filter by actor types (show/hide specific node types)
* - Filter by relation types (show/hide specific edge types) * - Filter by relation types (show/hide specific edge types)
* - Results tracking * - Results tracking
*/ */
interface SearchStore { interface SearchStore {
// Search text // Search text (applies to both actors and edges)
searchText: string; searchText: string;
setSearchText: (text: string) => void; setSearchText: (text: string) => void;