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

@@ -1,5 +1,9 @@
import type { StorybookConfig } from '@storybook/react-webpack5';
import path from 'path';
import { fileURLToPath } from 'url';
import type { StorybookConfig } from '@storybook/react-webpack5';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const editorDir = path.join(__dirname, '../../noodl-editor');
const coreLibDir = path.join(__dirname, '../');
@@ -40,7 +44,7 @@ const config: StorybookConfig = {
test: /\.ts$/,
use: [
{
loader: require.resolve('ts-loader')
loader: 'ts-loader'
}
]
});

View File

@@ -34,7 +34,16 @@
]
},
"dependencies": {
"classnames": "^2.5.1"
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.12.1",
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.9",
"classnames": "^2.5.1",
"prismjs": "^1.30.0"
},
"peerDependencies": {
"@noodl/platform": "file:../noodl-platform",
@@ -50,6 +59,7 @@
"@storybook/react-webpack5": "^8.6.14",
"@types/jest": "^27.5.2",
"@types/node": "^16.11.42",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"babel-plugin-named-exports-order": "^0.0.2",

View File

@@ -0,0 +1,67 @@
/**
* CodeHistoryButton Styles
*/
.Root {
position: relative;
}
.Button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-3);
border-color: var(--theme-color-border-highlight);
color: var(--theme-color-fg-highlight);
}
&:active {
transform: translateY(1px);
}
}
.Icon {
width: 16px;
height: 16px;
opacity: 0.8;
}
.Label {
line-height: 1;
}
.Dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 1000;
min-width: 350px;
max-width: 450px;
background: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: slideDown 0.15s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,94 @@
/**
* CodeHistoryButton Component
*
* Displays a history button in the code editor toolbar.
* Opens a dropdown showing code snapshots with diffs.
*
* @module code-editor/CodeHistory
*/
import React, { useState, useRef, useEffect } from 'react';
import css from './CodeHistoryButton.module.scss';
import { CodeHistoryDropdown } from './CodeHistoryDropdown';
import type { CodeSnapshot } from './types';
export interface CodeHistoryButtonProps {
/** Node ID to fetch history for */
nodeId: string;
/** Parameter name (e.g., 'code', 'expression') */
parameterName: string;
/** Current code value */
currentCode: string;
/** Callback when user wants to restore a snapshot */
onRestore: (snapshot: CodeSnapshot) => void;
}
/**
* History button with dropdown
*/
export function CodeHistoryButton({ nodeId, parameterName, currentCode, onRestore }: CodeHistoryButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen) return;
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div className={css.Root}>
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
className={css.Button}
title="View code history"
type="button"
>
<svg className={css.Icon} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M8 4v4l2 2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className={css.Label}>History</span>
</button>
{isOpen && (
<div ref={dropdownRef} className={css.Dropdown}>
<CodeHistoryDropdown
nodeId={nodeId}
parameterName={parameterName}
currentCode={currentCode}
onRestore={(snapshot) => {
onRestore(snapshot);
setIsOpen(false);
}}
onClose={() => setIsOpen(false)}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,326 @@
/**
* CodeHistoryDiffModal Styles
* The KILLER feature - beautiful side-by-side diff comparison
*/
.Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
.Modal {
background: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
width: 90vw;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: scaleIn 0.2s ease;
}
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--theme-color-border-default);
}
.Title {
margin: 0;
font-size: 17px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.CloseButton {
background: none;
border: none;
color: var(--theme-color-fg-default-shy);
font-size: 28px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
}
}
/* Diff Container */
.DiffContainer {
display: flex;
gap: 16px;
padding: 24px;
overflow: hidden;
flex: 1;
min-height: 0;
}
.DiffSide {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
overflow: hidden;
}
.DiffHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
}
.DiffLabel {
font-size: 13px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.DiffInfo {
display: flex;
gap: 8px;
font-size: 12px;
font-weight: 600;
}
.Additions {
color: #4ade80;
}
.Deletions {
color: #f87171;
}
.Modifications {
color: #fbbf24;
}
.DiffCode {
flex: 1;
overflow-y: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.DiffLine {
display: flex;
padding: 2px 0;
min-height: 21px;
transition: background 0.1s ease;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.LineNumber {
flex-shrink: 0;
width: 50px;
padding: 0 12px;
text-align: right;
color: var(--theme-color-fg-default-shy);
user-select: none;
font-size: 12px;
}
.LineContent {
flex: 1;
padding-right: 12px;
white-space: pre;
overflow-x: auto;
color: var(--theme-color-fg-default);
}
/* Diff line states */
.DiffLineAdded {
background: rgba(74, 222, 128, 0.15);
.LineNumber {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
}
.LineContent {
color: #d9f99d;
}
}
.DiffLineRemoved {
background: rgba(248, 113, 113, 0.15);
.LineNumber {
background: rgba(248, 113, 113, 0.2);
color: #f87171;
}
.LineContent {
color: #fecaca;
text-decoration: line-through;
opacity: 0.8;
}
}
.DiffLineModified {
background: rgba(251, 191, 36, 0.12);
.LineNumber {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.LineContent {
color: #fef3c7;
}
}
.DiffLineEmpty {
background: rgba(255, 255, 255, 0.02);
opacity: 0.3;
.LineNumber {
opacity: 0;
}
.LineContent {
opacity: 0;
}
}
/* Separator */
.DiffSeparator {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-color-fg-default-shy);
opacity: 0.6;
}
/* Summary */
.Summary {
padding: 16px 24px;
border-top: 1px solid var(--theme-color-border-default);
border-bottom: 1px solid var(--theme-color-border-default);
background: var(--theme-color-bg-2);
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: var(--theme-color-fg-default-shy);
}
/* Footer */
.Footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
}
.CancelButton {
padding: 10px 20px;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
color: var(--theme-color-fg-default);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-3);
border-color: var(--theme-color-border-highlight);
}
}
.RestoreButton {
padding: 10px 20px;
background: var(--theme-color-primary);
border: 1px solid var(--theme-color-primary);
border-radius: 6px;
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-primary-highlight);
border-color: var(--theme-color-primary-highlight);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
&:active {
transform: translateY(0);
}
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Scrollbar styling */
.DiffCode::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.DiffCode::-webkit-scrollbar-track {
background: var(--theme-color-bg-1);
}
.DiffCode::-webkit-scrollbar-thumb {
background: var(--theme-color-border-default);
border-radius: 4px;
&:hover {
background: var(--theme-color-border-highlight);
}
}

View File

@@ -0,0 +1,177 @@
/**
* CodeHistoryDiffModal Component
*
* Shows a side-by-side diff comparison between code versions.
* This is the KILLER feature - beautiful visual diff with restore confirmation.
*
* @module code-editor/CodeHistory
*/
import React, { useMemo } from 'react';
import { computeDiff, getContextualDiff } from '../utils/codeDiff';
import css from './CodeHistoryDiffModal.module.scss';
export interface CodeHistoryDiffModalProps {
oldCode: string;
newCode: string;
timestamp: string;
onRestore: () => void;
onClose: () => void;
}
// Format timestamp
function formatTimestamp(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const diffMin = Math.floor(diffMs / 1000 / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
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';
} else {
return `${diffDay} days ago`;
}
}
export function CodeHistoryDiffModal({ oldCode, newCode, timestamp, onRestore, onClose }: CodeHistoryDiffModalProps) {
// Compute diff
const diff = useMemo(() => {
const fullDiff = computeDiff(oldCode, newCode);
const contextualLines = getContextualDiff(fullDiff, 3);
return {
full: fullDiff,
lines: contextualLines
};
}, [oldCode, newCode]);
// Split into old and new for side-by-side view
const sideBySide = useMemo(() => {
const oldLines: Array<{ content: string; type: string; lineNumber: number }> = [];
const newLines: Array<{ content: string; type: string; lineNumber: number }> = [];
diff.lines.forEach((line) => {
if (line.type === 'unchanged') {
oldLines.push({ content: line.content, type: 'unchanged', lineNumber: line.lineNumber });
newLines.push({ content: line.content, type: 'unchanged', lineNumber: line.lineNumber });
} else if (line.type === 'removed') {
oldLines.push({ content: line.content, type: 'removed', lineNumber: line.lineNumber });
newLines.push({ content: '', type: 'empty', lineNumber: line.lineNumber });
} else if (line.type === 'added') {
oldLines.push({ content: '', type: 'empty', lineNumber: line.lineNumber });
newLines.push({ content: line.content, type: 'added', lineNumber: line.lineNumber });
} else if (line.type === 'modified') {
oldLines.push({ content: line.oldContent || '', type: 'modified-old', lineNumber: line.lineNumber });
newLines.push({ content: line.newContent || '', type: 'modified-new', lineNumber: line.lineNumber });
}
});
return { oldLines, newLines };
}, [diff.lines]);
return (
<div className={css.Overlay} onClick={onClose}>
<div className={css.Modal} onClick={(e) => e.stopPropagation()}>
<div className={css.Header}>
<h2 className={css.Title}>Restore code from {formatTimestamp(timestamp)}?</h2>
<button onClick={onClose} className={css.CloseButton} type="button" title="Close">
×
</button>
</div>
<div className={css.DiffContainer}>
<div className={css.DiffSide}>
<div className={css.DiffHeader}>
<span className={css.DiffLabel}>{formatTimestamp(timestamp)}</span>
<span className={css.DiffInfo}>
{diff.full.deletions > 0 && <span className={css.Deletions}>-{diff.full.deletions}</span>}
{diff.full.modifications > 0 && <span className={css.Modifications}>~{diff.full.modifications}</span>}
</span>
</div>
<div className={css.DiffCode}>
{sideBySide.oldLines.map((line, index) => (
<div
key={index}
className={`${css.DiffLine} ${
line.type === 'removed'
? css.DiffLineRemoved
: line.type === 'modified-old'
? css.DiffLineModified
: line.type === 'empty'
? css.DiffLineEmpty
: ''
}`}
>
<span className={css.LineNumber}>{line.type !== 'empty' ? line.lineNumber : ''}</span>
<span className={css.LineContent}>{line.content || ' '}</span>
</div>
))}
</div>
</div>
<div className={css.DiffSeparator}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M5 12h14M13 5l7 7-7 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className={css.DiffSide}>
<div className={css.DiffHeader}>
<span className={css.DiffLabel}>Current</span>
<span className={css.DiffInfo}>
{diff.full.additions > 0 && <span className={css.Additions}>+{diff.full.additions}</span>}
{diff.full.modifications > 0 && <span className={css.Modifications}>~{diff.full.modifications}</span>}
</span>
</div>
<div className={css.DiffCode}>
{sideBySide.newLines.map((line, index) => (
<div
key={index}
className={`${css.DiffLine} ${
line.type === 'added'
? css.DiffLineAdded
: line.type === 'modified-new'
? css.DiffLineModified
: line.type === 'empty'
? css.DiffLineEmpty
: ''
}`}
>
<span className={css.LineNumber}>{line.type !== 'empty' ? line.lineNumber : ''}</span>
<span className={css.LineContent}>{line.content || ' '}</span>
</div>
))}
</div>
</div>
</div>
<div className={css.Summary}>
{diff.full.additions > 0 && <span> {diff.full.additions} line(s) will be removed</span>}
{diff.full.deletions > 0 && <span> {diff.full.deletions} line(s) will be added</span>}
{diff.full.modifications > 0 && <span> {diff.full.modifications} line(s) will change</span>}
</div>
<div className={css.Footer}>
<button onClick={onClose} className={css.CancelButton} type="button">
Cancel
</button>
<button onClick={onRestore} className={css.RestoreButton} type="button">
Restore Code
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
/**
* CodeHistoryDropdown Styles
*/
.Root {
display: flex;
flex-direction: column;
max-height: 500px;
}
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
}
.Title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.CloseButton {
background: none;
border: none;
color: var(--theme-color-fg-default-shy);
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
}
}
.List {
overflow-y: auto;
max-height: 400px;
padding: 8px;
}
.Item {
padding: 12px;
border-radius: 6px;
margin-bottom: 4px;
border: 1px solid transparent;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-bg-2);
border-color: var(--theme-color-border-default);
}
}
.ItemCurrent {
background: var(--theme-color-primary);
color: white;
opacity: 0.9;
&:hover {
background: var(--theme-color-primary);
opacity: 1;
}
.ItemIcon {
color: white;
}
.ItemTime {
color: white;
font-weight: 600;
}
}
.ItemHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.ItemIcon {
font-size: 16px;
width: 20px;
text-align: center;
color: var(--theme-color-fg-default-shy);
}
.ItemTime {
font-size: 13px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
.ItemSummary {
font-size: 12px;
color: var(--theme-color-fg-default-shy);
margin-left: 28px;
margin-bottom: 8px;
}
.ItemActions {
display: flex;
gap: 8px;
margin-left: 28px;
}
.PreviewButton {
padding: 4px 12px;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--theme-color-primary);
border-color: var(--theme-color-primary);
color: white;
}
}
/* Empty state */
.Empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
}
.EmptyIcon {
color: var(--theme-color-fg-default-shy);
opacity: 0.5;
margin-bottom: 16px;
}
.EmptyText {
margin: 0 0 8px 0;
font-size: 15px;
font-weight: 600;
color: var(--theme-color-fg-default);
}
.EmptyHint {
margin: 0;
font-size: 13px;
color: var(--theme-color-fg-default-shy);
max-width: 280px;
}

View File

@@ -0,0 +1,172 @@
/**
* CodeHistoryDropdown Component
*
* Shows a list of code snapshots with preview and restore functionality.
*
* @module code-editor/CodeHistory
*/
import React, { useState, useMemo } from 'react';
import { computeDiff, getDiffSummary } from '../utils/codeDiff';
import { CodeHistoryDiffModal } from './CodeHistoryDiffModal';
import css from './CodeHistoryDropdown.module.scss';
import type { CodeSnapshot } from './types';
export interface CodeHistoryDropdownProps {
nodeId: string;
parameterName: string;
currentCode: string;
onRestore: (snapshot: CodeSnapshot) => void;
onClose: () => void;
}
// Format timestamp to human-readable format
function 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 {
return then.toLocaleDateString() + ' at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
export function CodeHistoryDropdown({
nodeId,
parameterName,
currentCode,
onRestore,
onClose
}: CodeHistoryDropdownProps) {
const [selectedSnapshot, setSelectedSnapshot] = useState<CodeSnapshot | null>(null);
const [history, setHistory] = useState<CodeSnapshot[]>([]);
// Load history on mount
React.useEffect(() => {
// Dynamically import CodeHistoryManager to avoid circular dependencies
// This allows noodl-core-ui to access noodl-editor functionality
import('@noodl-models/CodeHistoryManager')
.then(({ CodeHistoryManager }) => {
const historyData = CodeHistoryManager.instance.getHistory(nodeId, parameterName);
setHistory(historyData);
})
.catch((error) => {
console.warn('Could not load CodeHistoryManager:', error);
setHistory([]);
});
}, [nodeId, parameterName]);
// Compute diffs for all snapshots (newest first)
const snapshotsWithDiffs = useMemo(() => {
return history
.slice() // Don't mutate original
.reverse() // Newest first
.map((snapshot) => {
const diff = computeDiff(snapshot.code, currentCode);
const summary = getDiffSummary(diff);
return {
snapshot,
diff,
summary
};
});
}, [history, currentCode]);
if (history.length === 0) {
return (
<div className={css.Root}>
<div className={css.Header}>
<h3 className={css.Title}>Code History</h3>
<button onClick={onClose} className={css.CloseButton} type="button" title="Close">
×
</button>
</div>
<div className={css.Empty}>
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" className={css.EmptyIcon}>
<path
d="M24 42c9.941 0 18-8.059 18-18S33.941 6 24 6 6 14.059 6 24s8.059 18 18 18z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M24 14v12l6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className={css.EmptyText}>No history yet</p>
<p className={css.EmptyHint}>Code snapshots are saved automatically when you save changes.</p>
</div>
</div>
);
}
return (
<>
<div className={css.Root}>
<div className={css.Header}>
<h3 className={css.Title}>Code History</h3>
<button onClick={onClose} className={css.CloseButton} type="button" title="Close">
×
</button>
</div>
<div className={css.List}>
{/* Historical snapshots (newest first) */}
{snapshotsWithDiffs.map(({ snapshot, diff, summary }, index) => (
<div key={snapshot.timestamp} className={css.Item}>
<div className={css.ItemHeader}>
<span className={css.ItemIcon}></span>
<span className={css.ItemTime}>{formatTimestamp(snapshot.timestamp)}</span>
</div>
<div className={css.ItemSummary}>{summary.description}</div>
<div className={css.ItemActions}>
<button
onClick={() => setSelectedSnapshot(snapshot)}
className={css.PreviewButton}
type="button"
title="Preview changes"
>
Preview
</button>
</div>
</div>
))}
</div>
</div>
{/* Diff Modal */}
{selectedSnapshot && (
<CodeHistoryDiffModal
oldCode={selectedSnapshot.code}
newCode={currentCode}
timestamp={selectedSnapshot.timestamp}
onRestore={() => {
onRestore(selectedSnapshot);
setSelectedSnapshot(null);
}}
onClose={() => setSelectedSnapshot(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Code History Components
*
* Exports code history components for use in code editors.
*
* @module code-editor/CodeHistory
*/
export { CodeHistoryButton } from './CodeHistoryButton';
export { CodeHistoryDropdown } from './CodeHistoryDropdown';
export { CodeHistoryDiffModal } from './CodeHistoryDiffModal';
export type { CodeSnapshot } from './types';

View File

@@ -0,0 +1,14 @@
/**
* Shared types for Code History components
*
* @module code-editor/CodeHistory
*/
/**
* A single code snapshot
*/
export interface CodeSnapshot {
code: string;
timestamp: string; // ISO 8601 format
hash: string; // For deduplication
}

View File

@@ -0,0 +1,187 @@
/**
* JavaScriptEditor Component Styles
* Uses design tokens for consistency with OpenNoodl design system
*/
.Root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: var(--theme-color-bg-1);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
overflow: hidden;
}
/* Toolbar */
.Toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
min-height: 36px;
}
.ToolbarLeft,
.ToolbarRight {
display: flex;
align-items: center;
gap: 12px;
}
.ModeLabel {
font-size: 12px;
font-weight: 600;
color: var(--theme-color-fg-default);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.StatusValid {
font-size: 12px;
font-weight: 500;
color: var(--theme-color-success);
}
.StatusInvalid {
font-size: 12px;
font-weight: 500;
color: var(--theme-color-error);
}
.FormatButton,
.SaveButton {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-primary);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.SaveButton {
background-color: var(--theme-color-primary);
color: white;
border-color: var(--theme-color-primary);
&:hover:not(:disabled) {
background-color: var(--theme-color-primary-hover, var(--theme-color-primary));
border-color: var(--theme-color-primary-hover, var(--theme-color-primary));
}
}
/* Editor Container with CodeMirror */
.EditorContainer {
flex: 1;
overflow: hidden;
position: relative;
background-color: var(--theme-color-bg-2);
/* CodeMirror will fill this container */
:global(.cm-editor) {
height: 100%;
font-family: var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace);
}
:global(.cm-scroller) {
overflow: auto;
}
}
/* Error Panel */
.ErrorPanel {
padding: 12px 16px;
background-color: #fef2f2;
border-top: 1px solid #fecaca;
}
.ErrorHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.ErrorIcon {
font-size: 18px;
line-height: 1;
}
.ErrorTitle {
font-size: 13px;
font-weight: 600;
color: #dc2626;
}
.ErrorMessage {
font-size: 13px;
color: #7c2d12;
line-height: 1.5;
font-family: var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace);
padding: 8px 12px;
background-color: #fff;
border: 1px solid #fecaca;
border-radius: 4px;
margin-bottom: 8px;
}
.ErrorSuggestion {
font-size: 12px;
color: #7c2d12;
line-height: 1.4;
padding: 8px 12px;
background-color: #fef3c7;
border: 1px solid #fde68a;
border-radius: 4px;
margin-bottom: 8px;
strong {
font-weight: 600;
}
}
.ErrorLocation {
font-size: 11px;
color: #92400e;
font-family: var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace);
font-weight: 500;
}
/* Footer with resize grip */
.Footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background-color: var(--theme-color-bg-2);
border-top: 1px solid var(--theme-color-border-default);
min-height: 24px;
flex-shrink: 0;
}
.FooterLeft,
.FooterRight {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,176 @@
/**
* Storybook Stories for JavaScriptEditor
*
* Demonstrates all validation modes and features
*/
import type { Meta, StoryObj } from '@storybook/react';
import React, { useState } from 'react';
import { JavaScriptEditor } from './JavaScriptEditor';
const meta: Meta<typeof JavaScriptEditor> = {
title: 'Code Editor/JavaScriptEditor',
component: JavaScriptEditor,
parameters: {
layout: 'padded'
},
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof JavaScriptEditor>;
/**
* Interactive wrapper for stories
*/
function InteractiveEditor(props: React.ComponentProps<typeof JavaScriptEditor>) {
const [value, setValue] = useState(props.value || '');
return (
<div style={{ width: '800px', height: '500px' }}>
<JavaScriptEditor {...props} value={value} onChange={setValue} />
</div>
);
}
/**
* Expression validation mode
* Used for Expression nodes - validates as a JavaScript expression
*/
export const ExpressionMode: Story = {
render: () => (
<InteractiveEditor value="a + b" validationType="expression" placeholder="Enter a JavaScript expression..." />
)
};
/**
* Function validation mode
* Used for Function nodes - validates as a function body
*/
export const FunctionMode: Story = {
render: () => (
<InteractiveEditor
value={`// Calculate sum
const sum = inputs.a + inputs.b;
outputs.result = sum;`}
validationType="function"
placeholder="Enter JavaScript function code..."
/>
)
};
/**
* Script validation mode
* Used for Script nodes - validates as JavaScript statements
*/
export const ScriptMode: Story = {
render: () => (
<InteractiveEditor
value={`console.log('Script running');
const value = 42;
return value;`}
validationType="script"
placeholder="Enter JavaScript script code..."
/>
)
};
/**
* Invalid expression
* Shows error display and validation
*/
export const InvalidExpression: Story = {
render: () => <InteractiveEditor value="a + + b" validationType="expression" />
};
/**
* Invalid function
* Missing closing brace
*/
export const InvalidFunction: Story = {
render: () => (
<InteractiveEditor
value={`function test() {
console.log('missing closing brace');
// Missing }`}
validationType="function"
/>
)
};
/**
* With onSave callback
* Shows Save button and handles Ctrl+S
*/
export const WithSaveCallback: Story = {
render: () => {
const [savedValue, setSavedValue] = useState('');
return (
<div>
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#f0f0f0', borderRadius: '4px' }}>
<strong>Last saved:</strong> {savedValue || '(not saved yet)'}
</div>
<InteractiveEditor
value="a + b"
validationType="expression"
onSave={(code) => {
setSavedValue(code);
alert(`Saved: ${code}`);
}}
/>
</div>
);
}
};
/**
* Disabled state
*/
export const Disabled: Story = {
render: () => <InteractiveEditor value="a + b" validationType="expression" disabled={true} />
};
/**
* Custom height
*/
export const CustomHeight: Story = {
render: () => (
<div style={{ width: '800px' }}>
<JavaScriptEditor
value={`// Small editor
const x = 1;`}
onChange={() => {}}
validationType="function"
height={200}
/>
</div>
)
};
/**
* Complex function example
* Real-world usage scenario
*/
export const ComplexFunction: Story = {
render: () => (
<InteractiveEditor
value={`// Process user data
const name = inputs.firstName + ' ' + inputs.lastName;
const age = inputs.age;
if (age >= 18) {
outputs.category = 'adult';
outputs.message = 'Welcome, ' + name;
} else {
outputs.category = 'minor';
outputs.message = 'Hello, ' + name;
}
outputs.displayName = name;
outputs.isValid = true;`}
validationType="function"
/>
)
};

View File

@@ -0,0 +1,334 @@
/**
* JavaScriptEditor Component
*
* A feature-rich JavaScript code editor powered by CodeMirror 6.
* Includes syntax highlighting, autocompletion, linting, and all IDE features.
*
* @module code-editor
*/
import { EditorView } from '@codemirror/view';
import { useDragHandler } from '@noodl-hooks/useDragHandler';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { ToolbarGrip } from '@noodl-core-ui/components/toolbar/ToolbarGrip';
import { CodeHistoryButton, type CodeSnapshot } from './CodeHistory';
import { createEditorState, createExtensions } from './codemirror-extensions';
import css from './JavaScriptEditor.module.scss';
import { formatJavaScript } from './utils/jsFormatter';
import { validateJavaScript } from './utils/jsValidator';
import { JavaScriptEditorProps } from './utils/types';
/**
* Main JavaScriptEditor Component
*/
export function JavaScriptEditor({
value,
onChange,
onSave,
validationType = 'expression',
disabled = false,
height,
width,
placeholder = '// Enter your JavaScript code here',
nodeId,
parameterName
}: JavaScriptEditorProps) {
const rootRef = useRef<HTMLDivElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(null);
// Generation counter approach to prevent race conditions
// Replaces the unreliable isInternalChangeRef + setTimeout pattern
const changeGenerationRef = useRef(0);
const lastSyncedGenerationRef = useRef(0);
// Only store validation state (needed for display outside editor)
// Don't store localValue - CodeMirror is the single source of truth
const [validation, setValidation] = useState(validateJavaScript(value || '', validationType));
// Resize support - convert width/height to numbers
const initialWidth = typeof width === 'number' ? width : typeof width === 'string' ? parseInt(width, 10) : 800;
const initialHeight = typeof height === 'number' ? height : typeof height === 'string' ? parseInt(height, 10) : 500;
const [size, setSize] = useState<{ width: number; height: number }>({
width: initialWidth,
height: initialHeight
});
const { startDrag } = useDragHandler({
root: rootRef,
minHeight: 200,
minWidth: 400,
onDrag(contentWidth, contentHeight) {
setSize({
width: contentWidth,
height: contentHeight
});
},
onEndDrag() {
editorViewRef.current?.focus();
}
});
// Handle text changes from CodeMirror
const handleChange = useCallback(
(newValue: string) => {
// Increment generation counter for every internal change
// This prevents race conditions with external value syncing
changeGenerationRef.current++;
// Validate the new code
const result = validateJavaScript(newValue, validationType);
setValidation(result);
// Propagate changes to parent
if (onChange) {
onChange(newValue);
}
// No setTimeout needed - generation counter handles sync safely
},
[onChange, validationType]
);
// Handle format button
const handleFormat = useCallback(() => {
if (!editorViewRef.current) return;
try {
const currentCode = editorViewRef.current.state.doc.toString();
const formatted = formatJavaScript(currentCode);
// Increment generation counter for programmatic changes
changeGenerationRef.current++;
// Update CodeMirror with formatted code
editorViewRef.current.dispatch({
changes: {
from: 0,
to: editorViewRef.current.state.doc.length,
insert: formatted
}
});
if (onChange) {
onChange(formatted);
}
// No setTimeout needed
} catch (error) {
console.error('Format error:', error);
}
}, [onChange]);
// Initialize CodeMirror editor
useEffect(() => {
if (!editorContainerRef.current) return;
// Create extensions
const extensions = createExtensions({
validationType,
placeholder,
readOnly: disabled,
onChange: handleChange,
onSave,
tabSize: 2
});
// Create editor state
const state = createEditorState(value || '', extensions);
// Create editor view
const view = new EditorView({
state,
parent: editorContainerRef.current
});
editorViewRef.current = view;
// Cleanup on unmount
return () => {
view.destroy();
editorViewRef.current = null;
};
// Only run on mount - we handle updates separately
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update editor when external value changes (but NOT from internal typing)
useEffect(() => {
if (!editorViewRef.current) return;
// Skip if internal changes have happened since last sync
// This prevents race conditions from auto-complete, fold, etc.
if (changeGenerationRef.current > lastSyncedGenerationRef.current) {
return;
}
const currentValue = editorViewRef.current.state.doc.toString();
// Only update if value actually changed from external source
if (currentValue !== value) {
// Update synced generation to current
lastSyncedGenerationRef.current = changeGenerationRef.current;
// Preserve cursor position during external update
const currentSelection = editorViewRef.current.state.selection;
editorViewRef.current.dispatch({
changes: {
from: 0,
to: editorViewRef.current.state.doc.length,
insert: value || ''
},
// Try to preserve selection if it's still valid
selection: currentSelection.ranges[0].to <= (value || '').length ? currentSelection : undefined
});
setValidation(validateJavaScript(value || '', validationType));
}
}, [value, validationType]);
// Update read-only state
useEffect(() => {
if (!editorViewRef.current) return;
editorViewRef.current.dispatch({
effects: [
// Note: This requires reconfiguring the editor
// For now, we handle it on initial mount
]
});
}, [disabled]);
// Get validation mode label
const getModeLabel = () => {
switch (validationType) {
case 'expression':
return 'Expression';
case 'function':
return 'Function';
case 'script':
return 'Script';
default:
return 'JavaScript';
}
};
return (
<div
ref={rootRef}
className={css['Root']}
style={{
width: size.width,
height: size.height,
minWidth: 400,
minHeight: 200
}}
>
{/* Toolbar */}
<div className={css['Toolbar']}>
<div className={css['ToolbarLeft']}>
<span className={css['ModeLabel']}>{getModeLabel()}</span>
{validation.valid ? (
<span className={css['StatusValid']}> Valid</span>
) : (
<span className={css['StatusInvalid']}> Error</span>
)}
</div>
<div className={css['ToolbarRight']}>
{/* History button - only show if nodeId and parameterName provided */}
{nodeId && parameterName && (
<CodeHistoryButton
nodeId={nodeId}
parameterName={parameterName}
currentCode={editorViewRef.current?.state.doc.toString() || value || ''}
onRestore={(snapshot: CodeSnapshot) => {
if (!editorViewRef.current) return;
// Increment generation counter for restore operation
changeGenerationRef.current++;
// Restore code from snapshot
editorViewRef.current.dispatch({
changes: {
from: 0,
to: editorViewRef.current.state.doc.length,
insert: snapshot.code
}
});
if (onChange) {
onChange(snapshot.code);
}
// No setTimeout needed
// Don't auto-save - let user manually save if they want to keep the restored version
// This prevents creating duplicate snapshots
}}
/>
)}
<button
onClick={handleFormat}
disabled={disabled}
className={css['FormatButton']}
title="Format code"
type="button"
>
Format
</button>
{onSave && (
<button
onClick={() => {
const currentCode = editorViewRef.current?.state.doc.toString() || '';
onSave(currentCode);
}}
disabled={disabled}
className={css['SaveButton']}
title="Save (Ctrl+S)"
type="button"
>
Save
</button>
)}
</div>
</div>
{/* CodeMirror Editor Container */}
<div ref={editorContainerRef} className={css['EditorContainer']} />
{/* Validation Errors */}
{!validation.valid && (
<div className={css['ErrorPanel']}>
<div className={css['ErrorHeader']}>
<span className={css['ErrorIcon']}></span>
<span className={css['ErrorTitle']}>Syntax Error</span>
</div>
<div className={css['ErrorMessage']}>{validation.error}</div>
{validation.suggestion && (
<div className={css['ErrorSuggestion']}>
<strong>💡 Suggestion:</strong> {validation.suggestion}
</div>
)}
{validation.line !== undefined && (
<div className={css['ErrorLocation']}>
Line {validation.line}
{validation.column !== undefined && `, Column ${validation.column}`}
</div>
)}
</div>
)}
{/* Footer with resize grip */}
<div className={css['Footer']}>
<div className={css['FooterLeft']}></div>
<div className={css['FooterRight']}>
<ToolbarGrip onMouseDown={startDrag} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,437 @@
/**
* CodeMirror Extensions Configuration
*
* Configures all CodeMirror extensions and features including:
* - Language support (JavaScript)
* - Autocompletion
* - Search/replace
* - Code folding
* - Linting
* - Custom keybindings
* - Bracket colorization
* - Indent guides
* - And more...
*
* @module code-editor
*/
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap, indentWithTab, redo, undo, toggleComment } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import {
bracketMatching,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
defaultHighlightStyle
} from '@codemirror/language';
import { lintGutter, linter, type Diagnostic } from '@codemirror/lint';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { EditorSelection, EditorState, Extension, StateEffect, StateField, type Range } from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
keymap,
lineNumbers,
placeholder as placeholderExtension,
rectangularSelection,
ViewPlugin,
ViewUpdate,
Decoration,
DecorationSet
} from '@codemirror/view';
import { createOpenNoodlTheme } from './codemirror-theme';
import { noodlCompletionSource } from './noodl-completions';
import { validateJavaScript } from './utils/jsValidator';
/**
* Options for creating CodeMirror extensions
*/
export interface ExtensionOptions {
/** Validation type (expression, function, script) */
validationType?: 'expression' | 'function' | 'script';
/** Placeholder text */
placeholder?: string;
/** Is editor read-only? */
readOnly?: boolean;
/** onChange callback */
onChange?: (value: string) => void;
/** onSave callback (Cmd+S) */
onSave?: (value: string) => void;
/** Tab size (default: 2) */
tabSize?: number;
}
/**
* Indent guides extension
* Draws vertical lines to show indentation levels
*/
function indentGuides(): Extension {
const indentGuideDeco = Decoration.line({
attributes: { class: 'cm-indent-guide' }
});
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const decorations: Range<Decoration>[] = [];
const tabSize = view.state.tabSize;
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to; ) {
const line = view.state.doc.lineAt(pos);
const text = line.text;
// Count leading spaces/tabs
let indent = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === ' ') indent++;
else if (text[i] === '\t') indent += tabSize;
else break;
}
// Add decoration if line has indentation
if (indent > 0) {
decorations.push(indentGuideDeco.range(line.from));
}
pos = line.to + 1;
}
}
return Decoration.set(decorations);
}
},
{
decorations: (v) => v.decorations
}
);
}
/**
* Custom Enter key handler for better brace/bracket handling
*/
function handleEnterKey(view: EditorView): boolean {
const { state } = view;
const { selection } = state;
// Only handle if single cursor
if (selection.ranges.length !== 1) {
return false;
}
const range = selection.main;
if (!range.empty) {
return false; // Has selection, use default behavior
}
const pos = range.from;
const line = state.doc.lineAt(pos);
const before = state.sliceDoc(line.from, pos);
// Check if cursor is between matching brackets/braces
const beforeChar = state.sliceDoc(Math.max(0, pos - 1), pos);
const afterChar = state.sliceDoc(pos, Math.min(state.doc.length, pos + 1));
const matchingPairs: Record<string, string> = {
'{': '}',
'[': ']',
'(': ')'
};
// If between matching pair (e.g., {|})
if (matchingPairs[beforeChar] === afterChar) {
// Calculate indentation
const indent = before.match(/^\s*/)?.[0] || '';
const indentSize = state.tabSize;
const newIndent = indent + ' '.repeat(indentSize);
// Insert newline with indentation, then another newline with original indentation
view.dispatch({
changes: {
from: pos,
to: pos,
insert: '\n' + newIndent + '\n' + indent
},
selection: { anchor: pos + 1 + newIndent.length }
});
return true; // Handled
}
// Default behavior
return false;
}
/**
* Move line up command (Alt+↑)
*/
function moveLineUp(view: EditorView): boolean {
const { state } = view;
const changes = state.changeByRange((range) => {
const line = state.doc.lineAt(range.from);
if (line.number === 1) return { range }; // Can't move first line up
const prevLine = state.doc.line(line.number - 1);
const lineText = state.doc.sliceString(line.from, line.to);
const prevLineText = state.doc.sliceString(prevLine.from, prevLine.to);
return {
changes: [
{ from: prevLine.from, to: prevLine.to, insert: lineText },
{ from: line.from, to: line.to, insert: prevLineText }
],
range: EditorSelection.range(prevLine.from, prevLine.from + lineText.length)
};
});
view.dispatch(changes);
return true;
}
/**
* Move line down command (Alt+↓)
*/
function moveLineDown(view: EditorView): boolean {
const { state } = view;
const changes = state.changeByRange((range) => {
const line = state.doc.lineAt(range.from);
if (line.number === state.doc.lines) return { range }; // Can't move last line down
const nextLine = state.doc.line(line.number + 1);
const lineText = state.doc.sliceString(line.from, line.to);
const nextLineText = state.doc.sliceString(nextLine.from, nextLine.to);
return {
changes: [
{ from: line.from, to: line.to, insert: nextLineText },
{ from: nextLine.from, to: nextLine.to, insert: lineText }
],
range: EditorSelection.range(nextLine.from, nextLine.from + lineText.length)
};
});
view.dispatch(changes);
return true;
}
/**
* Create custom keybindings
*/
function customKeybindings(options: ExtensionOptions) {
return keymap.of([
// Custom Enter key handler (before default keymap)
{
key: 'Enter',
run: handleEnterKey
},
// Standard keymaps
// REMOVED: closeBracketsKeymap (was intercepting closing brackets)
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
// Tab key for indentation (not focus change)
indentWithTab,
// Comment toggle (Cmd+/)
{
key: 'Mod-/',
run: toggleComment
},
// Move lines up/down (Alt+↑/↓)
{
key: 'Alt-ArrowUp',
run: moveLineUp
},
{
key: 'Alt-ArrowDown',
run: moveLineDown
},
// Save (Cmd+S)
...(options.onSave
? [
{
key: 'Mod-s',
preventDefault: true,
run: (view: EditorView) => {
options.onSave?.(view.state.doc.toString());
return true;
}
}
]
: []),
// Undo/Redo (ensure they work)
{
key: 'Mod-z',
run: undo
},
{
key: 'Mod-Shift-z',
run: redo
},
{
key: 'Mod-y',
mac: 'Mod-Shift-z',
run: redo
}
]);
}
/**
* Create a linter from our validation function
*/
function createLinter(validationType: 'expression' | 'function' | 'script') {
return linter((view) => {
const code = view.state.doc.toString();
const validation = validateJavaScript(code, validationType);
if (validation.valid) {
return [];
}
const diagnostics: Diagnostic[] = [];
// Calculate position from line/column
let from = 0;
let to = code.length;
if (validation.line !== undefined) {
const lines = code.split('\n');
const lineIndex = validation.line - 1;
if (lineIndex >= 0 && lineIndex < lines.length) {
// Calculate character position of the line
from = lines.slice(0, lineIndex).reduce((sum, line) => sum + line.length + 1, 0);
if (validation.column !== undefined) {
from += validation.column;
to = from + 1; // Highlight just one character
} else {
to = from + lines[lineIndex].length;
}
}
}
diagnostics.push({
from: Math.max(0, from),
to: Math.min(code.length, to),
severity: 'error',
message: validation.error || 'Syntax error'
});
return diagnostics;
});
}
/**
* Create all CodeMirror extensions
*/
export function createExtensions(options: ExtensionOptions = {}): Extension[] {
const {
validationType = 'expression',
placeholder = '// Enter your JavaScript code here',
readOnly = false,
onChange,
tabSize = 2
} = options;
// Adding extensions back one by one to find the culprit
const extensions: Extension[] = [
// 1. Language support
javascript(),
// 2. Theme
createOpenNoodlTheme(),
// 3. Custom keybindings with Enter handler
customKeybindings(options),
// 4. Essential UI
lineNumbers(),
history(),
// 5. Visual enhancements (Group 1 - SAFE ✅)
highlightActiveLineGutter(),
highlightActiveLine(),
drawSelection(),
dropCursor(),
rectangularSelection(),
// 6. Bracket & selection features (Group 2 - SAFE ✅)
bracketMatching(),
highlightSelectionMatches(),
placeholderExtension(placeholder),
EditorView.lineWrapping,
// 7. Complex features (tested safe)
foldGutter({
openText: '▼',
closedText: '▶'
}),
autocompletion({
activateOnTyping: true,
maxRenderedOptions: 10,
defaultKeymap: true,
override: [noodlCompletionSource]
}),
// 8. Tab size
EditorState.tabSize.of(tabSize),
// 9. Read-only mode
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
// 10. onChange handler
...(onChange
? [
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
})
]
: [])
// ALL EXTENSIONS NOW ENABLED (except closeBrackets/indentOnInput)
// closeBrackets() - PERMANENTLY DISABLED (conflicted with custom Enter handler)
// closeBracketsKeymap - PERMANENTLY REMOVED (intercepted closing brackets)
// indentOnInput() - PERMANENTLY DISABLED (not needed with our custom handler)
];
return extensions;
}
/**
* Helper to create a basic CodeMirror state
*/
export function createEditorState(initialValue: string, extensions: Extension[]): EditorState {
return EditorState.create({
doc: initialValue,
extensions
});
}

View File

@@ -0,0 +1,338 @@
/**
* CodeMirror Theme for OpenNoodl
*
* Custom theme matching OpenNoodl design tokens and VSCode Dark+ colors.
* Provides syntax highlighting, UI colors, and visual feedback.
*
* @module code-editor
*/
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
/**
* Create the OpenNoodl editor theme
*/
export function createOpenNoodlTheme(): Extension {
// Editor theme (UI elements)
const editorTheme = EditorView.theme(
{
// Main editor
'&': {
backgroundColor: 'var(--theme-color-bg-2)',
color: 'var(--theme-color-fg-default)',
fontSize: '13px',
fontFamily: "var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace)",
lineHeight: '1.6'
},
// Content area
'.cm-content': {
caretColor: 'var(--theme-color-fg-default)',
padding: '16px 0'
},
// Cursor
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--theme-color-fg-default)',
borderLeftWidth: '2px'
},
// Selection
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'rgba(86, 156, 214, 0.3)'
},
// Active line
'.cm-activeLine': {
backgroundColor: 'rgba(255, 255, 255, 0.05)'
},
// Line numbers gutter
'.cm-gutters': {
backgroundColor: 'var(--theme-color-bg-3)',
color: 'var(--theme-color-fg-muted)',
border: 'none',
borderRight: '1px solid var(--theme-color-border-default)',
minWidth: '35px'
},
'.cm-gutter': {
minWidth: '35px'
},
'.cm-lineNumbers': {
minWidth: '35px'
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 8px 0 6px',
textAlign: 'right',
minWidth: '35px'
},
// Active line number
'.cm-activeLineGutter': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
color: 'var(--theme-color-fg-default)'
},
// Fold gutter
'.cm-foldGutter': {
width: '20px',
padding: '0 4px'
},
'.cm-foldGutter .cm-gutterElement': {
textAlign: 'center',
cursor: 'pointer'
},
'.cm-foldPlaceholder': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
color: 'var(--theme-color-fg-muted)',
borderRadius: '3px',
padding: '0 6px',
margin: '0 4px'
},
// Search panel
'.cm-panel': {
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
padding: '8px'
},
'.cm-panel.cm-search': {
padding: '8px 12px'
},
'.cm-searchMatch': {
backgroundColor: 'rgba(255, 215, 0, 0.3)',
outline: '1px solid rgba(255, 215, 0, 0.5)'
},
'.cm-searchMatch-selected': {
backgroundColor: 'rgba(255, 165, 0, 0.4)',
outline: '1px solid rgba(255, 165, 0, 0.7)'
},
// Highlight selection matches
'.cm-selectionMatch': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
outline: '1px solid rgba(255, 255, 255, 0.2)'
},
// Matching brackets
'.cm-matchingBracket': {
backgroundColor: 'rgba(255, 255, 255, 0.15)',
outline: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '2px'
},
'.cm-nonmatchingBracket': {
backgroundColor: 'rgba(255, 0, 0, 0.2)',
outline: '1px solid rgba(255, 0, 0, 0.4)'
},
// Autocomplete panel
'.cm-tooltip-autocomplete': {
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
overflow: 'hidden',
fontFamily: "var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace)",
fontSize: '13px'
},
'.cm-tooltip-autocomplete ul': {
maxHeight: '300px',
overflowY: 'auto'
},
'.cm-tooltip-autocomplete ul li': {
padding: '6px 12px',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer'
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: 'var(--theme-color-primary)',
color: 'white'
},
'.cm-completionIcon': {
width: '1em',
marginRight: '8px',
fontSize: '14px',
lineHeight: '1'
},
// Lint markers (errors/warnings)
'.cm-lintRange-error': {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='3'%3E%3Cpath d='m0 3 l3 -3 l3 3' stroke='%23ef4444' fill='none' stroke-width='.7'/%3E%3C/svg%3E\")",
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left bottom',
paddingBottom: '3px'
},
'.cm-lintRange-warning': {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='3'%3E%3Cpath d='m0 3 l3 -3 l3 3' stroke='%23f59e0b' fill='none' stroke-width='.7'/%3E%3C/svg%3E\")",
backgroundRepeat: 'repeat-x',
backgroundPosition: 'left bottom',
paddingBottom: '3px'
},
'.cm-lint-marker-error': {
content: '●',
color: '#ef4444'
},
'.cm-lint-marker-warning': {
content: '●',
color: '#f59e0b'
},
// Hover tooltips
'.cm-tooltip': {
backgroundColor: 'var(--theme-color-bg-4)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: '4px',
padding: '6px 10px',
color: 'var(--theme-color-fg-default)',
fontSize: '12px',
maxWidth: '400px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)'
},
'.cm-tooltip-lint': {
fontFamily: "var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace)"
},
// Placeholder
'.cm-placeholder': {
color: 'var(--theme-color-fg-muted)',
opacity: 0.6
},
// Indent guides (will be added via custom extension)
'.cm-indent-guide': {
position: 'absolute',
top: '0',
bottom: '0',
width: '1px',
backgroundColor: 'rgba(255, 255, 255, 0.1)'
},
// Scroller
'.cm-scroller': {
overflow: 'auto',
fontFamily: "var(--theme-font-mono, 'Monaco', 'Menlo', 'Courier New', monospace)"
},
// Focused state
'&.cm-focused': {
outline: 'none'
}
},
{ dark: true }
);
// Syntax highlighting theme (token colors)
const syntaxTheme = HighlightStyle.define([
// Keywords (if, for, function, return, etc.)
{ tag: t.keyword, color: '#569cd6', fontWeight: 'bold' },
// Control keywords (if, else, switch, case)
{ tag: t.controlKeyword, color: '#c586c0', fontWeight: 'bold' },
// Definition keywords (function, class, const, let, var)
{ tag: t.definitionKeyword, color: '#569cd6', fontWeight: 'bold' },
// Module keywords (import, export)
{ tag: t.moduleKeyword, color: '#c586c0', fontWeight: 'bold' },
// Operator keywords (typeof, instanceof, new, delete)
{ tag: t.operatorKeyword, color: '#569cd6', fontWeight: 'bold' },
// Comments
{ tag: t.comment, color: '#6a9955', fontStyle: 'italic' },
{ tag: t.lineComment, color: '#6a9955', fontStyle: 'italic' },
{ tag: t.blockComment, color: '#6a9955', fontStyle: 'italic' },
// Strings
{ tag: t.string, color: '#ce9178' },
{ tag: t.special(t.string), color: '#d16969' },
// Numbers
{ tag: t.number, color: '#b5cea8' },
{ tag: t.integer, color: '#b5cea8' },
{ tag: t.float, color: '#b5cea8' },
// Booleans
{ tag: t.bool, color: '#569cd6', fontWeight: 'bold' },
// Null/Undefined
{ tag: t.null, color: '#569cd6', fontWeight: 'bold' },
// Variables
{ tag: t.variableName, color: '#9cdcfe' },
{ tag: t.local(t.variableName), color: '#9cdcfe' },
{ tag: t.definition(t.variableName), color: '#9cdcfe' },
// Functions
{ tag: t.function(t.variableName), color: '#dcdcaa' },
{ tag: t.function(t.propertyName), color: '#dcdcaa' },
// Properties
{ tag: t.propertyName, color: '#9cdcfe' },
{ tag: t.special(t.propertyName), color: '#4fc1ff' },
// Operators
{ tag: t.operator, color: '#d4d4d4' },
{ tag: t.arithmeticOperator, color: '#d4d4d4' },
{ tag: t.logicOperator, color: '#d4d4d4' },
{ tag: t.compareOperator, color: '#d4d4d4' },
// Punctuation
{ tag: t.punctuation, color: '#d4d4d4' },
{ tag: t.separator, color: '#d4d4d4' },
{ tag: t.paren, color: '#ffd700' }, // Gold for ()
{ tag: t.bracket, color: '#87ceeb' }, // Sky blue for []
{ tag: t.brace, color: '#98fb98' }, // Pale green for {}
{ tag: t.squareBracket, color: '#87ceeb' },
{ tag: t.angleBracket, color: '#dda0dd' },
// Types (for TypeScript/JSDoc)
{ tag: t.typeName, color: '#4ec9b0' },
{ tag: t.className, color: '#4ec9b0' },
{ tag: t.namespace, color: '#4ec9b0' },
// Special identifiers (self keyword)
{ tag: t.self, color: '#569cd6', fontWeight: 'bold' },
// Regular expressions
{ tag: t.regexp, color: '#d16969' },
// Invalid/Error
{ tag: t.invalid, color: '#f44747', textDecoration: 'underline' },
// Meta
{ tag: t.meta, color: '#808080' },
// Escape sequences
{ tag: t.escape, color: '#d7ba7d' },
// Labels
{ tag: t.labelName, color: '#c8c8c8' }
]);
return [editorTheme, syntaxHighlighting(syntaxTheme)];
}

View File

@@ -0,0 +1,14 @@
/**
* JavaScriptEditor Component
*
* A feature-rich JavaScript code editor powered by CodeMirror 6.
* Includes syntax highlighting, autocompletion, linting, code folding,
* and all modern IDE features for Expression, Function, and Script nodes.
*
* @module code-editor
*/
export { JavaScriptEditor } from './JavaScriptEditor';
export type { JavaScriptEditorProps, ValidationType, ValidationResult } from './utils/types';
export { validateJavaScript } from './utils/jsValidator';
export { formatJavaScript } from './utils/jsFormatter';

View File

@@ -0,0 +1,109 @@
/**
* Noodl-Specific Autocomplete
*
* Provides intelligent code completion for Noodl's global API:
* - Noodl.Variables, Noodl.Objects, Noodl.Arrays
* - Inputs, Outputs, State, Props (node context)
* - Math helpers (min, max, cos, sin, etc.)
*
* @module code-editor
*/
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { syntaxTree } from '@codemirror/language';
/**
* Noodl API structure completions
*/
const noodlCompletions = [
// Noodl global API
{ label: 'Noodl.Variables', type: 'property', info: 'Access global variables' },
{ label: 'Noodl.Objects', type: 'property', info: 'Access objects from model scope' },
{ label: 'Noodl.Arrays', type: 'property', info: 'Access arrays from model scope' },
// Shorthand versions
{ label: 'Variables', type: 'property', info: 'Shorthand for Noodl.Variables' },
{ label: 'Objects', type: 'property', info: 'Shorthand for Noodl.Objects' },
{ label: 'Arrays', type: 'property', info: 'Shorthand for Noodl.Arrays' },
// Node context (for Expression/Function nodes)
{ label: 'Inputs', type: 'property', info: 'Access node input values' },
{ label: 'Outputs', type: 'property', info: 'Set node output values' },
{ label: 'State', type: 'property', info: 'Access component state' },
{ label: 'Props', type: 'property', info: 'Access component props' },
// Math helpers
{ label: 'min', type: 'function', info: 'Math.min - Return smallest value' },
{ label: 'max', type: 'function', info: 'Math.max - Return largest value' },
{ label: 'cos', type: 'function', info: 'Math.cos - Cosine function' },
{ label: 'sin', type: 'function', info: 'Math.sin - Sine function' },
{ label: 'tan', type: 'function', info: 'Math.tan - Tangent function' },
{ label: 'sqrt', type: 'function', info: 'Math.sqrt - Square root' },
{ label: 'pi', type: 'constant', info: 'Math.PI - The pi constant (3.14159...)' },
{ label: 'round', type: 'function', info: 'Math.round - Round to nearest integer' },
{ label: 'floor', type: 'function', info: 'Math.floor - Round down' },
{ label: 'ceil', type: 'function', info: 'Math.ceil - Round up' },
{ label: 'abs', type: 'function', info: 'Math.abs - Absolute value' },
{ label: 'random', type: 'function', info: 'Math.random - Random number 0-1' },
{ label: 'pow', type: 'function', info: 'Math.pow - Power function' },
{ label: 'log', type: 'function', info: 'Math.log - Natural logarithm' },
{ label: 'exp', type: 'function', info: 'Math.exp - e to the power of x' }
];
/**
* Get the word before the cursor
*/
function wordBefore(context: CompletionContext): { from: number; to: number; text: string } | null {
const word = context.matchBefore(/\w*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
return word;
}
/**
* Get completions for after "Noodl."
*/
function getNoodlPropertyCompletions(): CompletionResult {
return {
from: 0, // Will be set by caller
options: [
{ label: 'Variables', type: 'property', info: 'Access global variables' },
{ label: 'Objects', type: 'property', info: 'Access objects from model scope' },
{ label: 'Arrays', type: 'property', info: 'Access arrays from model scope' }
]
};
}
/**
* Main Noodl completion source
*/
export function noodlCompletionSource(context: CompletionContext): CompletionResult | null {
const word = wordBefore(context);
if (!word) return null;
// Check if we're after "Noodl."
const textBefore = context.state.doc.sliceString(Math.max(0, word.from - 6), word.from);
if (textBefore.endsWith('Noodl.')) {
const result = getNoodlPropertyCompletions();
result.from = word.from;
return result;
}
// Check if we're typing "Noodl" itself
if (word.text.toLowerCase().startsWith('nood')) {
return {
from: word.from,
options: [{ label: 'Noodl', type: 'namespace', info: 'Noodl global namespace' }]
};
}
// General completions (always available)
const filtered = noodlCompletions.filter((c) => c.label.toLowerCase().startsWith(word.text.toLowerCase()));
if (filtered.length === 0) return null;
return {
from: word.from,
options: filtered
};
}

View File

@@ -0,0 +1,279 @@
/**
* Code Diff Utilities
*
* Computes line-based diffs between code snippets for history visualization.
* Uses a simplified Myers diff algorithm.
*
* @module code-editor/utils
*/
export type DiffLineType = 'unchanged' | 'added' | 'removed' | 'modified';
export interface DiffLine {
type: DiffLineType;
lineNumber: number;
content: string;
oldContent?: string; // For modified lines
newContent?: string; // For modified lines
}
export interface DiffResult {
lines: DiffLine[];
additions: number;
deletions: number;
modifications: number;
}
export interface DiffSummary {
additions: number;
deletions: number;
modifications: number;
description: string;
}
/**
* Compute a diff between two code snippets
*/
export function computeDiff(oldCode: string, newCode: string): DiffResult {
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
const diff = simpleDiff(oldLines, newLines);
return {
lines: diff,
additions: diff.filter((l) => l.type === 'added').length,
deletions: diff.filter((l) => l.type === 'removed').length,
modifications: diff.filter((l) => l.type === 'modified').length
};
}
/**
* Get a human-readable summary of changes
*/
export function getDiffSummary(diff: DiffResult): DiffSummary {
const { additions, deletions, modifications } = diff;
let description = '';
const parts: string[] = [];
if (additions > 0) {
parts.push(`+${additions} line${additions === 1 ? '' : 's'}`);
}
if (deletions > 0) {
parts.push(`-${deletions} line${deletions === 1 ? '' : 's'}`);
}
if (modifications > 0) {
parts.push(`~${modifications} modified`);
}
if (parts.length === 0) {
description = 'No changes';
} else if (additions + deletions + modifications > 10) {
description = 'Major refactor';
} else if (modifications > additions && modifications > deletions) {
description = 'Modified: ' + parts.join(', ');
} else {
description = 'Changed: ' + parts.join(', ');
}
return {
additions,
deletions,
modifications,
description
};
}
/**
* Simplified diff algorithm
* Uses Longest Common Subsequence (LCS) approach
*/
function simpleDiff(oldLines: string[], newLines: string[]): DiffLine[] {
const result: DiffLine[] = [];
// Compute LCS matrix
const lcs = computeLCS(oldLines, newLines);
// Backtrack through LCS to build diff (builds in reverse)
let i = oldLines.length;
let j = newLines.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
// Lines are identical
result.unshift({
type: 'unchanged',
lineNumber: 0, // Will assign later
content: oldLines[i - 1]
});
i--;
j--;
} else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
// Line added in new version
result.unshift({
type: 'added',
lineNumber: 0, // Will assign later
content: newLines[j - 1]
});
j--;
} else if (i > 0 && (j === 0 || lcs[i][j - 1] < lcs[i - 1][j])) {
// Line removed from old version
result.unshift({
type: 'removed',
lineNumber: 0, // Will assign later
content: oldLines[i - 1]
});
i--;
}
}
// Post-process to detect modifications (adjacent add/remove pairs)
const processed = detectModifications(result);
// Assign sequential line numbers (ascending order)
let lineNumber = 1;
processed.forEach((line) => {
line.lineNumber = lineNumber++;
});
return processed;
}
/**
* Detect modified lines (pairs of removed + added lines)
*/
function detectModifications(lines: DiffLine[]): DiffLine[] {
const result: DiffLine[] = [];
for (let i = 0; i < lines.length; i++) {
const current = lines[i];
const next = lines[i + 1];
// Check if we have a removed line followed by an added line
if (current.type === 'removed' && next && next.type === 'added') {
// This is likely a modification
const similarity = calculateSimilarity(current.content, next.content);
// If lines are somewhat similar (>30% similar), treat as modification
if (similarity > 0.3) {
result.push({
type: 'modified',
lineNumber: current.lineNumber,
content: next.content,
oldContent: current.content,
newContent: next.content
});
i++; // Skip next line (we processed it)
continue;
}
}
result.push(current);
}
return result;
}
/**
* Compute Longest Common Subsequence (LCS) matrix
*/
function computeLCS(a: string[], b: string[]): number[][] {
const m = a.length;
const n = b.length;
const lcs: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
lcs[i][j] = lcs[i - 1][j - 1] + 1;
} else {
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
}
}
}
return lcs;
}
/**
* Calculate similarity between two strings (0 to 1)
* Uses simple character overlap metric
*/
function calculateSimilarity(a: string, b: string): number {
if (a === b) return 1;
if (a.length === 0 || b.length === 0) return 0;
// Count matching characters (case insensitive, ignoring whitespace)
const aNorm = a.toLowerCase().replace(/\s+/g, '');
const bNorm = b.toLowerCase().replace(/\s+/g, '');
const shorter = aNorm.length < bNorm.length ? aNorm : bNorm;
const longer = aNorm.length >= bNorm.length ? aNorm : bNorm;
let matches = 0;
for (let i = 0; i < shorter.length; i++) {
if (longer.includes(shorter[i])) {
matches++;
}
}
return matches / longer.length;
}
/**
* Format diff for display - returns context-aware subset of lines
* Shows changes with 3 lines of context before/after
*/
export function getContextualDiff(diff: DiffResult, contextLines = 3): DiffLine[] {
const { lines } = diff;
// Find all changed lines
const changedIndices = lines
.map((line, index) => (line.type !== 'unchanged' ? index : -1))
.filter((index) => index !== -1);
if (changedIndices.length === 0) {
// No changes, return first few lines
return lines.slice(0, Math.min(10, lines.length));
}
// Determine ranges to include (changes + context)
const ranges: Array<[number, number]> = [];
for (const index of changedIndices) {
const start = Math.max(0, index - contextLines);
const end = Math.min(lines.length - 1, index + contextLines);
// Merge overlapping ranges
if (ranges.length > 0) {
const lastRange = ranges[ranges.length - 1];
if (start <= lastRange[1] + 1) {
lastRange[1] = Math.max(lastRange[1], end);
continue;
}
}
ranges.push([start, end]);
}
// Extract lines from ranges
const result: DiffLine[] = [];
for (let i = 0; i < ranges.length; i++) {
const [start, end] = ranges[i];
// Add separator if not first range
if (i > 0 && start - ranges[i - 1][1] > 1) {
result.push({
type: 'unchanged',
lineNumber: -1,
content: '...'
});
}
result.push(...lines.slice(start, end + 1));
}
return result;
}

View File

@@ -0,0 +1,101 @@
/**
* JavaScript Formatting Utilities
*
* Simple indentation and formatting for JavaScript code.
* Not a full formatter, just basic readability improvements.
*
* @module code-editor/utils
*/
/**
* Format JavaScript code with basic indentation
*
* This is a simple formatter that:
* - Adds indentation after opening braces
* - Removes indentation after closing braces
* - Adds newlines for readability
*
* Not perfect, but good enough for small code snippets.
*/
export function formatJavaScript(code: string): string {
if (!code || code.trim() === '') {
return code;
}
let formatted = '';
let indentLevel = 0;
const indentSize = 2; // 2 spaces per indent
let inString = false;
let stringChar = '';
// Remove existing whitespace for consistent formatting
const trimmed = code.trim();
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
const prevChar = i > 0 ? trimmed[i - 1] : '';
const nextChar = i < trimmed.length - 1 ? trimmed[i + 1] : '';
// Track string state to avoid formatting inside strings
if ((char === '"' || char === "'" || char === '`') && prevChar !== '\\') {
if (!inString) {
inString = true;
stringChar = char;
} else if (char === stringChar) {
inString = false;
stringChar = '';
}
}
// Don't format inside strings
if (inString) {
formatted += char;
continue;
}
// Handle opening brace
if (char === '{') {
formatted += char;
indentLevel++;
if (nextChar !== '}') {
formatted += '\n' + ' '.repeat(indentLevel * indentSize);
}
continue;
}
// Handle closing brace
if (char === '}') {
indentLevel = Math.max(0, indentLevel - 1);
// Add newline before closing brace if there's content before it
if (prevChar !== '{' && prevChar !== '\n') {
formatted += '\n' + ' '.repeat(indentLevel * indentSize);
}
formatted += char;
continue;
}
// Handle semicolon (add newline after)
if (char === ';') {
formatted += char;
if (nextChar && nextChar !== '\n' && nextChar !== '}') {
formatted += '\n' + ' '.repeat(indentLevel * indentSize);
}
continue;
}
// Skip multiple consecutive spaces/newlines
if ((char === ' ' || char === '\n') && (prevChar === ' ' || prevChar === '\n')) {
continue;
}
// Replace newlines with properly indented newlines
if (char === '\n') {
formatted += '\n' + ' '.repeat(indentLevel * indentSize);
continue;
}
formatted += char;
}
return formatted.trim();
}

View File

@@ -0,0 +1,125 @@
/**
* JavaScript Validation Utilities
*
* Validates JavaScript code using the Function constructor.
* This catches syntax errors without needing a full parser.
*
* @module code-editor/utils
*/
import { ValidationResult, ValidationType } from './types';
/**
* Extract line and column from error message
*/
function parseErrorLocation(error: Error): { line?: number; column?: number } {
const message = error.message;
// Try to extract line number from various error formats
const lineMatch = message.match(/line (\d+)/i);
const posMatch = message.match(/position (\d+)/i);
return {
line: lineMatch ? parseInt(lineMatch[1], 10) : undefined,
column: posMatch ? parseInt(posMatch[1], 10) : undefined
};
}
/**
* Get helpful suggestion based on error message
*/
function getSuggestion(error: Error): string | undefined {
const message = error.message.toLowerCase();
if (message.includes('unexpected token') || message.includes('unexpected identifier')) {
return 'Check for missing or extra brackets, parentheses, or quotes';
}
if (message.includes('unexpected end of input')) {
return 'You may be missing a closing bracket or parenthesis';
}
if (message.includes('unexpected string') || message.includes("unexpected ','")) {
return 'Check for missing operators or commas between values';
}
if (message.includes('missing') && message.includes('after')) {
return 'Check the syntax around the indicated position';
}
return undefined;
}
/**
* Validate JavaScript expression
* Wraps code in `return ()` to validate as expression
*/
function validateExpression(code: string): ValidationResult {
if (!code || code.trim() === '') {
return { valid: true };
}
try {
// Try to create a function that returns the expression
// This validates that it's a valid JavaScript expression
new Function(`return (${code});`);
return { valid: true };
} catch (error) {
const location = parseErrorLocation(error as Error);
return {
valid: false,
error: (error as Error).message,
suggestion: getSuggestion(error as Error),
...location
};
}
}
/**
* Validate JavaScript function body
* Creates a function with the code as body
*/
function validateFunction(code: string): ValidationResult {
if (!code || code.trim() === '') {
return { valid: true };
}
try {
// Create a function with the code as body
new Function(code);
return { valid: true };
} catch (error) {
const location = parseErrorLocation(error as Error);
return {
valid: false,
error: (error as Error).message,
suggestion: getSuggestion(error as Error),
...location
};
}
}
/**
* Validate JavaScript script
* Same as function validation for our purposes
*/
function validateScript(code: string): ValidationResult {
return validateFunction(code);
}
/**
* Main validation function
* Validates JavaScript code based on validation type
*/
export function validateJavaScript(code: string, validationType: ValidationType = 'expression'): ValidationResult {
switch (validationType) {
case 'expression':
return validateExpression(code);
case 'function':
return validateFunction(code);
case 'script':
return validateScript(code);
default:
return { valid: false, error: 'Unknown validation type' };
}
}

View File

@@ -0,0 +1,47 @@
/**
* Type definitions for JavaScriptEditor
*
* @module code-editor/utils
*/
export type ValidationType = 'expression' | 'function' | 'script';
export interface ValidationResult {
valid: boolean;
error?: string;
suggestion?: string;
line?: number;
column?: number;
}
export interface JavaScriptEditorProps {
/** Current code value */
value: string;
/** Callback when code changes */
onChange?: (value: string) => void;
/** Callback when user saves (Ctrl+S or Save button) */
onSave?: (value: string) => void;
/** Validation type */
validationType?: ValidationType;
/** Disable the editor */
disabled?: boolean;
/** Width of the editor */
width?: number | string;
/** Height of the editor */
height?: number | string;
/** Placeholder text */
placeholder?: string;
/** Node ID for history tracking (optional) */
nodeId?: string;
/** Parameter name for history tracking (optional) */
parameterName?: string;
}

View File

@@ -0,0 +1,64 @@
.Root {
display: flex;
align-items: center;
gap: 6px;
background-color: var(--theme-color-bg-3, rgba(99, 102, 241, 0.05));
border: 1px solid var(--theme-color-border-default, rgba(99, 102, 241, 0.2));
border-radius: 4px;
padding: 4px 8px;
flex: 1;
transition: all 0.15s ease;
&:focus-within {
border-color: var(--theme-color-primary, #6366f1);
background-color: var(--theme-color-bg-2, rgba(99, 102, 241, 0.08));
}
&.HasError {
border-color: var(--theme-color-error, #ef4444);
background-color: var(--theme-color-bg-2, rgba(239, 68, 68, 0.05));
}
}
.Badge {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 10px;
font-weight: 600;
color: var(--theme-color-primary, #6366f1);
padding: 2px 4px;
background-color: var(--theme-color-bg-2, rgba(99, 102, 241, 0.15));
border-radius: 2px;
flex-shrink: 0;
user-select: none;
}
.Input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 12px;
color: var(--theme-color-fg-default, #ffffff);
padding: 0;
min-width: 0;
&::placeholder {
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.4));
opacity: 1;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.ErrorIndicator {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--theme-color-error, #ef4444);
cursor: help;
}

View File

@@ -0,0 +1,170 @@
import { Meta, StoryFn } from '@storybook/react';
import React, { useState } from 'react';
import { ExpressionInput, ExpressionInputProps } from './ExpressionInput';
export default {
title: 'Property Panel/Expression Input',
component: ExpressionInput,
argTypes: {
hasError: {
control: 'boolean'
},
placeholder: {
control: 'text'
},
debounceMs: {
control: 'number'
}
}
} as Meta<typeof ExpressionInput>;
const Template: StoryFn<ExpressionInputProps> = (args) => {
const [expression, setExpression] = useState(args.expression);
return (
<div style={{ padding: '20px', maxWidth: '400px' }}>
<ExpressionInput {...args} expression={expression} onChange={setExpression} />
<div style={{ marginTop: '12px', fontSize: '12px', opacity: 0.6 }}>
Current value: <code>{expression}</code>
</div>
</div>
);
};
export const Default = Template.bind({});
Default.args = {
expression: 'Variables.x * 2',
hasError: false,
placeholder: 'Enter expression...'
};
export const Empty = Template.bind({});
Empty.args = {
expression: '',
hasError: false,
placeholder: 'Enter expression...'
};
export const WithError = Template.bind({});
WithError.args = {
expression: 'invalid syntax +',
hasError: true,
errorMessage: 'Syntax error: Unexpected token +',
placeholder: 'Enter expression...'
};
export const LongExpression = Template.bind({});
LongExpression.args = {
expression: 'Variables.isAdmin ? "Administrator Panel" : Variables.isModerator ? "Moderator Panel" : "User Panel"',
hasError: false,
placeholder: 'Enter expression...'
};
export const InteractiveDemo: StoryFn<ExpressionInputProps> = () => {
const [expression, setExpression] = useState('Variables.count');
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handleChange = (newExpression: string) => {
setExpression(newExpression);
// Simple validation: check for unmatched parentheses
const openParens = (newExpression.match(/\(/g) || []).length;
const closeParens = (newExpression.match(/\)/g) || []).length;
if (openParens !== closeParens) {
setHasError(true);
setErrorMessage('Unmatched parentheses');
} else if (newExpression.includes('++') || newExpression.includes('--')) {
setHasError(true);
setErrorMessage('Increment/decrement operators not supported');
} else {
setHasError(false);
setErrorMessage('');
}
};
return (
<div style={{ padding: '20px', maxWidth: '600px' }}>
<h3 style={{ marginTop: 0 }}>Expression Input with Validation</h3>
<p style={{ fontSize: '14px', opacity: 0.8 }}>Try typing expressions. The input validates in real-time.</p>
<div style={{ marginTop: '20px' }}>
<ExpressionInput
expression={expression}
onChange={handleChange}
hasError={hasError}
errorMessage={errorMessage}
/>
</div>
<div
style={{
marginTop: '20px',
padding: '16px',
backgroundColor: hasError ? '#fee' : '#efe',
borderRadius: '4px',
fontSize: '13px'
}}
>
{hasError ? (
<>
<strong style={{ color: '#c00' }}>Error:</strong> {errorMessage}
</>
) : (
<>
<strong style={{ color: '#080' }}>Valid expression</strong>
</>
)}
</div>
<div style={{ marginTop: '20px', fontSize: '12px' }}>
<h4>Try these examples:</h4>
<ul style={{ lineHeight: '1.8' }}>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleChange('Variables.x + Variables.y')}
>
Variables.x + Variables.y
</code>
</li>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleChange('Variables.count * 2')}
>
Variables.count * 2
</code>
</li>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleChange('Math.max(Variables.a, Variables.b)')}
>
Math.max(Variables.a, Variables.b)
</code>
</li>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleChange('Variables.items.filter(x => x.active).length')}
>
Variables.items.filter(x =&gt; x.active).length
</code>
</li>
<li>
<code
style={{ cursor: 'pointer', textDecoration: 'underline', color: '#c00' }}
onClick={() => handleChange('invalid syntax (')}
>
invalid syntax (
</code>{' '}
<em>(causes error)</em>
</li>
</ul>
</div>
</div>
);
};

View File

@@ -0,0 +1,148 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import { UnsafeStyleProps } from '@noodl-core-ui/types/global';
import css from './ExpressionInput.module.scss';
export interface ExpressionInputProps extends UnsafeStyleProps {
/** The expression string */
expression: string;
/** Callback when expression changes (debounced) */
onChange: (expression: string) => void;
/** Callback when input loses focus */
onBlur?: () => void;
/** Whether the expression has an error */
hasError?: boolean;
/** Error message to show in tooltip */
errorMessage?: string;
/** Placeholder text */
placeholder?: string;
/** Test ID for automation */
testId?: string;
/** Debounce delay in milliseconds */
debounceMs?: number;
}
/**
* ExpressionInput
*
* A specialized input field for entering JavaScript expressions.
* Features monospace font, "fx" badge, and error indication.
*
* @example
* ```tsx
* <ExpressionInput
* expression="Variables.x * 2"
* onChange={(expr) => updateExpression(expr)}
* hasError={false}
* />
* ```
*/
export function ExpressionInput({
expression,
onChange,
onBlur,
hasError = false,
errorMessage,
placeholder = 'Enter expression...',
testId,
debounceMs = 300,
UNSAFE_className,
UNSAFE_style
}: ExpressionInputProps) {
const [localValue, setLocalValue] = useState(expression);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// Update local value when prop changes
useEffect(() => {
setLocalValue(expression);
}, [expression]);
// Debounced onChange handler
const debouncedOnChange = useCallback(
(value: string) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
onChange(value);
}, debounceMs);
},
[onChange, debounceMs]
);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
debouncedOnChange(newValue);
};
const handleBlur = () => {
// Cancel debounce and apply immediately on blur
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (localValue !== expression) {
onChange(localValue);
}
onBlur?.();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
// Apply immediately on Enter
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
onChange(localValue);
e.currentTarget.blur();
}
};
return (
<div
className={`${css['Root']} ${hasError ? css['HasError'] : ''} ${UNSAFE_className || ''}`}
style={UNSAFE_style}
data-test={testId}
>
<span className={css['Badge']}>fx</span>
<input
type="text"
className={css['Input']}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
/>
{hasError && errorMessage && (
<Tooltip content={errorMessage}>
<div className={css['ErrorIndicator']}>
<Icon icon={IconName.WarningCircle} size={IconSize.Tiny} />
</div>
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { ExpressionInput } from './ExpressionInput';
export type { ExpressionInputProps } from './ExpressionInput';

View File

@@ -0,0 +1,28 @@
.Root {
display: flex;
align-items: center;
justify-content: center;
}
.ExpressionActive {
background-color: var(--theme-color-primary, #6366f1);
color: white;
&:hover {
background-color: var(--theme-color-primary-hover, #4f46e5);
}
&:active {
background-color: var(--theme-color-primary-active, #4338ca);
}
}
.ConnectionIndicator {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
opacity: 0.5;
cursor: default;
}

View File

@@ -0,0 +1,100 @@
import { Meta, StoryFn } from '@storybook/react';
import React, { useState } from 'react';
import { ExpressionToggle, ExpressionToggleProps } from './ExpressionToggle';
export default {
title: 'Property Panel/Expression Toggle',
component: ExpressionToggle,
argTypes: {
mode: {
control: { type: 'radio' },
options: ['fixed', 'expression']
},
isConnected: {
control: 'boolean'
},
isDisabled: {
control: 'boolean'
}
}
} as Meta<typeof ExpressionToggle>;
const Template: StoryFn<ExpressionToggleProps> = (args) => {
const [mode, setMode] = useState<'fixed' | 'expression'>(args.mode);
const handleToggle = () => {
setMode((prevMode) => (prevMode === 'fixed' ? 'expression' : 'fixed'));
};
return <ExpressionToggle {...args} mode={mode} onToggle={handleToggle} />;
};
export const FixedMode = Template.bind({});
FixedMode.args = {
mode: 'fixed',
isConnected: false,
isDisabled: false
};
export const ExpressionMode = Template.bind({});
ExpressionMode.args = {
mode: 'expression',
isConnected: false,
isDisabled: false
};
export const Connected = Template.bind({});
Connected.args = {
mode: 'fixed',
isConnected: true,
isDisabled: false
};
export const Disabled = Template.bind({});
Disabled.args = {
mode: 'fixed',
isConnected: false,
isDisabled: true
};
export const InteractiveDemo: StoryFn<ExpressionToggleProps> = () => {
const [mode, setMode] = useState<'fixed' | 'expression'>('fixed');
const [isConnected, setIsConnected] = useState(false);
const handleToggle = () => {
setMode((prevMode) => (prevMode === 'fixed' ? 'expression' : 'fixed'));
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ width: '120px' }}>Normal Toggle:</span>
<ExpressionToggle mode={mode} isConnected={false} onToggle={handleToggle} />
<span style={{ opacity: 0.6, fontSize: '12px' }}>
Current mode: <strong>{mode}</strong>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ width: '120px' }}>Connected:</span>
<ExpressionToggle mode={mode} isConnected={true} onToggle={handleToggle} />
<span style={{ opacity: 0.6, fontSize: '12px' }}>Shows connection indicator</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ width: '120px' }}>Disabled:</span>
<ExpressionToggle mode={mode} isConnected={false} isDisabled={true} onToggle={handleToggle} />
<span style={{ opacity: 0.6, fontSize: '12px' }}>Cannot be clicked</span>
</div>
<div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<h4 style={{ margin: '0 0 8px 0' }}>Simulate Connection:</h4>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={isConnected} onChange={(e) => setIsConnected(e.target.checked)} />
<span>Port is connected via cable</span>
</label>
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import { UnsafeStyleProps } from '@noodl-core-ui/types/global';
import css from './ExpressionToggle.module.scss';
export interface ExpressionToggleProps extends UnsafeStyleProps {
/** Current mode: 'fixed' for static values, 'expression' for dynamic expressions */
mode: 'fixed' | 'expression';
/** Whether the port is connected via a cable (disables expression toggle) */
isConnected?: boolean;
/** Callback when toggle is clicked */
onToggle: () => void;
/** Whether the toggle is disabled */
isDisabled?: boolean;
/** Test ID for automation */
testId?: string;
}
/**
* ExpressionToggle
*
* Toggle button that switches a property between fixed value mode and expression mode.
* Shows a connection indicator when the port is connected via cable.
*
* @example
* ```tsx
* <ExpressionToggle
* mode="fixed"
* onToggle={() => setMode(mode === 'fixed' ? 'expression' : 'fixed')}
* />
* ```
*/
export function ExpressionToggle({
mode,
isConnected = false,
onToggle,
isDisabled = false,
testId,
UNSAFE_className,
UNSAFE_style
}: ExpressionToggleProps) {
// If connected via cable, show connection indicator instead of toggle
if (isConnected) {
return (
<Tooltip content="Connected via cable">
<div className={css['ConnectionIndicator']} data-test={testId} style={UNSAFE_style}>
<Icon icon={IconName.Link} size={IconSize.Tiny} />
</div>
</Tooltip>
);
}
const isExpressionMode = mode === 'expression';
const tooltipContent = isExpressionMode ? 'Switch to fixed value' : 'Switch to expression';
const icon = isExpressionMode ? IconName.Code : IconName.MagicWand;
const variant = isExpressionMode ? IconButtonVariant.Default : IconButtonVariant.OpaqueOnHover;
return (
<Tooltip content={tooltipContent}>
<div className={css['Root']} style={UNSAFE_style}>
<IconButton
icon={icon}
size={IconSize.Tiny}
variant={variant}
onClick={onToggle}
isDisabled={isDisabled}
testId={testId}
UNSAFE_className={isExpressionMode ? css['ExpressionActive'] : UNSAFE_className}
/>
</div>
</Tooltip>
);
}

View File

@@ -0,0 +1,2 @@
export { ExpressionToggle } from './ExpressionToggle';
export type { ExpressionToggleProps } from './ExpressionToggle';

View File

@@ -1,16 +1,33 @@
import classNames from 'classnames';
import React, { useMemo } from 'react';
import { ExpressionInput } from '@noodl-core-ui/components/property-panel/ExpressionInput';
import { ExpressionToggle } from '@noodl-core-ui/components/property-panel/ExpressionToggle';
import { PropertyPanelBaseInputProps } from '@noodl-core-ui/components/property-panel/PropertyPanelBaseInput';
import { PropertyPanelButton, PropertyPanelButtonProps } from '@noodl-core-ui/components/property-panel/PropertyPanelButton';
import {
PropertyPanelButton,
PropertyPanelButtonProps
} from '@noodl-core-ui/components/property-panel/PropertyPanelButton';
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
import { PropertyPanelIconRadioInput, PropertyPanelIconRadioProperties } from '@noodl-core-ui/components/property-panel/PropertyPanelIconRadioInput';
import {
PropertyPanelIconRadioInput,
PropertyPanelIconRadioProperties
} from '@noodl-core-ui/components/property-panel/PropertyPanelIconRadioInput';
import { PropertyPanelLengthUnitInput } from '@noodl-core-ui/components/property-panel/PropertyPanelLengthUnitInput';
import { PropertyPanelNumberInput } from '@noodl-core-ui/components/property-panel/PropertyPanelNumberInput';
import { PropertyPanelSelectInput, PropertyPanelSelectProperties } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import { PropertyPanelSliderInput, PropertyPanelSliderInputProps } from '@noodl-core-ui/components/property-panel/PropertyPanelSliderInput';
import {
PropertyPanelSelectInput,
PropertyPanelSelectProperties
} from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import {
PropertyPanelSliderInput,
PropertyPanelSliderInputProps
} from '@noodl-core-ui/components/property-panel/PropertyPanelSliderInput';
import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput';
import { PropertyPanelTextRadioInput, PropertyPanelTextRadioProperties } from '@noodl-core-ui/components/property-panel/PropertyPanelTextRadioInput';
import {
PropertyPanelTextRadioInput,
PropertyPanelTextRadioProperties
} from '@noodl-core-ui/components/property-panel/PropertyPanelTextRadioInput';
import { Slot } from '@noodl-core-ui/types/global';
import css from './PropertyPanelInput.module.scss';
@@ -31,13 +48,32 @@ export enum PropertyPanelInputType {
// SizeMode = 'size-mode',
}
export type PropertyPanelProps = undefined |PropertyPanelIconRadioProperties | PropertyPanelButtonProps["properties"]
| PropertyPanelSliderInputProps ["properties"] | PropertyPanelSelectProperties | PropertyPanelTextRadioProperties
export type PropertyPanelProps =
| undefined
| PropertyPanelIconRadioProperties
| PropertyPanelButtonProps['properties']
| PropertyPanelSliderInputProps['properties']
| PropertyPanelSelectProperties
| PropertyPanelTextRadioProperties;
export interface PropertyPanelInputProps extends Omit<PropertyPanelBaseInputProps, 'type'> {
label: string;
inputType: PropertyPanelInputType;
properties: PropertyPanelProps;
// Expression support
/** Whether this input type supports expression mode (default: true for most types) */
supportsExpression?: boolean;
/** Current mode: 'fixed' for static values, 'expression' for dynamic expressions */
expressionMode?: 'fixed' | 'expression';
/** The expression string (when in expression mode) */
expression?: string;
/** Callback when expression mode changes */
onExpressionModeChange?: (mode: 'fixed' | 'expression') => void;
/** Callback when expression text changes */
onExpressionChange?: (expression: string) => void;
/** Whether the expression has an error */
expressionError?: string;
}
export function PropertyPanelInput({
@@ -47,7 +83,14 @@ export function PropertyPanelInput({
properties,
isChanged,
isConnected,
onChange
onChange,
// Expression props
supportsExpression = true,
expressionMode = 'fixed',
expression = '',
onExpressionModeChange,
onExpressionChange,
expressionError
}: PropertyPanelInputProps) {
const Input = useMemo(() => {
switch (inputType) {
@@ -72,28 +115,62 @@ export function PropertyPanelInput({
}
}, [inputType]);
// Determine if we should show expression UI
const showExpressionToggle = supportsExpression && !isConnected;
const isExpressionMode = expressionMode === 'expression';
// Handle toggle between fixed and expression modes
const handleToggleMode = () => {
if (onExpressionModeChange) {
const newMode = isExpressionMode ? 'fixed' : 'expression';
onExpressionModeChange(newMode);
}
};
// Render the appropriate input based on mode
const renderInput = () => {
if (isExpressionMode && onExpressionChange) {
return (
<ExpressionInput
expression={expression}
onChange={onExpressionChange}
hasError={!!expressionError}
errorMessage={expressionError}
UNSAFE_style={{ flex: 1 }}
/>
);
}
// Standard input rendering
return (
// FIXME: fix below ts-ignore with better typing
// this is caused by PropertyPanelBaseInputProps having a generic for "value"
// i want to pass a boolan to the checkbox value that will be used in checked for a better API
<Input
// @ts-expect-error
value={value}
// @ts-expect-error
onChange={onChange}
// @ts-expect-error
isChanged={isChanged}
// @ts-expect-error
isConnected={isConnected}
// @ts-expect-error
properties={properties}
/>
);
};
return (
<div className={css['Root']}>
<div className={classNames(css['Label'], isChanged && css['is-changed'])}>{label}</div>
<div className={css['InputContainer']}>
{
// FIXME: fix below ts-ignore with better typing
// this is caused by PropertyPanelBaseInputProps having a generic for "value"
// i want to pass a boolan to the checkbox value that will be used in checked for a better API
<Input
// @ts-expect-error
value={value}
// @ts-expect-error
onChange={onChange}
// @ts-expect-error
isChanged={isChanged}
// @ts-expect-error
isConnected={isConnected}
// @ts-expect-error
properties={properties}
/>
}
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', width: '100%' }}>
{renderInput()}
{showExpressionToggle && (
<ExpressionToggle mode={expressionMode} isConnected={isConnected} onToggle={handleToggleMode} />
)}
</div>
</div>
</div>
);

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';

View File

@@ -0,0 +1,314 @@
/**
* Expression Evaluator
*
* Compiles JavaScript expressions with access to Noodl globals
* and tracks dependencies for reactive updates.
*
* Features:
* - Full Noodl.Variables, Noodl.Objects, Noodl.Arrays access
* - Math helpers (min, max, cos, sin, etc.)
* - Dependency detection and change subscription
* - Expression versioning for future compatibility
* - Caching of compiled functions
*
* @module expression-evaluator
* @since 1.0.0
*/
'use strict';
const Model = require('./model');
// Expression system version - increment when context changes
const EXPRESSION_VERSION = 1;
// Cache for compiled functions
const compiledFunctionsCache = new Map();
// Math helpers to inject into expression context
const mathHelpers = {
min: Math.min,
max: Math.max,
cos: Math.cos,
sin: Math.sin,
tan: Math.tan,
sqrt: Math.sqrt,
pi: Math.PI,
round: Math.round,
floor: Math.floor,
ceil: Math.ceil,
abs: Math.abs,
random: Math.random,
pow: Math.pow,
log: Math.log,
exp: Math.exp
};
/**
* Detect dependencies in an expression string
* Returns { variables: string[], objects: string[], arrays: string[] }
*
* @param {string} expression - The JavaScript expression to analyze
* @returns {{ variables: string[], objects: string[], arrays: string[] }}
*
* @example
* detectDependencies('Noodl.Variables.isLoggedIn ? "Hi" : "Login"')
* // Returns: { variables: ['isLoggedIn'], objects: [], arrays: [] }
*/
function detectDependencies(expression) {
const dependencies = {
variables: [],
objects: [],
arrays: []
};
// Remove strings to avoid false matches
const exprWithoutStrings = expression
.replace(/"([^"\\]|\\.)*"/g, '""')
.replace(/'([^'\\]|\\.)*'/g, "''")
.replace(/`([^`\\]|\\.)*`/g, '``');
// Match Noodl.Variables.X or Noodl.Variables["X"] or Variables.X or Variables["X"]
const variableMatches = exprWithoutStrings.matchAll(
/(?:Noodl\.)?Variables\.([a-zA-Z_$][a-zA-Z0-9_$]*)|(?:Noodl\.)?Variables\[["']([^"']+)["']\]/g
);
for (const match of variableMatches) {
const varName = match[1] || match[2];
if (varName && !dependencies.variables.includes(varName)) {
dependencies.variables.push(varName);
}
}
// Match Noodl.Objects.X or Noodl.Objects["X"] or Objects.X or Objects["X"]
const objectMatches = exprWithoutStrings.matchAll(
/(?:Noodl\.)?Objects\.([a-zA-Z_$][a-zA-Z0-9_$]*)|(?:Noodl\.)?Objects\[["']([^"']+)["']\]/g
);
for (const match of objectMatches) {
const objId = match[1] || match[2];
if (objId && !dependencies.objects.includes(objId)) {
dependencies.objects.push(objId);
}
}
// Match Noodl.Arrays.X or Noodl.Arrays["X"] or Arrays.X or Arrays["X"]
const arrayMatches = exprWithoutStrings.matchAll(
/(?:Noodl\.)?Arrays\.([a-zA-Z_$][a-zA-Z0-9_$]*)|(?:Noodl\.)?Arrays\[["']([^"']+)["']\]/g
);
for (const match of arrayMatches) {
const arrId = match[1] || match[2];
if (arrId && !dependencies.arrays.includes(arrId)) {
dependencies.arrays.push(arrId);
}
}
return dependencies;
}
/**
* Create the Noodl context object for expression evaluation
*
* @param {Model.Scope} [modelScope] - Optional model scope (defaults to global Model)
* @returns {Object} Noodl context with Variables, Objects, Arrays accessors
*/
function createNoodlContext(modelScope) {
const scope = modelScope || Model;
// Get the global variables model
const variablesModel = scope.get('--ndl--global-variables');
return {
Variables: variablesModel ? variablesModel.data : {},
Objects: new Proxy(
{},
{
get(target, prop) {
if (typeof prop === 'symbol') return undefined;
const obj = scope.get(prop);
return obj ? obj.data : undefined;
}
}
),
Arrays: new Proxy(
{},
{
get(target, prop) {
if (typeof prop === 'symbol') return undefined;
const arr = scope.get(prop);
return arr ? arr.data : undefined;
}
}
),
Object: scope
};
}
/**
* Compile an expression string into a callable function
*
* @param {string} expression - The JavaScript expression to compile
* @returns {Function|null} Compiled function or null if compilation fails
*
* @example
* const fn = compileExpression('min(10, 5) + 2');
* const result = evaluateExpression(fn); // 7
*/
function compileExpression(expression) {
const cacheKey = `v${EXPRESSION_VERSION}:${expression}`;
if (compiledFunctionsCache.has(cacheKey)) {
return compiledFunctionsCache.get(cacheKey);
}
// Build parameter list for the function
const paramNames = ['Noodl', 'Variables', 'Objects', 'Arrays', ...Object.keys(mathHelpers)];
// Wrap expression in return statement with error handling
const functionBody = `
"use strict";
try {
return (${expression});
} catch (e) {
console.error('Expression evaluation error:', e.message);
return undefined;
}
`;
try {
const fn = new Function(...paramNames, functionBody);
compiledFunctionsCache.set(cacheKey, fn);
return fn;
} catch (e) {
console.error('Expression compilation error:', e.message);
return null;
}
}
/**
* Evaluate a compiled expression with the current context
*
* @param {Function|null} compiledFn - The compiled expression function
* @param {Model.Scope} [modelScope] - Optional model scope
* @returns {*} The result of the expression evaluation
*/
function evaluateExpression(compiledFn, modelScope) {
if (!compiledFn) return undefined;
const noodlContext = createNoodlContext(modelScope);
const mathValues = Object.values(mathHelpers);
try {
// Pass Noodl context plus shorthand accessors
return compiledFn(noodlContext, noodlContext.Variables, noodlContext.Objects, noodlContext.Arrays, ...mathValues);
} catch (e) {
console.error('Expression evaluation error:', e.message);
return undefined;
}
}
/**
* Subscribe to changes in expression dependencies
* Returns an unsubscribe function
*
* @param {{ variables: string[], objects: string[], arrays: string[] }} dependencies
* @param {Function} callback - Called when any dependency changes
* @param {Model.Scope} [modelScope] - Optional model scope
* @returns {Function} Unsubscribe function
*
* @example
* const deps = { variables: ['userName'], objects: [], arrays: [] };
* const unsub = subscribeToChanges(deps, () => console.log('Changed!'));
* // Later: unsub();
*/
function subscribeToChanges(dependencies, callback, modelScope) {
const scope = modelScope || Model;
const listeners = [];
// Subscribe to variable changes
if (dependencies.variables.length > 0) {
const variablesModel = scope.get('--ndl--global-variables');
if (variablesModel) {
const handler = (args) => {
// Check if any of our dependencies changed
if (dependencies.variables.some((v) => args.name === v || !args.name)) {
callback();
}
};
variablesModel.on('change', handler);
listeners.push(() => variablesModel.off('change', handler));
}
}
// Subscribe to object changes
for (const objId of dependencies.objects) {
const objModel = scope.get(objId);
if (objModel) {
const handler = () => callback();
objModel.on('change', handler);
listeners.push(() => objModel.off('change', handler));
}
}
// Subscribe to array changes
for (const arrId of dependencies.arrays) {
const arrModel = scope.get(arrId);
if (arrModel) {
const handler = () => callback();
arrModel.on('change', handler);
listeners.push(() => arrModel.off('change', handler));
}
}
// Return unsubscribe function
return () => {
listeners.forEach((unsub) => unsub());
};
}
/**
* Validate expression syntax without executing
*
* @param {string} expression - The expression to validate
* @returns {{ valid: boolean, error: string|null }}
*
* @example
* validateExpression('1 + 1'); // { valid: true, error: null }
* validateExpression('1 +'); // { valid: false, error: 'Unexpected end of input' }
*/
function validateExpression(expression) {
try {
new Function(`return (${expression})`);
return { valid: true, error: null };
} catch (e) {
return { valid: false, error: e.message };
}
}
/**
* Get the current expression system version
* Used for migration when expression context changes
*
* @returns {number} Current version number
*/
function getExpressionVersion() {
return EXPRESSION_VERSION;
}
/**
* Clear the compiled functions cache
* Useful for testing or when context changes
*/
function clearCache() {
compiledFunctionsCache.clear();
}
module.exports = {
detectDependencies,
compileExpression,
evaluateExpression,
subscribeToChanges,
validateExpression,
createNoodlContext,
getExpressionVersion,
clearCache,
EXPRESSION_VERSION
};

View File

@@ -0,0 +1,111 @@
/**
* Expression Type Coercion
*
* Coerces expression evaluation results to match expected property types.
* Ensures type safety when expressions are used for node properties.
*
* @module expression-type-coercion
* @since 1.1.0
*/
'use strict';
/**
* Coerce expression result to expected property type
*
* @param {*} value - The value from expression evaluation
* @param {string} expectedType - The expected type (string, number, boolean, color, enum, etc.)
* @param {*} [fallback] - Fallback value if coercion fails
* @param {Array} [enumOptions] - Valid options for enum type
* @returns {*} Coerced value or fallback
*
* @example
* coerceToType('42', 'number') // 42
* coerceToType(true, 'string') // 'true'
* coerceToType('#ff0000', 'color') // '#ff0000'
*/
function coerceToType(value, expectedType, fallback, enumOptions) {
// Handle undefined/null upfront
if (value === undefined || value === null) {
return fallback;
}
switch (expectedType) {
case 'string':
return String(value);
case 'number': {
const num = Number(value);
// Check for NaN (includes invalid strings, NaN itself, etc.)
return isNaN(num) ? fallback : num;
}
case 'boolean':
return !!value;
case 'color':
return coerceToColor(value, fallback);
case 'enum':
return coerceToEnum(value, fallback, enumOptions);
default:
// Unknown types pass through as-is
return value;
}
}
/**
* Coerce value to valid color string
*
* @param {*} value - The value to coerce
* @param {*} fallback - Fallback color
* @returns {string} Valid color or fallback
*/
function coerceToColor(value, fallback) {
const str = String(value);
// Validate hex colors: #RGB or #RRGGBB (case insensitive)
if (/^#[0-9A-Fa-f]{3}$/.test(str) || /^#[0-9A-Fa-f]{6}$/.test(str)) {
return str;
}
// Validate rgb() or rgba() format
if (/^rgba?\(/.test(str)) {
return str;
}
// Invalid color format
return fallback;
}
/**
* Coerce value to valid enum option
*
* @param {*} value - The value to coerce
* @param {*} fallback - Fallback enum value
* @param {Array} enumOptions - Valid enum options (strings or {value, label} objects)
* @returns {string} Valid enum value or fallback
*/
function coerceToEnum(value, fallback, enumOptions) {
if (!enumOptions) {
return fallback;
}
const enumVal = String(value);
// Check if value matches any option
const isValid = enumOptions.some((opt) => {
if (typeof opt === 'string') {
return opt === enumVal;
}
// Handle {value, label} format
return opt.value === enumVal;
});
return isValid ? enumVal : fallback;
}
module.exports = {
coerceToType
};

View File

@@ -1,4 +1,21 @@
const OutputProperty = require('./outputproperty');
const { evaluateExpression } = require('./expression-evaluator');
const { coerceToType } = require('./expression-type-coercion');
/**
* Helper to check if a value is an expression parameter
* @param {*} value - The value to check
* @returns {boolean} True if value is an expression parameter
*/
function isExpressionParameter(value) {
return (
value !== null &&
value !== undefined &&
typeof value === 'object' &&
value.mode === 'expression' &&
typeof value.expression === 'string'
);
}
/**
* Base class for all Nodes
@@ -83,6 +100,63 @@ Node.prototype.registerInputIfNeeded = function () {
//noop, can be overriden by subclasses
};
/**
* Evaluate an expression parameter and return the coerced result
*
* @param {*} paramValue - The parameter value (might be an ExpressionParameter)
* @param {string} portName - The input port name
* @returns {*} The evaluated and coerced value (or original if not an expression)
*/
Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
// Check if this is an expression parameter
if (!isExpressionParameter(paramValue)) {
return paramValue; // Simple value, return as-is
}
const input = this.getInput(portName);
if (!input) {
return paramValue.fallback; // No input definition, use fallback
}
try {
// Evaluate the expression with access to context
const result = evaluateExpression(paramValue.expression, this.context);
// Coerce to expected type
const coercedValue = coerceToType(result, input.type, paramValue.fallback);
// Clear any previous expression errors
if (this.context.editorConnection) {
this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name,
this.id,
'expression-error-' + portName
);
}
return coercedValue;
} catch (error) {
// Expression evaluation failed
console.warn(`Expression evaluation failed for ${this.name}.${portName}:`, error);
// Show warning in editor
if (this.context.editorConnection) {
this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name,
this.id,
'expression-error-' + portName,
{
showGlobally: true,
message: `Expression error: ${error.message}`
}
);
}
// Return fallback value
return paramValue.fallback;
}
};
Node.prototype.setInputValue = function (name, value) {
// DEBUG: Track input value setting for HTTP node
if (this.name === 'net.noodl.HTTP') {
@@ -115,6 +189,9 @@ Node.prototype.setInputValue = function (name, value) {
//Save the current input value. Save it before resolving color styles so delta updates on color styles work correctly
this._inputValues[name] = value;
// Evaluate expression parameters before further processing
value = this._evaluateExpressionParameter(value, name);
if (input.type === 'color' && this.context && this.context.styles) {
value = this.context.styles.resolveColor(value);
} else if (input.type === 'array' && typeof value === 'string') {

View File

@@ -1,8 +1,7 @@
'use strict';
const difference = require('lodash.difference');
//const Model = require('./data/model');
const ExpressionEvaluator = require('../../expression-evaluator');
const ExpressionNode = {
name: 'Expression',
@@ -26,6 +25,19 @@ const ExpressionNode = {
internal.compiledFunction = undefined;
internal.inputNames = [];
internal.inputValues = [];
// New: Expression evaluator integration
internal.noodlDependencies = { variables: [], objects: [], arrays: [] };
internal.unsubscribe = null;
},
methods: {
_onNodeDeleted: function () {
// Clean up reactive subscriptions to prevent memory leaks
if (this._internal.unsubscribe) {
this._internal.unsubscribe();
this._internal.unsubscribe = null;
}
}
},
getInspectInfo() {
return this._internal.cachedValue;
@@ -72,15 +84,31 @@ const ExpressionNode = {
self._inputValues[name] = 0;
});
/* if(value.indexOf('Vars') !== -1 || value.indexOf('Variables') !== -1) {
// This expression is using variables, it should listen for changes
this._internal.onVariablesChangedCallback = (args) => {
this._scheduleEvaluateExpression()
}
// Detect dependencies for reactive updates
internal.noodlDependencies = ExpressionEvaluator.detectDependencies(value);
Model.get('--ndl--global-variables').off('change',this._internal.onVariablesChangedCallback)
Model.get('--ndl--global-variables').on('change',this._internal.onVariablesChangedCallback)
}*/
// Clean up old subscription
if (internal.unsubscribe) {
internal.unsubscribe();
internal.unsubscribe = null;
}
// Subscribe to Noodl global changes if expression uses them
if (
internal.noodlDependencies.variables.length > 0 ||
internal.noodlDependencies.objects.length > 0 ||
internal.noodlDependencies.arrays.length > 0
) {
internal.unsubscribe = ExpressionEvaluator.subscribeToChanges(
internal.noodlDependencies,
function () {
if (!self.isInputConnected('run')) {
self._scheduleEvaluateExpression();
}
},
self.context && self.context.modelScope
);
}
internal.inputNames = Object.keys(internal.scope);
if (!this.isInputConnected('run')) this._scheduleEvaluateExpression();
@@ -141,6 +169,33 @@ const ExpressionNode = {
group: 'Events',
type: 'signal',
displayName: 'On False'
},
// New typed outputs for better downstream compatibility
asString: {
group: 'Typed Results',
type: 'string',
displayName: 'As String',
getter: function () {
const val = this._internal.cachedValue;
return val !== undefined && val !== null ? String(val) : '';
}
},
asNumber: {
group: 'Typed Results',
type: 'number',
displayName: 'As Number',
getter: function () {
const val = this._internal.cachedValue;
return typeof val === 'number' ? val : Number(val) || 0;
}
},
asBoolean: {
group: 'Typed Results',
type: 'boolean',
displayName: 'As Boolean',
getter: function () {
return !!this._internal.cachedValue;
}
}
},
prototypeExtensions: {
@@ -235,8 +290,19 @@ var functionPreamble = [
' floor = Math.floor,' +
' ceil = Math.ceil,' +
' abs = Math.abs,' +
' random = Math.random;'
/* ' Vars = Variables = Noodl.Object.get("--ndl--global-variables");' */
' random = Math.random,' +
' pow = Math.pow,' +
' log = Math.log,' +
' exp = Math.exp;' +
// Add Noodl global context
'try {' +
' var NoodlContext = (typeof Noodl !== "undefined") ? Noodl : (typeof global !== "undefined" && global.Noodl) || {};' +
' var Variables = NoodlContext.Variables || {};' +
' var Objects = NoodlContext.Objects || {};' +
' var Arrays = NoodlContext.Arrays || {};' +
'} catch (e) {' +
' var Variables = {}, Objects = {}, Arrays = {};' +
'}'
].join('');
//Since apply cannot be used on constructors (i.e. new Something) we need this hax
@@ -264,11 +330,19 @@ var portsToIgnore = [
'ceil',
'abs',
'random',
'pow',
'log',
'exp',
'Math',
'window',
'document',
'undefined',
'Vars',
'Variables',
'Objects',
'Arrays',
'Noodl',
'NoodlContext',
'true',
'false',
'null',
@@ -326,13 +400,43 @@ function updatePorts(nodeId, expression, editorConnection) {
}
function evalCompileWarnings(editorConnection, node) {
try {
new Function(node.parameters.expression);
const expression = node.parameters.expression;
if (!expression) {
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
} catch (e) {
return;
}
// Validate expression syntax
const validation = ExpressionEvaluator.validateExpression(expression);
if (!validation.valid) {
editorConnection.sendWarning(node.component.name, node.id, 'expression-compile-error', {
message: e.message
message: 'Syntax error: ' + validation.error
});
} else {
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
// Optionally show detected dependencies as info (helpful for users)
const deps = ExpressionEvaluator.detectDependencies(expression);
const depCount = deps.variables.length + deps.objects.length + deps.arrays.length;
if (depCount > 0) {
const depList = [];
if (deps.variables.length > 0) {
depList.push('Variables: ' + deps.variables.join(', '));
}
if (deps.objects.length > 0) {
depList.push('Objects: ' + deps.objects.join(', '));
}
if (deps.arrays.length > 0) {
depList.push('Arrays: ' + deps.arrays.join(', '));
}
// This is just informational, not an error
// Could be shown in a future info panel
// For now, we'll just log it
console.log('[Expression Node] Reactive dependencies detected:', depList.join('; '));
}
}
}

View File

@@ -0,0 +1,357 @@
const ExpressionEvaluator = require('../src/expression-evaluator');
const Model = require('../src/model');
describe('Expression Evaluator', () => {
beforeEach(() => {
// Reset Model state before each test
Model._models = {};
// Ensure global variables model exists
Model.get('--ndl--global-variables');
ExpressionEvaluator.clearCache();
});
describe('detectDependencies', () => {
it('detects Noodl.Variables references', () => {
const deps = ExpressionEvaluator.detectDependencies(
'Noodl.Variables.isLoggedIn ? Noodl.Variables.userName : "guest"'
);
expect(deps.variables).toContain('isLoggedIn');
expect(deps.variables).toContain('userName');
expect(deps.variables.length).toBe(2);
});
it('detects Variables shorthand references', () => {
const deps = ExpressionEvaluator.detectDependencies('Variables.count + Variables.offset');
expect(deps.variables).toContain('count');
expect(deps.variables).toContain('offset');
});
it('detects bracket notation', () => {
const deps = ExpressionEvaluator.detectDependencies('Noodl.Variables["my variable"]');
expect(deps.variables).toContain('my variable');
});
it('ignores references inside strings', () => {
const deps = ExpressionEvaluator.detectDependencies('"Noodl.Variables.notReal"');
expect(deps.variables).toHaveLength(0);
});
it('detects Noodl.Objects references', () => {
const deps = ExpressionEvaluator.detectDependencies('Noodl.Objects.CurrentUser.name');
expect(deps.objects).toContain('CurrentUser');
});
it('detects Objects shorthand references', () => {
const deps = ExpressionEvaluator.detectDependencies('Objects.User.id');
expect(deps.objects).toContain('User');
});
it('detects Noodl.Arrays references', () => {
const deps = ExpressionEvaluator.detectDependencies('Noodl.Arrays.items.length');
expect(deps.arrays).toContain('items');
});
it('detects Arrays shorthand references', () => {
const deps = ExpressionEvaluator.detectDependencies('Arrays.todos.filter(x => x.done)');
expect(deps.arrays).toContain('todos');
});
it('handles mixed dependencies', () => {
const deps = ExpressionEvaluator.detectDependencies(
'Variables.isAdmin && Objects.User.role === "admin" ? Arrays.items.length : 0'
);
expect(deps.variables).toContain('isAdmin');
expect(deps.objects).toContain('User');
expect(deps.arrays).toContain('items');
});
it('handles template literals', () => {
const deps = ExpressionEvaluator.detectDependencies('`Hello, ${Variables.userName}!`');
expect(deps.variables).toContain('userName');
});
});
describe('compileExpression', () => {
it('compiles valid expression', () => {
const fn = ExpressionEvaluator.compileExpression('1 + 1');
expect(fn).not.toBeNull();
expect(typeof fn).toBe('function');
});
it('returns null for invalid expression', () => {
const fn = ExpressionEvaluator.compileExpression('1 +');
expect(fn).toBeNull();
});
it('caches compiled functions', () => {
const fn1 = ExpressionEvaluator.compileExpression('2 + 2');
const fn2 = ExpressionEvaluator.compileExpression('2 + 2');
expect(fn1).toBe(fn2);
});
it('different expressions compile separately', () => {
const fn1 = ExpressionEvaluator.compileExpression('1 + 1');
const fn2 = ExpressionEvaluator.compileExpression('2 + 2');
expect(fn1).not.toBe(fn2);
});
});
describe('validateExpression', () => {
it('validates correct syntax', () => {
const result = ExpressionEvaluator.validateExpression('a > b ? 1 : 0');
expect(result.valid).toBe(true);
expect(result.error).toBeNull();
});
it('catches syntax errors', () => {
const result = ExpressionEvaluator.validateExpression('a >');
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it('validates complex expressions', () => {
const result = ExpressionEvaluator.validateExpression('Variables.count > 10 ? "many" : "few"');
expect(result.valid).toBe(true);
});
});
describe('evaluateExpression', () => {
it('evaluates simple math expressions', () => {
const fn = ExpressionEvaluator.compileExpression('5 + 3');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe(8);
});
it('evaluates with min/max helpers', () => {
const fn = ExpressionEvaluator.compileExpression('min(10, 5) + max(1, 2)');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe(7);
});
it('evaluates with pi constant', () => {
const fn = ExpressionEvaluator.compileExpression('round(pi * 100) / 100');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe(3.14);
});
it('evaluates with pow helper', () => {
const fn = ExpressionEvaluator.compileExpression('pow(2, 3)');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe(8);
});
it('returns undefined for null function', () => {
const result = ExpressionEvaluator.evaluateExpression(null);
expect(result).toBeUndefined();
});
it('evaluates with Noodl.Variables', () => {
const varsModel = Model.get('--ndl--global-variables');
varsModel.set('testVar', 42);
const fn = ExpressionEvaluator.compileExpression('Variables.testVar * 2');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe(84);
});
it('evaluates with Noodl.Objects', () => {
const userModel = Model.get('CurrentUser');
userModel.set('name', 'Alice');
const fn = ExpressionEvaluator.compileExpression('Objects.CurrentUser.name');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe('Alice');
});
it('handles undefined Variables gracefully', () => {
const fn = ExpressionEvaluator.compileExpression('Variables.nonExistent || "default"');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe('default');
});
it('evaluates ternary expressions', () => {
const varsModel = Model.get('--ndl--global-variables');
varsModel.set('isAdmin', true);
const fn = ExpressionEvaluator.compileExpression('Variables.isAdmin ? "Admin" : "User"');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe('Admin');
});
it('evaluates template literals', () => {
const varsModel = Model.get('--ndl--global-variables');
varsModel.set('name', 'Bob');
const fn = ExpressionEvaluator.compileExpression('`Hello, ${Variables.name}!`');
const result = ExpressionEvaluator.evaluateExpression(fn);
expect(result).toBe('Hello, Bob!');
});
});
describe('subscribeToChanges', () => {
it('calls callback when Variable changes', (done) => {
const varsModel = Model.get('--ndl--global-variables');
varsModel.set('counter', 0);
const deps = { variables: ['counter'], objects: [], arrays: [] };
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
unsub();
done();
});
varsModel.set('counter', 1);
});
it('calls callback when Object changes', (done) => {
const userModel = Model.get('TestUser');
userModel.set('name', 'Initial');
const deps = { variables: [], objects: ['TestUser'], arrays: [] };
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
unsub();
done();
});
userModel.set('name', 'Changed');
});
it('does not call callback for unrelated Variable changes', () => {
const varsModel = Model.get('--ndl--global-variables');
let called = false;
const deps = { variables: ['watchThis'], objects: [], arrays: [] };
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
called = true;
});
varsModel.set('notWatching', 'value');
setTimeout(() => {
expect(called).toBe(false);
unsub();
}, 50);
});
it('unsubscribe prevents future callbacks', () => {
const varsModel = Model.get('--ndl--global-variables');
let callCount = 0;
const deps = { variables: ['test'], objects: [], arrays: [] };
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
callCount++;
});
varsModel.set('test', 1);
unsub();
varsModel.set('test', 2);
setTimeout(() => {
expect(callCount).toBe(1);
}, 50);
});
it('handles multiple dependencies', (done) => {
const varsModel = Model.get('--ndl--global-variables');
const userModel = Model.get('User');
let callCount = 0;
const deps = { variables: ['count'], objects: ['User'], arrays: [] };
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
callCount++;
if (callCount === 2) {
unsub();
done();
}
});
varsModel.set('count', 1);
userModel.set('name', 'Test');
});
});
describe('createNoodlContext', () => {
it('creates context with Variables', () => {
const varsModel = Model.get('--ndl--global-variables');
varsModel.set('test', 123);
const context = ExpressionEvaluator.createNoodlContext();
expect(context.Variables.test).toBe(123);
});
it('creates context with Objects proxy', () => {
const userModel = Model.get('TestUser');
userModel.set('id', 'user-1');
const context = ExpressionEvaluator.createNoodlContext();
expect(context.Objects.TestUser.id).toBe('user-1');
});
it('handles non-existent Objects', () => {
const context = ExpressionEvaluator.createNoodlContext();
expect(context.Objects.NonExistent).toBeUndefined();
});
it('handles empty Variables', () => {
const context = ExpressionEvaluator.createNoodlContext();
expect(context.Variables).toBeDefined();
expect(typeof context.Variables).toBe('object');
});
});
describe('getExpressionVersion', () => {
it('returns a number', () => {
const version = ExpressionEvaluator.getExpressionVersion();
expect(typeof version).toBe('number');
});
it('returns consistent version', () => {
const v1 = ExpressionEvaluator.getExpressionVersion();
const v2 = ExpressionEvaluator.getExpressionVersion();
expect(v1).toBe(v2);
});
});
describe('clearCache', () => {
it('clears compiled functions cache', () => {
const fn1 = ExpressionEvaluator.compileExpression('1 + 1');
ExpressionEvaluator.clearCache();
const fn2 = ExpressionEvaluator.compileExpression('1 + 1');
expect(fn1).not.toBe(fn2);
});
});
describe('Integration tests', () => {
it('full workflow: compile, evaluate, subscribe', (done) => {
const varsModel = Model.get('--ndl--global-variables');
varsModel.set('counter', 0);
const expression = 'Variables.counter * 2';
const deps = ExpressionEvaluator.detectDependencies(expression);
const compiled = ExpressionEvaluator.compileExpression(expression);
let result = ExpressionEvaluator.evaluateExpression(compiled);
expect(result).toBe(0);
const unsub = ExpressionEvaluator.subscribeToChanges(deps, () => {
result = ExpressionEvaluator.evaluateExpression(compiled);
expect(result).toBe(10);
unsub();
done();
});
varsModel.set('counter', 5);
});
it('complex expression with multiple operations', () => {
const varsModel = Model.get('--ndl--global-variables');
varsModel.set('a', 10);
varsModel.set('b', 5);
const expression = 'min(Variables.a, Variables.b) + max(Variables.a, Variables.b)';
const compiled = ExpressionEvaluator.compileExpression(expression);
const result = ExpressionEvaluator.evaluateExpression(compiled);
expect(result).toBe(15); // min(10, 5) + max(10, 5) = 5 + 10
});
});
});

View File

@@ -0,0 +1,211 @@
/**
* Type Coercion Tests for Expression Parameters
*
* Tests type conversion from expression results to expected property types
*/
const { coerceToType } = require('../src/expression-type-coercion');
describe('Expression Type Coercion', () => {
describe('String coercion', () => {
it('converts number to string', () => {
expect(coerceToType(42, 'string')).toBe('42');
});
it('converts boolean to string', () => {
expect(coerceToType(true, 'string')).toBe('true');
expect(coerceToType(false, 'string')).toBe('false');
});
it('converts object to string', () => {
expect(coerceToType({ a: 1 }, 'string')).toBe('[object Object]');
});
it('converts array to string', () => {
expect(coerceToType([1, 2, 3], 'string')).toBe('1,2,3');
});
it('returns empty string for undefined', () => {
expect(coerceToType(undefined, 'string', 'fallback')).toBe('fallback');
});
it('returns empty string for null', () => {
expect(coerceToType(null, 'string', 'fallback')).toBe('fallback');
});
it('keeps string as-is', () => {
expect(coerceToType('hello', 'string')).toBe('hello');
});
});
describe('Number coercion', () => {
it('converts string number to number', () => {
expect(coerceToType('42', 'number')).toBe(42);
});
it('converts string float to number', () => {
expect(coerceToType('3.14', 'number')).toBe(3.14);
});
it('converts boolean to number', () => {
expect(coerceToType(true, 'number')).toBe(1);
expect(coerceToType(false, 'number')).toBe(0);
});
it('returns fallback for invalid string', () => {
expect(coerceToType('not a number', 'number', 0)).toBe(0);
});
it('returns fallback for undefined', () => {
expect(coerceToType(undefined, 'number', 42)).toBe(42);
});
it('returns fallback for null', () => {
expect(coerceToType(null, 'number', 42)).toBe(42);
});
it('returns fallback for NaN', () => {
expect(coerceToType(NaN, 'number', 0)).toBe(0);
});
it('keeps number as-is', () => {
expect(coerceToType(123, 'number')).toBe(123);
});
it('converts negative numbers correctly', () => {
expect(coerceToType('-10', 'number')).toBe(-10);
});
});
describe('Boolean coercion', () => {
it('converts truthy values to true', () => {
expect(coerceToType(1, 'boolean')).toBe(true);
expect(coerceToType('yes', 'boolean')).toBe(true);
expect(coerceToType({}, 'boolean')).toBe(true);
expect(coerceToType([], 'boolean')).toBe(true);
});
it('converts falsy values to false', () => {
expect(coerceToType(0, 'boolean')).toBe(false);
expect(coerceToType('', 'boolean')).toBe(false);
expect(coerceToType(null, 'boolean')).toBe(false);
expect(coerceToType(undefined, 'boolean')).toBe(false);
expect(coerceToType(NaN, 'boolean')).toBe(false);
});
it('keeps boolean as-is', () => {
expect(coerceToType(true, 'boolean')).toBe(true);
expect(coerceToType(false, 'boolean')).toBe(false);
});
});
describe('Color coercion', () => {
it('accepts valid hex colors', () => {
expect(coerceToType('#ff0000', 'color')).toBe('#ff0000');
expect(coerceToType('#FF0000', 'color')).toBe('#FF0000');
expect(coerceToType('#abc123', 'color')).toBe('#abc123');
});
it('accepts 3-digit hex colors', () => {
expect(coerceToType('#f00', 'color')).toBe('#f00');
expect(coerceToType('#FFF', 'color')).toBe('#FFF');
});
it('accepts rgb() format', () => {
expect(coerceToType('rgb(255, 0, 0)', 'color')).toBe('rgb(255, 0, 0)');
});
it('accepts rgba() format', () => {
expect(coerceToType('rgba(255, 0, 0, 0.5)', 'color')).toBe('rgba(255, 0, 0, 0.5)');
});
it('returns fallback for invalid hex', () => {
expect(coerceToType('#gg0000', 'color', '#000000')).toBe('#000000');
expect(coerceToType('not a color', 'color', '#000000')).toBe('#000000');
});
it('returns fallback for undefined', () => {
expect(coerceToType(undefined, 'color', '#ffffff')).toBe('#ffffff');
});
it('returns fallback for null', () => {
expect(coerceToType(null, 'color', '#ffffff')).toBe('#ffffff');
});
});
describe('Enum coercion', () => {
const enumOptions = ['small', 'medium', 'large'];
const enumOptionsWithValues = [
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Medium' },
{ value: 'lg', label: 'Large' }
];
it('accepts valid enum value', () => {
expect(coerceToType('medium', 'enum', 'small', enumOptions)).toBe('medium');
});
it('accepts valid enum value from object options', () => {
expect(coerceToType('md', 'enum', 'sm', enumOptionsWithValues)).toBe('md');
});
it('returns fallback for invalid enum value', () => {
expect(coerceToType('xlarge', 'enum', 'small', enumOptions)).toBe('small');
});
it('returns fallback for undefined', () => {
expect(coerceToType(undefined, 'enum', 'medium', enumOptions)).toBe('medium');
});
it('returns fallback for null', () => {
expect(coerceToType(null, 'enum', 'medium', enumOptions)).toBe('medium');
});
it('converts number to string for enum matching', () => {
const numericEnum = ['1', '2', '3'];
expect(coerceToType(2, 'enum', '1', numericEnum)).toBe('2');
});
it('returns fallback when enumOptions is not provided', () => {
expect(coerceToType('value', 'enum', 'fallback')).toBe('fallback');
});
});
describe('Unknown type (passthrough)', () => {
it('returns value as-is for unknown types', () => {
expect(coerceToType({ a: 1 }, 'object')).toEqual({ a: 1 });
expect(coerceToType([1, 2, 3], 'array')).toEqual([1, 2, 3]);
expect(coerceToType('test', 'custom')).toBe('test');
});
it('returns undefined for undefined value with unknown type', () => {
expect(coerceToType(undefined, 'custom', 'fallback')).toBe('fallback');
});
});
describe('Edge cases', () => {
it('handles empty string as value', () => {
expect(coerceToType('', 'string')).toBe('');
expect(coerceToType('', 'number', 0)).toBe(0);
expect(coerceToType('', 'boolean')).toBe(false);
});
it('handles zero as value', () => {
expect(coerceToType(0, 'string')).toBe('0');
expect(coerceToType(0, 'number')).toBe(0);
expect(coerceToType(0, 'boolean')).toBe(false);
});
it('handles Infinity', () => {
expect(coerceToType(Infinity, 'string')).toBe('Infinity');
expect(coerceToType(Infinity, 'number')).toBe(Infinity);
expect(coerceToType(Infinity, 'boolean')).toBe(true);
});
it('handles negative zero', () => {
expect(coerceToType(-0, 'string')).toBe('0');
expect(coerceToType(-0, 'number')).toBe(-0);
expect(coerceToType(-0, 'boolean')).toBe(false);
});
});
});

View File

@@ -0,0 +1,345 @@
/**
* Node Expression Evaluation Tests
*
* Tests the integration of expression parameters with the Node base class.
* Verifies that expressions are evaluated correctly and results are type-coerced.
*
* @jest-environment jsdom
*/
/* eslint-env jest */
const Node = require('../src/node');
// Helper to create expression parameter
function createExpressionParameter(expression, fallback, version = 1) {
return {
mode: 'expression',
expression,
fallback,
version
};
}
describe('Node Expression Evaluation', () => {
let mockContext;
let node;
beforeEach(() => {
// Create mock context with Variables
mockContext = {
updateIteration: 0,
nodeIsDirty: jest.fn(),
styles: {
resolveColor: jest.fn((color) => color)
},
editorConnection: {
sendWarning: jest.fn(),
clearWarning: jest.fn()
},
getDefaultValueForInput: jest.fn(() => undefined),
Variables: {
x: 10,
count: 5,
isAdmin: true,
message: 'Hello'
}
};
// Create a test node
node = new Node(mockContext, 'test-node-1');
node.name = 'TestNode';
node.nodeScope = {
componentOwner: { name: 'TestComponent' }
};
// Register test inputs with different types
node.registerInputs({
numberInput: {
type: 'number',
default: 0,
set: jest.fn()
},
stringInput: {
type: 'string',
default: '',
set: jest.fn()
},
booleanInput: {
type: 'boolean',
default: false,
set: jest.fn()
},
colorInput: {
type: 'color',
default: '#000000',
set: jest.fn()
},
anyInput: {
type: undefined,
default: null,
set: jest.fn()
}
});
});
describe('_evaluateExpressionParameter', () => {
describe('Basic evaluation', () => {
it('returns simple values as-is', () => {
expect(node._evaluateExpressionParameter(42, 'numberInput')).toBe(42);
expect(node._evaluateExpressionParameter('hello', 'stringInput')).toBe('hello');
expect(node._evaluateExpressionParameter(true, 'booleanInput')).toBe(true);
expect(node._evaluateExpressionParameter(null, 'anyInput')).toBe(null);
expect(node._evaluateExpressionParameter(undefined, 'anyInput')).toBe(undefined);
});
it('evaluates expression parameters', () => {
const expr = createExpressionParameter('10 + 5', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(15);
});
it('uses fallback on evaluation error', () => {
const expr = createExpressionParameter('undefined.foo', 100);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(100);
});
it('uses fallback when no input definition exists', () => {
const expr = createExpressionParameter('10 + 5', 999);
const result = node._evaluateExpressionParameter(expr, 'nonexistentInput');
expect(result).toBe(999);
});
it('coerces result to expected port type', () => {
const expr = createExpressionParameter('"42"', 0); // String expression
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(42); // Coerced to number
expect(typeof result).toBe('number');
});
});
describe('Type coercion integration', () => {
it('coerces string expressions to numbers', () => {
const expr = createExpressionParameter('"123"', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(123);
});
it('coerces number expressions to strings', () => {
const expr = createExpressionParameter('456', '');
const result = node._evaluateExpressionParameter(expr, 'stringInput');
expect(result).toBe('456');
expect(typeof result).toBe('string');
});
it('coerces boolean expressions correctly', () => {
const expr = createExpressionParameter('1', false);
const result = node._evaluateExpressionParameter(expr, 'booleanInput');
expect(result).toBe(true);
});
it('validates color expressions', () => {
const expr = createExpressionParameter('"#ff0000"', '#000000');
const result = node._evaluateExpressionParameter(expr, 'colorInput');
expect(result).toBe('#ff0000');
});
it('uses fallback for invalid color expressions', () => {
const expr = createExpressionParameter('"not-a-color"', '#000000');
const result = node._evaluateExpressionParameter(expr, 'colorInput');
expect(result).toBe('#000000');
});
});
describe('Error handling', () => {
it('handles syntax errors gracefully', () => {
const expr = createExpressionParameter('10 +', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(0); // Fallback
});
it('handles reference errors gracefully', () => {
const expr = createExpressionParameter('unknownVariable', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(0); // Fallback
});
it('sends warning to editor on error', () => {
const expr = createExpressionParameter('undefined.foo', 0);
node._evaluateExpressionParameter(expr, 'numberInput');
expect(mockContext.editorConnection.sendWarning).toHaveBeenCalledWith(
'TestComponent',
'test-node-1',
'expression-error-numberInput',
expect.objectContaining({
showGlobally: true,
message: expect.stringContaining('Expression error')
})
);
});
it('clears warnings on successful evaluation', () => {
const expr = createExpressionParameter('10 + 5', 0);
node._evaluateExpressionParameter(expr, 'numberInput');
expect(mockContext.editorConnection.clearWarning).toHaveBeenCalledWith(
'TestComponent',
'test-node-1',
'expression-error-numberInput'
);
});
});
describe('Context integration', () => {
it('has access to Variables', () => {
const expr = createExpressionParameter('Variables.x * 2', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(20); // Variables.x = 10, * 2 = 20
});
it('evaluates complex expressions with Variables', () => {
const expr = createExpressionParameter('Variables.isAdmin ? "Admin" : "User"', 'User');
const result = node._evaluateExpressionParameter(expr, 'stringInput');
expect(result).toBe('Admin'); // Variables.isAdmin = true
});
it('handles arithmetic with Variables', () => {
const expr = createExpressionParameter('Variables.count + Variables.x', 0);
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(15); // 5 + 10 = 15
});
});
describe('Edge cases', () => {
it('handles undefined fallback', () => {
const expr = createExpressionParameter('invalid syntax +', undefined);
const result = node._evaluateExpressionParameter(expr, 'anyInput');
expect(result).toBeUndefined();
});
it('handles null expression result', () => {
const expr = createExpressionParameter('null', 'fallback');
const result = node._evaluateExpressionParameter(expr, 'stringInput');
expect(result).toBe('null'); // Coerced to string
});
it('handles complex object expressions', () => {
mockContext.data = { items: [1, 2, 3] };
const expr = createExpressionParameter('data.items.length', 0);
node.context = mockContext;
const result = node._evaluateExpressionParameter(expr, 'numberInput');
expect(result).toBe(3);
});
it('handles empty string expression', () => {
const expr = createExpressionParameter('', 'fallback');
const result = node._evaluateExpressionParameter(expr, 'stringInput');
// Empty expression evaluates to undefined, uses fallback
expect(result).toBe('fallback');
});
it('handles multi-line expressions', () => {
const expr = createExpressionParameter(
`Variables.x > 5 ?
"Greater" :
"Lesser"`,
'Unknown'
);
const result = node._evaluateExpressionParameter(expr, 'stringInput');
expect(result).toBe('Greater');
});
});
});
describe('setInputValue with expressions', () => {
describe('Integration with input setters', () => {
it('evaluates expressions before calling input setter', () => {
const expr = createExpressionParameter('Variables.x * 2', 0);
node.setInputValue('numberInput', expr);
const input = node.getInput('numberInput');
expect(input.set).toHaveBeenCalledWith(20); // Evaluated result
});
it('passes simple values directly to setter', () => {
node.setInputValue('numberInput', 42);
const input = node.getInput('numberInput');
expect(input.set).toHaveBeenCalledWith(42);
});
it('stores evaluated value in _inputValues', () => {
const expr = createExpressionParameter('Variables.count', 0);
node.setInputValue('numberInput', expr);
// _inputValues should store the expression, not the evaluated result
// (This allows re-evaluation on context changes)
expect(node._inputValues['numberInput']).toEqual(expr);
});
it('works with string input type', () => {
const expr = createExpressionParameter('Variables.message', 'default');
node.setInputValue('stringInput', expr);
const input = node.getInput('stringInput');
expect(input.set).toHaveBeenCalledWith('Hello');
});
it('works with boolean input type', () => {
const expr = createExpressionParameter('Variables.isAdmin', false);
node.setInputValue('booleanInput', expr);
const input = node.getInput('booleanInput');
expect(input.set).toHaveBeenCalledWith(true);
});
});
describe('Maintains existing behavior', () => {
it('maintains existing unit handling', () => {
// Set initial value with unit
node.setInputValue('numberInput', { value: 10, unit: 'px' });
// Update with unitless value
node.setInputValue('numberInput', 20);
const input = node.getInput('numberInput');
expect(input.set).toHaveBeenLastCalledWith({ value: 20, unit: 'px' });
});
it('maintains existing color resolution', () => {
mockContext.styles.resolveColor = jest.fn((color) => '#resolved');
node.setInputValue('colorInput', '#ff0000');
const input = node.getInput('colorInput');
expect(input.set).toHaveBeenCalledWith('#resolved');
});
it('handles non-existent input gracefully', () => {
// Should not throw
expect(() => {
node.setInputValue('nonexistent', 42);
}).not.toThrow();
});
});
describe('Expression evaluation errors', () => {
it('uses fallback when expression fails', () => {
const expr = createExpressionParameter('undefined.prop', 999);
node.setInputValue('numberInput', expr);
const input = node.getInput('numberInput');
expect(input.set).toHaveBeenCalledWith(999); // Fallback
});
it('sends warning on expression error', () => {
const expr = createExpressionParameter('syntax error +', 0);
node.setInputValue('numberInput', expr);
expect(mockContext.editorConnection.sendWarning).toHaveBeenCalled();
});
});
});
});