mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
new code editor
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
279
packages/noodl-editor/tests/models/expression-parameter.test.ts
Normal file
279
packages/noodl-editor/tests/models/expression-parameter.test.ts
Normal 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"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
387
packages/noodl-editor/tests/utils/ParameterValueResolver.test.ts
Normal file
387
packages/noodl-editor/tests/utils/ParameterValueResolver.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from './ParameterValueResolver.test';
|
||||
export * from './verify-json.spec';
|
||||
|
||||
Reference in New Issue
Block a user