mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
feat: add search and filter functionality with Ctrl+F shortcut
Implements search and filter feature from UX_ANALYSIS.md to help users find actors and relations in complex graphs. Features: - Search store with Zustand for managing search/filter state - Real-time search by actor label, description, or type name - Filter by actor types (show/hide specific node types) - Filter by relation types (show/hide specific edge types) - Visual feedback: non-matching items dimmed to 20% opacity - Matching items highlighted with colored glow when filters active - Results counter showing X actors of Y total - Ctrl+F keyboard shortcut to focus search input - Expands left panel if collapsed - Opens search section if closed - Focuses search input field UI improvements: - Search input with magnifying glass icon and clear button - Reset filters link (only visible when filters active) - Checkboxes for each actor/relation type with visual indicators - Smooth transitions and hover states - Fixed icon overlap issue in search input Components modified: - CustomNode: Apply opacity/highlighting based on search matches - CustomEdge: Apply opacity based on relation type filters - LeftPanel: Full search UI with filters in existing section - App: Wire up Ctrl+F shortcut with ref-based focus handler 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
aa2bd7e5d7
commit
1646cfb0ce
6 changed files with 453 additions and 13 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import GraphEditor from "./components/Editor/GraphEditor";
|
||||
import LeftPanel from "./components/Panels/LeftPanel";
|
||||
import LeftPanel, { type LeftPanelRef } from "./components/Panels/LeftPanel";
|
||||
import RightPanel from "./components/Panels/RightPanel";
|
||||
import BottomPanel from "./components/Timeline/BottomPanel";
|
||||
import DocumentTabs from "./components/Workspace/DocumentTabs";
|
||||
|
|
@ -46,6 +46,9 @@ function AppContent() {
|
|||
const { handleNewDocument, NewDocumentDialog } = useCreateDocument();
|
||||
const [showDocumentManager, setShowDocumentManager] = useState(false);
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||
|
||||
// Ref for LeftPanel to call focusSearch
|
||||
const leftPanelRef = useRef<LeftPanelRef>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
|
||||
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
|
||||
const [addNodeCallback, setAddNodeCallback] = useState<
|
||||
|
|
@ -88,6 +91,7 @@ function AppContent() {
|
|||
onOpenHelp: () => setShowKeyboardHelp(true),
|
||||
onFitView: handleFitView,
|
||||
onSelectAll: handleSelectAll,
|
||||
onFocusSearch: () => leftPanelRef.current?.focusSearch(),
|
||||
});
|
||||
|
||||
// Escape key to close property panels
|
||||
|
|
@ -147,6 +151,7 @@ function AppContent() {
|
|||
{/* Left Panel */}
|
||||
{leftPanelVisible && activeDocumentId && (
|
||||
<LeftPanel
|
||||
ref={leftPanelRef}
|
||||
onDeselectAll={() => {
|
||||
setSelectedNode(null);
|
||||
setSelectedEdge(null);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import {
|
||||
EdgeProps,
|
||||
getBezierPath,
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
BaseEdge,
|
||||
} from 'reactflow';
|
||||
import { useGraphStore } from '../../stores/graphStore';
|
||||
import { useSearchStore } from '../../stores/searchStore';
|
||||
import type { RelationData } from '../../types';
|
||||
|
||||
/**
|
||||
|
|
@ -32,6 +33,7 @@ const CustomEdge = ({
|
|||
selected,
|
||||
}: EdgeProps<RelationData>) => {
|
||||
const edgeTypes = useGraphStore((state) => state.edgeTypes);
|
||||
const { visibleRelationTypes } = useSearchStore();
|
||||
|
||||
// Calculate the bezier path
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
|
|
@ -61,6 +63,18 @@ const CustomEdge = ({
|
|||
// Get directionality (default to 'directed' for backwards compatibility)
|
||||
const directionality = data?.directionality || edgeTypeConfig?.defaultDirectionality || 'directed';
|
||||
|
||||
// Check if this edge matches the filter criteria
|
||||
const isVisible = useMemo(() => {
|
||||
const edgeType = data?.type || '';
|
||||
return visibleRelationTypes[edgeType] !== false;
|
||||
}, [data?.type, visibleRelationTypes]);
|
||||
|
||||
// Determine if filters are active
|
||||
const hasActiveFilters = Object.values(visibleRelationTypes).some(v => v === false);
|
||||
|
||||
// Calculate opacity based on visibility
|
||||
const edgeOpacity = hasActiveFilters && !isVisible ? 0.2 : 1.0;
|
||||
|
||||
// Create unique marker IDs based on color (for reusability)
|
||||
const safeColor = edgeColor.replace('#', '');
|
||||
const markerEndId = `arrow-end-${safeColor}`;
|
||||
|
|
@ -114,6 +128,7 @@ const CustomEdge = ({
|
|||
stroke: edgeColor,
|
||||
strokeWidth: selected ? 3 : 2,
|
||||
strokeDasharray,
|
||||
opacity: edgeOpacity,
|
||||
}}
|
||||
markerEnd={markerEnd}
|
||||
markerStart={markerStart}
|
||||
|
|
@ -127,6 +142,7 @@ const CustomEdge = ({
|
|||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
opacity: edgeOpacity,
|
||||
}}
|
||||
className="bg-white px-2 py-1 rounded border border-gray-300 text-xs font-medium shadow-sm"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, NodeProps, useStore } from 'reactflow';
|
||||
import { useGraphStore } from '../../stores/graphStore';
|
||||
import { useSearchStore } from '../../stores/searchStore';
|
||||
import { getContrastColor, adjustColorBrightness } from '../../utils/colorUtils';
|
||||
import { getIconComponent } from '../../utils/iconUtils';
|
||||
import type { ActorData } from '../../types';
|
||||
|
|
@ -18,6 +19,7 @@ import type { ActorData } from '../../types';
|
|||
*/
|
||||
const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
||||
const nodeTypes = useGraphStore((state) => state.nodeTypes);
|
||||
const { searchText, visibleActorTypes } = useSearchStore();
|
||||
|
||||
// Check if any connection is being made (to show handles)
|
||||
const connectionNodeId = useStore((state) => state.connectionNodeId);
|
||||
|
|
@ -36,6 +38,39 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
|||
// Show handles when selected or when connecting
|
||||
const showHandles = selected || isConnecting;
|
||||
|
||||
// Check if this node matches the search and filter criteria
|
||||
const isMatch = useMemo(() => {
|
||||
// Check type visibility
|
||||
const isTypeVisible = visibleActorTypes[data.type] !== false;
|
||||
if (!isTypeVisible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check search text match
|
||||
if (searchText.trim()) {
|
||||
const searchLower = searchText.toLowerCase();
|
||||
const label = data.label?.toLowerCase() || '';
|
||||
const description = data.description?.toLowerCase() || '';
|
||||
const typeName = nodeLabel.toLowerCase();
|
||||
|
||||
return (
|
||||
label.includes(searchLower) ||
|
||||
description.includes(searchLower) ||
|
||||
typeName.includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [searchText, visibleActorTypes, data.type, data.label, data.description, nodeLabel]);
|
||||
|
||||
// Determine if filters are active
|
||||
const hasActiveFilters = searchText.trim() !== '' ||
|
||||
Object.values(visibleActorTypes).some(v => v === false);
|
||||
|
||||
// Calculate opacity based on match status
|
||||
const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0;
|
||||
const isHighlighted = hasActiveFilters && isMatch;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
|
|
@ -49,8 +84,11 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
|||
borderStyle: 'solid',
|
||||
borderColor: borderColor,
|
||||
color: textColor,
|
||||
opacity: nodeOpacity,
|
||||
boxShadow: selected
|
||||
? `0 0 0 3px ${nodeColor}40` // Add outer glow when selected (40 = ~25% opacity)
|
||||
: isHighlighted
|
||||
? `0 0 0 3px ${nodeColor}80, 0 0 12px ${nodeColor}60` // Highlight glow for search matches
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import { useCallback, useState, useMemo, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||
import { IconButton, Tooltip, Checkbox } from '@mui/material';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import FilterAltOffIcon from '@mui/icons-material/FilterAltOff';
|
||||
import { usePanelStore } from '../../stores/panelStore';
|
||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||
import { useEditorStore } from '../../stores/editorStore';
|
||||
import { useSearchStore } from '../../stores/searchStore';
|
||||
import { createNode } from '../../utils/nodeUtils';
|
||||
import { getIconComponent } from '../../utils/iconUtils';
|
||||
import { getContrastColor } from '../../utils/colorUtils';
|
||||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||
import type { Actor } from '../../types';
|
||||
|
||||
/**
|
||||
* LeftPanel - Collapsible tools panel on the left side
|
||||
|
|
@ -31,7 +36,11 @@ interface LeftPanelProps {
|
|||
onAddNode?: (nodeTypeId: string, position?: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => {
|
||||
export interface LeftPanelRef {
|
||||
focusSearch: () => void;
|
||||
}
|
||||
|
||||
const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onAddNode }, ref) => {
|
||||
const {
|
||||
leftPanelCollapsed,
|
||||
leftPanelWidth,
|
||||
|
|
@ -41,11 +50,99 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => {
|
|||
expandLeftPanel,
|
||||
} = usePanelStore();
|
||||
|
||||
const { nodeTypes, edgeTypes, addNode } = useGraphWithHistory();
|
||||
const { nodeTypes, edgeTypes, addNode, nodes } = useGraphWithHistory();
|
||||
const { selectedRelationType, setSelectedRelationType } = useEditorStore();
|
||||
const [showNodeConfig, setShowNodeConfig] = useState(false);
|
||||
const [showEdgeConfig, setShowEdgeConfig] = useState(false);
|
||||
|
||||
// Ref for the search input
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Expose focusSearch method to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusSearch: () => {
|
||||
// Expand left panel if collapsed
|
||||
if (leftPanelCollapsed) {
|
||||
expandLeftPanel();
|
||||
}
|
||||
|
||||
// Expand search section if collapsed
|
||||
if (!leftPanelSections.search) {
|
||||
toggleLeftPanelSection('search');
|
||||
}
|
||||
|
||||
// Focus the search input after a small delay to ensure DOM updates
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 100);
|
||||
},
|
||||
}), [leftPanelCollapsed, leftPanelSections.search, expandLeftPanel, toggleLeftPanelSection]);
|
||||
|
||||
// Search and filter state
|
||||
const {
|
||||
searchText,
|
||||
setSearchText,
|
||||
visibleActorTypes,
|
||||
setActorTypeVisible,
|
||||
visibleRelationTypes,
|
||||
setRelationTypeVisible,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
} = useSearchStore();
|
||||
|
||||
// Initialize filter state when node/edge types change
|
||||
useEffect(() => {
|
||||
nodeTypes.forEach((nodeType) => {
|
||||
if (!(nodeType.id in visibleActorTypes)) {
|
||||
setActorTypeVisible(nodeType.id, true);
|
||||
}
|
||||
});
|
||||
}, [nodeTypes, visibleActorTypes, setActorTypeVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
edgeTypes.forEach((edgeType) => {
|
||||
if (!(edgeType.id in visibleRelationTypes)) {
|
||||
setRelationTypeVisible(edgeType.id, true);
|
||||
}
|
||||
});
|
||||
}, [edgeTypes, visibleRelationTypes, setRelationTypeVisible]);
|
||||
|
||||
// Calculate matching nodes based on search and filters
|
||||
const matchingNodes = useMemo(() => {
|
||||
const searchLower = searchText.toLowerCase().trim();
|
||||
|
||||
return nodes.filter((node) => {
|
||||
const actor = node as Actor;
|
||||
const actorType = actor.data?.type || '';
|
||||
|
||||
// Filter by actor type visibility
|
||||
const isTypeVisible = visibleActorTypes[actorType] !== false;
|
||||
if (!isTypeVisible) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
if (searchLower) {
|
||||
const label = actor.data?.label?.toLowerCase() || '';
|
||||
const description = actor.data?.description?.toLowerCase() || '';
|
||||
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === actorType);
|
||||
const typeName = nodeTypeConfig?.label?.toLowerCase() || '';
|
||||
|
||||
const matches =
|
||||
label.includes(searchLower) ||
|
||||
description.includes(searchLower) ||
|
||||
typeName.includes(searchLower);
|
||||
|
||||
if (!matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [nodes, searchText, visibleActorTypes, nodeTypes]);
|
||||
|
||||
|
||||
const handleAddNode = useCallback(
|
||||
(nodeTypeId: string) => {
|
||||
// Use the shared callback from GraphEditor if available
|
||||
|
|
@ -265,10 +362,140 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => {
|
|||
{leftPanelSections.search ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
|
||||
</button>
|
||||
{leftPanelSections.search && (
|
||||
<div className="px-3 py-3">
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
Search features coming soon
|
||||
</p>
|
||||
<div className="px-3 py-3 space-y-4">
|
||||
{/* Search Input */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
Search
|
||||
</label>
|
||||
{/* Reset Filters Link */}
|
||||
{hasActiveFilters() && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="flex items-center space-x-1 text-xs text-gray-500 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<FilterAltOffIcon sx={{ fontSize: 14 }} />
|
||||
<span>Reset filters</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none">
|
||||
<SearchIcon className="text-gray-400" sx={{ fontSize: 18 }} />
|
||||
</div>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="Search actors..."
|
||||
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 && (
|
||||
<div className="absolute right-1 top-1/2 transform -translate-y-1/2">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSearchText('')}
|
||||
sx={{ padding: '4px' }}
|
||||
>
|
||||
<ClearIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter by Actor Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
Filter by Actor Type
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
{nodeTypes.map((nodeType) => {
|
||||
const isVisible = visibleActorTypes[nodeType.id] !== false;
|
||||
const IconComponent = getIconComponent(nodeType.icon);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={nodeType.id}
|
||||
className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onChange={() => setActorTypeVisible(nodeType.id, !isVisible)}
|
||||
size="small"
|
||||
sx={{ padding: '2px' }}
|
||||
/>
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
{IconComponent ? (
|
||||
<div
|
||||
className="w-4 h-4 flex items-center justify-center"
|
||||
style={{ color: nodeType.color, fontSize: '1rem' }}
|
||||
>
|
||||
<IconComponent />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: nodeType.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-gray-700">{nodeType.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter by Relation */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">
|
||||
Filter by Relation
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
{edgeTypes.map((edgeType) => {
|
||||
const isVisible = visibleRelationTypes[edgeType.id] !== false;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={edgeType.id}
|
||||
className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onChange={() => setRelationTypeVisible(edgeType.id, !isVisible)}
|
||||
size="small"
|
||||
sx={{ padding: '2px' }}
|
||||
/>
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
<div
|
||||
className="w-6 h-0.5"
|
||||
style={{
|
||||
backgroundColor: edgeType.color,
|
||||
borderStyle: edgeType.style === 'dashed' ? 'dashed' : edgeType.style === 'dotted' ? 'dotted' : 'solid',
|
||||
borderWidth: edgeType.style !== 'solid' ? '1px' : '0',
|
||||
borderColor: edgeType.style !== 'solid' ? edgeType.color : 'transparent',
|
||||
height: edgeType.style !== 'solid' ? '0' : '2px',
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{edgeType.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-600">
|
||||
<span className="font-medium">Results:</span>{' '}
|
||||
{matchingNodes.length} actor{matchingNodes.length !== 1 ? 's' : ''}
|
||||
{searchText || hasActiveFilters() ? ` of ${nodes.length}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -285,6 +512,8 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
LeftPanel.displayName = 'LeftPanel';
|
||||
|
||||
export default LeftPanel;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface UseGlobalShortcutsOptions {
|
|||
onOpenHelp?: () => void;
|
||||
onFitView?: () => void;
|
||||
onSelectAll?: () => void;
|
||||
onFocusSearch?: () => void;
|
||||
}
|
||||
|
||||
export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
|
||||
|
|
@ -183,6 +184,15 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
|
|||
category: "Navigation",
|
||||
enabled: !!options.onOpenHelp,
|
||||
},
|
||||
{
|
||||
id: "focus-search",
|
||||
description: "Focus Search",
|
||||
key: "f",
|
||||
ctrl: true,
|
||||
handler: () => options.onFocusSearch?.(),
|
||||
category: "Navigation",
|
||||
enabled: !!options.onFocusSearch,
|
||||
},
|
||||
];
|
||||
|
||||
// Register all shortcuts
|
||||
|
|
|
|||
142
src/stores/searchStore.ts
Normal file
142
src/stores/searchStore.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* SearchStore - Manages search and filter state
|
||||
*
|
||||
* Features:
|
||||
* - Search text for filtering nodes by label, description, or type
|
||||
* - Filter by actor types (show/hide specific node types)
|
||||
* - Filter by relation types (show/hide specific edge types)
|
||||
* - Results tracking
|
||||
*/
|
||||
|
||||
interface SearchStore {
|
||||
// Search text
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
|
||||
// Filter visibility by actor types (nodeTypeId -> visible)
|
||||
visibleActorTypes: Record<string, boolean>;
|
||||
setActorTypeVisible: (typeId: string, visible: boolean) => void;
|
||||
toggleActorType: (typeId: string) => void;
|
||||
setAllActorTypesVisible: (visible: boolean) => void;
|
||||
|
||||
// Filter visibility by relation types (edgeTypeId -> visible)
|
||||
visibleRelationTypes: Record<string, boolean>;
|
||||
setRelationTypeVisible: (typeId: string, visible: boolean) => void;
|
||||
toggleRelationType: (typeId: string) => void;
|
||||
setAllRelationTypesVisible: (visible: boolean) => void;
|
||||
|
||||
// Clear all filters
|
||||
clearFilters: () => void;
|
||||
|
||||
// Check if any filters are active
|
||||
hasActiveFilters: () => boolean;
|
||||
}
|
||||
|
||||
export const useSearchStore = create<SearchStore>((set, get) => ({
|
||||
searchText: '',
|
||||
visibleActorTypes: {},
|
||||
visibleRelationTypes: {},
|
||||
|
||||
setSearchText: (text: string) =>
|
||||
set({ searchText: text }),
|
||||
|
||||
setActorTypeVisible: (typeId: string, visible: boolean) =>
|
||||
set((state) => ({
|
||||
visibleActorTypes: {
|
||||
...state.visibleActorTypes,
|
||||
[typeId]: visible,
|
||||
},
|
||||
})),
|
||||
|
||||
toggleActorType: (typeId: string) =>
|
||||
set((state) => ({
|
||||
visibleActorTypes: {
|
||||
...state.visibleActorTypes,
|
||||
[typeId]: !state.visibleActorTypes[typeId],
|
||||
},
|
||||
})),
|
||||
|
||||
setAllActorTypesVisible: (visible: boolean) =>
|
||||
set((state) => {
|
||||
const updated: Record<string, boolean> = {};
|
||||
Object.keys(state.visibleActorTypes).forEach((typeId) => {
|
||||
updated[typeId] = visible;
|
||||
});
|
||||
return { visibleActorTypes: updated };
|
||||
}),
|
||||
|
||||
setRelationTypeVisible: (typeId: string, visible: boolean) =>
|
||||
set((state) => ({
|
||||
visibleRelationTypes: {
|
||||
...state.visibleRelationTypes,
|
||||
[typeId]: visible,
|
||||
},
|
||||
})),
|
||||
|
||||
toggleRelationType: (typeId: string) =>
|
||||
set((state) => ({
|
||||
visibleRelationTypes: {
|
||||
...state.visibleRelationTypes,
|
||||
[typeId]: !state.visibleRelationTypes[typeId],
|
||||
},
|
||||
})),
|
||||
|
||||
setAllRelationTypesVisible: (visible: boolean) =>
|
||||
set((state) => {
|
||||
const updated: Record<string, boolean> = {};
|
||||
Object.keys(state.visibleRelationTypes).forEach((typeId) => {
|
||||
updated[typeId] = visible;
|
||||
});
|
||||
return { visibleRelationTypes: updated };
|
||||
}),
|
||||
|
||||
clearFilters: () =>
|
||||
set((state) => {
|
||||
// Reset all actor types to visible
|
||||
const resetActorTypes: Record<string, boolean> = {};
|
||||
Object.keys(state.visibleActorTypes).forEach((typeId) => {
|
||||
resetActorTypes[typeId] = true;
|
||||
});
|
||||
|
||||
// Reset all relation types to visible
|
||||
const resetRelationTypes: Record<string, boolean> = {};
|
||||
Object.keys(state.visibleRelationTypes).forEach((typeId) => {
|
||||
resetRelationTypes[typeId] = true;
|
||||
});
|
||||
|
||||
return {
|
||||
searchText: '',
|
||||
visibleActorTypes: resetActorTypes,
|
||||
visibleRelationTypes: resetRelationTypes,
|
||||
};
|
||||
}),
|
||||
|
||||
hasActiveFilters: () => {
|
||||
const state = get();
|
||||
|
||||
// Check if search text is present
|
||||
if (state.searchText.trim() !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any actor type is hidden
|
||||
const hasHiddenActorType = Object.values(state.visibleActorTypes).some(
|
||||
(visible) => !visible
|
||||
);
|
||||
if (hasHiddenActorType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any relation type is hidden
|
||||
const hasHiddenRelationType = Object.values(state.visibleRelationTypes).some(
|
||||
(visible) => !visible
|
||||
);
|
||||
if (hasHiddenRelationType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in a new issue