new code editor

This commit is contained in:
Richard Osborne
2026-01-11 09:48:20 +01:00
parent 7fc49ae3a8
commit 6f08163590
63 changed files with 12074 additions and 74 deletions

View File

@@ -0,0 +1,242 @@
/**
* CodeHistoryManager
*
* Manages automatic code snapshots for Expression, Function, and Script nodes.
* Allows users to view history and restore previous versions.
*
* @module models
*/
import { NodeGraphNode } from '@noodl-models/nodegraphmodel/NodeGraphNode';
import { ProjectModel } from '@noodl-models/projectmodel';
import Model from '../../../shared/model';
/**
* A single code snapshot
*/
export interface CodeSnapshot {
code: string;
timestamp: string; // ISO 8601 format
hash: string; // For deduplication
}
/**
* Metadata structure for code history
*/
export interface CodeHistoryMetadata {
codeHistory?: CodeSnapshot[];
}
/**
* Manages code history for nodes
*/
export class CodeHistoryManager extends Model {
public static instance = new CodeHistoryManager();
private readonly MAX_SNAPSHOTS = 20;
/**
* Save a code snapshot for a node
* Only saves if code has actually changed (hash comparison)
*/
saveSnapshot(nodeId: string, parameterName: string, code: string): void {
const node = this.getNode(nodeId);
if (!node) {
console.warn('CodeHistoryManager: Node not found:', nodeId);
return;
}
// Don't save empty code
if (!code || code.trim() === '') {
return;
}
// Compute hash for deduplication
const hash = this.hashCode(code);
// Get existing history
const history = this.getHistory(nodeId, parameterName);
// Check if last snapshot is identical (deduplication)
if (history.length > 0) {
const lastSnapshot = history[history.length - 1];
if (lastSnapshot.hash === hash) {
// Code hasn't changed, don't create duplicate snapshot
return;
}
}
// Create new snapshot
const snapshot: CodeSnapshot = {
code,
timestamp: new Date().toISOString(),
hash
};
// Add to history
history.push(snapshot);
// Prune old snapshots
if (history.length > this.MAX_SNAPSHOTS) {
history.splice(0, history.length - this.MAX_SNAPSHOTS);
}
// Save to node metadata
this.saveHistory(node, parameterName, history);
console.log(`📸 Code snapshot saved for node ${nodeId}, param ${parameterName} (${history.length} total)`);
}
/**
* Get code history for a node parameter
*/
getHistory(nodeId: string, parameterName: string): CodeSnapshot[] {
const node = this.getNode(nodeId);
if (!node) {
return [];
}
const historyKey = this.getHistoryKey(parameterName);
const metadata = node.metadata as CodeHistoryMetadata | undefined;
if (!metadata || !metadata[historyKey]) {
return [];
}
return metadata[historyKey] as CodeSnapshot[];
}
/**
* Restore a snapshot by timestamp
* Returns the code from that snapshot
*/
restoreSnapshot(nodeId: string, parameterName: string, timestamp: string): string | undefined {
const history = this.getHistory(nodeId, parameterName);
const snapshot = history.find((s) => s.timestamp === timestamp);
if (!snapshot) {
console.warn('CodeHistoryManager: Snapshot not found:', timestamp);
return undefined;
}
console.log(`↩️ Restoring snapshot from ${timestamp}`);
return snapshot.code;
}
/**
* Get a specific snapshot by timestamp
*/
getSnapshot(nodeId: string, parameterName: string, timestamp: string): CodeSnapshot | undefined {
const history = this.getHistory(nodeId, parameterName);
return history.find((s) => s.timestamp === timestamp);
}
/**
* Clear all history for a node parameter
*/
clearHistory(nodeId: string, parameterName: string): void {
const node = this.getNode(nodeId);
if (!node) {
return;
}
const historyKey = this.getHistoryKey(parameterName);
if (node.metadata) {
delete node.metadata[historyKey];
}
console.log(`🗑️ Cleared history for node ${nodeId}, param ${parameterName}`);
}
/**
* Get the node from the current project
*/
private getNode(nodeId: string): NodeGraphNode | undefined {
const project = ProjectModel.instance;
if (!project) {
return undefined;
}
// Search all components for the node
for (const component of project.getComponents()) {
const graph = component.graph;
if (!graph) continue;
const node = graph.findNodeWithId(nodeId);
if (node) {
return node;
}
}
return undefined;
}
/**
* Save history to node metadata
*/
private saveHistory(node: NodeGraphNode, parameterName: string, history: CodeSnapshot[]): void {
const historyKey = this.getHistoryKey(parameterName);
if (!node.metadata) {
node.metadata = {};
}
node.metadata[historyKey] = history;
// Notify that metadata changed (triggers project save)
node.notifyListeners('metadataChanged');
}
/**
* Get the metadata key for a parameter's history
* Uses a prefix to avoid conflicts with other metadata
*/
private getHistoryKey(parameterName: string): string {
return `codeHistory_${parameterName}`;
}
/**
* Compute a simple hash of code for deduplication
* Not cryptographic, just for detecting changes
*/
private hashCode(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(36);
}
/**
* Format a timestamp for display
* Returns human-readable relative time ("5 minutes ago", "Yesterday")
*/
formatTimestamp(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) {
return 'just now';
} else if (diffMin < 60) {
return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
} else if (diffHour < 24) {
return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
} else if (diffDay === 1) {
return 'yesterday at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (diffDay < 7) {
return `${diffDay} days ago`;
} else {
// Full date for older snapshots
return then.toLocaleDateString() + ' at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
}

View File

@@ -0,0 +1,170 @@
/**
* Expression Parameter Types
*
* Defines types and helper functions for expression-based property values.
* Allows properties to be set to JavaScript expressions that evaluate at runtime.
*
* @module ExpressionParameter
* @since 1.1.0
*/
/**
* An expression parameter stores a JavaScript expression that evaluates at runtime
*/
export interface ExpressionParameter {
/** Marker to identify expression parameters */
mode: 'expression';
/** The JavaScript expression to evaluate */
expression: string;
/** Fallback value if expression fails or is invalid */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallback?: any;
/** Expression system version for future migrations */
version?: number;
}
/**
* A parameter can be a simple value or an expression
* Note: any is intentional - parameters can be any JSON-serializable value
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ParameterValue = any | ExpressionParameter;
/**
* Type guard to check if a parameter value is an expression
*
* @param value - The parameter value to check
* @returns True if value is an ExpressionParameter
*
* @example
* ```typescript
* const param = node.getParameter('marginLeft');
* if (isExpressionParameter(param)) {
* console.log('Expression:', param.expression);
* } else {
* console.log('Fixed value:', param);
* }
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isExpressionParameter(value: any): value is ExpressionParameter {
return (
value !== null &&
value !== undefined &&
typeof value === 'object' &&
value.mode === 'expression' &&
typeof value.expression === 'string'
);
}
/**
* Get the display value for a parameter (for UI rendering)
*
* - For expression parameters: returns the expression string
* - For simple values: returns the value as-is
*
* @param value - The parameter value
* @returns Display value (expression string or simple value)
*
* @example
* ```typescript
* const expr = { mode: 'expression', expression: 'Variables.x * 2', fallback: 0 };
* getParameterDisplayValue(expr); // Returns: 'Variables.x * 2'
* getParameterDisplayValue(42); // Returns: 42
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getParameterDisplayValue(value: ParameterValue): any {
if (isExpressionParameter(value)) {
return value.expression;
}
return value;
}
/**
* Get the actual value for a parameter (unwraps expression fallback)
*
* - For expression parameters: returns the fallback value
* - For simple values: returns the value as-is
*
* This is useful when you need a concrete value for initialization
* before the expression can be evaluated.
*
* @param value - The parameter value
* @returns Actual value (fallback or simple value)
*
* @example
* ```typescript
* const expr = { mode: 'expression', expression: 'Variables.x', fallback: 100 };
* getParameterActualValue(expr); // Returns: 100
* getParameterActualValue(42); // Returns: 42
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getParameterActualValue(value: ParameterValue): any {
if (isExpressionParameter(value)) {
return value.fallback;
}
return value;
}
/**
* Create an expression parameter
*
* @param expression - The JavaScript expression string
* @param fallback - Optional fallback value if expression fails
* @param version - Expression system version (default: 1)
* @returns A new ExpressionParameter object
*
* @example
* ```typescript
* // Simple expression with fallback
* const param = createExpressionParameter('Variables.count', 0);
*
* // Complex expression
* const param = createExpressionParameter(
* 'Variables.isAdmin ? "Admin" : "User"',
* 'User'
* );
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function createExpressionParameter(
expression: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallback?: any,
version: number = 1
): ExpressionParameter {
return {
mode: 'expression',
expression,
fallback,
version
};
}
/**
* Convert a value to a parameter (for consistency)
*
* - Expression parameters are returned as-is
* - Simple values are returned as-is
*
* This is mainly for type safety and consistency in parameter handling.
*
* @param value - The value to convert
* @returns The value as a ParameterValue
*
* @example
* ```typescript
* const expr = createExpressionParameter('Variables.x');
* toParameter(expr); // Returns: expr (unchanged)
* toParameter(42); // Returns: 42 (unchanged)
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function toParameter(value: any): ParameterValue {
return value;
}

View File

@@ -10,6 +10,7 @@ import { UndoActionGroup, UndoQueue } from '@noodl-models/undo-queue-model';
import { WarningsModel } from '@noodl-models/warningsmodel';
import Model from '../../../../shared/model';
import { ParameterValueResolver } from '../../utils/ParameterValueResolver';
export type NodeGraphNodeParameters = {
[key: string]: any;
@@ -772,6 +773,28 @@ export class NodeGraphNode extends Model {
return port ? port.default : undefined;
}
/**
* Get a parameter value formatted as a display string.
* Handles expression parameter objects by resolving them to strings.
*
* @param name - The parameter name
* @param args - Optional args (same as getParameter)
* @returns A string representation of the parameter value, safe for UI display
*
* @example
* ```ts
* // Regular value
* node.getParameterDisplayValue('width') // '100'
*
* // Expression parameter object
* node.getParameterDisplayValue('height') // '{height * 2}' (not '[object Object]')
* ```
*/
getParameterDisplayValue(name: string, args?): string {
const value = this.getParameter(name, args);
return ParameterValueResolver.toString(value);
}
// Sets the dynamic instance ports for this node
setDynamicPorts(ports: NodeGrapPort[], options?: DynamicPortsOptions) {
if (portsEqual(ports, this.dynamicports)) {

View File

@@ -0,0 +1,193 @@
/**
* ParameterValueResolver
*
* Centralized utility for resolving parameter values from storage to their display/runtime values.
* Handles the conversion of expression parameter objects to primitive values based on context.
*
* This is necessary because parameters can be stored as either:
* 1. Primitive values (string, number, boolean)
* 2. Expression parameter objects: { mode: 'expression', expression: '...', fallback: '...', version: 1 }
*
* Consumers need different values based on their context:
* - Display (UI, canvas): Use fallback value
* - Runtime: Use evaluated expression (handled separately by runtime)
* - Serialization: Use raw value as-is
*
* @module noodl-editor/utils
* @since TASK-006B
*/
import { isExpressionParameter, ExpressionParameter } from '@noodl-models/ExpressionParameter';
/**
* Context in which a parameter value is being used
*/
export enum ValueContext {
/**
* Display context - for UI rendering (property panel, canvas)
* Returns the fallback value from expression parameters
*/
Display = 'display',
/**
* Runtime context - for runtime evaluation
* Returns the fallback value (actual evaluation happens in runtime)
*/
Runtime = 'runtime',
/**
* Serialization context - for saving/loading
* Returns the raw value unchanged
*/
Serialization = 'serialization'
}
/**
* Type for primitive parameter values
*/
export type PrimitiveValue = string | number | boolean | undefined;
/**
* ParameterValueResolver class
*
* Provides static methods to safely extract primitive values from parameters
* that may be either primitives or expression parameter objects.
*/
export class ParameterValueResolver {
/**
* Resolves a parameter value to a primitive based on context.
*
* @param paramValue - The raw parameter value (could be primitive or expression object)
* @param context - The context in which the value is being used
* @returns A primitive value appropriate for the context
*
* @example
* ```typescript
* // Primitive value passes through
* resolve('hello', ValueContext.Display) // => 'hello'
*
* // Expression parameter returns fallback
* const expr = { mode: 'expression', expression: 'Variables.x', fallback: 'default', version: 1 };
* resolve(expr, ValueContext.Display) // => 'default'
* ```
*/
static resolve(paramValue: unknown, context: ValueContext): PrimitiveValue | ExpressionParameter {
// If not an expression parameter, return as-is (assuming it's a primitive)
if (!isExpressionParameter(paramValue)) {
return paramValue as PrimitiveValue;
}
// Handle expression parameters based on context
switch (context) {
case ValueContext.Display:
// For display contexts (UI, canvas), use the fallback value
return paramValue.fallback ?? '';
case ValueContext.Runtime:
// For runtime, return fallback (actual evaluation happens in node runtime)
// This prevents display code from trying to evaluate expressions
return paramValue.fallback ?? '';
case ValueContext.Serialization:
// For serialization, return the whole object unchanged
return paramValue;
default:
// Default to fallback value for safety
return paramValue.fallback ?? '';
}
}
/**
* Safely converts any parameter value to a string for display.
* Always returns a string, never an object.
*
* @param paramValue - The raw parameter value
* @returns A string representation safe for display
*
* @example
* ```typescript
* toString('hello') // => 'hello'
* toString(42) // => '42'
* toString(null) // => ''
* toString(undefined) // => ''
* toString({ mode: 'expression', expression: '', fallback: 'test', version: 1 }) // => 'test'
* ```
*/
static toString(paramValue: unknown): string {
const resolved = this.resolve(paramValue, ValueContext.Display);
// If resolved is still an object (shouldn't happen, but defensive)
if (typeof resolved === 'object' && resolved !== null) {
return '';
}
return String(resolved ?? '');
}
/**
* Safely converts any parameter value to a number for display.
* Returns undefined if the value cannot be converted to a valid number.
*
* @param paramValue - The raw parameter value
* @returns A number, or undefined if conversion fails
*
* @example
* ```typescript
* toNumber(42) // => 42
* toNumber('42') // => 42
* toNumber('hello') // => undefined
* toNumber(null) // => undefined
* toNumber({ mode: 'expression', expression: '', fallback: 123, version: 1 }) // => 123
* ```
*/
static toNumber(paramValue: unknown): number | undefined {
const resolved = this.resolve(paramValue, ValueContext.Display);
// If resolved is still an object (shouldn't happen, but defensive)
if (typeof resolved === 'object' && resolved !== null) {
return undefined;
}
const num = Number(resolved);
return isNaN(num) ? undefined : num;
}
/**
* Safely converts any parameter value to a boolean for display.
* Uses JavaScript truthiness rules.
*
* @param paramValue - The raw parameter value
* @returns A boolean value
*
* @example
* ```typescript
* toBoolean(true) // => true
* toBoolean('hello') // => true
* toBoolean('') // => false
* toBoolean(0) // => false
* toBoolean({ mode: 'expression', expression: '', fallback: true, version: 1 }) // => true
* ```
*/
static toBoolean(paramValue: unknown): boolean {
const resolved = this.resolve(paramValue, ValueContext.Display);
// If resolved is still an object (shouldn't happen, but defensive)
if (typeof resolved === 'object' && resolved !== null) {
return false;
}
return Boolean(resolved);
}
/**
* Checks if a parameter value is an expression parameter.
* Convenience method that delegates to the ExpressionParameter module.
*
* @param paramValue - The value to check
* @returns True if the value is an expression parameter object
*/
static isExpression(paramValue: unknown): paramValue is ExpressionParameter {
return isExpressionParameter(paramValue);
}
}

View File

@@ -25,13 +25,22 @@ function measureTextHeight(text, font, lineHeight, maxWidth) {
ctx.font = font;
ctx.textBaseline = 'top';
return textWordWrap(ctx, text, 0, 0, lineHeight, maxWidth);
// Defensive: convert to string (handles expression objects, numbers, etc.)
const textString = typeof text === 'string' ? text : String(text || '');
return textWordWrap(ctx, textString, 0, 0, lineHeight, maxWidth);
}
function textWordWrap(context, text, x, y, lineHeight, maxWidth, cb?) {
if (!text) return;
// Defensive: ensure we have a string
const textString = typeof text === 'string' ? text : String(text || '');
let words = text.split(' ');
// Empty string still has height (return lineHeight, not undefined)
if (!textString) {
return lineHeight;
}
let words = textString.split(' ');
let currentLine = 0;
let idx = 1;
while (words.length > 0 && idx <= words.length) {

View File

@@ -2,10 +2,13 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { CodeHistoryManager } from '@noodl-models/CodeHistoryManager';
import { WarningsModel } from '@noodl-models/warningsmodel';
import { createModel } from '@noodl-utils/CodeEditor';
import { EditorModel } from '@noodl-utils/CodeEditor/model/editorModel';
import { JavaScriptEditor, type ValidationType } from '@noodl-core-ui/components/code-editor';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
import { CodeEditorProps } from './CodeEditor';
@@ -204,19 +207,32 @@ export class CodeEditorType extends TypeView {
this.parent.hidePopout();
WarningsModel.instance.off(this);
WarningsModel.instance.on(
'warningsChanged',
function () {
_this.updateWarnings();
},
this
);
// Always use new JavaScriptEditor for JavaScript/TypeScript
const isJavaScriptEditor = this.type.codeeditor === 'javascript' || this.type.codeeditor === 'typescript';
// Only set up Monaco warnings for Monaco-based editors
if (!isJavaScriptEditor) {
WarningsModel.instance.off(this);
WarningsModel.instance.on(
'warningsChanged',
function () {
_this.updateWarnings();
},
this
);
}
function save() {
let source = _this.model.getValue();
// For JavaScriptEditor, use this.value (already updated in onChange)
// For Monaco editor, get value from model
let source = isJavaScriptEditor ? _this.value : _this.model.getValue();
if (source === '') source = undefined;
// Save snapshot to history (before updating)
if (source && nodeId) {
CodeHistoryManager.instance.saveSnapshot(nodeId, scope.name, source);
}
_this.value = source;
_this.parent.setParameter(scope.name, source !== _this.default ? source : undefined);
_this.isDefault = source === undefined;
@@ -224,14 +240,17 @@ export class CodeEditorType extends TypeView {
const node = this.parent.model.model;
this.model = createModel(
{
type: this.type.name || this.type,
value: this.value,
codeeditor: this.type.codeeditor?.toLowerCase()
},
node
);
// Only create Monaco model for Monaco-based editors
if (!isJavaScriptEditor) {
this.model = createModel(
{
type: this.type.name || this.type,
value: this.value,
codeeditor: this.type.codeeditor?.toLowerCase()
},
node
);
}
const props: CodeEditorProps = {
nodeId,
@@ -265,11 +284,62 @@ export class CodeEditorType extends TypeView {
y: height
};
} catch (error) {}
} else {
// Default size: Make it wider (60% of viewport width, 70% of height)
const b = document.body.getBoundingClientRect();
props.initialSize = {
x: Math.min(b.width * 0.6, b.width - 200), // 60% width, but leave some margin
y: Math.min(b.height * 0.7, b.height - 200) // 70% height
};
}
this.popoutDiv = document.createElement('div');
this.popoutRoot = createRoot(this.popoutDiv);
this.popoutRoot.render(React.createElement(CodeEditor, props));
// Determine which editor to use
if (isJavaScriptEditor) {
console.log('✨ Using JavaScriptEditor for:', this.type.codeeditor);
// Determine validation type based on editor type
let validationType: ValidationType = 'function';
if (this.type.codeeditor === 'javascript') {
// Could be expression or function - check type name for hints
const typeName = (this.type.name || '').toLowerCase();
if (typeName.includes('expression')) {
validationType = 'expression';
} else if (typeName.includes('script')) {
validationType = 'script';
} else {
validationType = 'function';
}
} else if (this.type.codeeditor === 'typescript') {
validationType = 'script';
}
// Render JavaScriptEditor with proper sizing and history support
this.popoutRoot.render(
React.createElement(JavaScriptEditor, {
value: this.value || '',
onChange: (newValue) => {
this.value = newValue;
// Don't update Monaco model - JavaScriptEditor is independent
// The old code triggered Monaco validation which caused errors
},
onSave: () => {
save();
},
validationType,
width: props.initialSize?.x || 800,
height: props.initialSize?.y || 500,
// Add history tracking
nodeId: nodeId,
parameterName: scope.name
})
);
} else {
// Use existing Monaco CodeEditor
this.popoutRoot.render(React.createElement(CodeEditor, props));
}
const popoutDiv = this.popoutDiv;
this.parent.showPopout({
@@ -303,7 +373,11 @@ export class CodeEditorType extends TypeView {
}
});
this.updateWarnings();
// Only update warnings for Monaco-based editors
if (!isJavaScriptEditor) {
this.updateWarnings();
}
evt.stopPropagation();
}
}

View File

@@ -1,4 +1,14 @@
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { isExpressionParameter, createExpressionParameter } from '@noodl-models/ExpressionParameter';
import { NodeLibrary } from '@noodl-models/nodelibrary';
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
import {
PropertyPanelInput,
PropertyPanelInputType
} from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
@@ -7,8 +17,20 @@ function firstType(type) {
return NodeLibrary.nameForPortType(type);
}
function mapTypeToInputType(type: string): PropertyPanelInputType {
switch (type) {
case 'number':
return PropertyPanelInputType.Number;
case 'string':
default:
return PropertyPanelInputType.Text;
}
}
export class BasicType extends TypeView {
el: TSFixme;
private root: Root | null = null;
static fromPort(args) {
const view = new BasicType();
@@ -28,12 +50,125 @@ export class BasicType extends TypeView {
return view;
}
render() {
this.el = this.bindView(this.parent.cloneTemplate(firstType(this.type)), this);
TypeView.prototype.render.call(this);
render() {
// Create container for React component
const div = document.createElement('div');
div.style.width = '100%';
if (!this.root) {
this.root = createRoot(div);
}
this.renderReact();
this.el = div;
return this.el;
}
renderReact() {
if (!this.root) return;
const paramValue = this.parent.model.getParameter(this.name);
const isExprMode = isExpressionParameter(paramValue);
// Get display value - MUST be a primitive, never an object
// Use ParameterValueResolver to defensively handle any value type,
// including expression objects that might slip through during state transitions
const rawValue = isExprMode ? paramValue.fallback : paramValue;
const displayValue = ParameterValueResolver.toString(rawValue);
const props = {
label: this.displayName,
value: displayValue,
inputType: mapTypeToInputType(firstType(this.type)),
properties: undefined, // No special properties needed for basic types
isChanged: !this.isDefault,
isConnected: this.isConnected,
onChange: (value: unknown) => {
// Handle standard value change
if (firstType(this.type) === 'number') {
const numValue = parseFloat(String(value));
this.parent.setParameter(this.name, isNaN(numValue) ? undefined : numValue, {
undo: true,
label: `change ${this.displayName}`
});
} else {
this.parent.setParameter(this.name, value, {
undo: true,
label: `change ${this.displayName}`
});
}
this.isDefault = false;
},
// Expression support
supportsExpression: true,
expressionMode: isExprMode ? ('expression' as const) : ('fixed' as const),
expression: isExprMode ? paramValue.expression : '',
onExpressionModeChange: (mode: 'fixed' | 'expression') => {
const currentParam = this.parent.model.getParameter(this.name);
if (mode === 'expression') {
// Convert to expression parameter
const currentValue = isExpressionParameter(currentParam) ? currentParam.fallback : currentParam;
const exprParam = createExpressionParameter(String(currentValue || ''), currentValue, 1);
this.parent.setParameter(this.name, exprParam, {
undo: true,
label: `enable expression for ${this.displayName}`
});
} else {
// Convert back to fixed value
const fixedValue = isExpressionParameter(currentParam) ? currentParam.fallback : currentParam;
this.parent.setParameter(this.name, fixedValue, {
undo: true,
label: `disable expression for ${this.displayName}`
});
}
this.isDefault = false;
// Re-render to update UI
setTimeout(() => this.renderReact(), 0);
},
onExpressionChange: (expression: string) => {
const currentParam = this.parent.model.getParameter(this.name);
if (isExpressionParameter(currentParam)) {
// Update the expression
this.parent.setParameter(
this.name,
{
...currentParam,
expression
},
{
undo: true,
label: `change ${this.displayName} expression`
}
);
}
this.isDefault = false;
}
};
this.root.render(React.createElement(PropertyPanelInput, props));
}
dispose() {
if (this.root) {
this.root.unmount();
this.root = null;
}
super.dispose();
}
// Legacy method kept for compatibility
onPropertyChanged(scope, el) {
if (firstType(scope.type) === 'number') {
const value = parseFloat(el.val());
@@ -42,7 +177,6 @@ export class BasicType extends TypeView {
this.parent.setParameter(scope.name, el.val());
}
// Update current value and if it is default or not
const current = this.getCurrentValue();
el.val(current.value);
this.isDefault = current.isDefault;

View File

@@ -5,6 +5,7 @@ import { platform } from '@noodl/platform';
import { Keybindings } from '@noodl-constants/Keybindings';
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
import getDocsEndpoint from '@noodl-utils/getDocsEndpoint';
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
import { tracker } from '@noodl-utils/tracker';
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
@@ -22,14 +23,16 @@ export interface NodeLabelProps {
export function NodeLabel({ model, showHelp = true }: NodeLabelProps) {
const labelInputRef = useRef<HTMLInputElement | null>(null);
const [isEditingLabel, setIsEditingLabel] = useState(false);
const [label, setLabel] = useState(model.label);
// Defensive: convert label to string (handles expression parameter objects)
const [label, setLabel] = useState(ParameterValueResolver.toString(model.label));
// Listen for label changes on the model
useEffect(() => {
model.on(
'labelChanged',
() => {
setLabel(model.label);
// Defensive: convert label to string (handles expression parameter objects)
setLabel(ParameterValueResolver.toString(model.label));
},
this
);

View File

@@ -0,0 +1,279 @@
/**
* Expression Parameter Types Tests
*
* Tests type definitions and helper functions for expression-based parameters
*/
import {
ExpressionParameter,
isExpressionParameter,
getParameterDisplayValue,
getParameterActualValue,
createExpressionParameter,
toParameter
} from '../../src/editor/src/models/ExpressionParameter';
describe('Expression Parameter Types', () => {
describe('isExpressionParameter', () => {
it('identifies expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x + 1',
fallback: 0
};
expect(isExpressionParameter(expr)).toBe(true);
});
it('identifies expression without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x'
};
expect(isExpressionParameter(expr)).toBe(true);
});
it('rejects simple values', () => {
expect(isExpressionParameter(42)).toBe(false);
expect(isExpressionParameter('hello')).toBe(false);
expect(isExpressionParameter(true)).toBe(false);
expect(isExpressionParameter(null)).toBe(false);
expect(isExpressionParameter(undefined)).toBe(false);
});
it('rejects objects without mode', () => {
expect(isExpressionParameter({ expression: 'test' })).toBe(false);
});
it('rejects objects with wrong mode', () => {
expect(isExpressionParameter({ mode: 'fixed', value: 42 })).toBe(false);
});
it('rejects objects without expression', () => {
expect(isExpressionParameter({ mode: 'expression' })).toBe(false);
});
it('rejects objects with non-string expression', () => {
expect(isExpressionParameter({ mode: 'expression', expression: 42 })).toBe(false);
});
});
describe('getParameterDisplayValue', () => {
it('returns expression string for expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x * 2',
fallback: 0
};
expect(getParameterDisplayValue(expr)).toBe('Variables.x * 2');
});
it('returns expression even without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.count'
};
expect(getParameterDisplayValue(expr)).toBe('Variables.count');
});
it('returns value as-is for simple values', () => {
expect(getParameterDisplayValue(42)).toBe(42);
expect(getParameterDisplayValue('hello')).toBe('hello');
expect(getParameterDisplayValue(true)).toBe(true);
expect(getParameterDisplayValue(null)).toBe(null);
expect(getParameterDisplayValue(undefined)).toBe(undefined);
});
it('returns value as-is for objects', () => {
const obj = { a: 1, b: 2 };
expect(getParameterDisplayValue(obj)).toBe(obj);
});
});
describe('getParameterActualValue', () => {
it('returns fallback for expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x * 2',
fallback: 100
};
expect(getParameterActualValue(expr)).toBe(100);
});
it('returns undefined for expression without fallback', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x'
};
expect(getParameterActualValue(expr)).toBeUndefined();
});
it('returns value as-is for simple values', () => {
expect(getParameterActualValue(42)).toBe(42);
expect(getParameterActualValue('hello')).toBe('hello');
expect(getParameterActualValue(false)).toBe(false);
});
});
describe('createExpressionParameter', () => {
it('creates expression parameter with all fields', () => {
const expr = createExpressionParameter('Variables.count', 0, 2);
expect(expr.mode).toBe('expression');
expect(expr.expression).toBe('Variables.count');
expect(expr.fallback).toBe(0);
expect(expr.version).toBe(2);
});
it('uses default version if not provided', () => {
const expr = createExpressionParameter('Variables.x', 10);
expect(expr.version).toBe(1);
});
it('allows undefined fallback', () => {
const expr = createExpressionParameter('Variables.x');
expect(expr.fallback).toBeUndefined();
expect(expr.version).toBe(1);
});
it('allows null fallback', () => {
const expr = createExpressionParameter('Variables.x', null);
expect(expr.fallback).toBe(null);
});
it('allows zero as fallback', () => {
const expr = createExpressionParameter('Variables.x', 0);
expect(expr.fallback).toBe(0);
});
it('allows empty string as fallback', () => {
const expr = createExpressionParameter('Variables.x', '');
expect(expr.fallback).toBe('');
});
});
describe('toParameter', () => {
it('passes through expression parameters', () => {
const expr: ExpressionParameter = {
mode: 'expression',
expression: 'Variables.x',
fallback: 0
};
expect(toParameter(expr)).toBe(expr);
});
it('returns simple values as-is', () => {
expect(toParameter(42)).toBe(42);
expect(toParameter('hello')).toBe('hello');
expect(toParameter(true)).toBe(true);
expect(toParameter(null)).toBe(null);
expect(toParameter(undefined)).toBe(undefined);
});
it('returns objects as-is', () => {
const obj = { a: 1 };
expect(toParameter(obj)).toBe(obj);
});
});
describe('Serialization', () => {
it('expression parameters serialize to JSON correctly', () => {
const expr = createExpressionParameter('Variables.count', 10);
const json = JSON.stringify(expr);
const parsed = JSON.parse(json);
expect(parsed.mode).toBe('expression');
expect(parsed.expression).toBe('Variables.count');
expect(parsed.fallback).toBe(10);
expect(parsed.version).toBe(1);
});
it('deserialized expression parameters are recognized', () => {
const json = '{"mode":"expression","expression":"Variables.x","fallback":0,"version":1}';
const parsed = JSON.parse(json);
expect(isExpressionParameter(parsed)).toBe(true);
expect(parsed.expression).toBe('Variables.x');
expect(parsed.fallback).toBe(0);
});
it('handles undefined fallback in serialization', () => {
const expr = createExpressionParameter('Variables.x');
const json = JSON.stringify(expr);
const parsed = JSON.parse(json);
expect(parsed.fallback).toBeUndefined();
expect(isExpressionParameter(parsed)).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('simple values in parameters object work', () => {
const params = {
marginLeft: 16,
color: '#ff0000',
enabled: true
};
expect(isExpressionParameter(params.marginLeft)).toBe(false);
expect(isExpressionParameter(params.color)).toBe(false);
expect(isExpressionParameter(params.enabled)).toBe(false);
});
it('mixed parameters work', () => {
const params = {
marginLeft: createExpressionParameter('Variables.spacing', 16),
marginRight: 8, // Simple value
color: '#ff0000'
};
expect(isExpressionParameter(params.marginLeft)).toBe(true);
expect(isExpressionParameter(params.marginRight)).toBe(false);
expect(isExpressionParameter(params.color)).toBe(false);
});
it('old project parameters load correctly', () => {
// Simulating loading old project
const oldParams = {
width: 200,
height: 100,
text: 'Hello'
};
// None should be expressions
Object.values(oldParams).forEach((value) => {
expect(isExpressionParameter(value)).toBe(false);
});
});
it('new project with expressions loads correctly', () => {
const newParams = {
width: createExpressionParameter('Variables.width', 200),
height: 100, // Mixed: some expression, some not
text: 'Static text'
};
expect(isExpressionParameter(newParams.width)).toBe(true);
expect(isExpressionParameter(newParams.height)).toBe(false);
expect(isExpressionParameter(newParams.text)).toBe(false);
});
});
describe('Edge Cases', () => {
it('handles complex expressions', () => {
const expr = createExpressionParameter('Variables.isAdmin ? "Admin Panel" : "User Panel"', 'User Panel');
expect(expr.expression).toBe('Variables.isAdmin ? "Admin Panel" : "User Panel"');
});
it('handles multi-line expressions', () => {
const multiLine = `Variables.items
.filter(x => x.active)
.length`;
const expr = createExpressionParameter(multiLine, 0);
expect(expr.expression).toBe(multiLine);
});
it('handles expressions with special characters', () => {
const expr = createExpressionParameter('Variables["my-variable"]', null);
expect(expr.expression).toBe('Variables["my-variable"]');
});
});
});

View File

@@ -0,0 +1,387 @@
/**
* Unit tests for ParameterValueResolver
*
* Tests the resolution of parameter values from storage (primitives or expression objects)
* to display/runtime values based on context.
*
* @module noodl-editor/tests/utils
*/
import { describe, it, expect } from '@jest/globals';
import { createExpressionParameter, ExpressionParameter } from '../../src/editor/src/models/ExpressionParameter';
import { ParameterValueResolver, ValueContext } from '../../src/editor/src/utils/ParameterValueResolver';
describe('ParameterValueResolver', () => {
describe('resolve()', () => {
describe('with primitive values', () => {
it('should return string values as-is', () => {
expect(ParameterValueResolver.resolve('hello', ValueContext.Display)).toBe('hello');
expect(ParameterValueResolver.resolve('', ValueContext.Display)).toBe('');
expect(ParameterValueResolver.resolve('123', ValueContext.Display)).toBe('123');
});
it('should return number values as-is', () => {
expect(ParameterValueResolver.resolve(42, ValueContext.Display)).toBe(42);
expect(ParameterValueResolver.resolve(0, ValueContext.Display)).toBe(0);
expect(ParameterValueResolver.resolve(-42.5, ValueContext.Display)).toBe(-42.5);
});
it('should return boolean values as-is', () => {
expect(ParameterValueResolver.resolve(true, ValueContext.Display)).toBe(true);
expect(ParameterValueResolver.resolve(false, ValueContext.Display)).toBe(false);
});
it('should return undefined as-is', () => {
expect(ParameterValueResolver.resolve(undefined, ValueContext.Display)).toBe(undefined);
});
it('should handle null', () => {
expect(ParameterValueResolver.resolve(null, ValueContext.Display)).toBe(null);
});
});
describe('with expression parameters', () => {
it('should extract fallback from expression parameter in Display context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('default');
});
it('should extract fallback from expression parameter in Runtime context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Runtime)).toBe('default');
});
it('should return full object in Serialization context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
const result = ParameterValueResolver.resolve(exprParam, ValueContext.Serialization);
expect(result).toBe(exprParam);
expect((result as ExpressionParameter).mode).toBe('expression');
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('');
});
it('should handle expression parameter with numeric fallback', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe(42);
});
it('should handle expression parameter with boolean fallback', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe(true);
});
it('should handle expression parameter with empty string fallback', () => {
const exprParam = createExpressionParameter('Variables.x', '', 1);
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('');
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Should return as-is since it's not an expression parameter
expect(ParameterValueResolver.resolve(regularObj, ValueContext.Display)).toBe(regularObj);
});
it('should default to fallback for unknown context', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
// Cast to any to test invalid context
expect(ParameterValueResolver.resolve(exprParam, 'invalid' as any)).toBe('default');
});
});
});
describe('toString()', () => {
describe('with primitive values', () => {
it('should convert string to string', () => {
expect(ParameterValueResolver.toString('hello')).toBe('hello');
expect(ParameterValueResolver.toString('')).toBe('');
});
it('should convert number to string', () => {
expect(ParameterValueResolver.toString(42)).toBe('42');
expect(ParameterValueResolver.toString(0)).toBe('0');
expect(ParameterValueResolver.toString(-42.5)).toBe('-42.5');
});
it('should convert boolean to string', () => {
expect(ParameterValueResolver.toString(true)).toBe('true');
expect(ParameterValueResolver.toString(false)).toBe('false');
});
it('should convert undefined to empty string', () => {
expect(ParameterValueResolver.toString(undefined)).toBe('');
});
it('should convert null to empty string', () => {
expect(ParameterValueResolver.toString(null)).toBe('');
});
});
describe('with expression parameters', () => {
it('should extract fallback as string from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.x', 'test', 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('test');
});
it('should convert numeric fallback to string', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('42');
});
it('should convert boolean fallback to string', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('true');
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('');
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('');
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Should return empty string for safety (defensive behavior)
expect(ParameterValueResolver.toString(regularObj)).toBe('');
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toString([1, 2, 3])).toBe('');
});
});
});
describe('toNumber()', () => {
describe('with primitive values', () => {
it('should return number as-is', () => {
expect(ParameterValueResolver.toNumber(42)).toBe(42);
expect(ParameterValueResolver.toNumber(0)).toBe(0);
expect(ParameterValueResolver.toNumber(-42.5)).toBe(-42.5);
});
it('should convert numeric string to number', () => {
expect(ParameterValueResolver.toNumber('42')).toBe(42);
expect(ParameterValueResolver.toNumber('0')).toBe(0);
expect(ParameterValueResolver.toNumber('-42.5')).toBe(-42.5);
});
it('should return undefined for non-numeric string', () => {
expect(ParameterValueResolver.toNumber('hello')).toBe(undefined);
expect(ParameterValueResolver.toNumber('not a number')).toBe(undefined);
});
it('should return undefined for undefined', () => {
expect(ParameterValueResolver.toNumber(undefined)).toBe(undefined);
});
it('should return undefined for null', () => {
expect(ParameterValueResolver.toNumber(null)).toBe(undefined);
});
it('should convert boolean to number', () => {
expect(ParameterValueResolver.toNumber(true)).toBe(1);
expect(ParameterValueResolver.toNumber(false)).toBe(0);
});
});
describe('with expression parameters', () => {
it('should extract numeric fallback from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
});
it('should convert string fallback to number', () => {
const exprParam = createExpressionParameter('Variables.count', '42', 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
});
it('should return undefined for non-numeric fallback', () => {
const exprParam = createExpressionParameter('Variables.text', 'hello', 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
expect(ParameterValueResolver.toNumber(regularObj)).toBe(undefined);
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toNumber([1, 2, 3])).toBe(undefined);
});
it('should handle empty string', () => {
expect(ParameterValueResolver.toNumber('')).toBe(0); // Empty string converts to 0
});
it('should handle whitespace string', () => {
expect(ParameterValueResolver.toNumber(' ')).toBe(0); // Whitespace converts to 0
});
});
});
describe('toBoolean()', () => {
describe('with primitive values', () => {
it('should return boolean as-is', () => {
expect(ParameterValueResolver.toBoolean(true)).toBe(true);
expect(ParameterValueResolver.toBoolean(false)).toBe(false);
});
it('should convert truthy strings to true', () => {
expect(ParameterValueResolver.toBoolean('hello')).toBe(true);
expect(ParameterValueResolver.toBoolean('0')).toBe(true); // Non-empty string is truthy
expect(ParameterValueResolver.toBoolean('false')).toBe(true); // Non-empty string is truthy
});
it('should convert empty string to false', () => {
expect(ParameterValueResolver.toBoolean('')).toBe(false);
});
it('should convert numbers using truthiness', () => {
expect(ParameterValueResolver.toBoolean(1)).toBe(true);
expect(ParameterValueResolver.toBoolean(42)).toBe(true);
expect(ParameterValueResolver.toBoolean(0)).toBe(false);
expect(ParameterValueResolver.toBoolean(-1)).toBe(true);
});
it('should convert undefined to false', () => {
expect(ParameterValueResolver.toBoolean(undefined)).toBe(false);
});
it('should convert null to false', () => {
expect(ParameterValueResolver.toBoolean(null)).toBe(false);
});
});
describe('with expression parameters', () => {
it('should extract boolean fallback from expression parameter', () => {
const exprParam = createExpressionParameter('Variables.flag', true, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(true);
});
it('should convert string fallback to boolean', () => {
const exprParamTruthy = createExpressionParameter('Variables.text', 'hello', 1);
expect(ParameterValueResolver.toBoolean(exprParamTruthy)).toBe(true);
const exprParamFalsy = createExpressionParameter('Variables.text', '', 1);
expect(ParameterValueResolver.toBoolean(exprParamFalsy)).toBe(false);
});
it('should convert numeric fallback to boolean', () => {
const exprParamTruthy = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toBoolean(exprParamTruthy)).toBe(true);
const exprParamFalsy = createExpressionParameter('Variables.count', 0, 1);
expect(ParameterValueResolver.toBoolean(exprParamFalsy)).toBe(false);
});
it('should handle expression parameter with undefined fallback', () => {
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(false);
});
it('should handle expression parameter with null fallback', () => {
const exprParam = createExpressionParameter('Variables.x', null, 1);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(false);
});
});
describe('edge cases', () => {
it('should handle objects that are not expression parameters', () => {
const regularObj = { foo: 'bar' };
// Non-expression objects should return false (defensive behavior)
expect(ParameterValueResolver.toBoolean(regularObj)).toBe(false);
});
it('should handle arrays', () => {
expect(ParameterValueResolver.toBoolean([1, 2, 3])).toBe(false);
});
});
});
describe('isExpression()', () => {
it('should return true for expression parameters', () => {
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
expect(ParameterValueResolver.isExpression(exprParam)).toBe(true);
});
it('should return false for primitive values', () => {
expect(ParameterValueResolver.isExpression('hello')).toBe(false);
expect(ParameterValueResolver.isExpression(42)).toBe(false);
expect(ParameterValueResolver.isExpression(true)).toBe(false);
expect(ParameterValueResolver.isExpression(undefined)).toBe(false);
expect(ParameterValueResolver.isExpression(null)).toBe(false);
});
it('should return false for regular objects', () => {
const regularObj = { foo: 'bar' };
expect(ParameterValueResolver.isExpression(regularObj)).toBe(false);
});
it('should return false for arrays', () => {
expect(ParameterValueResolver.isExpression([1, 2, 3])).toBe(false);
});
});
describe('integration scenarios', () => {
it('should handle converting expression parameter through all type conversions', () => {
const exprParam = createExpressionParameter('Variables.count', 42, 1);
expect(ParameterValueResolver.toString(exprParam)).toBe('42');
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(true);
expect(ParameterValueResolver.isExpression(exprParam)).toBe(true);
});
it('should handle canvas rendering scenario (text.split prevention)', () => {
// This is the actual bug we're fixing - canvas tries to call .split() on a parameter
const exprParam = createExpressionParameter('Variables.text', 'Hello\nWorld', 1);
// Before fix: this would return the object, causing text.split() to crash
// After fix: this returns a string that can be safely split
const text = ParameterValueResolver.toString(exprParam);
expect(typeof text).toBe('string');
expect(() => text.split('\n')).not.toThrow();
expect(text.split('\n')).toEqual(['Hello', 'World']);
});
it('should handle property panel display scenario', () => {
// Property panel needs to show fallback value while user edits expression
const exprParam = createExpressionParameter('2 + 2', '4', 1);
const displayValue = ParameterValueResolver.resolve(exprParam, ValueContext.Display);
expect(displayValue).toBe('4');
});
it('should handle serialization scenario', () => {
// When saving, we need the full object preserved
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
const serialized = ParameterValueResolver.resolve(exprParam, ValueContext.Serialization);
expect(serialized).toBe(exprParam);
expect((serialized as ExpressionParameter).expression).toBe('Variables.x');
});
});
});

View File

@@ -1 +1,2 @@
export * from './ParameterValueResolver.test';
export * from './verify-json.spec';