diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/ExpressionSelector.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/ExpressionSelector.tsx new file mode 100644 index 00000000..76ee885e --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/ExpressionSelector.tsx @@ -0,0 +1,189 @@ +/** + * ExpressionSelector - Searchable dropdown for NX expressions + * + * Fetches expressions from the NX model introspection API and provides + * a searchable dropdown for selecting design variable expressions. + */ + +import { useState, useEffect, useRef } from 'react'; +import { RefreshCw, ChevronDown, X } from 'lucide-react'; +import { apiClient } from '../../../api/client'; +import { useStudy } from '../../../context/StudyContext'; + +interface Expression { + name: string; + value: number; + units?: string; + formula?: string; +} + +interface ExpressionSelectorProps { + value: string; + onChange: (name: string, value?: number, units?: string) => void; + placeholder?: string; +} + +export function ExpressionSelector({ + value, + onChange, + placeholder = 'Select expression...' +}: ExpressionSelectorProps) { + const { selectedStudy } = useStudy(); + const [expressions, setExpressions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(''); + const dropdownRef = useRef(null); + + // Fetch expressions when study changes + useEffect(() => { + if (selectedStudy?.id) { + loadExpressions(); + } + }, [selectedStudy?.id]); + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const loadExpressions = async () => { + if (!selectedStudy?.id) return; + + setLoading(true); + setError(null); + + try { + const result = await apiClient.getNxExpressions(selectedStudy.id); + setExpressions(result.expressions || []); + } catch (err) { + setError('Failed to load expressions'); + console.error('Expression load error:', err); + } finally { + setLoading(false); + } + }; + + const filteredExpressions = expressions.filter(expr => + expr.name.toLowerCase().includes(search.toLowerCase()) + ); + + const handleSelect = (expr: Expression) => { + onChange(expr.name, expr.value, expr.units); + setIsOpen(false); + setSearch(''); + }; + + const handleClear = () => { + onChange(''); + setSearch(''); + }; + + const inputClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors"; + + return ( +
+ {/* Selected value display / input */} +
+ setSearch(e.target.value)} + onFocus={() => setIsOpen(true)} + placeholder={placeholder} + className={`${inputClass} pr-16 font-mono`} + /> +
+ {value && !isOpen && ( + + )} + + +
+
+ + {/* Dropdown */} + {isOpen && ( +
+ {loading ? ( +
+ + Loading expressions... +
+ ) : error ? ( +
+ {error} + +
+ ) : expressions.length === 0 ? ( +
+ No expressions found. +
+ Run NX introspection first. +
+ ) : filteredExpressions.length === 0 ? ( +
+ No matches for "{search}" +
+ ) : ( +
    + {filteredExpressions.map((expr) => ( +
  • + +
  • + ))} +
+ )} +
+ )} + + {/* Expression info hint */} + {value && !isOpen && ( +
+ {expressions.find(e => e.name === value)?.formula && ( + + = {expressions.find(e => e.name === value)?.formula} + + )} +
+ )} +
+ ); +} diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx index 745df0d2..79f40d4e 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx @@ -1,4 +1,5 @@ import { useCanvasStore } from '../../../hooks/useCanvasStore'; +import { ExpressionSelector } from './ExpressionSelector'; import { ModelNodeData, SolverNodeData, @@ -117,12 +118,24 @@ export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) { - handleChange('expressionName', e.target.value)} - placeholder="thickness" - className={`${inputClass} font-mono`} + onChange={(name, value, units) => { + handleChange('expressionName', name); + handleChange('label', name || 'Design Variable'); + if (units) handleChange('unit', units); + // Set default min/max around current value + if (value !== undefined) { + const dvData = data as DesignVarNodeData; + if (dvData.minValue === undefined) { + handleChange('minValue', value * 0.5); + } + if (dvData.maxValue === undefined) { + handleChange('maxValue', value * 1.5); + } + } + }} + placeholder="Select NX expression..." />