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 { useCanvasStore } from '../../../hooks/useCanvasStore';
|
||||||
|
import { ExpressionSelector } from './ExpressionSelector';
|
||||||
import {
|
import {
|
||||||
ModelNodeData,
|
ModelNodeData,
|
||||||
SolverNodeData,
|
SolverNodeData,
|
||||||
@@ -117,12 +118,24 @@ export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) {
|
|||||||
<label className={labelClass}>
|
<label className={labelClass}>
|
||||||
Expression Name
|
Expression Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<ExpressionSelector
|
||||||
type="text"
|
|
||||||
value={(data as DesignVarNodeData).expressionName || ''}
|
value={(data as DesignVarNodeData).expressionName || ''}
|
||||||
onChange={(e) => handleChange('expressionName', e.target.value)}
|
onChange={(name, value, units) => {
|
||||||
placeholder="thickness"
|
handleChange('expressionName', name);
|
||||||
className={`${inputClass} font-mono`}
|
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>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user