From a6af1a37eeba90814c0ebc708b208ff1dc932e8d Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Mon, 2 Feb 2026 15:29:18 -0600 Subject: [PATCH 01/23] refactor: extract shared search sub-components from SearchBar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract five reusable components to web/src/components/shared/search/: - ConceptSearchInput: search input + spinner + dropdown (was 4x duplicated) - SelectedConceptChip: concept selection display with Change button (5x) - SliderControl: labeled range slider for similarity/depth/hops (6x) - LoadButtons: Clean Graph / Add to Existing button pair (3x) - PathResults: path list, selection, and load buttons SearchBar uses these in all three mode sections. No behavior change — same three tabs, same state, same handlers. Reduces SearchBar from 1170 to 801 lines; extracted components are 260 lines total. --- web/src/components/shared/SearchBar.tsx | 1064 ++++++----------- .../shared/search/ConceptSearchInput.tsx | 52 + .../components/shared/search/LoadButtons.tsx | 30 + .../components/shared/search/PathResults.tsx | 103 ++ .../shared/search/SelectedConceptChip.tsx | 28 + .../shared/search/SliderControl.tsx | 47 + web/src/components/shared/search/index.ts | 5 + 7 files changed, 613 insertions(+), 716 deletions(-) create mode 100644 web/src/components/shared/search/ConceptSearchInput.tsx create mode 100644 web/src/components/shared/search/LoadButtons.tsx create mode 100644 web/src/components/shared/search/PathResults.tsx create mode 100644 web/src/components/shared/search/SelectedConceptChip.tsx create mode 100644 web/src/components/shared/search/SliderControl.tsx create mode 100644 web/src/components/shared/search/index.ts diff --git a/web/src/components/shared/SearchBar.tsx b/web/src/components/shared/SearchBar.tsx index 22730f8f..0e84fab1 100644 --- a/web/src/components/shared/SearchBar.tsx +++ b/web/src/components/shared/SearchBar.tsx @@ -3,13 +3,8 @@ * * Top-level: Query mode selection via radio dial * - Smart Search (with sub-modes: Concept/Neighborhood/Path) - * - Block Builder (future) - * - openCypher Editor (future) - * - * Within Smart Search: - * - Concept: Semantic search for individual concepts (IMPLEMENTED) - * - Neighborhood: Explore concepts within N hops (IMPLEMENTED) - * - Path: Find paths connecting two concepts (IMPLEMENTED) + * - Block Builder + * - openCypher Editor */ import React, { useState } from 'react'; @@ -21,8 +16,14 @@ import { useGraphStore } from '../../store/graphStore'; import { ModeDial } from './ModeDial'; import { apiClient } from '../../api/client'; import { BlockBuilder } from '../blocks/BlockBuilder'; -import { SearchResultsDropdown } from './SearchResultsDropdown'; import { getZIndexClass } from '../../config/zIndex'; +import { + ConceptSearchInput, + SelectedConceptChip, + SliderControl, + LoadButtons, + PathResults, +} from './search'; type SmartSearchSubMode = 'concept' | 'neighborhood' | 'path'; @@ -48,41 +49,22 @@ export const SearchBar: React.FC = () => { const toggleSmartSearch = () => { setSmartSearchExpanded(!smartSearchExpanded); - - // Trigger window resize event after a short delay to let the DOM update - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 100); + setTimeout(() => window.dispatchEvent(new Event('resize')), 100); }; const toggleSection = (mode: SmartSearchSubMode) => { - setExpandedSections(prev => ({ - ...prev, - [mode]: !prev[mode], - })); - - // Trigger window resize event after a short delay to let the DOM update - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 100); + setExpandedSections(prev => ({ ...prev, [mode]: !prev[mode] })); + setTimeout(() => window.dispatchEvent(new Event('resize')), 100); }; const toggleCypherEditor = () => { setCypherEditorExpanded(!cypherEditorExpanded); - - // Trigger window resize event after a short delay to let the DOM update - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 100); + setTimeout(() => window.dispatchEvent(new Event('resize')), 100); }; const toggleBlockBuilder = () => { setBlockBuilderExpanded(!blockBuilderExpanded); - - // Trigger window resize event after a short delay to let the DOM update - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 100); + setTimeout(() => window.dispatchEvent(new Event('resize')), 100); }; // Shared controls - use global similarity threshold from store @@ -117,16 +99,14 @@ LIMIT 50`); const { setSearchParams, setRawGraphData, mergeRawGraphData, setGraphData } = useGraphStore(); - // Debounce values to prevent excessive API calls while user is typing/dragging sliders - // 800ms for typing (embeddings are expensive), 500ms for sliders (cheaper operations) + // Debounce values to prevent excessive API calls const debouncedConceptQuery = useDebouncedValue(conceptQuery, 800); const debouncedNeighborhoodQuery = useDebouncedValue(neighborhoodQuery, 800); const debouncedPathFromQuery = useDebouncedValue(pathFromQuery, 800); const debouncedPathToQuery = useDebouncedValue(pathToQuery, 800); const debouncedSimilarity = useDebouncedValue(similarity, 500); - // Concept search results (only when no concept selected) - // Uses debounced similarity to prevent search spam while dragging slider + // Search hooks (gated by mode + selection state) const { data: conceptResults, isLoading: isLoadingConcepts } = useSearchConcepts( debouncedConceptQuery, { @@ -136,7 +116,6 @@ LIMIT 50`); } ); - // Neighborhood center search results (only when no center selected) const { data: neighborhoodSearchResults, isLoading: isLoadingNeighborhoodSearch } = useSearchConcepts( debouncedNeighborhoodQuery, { @@ -146,7 +125,6 @@ LIMIT 50`); } ); - // Path From search results (only when no from concept selected) const { data: pathFromSearchResults, isLoading: isLoadingPathFromSearch } = useSearchConcepts( debouncedPathFromQuery, { @@ -156,7 +134,6 @@ LIMIT 50`); } ); - // Path To search results (only when no to concept selected) const { data: pathToSearchResults, isLoading: isLoadingPathToSearch } = useSearchConcepts( debouncedPathToQuery, { @@ -166,37 +143,30 @@ LIMIT 50`); } ); - // Note: Path search is now manual (via button) instead of auto-search - // This prevents the UI from appearing to hang during expensive path searches - - // Handler: Select concept in Concept mode + // Selection handlers const handleSelectConcept = (concept: any) => { setSelectedConcept(concept); setConceptQuery(''); }; - // Handler: Select center concept in Neighborhood mode const handleSelectCenterConcept = (concept: any) => { setSelectedCenterConcept(concept); setNeighborhoodQuery(''); }; - // Handler: Select From concept in Path mode const handleSelectFromConcept = (concept: any) => { setSelectedFromConcept(concept); setPathFromQuery(''); }; - // Handler: Select To concept in Path mode const handleSelectToConcept = (concept: any) => { setSelectedToConcept(concept); setPathToQuery(''); }; - // Handler: Load concept (sets search parameters for App.tsx to react to) + // Load handlers const handleLoadConcept = (loadMode: 'clean' | 'add') => { if (!selectedConcept) return; - setSearchParams({ mode: 'concept', conceptId: selectedConcept.concept_id, @@ -204,10 +174,8 @@ LIMIT 50`); }); }; - // Handler: Load neighborhood (sets search parameters for App.tsx to react to) const handleLoadNeighborhood = (loadMode: 'clean' | 'add') => { if (!selectedCenterConcept) return; - setSearchParams({ mode: 'neighborhood', centerConceptId: selectedCenterConcept.concept_id, @@ -216,7 +184,7 @@ LIMIT 50`); }); }; - // Handler: Search for paths between selected concepts (stores results locally) + // Path search (manual, not auto) const handleFindPaths = async () => { if (!selectedFromConcept || !selectedToConcept) return; @@ -243,18 +211,13 @@ LIMIT 50`); errorMessage = error.message; } - setPathResults({ - error: errorMessage, - count: 0, - paths: [] - }); + setPathResults({ error: errorMessage, count: 0, paths: [] }); } finally { setIsLoadingPath(false); } }; - // Handler: Load selected path directly into graph - // Converts the specific selected path to graph format (matching useFindConnection logic) + // Load selected path directly into graph const handleLoadPath = (loadMode: 'clean' | 'add') => { if (!selectedPath) return; @@ -301,12 +264,11 @@ LIMIT 50`); mergeRawGraphData({ nodes, links }); } - // Dismiss the path selection UI after loading setPathResults(null); setSelectedPath(null); }; - // Handler: Execute openCypher query + // Cypher execution const handleExecuteCypher = async () => { if (!cypherQuery.trim()) return; @@ -316,20 +278,15 @@ LIMIT 50`); try { const result = await apiClient.executeCypherQuery({ query: cypherQuery, - limit: 100, // Default limit for safety + limit: 100, }); - // Transform result to graph format and load const { transformForD3 } = await import('../../utils/graphTransform'); - - // Convert CypherNode to our node format const nodes = result.nodes.map((n: any) => ({ concept_id: n.id, label: n.label, ontology: n.properties?.ontology || 'default', })); - - // Convert CypherRelationship to our link format const links = result.relationships.map((r: any) => ({ from_id: r.from_id, to_id: r.to_id, @@ -338,7 +295,6 @@ LIMIT 50`); const graphData = transformForD3(nodes, links); useGraphStore.getState().setGraphData(graphData); - } catch (error: any) { console.error('Failed to execute Cypher query:', error); setCypherError(error.response?.data?.detail || error.message || 'Query execution failed'); @@ -347,29 +303,20 @@ LIMIT 50`); } }; - // Handler: Send compiled blocks to openCypher editor + // Block builder → Cypher editor const handleSendToEditor = (compiledCypher: string) => { - // Check if there's existing code in the editor const hasExistingCode = cypherQuery.trim().length > 0; - if (hasExistingCode) { const confirmed = window.confirm( 'The openCypher editor already has code. Do you want to overwrite it with the compiled query from the block builder?' ); - - if (!confirmed) { - return; // User cancelled - } + if (!confirmed) return; } - - // Load the compiled query into the editor setCypherQuery(compiledCypher); - - // Switch to cypher-editor mode setQueryMode('cypher-editor'); }; - // Get mode-specific info for the header + // Mode info for header const getModeInfo = () => { switch (queryMode) { case 'smart-search': @@ -396,11 +343,59 @@ LIMIT 50`); const modeInfo = getModeInfo(); const ModeIcon = modeInfo.icon; + // "No results" content for concept search (with below-threshold suggestions) + const conceptNoResults = conceptResults && conceptResults.results && conceptResults.results.length === 0 && ( +
+
+ {conceptResults.below_threshold_count ? ( +
+
+
No results at {(similarity * 100).toFixed(0)}% similarity
+
+ Found {conceptResults.below_threshold_count} concept{conceptResults.below_threshold_count > 1 ? 's' : ''} at lower similarity +
+
+ {conceptResults.suggested_threshold && ( + + )} + {conceptResults.top_match && ( +
+
Top match:
+
{conceptResults.top_match.label}
+
+ {(conceptResults.top_match.score * 100).toFixed(0)}% similarity +
+
+ )} +
+ ) : ( +
No results found
+ )} +
+
+ ); + + // Similarity slider shorthand + const similaritySlider = ( + setSimilarity(v / 100)} + displayValue={`${(similarity * 100).toFixed(0)}%`} + /> + ); + return (
{/* Header with Mode Info and Dial */}
- {/* Mode Description Panel */}
@@ -414,8 +409,6 @@ LIMIT 50`);

- - {/* Mode Dial */}
@@ -442,623 +435,268 @@ LIMIT 50`); <> {/* Sub-mode Selector (Pill Buttons) */}
- - - -
- - {/* Concept Search */} - {smartSearchMode === 'concept' && ( -
- {/* Collapsible Header */} - - - {expandedSections.concept && ( - <> - {!selectedConcept ? ( -
-
- - setConceptQuery(e.target.value)} - placeholder="Search for a concept..." - className="w-full pl-10 pr-10 py-2 rounded-lg border border-input bg-background focus:outline-none focus:ring-2 focus:ring-ring" - /> - {isLoadingConcepts && ( - - )} -
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- - {/* Search Results */} - {debouncedConceptQuery && conceptResults && conceptResults.results && conceptResults.results.length > 0 && ( - - )} + + + +
- {/* No Results with Smart Recommendations */} - {debouncedConceptQuery && conceptResults && conceptResults.results && conceptResults.results.length === 0 && ( -
-
- {conceptResults.below_threshold_count ? ( -
-
-
No results at {(similarity * 100).toFixed(0)}% similarity
-
- Found {conceptResults.below_threshold_count} concept{conceptResults.below_threshold_count > 1 ? 's' : ''} at lower similarity -
-
- {conceptResults.suggested_threshold && ( - - )} - {conceptResults.top_match && ( -
-
Top match:
-
{conceptResults.top_match.label}
-
- {(conceptResults.top_match.score * 100).toFixed(0)}% similarity -
-
- )} -
- ) : ( -
No results found
- )} -
-
- )} -
- ) : ( + {/* ===== CONCEPT MODE ===== */} + {smartSearchMode === 'concept' && (
- {/* Selected Concept */} -
-
-
-
Selected concept:
-
{selectedConcept.label}
-
- +
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- - {/* Load Buttons */} -
- - -
-
- )} - - )} -
- )} - - {/* Neighborhood Search */} - {smartSearchMode === 'neighborhood' && ( -
- {/* Collapsible Header */} - - - {expandedSections.neighborhood && ( - <> - {!selectedCenterConcept ? ( -
-
- - setNeighborhoodQuery(e.target.value)} - placeholder="Search for center concept..." - className="w-full pl-10 pr-10 py-2 rounded-lg border border-input bg-background focus:outline-none focus:ring-2 focus:ring-ring" - /> - {isLoadingNeighborhoodSearch && ( - + {expandedSections.concept ? ( + + ) : ( + )} -
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
+ - {/* Search Results */} - {debouncedNeighborhoodQuery && neighborhoodSearchResults && neighborhoodSearchResults.results && neighborhoodSearchResults.results.length > 0 && ( - + {expandedSections.concept && ( + <> + {!selectedConcept ? ( +
+ + {similaritySlider} +
+ ) : ( +
+ setSelectedConcept(null)} + /> + {similaritySlider} + handleLoadConcept('clean')} + onLoadAdd={() => handleLoadConcept('add')} + /> +
+ )} + )}
- ) : ( -
- {/* Selected Center Concept */} -
-
-
-
Center concept:
-
{selectedCenterConcept.label}
-
- -
-
- - {/* Depth Slider */} -
- - setNeighborhoodDepth(parseInt(e.target.value))} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {neighborhoodDepth} - - - hop{neighborhoodDepth > 1 ? 's' : ''} - -
- - {/* Load Buttons */} -
- - -
-
- )} - )} -
- )} - - {/* Path Search */} - {smartSearchMode === 'path' && ( -
- {/* Collapsible Header */} - - - {expandedSections.path && ( - <> - {/* Step 1: Search for From concept */} - {!selectedFromConcept ? ( -
-
- - setPathFromQuery(e.target.value)} - placeholder="Search for starting concept..." - className="w-full pl-10 pr-10 py-2 rounded-lg border border-input bg-background focus:outline-none focus:ring-2 focus:ring-ring" - /> - {isLoadingPathFromSearch && ( - - )} -
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- {/* From Search Results */} - {debouncedPathFromQuery && pathFromSearchResults && pathFromSearchResults.results && pathFromSearchResults.results.length > 0 && ( - - )} -
- ) : !selectedToConcept ? ( + {/* ===== NEIGHBORHOOD MODE ===== */} + {smartSearchMode === 'neighborhood' && (
- {/* Selected From Concept */} -
-
-
-
From concept:
-
{selectedFromConcept.label}
-
- -
-
- - {/* Step 2: Search for To concept */} -
-
- - setPathToQuery(e.target.value)} - placeholder="Search for target concept..." - className="w-full pl-10 pr-10 py-2 rounded-lg border border-input bg-background focus:outline-none focus:ring-2 focus:ring-ring" - /> - {isLoadingPathToSearch && ( - - )} +
-
- ) : ( -
- {/* Selected From and To Concepts */} -
-
-
-
-
From concept:
-
{selectedFromConcept.label}
+ + + {expandedSections.neighborhood && ( + <> + {!selectedCenterConcept ? ( +
+ + {similaritySlider}
- -
-
-
-
-
-
To concept:
-
{selectedToConcept.label}
+ ) : ( +
+ setSelectedCenterConcept(null)} + /> + 1 ? 's' : ''}`} + /> + handleLoadNeighborhood('clean')} + onLoadAdd={() => handleLoadNeighborhood('add')} + />
- -
-
-
- - {/* Similarity Slider */} -
- - setSimilarity(parseInt(e.target.value) / 100)} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {(similarity * 100).toFixed(0)}% - -
- - {/* Max Hops Slider */} -
- - setMaxHops(parseInt(e.target.value))} - className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary - [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0" - /> - - {maxHops} - -
+ )} + + )} +
+ )} - {/* Find Paths Button */} + {/* ===== PATH MODE ===== */} + {smartSearchMode === 'path' && ( +
- {/* Path Results */} - {pathResults && !isLoadingPath && ( -
- {pathResults.error ? ( -
- {pathResults.error} + {expandedSections.path && ( + <> + {/* Step 1: Select From concept */} + {!selectedFromConcept ? ( +
+ + {similaritySlider}
- ) : pathResults.count > 0 ? ( - <> -
- Found {pathResults.count} path{pathResults.count > 1 ? 's' : ''} ({pathResults.paths[0].hops} hop{pathResults.paths[0].hops > 1 ? 's' : ''}) -
- - {/* Path Selection */} + ) : !selectedToConcept ? ( + /* Step 2: Select To concept */ +
+ setSelectedFromConcept(null)} + /> + +
+ ) : ( + /* Step 3: Both selected — configure and search */ +
-
Select a path:
- {pathResults.paths.map((path: any, index: number) => ( - - ))} + { + setSelectedFromConcept(null); + setSelectedToConcept(null); + }} + /> + setSelectedToConcept(null)} + />
- {/* Load Buttons (only if path selected) */} - {selectedPath && ( -
- - -
- )} - - ) : ( -
- No paths found. Try lowering similarity or increasing max hops. + {similaritySlider} + + + + {/* Find Paths Button */} + + + handleLoadPath('clean')} + onLoadAdd={() => handleLoadPath('add')} + />
)} -
+ )}
)} - - )} -
- )} )}
@@ -1067,7 +705,6 @@ LIMIT 50`); {/* Block Builder Mode */} {queryMode === 'block-builder' && (
- {/* Collapsible Header */}