feat(canvas): Add ExpressionSelector for design variable configuration

Phase 5 of Canvas Professional Upgrade:
- Create ExpressionSelector component with searchable dropdown
- Fetch expressions from NX introspection API
- Auto-populate label, units, and default min/max from expression value
- Add refresh button to reload expressions
- Integrate into NodeConfigPanel for DesignVar nodes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 22:42:14 -05:00
parent 0eb5028d8f
commit 22b483a912
2 changed files with 207 additions and 5 deletions

View File

@@ -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<Expression[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const dropdownRef = useRef<HTMLDivElement>(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 (
<div ref={dropdownRef} className="relative">
{/* Selected value display / input */}
<div className="relative">
<input
type="text"
value={isOpen ? search : value}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
className={`${inputClass} pr-16 font-mono`}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
{value && !isOpen && (
<button
onClick={handleClear}
className="p-1 text-dark-400 hover:text-white transition-colors"
>
<X size={14} />
</button>
)}
<button
onClick={loadExpressions}
disabled={loading}
className={`p-1 text-dark-400 hover:text-white transition-colors ${loading ? 'animate-spin' : ''}`}
title="Refresh expressions"
>
<RefreshCw size={14} />
</button>
<ChevronDown size={14} className="text-dark-400" />
</div>
</div>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-dark-800 border border-dark-600 rounded-lg shadow-xl max-h-60 overflow-y-auto">
{loading ? (
<div className="p-3 text-center text-dark-400">
<RefreshCw size={18} className="animate-spin inline-block mr-2" />
Loading expressions...
</div>
) : error ? (
<div className="p-3 text-center text-red-400 text-sm">
{error}
<button
onClick={loadExpressions}
className="block mx-auto mt-2 text-primary-400 hover:text-primary-300"
>
Retry
</button>
</div>
) : expressions.length === 0 ? (
<div className="p-3 text-center text-dark-400 text-sm">
No expressions found.
<br />
<span className="text-xs">Run NX introspection first.</span>
</div>
) : filteredExpressions.length === 0 ? (
<div className="p-3 text-center text-dark-400 text-sm">
No matches for "{search}"
</div>
) : (
<ul>
{filteredExpressions.map((expr) => (
<li key={expr.name}>
<button
onClick={() => handleSelect(expr)}
className="w-full px-3 py-2 text-left hover:bg-dark-700 flex justify-between items-center group transition-colors"
>
<span className="font-mono text-white group-hover:text-primary-300">
{expr.name}
</span>
<span className="text-sm text-dark-400">
{expr.value}
{expr.units && (
<span className="ml-1 text-dark-500">{expr.units}</span>
)}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
{/* Expression info hint */}
{value && !isOpen && (
<div className="mt-1 text-xs text-dark-500">
{expressions.find(e => e.name === value)?.formula && (
<span className="italic">
= {expressions.find(e => e.name === value)?.formula}
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -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) {
<label className={labelClass}>
Expression Name
</label>
<input
type="text"
<ExpressionSelector
value={(data as DesignVarNodeData).expressionName || ''}
onChange={(e) => 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..."
/>
</div>
<div className="grid grid-cols-2 gap-2">