Added some community improvement suggestions in docs

This commit is contained in:
Richard Osborne
2025-12-17 09:30:30 +01:00
parent ea45e8b3a3
commit 7d307066d8
8 changed files with 2924 additions and 26 deletions

View File

@@ -0,0 +1,738 @@
# TASK: Enhanced Expression Node & Expression Evaluator Foundation
## Overview
Upgrade the existing Expression node to support full JavaScript expressions with access to `Noodl.Variables`, `Noodl.Objects`, and `Noodl.Arrays`, plus reactive dependency tracking. This establishes the foundation for Phase 2 (inline expression properties throughout the editor).
**Estimated effort:** 2-3 weeks
**Priority:** High - Foundation for Expression Properties feature
**Dependencies:** None
---
## Background & Motivation
### Current Expression Node Limitations
The existing Expression node (`packages/noodl-runtime/src/nodes/std-library/expression.js`):
1. **Limited context** - Only provides Math helpers (min, max, cos, sin, etc.)
2. **No Noodl globals** - Cannot access `Noodl.Variables.X`, `Noodl.Objects.Y`, `Noodl.Arrays.Z`
3. **Boolean-focused outputs** - Primarily `isTrue`/`isFalse`, though `result` exists as `*` type
4. **Workaround required** - Users must create connected input ports to pass in variable values
5. **No reactive updates** - Doesn't automatically re-evaluate when referenced Variables/Objects change
### Desired State
Users should be able to write expressions like:
```javascript
Noodl.Variables.isLoggedIn ? `Welcome, ${Noodl.Variables.userName}!` : "Please log in"
```
And have the expression automatically re-evaluate whenever `isLoggedIn` or `userName` changes.
---
## Files to Analyze First
Before making changes, thoroughly read and understand these files:
### Core Expression Implementation
```
@packages/noodl-runtime/src/nodes/std-library/expression.js
```
- Current expression compilation using `new Function()`
- `functionPreamble` that injects Math helpers
- `parsePorts()` for extracting variable references
- Scheduling and caching mechanisms
### Noodl Global APIs
```
@packages/noodl-runtime/src/model.js
@packages/noodl-viewer-react/src/noodl-js-api.js
@packages/noodl-viewer-cloud/src/noodl-js-api.js
```
- How `Noodl.Variables`, `Noodl.Objects`, `Noodl.Arrays` are implemented
- The Model class and its change event system
- `Model.get('--ndl--global-variables')` pattern
### Type Definitions (for autocomplete later)
```
@packages/noodl-viewer-react/static/viewer/global.d.ts.keep
@packages/noodl-viewer-cloud/static/viewer/global.d.ts.keep
```
- TypeScript definitions for the Noodl namespace
- Documentation of the API surface
### JavaScript/Function Node (reference)
```
@packages/noodl-runtime/src/nodes/std-library/javascriptfunction.js
```
- How full JavaScript nodes access Noodl context
- Pattern for providing richer execution context
---
## Implementation Plan
### Step 1: Create Expression Evaluator Module
Create a new shared module that handles expression compilation, dependency tracking, and evaluation.
**Create file:** `packages/noodl-runtime/src/expression-evaluator.js`
```javascript
/**
* Expression Evaluator
*
* Compiles JavaScript expressions with access to Noodl globals
* and tracks dependencies for reactive updates.
*
* Features:
* - Full Noodl.Variables, Noodl.Objects, Noodl.Arrays access
* - Math helpers (min, max, cos, sin, etc.)
* - Dependency detection and change subscription
* - Expression versioning for future compatibility
* - Caching of compiled functions
*/
'use strict';
const Model = require('./model');
// Expression system version - increment when context changes
const EXPRESSION_VERSION = 1;
// Cache for compiled functions
const compiledFunctionsCache = new Map();
// Math helpers to inject
const mathHelpers = {
min: Math.min,
max: Math.max,
cos: Math.cos,
sin: Math.sin,
tan: Math.tan,
sqrt: Math.sqrt,
pi: Math.PI,
round: Math.round,
floor: Math.floor,
ceil: Math.ceil,
abs: Math.abs,
random: Math.random,
pow: Math.pow,
log: Math.log,
exp: Math.exp
};
/**
* Detect dependencies in an expression string
* Returns { variables: string[], objects: string[], arrays: string[] }
*/
function detectDependencies(expression) {
const dependencies = {
variables: [],
objects: [],
arrays: []
};
// Remove strings to avoid false matches
const exprWithoutStrings = expression
.replace(/"([^"\\]|\\.)*"/g, '""')
.replace(/'([^'\\]|\\.)*'/g, "''")
.replace(/`([^`\\]|\\.)*`/g, '``');
// Match Noodl.Variables.X or Noodl.Variables["X"]
const variableMatches = exprWithoutStrings.matchAll(
/Noodl\.Variables\.([a-zA-Z_$][a-zA-Z0-9_$]*)|Noodl\.Variables\[["']([^"']+)["']\]/g
);
for (const match of variableMatches) {
const varName = match[1] || match[2];
if (varName && !dependencies.variables.includes(varName)) {
dependencies.variables.push(varName);
}
}
// Match Noodl.Objects.X or Noodl.Objects["X"]
const objectMatches = exprWithoutStrings.matchAll(
/Noodl\.Objects\.([a-zA-Z_$][a-zA-Z0-9_$]*)|Noodl\.Objects\[["']([^"']+)["']\]/g
);
for (const match of objectMatches) {
const objId = match[1] || match[2];
if (objId && !dependencies.objects.includes(objId)) {
dependencies.objects.push(objId);
}
}
// Match Noodl.Arrays.X or Noodl.Arrays["X"]
const arrayMatches = exprWithoutStrings.matchAll(
/Noodl\.Arrays\.([a-zA-Z_$][a-zA-Z0-9_$]*)|Noodl\.Arrays\[["']([^"']+)["']\]/g
);
for (const match of arrayMatches) {
const arrId = match[1] || match[2];
if (arrId && !dependencies.arrays.includes(arrId)) {
dependencies.arrays.push(arrId);
}
}
return dependencies;
}
/**
* Create the Noodl context object for expression evaluation
*/
function createNoodlContext(modelScope) {
const scope = modelScope || Model;
return {
Variables: scope.get('--ndl--global-variables')?.data || {},
Objects: new Proxy({}, {
get(target, prop) {
const obj = scope.get(prop);
return obj ? obj.data : undefined;
}
}),
Arrays: new Proxy({}, {
get(target, prop) {
const arr = scope.get(prop);
return arr ? arr.data : undefined;
}
}),
Object: scope
};
}
/**
* Compile an expression string into a callable function
*/
function compileExpression(expression) {
const cacheKey = `v${EXPRESSION_VERSION}:${expression}`;
if (compiledFunctionsCache.has(cacheKey)) {
return compiledFunctionsCache.get(cacheKey);
}
// Build parameter list for the function
const paramNames = ['Noodl', ...Object.keys(mathHelpers)];
// Wrap expression in return statement
const functionBody = `
"use strict";
try {
return (${expression});
} catch (e) {
console.error('Expression evaluation error:', e.message);
return undefined;
}
`;
try {
const fn = new Function(...paramNames, functionBody);
compiledFunctionsCache.set(cacheKey, fn);
return fn;
} catch (e) {
console.error('Expression compilation error:', e.message);
return null;
}
}
/**
* Evaluate a compiled expression with the current context
*/
function evaluateExpression(compiledFn, modelScope) {
if (!compiledFn) return undefined;
const noodlContext = createNoodlContext(modelScope);
const mathValues = Object.values(mathHelpers);
try {
return compiledFn(noodlContext, ...mathValues);
} catch (e) {
console.error('Expression evaluation error:', e.message);
return undefined;
}
}
/**
* Subscribe to changes in expression dependencies
* Returns an unsubscribe function
*/
function subscribeToChanges(dependencies, callback, modelScope) {
const scope = modelScope || Model;
const listeners = [];
// Subscribe to variable changes
if (dependencies.variables.length > 0) {
const variablesModel = scope.get('--ndl--global-variables');
if (variablesModel) {
const handler = (args) => {
// Check if any of our dependencies changed
if (dependencies.variables.some(v => args.name === v || !args.name)) {
callback();
}
};
variablesModel.on('change', handler);
listeners.push(() => variablesModel.off('change', handler));
}
}
// Subscribe to object changes
for (const objId of dependencies.objects) {
const objModel = scope.get(objId);
if (objModel) {
const handler = () => callback();
objModel.on('change', handler);
listeners.push(() => objModel.off('change', handler));
}
}
// Subscribe to array changes
for (const arrId of dependencies.arrays) {
const arrModel = scope.get(arrId);
if (arrModel) {
const handler = () => callback();
arrModel.on('change', handler);
listeners.push(() => arrModel.off('change', handler));
}
}
// Return unsubscribe function
return () => {
listeners.forEach(unsub => unsub());
};
}
/**
* Validate expression syntax without executing
*/
function validateExpression(expression) {
try {
new Function(`return (${expression})`);
return { valid: true, error: null };
} catch (e) {
return { valid: false, error: e.message };
}
}
/**
* Get the current expression system version
*/
function getExpressionVersion() {
return EXPRESSION_VERSION;
}
module.exports = {
detectDependencies,
compileExpression,
evaluateExpression,
subscribeToChanges,
validateExpression,
createNoodlContext,
getExpressionVersion,
EXPRESSION_VERSION
};
```
### Step 2: Upgrade Expression Node
Modify the existing Expression node to use the new evaluator and support reactive updates.
**Modify file:** `packages/noodl-runtime/src/nodes/std-library/expression.js`
Key changes:
1. Use `expression-evaluator.js` for compilation
2. Add Noodl globals to the function preamble
3. Implement dependency detection
4. Subscribe to changes for automatic re-evaluation
5. Add new typed outputs (`asString`, `asNumber`)
6. Clean up subscriptions on node deletion
```javascript
// Key additions to the expression node:
const ExpressionEvaluator = require('../../expression-evaluator');
// In initialize():
internal.unsubscribe = null;
internal.dependencies = { variables: [], objects: [], arrays: [] };
// In the expression input setter:
// After compiling the expression:
internal.dependencies = ExpressionEvaluator.detectDependencies(value);
// Set up reactive subscription
if (internal.unsubscribe) {
internal.unsubscribe();
}
if (internal.dependencies.variables.length > 0 ||
internal.dependencies.objects.length > 0 ||
internal.dependencies.arrays.length > 0) {
internal.unsubscribe = ExpressionEvaluator.subscribeToChanges(
internal.dependencies,
() => this._scheduleEvaluateExpression(),
this.context?.modelScope
);
}
// Add cleanup in _onNodeDeleted or add a delete listener
```
### Step 3: Update Function Preamble
Update the preamble to include Noodl globals:
```javascript
var functionPreamble = [
// Math helpers (existing)
'var min = Math.min,',
' max = Math.max,',
' cos = Math.cos,',
' sin = Math.sin,',
' tan = Math.tan,',
' sqrt = Math.sqrt,',
' pi = Math.PI,',
' round = Math.round,',
' floor = Math.floor,',
' ceil = Math.ceil,',
' abs = Math.abs,',
' pow = Math.pow,',
' log = Math.log,',
' exp = Math.exp,',
' random = Math.random;',
// Noodl context shortcuts (new)
'var Variables = Noodl.Variables,',
' Objects = Noodl.Objects,',
' Arrays = Noodl.Arrays;'
].join('\n');
```
### Step 4: Add New Outputs
Add typed output alternatives for better downstream compatibility:
```javascript
outputs: {
// Existing outputs (keep for backward compatibility)
result: { /* ... */ },
isTrue: { /* ... */ },
isFalse: { /* ... */ },
isTrueEv: { /* ... */ },
isFalseEv: { /* ... */ },
// New typed outputs
asString: {
group: 'Typed Results',
type: 'string',
displayName: 'As String',
getter: function() {
const val = this._internal.cachedValue;
return val !== undefined && val !== null ? String(val) : '';
}
},
asNumber: {
group: 'Typed Results',
type: 'number',
displayName: 'As Number',
getter: function() {
const val = this._internal.cachedValue;
return typeof val === 'number' ? val : Number(val) || 0;
}
},
asBoolean: {
group: 'Typed Results',
type: 'boolean',
displayName: 'As Boolean',
getter: function() {
return !!this._internal.cachedValue;
}
}
}
```
### Step 5: Add Expression Validation in Editor
Enhance the editor-side validation to provide better error messages:
**Modify file:** `packages/noodl-runtime/src/nodes/std-library/expression.js` (setup function)
```javascript
// In the setup function, enhance evalCompileWarnings:
function evalCompileWarnings(editorConnection, node) {
const expression = node.parameters.expression;
if (!expression) {
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
return;
}
const validation = ExpressionEvaluator.validateExpression(expression);
if (!validation.valid) {
editorConnection.sendWarning(node.component.name, node.id, 'expression-compile-error', {
message: `Syntax error: ${validation.error}`
});
} else {
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
// Also show detected dependencies as info (optional)
const deps = ExpressionEvaluator.detectDependencies(expression);
if (deps.variables.length > 0 || deps.objects.length > 0 || deps.arrays.length > 0) {
// Could show this as info, not warning
}
}
}
```
### Step 6: Add Tests
**Create file:** `packages/noodl-runtime/test/expression-evaluator.test.js`
```javascript
const ExpressionEvaluator = require('../src/expression-evaluator');
describe('Expression Evaluator', () => {
describe('detectDependencies', () => {
test('detects Noodl.Variables references', () => {
const deps = ExpressionEvaluator.detectDependencies(
'Noodl.Variables.isLoggedIn ? Noodl.Variables.userName : "guest"'
);
expect(deps.variables).toContain('isLoggedIn');
expect(deps.variables).toContain('userName');
});
test('detects bracket notation', () => {
const deps = ExpressionEvaluator.detectDependencies(
'Noodl.Variables["my variable"]'
);
expect(deps.variables).toContain('my variable');
});
test('ignores references inside strings', () => {
const deps = ExpressionEvaluator.detectDependencies(
'"Noodl.Variables.notReal"'
);
expect(deps.variables).toHaveLength(0);
});
test('detects Noodl.Objects references', () => {
const deps = ExpressionEvaluator.detectDependencies(
'Noodl.Objects.CurrentUser.name'
);
expect(deps.objects).toContain('CurrentUser');
});
test('detects Noodl.Arrays references', () => {
const deps = ExpressionEvaluator.detectDependencies(
'Noodl.Arrays.items.length'
);
expect(deps.arrays).toContain('items');
});
});
describe('compileExpression', () => {
test('compiles valid expression', () => {
const fn = ExpressionEvaluator.compileExpression('1 + 1');
expect(fn).not.toBeNull();
});
test('returns null for invalid expression', () => {
const fn = ExpressionEvaluator.compileExpression('1 +');
expect(fn).toBeNull();
});
test('caches compiled functions', () => {
const fn1 = ExpressionEvaluator.compileExpression('2 + 2');
const fn2 = ExpressionEvaluator.compileExpression('2 + 2');
expect(fn1).toBe(fn2);
});
});
describe('validateExpression', () => {
test('validates correct syntax', () => {
const result = ExpressionEvaluator.validateExpression('a > b ? 1 : 0');
expect(result.valid).toBe(true);
});
test('catches syntax errors', () => {
const result = ExpressionEvaluator.validateExpression('a >');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('evaluateExpression', () => {
test('evaluates math expressions', () => {
const fn = ExpressionEvaluator.compileExpression('min(10, 5) + max(1, 2)');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe(7);
});
test('handles pi constant', () => {
const fn = ExpressionEvaluator.compileExpression('round(pi * 100) / 100');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe(3.14);
});
});
});
```
### Step 7: Update TypeScript Definitions
**Modify file:** `packages/noodl-editor/src/editor/src/utils/CodeEditor/model.ts`
Add the enhanced context for Expression nodes in the Monaco editor:
```typescript
// In registerOrUpdate_Expression function, add more complete typings
function registerOrUpdate_Expression(): TypescriptModule {
return {
uri: 'expression-context.d.ts',
source: `
declare const Noodl: {
Variables: Record<string, any>;
Objects: Record<string, any>;
Arrays: Record<string, any>;
};
declare const Variables: Record<string, any>;
declare const Objects: Record<string, any>;
declare const Arrays: Record<string, any>;
declare const min: typeof Math.min;
declare const max: typeof Math.max;
declare const cos: typeof Math.cos;
declare const sin: typeof Math.sin;
declare const tan: typeof Math.tan;
declare const sqrt: typeof Math.sqrt;
declare const pi: number;
declare const round: typeof Math.round;
declare const floor: typeof Math.floor;
declare const ceil: typeof Math.ceil;
declare const abs: typeof Math.abs;
declare const pow: typeof Math.pow;
declare const log: typeof Math.log;
declare const exp: typeof Math.exp;
declare const random: typeof Math.random;
`,
libs: []
};
}
```
---
## Success Criteria
### Functional Requirements
- [ ] Expression node can evaluate `Noodl.Variables.X` syntax
- [ ] Expression node can evaluate `Noodl.Objects.X.property` syntax
- [ ] Expression node can evaluate `Noodl.Arrays.X` syntax
- [ ] Shorthand aliases work (`Variables.X`, `Objects.X`, `Arrays.X`)
- [ ] Expression auto-re-evaluates when referenced Variable changes
- [ ] Expression auto-re-evaluates when referenced Object property changes
- [ ] Expression auto-re-evaluates when referenced Array changes
- [ ] New typed outputs (`asString`, `asNumber`, `asBoolean`) work correctly
- [ ] Backward compatibility - existing expressions continue to work
- [ ] Math helpers continue to work (min, max, cos, sin, etc.)
- [ ] Syntax errors show clear warning messages in editor
### Non-Functional Requirements
- [ ] Compiled functions are cached for performance
- [ ] Memory cleanup - subscriptions are removed when node is deleted
- [ ] Expression version is tracked for future migration support
- [ ] No performance regression for expressions without Noodl globals
---
## Testing Checklist
### Manual Testing
1. **Basic Math Expression**
- Create Expression node with `min(10, 5) + max(1, 2)`
- Verify result output is 7
2. **Variable Reference**
- Set `Noodl.Variables.testVar = 42` in a Function node
- Create Expression node with `Noodl.Variables.testVar * 2`
- Verify result is 84
3. **Reactive Update**
- Create Expression with `Noodl.Variables.counter`
- Connect a button to increment `Noodl.Variables.counter`
- Verify Expression result updates automatically on button click
4. **Object Property Access**
- Create an Object with ID "TestObject" and property "name"
- Create Expression with `Noodl.Objects.TestObject.name`
- Verify result shows the name value
5. **Ternary with Variables**
- Set `Noodl.Variables.isAdmin = true`
- Create Expression: `Noodl.Variables.isAdmin ? "Admin" : "User"`
- Verify result is "Admin"
- Toggle isAdmin to false, verify result changes to "User"
6. **Template Literals**
- Set `Noodl.Variables.name = "Alice"`
- Create Expression: `` `Hello, ${Noodl.Variables.name}!` ``
- Verify result is "Hello, Alice!"
7. **Syntax Error Handling**
- Create Expression with invalid syntax `1 +`
- Verify warning appears in editor
- Verify node doesn't crash
8. **Typed Outputs**
- Create Expression: `"42"`
- Connect `asNumber` output to a Number display
- Verify it shows 42 as number
### Automated Testing
- [ ] Run `npm test` in packages/noodl-runtime
- [ ] All expression-evaluator tests pass
- [ ] Existing expression.test.js tests pass
- [ ] No TypeScript errors in editor package
---
## Rollback Plan
If issues are discovered:
1. The expression-evaluator.js module is additive - can be removed without breaking existing code
2. Expression node changes are backward compatible - old expressions work
3. New outputs are additive - removing them won't break existing connections
4. Keep original functionPreamble as fallback option
---
## Notes for Implementer
### Important Patterns to Preserve
1. **Input port generation** - The expression node dynamically creates input ports for referenced variables. This behavior should be preserved for explicit inputs while also supporting implicit Noodl.Variables access.
2. **Scheduling** - Use `scheduleAfterInputsHaveUpdated` pattern for batching evaluations.
3. **Caching** - The existing `cachedValue` pattern prevents unnecessary output updates.
### Edge Cases to Handle
1. **Circular dependencies** - What if Variable A's expression references Variable B and vice versa?
2. **Missing variables** - Handle gracefully when referenced variable doesn't exist
3. **Type coercion** - Be consistent with JavaScript's type coercion rules
4. **Async expressions** - Current system is sync-only, keep it that way
### Questions to Resolve During Implementation
1. Should the shorthand `Variables.X` work without `Noodl.` prefix?
- **Recommendation:** Yes, add to preamble for convenience
2. Should we detect unused input ports and warn?
- **Recommendation:** Not in this phase
3. How to handle expressions that error at runtime?
- **Recommendation:** Return undefined, log error, don't crash

View File

@@ -0,0 +1,960 @@
# 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 (
<Tooltip content="Connected via cable">
<div className={css.connectionIndicator}>
<Icon name={IconName.Connection} size={IconSize.Tiny} />
</div>
</Tooltip>
);
}
const tooltipText = mode === 'expression'
? 'Switch to fixed value'
: 'Switch to expression';
return (
<Tooltip content={tooltipText}>
<IconButton
icon={mode === 'expression' ? IconName.Function : IconName.Lightning}
size={IconSize.Tiny}
variant={mode === 'expression'
? IconButtonVariant.Active
: IconButtonVariant.OpaqueOnHover}
onClick={onToggle}
isDisabled={disabled}
UNSAFE_className={mode === 'expression' ? css.expressionActive : undefined}
/>
</Tooltip>
);
}
```
**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 (
<div className={css.container}>
<span className={css.badge}>fx</span>
<TextInput
value={localValue}
onChange={(e) => 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 && (
<Tooltip content="Open expression builder (Cmd+Shift+E)">
<IconButton
icon={IconName.Expand}
size={IconSize.Tiny}
onClick={onOpenBuilder}
/>
</Tooltip>
)}
{hasError && errorMessage && (
<Tooltip content={errorMessage}>
<div className={css.errorIndicator}>!</div>
</Tooltip>
)}
</div>
);
}
```
**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<PropertyPanelBaseInputProps, 'type'> {
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 (
<ExpressionInput
expression={expression || ''}
onChange={onExpressionChange}
expectedType={inputType}
/>
);
}
// Standard input rendering (existing code)
const Input = useMemo(() => {
switch (inputType) {
case PropertyPanelInputType.Text:
return PropertyPanelTextInput;
// ... rest of existing switch
}
}, [inputType]);
return (
<Input
value={value}
properties={properties}
isChanged={isChanged}
isConnected={isConnected}
onChange={onChange}
/>
);
};
return (
<div className={css.container}>
<label className={css.label}>{label}</label>
<div className={css.inputRow}>
{renderInput()}
{showExpressionToggle && (
<ExpressionToggle
mode={expressionMode}
isConnected={isConnected}
onToggle={handleToggleMode}
/>
)}
</div>
</div>
);
}
```
### 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 (
<Modal
isOpen={isOpen}
onClose={onCancel}
title="Expression Builder"
size="large"
>
<div className={css.container}>
<div className={css.editor}>
<MonacoEditor
value={expression}
onChange={setExpression}
language="javascript"
options={{
minimap: { enabled: false },
lineNumbers: 'off',
fontSize: 14,
wordWrap: 'on'
}}
/>
</div>
<div className={css.sidebar}>
<div className={css.completions}>
<h4>Available</h4>
<TreeView
items={completionsTree}
onItemClick={(item) => {
// Insert at cursor
if (item.insertText) {
setExpression(prev => prev + item.insertText);
}
}}
/>
</div>
<div className={css.preview}>
<h4>Preview</h4>
{preview.error ? (
<div className={css.error}>{preview.error}</div>
) : (
<div className={css.result}>
<div className={css.resultLabel}>Result:</div>
<div className={css.resultValue}>
{JSON.stringify(preview.result)}
</div>
<div className={css.resultType}>
Type: {typeof preview.result}
</div>
</div>
)}
</div>
</div>
<div className={css.actions}>
<button onClick={onCancel}>Cancel</button>
<button onClick={() => onApply(expression)}>Apply</button>
</div>
</div>
</Modal>
);
}
```
### 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`