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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user