mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
Added some community improvement suggestions in docs
This commit is contained in:
@@ -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
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user