# TASK: Inline Expression Properties in Property Panel ## Overview Add the ability to toggle any node property between "fixed value" and "expression mode" directly in the property panel - similar to n8n's approach. When in expression mode, users can write JavaScript expressions that evaluate at runtime with full access to Noodl globals. **Estimated effort:** 3-4 weeks **Priority:** High - Major UX modernization **Dependencies:** Phase 1 (Enhanced Expression Node) must be complete --- ## Background & Motivation ### The Problem Today To make any property dynamic in Noodl, users must: 1. Create a separate Expression, Variable, or Function node 2. Configure that node with the logic 3. Draw a connection cable to the target property 4. Repeat for every dynamic value **Result:** Canvas cluttered with helper nodes, hard to understand data flow. ### The Solution Every property input gains a toggle between: - **Fixed Mode** (default): Traditional static value editing - **Expression Mode**: JavaScript expression evaluated at runtime ``` ┌─────────────────────────────────────────────────────────────┐ │ Margin Left │ │ ┌────────┬────────────────────────────────────────────┬───┐ │ │ │ Fixed │ 16 │ ⚡ │ │ │ └────────┴────────────────────────────────────────────┴───┘ │ │ │ │ After clicking ⚡ toggle: │ │ │ │ ┌────────┬────────────────────────────────────────────┬───┐ │ │ │ fx │ Noodl.Variables.isMobile ? 8 : 16 │ ⚡ │ │ │ └────────┴────────────────────────────────────────────┴───┘ │ └─────────────────────────────────────────────────────────────┘ ``` --- ## Files to Analyze First ### Phase 1 Foundation (must be complete) ``` @packages/noodl-runtime/src/expression-evaluator.js ``` - Expression compilation and evaluation - Dependency detection - Change subscription ### Property Panel Architecture ``` @packages/noodl-core-ui/src/components/property-panel/PropertyPanelInput/PropertyPanelInput.tsx @packages/noodl-core-ui/src/components/property-panel/README.md @packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts @packages/noodl-editor/src/editor/src/views/panels/propertyeditor/index.tsx ``` - Property panel component structure - How different property types are rendered - Property value flow from model to UI and back ### Type-Specific Editors ``` @packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts @packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BooleanType.ts @packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ColorType.ts @packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/VariableType.ts ``` - Pattern for different input types - How values are stored and retrieved ### Node Model & Parameters ``` @packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts @packages/noodl-runtime/src/node.js ``` - How parameters are stored - Parameter update events - Visual state parameter patterns (`paramName_stateName`) ### Port/Connection System ``` @packages/noodl-editor/src/editor/src/models/nodelibrary/nodelibrary.ts ``` - Port type definitions - Connection state detection (`isConnected`) --- ## Implementation Plan ### Step 1: Extend Parameter Storage Model Parameters need to support both simple values and expression metadata. **Modify:** Node model parameter handling ```typescript // New parameter value types interface FixedParameter { value: any; } interface ExpressionParameter { mode: 'expression'; expression: string; fallback?: any; // Value to use if expression errors version?: number; // Expression system version for migration } type ParameterValue = any | ExpressionParameter; // Helper to check if parameter is expression function isExpressionParameter(param: any): param is ExpressionParameter { return param && typeof param === 'object' && param.mode === 'expression'; } // Helper to get display value function getParameterDisplayValue(param: ParameterValue): any { if (isExpressionParameter(param)) { return param.expression; } return param; } ``` **Ensure backward compatibility:** - Simple values (strings, numbers, etc.) continue to work as-is - Expression parameters are objects with `mode: 'expression'` - Serialization/deserialization handles both formats ### Step 2: Create Expression Toggle Component **Create file:** `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.tsx` ```tsx import React from 'react'; import { IconButton, IconButtonVariant } from '../../inputs/IconButton'; import { IconName, IconSize } from '../../common/Icon'; import { Tooltip } from '../../popups/Tooltip'; import css from './ExpressionToggle.module.scss'; export interface ExpressionToggleProps { mode: 'fixed' | 'expression'; isConnected?: boolean; // Port has cable connection onToggle: () => void; disabled?: boolean; } export function ExpressionToggle({ mode, isConnected, onToggle, disabled }: ExpressionToggleProps) { // If connected via cable, show connection indicator instead if (isConnected) { return (
); } const tooltipText = mode === 'expression' ? 'Switch to fixed value' : 'Switch to expression'; return ( ); } ``` **Create file:** `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.module.scss` ```scss .connectionIndicator { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; opacity: 0.5; } .expressionActive { background-color: var(--theme-color-expression-bg, #6366f1); color: white; &:hover { background-color: var(--theme-color-expression-bg-hover, #4f46e5); } } ``` ### Step 3: Create Expression Input Component **Create file:** `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.tsx` ```tsx import React, { useState, useCallback } from 'react'; import { TextInput, TextInputVariant } from '../../inputs/TextInput'; import { IconButton } from '../../inputs/IconButton'; import { IconName, IconSize } from '../../common/Icon'; import { Tooltip } from '../../popups/Tooltip'; import css from './ExpressionInput.module.scss'; export interface ExpressionInputProps { expression: string; onChange: (expression: string) => void; onOpenBuilder?: () => void; expectedType?: string; // 'string', 'number', 'boolean', 'color' hasError?: boolean; errorMessage?: string; } export function ExpressionInput({ expression, onChange, onOpenBuilder, expectedType, hasError, errorMessage }: ExpressionInputProps) { const [localValue, setLocalValue] = useState(expression); const handleBlur = useCallback(() => { if (localValue !== expression) { onChange(localValue); } }, [localValue, expression, onChange]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { onChange(localValue); } }, [localValue, onChange]); return (
fx setLocalValue(e.target.value)} onBlur={handleBlur} onKeyDown={handleKeyDown} variant={TextInputVariant.Transparent} placeholder="Enter expression..." UNSAFE_style={{ fontFamily: 'monospace', fontSize: '12px' }} UNSAFE_className={hasError ? css.hasError : undefined} /> {onOpenBuilder && ( )} {hasError && errorMessage && (
!
)}
); } ``` **Create file:** `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.module.scss` ```scss .container { display: flex; align-items: center; gap: 4px; background-color: var(--theme-color-expression-input-bg, rgba(99, 102, 241, 0.1)); border-radius: 4px; padding: 0 4px; border: 1px solid var(--theme-color-expression-border, rgba(99, 102, 241, 0.3)); } .badge { font-family: monospace; font-size: 10px; font-weight: 600; color: var(--theme-color-expression-badge, #6366f1); padding: 2px 4px; background-color: var(--theme-color-expression-badge-bg, rgba(99, 102, 241, 0.2)); border-radius: 2px; } .hasError { border-color: var(--theme-color-error, #ef4444); } .errorIndicator { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; background-color: var(--theme-color-error, #ef4444); color: white; border-radius: 50%; font-size: 10px; font-weight: bold; } ``` ### Step 4: Integrate with PropertyPanelInput **Modify:** `packages/noodl-core-ui/src/components/property-panel/PropertyPanelInput/PropertyPanelInput.tsx` ```tsx // Add to imports import { ExpressionToggle } from '../ExpressionToggle'; import { ExpressionInput } from '../ExpressionInput'; // Extend props interface export interface PropertyPanelInputProps extends Omit { label: string; inputType: PropertyPanelInputType; properties: TSFixme; // Expression support (new) supportsExpression?: boolean; // Default true for most types expressionMode?: 'fixed' | 'expression'; expression?: string; isConnected?: boolean; onExpressionModeChange?: (mode: 'fixed' | 'expression') => void; onExpressionChange?: (expression: string) => void; } export function PropertyPanelInput({ label, value, inputType = PropertyPanelInputType.Text, properties, isChanged, isConnected, onChange, // Expression props supportsExpression = true, expressionMode = 'fixed', expression, onExpressionModeChange, onExpressionChange }: PropertyPanelInputProps) { // Determine if we should show expression UI const showExpressionToggle = supportsExpression && !isConnected; const isExpressionMode = expressionMode === 'expression'; // Handle mode toggle const handleToggleMode = () => { if (onExpressionModeChange) { onExpressionModeChange(isExpressionMode ? 'fixed' : 'expression'); } }; // Render expression input or standard input const renderInput = () => { if (isExpressionMode && onExpressionChange) { return ( ); } // Standard input rendering (existing code) const Input = useMemo(() => { switch (inputType) { case PropertyPanelInputType.Text: return PropertyPanelTextInput; // ... rest of existing switch } }, [inputType]); return ( ); }; return (
{renderInput()} {showExpressionToggle && ( )}
); } ``` ### Step 5: Wire Up to Property Editor **Modify:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts` This is where the connection between the model and property panel happens. Add expression support: ```typescript // In the render or value handling logic: // Check if current parameter value is an expression const paramValue = parent.model.parameters[port.name]; const isExpressionMode = isExpressionParameter(paramValue); // When mode changes: onExpressionModeChange(mode) { if (mode === 'expression') { // Convert current value to expression parameter const currentValue = parent.model.parameters[port.name]; parent.model.setParameter(port.name, { mode: 'expression', expression: String(currentValue || ''), fallback: currentValue, version: ExpressionEvaluator.EXPRESSION_VERSION }); } else { // Convert back to fixed value const param = parent.model.parameters[port.name]; const fixedValue = isExpressionParameter(param) ? param.fallback : param; parent.model.setParameter(port.name, fixedValue); } } // When expression changes: onExpressionChange(expression) { const param = parent.model.parameters[port.name]; if (isExpressionParameter(param)) { parent.model.setParameter(port.name, { ...param, expression }); } } ``` ### Step 6: Runtime Expression Evaluation **Modify:** `packages/noodl-runtime/src/node.js` Add expression evaluation to the parameter update flow: ```javascript // In Node.prototype._onNodeModelParameterUpdated or similar: Node.prototype._evaluateExpressionParameter = function(paramName, paramValue) { const ExpressionEvaluator = require('./expression-evaluator'); if (!paramValue || paramValue.mode !== 'expression') { return paramValue; } // Compile and evaluate const compiled = ExpressionEvaluator.compileExpression(paramValue.expression); if (!compiled) { return paramValue.fallback; } const result = ExpressionEvaluator.evaluateExpression(compiled, this.context?.modelScope); // Set up reactive subscription if not already if (!this._expressionSubscriptions) { this._expressionSubscriptions = {}; } if (!this._expressionSubscriptions[paramName]) { const deps = ExpressionEvaluator.detectDependencies(paramValue.expression); if (deps.variables.length > 0 || deps.objects.length > 0 || deps.arrays.length > 0) { this._expressionSubscriptions[paramName] = ExpressionEvaluator.subscribeToChanges( deps, () => { // Re-evaluate and update const newResult = ExpressionEvaluator.evaluateExpression(compiled, this.context?.modelScope); this.queueInput(paramName, newResult); }, this.context?.modelScope ); } } return result !== undefined ? result : paramValue.fallback; }; // Clean up subscriptions on delete Node.prototype._onNodeDeleted = function() { // ... existing cleanup ... // Clean up expression subscriptions if (this._expressionSubscriptions) { for (const unsub of Object.values(this._expressionSubscriptions)) { if (typeof unsub === 'function') unsub(); } this._expressionSubscriptions = null; } }; ``` ### Step 7: Expression Builder Modal (Optional Enhancement) **Create file:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionBuilder/ExpressionBuilder.tsx` A full-featured modal for complex expression editing: ```tsx import React, { useState, useEffect, useMemo } from 'react'; import { Modal } from '@noodl-core-ui/components/layout/Modal'; import { MonacoEditor } from '@noodl-core-ui/components/inputs/MonacoEditor'; import { TreeView } from '@noodl-core-ui/components/tree/TreeView'; import css from './ExpressionBuilder.module.scss'; interface ExpressionBuilderProps { isOpen: boolean; expression: string; expectedType?: string; onApply: (expression: string) => void; onCancel: () => void; } export function ExpressionBuilder({ isOpen, expression: initialExpression, expectedType, onApply, onCancel }: ExpressionBuilderProps) { const [expression, setExpression] = useState(initialExpression); const [preview, setPreview] = useState<{ result: any; error?: string }>({ result: null }); // Build available completions tree const completionsTree = useMemo(() => { // This would be populated from actual project data return [ { label: 'Noodl', children: [ { label: 'Variables', children: [] // Populated from Noodl.Variables }, { label: 'Objects', children: [] // Populated from known Object IDs }, { label: 'Arrays', children: [] // Populated from known Array IDs } ] }, { label: 'Math', children: [ { label: 'min(a, b)', insertText: 'min()' }, { label: 'max(a, b)', insertText: 'max()' }, { label: 'round(n)', insertText: 'round()' }, { label: 'floor(n)', insertText: 'floor()' }, { label: 'ceil(n)', insertText: 'ceil()' }, { label: 'abs(n)', insertText: 'abs()' }, { label: 'sqrt(n)', insertText: 'sqrt()' }, { label: 'pow(base, exp)', insertText: 'pow()' }, { label: 'pi', insertText: 'pi' }, { label: 'random()', insertText: 'random()' } ] } ]; }, []); // Live preview useEffect(() => { const ExpressionEvaluator = require('@noodl/runtime/src/expression-evaluator'); const validation = ExpressionEvaluator.validateExpression(expression); if (!validation.valid) { setPreview({ result: null, error: validation.error }); return; } const compiled = ExpressionEvaluator.compileExpression(expression); if (compiled) { const result = ExpressionEvaluator.evaluateExpression(compiled); setPreview({ result, error: undefined }); } }, [expression]); return (

Available

{ // Insert at cursor if (item.insertText) { setExpression(prev => prev + item.insertText); } }} />

Preview

{preview.error ? (
{preview.error}
) : (
Result:
{JSON.stringify(preview.result)}
Type: {typeof preview.result}
)}
); } ``` ### Step 8: Add Keyboard Shortcuts **Modify:** `packages/noodl-editor/src/editor/src/constants/Keybindings.ts` ```typescript export namespace Keybindings { // ... existing keybindings ... // Expression shortcuts (new) export const TOGGLE_EXPRESSION_MODE = new Keybinding(KeyMod.CtrlCmd, KeyCode.KEY_E); export const OPEN_EXPRESSION_BUILDER = new Keybinding(KeyMod.CtrlCmd, KeyMod.Shift, KeyCode.KEY_E); } ``` ### Step 9: Handle Property Types Different property types need type-appropriate expression handling: | Property Type | Expression Returns | Coercion | |--------------|-------------------|----------| | `string` | Any → String | `String(result)` | | `number` | Number | `Number(result) \|\| fallback` | | `boolean` | Truthy/Falsy | `!!result` | | `color` | Hex/RGB string | Validate format | | `enum` | Enum value string | Validate against options | | `component` | Component name | Validate exists | **Create file:** `packages/noodl-runtime/src/expression-type-coercion.js` ```javascript /** * Coerce expression result to expected property type */ function coerceToType(value, expectedType, fallback, enumOptions) { if (value === undefined || value === null) { return fallback; } switch (expectedType) { case 'string': return String(value); case 'number': const num = Number(value); return isNaN(num) ? fallback : num; case 'boolean': return !!value; case 'color': const str = String(value); // Basic validation for hex or rgb if (/^#[0-9A-Fa-f]{6}$/.test(str) || /^rgba?\(/.test(str)) { return str; } return fallback; case 'enum': const enumVal = String(value); if (enumOptions && enumOptions.some(opt => opt === enumVal || opt.value === enumVal )) { return enumVal; } return fallback; default: return value; } } module.exports = { coerceToType }; ``` --- ## Success Criteria ### Functional Requirements - [ ] Expression toggle button appears on supported property types - [ ] Toggle switches between fixed and expression modes - [ ] Expression mode shows `fx` badge and code-style input - [ ] Expression evaluates correctly at runtime - [ ] Expression re-evaluates when dependencies change - [ ] Connected ports (via cables) disable expression mode - [ ] Type coercion works for each property type - [ ] Invalid expressions show error state - [ ] Copy/paste expressions works - [ ] Expression builder modal opens (Cmd+Shift+E) - [ ] Undo/redo works for expression changes ### Property Types Supported - [ ] String (`PropertyPanelTextInput`) - [ ] Number (`PropertyPanelNumberInput`) - [ ] Number with units (`PropertyPanelLengthUnitInput`) - [ ] Boolean (`PropertyPanelCheckbox`) - [ ] Select/Enum (`PropertyPanelSelectInput`) - [ ] Slider (`PropertyPanelSliderInput`) - [ ] Color (`ColorType` / color picker) ### Non-Functional Requirements - [ ] No performance regression in property panel rendering - [ ] Expressions compile once, evaluate efficiently - [ ] Memory cleanup when nodes are deleted - [ ] Backward compatibility with existing projects --- ## Testing Checklist ### Manual Testing 1. **Basic Toggle** - Select a Group node - Find the "Margin Left" property - Click expression toggle button - Verify UI changes to expression mode - Toggle back to fixed mode - Verify original value is preserved 2. **Expression Evaluation** - Set a Group's margin to expression mode - Enter: `Noodl.Variables.spacing || 16` - Set `Noodl.Variables.spacing = 32` in a Function node - Verify margin updates to 32 3. **Reactive Updates** - Create expression: `Noodl.Variables.isExpanded ? 200 : 50` - Add button that toggles `Noodl.Variables.isExpanded` - Click button, verify property updates 4. **Connected Port Behavior** - Connect an output to a property input - Verify expression toggle is disabled/hidden - Disconnect - Verify toggle is available again 5. **Type Coercion** - Number property with expression returning string "42" - Verify it coerces to number 42 - Boolean property with expression returning "yes" - Verify it coerces to true 6. **Error Handling** - Enter invalid expression: `1 +` - Verify error indicator appears - Verify property uses fallback value - Fix expression - Verify error clears 7. **Undo/Redo** - Change property to expression mode - Undo (Cmd+Z) - Verify returns to fixed mode - Redo - Verify returns to expression mode 8. **Project Save/Load** - Create property with expression - Save project - Close and reopen project - Verify expression is preserved and working ### Property Type Coverage - [ ] Text input with expression - [ ] Number input with expression - [ ] Number with units (px, %, etc.) with expression - [ ] Checkbox/boolean with expression - [ ] Dropdown/select with expression - [ ] Color picker with expression - [ ] Slider with expression ### Edge Cases - [ ] Expression referencing non-existent variable - [ ] Expression with runtime error (division by zero) - [ ] Very long expression - [ ] Expression with special characters - [ ] Expression in visual state parameter - [ ] Expression in variant parameter --- ## Migration Considerations ### Existing Projects - Existing projects have simple parameter values - These continue to work as-is (backward compatible) - No automatic migration needed ### Future Expression Version Changes If we need to change the expression context in the future: 1. Increment `EXPRESSION_VERSION` in expression-evaluator.js 2. Add migration logic to handle old version expressions 3. Show warning for expressions with old version --- ## Notes for Implementer ### Important Patterns 1. **Model-View Separation** - Property panel is the view - NodeGraphNode.parameters is the model - Changes go through `setParameter()` for undo support 2. **Port Connection Priority** - Connected ports take precedence over expressions - Connected ports take precedence over fixed values - This is existing behavior, preserve it 3. **Visual States** - Visual state parameters use `paramName_stateName` pattern - Expression parameters in visual states need same pattern - Example: `marginLeft_hover` could be an expression ### Edge Cases to Handle 1. **Expression references port that's also connected** - Expression should still work - Connected value might be available via `this.inputs.X` 2. **Circular expressions** - Expression A references Variable that's set by Expression B - Shouldn't cause infinite loop (dependency tracking prevents) 3. **Expressions in cloud runtime** - Cloud uses different Noodl.js API - Ensure expression-evaluator works in both contexts ### Questions to Resolve 1. **Which property types should NOT support expressions?** - Recommendation: component picker, image picker - These need special UI that doesn't fit expression pattern 2. **Should expressions work in style properties?** - Recommendation: Yes, if using inputCss pattern - CSS values often need to be dynamic 3. **Mobile/responsive expressions?** - Recommendation: Expressions can reference `Noodl.Variables.screenWidth` - Combine with existing variants system --- ## Files Created/Modified Summary ### New Files - `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.tsx` - `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.module.scss` - `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.tsx` - `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.module.scss` - `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionBuilder/ExpressionBuilder.tsx` - `packages/noodl-runtime/src/expression-type-coercion.js` ### Modified Files - `packages/noodl-core-ui/src/components/property-panel/PropertyPanelInput/PropertyPanelInput.tsx` - `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts` - `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BooleanType.ts` - `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ColorType.ts` - `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` - `packages/noodl-runtime/src/node.js` - `packages/noodl-editor/src/editor/src/constants/Keybindings.ts`