Merge origin/cline-dev - kept local version of LEARNINGS.md

This commit is contained in:
Tara West
2026-01-12 13:44:53 +01:00
130 changed files with 25758 additions and 355 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

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,36 +0,0 @@
const fs = require('fs');
const path = require('path');
module.exports = async function (params) {
if (process.platform !== 'darwin') {
return;
}
if (!process.env.appleIdPassword) {
console.log('apple password not set, skipping notarization');
return;
}
const appId = 'com.opennoodl.app';
const appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`);
if (!fs.existsSync(appPath)) {
throw new Error(`Cannot find application at: ${appPath}`);
}
console.log(`Notarizing ${appId} found at ${appPath}`);
try {
const electron_notarize = require('electron-notarize');
await electron_notarize.notarize({
appBundleId: appId,
appPath: appPath,
appleId: process.env.appleId,
appleIdPassword: process.env.appleIdPassword
});
} catch (error) {
console.error(error);
}
console.log(`Done notarizing ${appId}`);
};

View File

@@ -60,6 +60,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@babel/parser": "^7.28.5",
"@blockly/theme-dark": "^8.0.3",
"@electron/remote": "^2.1.3",
"@jaames/iro": "^5.5.2",
"@microlink/react-json-view": "^1.27.0",
@@ -68,10 +69,13 @@
"@noodl/noodl-parse-dashboard": "file:../noodl-parse-dashboard",
"@noodl/platform": "file:../noodl-platform",
"@noodl/platform-electron": "file:../noodl-platform-electron",
"@octokit/auth-oauth-device": "^7.1.5",
"@octokit/rest": "^20.1.2",
"about-window": "^1.15.2",
"algoliasearch": "^5.35.0",
"archiver": "^5.3.2",
"async": "^3.2.6",
"blockly": "^12.3.1",
"classnames": "^2.5.1",
"dagre": "^0.8.5",
"diff3": "0.0.4",

View File

@@ -0,0 +1,213 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
/**
* Tab types supported by the canvas tab system
*/
export type TabType = 'logic-builder';
/**
* Tab data structure
*/
export interface Tab {
/** Unique tab identifier */
id: string;
/** Type of tab */
type: TabType;
/** Node ID (for logic-builder tabs) */
nodeId?: string;
/** Node name for display (for logic-builder tabs) */
nodeName?: string;
/** Blockly workspace JSON (for logic-builder tabs) */
workspace?: string;
}
/**
* Context value shape
*/
export interface CanvasTabsContextValue {
/** All open tabs */
tabs: Tab[];
/** Currently active tab ID */
activeTabId: string;
/** Open a new tab or switch to existing */
openTab: (tab: Omit<Tab, 'id'> & { id?: string }) => void;
/** Close a tab by ID */
closeTab: (tabId: string) => void;
/** Switch to a different tab */
switchTab: (tabId: string) => void;
/** Update tab data */
updateTab: (tabId: string, updates: Partial<Tab>) => void;
/** Get tab by ID */
getTab: (tabId: string) => Tab | undefined;
}
const CanvasTabsContext = createContext<CanvasTabsContextValue | undefined>(undefined);
/**
* Hook to access canvas tabs context
*/
export function useCanvasTabs(): CanvasTabsContextValue {
const context = useContext(CanvasTabsContext);
if (!context) {
throw new Error('useCanvasTabs must be used within a CanvasTabsProvider');
}
return context;
}
interface CanvasTabsProviderProps {
children: ReactNode;
}
/**
* Provider for canvas tabs state
*/
export function CanvasTabsProvider({ children }: CanvasTabsProviderProps) {
// Start with no tabs - Logic Builder tabs are opened on demand
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | undefined>(undefined);
/**
* Open a new tab or switch to existing one
*/
const openTab = useCallback((newTab: Omit<Tab, 'id'> & { id?: string }) => {
// Generate ID if not provided
const tabId = newTab.id || `${newTab.type}-${newTab.nodeId || Date.now()}`;
setTabs((prevTabs) => {
// Check if tab already exists
const existingTab = prevTabs.find((t) => t.id === tabId);
if (existingTab) {
// Tab exists, just switch to it
setActiveTabId(tabId);
return prevTabs;
}
// Add new tab
const tab: Tab = {
...newTab,
id: tabId
};
const newTabs = [...prevTabs, tab];
// Emit event that a Logic Builder tab was opened (first tab)
if (prevTabs.length === 0) {
EventDispatcher.instance.emit('LogicBuilder.TabOpened');
}
return newTabs;
});
// Switch to the new/existing tab
setActiveTabId(tabId);
}, []);
/**
* Listen for Logic Builder tab open requests from property panel
*/
useEffect(() => {
const context = {};
const handleOpenTab = (data: { nodeId: string; nodeName: string; workspace: string }) => {
console.log('[CanvasTabsContext] Received LogicBuilder.OpenTab event:', data);
openTab({
type: 'logic-builder',
nodeId: data.nodeId,
nodeName: data.nodeName,
workspace: data.workspace
});
};
EventDispatcher.instance.on('LogicBuilder.OpenTab', handleOpenTab, context);
return () => {
EventDispatcher.instance.off(context);
};
}, [openTab]);
/**
* Close a tab by ID
*/
const closeTab = useCallback(
(tabId: string) => {
setTabs((prevTabs) => {
const tabIndex = prevTabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) {
return prevTabs;
}
const newTabs = prevTabs.filter((t) => t.id !== tabId);
// If closing the active tab, switch to another tab or clear active
if (activeTabId === tabId) {
if (newTabs.length > 0) {
setActiveTabId(newTabs[newTabs.length - 1].id);
} else {
setActiveTabId(undefined);
// Emit event that all Logic Builder tabs are closed
EventDispatcher.instance.emit('LogicBuilder.AllTabsClosed');
}
}
return newTabs;
});
},
[activeTabId]
);
/**
* Switch to a different tab
*/
const switchTab = useCallback((tabId: string) => {
setTabs((prevTabs) => {
// Verify tab exists
const tab = prevTabs.find((t) => t.id === tabId);
if (!tab) {
console.warn(`[CanvasTabs] Tab ${tabId} not found`);
return prevTabs;
}
setActiveTabId(tabId);
return prevTabs;
});
}, []);
/**
* Update tab data
*/
const updateTab = useCallback((tabId: string, updates: Partial<Tab>) => {
setTabs((prevTabs) => {
return prevTabs.map((tab) => {
if (tab.id === tabId) {
return { ...tab, ...updates };
}
return tab;
});
});
}, []);
/**
* Get tab by ID
*/
const getTab = useCallback(
(tabId: string): Tab | undefined => {
return tabs.find((t) => t.id === tabId);
},
[tabs]
);
const value: CanvasTabsContextValue = {
tabs,
activeTabId,
openTab,
closeTab,
switchTab,
updateTab,
getTab
};
return <CanvasTabsContext.Provider value={value}>{children}</CanvasTabsContext.Provider>;
}

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

@@ -15,11 +15,9 @@ import {
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { GitHubUser } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { useEventListener } from '../../hooks/useEventListener';
import { IRouteProps } from '../../pages/AppRoute';
import { GitHubOAuthService } from '../../services/GitHubOAuthService';
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
@@ -49,11 +47,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Real projects from LocalProjectsModel
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
// GitHub OAuth state
const [githubUser, setGithubUser] = useState<GitHubUser | null>(null);
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState<boolean>(false);
const [githubIsConnecting, setGithubIsConnecting] = useState<boolean>(false);
// Create project modal state
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
@@ -62,17 +55,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
// Initialize GitHub OAuth service
const initGitHub = async () => {
console.log('🔧 Initializing GitHub OAuth service...');
await GitHubOAuthService.instance.initialize();
const user = GitHubOAuthService.instance.getCurrentUser();
const isAuth = GitHubOAuthService.instance.isAuthenticated();
setGithubUser(user);
setGithubIsAuthenticated(isAuth);
console.log('✅ GitHub OAuth initialized. Authenticated:', isAuth);
};
// Load projects
const loadProjects = async () => {
await LocalProjectsModel.instance.fetch();
@@ -80,31 +62,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
setRealProjects(projects.map(mapProjectToLauncherData));
};
initGitHub();
loadProjects();
// Set up IPC listener for OAuth callback
const handleOAuthCallback = (_event: any, { code, state }: { code: string; state: string }) => {
console.log('🔄 Received GitHub OAuth callback from main process');
setGithubIsConnecting(true);
GitHubOAuthService.instance
.handleCallback(code, state)
.then(() => {
console.log('✅ OAuth callback handled successfully');
setGithubIsConnecting(false);
})
.catch((error) => {
console.error('❌ OAuth callback failed:', error);
setGithubIsConnecting(false);
ToastLayer.showError('GitHub authentication failed');
});
};
ipcRenderer.on('github-oauth-callback', handleOAuthCallback);
return () => {
ipcRenderer.removeListener('github-oauth-callback', handleOAuthCallback);
};
}, []);
// Subscribe to project list changes
@@ -114,44 +72,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
setRealProjects(projects.map(mapProjectToLauncherData));
});
// Subscribe to GitHub OAuth state changes
useEventListener(GitHubOAuthService.instance, 'oauth-success', (data: { user: GitHubUser }) => {
console.log('🎉 GitHub OAuth success:', data.user.login);
setGithubUser(data.user);
setGithubIsAuthenticated(true);
setGithubIsConnecting(false);
ToastLayer.showSuccess(`Connected to GitHub as ${data.user.login}`);
});
useEventListener(GitHubOAuthService.instance, 'auth-state-changed', (data: { authenticated: boolean }) => {
console.log('🔐 GitHub auth state changed:', data.authenticated);
setGithubIsAuthenticated(data.authenticated);
if (data.authenticated) {
const user = GitHubOAuthService.instance.getCurrentUser();
setGithubUser(user);
} else {
setGithubUser(null);
}
});
useEventListener(GitHubOAuthService.instance, 'oauth-started', () => {
console.log('🚀 GitHub OAuth flow started');
setGithubIsConnecting(true);
});
useEventListener(GitHubOAuthService.instance, 'oauth-error', (data: { error: string }) => {
console.error('❌ GitHub OAuth error:', data.error);
setGithubIsConnecting(false);
ToastLayer.showError(`GitHub authentication failed: ${data.error}`);
});
useEventListener(GitHubOAuthService.instance, 'disconnected', () => {
console.log('👋 GitHub disconnected');
setGithubUser(null);
setGithubIsAuthenticated(false);
ToastLayer.showSuccess('Disconnected from GitHub');
});
const handleCreateProject = useCallback(() => {
setIsCreateModalVisible(true);
}, []);
@@ -336,17 +256,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
}
}, []);
// GitHub OAuth handlers
const handleGitHubConnect = useCallback(() => {
console.log('🔗 Initiating GitHub OAuth...');
GitHubOAuthService.instance.initiateOAuth();
}, []);
const handleGitHubDisconnect = useCallback(() => {
console.log('🔌 Disconnecting GitHub...');
GitHubOAuthService.instance.disconnect();
}, []);
return (
<>
<Launcher
@@ -357,11 +266,11 @@ export function ProjectsPage(props: ProjectsPageProps) {
onOpenProjectFolder={handleOpenProjectFolder}
onDeleteProject={handleDeleteProject}
projectOrganizationService={ProjectOrganizationService.instance}
githubUser={githubUser}
githubIsAuthenticated={githubIsAuthenticated}
githubIsConnecting={githubIsConnecting}
onGitHubConnect={handleGitHubConnect}
onGitHubDisconnect={handleGitHubDisconnect}
githubUser={null}
githubIsAuthenticated={false}
githubIsConnecting={false}
onGitHubConnect={() => {}}
onGitHubDisconnect={() => {}}
/>
<CreateProjectModal

View File

@@ -0,0 +1,308 @@
/**
* GitHubAuth
*
* Handles GitHub OAuth authentication using Web OAuth Flow.
* Web OAuth Flow allows users to select which organizations and repositories
* to grant access to, providing better permission control.
*
* @module services/github
* @since 1.1.0
*/
import { ipcRenderer, shell } from 'electron';
import { GitHubTokenStore } from './GitHubTokenStore';
import type {
GitHubAuthState,
GitHubDeviceCode,
GitHubToken,
GitHubAuthError,
GitHubUser,
GitHubInstallation
} from './GitHubTypes';
/**
* Scopes required for GitHub integration
* - repo: Full control of private repositories (for issues, PRs)
* - read:org: Read organization membership
* - read:user: Read user profile data
* - user:email: Read user email addresses
*/
const REQUIRED_SCOPES = ['repo', 'read:org', 'read:user', 'user:email'];
/**
* GitHubAuth
*
* Manages GitHub OAuth authentication using Device Flow.
* Provides methods to authenticate, check status, and disconnect.
*/
export class GitHubAuth {
/**
* Initiate GitHub Web OAuth flow
*
* Opens browser to GitHub authorization page where user can select
* which organizations and repositories to grant access to.
*
* @param onProgress - Callback for progress updates
* @returns Promise that resolves when authentication completes
*
* @throws {GitHubAuthError} If OAuth flow fails
*
* @example
* ```typescript
* await GitHubAuth.startWebOAuthFlow((message) => {
* console.log(message);
* });
* console.log('Successfully authenticated!');
* ```
*/
static async startWebOAuthFlow(onProgress?: (message: string) => void): Promise<void> {
try {
onProgress?.('Starting GitHub authentication...');
// Request OAuth flow from main process
const result = await ipcRenderer.invoke('github-oauth-start');
if (!result.success) {
throw new Error(result.error || 'Failed to start OAuth flow');
}
onProgress?.('Opening GitHub in your browser...');
// Open browser to GitHub authorization page
shell.openExternal(result.authUrl);
// Wait for OAuth callback from main process
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error('Authentication timed out after 5 minutes'));
}, 300000); // 5 minutes
const handleSuccess = async (_event: Electron.IpcRendererEvent, data: any) => {
console.log('🎉 [GitHub Auth] ========================================');
console.log('🎉 [GitHub Auth] IPC EVENT RECEIVED: github-oauth-complete');
console.log('🎉 [GitHub Auth] Data:', data);
console.log('🎉 [GitHub Auth] ========================================');
cleanup();
try {
onProgress?.('Authentication successful, fetching details...');
// Save token and user info
const token: GitHubToken = {
access_token: data.token.access_token,
token_type: data.token.token_type,
scope: data.token.scope
};
const installations = data.installations as GitHubInstallation[];
GitHubTokenStore.saveToken(token, data.user.login, data.user.email, installations);
onProgress?.(`Successfully authenticated as ${data.user.login}`);
resolve();
} catch (error) {
reject(error);
}
};
const handleError = (_event: Electron.IpcRendererEvent, data: any) => {
cleanup();
reject(new Error(data.message || 'Authentication failed'));
};
const cleanup = () => {
clearTimeout(timeout);
ipcRenderer.removeListener('github-oauth-complete', handleSuccess);
ipcRenderer.removeListener('github-oauth-error', handleError);
};
ipcRenderer.once('github-oauth-complete', handleSuccess);
ipcRenderer.once('github-oauth-error', handleError);
});
} catch (error) {
const authError: GitHubAuthError = new Error(
`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
authError.code = error instanceof Error && 'code' in error ? (error as { code?: string }).code : undefined;
console.error('[GitHub] Authentication error:', authError);
throw authError;
}
}
/**
* @deprecated Use startWebOAuthFlow instead. Device Flow kept for backward compatibility.
*/
static async startDeviceFlow(onProgress?: (message: string) => void): Promise<GitHubDeviceCode> {
console.warn('[GitHub] startDeviceFlow is deprecated, using startWebOAuthFlow instead');
await this.startWebOAuthFlow(onProgress);
// Return empty device code for backward compatibility
return {
device_code: '',
user_code: '',
verification_uri: '',
expires_in: 0,
interval: 0
};
}
/**
* Fetch user information from GitHub API
*
* @param token - Access token
* @returns User information
*
* @throws {Error} If API request fails
*/
private static async fetchUserInfo(token: string): Promise<GitHubUser> {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
return response.json();
}
/**
* Get current authentication state
*
* @returns Current auth state
*
* @example
* ```typescript
* const state = GitHubAuth.getAuthState();
* if (state.isAuthenticated) {
* console.log('Connected as:', state.username);
* }
* ```
*/
static getAuthState(): GitHubAuthState {
const storedAuth = GitHubTokenStore.getToken();
if (!storedAuth) {
return {
isAuthenticated: false
};
}
// Check if token is expired
if (GitHubTokenStore.isTokenExpired()) {
console.warn('[GitHub] Token is expired');
return {
isAuthenticated: false
};
}
return {
isAuthenticated: true,
username: storedAuth.user.login,
email: storedAuth.user.email || undefined,
token: storedAuth.token,
authenticatedAt: storedAuth.storedAt
};
}
/**
* Check if user is currently authenticated
*
* @returns True if authenticated and token is valid
*/
static isAuthenticated(): boolean {
return this.getAuthState().isAuthenticated;
}
/**
* Get the username of authenticated user
*
* @returns Username or null if not authenticated
*/
static getUsername(): string | null {
return this.getAuthState().username || null;
}
/**
* Get current access token
*
* @returns Access token or null if not authenticated
*/
static getAccessToken(): string | null {
const state = this.getAuthState();
return state.token?.access_token || null;
}
/**
* Disconnect from GitHub
*
* Clears stored authentication data. User will need to re-authenticate.
*
* @example
* ```typescript
* GitHubAuth.disconnect();
* console.log('Disconnected from GitHub');
* ```
*/
static disconnect(): void {
GitHubTokenStore.clearToken();
console.log('[GitHub] User disconnected');
}
/**
* Validate current token by making a test API call
*
* @returns True if token is valid, false otherwise
*/
static async validateToken(): Promise<boolean> {
const token = this.getAccessToken();
if (!token) {
return false;
}
try {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
return response.ok;
} catch (error) {
console.error('[GitHub] Token validation failed:', error);
return false;
}
}
/**
* Refresh user information from GitHub
*
* Useful for updating cached user data
*
* @returns Updated auth state
* @throws {Error} If not authenticated or refresh fails
*/
static async refreshUserInfo(): Promise<GitHubAuthState> {
const token = this.getAccessToken();
if (!token) {
throw new Error('Not authenticated');
}
const user = await this.fetchUserInfo(token);
// Update stored auth with new user info
const storedAuth = GitHubTokenStore.getToken();
if (storedAuth) {
GitHubTokenStore.saveToken(storedAuth.token, user.login, user.email);
}
return this.getAuthState();
}
}

View File

@@ -0,0 +1,255 @@
/**
* GitHubClient
*
* Wrapper around Octokit REST API client with authentication and rate limiting.
* Provides convenient methods for GitHub API operations needed by OpenNoodl.
*
* @module services/github
* @since 1.1.0
*/
import { Octokit } from '@octokit/rest';
import { GitHubAuth } from './GitHubAuth';
import type { GitHubRepository, GitHubRateLimit, GitHubUser } from './GitHubTypes';
/**
* GitHubClient
*
* Main client for GitHub API interactions.
* Automatically uses authenticated token from GitHubAuth.
* Handles rate limiting and provides typed API methods.
*/
export class GitHubClient {
private octokit: Octokit | null = null;
private lastRateLimit: GitHubRateLimit | null = null;
/**
* Initialize Octokit instance with current auth token
*
* @returns Octokit instance or null if not authenticated
*/
private getOctokit(): Octokit | null {
const token = GitHubAuth.getAccessToken();
if (!token) {
console.warn('[GitHub Client] Not authenticated');
return null;
}
// Create new instance if token changed or doesn't exist
if (!this.octokit) {
this.octokit = new Octokit({
auth: token,
userAgent: 'OpenNoodl/1.1.0'
});
}
return this.octokit;
}
/**
* Check if client is ready (authenticated)
*
* @returns True if client has valid auth token
*/
isReady(): boolean {
return GitHubAuth.isAuthenticated();
}
/**
* Get current rate limit status
*
* @returns Rate limit information
* @throws {Error} If not authenticated
*/
async getRateLimit(): Promise<GitHubRateLimit> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.rateLimit.get();
const core = response.data.resources.core;
const rateLimit: GitHubRateLimit = {
limit: core.limit,
remaining: core.remaining,
reset: core.reset,
resource: 'core'
};
this.lastRateLimit = rateLimit;
return rateLimit;
}
/**
* Check if we're approaching rate limit
*
* @returns True if remaining requests < 100
*/
isApproachingRateLimit(): boolean {
if (!this.lastRateLimit) {
return false;
}
return this.lastRateLimit.remaining < 100;
}
/**
* Get authenticated user's information
*
* @returns User information
* @throws {Error} If not authenticated or API call fails
*/
async getAuthenticatedUser(): Promise<GitHubUser> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.users.getAuthenticated();
return response.data as GitHubUser;
}
/**
* Get repository information
*
* @param owner - Repository owner
* @param repo - Repository name
* @returns Repository information
* @throws {Error} If repository not found or API call fails
*/
async getRepository(owner: string, repo: string): Promise<GitHubRepository> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.repos.get({ owner, repo });
return response.data as GitHubRepository;
}
/**
* List user's repositories
*
* @param options - Listing options
* @returns Array of repositories
* @throws {Error} If not authenticated or API call fails
*/
async listRepositories(options?: {
visibility?: 'all' | 'public' | 'private';
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
per_page?: number;
}): Promise<GitHubRepository[]> {
const octokit = this.getOctokit();
if (!octokit) {
throw new Error('Not authenticated with GitHub');
}
const response = await octokit.repos.listForAuthenticatedUser({
visibility: options?.visibility || 'all',
sort: options?.sort || 'updated',
per_page: options?.per_page || 30
});
return response.data as GitHubRepository[];
}
/**
* Check if a repository exists and user has access
*
* @param owner - Repository owner
* @param repo - Repository name
* @returns True if repository exists and accessible
*/
async repositoryExists(owner: string, repo: string): Promise<boolean> {
try {
await this.getRepository(owner, repo);
return true;
} catch (error) {
return false;
}
}
/**
* Parse repository URL to owner/repo
*
* Handles various GitHub URL formats:
* - https://github.com/owner/repo
* - git@github.com:owner/repo.git
* - https://github.com/owner/repo.git
*
* @param url - GitHub repository URL
* @returns Object with owner and repo, or null if invalid
*/
static parseRepoUrl(url: string): { owner: string; repo: string } | null {
try {
// Remove .git suffix if present
const cleanUrl = url.replace(/\.git$/, '');
// Handle SSH format: git@github.com:owner/repo
if (cleanUrl.includes('git@github.com:')) {
const parts = cleanUrl.split('git@github.com:')[1].split('/');
if (parts.length >= 2) {
return {
owner: parts[0],
repo: parts[1]
};
}
}
// Handle HTTPS format: https://github.com/owner/repo
if (cleanUrl.includes('github.com/')) {
const parts = cleanUrl.split('github.com/')[1].split('/');
if (parts.length >= 2) {
return {
owner: parts[0],
repo: parts[1]
};
}
}
return null;
} catch (error) {
console.error('[GitHub Client] Error parsing repo URL:', error);
return null;
}
}
/**
* Get repository from local Git remote URL
*
* Useful for getting GitHub repo info from current project's git remote.
*
* @param remoteUrl - Git remote URL
* @returns Repository information if GitHub repo, null otherwise
*/
async getRepositoryFromRemoteUrl(remoteUrl: string): Promise<GitHubRepository | null> {
const parsed = GitHubClient.parseRepoUrl(remoteUrl);
if (!parsed) {
return null;
}
try {
return await this.getRepository(parsed.owner, parsed.repo);
} catch (error) {
console.error('[GitHub Client] Error fetching repository:', error);
return null;
}
}
/**
* Reset client state
*
* Call this when user disconnects or token changes.
*/
reset(): void {
this.octokit = null;
this.lastRateLimit = null;
}
}
/**
* Singleton instance of GitHubClient
* Use this for all GitHub API operations
*/
export const githubClient = new GitHubClient();

View File

@@ -0,0 +1,217 @@
/**
* GitHubTokenStore
*
* Secure storage for GitHub OAuth tokens using Electron Store.
* Tokens are stored encrypted using Electron's safeStorage API.
* This provides OS-level encryption (Keychain on macOS, Credential Manager on Windows).
*
* @module services/github
* @since 1.1.0
*/
import ElectronStore from 'electron-store';
import type { StoredGitHubAuth, GitHubToken, GitHubInstallation } from './GitHubTypes';
/**
* Store key for GitHub authentication data
*/
const GITHUB_AUTH_KEY = 'github.auth';
/**
* Electron store instance for GitHub credentials
* Uses encryption for sensitive data
*/
const store = new ElectronStore<{
'github.auth'?: StoredGitHubAuth;
}>({
name: 'github-credentials',
// Encrypt the entire store for security
encryptionKey: 'opennoodl-github-credentials'
});
/**
* GitHubTokenStore
*
* Manages secure storage and retrieval of GitHub OAuth tokens.
* Provides methods to save, retrieve, and clear authentication data.
*/
export class GitHubTokenStore {
/**
* Save GitHub authentication data to secure storage
*
* @param token - OAuth access token
* @param username - GitHub username
* @param email - User's email (nullable)
* @param installations - Optional list of installations (orgs/repos with access)
*
* @example
* ```typescript
* await GitHubTokenStore.saveToken(
* { access_token: 'gho_...', token_type: 'bearer', scope: 'repo' },
* 'octocat',
* 'octocat@github.com',
* installations
* );
* ```
*/
static saveToken(
token: GitHubToken,
username: string,
email: string | null,
installations?: GitHubInstallation[]
): void {
const authData: StoredGitHubAuth = {
token,
user: {
login: username,
email
},
installations,
storedAt: new Date().toISOString()
};
store.set(GITHUB_AUTH_KEY, authData);
if (installations && installations.length > 0) {
const orgNames = installations.map((i) => i.account.login).join(', ');
console.log(`[GitHub] Token saved for user: ${username} with access to: ${orgNames}`);
} else {
console.log('[GitHub] Token saved for user:', username);
}
}
/**
* Get installations (organizations/repos with access)
*
* @returns List of installations if authenticated, empty array otherwise
*/
static getInstallations(): GitHubInstallation[] {
const authData = this.getToken();
return authData?.installations || [];
}
/**
* Retrieve stored GitHub authentication data
*
* @returns Stored auth data if exists, null otherwise
*
* @example
* ```typescript
* const authData = GitHubTokenStore.getToken();
* if (authData) {
* console.log('Authenticated as:', authData.user.login);
* }
* ```
*/
static getToken(): StoredGitHubAuth | null {
try {
const authData = store.get(GITHUB_AUTH_KEY);
return authData || null;
} catch (error) {
console.error('[GitHub] Error reading token:', error);
return null;
}
}
/**
* Check if a valid token exists
*
* @returns True if token exists, false otherwise
*
* @example
* ```typescript
* if (GitHubTokenStore.hasToken()) {
* // User is authenticated
* }
* ```
*/
static hasToken(): boolean {
const authData = this.getToken();
return authData !== null && !!authData.token.access_token;
}
/**
* Get the username of the authenticated user
*
* @returns Username if authenticated, null otherwise
*/
static getUsername(): string | null {
const authData = this.getToken();
return authData?.user.login || null;
}
/**
* Get the access token string
*
* @returns Access token if exists, null otherwise
*/
static getAccessToken(): string | null {
const authData = this.getToken();
return authData?.token.access_token || null;
}
/**
* Clear stored authentication data
* Call this when user disconnects their GitHub account
*
* @example
* ```typescript
* GitHubTokenStore.clearToken();
* console.log('User disconnected from GitHub');
* ```
*/
static clearToken(): void {
store.delete(GITHUB_AUTH_KEY);
console.log('[GitHub] Token cleared');
}
/**
* Check if token is expired (if expiration is set)
*
* @returns True if token is expired, false if valid or no expiration
*/
static isTokenExpired(): boolean {
const authData = this.getToken();
if (!authData || !authData.token.expires_at) {
// No expiration set - assume valid
return false;
}
const expiresAt = new Date(authData.token.expires_at);
const now = new Date();
return now >= expiresAt;
}
/**
* Update token (for refresh scenarios)
*
* @param token - New OAuth token
*/
static updateToken(token: GitHubToken): void {
const existing = this.getToken();
if (!existing) {
throw new Error('Cannot update token: No existing auth data found');
}
const updated: StoredGitHubAuth = {
...existing,
token,
storedAt: new Date().toISOString()
};
store.set(GITHUB_AUTH_KEY, updated);
console.log('[GitHub] Token updated');
}
/**
* Get all stored GitHub data (for debugging)
* WARNING: Contains sensitive data - use carefully
*
* @returns All stored data
*/
static _debug_getAllData(): StoredGitHubAuth | null {
return this.getToken();
}
}

View File

@@ -0,0 +1,184 @@
/**
* GitHubTypes
*
* TypeScript type definitions for GitHub OAuth and API integration.
* These types define the structure of tokens, authentication state, and API responses.
*
* @module services/github
* @since 1.1.0
*/
/**
* OAuth device code response from GitHub
* Returned when initiating device flow authorization
*/
export interface GitHubDeviceCode {
/** The device verification code */
device_code: string;
/** The user verification code (8-character code) */
user_code: string;
/** URL where user enters the code */
verification_uri: string;
/** Expiration time in seconds (default: 900) */
expires_in: number;
/** Polling interval in seconds (default: 5) */
interval: number;
}
/**
* GitHub OAuth access token
* Stored securely and used for API authentication
*/
export interface GitHubToken {
/** The OAuth access token */
access_token: string;
/** Token type (always 'bearer' for GitHub) */
token_type: string;
/** Granted scopes (comma-separated) */
scope: string;
/** Token expiration timestamp (ISO 8601) - undefined if no expiration */
expires_at?: string;
}
/**
* Current GitHub authentication state
* Used by React components to display connection status
*/
export interface GitHubAuthState {
/** Whether user is authenticated with GitHub */
isAuthenticated: boolean;
/** GitHub username if authenticated */
username?: string;
/** User's primary email if authenticated */
email?: string;
/** Current token (for internal use only) */
token?: GitHubToken;
/** Timestamp of last successful authentication */
authenticatedAt?: string;
}
/**
* GitHub user information
* Retrieved from /user API endpoint
*/
export interface GitHubUser {
/** GitHub username */
login: string;
/** GitHub user ID */
id: number;
/** User's display name */
name: string | null;
/** User's primary email */
email: string | null;
/** Avatar URL */
avatar_url: string;
/** Profile URL */
html_url: string;
/** User type (User or Organization) */
type: string;
}
/**
* GitHub repository information
* Basic repo details for issue/PR association
*/
export interface GitHubRepository {
/** Repository ID */
id: number;
/** Repository name (without owner) */
name: string;
/** Full repository name (owner/repo) */
full_name: string;
/** Repository owner */
owner: {
login: string;
id: number;
avatar_url: string;
};
/** Whether repo is private */
private: boolean;
/** Repository URL */
html_url: string;
/** Default branch */
default_branch: string;
}
/**
* GitHub App installation information
* Represents organizations/accounts where the app was installed
*/
export interface GitHubInstallation {
/** Installation ID */
id: number;
/** Account where app is installed */
account: {
login: string;
type: 'User' | 'Organization';
avatar_url: string;
};
/** Repository selection type */
repository_selection: 'all' | 'selected';
/** List of repositories (if selected) */
repositories?: Array<{
id: number;
name: string;
full_name: string;
private: boolean;
}>;
}
/**
* Rate limit information from GitHub API
* Used to prevent hitting API limits
*/
export interface GitHubRateLimit {
/** Maximum requests allowed per hour */
limit: number;
/** Remaining requests in current window */
remaining: number;
/** Timestamp when rate limit resets (Unix epoch) */
reset: number;
/** Resource type (core, search, graphql) */
resource: string;
}
/**
* Error response from GitHub API
*/
export interface GitHubError {
/** HTTP status code */
status: number;
/** Error message */
message: string;
/** Detailed documentation URL if available */
documentation_url?: string;
}
/**
* OAuth authorization error
* Thrown during device flow authorization
*/
export interface GitHubAuthError extends Error {
/** Error code from GitHub */
code?: string;
/** HTTP status if applicable */
status?: number;
}
/**
* Stored token data (persisted format)
* Encrypted and stored in Electron's secure storage
*/
export interface StoredGitHubAuth {
/** OAuth token */
token: GitHubToken;
/** Associated user info */
user: {
login: string;
email: string | null;
};
/** Installation information (organizations/repos with access) */
installations?: GitHubInstallation[];
/** Timestamp when stored */
storedAt: string;
}

View File

@@ -0,0 +1,41 @@
/**
* GitHub Services
*
* Public exports for GitHub OAuth authentication and API integration.
* This module provides everything needed to connect to GitHub,
* authenticate users, and interact with the GitHub API.
*
* @module services/github
* @since 1.1.0
*
* @example
* ```typescript
* import { GitHubAuth, githubClient } from '@noodl-services/github';
*
* // Check if authenticated
* if (GitHubAuth.isAuthenticated()) {
* // Fetch user repos
* const repos = await githubClient.listRepositories();
* }
* ```
*/
// Authentication
export { GitHubAuth } from './GitHubAuth';
export { GitHubTokenStore } from './GitHubTokenStore';
// API Client
export { GitHubClient, githubClient } from './GitHubClient';
// Types
export type {
GitHubDeviceCode,
GitHubToken,
GitHubAuthState,
GitHubUser,
GitHubRepository,
GitHubRateLimit,
GitHubError,
GitHubAuthError,
StoredGitHubAuth
} from './GitHubTypes';

View File

@@ -232,8 +232,8 @@
}
:root {
--popup-layer-tooltip-border-color: var(--theme-color-secondary);
--popup-layer-tooltip-background-color: var(--theme-color-secondary);
--popup-layer-tooltip-border-color: var(--theme-color-border-default);
--popup-layer-tooltip-background-color: var(--theme-color-bg-3);
}
.popup-layer-tooltip {
@@ -244,7 +244,7 @@
border-color: var(--popup-layer-tooltip-border-color);
border-width: 1px;
padding: 12px 16px;
color: var(--theme-color-fg-highlight);
color: var(--theme-color-fg-default);
position: absolute;
opacity: 0;
-webkit-transition: opacity 0.3s;

View File

@@ -1,4 +1,7 @@
<div class="nodegrapgeditor-bg nodegrapheditor-canvas" style="width: 100%; height: 100%">
<!-- Canvas Tabs Root (for React component) -->
<div id="canvas-tabs-root" style="position: absolute; width: 100%; height: 100%; z-index: 100; pointer-events: none;"></div>
<!--
wrap in a div to not trigger chromium bug where comments "scrolls" all the siblings
if comments are below the bottom of the parent

View File

@@ -0,0 +1,51 @@
/**
* Blockly Editor Globals
*
* Exposes Blockly-related utilities to the global scope for use by runtime nodes
*/
import { generateCode } from '../views/BlocklyEditor/NoodlGenerators';
import { detectIO } from './IODetector';
// Extend window interface
declare global {
interface Window {
NoodlEditor?: {
detectIO?: typeof detectIO;
generateBlocklyCode?: typeof generateCode;
};
}
}
/**
* Initialize Blockly editor globals
* This makes IODetector and code generation available to runtime nodes
*/
export function initBlocklyEditorGlobals() {
console.log('🔍 [BlocklyGlobals] initBlocklyEditorGlobals called');
console.log('🔍 [BlocklyGlobals] window undefined?', typeof window === 'undefined');
// Create NoodlEditor namespace if it doesn't exist
if (typeof window !== 'undefined') {
console.log('🔍 [BlocklyGlobals] window.NoodlEditor before:', window.NoodlEditor);
if (!window.NoodlEditor) {
window.NoodlEditor = {};
console.log('🔍 [BlocklyGlobals] Created new window.NoodlEditor');
}
// Expose IODetector
window.NoodlEditor.detectIO = detectIO;
console.log('🔍 [BlocklyGlobals] Assigned detectIO:', typeof window.NoodlEditor.detectIO);
// Expose code generator
window.NoodlEditor.generateBlocklyCode = generateCode;
console.log('🔍 [BlocklyGlobals] Assigned generateBlocklyCode:', typeof window.NoodlEditor.generateBlocklyCode);
console.log('✅ [Blockly] Editor globals initialized');
console.log('🔍 [BlocklyGlobals] window.NoodlEditor after:', window.NoodlEditor);
}
}
// Auto-initialize when module loads
initBlocklyEditorGlobals();

View File

@@ -0,0 +1,189 @@
/**
* IODetector
*
* Utility for detecting inputs, outputs, and signals from Blockly workspaces.
* Scans workspace JSON to find Input/Output definition blocks and extracts their configuration.
*
* @module utils
*/
export interface DetectedInput {
name: string;
type: string;
}
export interface DetectedOutput {
name: string;
type: string;
}
export interface DetectedIO {
inputs: DetectedInput[];
outputs: DetectedOutput[];
signalInputs: string[];
signalOutputs: string[];
}
interface BlocklyBlock {
type?: string;
fields?: Record<string, string>;
inputs?: Record<string, { block?: BlocklyBlock }>;
next?: { block?: BlocklyBlock };
}
/**
* Detect all I/O from a Blockly workspace JSON
*
* @param workspaceJson - Serialized Blockly workspace (JSON string or object)
* @returns Detected inputs, outputs, and signals
*/
export function detectIO(workspaceJson: string | object): DetectedIO {
const result: DetectedIO = {
inputs: [],
outputs: [],
signalInputs: [],
signalOutputs: []
};
try {
const workspace = typeof workspaceJson === 'string' ? JSON.parse(workspaceJson) : workspaceJson;
if (!workspace || !workspace.blocks || !workspace.blocks.blocks) {
return result;
}
const blocks = workspace.blocks.blocks;
for (const block of blocks) {
processBlock(block, result);
}
} catch (error) {
console.error('[IODetector] Failed to parse workspace:', error);
}
// Remove duplicates
result.inputs = uniqueBy(result.inputs, 'name');
result.outputs = uniqueBy(result.outputs, 'name');
result.signalInputs = Array.from(new Set(result.signalInputs));
result.signalOutputs = Array.from(new Set(result.signalOutputs));
return result;
}
/**
* Recursively process a block and its children
*/
function processBlock(block: BlocklyBlock, result: DetectedIO): void {
if (!block || !block.type) return;
switch (block.type) {
case 'noodl_define_input':
// Extract input definition
if (block.fields && block.fields.NAME && block.fields.TYPE) {
result.inputs.push({
name: block.fields.NAME,
type: block.fields.TYPE
});
}
break;
case 'noodl_get_input':
// Auto-detect input from usage
if (block.fields && block.fields.NAME) {
const name = block.fields.NAME;
if (!result.inputs.find((i) => i.name === name)) {
result.inputs.push({
name: name,
type: '*' // Default type
});
}
}
break;
case 'noodl_define_output':
// Extract output definition
if (block.fields && block.fields.NAME && block.fields.TYPE) {
result.outputs.push({
name: block.fields.NAME,
type: block.fields.TYPE
});
}
break;
case 'noodl_set_output':
// Auto-detect output from usage
if (block.fields && block.fields.NAME) {
const name = block.fields.NAME;
if (!result.outputs.find((o) => o.name === name)) {
result.outputs.push({
name: name,
type: '*' // Default type
});
}
}
break;
case 'noodl_define_signal_input':
// Extract signal input definition
if (block.fields && block.fields.NAME) {
result.signalInputs.push(block.fields.NAME);
}
break;
case 'noodl_on_signal':
// Auto-detect signal input from event handler
if (block.fields && block.fields.SIGNAL) {
const name = block.fields.SIGNAL;
if (!result.signalInputs.includes(name)) {
result.signalInputs.push(name);
}
}
break;
case 'noodl_define_signal_output':
// Extract signal output definition
if (block.fields && block.fields.NAME) {
result.signalOutputs.push(block.fields.NAME);
}
break;
case 'noodl_send_signal':
// Auto-detect signal output from send blocks
if (block.fields && block.fields.NAME) {
const name = block.fields.NAME;
if (!result.signalOutputs.includes(name)) {
result.signalOutputs.push(name);
}
}
break;
}
// Process nested blocks (inputs, next, etc.)
if (block.inputs) {
for (const inputKey in block.inputs) {
const input = block.inputs[inputKey];
if (input && input.block) {
processBlock(input.block, result);
}
}
}
if (block.next && block.next.block) {
processBlock(block.next.block, result);
}
}
/**
* Remove duplicates from array based on key
*/
function uniqueBy<T>(array: T[], key: keyof T): T[] {
const seen = new Set();
return array.filter((item) => {
const k = item[key];
if (seen.has(k)) {
return false;
}
seen.add(k);
return true;
});
}

View File

@@ -13,6 +13,7 @@ import Model from '../../../shared/model';
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
import { RuntimeVersionInfo } from '../models/migration/types';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import { GitHubAuth } from '../services/github';
import FileSystem from './filesystem';
import { tracker } from './tracker';
import { guid } from './utils';
@@ -119,6 +120,10 @@ export class LocalProjectsModel extends Model {
project.name = projectEntry.name; // Also assign the name
this.touchProject(projectEntry);
this.bindProject(project);
// Initialize Git authentication for this project
this.setCurrentGlobalGitAuth(projectEntry.id);
resolve(project);
});
});
@@ -329,13 +334,34 @@ export class LocalProjectsModel extends Model {
setCurrentGlobalGitAuth(projectId: string) {
const func = async (endpoint: string) => {
if (endpoint.includes('github.com')) {
// Priority 1: Check for global OAuth token
const authState = GitHubAuth.getAuthState();
if (authState.isAuthenticated && authState.token) {
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint);
return {
username: authState.username || 'oauth',
password: authState.token.access_token // Extract actual access token string
};
}
// Priority 2: Fall back to project-specific PAT
const config = await GitStore.get('github', projectId);
//username is not used by github when using a token, but git will still ask for it. Just set it to "noodl"
if (config?.password) {
console.log('[Git Auth] Using project PAT for:', endpoint);
return {
username: 'noodl',
password: config.password
};
}
// No credentials available
console.warn('[Git Auth] No GitHub credentials found for:', endpoint);
return {
username: 'noodl',
password: config?.password
password: ''
};
} else {
// Non-GitHub providers use project-specific credentials only
const config = await GitStore.get('unknown', projectId);
return {
username: config?.username,

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

@@ -0,0 +1,250 @@
/**
* BlocklyWorkspace Styles
*
* Styling for the Blockly visual programming workspace.
* Uses theme tokens for consistent integration with Noodl editor.
*/
.Root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: var(--theme-color-bg-1);
overflow: hidden;
}
.BlocklyContainer {
flex: 1;
width: 100%;
height: 100%;
position: relative;
/* Ensure Blockly SVG fills container */
& > .injectionDiv {
width: 100% !important;
height: 100% !important;
}
}
/* Override Blockly default styles to match Noodl theme */
:global {
/* Toolbox styling */
.blocklyToolboxDiv {
background-color: var(--theme-color-bg-2) !important;
border-right: 1px solid var(--theme-color-border-default) !important;
}
.blocklyTreeLabel {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
}
.blocklyTreeRow:hover {
background-color: var(--theme-color-bg-3) !important;
}
.blocklyTreeSelected {
background-color: var(--theme-color-primary) !important;
}
/* Flyout styling */
.blocklyFlyoutBackground {
fill: var(--theme-color-bg-2) !important;
fill-opacity: 0.95 !important;
}
/* Block styling - keep default Blockly colors for now */
/* May customize later to match Noodl node colors */
/* Zoom controls */
.blocklyZoom {
& image {
filter: brightness(0.8);
}
}
/* Trashcan */
.blocklyTrash {
& image {
filter: brightness(0.8);
}
}
/* Context menu */
.blocklyContextMenu {
background-color: var(--theme-color-bg-3) !important;
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
.blocklyContextMenu .blocklyMenuItem {
color: var(--theme-color-fg-default) !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
padding: 6px 12px !important;
&:hover,
&:hover * {
background-color: var(--theme-color-bg-4) !important;
color: var(--theme-color-fg-default) !important;
}
&.blocklyMenuItemDisabled {
color: var(--theme-color-fg-default-shy) !important;
opacity: 0.5;
}
}
/* Scrollbars */
.blocklyScrollbarHandle {
fill: var(--theme-color-border-default) !important;
}
/* Field editor backgrounds (dropdowns, text inputs, etc.) */
/* NOTE: blocklyWidgetDiv and blocklyDropDownDiv are rendered at document root! */
.blocklyWidgetDiv,
.blocklyDropDownDiv {
z-index: 10000 !important; /* Ensure it's above everything */
}
/* Blockly dropdown container - DARK BACKGROUND */
.blocklyDropDownDiv,
:global(.blocklyDropDownDiv) {
background-color: var(--theme-color-bg-3) !important; /* DARK background */
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
/* Inner scrollable container */
& > div {
background-color: var(--theme-color-bg-3) !important;
max-height: 400px !important;
overflow-y: auto !important;
}
/* SVG containers inside dropdown */
& svg {
background-color: var(--theme-color-bg-3) !important;
}
}
/* Text input fields */
.blocklyWidgetDiv input,
.blocklyHtmlInput {
background-color: var(--theme-color-bg-3) !important;
color: var(--theme-color-fg-default) !important;
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-family: var(--theme-font-family) !important;
}
/* Dropdown menus - DARK BACKGROUND with WHITE TEXT (matches Noodl theme) */
/* Target ACTUAL Blockly classes: .blocklyMenuItem not .goog-menuitem */
.goog-menu,
:global(.goog-menu) {
background-color: var(--theme-color-bg-3) !important; /* DARK background */
border: 1px solid var(--theme-color-border-default) !important;
border-radius: 4px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
z-index: 10001 !important;
}
/* Target Blockly's ACTUAL menu item class - DROPDOWN MENUS */
.blocklyDropDownDiv .blocklyMenuItem,
:global(.blocklyDropDownDiv) :global(.blocklyMenuItem) {
color: #ffffff !important; /* WHITE text */
background-color: transparent !important;
padding: 6px 12px !important;
cursor: pointer !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
/* ALL children white */
& *,
& div,
& span {
color: #ffffff !important;
}
/* HOVER - Keep white text with lighter background */
&:hover,
&:hover *,
&:hover div,
&:hover span {
background-color: var(--theme-color-bg-4) !important;
color: #ffffff !important; /* WHITE on hover */
}
&[aria-selected='true'],
&[aria-selected='true'] * {
background-color: var(--theme-color-primary) !important;
color: #ffffff !important;
}
&[aria-disabled='true'],
&[aria-disabled='true'] * {
color: #999999 !important;
opacity: 0.5;
cursor: not-allowed !important;
}
}
/* Target Blockly's ACTUAL content class */
.blocklyMenuItemContent,
:global(.blocklyMenuItemContent) {
color: #ffffff !important;
& *,
& div,
& span {
color: #ffffff !important;
}
}
/* Fallback for goog- classes if they exist */
.goog-menuitem,
.goog-option,
:global(.goog-menuitem),
:global(.goog-option) {
color: #ffffff !important;
background-color: transparent !important;
padding: 6px 12px !important;
font-family: var(--theme-font-family) !important;
font-size: 13px !important;
& *,
& div,
& span {
color: #ffffff !important;
}
&:hover,
&:hover * {
background-color: var(--theme-color-bg-4) !important;
}
}
.goog-menuitem-content,
:global(.goog-menuitem-content) {
color: #ffffff !important;
& * {
color: #ffffff !important;
}
}
/* Blockly dropdown content container */
:global(.blocklyDropDownContent) {
max-height: 400px !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}
}

View File

@@ -0,0 +1,263 @@
/**
* BlocklyWorkspace Component
*
* React wrapper for Google Blockly visual programming workspace.
* Provides integration with Noodl's node system for visual logic building.
*
* @module BlocklyEditor
*/
import DarkTheme from '@blockly/theme-dark';
import * as Blockly from 'blockly';
import { javascriptGenerator } from 'blockly/javascript';
import React, { useEffect, useRef } from 'react';
import css from './BlocklyWorkspace.module.scss';
import { initBlocklyIntegration } from './index';
export interface BlocklyWorkspaceProps {
/** Initial workspace JSON (for loading saved state) */
initialWorkspace?: string;
/** Toolbox configuration */
toolbox?: Blockly.utils.toolbox.ToolboxDefinition;
/** Callback when workspace changes */
onChange?: (workspace: Blockly.WorkspaceSvg, json: string, code: string) => void;
/** Read-only mode */
readOnly?: boolean;
/** Custom theme */
theme?: Blockly.Theme;
}
/**
* BlocklyWorkspace - React component for Blockly integration
*
* Handles:
* - Blockly workspace initialization
* - Workspace persistence (save/load)
* - Change detection and callbacks
* - Cleanup on unmount
*/
export function BlocklyWorkspace({
initialWorkspace,
toolbox,
onChange,
readOnly = false,
theme
}: BlocklyWorkspaceProps) {
const blocklyDiv = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
const changeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Initialize Blockly workspace
useEffect(() => {
if (!blocklyDiv.current) return;
// Initialize custom Noodl blocks and generators before creating workspace
initBlocklyIntegration();
console.log('🔧 [Blockly] Initializing workspace');
// Inject Blockly with dark theme
const workspace = Blockly.inject(blocklyDiv.current, {
toolbox: toolbox || getDefaultToolbox(),
theme: theme || DarkTheme,
readOnly: readOnly,
trashcan: true,
zoom: {
controls: true,
wheel: true,
startScale: 1.0,
maxScale: 3,
minScale: 0.3,
scaleSpeed: 1.2
},
grid: {
spacing: 20,
length: 3,
colour: '#ccc',
snap: true
}
});
workspaceRef.current = workspace;
// Load initial workspace if provided
if (initialWorkspace) {
try {
const json = JSON.parse(initialWorkspace);
Blockly.serialization.workspaces.load(json, workspace);
console.log('✅ [Blockly] Loaded initial workspace');
} catch (error) {
console.error('❌ [Blockly] Failed to load initial workspace:', error);
}
}
// Listen for changes - filter to only respond to finished workspace changes,
// not UI events like dragging or moving blocks
const changeListener = (event: Blockly.Events.Abstract) => {
if (!onChange || !workspace) return;
// Ignore UI events that don't change the workspace structure
// These fire constantly during drags and can cause state corruption
if (event.type === Blockly.Events.BLOCK_DRAG) return;
if (event.type === Blockly.Events.BLOCK_MOVE && !event.isUiEvent) return; // Allow programmatic moves
if (event.type === Blockly.Events.SELECTED) return;
if (event.type === Blockly.Events.CLICK) return;
if (event.type === Blockly.Events.VIEWPORT_CHANGE) return;
if (event.type === Blockly.Events.TOOLBOX_ITEM_SELECT) return;
if (event.type === Blockly.Events.THEME_CHANGE) return;
if (event.type === Blockly.Events.TRASHCAN_OPEN) return;
// For UI events that DO change the workspace, debounce them
const isUiEvent = event.isUiEvent;
if (isUiEvent) {
// Clear any pending timeout for UI events
if (changeTimeoutRef.current) {
clearTimeout(changeTimeoutRef.current);
}
// Debounce UI-initiated changes (user editing)
changeTimeoutRef.current = setTimeout(() => {
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
const code = javascriptGenerator.workspaceToCode(workspace);
console.log('[Blockly] Generated code:', code);
onChange(workspace, json, code);
}, 300);
} else {
// Programmatic changes fire immediately (e.g., undo/redo, loading)
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
const code = javascriptGenerator.workspaceToCode(workspace);
console.log('[Blockly] Generated code:', code);
onChange(workspace, json, code);
}
};
workspace.addChangeListener(changeListener);
// Cleanup
return () => {
console.log('🧹 [Blockly] Disposing workspace');
// Clear any pending debounced calls
if (changeTimeoutRef.current) {
clearTimeout(changeTimeoutRef.current);
}
workspace.removeChangeListener(changeListener);
workspace.dispose();
workspaceRef.current = null;
};
}, [toolbox, theme, readOnly]);
// NOTE: Do NOT reload workspace on initialWorkspace changes!
// The initialWorkspace prop changes on every save, which would cause corruption.
// Workspace is loaded ONCE on mount above, and changes are saved via onChange callback.
return (
<div className={css.Root}>
<div ref={blocklyDiv} className={css.BlocklyContainer} />
</div>
);
}
/**
* Default toolbox with Noodl-specific blocks
*/
function getDefaultToolbox(): Blockly.utils.toolbox.ToolboxDefinition {
return {
kind: 'categoryToolbox',
contents: [
// Noodl I/O Category
{
kind: 'category',
name: 'Noodl Inputs/Outputs',
colour: '230',
contents: [
{ kind: 'block', type: 'noodl_define_input' },
{ kind: 'block', type: 'noodl_get_input' },
{ kind: 'block', type: 'noodl_define_output' },
{ kind: 'block', type: 'noodl_set_output' }
]
},
// Noodl Signals Category
{
kind: 'category',
name: 'Noodl Signals',
colour: '180',
contents: [
{ kind: 'block', type: 'noodl_define_signal_input' },
{ kind: 'block', type: 'noodl_define_signal_output' },
{ kind: 'block', type: 'noodl_send_signal' }
]
},
// Noodl Variables Category
{
kind: 'category',
name: 'Noodl Variables',
colour: '330',
contents: [
{ kind: 'block', type: 'noodl_get_variable' },
{ kind: 'block', type: 'noodl_set_variable' }
]
},
// Noodl Objects Category
{
kind: 'category',
name: 'Noodl Objects',
colour: '20',
contents: [
{ kind: 'block', type: 'noodl_get_object' },
{ kind: 'block', type: 'noodl_get_object_property' },
{ kind: 'block', type: 'noodl_set_object_property' }
]
},
// Noodl Arrays Category
{
kind: 'category',
name: 'Noodl Arrays',
colour: '260',
contents: [
{ kind: 'block', type: 'noodl_get_array' },
{ kind: 'block', type: 'noodl_array_length' },
{ kind: 'block', type: 'noodl_array_add' }
]
},
// Standard Logic blocks (useful for conditionals)
{
kind: 'category',
name: 'Logic',
colour: '210',
contents: [
{ kind: 'block', type: 'controls_if' },
{ kind: 'block', type: 'logic_compare' },
{ kind: 'block', type: 'logic_operation' },
{ kind: 'block', type: 'logic_negate' },
{ kind: 'block', type: 'logic_boolean' }
]
},
// Standard Math blocks
{
kind: 'category',
name: 'Math',
colour: '230',
contents: [
{ kind: 'block', type: 'math_number' },
{ kind: 'block', type: 'math_arithmetic' },
{ kind: 'block', type: 'math_single' }
]
},
// Standard Text blocks
{
kind: 'category',
name: 'Text',
colour: '160',
contents: [
{ kind: 'block', type: 'text' },
{ kind: 'block', type: 'text_join' },
{ kind: 'block', type: 'text_length' }
]
}
]
};
}

View File

@@ -0,0 +1,283 @@
/**
* Noodl Custom Blocks for Blockly
*
* Defines custom blocks for Noodl-specific functionality:
* - Inputs/Outputs (node I/O)
* - Variables (Noodl.Variables)
* - Objects (Noodl.Objects)
* - Arrays (Noodl.Arrays)
* - Events/Signals
*
* @module BlocklyEditor
*/
import * as Blockly from 'blockly';
/**
* Initialize all Noodl custom blocks
*/
export function initNoodlBlocks() {
console.log('🔧 [Blockly] Initializing Noodl custom blocks');
// Input/Output blocks
defineInputOutputBlocks();
// Variable blocks
defineVariableBlocks();
// Object blocks (basic - will expand later)
defineObjectBlocks();
// Array blocks (basic - will expand later)
defineArrayBlocks();
console.log('✅ [Blockly] Noodl blocks initialized');
}
/**
* Input/Output Blocks
*/
function defineInputOutputBlocks() {
// Define Input block - declares an input port
Blockly.Blocks['noodl_define_input'] = {
init: function () {
this.appendDummyInput()
.appendField('📥 Define input')
.appendField(new Blockly.FieldTextInput('myInput'), 'NAME')
.appendField('type')
.appendField(
new Blockly.FieldDropdown([
['any', '*'],
['string', 'string'],
['number', 'number'],
['boolean', 'boolean'],
['object', 'object'],
['array', 'array']
]),
'TYPE'
);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Defines an input port that appears on the node');
this.setHelpUrl('');
}
};
// Get Input block - gets value from an input
Blockly.Blocks['noodl_get_input'] = {
init: function () {
this.appendDummyInput().appendField('📥 get input').appendField(new Blockly.FieldTextInput('value'), 'NAME');
this.setOutput(true, null);
this.setColour(230);
this.setTooltip('Gets the value from an input port');
this.setHelpUrl('');
}
};
// Define Output block - declares an output port
Blockly.Blocks['noodl_define_output'] = {
init: function () {
this.appendDummyInput()
.appendField('📤 Define output')
.appendField(new Blockly.FieldTextInput('result'), 'NAME')
.appendField('type')
.appendField(
new Blockly.FieldDropdown([
['any', '*'],
['string', 'string'],
['number', 'number'],
['boolean', 'boolean'],
['object', 'object'],
['array', 'array']
]),
'TYPE'
);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Defines an output port that appears on the node');
this.setHelpUrl('');
}
};
// Set Output block - sets value on an output
Blockly.Blocks['noodl_set_output'] = {
init: function () {
this.appendValueInput('VALUE')
.setCheck(null)
.appendField('📤 set output')
.appendField(new Blockly.FieldTextInput('result'), 'NAME')
.appendField('to');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(230);
this.setTooltip('Sets the value of an output port');
this.setHelpUrl('');
}
};
// Define Signal Input block
Blockly.Blocks['noodl_define_signal_input'] = {
init: function () {
this.appendDummyInput()
.appendField('⚡ Define signal input')
.appendField(new Blockly.FieldTextInput('trigger'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Defines a signal input that can trigger logic');
this.setHelpUrl('');
}
};
// Define Signal Output block
Blockly.Blocks['noodl_define_signal_output'] = {
init: function () {
this.appendDummyInput()
.appendField('⚡ Define signal output')
.appendField(new Blockly.FieldTextInput('done'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Defines a signal output that can trigger other nodes');
this.setHelpUrl('');
}
};
// Send Signal block
Blockly.Blocks['noodl_send_signal'] = {
init: function () {
this.appendDummyInput().appendField('⚡ send signal').appendField(new Blockly.FieldTextInput('done'), 'NAME');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(180);
this.setTooltip('Sends a signal to connected nodes');
this.setHelpUrl('');
}
};
}
/**
* Variable Blocks
*/
function defineVariableBlocks() {
// Get Variable block
Blockly.Blocks['noodl_get_variable'] = {
init: function () {
this.appendDummyInput()
.appendField('📖 get variable')
.appendField(new Blockly.FieldTextInput('myVariable'), 'NAME');
this.setOutput(true, null);
this.setColour(330);
this.setTooltip('Gets the value of a global Noodl variable');
this.setHelpUrl('');
}
};
// Set Variable block
Blockly.Blocks['noodl_set_variable'] = {
init: function () {
this.appendValueInput('VALUE')
.setCheck(null)
.appendField('✏️ set variable')
.appendField(new Blockly.FieldTextInput('myVariable'), 'NAME')
.appendField('to');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(330);
this.setTooltip('Sets the value of a global Noodl variable');
this.setHelpUrl('');
}
};
}
/**
* Object Blocks (basic set - will expand in Phase E)
*/
function defineObjectBlocks() {
// Get Object block
Blockly.Blocks['noodl_get_object'] = {
init: function () {
this.appendValueInput('ID').setCheck('String').appendField('📦 get object');
this.setOutput(true, 'Object');
this.setColour(20);
this.setTooltip('Gets a Noodl Object by its ID');
this.setHelpUrl('');
}
};
// Get Object Property block
Blockly.Blocks['noodl_get_object_property'] = {
init: function () {
this.appendValueInput('OBJECT')
.setCheck(null)
.appendField('📖 get')
.appendField(new Blockly.FieldTextInput('name'), 'PROPERTY')
.appendField('from object');
this.setOutput(true, null);
this.setColour(20);
this.setTooltip('Gets a property value from an object');
this.setHelpUrl('');
}
};
// Set Object Property block
Blockly.Blocks['noodl_set_object_property'] = {
init: function () {
this.appendValueInput('OBJECT')
.setCheck(null)
.appendField('✏️ set')
.appendField(new Blockly.FieldTextInput('name'), 'PROPERTY')
.appendField('on object');
this.appendValueInput('VALUE').setCheck(null).appendField('to');
this.setInputsInline(false);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(20);
this.setTooltip('Sets a property value on an object');
this.setHelpUrl('');
}
};
}
/**
* Array Blocks (basic set - will expand in Phase E)
*/
function defineArrayBlocks() {
// Get Array block
Blockly.Blocks['noodl_get_array'] = {
init: function () {
this.appendDummyInput().appendField('📋 get array').appendField(new Blockly.FieldTextInput('myArray'), 'NAME');
this.setOutput(true, 'Array');
this.setColour(260);
this.setTooltip('Gets a Noodl Array by name');
this.setHelpUrl('');
}
};
// Array Length block
Blockly.Blocks['noodl_array_length'] = {
init: function () {
this.appendValueInput('ARRAY').setCheck('Array').appendField('🔢 length of array');
this.setOutput(true, 'Number');
this.setColour(260);
this.setTooltip('Gets the number of items in an array');
this.setHelpUrl('');
}
};
// Array Add block
Blockly.Blocks['noodl_array_add'] = {
init: function () {
this.appendValueInput('ITEM').setCheck(null).appendField(' add');
this.appendValueInput('ARRAY').setCheck('Array').appendField('to array');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(260);
this.setTooltip('Adds an item to the end of an array');
this.setHelpUrl('');
}
};
}

View File

@@ -0,0 +1,165 @@
/**
* Noodl Code Generators for Blockly
*
* Converts Blockly blocks into executable JavaScript code for the Noodl runtime.
* Generated code has access to:
* - Inputs: Input values from connections
* - Outputs: Output values to connections
* - Noodl.Variables: Global variables
* - Noodl.Objects: Global objects
* - Noodl.Arrays: Global arrays
*
* @module BlocklyEditor
*/
import * as Blockly from 'blockly';
import { javascriptGenerator, Order } from 'blockly/javascript';
/**
* Initialize all Noodl code generators
*/
export function initNoodlGenerators() {
console.log('🔧 [Blockly] Initializing Noodl code generators');
// Input/Output generators
initInputOutputGenerators();
// Variable generators
initVariableGenerators();
// Object generators
initObjectGenerators();
// Array generators
initArrayGenerators();
console.log('✅ [Blockly] Noodl generators initialized');
}
/**
* Input/Output Generators
*/
function initInputOutputGenerators() {
// Define Input - no runtime code (used for I/O detection only)
javascriptGenerator.forBlock['noodl_define_input'] = function () {
return '';
};
// Get Input - generates: Inputs["name"]
javascriptGenerator.forBlock['noodl_get_input'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Inputs["${name}"]`;
return [code, Order.MEMBER];
};
// Define Output - no runtime code (used for I/O detection only)
javascriptGenerator.forBlock['noodl_define_output'] = function () {
return '';
};
// Set Output - generates: Outputs["name"] = value;
javascriptGenerator.forBlock['noodl_set_output'] = function (block) {
const name = block.getFieldValue('NAME');
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `Outputs["${name}"] = ${value};\n`;
};
// Define Signal Input - no runtime code
javascriptGenerator.forBlock['noodl_define_signal_input'] = function () {
return '';
};
// Define Signal Output - no runtime code
javascriptGenerator.forBlock['noodl_define_signal_output'] = function () {
return '';
};
// Send Signal - generates: this.sendSignalOnOutput("name");
javascriptGenerator.forBlock['noodl_send_signal'] = function (block) {
const name = block.getFieldValue('NAME');
return `this.sendSignalOnOutput("${name}");\n`;
};
}
/**
* Variable Generators
*/
function initVariableGenerators() {
// Get Variable - generates: Noodl.Variables["name"]
javascriptGenerator.forBlock['noodl_get_variable'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Noodl.Variables["${name}"]`;
return [code, Order.MEMBER];
};
// Set Variable - generates: Noodl.Variables["name"] = value;
javascriptGenerator.forBlock['noodl_set_variable'] = function (block) {
const name = block.getFieldValue('NAME');
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `Noodl.Variables["${name}"] = ${value};\n`;
};
}
/**
* Object Generators
*/
function initObjectGenerators() {
// Get Object - generates: Noodl.Objects[id]
javascriptGenerator.forBlock['noodl_get_object'] = function (block) {
const id = javascriptGenerator.valueToCode(block, 'ID', Order.NONE) || '""';
const code = `Noodl.Objects[${id}]`;
return [code, Order.MEMBER];
};
// Get Object Property - generates: object["property"]
javascriptGenerator.forBlock['noodl_get_object_property'] = function (block) {
const property = block.getFieldValue('PROPERTY');
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Order.MEMBER) || '{}';
const code = `${object}["${property}"]`;
return [code, Order.MEMBER];
};
// Set Object Property - generates: object["property"] = value;
javascriptGenerator.forBlock['noodl_set_object_property'] = function (block) {
const property = block.getFieldValue('PROPERTY');
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Order.MEMBER) || '{}';
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
return `${object}["${property}"] = ${value};\n`;
};
}
/**
* Array Generators
*/
function initArrayGenerators() {
// Get Array - generates: Noodl.Arrays["name"]
javascriptGenerator.forBlock['noodl_get_array'] = function (block) {
const name = block.getFieldValue('NAME');
const code = `Noodl.Arrays["${name}"]`;
return [code, Order.MEMBER];
};
// Array Length - generates: array.length
javascriptGenerator.forBlock['noodl_array_length'] = function (block) {
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Order.MEMBER) || '[]';
const code = `${array}.length`;
return [code, Order.MEMBER];
};
// Array Add - generates: array.push(item);
javascriptGenerator.forBlock['noodl_array_add'] = function (block) {
const item = javascriptGenerator.valueToCode(block, 'ITEM', Order.NONE) || 'null';
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Order.MEMBER) || '[]';
return `${array}.push(${item});\n`;
};
}
/**
* Generate complete JavaScript code from workspace
*
* @param workspace - The Blockly workspace
* @returns Generated JavaScript code
*/
export function generateCode(workspace: Blockly.WorkspaceSvg): string {
return javascriptGenerator.workspaceToCode(workspace);
}

View File

@@ -0,0 +1,49 @@
/**
* BlocklyEditor Module
*
* Entry point for Blockly integration in Noodl.
* Exports components, blocks, and generators for visual logic building.
*
* @module BlocklyEditor
*/
import { initNoodlBlocks } from './NoodlBlocks';
import { initNoodlGenerators } from './NoodlGenerators';
// Initialize globals (IODetector, code generation)
import '../../utils/BlocklyEditorGlobals';
// Main component
export { BlocklyWorkspace } from './BlocklyWorkspace';
export type { BlocklyWorkspaceProps } from './BlocklyWorkspace';
// Block definitions and generators
export { initNoodlBlocks } from './NoodlBlocks';
export { initNoodlGenerators, generateCode } from './NoodlGenerators';
// Track initialization to prevent double-registration
let blocklyInitialized = false;
/**
* Initialize all Noodl Blockly extensions
* Call this once at app startup before using Blockly components
* Safe to call multiple times - will only initialize once
*/
export function initBlocklyIntegration() {
if (blocklyInitialized) {
console.log('⏭️ [Blockly] Already initialized, skipping');
return;
}
console.log('🔧 [Blockly] Initializing Noodl Blockly integration');
// Initialize custom blocks
initNoodlBlocks();
// Initialize code generators
initNoodlGenerators();
// Note: BlocklyEditorGlobals auto-initializes via side-effect import above
blocklyInitialized = true;
console.log('✅ [Blockly] Integration initialized');
}

View File

@@ -0,0 +1,105 @@
/**
* Canvas Tabs Styling
*
* Theme-aware styling for canvas/Blockly tab system
*/
.CanvasTabs {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
pointer-events: all; /* Enable clicks on tabs */
}
.TabBar {
display: flex;
gap: var(--spacing-1);
padding: var(--spacing-2);
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
flex-shrink: 0;
}
.Tab {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border-radius: var(--radius-default) var(--radius-default) 0 0;
cursor: pointer;
user-select: none;
transition: background-color 0.15s ease;
border: 1px solid transparent;
border-bottom: none;
&:hover {
background-color: var(--theme-color-bg-4);
}
&.isActive {
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-highlight);
border-color: var(--theme-color-border-default);
border-bottom-color: var(--theme-color-bg-1); // Hide bottom border
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary);
outline-offset: -2px;
}
}
.TabLabel {
font-size: var(--theme-font-size-default);
font-weight: 500;
white-space: nowrap;
}
.TabCloseButton {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
color: var(--theme-color-fg-default-shy);
font-size: 18px;
line-height: 1;
cursor: pointer;
border-radius: var(--radius-s);
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-5);
color: var(--theme-color-fg-highlight);
}
&:active {
background-color: var(--theme-color-bg-4);
}
}
.TabContent {
flex: 1;
overflow: hidden;
position: relative;
background-color: var(--theme-color-bg-1);
}
.CanvasContainer {
width: 100%;
height: 100%;
position: relative;
}
.BlocklyContainer {
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--theme-color-bg-1);
}

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { useCanvasTabs } from '../../contexts/CanvasTabsContext';
import { BlocklyWorkspace } from '../BlocklyEditor';
import css from './CanvasTabs.module.scss';
export interface CanvasTabsProps {
/** Callback when workspace changes */
onWorkspaceChange?: (nodeId: string, workspace: string, code: string) => void;
}
/**
* Canvas Tabs Component
*
* Manages tabs for Logic Builder (Blockly) editors.
* The canvas itself is NOT managed here - it's always visible in the background
* unless a Logic Builder tab is open.
*/
export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
const { tabs, activeTabId, switchTab, closeTab, updateTab } = useCanvasTabs();
const activeTab = tabs.find((t) => t.id === activeTabId);
/**
* Handle workspace changes from Blockly editor
*/
const handleWorkspaceChange = (_workspaceSvg: unknown, json: string, code: string) => {
if (!activeTab) {
return;
}
// Update tab's workspace with JSON
updateTab(activeTab.id, { workspace: json });
// Notify parent (pass both workspace JSON and generated code)
if (onWorkspaceChange && activeTab.nodeId) {
onWorkspaceChange(activeTab.nodeId, json, code);
}
};
/**
* Handle tab click
*/
const handleTabClick = (tabId: string) => {
switchTab(tabId);
};
/**
* Handle tab close
*/
const handleTabClose = (e: React.MouseEvent, tabId: string) => {
e.stopPropagation(); // Don't trigger tab switch
closeTab(tabId);
};
// Don't render anything if no tabs are open
if (tabs.length === 0) {
return null;
}
return (
<div className={css['CanvasTabs']}>
{/* Tab Bar */}
<div className={css['TabBar']}>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={`${css['Tab']} ${isActive ? css['isActive'] : ''}`}
onClick={() => handleTabClick(tab.id)}
role="tab"
aria-selected={isActive}
tabIndex={0}
>
<span className={css['TabLabel']}>Logic Builder: {tab.nodeName || 'Unnamed'}</span>
<button
className={css['TabCloseButton']}
onClick={(e) => handleTabClose(e, tab.id)}
aria-label="Close tab"
title="Close tab"
>
×
</button>
</div>
);
})}
</div>
{/* Tab Content */}
<div className={css['TabContent']}>
{activeTab && (
<div className={css['BlocklyContainer']}>
<BlocklyWorkspace initialWorkspace={activeTab.workspace || undefined} onChange={handleWorkspaceChange} />
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
/**
* CanvasTabs Module
*
* Exports canvas tab system components
*/
export { CanvasTabs } from './CanvasTabs';
export type { CanvasTabsProps } from './CanvasTabs';

View File

@@ -24,6 +24,7 @@ import { PopupToolbar, PopupToolbarProps } from '@noodl-core-ui/components/popup
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
import View from '../../../shared/view';
import { CanvasTabsProvider } from '../contexts/CanvasTabsContext';
import { ComponentModel } from '../models/componentmodel';
import {
Connection,
@@ -36,10 +37,13 @@ import { NodeLibrary } from '../models/nodelibrary';
import { ProjectModel } from '../models/projectmodel';
import { WarningsModel } from '../models/warningsmodel';
import { HighlightManager } from '../services/HighlightManager';
// Initialize Blockly globals early (must run before runtime nodes load)
import { initBlocklyEditorGlobals } from '../utils/BlocklyEditorGlobals';
import DebugInspector from '../utils/debuginspector';
import { rectanglesOverlap, guid } from '../utils/utils';
import { ViewerConnection } from '../ViewerConnection';
import { HighlightOverlay } from './CanvasOverlays/HighlightOverlay';
import { CanvasTabs } from './CanvasTabs';
import CommentLayer from './commentlayer';
// Import test utilities for console debugging (dev only)
import '../services/HighlightManager/test-highlights';
@@ -57,6 +61,8 @@ import PopupLayer from './popuplayer';
import { showContextMenuInPopup } from './ShowContextMenuInPopup';
import { ToastLayer } from './ToastLayer/ToastLayer';
initBlocklyEditorGlobals();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const NodeGraphEditorTemplate = require('../templates/nodegrapheditor.html');
@@ -234,6 +240,7 @@ export class NodeGraphEditor extends View {
toolbarRoots: Root[] = [];
titleRoot: Root = null;
highlightOverlayRoot: Root = null;
canvasTabsRoot: Root = null;
constructor(args) {
super();
@@ -297,6 +304,36 @@ export class NodeGraphEditor extends View {
this
);
// Listen for Logic Builder tab opened - hide canvas
EventDispatcher.instance.on(
'LogicBuilder.TabOpened',
() => {
console.log('[NodeGraphEditor] Logic Builder tab opened - hiding canvas');
this.setCanvasVisibility(false);
},
this
);
// Listen for all Logic Builder tabs closed - show canvas
EventDispatcher.instance.on(
'LogicBuilder.AllTabsClosed',
() => {
console.log('[NodeGraphEditor] All Logic Builder tabs closed - showing canvas');
this.setCanvasVisibility(true);
},
this
);
// Listen for Logic Builder tab open requests (for opening tabs from property panel)
EventDispatcher.instance.on(
'LogicBuilder.OpenTab',
(args: { nodeId: string; nodeName: string; workspace: string }) => {
console.log('[NodeGraphEditor] Opening Logic Builder tab for node:', args.nodeId);
// The CanvasTabs context will handle the actual tab opening
},
this
);
if (import.meta.webpackHot) {
import.meta.webpackHot.accept('./createnewnodepanel');
}
@@ -411,6 +448,11 @@ export class NodeGraphEditor extends View {
this.highlightOverlayRoot = null;
}
if (this.canvasTabsRoot) {
this.canvasTabsRoot.unmount();
this.canvasTabsRoot = null;
}
SidebarModel.instance.off(this);
this.reset();
@@ -881,12 +923,66 @@ export class NodeGraphEditor extends View {
this.renderHighlightOverlay();
}, 1);
// Render the canvas tabs
setTimeout(() => {
this.renderCanvasTabs();
}, 1);
this.relayout();
this.repaint();
return this.el;
}
/**
* Render the CanvasTabs React component
*/
renderCanvasTabs() {
const tabsElement = this.el.find('#canvas-tabs-root').get(0);
if (!tabsElement) {
console.warn('Canvas tabs root not found in DOM');
return;
}
// Create React root if it doesn't exist
if (!this.canvasTabsRoot) {
this.canvasTabsRoot = createRoot(tabsElement);
}
// Render the tabs with provider
this.canvasTabsRoot.render(
React.createElement(
CanvasTabsProvider,
null,
React.createElement(CanvasTabs, {
onWorkspaceChange: this.handleBlocklyWorkspaceChange.bind(this)
})
)
);
}
/**
* Handle workspace changes from Blockly editor
*/
handleBlocklyWorkspaceChange(nodeId: string, workspace: string, code: string) {
console.log(`[NodeGraphEditor] Workspace changed for node ${nodeId}`);
const node = this.findNodeWithId(nodeId);
if (!node) {
console.warn(`[NodeGraphEditor] Node ${nodeId} not found`);
return;
}
// Save workspace JSON to node model
node.model.setParameter('workspace', workspace);
// Save generated JavaScript code to node model
// This triggers the runtime's parameterUpdated listener which calls updatePorts()
node.model.setParameter('generatedCode', code);
console.log(`[NodeGraphEditor] Saved workspace and generated code for node ${nodeId}`);
}
/**
* Get node bounds for the highlight overlay
* Maps node IDs to their screen coordinates
@@ -945,6 +1041,35 @@ export class NodeGraphEditor extends View {
}
}
/**
* Set canvas visibility (hide when Logic Builder is open, show when closed)
*/
setCanvasVisibility(visible: boolean) {
const canvasElement = this.el.find('#nodegraphcanvas');
const commentLayerBg = this.el.find('#comment-layer-bg');
const commentLayerFg = this.el.find('#comment-layer-fg');
const highlightOverlay = this.el.find('#highlight-overlay-layer');
const componentTrail = this.el.find('.nodegraph-component-trail-root');
if (visible) {
// Show canvas and related elements
canvasElement.css('display', 'block');
commentLayerBg.css('display', 'block');
commentLayerFg.css('display', 'block');
highlightOverlay.css('display', 'block');
componentTrail.css('display', 'flex');
this.domElementContainer.style.display = '';
} else {
// Hide canvas and related elements
canvasElement.css('display', 'none');
commentLayerBg.css('display', 'none');
commentLayerFg.css('display', 'none');
highlightOverlay.css('display', 'none');
componentTrail.css('display', 'none');
this.domElementContainer.style.display = 'none';
}
}
// This is called by the parent view (frames view) when the size and position
// changes
resize(layout) {

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

@@ -1,10 +1,14 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { GitProvider } from '@noodl/git';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextButton } from '@noodl-core-ui/components/inputs/TextButton';
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
import { Text } from '@noodl-core-ui/components/typography/Text';
import { GitHubAuth, type GitHubAuthState } from '../../../../../../services/github';
type CredentialsSectionProps = {
provider: GitProvider;
username: string;
@@ -25,39 +29,120 @@ export function CredentialsSection({
const [hidePassword, setHidePassword] = useState(true);
// OAuth state management
const [authState, setAuthState] = useState<GitHubAuthState>(GitHubAuth.getAuthState());
const [isConnecting, setIsConnecting] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const [error, setError] = useState<string | null>(null);
// Check auth state on mount
useEffect(() => {
if (provider === 'github') {
setAuthState(GitHubAuth.getAuthState());
}
}, [provider]);
const handleConnect = async () => {
setIsConnecting(true);
setError(null);
setProgressMessage('Initiating GitHub authentication...');
try {
await GitHubAuth.startWebOAuthFlow((message) => {
setProgressMessage(message);
});
// Update state after successful auth
setAuthState(GitHubAuth.getAuthState());
setProgressMessage('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed');
setProgressMessage('');
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = () => {
GitHubAuth.disconnect();
setAuthState(GitHubAuth.getAuthState());
setError(null);
};
return (
<Section title={getTitle(provider)} variant={SectionVariant.InModal} hasGutter>
{showUsername && (
<>
{/* OAuth Section - GitHub Only */}
{provider === 'github' && (
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
{authState.isAuthenticated ? (
// Connected state
<>
<Text hasBottomSpacing>
Connected as <strong>{authState.username}</strong>
</Text>
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />
</>
) : (
// Not connected state
<>
<Text hasBottomSpacing>
Connect your GitHub account for the best experience. This enables advanced features and is more secure
than Personal Access Tokens.
</Text>
{isConnecting && progressMessage && <Text hasBottomSpacing>{progressMessage}</Text>}
{error && <Text hasBottomSpacing>{error}</Text>}
<PrimaryButton
label={isConnecting ? 'Connecting...' : 'Connect GitHub Account'}
onClick={handleConnect}
isDisabled={isConnecting}
/>
</>
)}
</Section>
)}
{/* PAT Section - Existing, now as fallback for GitHub */}
<Section
title={provider === 'github' ? 'Or use Personal Access Token' : getTitle(provider)}
variant={SectionVariant.InModal}
hasGutter
>
{showUsername && (
<TextInput
hasBottomSpacing
label="Username"
value={username}
variant={TextInputVariant.InModal}
onChange={(ev) => onUserNameChanged(ev.target.value)}
/>
)}
<TextInput
hasBottomSpacing
label="Username"
value={username}
label={passwordLabel}
type={hidePassword ? 'password' : 'text'}
value={password}
variant={TextInputVariant.InModal}
onChange={(ev) => onUserNameChanged(ev.target.value)}
onChange={(ev) => onPasswordChanged(ev.target.value)}
onFocus={() => setHidePassword(false)}
onBlur={() => setHidePassword(true)}
/>
)}
<TextInput
hasBottomSpacing
label={passwordLabel}
type={hidePassword ? 'password' : 'text'}
value={password}
variant={TextInputVariant.InModal}
onChange={(ev) => onPasswordChanged(ev.target.value)}
onFocus={() => setHidePassword(false)}
onBlur={() => setHidePassword(true)}
/>
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
{provider === 'github' && !password?.length && (
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
target="_blank"
rel="noreferrer"
>
How to create a personal access token
</a>
)}
</Section>
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
{provider === 'github' && !password?.length && (
<a
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
target="_blank"
rel="noreferrer"
>
How to create a personal access token
</a>
)}
</Section>
</>
);
}

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

@@ -0,0 +1,113 @@
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
/**
* Custom editor for Logic Builder workspace parameter
* Shows an "Edit Blocks" button that opens the Blockly editor in a tab
*/
export class LogicBuilderWorkspaceType extends TypeView {
el: TSFixme;
editButton: JQuery;
static fromPort(args) {
const view = new LogicBuilderWorkspaceType();
const p = args.port;
const parent = args.parent;
view.port = p;
view.displayName = p.displayName ? p.displayName : p.name;
view.name = p.name;
view.type = getEditType(p);
view.group = p.group;
view.tooltip = p.tooltip;
view.value = parent.model.getParameter(p.name);
view.parent = parent;
view.isConnected = parent.model.isPortConnected(p.name, 'target');
view.isDefault = parent.model.parameters[p.name] === undefined;
return view;
}
render() {
// Create a simple container with a button
const html = `
<div class="property-basic-container logic-builder-workspace-editor" style="display: flex; flex-direction: column; gap: 8px;">
<div class="property-label-container" style="display: flex; align-items: center; gap: 8px;">
<div class="property-changed-dot" data-click="resetToDefault" style="display: none;"></div>
<div class="property-label">${this.displayName}</div>
</div>
<button class="edit-blocks-button"
style="
padding: 8px 16px;
background: var(--theme-color-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
"
onmouseover="this.style.backgroundColor='var(--theme-color-primary-hover)'"
onmouseout="this.style.backgroundColor='var(--theme-color-primary)'">
✨ Edit Logic Blocks
</button>
</div>
`;
this.el = this.bindView($(html), this);
// Get reference to button
this.editButton = this.el.find('.edit-blocks-button');
// Handle button click
this.editButton.on('click', () => {
this.onEditBlocksClicked();
});
// Call parent render for common functionality (tooltips, etc.)
TypeView.prototype.render.call(this);
// Show/hide the "changed" dot based on whether value is default
this.updateChangedDot();
return this.el;
}
onEditBlocksClicked() {
// ModelProxy wraps the actual node model in a .model property
const nodeId = this.parent?.model?.model?.id;
const nodeName = this.parent?.model?.model?.label || this.parent?.model?.type?.displayName || 'Logic Builder';
const workspace = this.parent?.model?.getParameter('workspace') || '';
console.log('[LogicBuilderWorkspaceType] Opening Logic Builder tab for node:', nodeId);
// Emit event to open Logic Builder tab
EventDispatcher.instance.emit('LogicBuilder.OpenTab', {
nodeId,
nodeName,
workspace
});
}
updateChangedDot() {
const dot = this.el.find('.property-changed-dot');
if (this.isDefault) {
dot.hide();
} else {
dot.show();
}
}
resetToDefault() {
// Reset workspace to empty
this.parent.model.setParameter(this.name, undefined, {
undo: true,
label: 'reset workspace'
});
this.isDefault = true;
this.updateChangedDot();
}
}

View File

@@ -21,6 +21,7 @@ import { FontType } from './FontType';
import { IconType } from './IconType';
import { IdentifierType } from './IdentifierType';
import { ImageType } from './ImageType';
import { LogicBuilderWorkspaceType } from './LogicBuilderWorkspaceType';
import { MarginPaddingType } from './MarginPaddingType';
import { NumberWithUnits } from './NumberWithUnits';
import { PopoutGroup } from './PopoutGroup';
@@ -220,6 +221,11 @@ export class Ports extends View {
viewClassForPort(p) {
const type = getEditType(p);
// Check for custom editorType
if (typeof type === 'object' && type.editorType === 'logic-builder-workspace') {
return LogicBuilderWorkspaceType;
}
// Align tools types
function isOfAlignToolsType() {
return NodeLibrary.nameForPortType(type) === 'enum' && typeof type === 'object' && type.alignComp !== undefined;

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,339 @@
/**
* GitHubOAuthCallbackHandler
*
* Handles GitHub OAuth callback in Electron main process using custom protocol handler.
* This enables Web OAuth Flow with organization/repository selection UI.
*
* @module noodl-editor/main
* @since 1.1.0
*/
const crypto = require('crypto');
const { ipcMain, BrowserWindow } = require('electron');
/**
* GitHub OAuth credentials
* Uses existing credentials from GitHubOAuthService
*/
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui';
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375';
/**
* Custom protocol for OAuth callback
*/
const OAUTH_PROTOCOL = 'noodl';
const OAUTH_CALLBACK_PATH = 'github-callback';
/**
* Manages GitHub OAuth using custom protocol handler
*/
class GitHubOAuthCallbackHandler {
constructor() {
this.pendingAuth = null;
}
/**
* Handle protocol callback from GitHub OAuth
* Called when user is redirected to noodl://github-callback?code=XXX&state=YYY
*/
async handleProtocolCallback(url) {
console.log('🔐 [GitHub OAuth] ========================================');
console.log('🔐 [GitHub OAuth] PROTOCOL CALLBACK RECEIVED');
console.log('🔐 [GitHub OAuth] URL:', url);
console.log('🔐 [GitHub OAuth] ========================================');
try {
// Parse the URL
const parsedUrl = new URL(url);
const params = parsedUrl.searchParams;
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
const error_description = params.get('error_description');
// Handle OAuth error
if (error) {
console.error('[GitHub OAuth] Error from GitHub:', error, error_description);
this.sendErrorToRenderer(error, error_description);
return;
}
// Validate required parameters
if (!code || !state) {
console.error('[GitHub OAuth] Missing code or state in callback');
this.sendErrorToRenderer('invalid_request', 'Missing authorization code or state');
return;
}
// Validate state (CSRF protection)
if (!this.validateState(state)) {
throw new Error('Invalid OAuth state - possible CSRF attack or expired');
}
// Exchange code for token
const token = await this.exchangeCodeForToken(code);
// Fetch user info
const user = await this.fetchUserInfo(token.access_token);
// Fetch installation info (organizations/repos)
const installations = await this.fetchInstallations(token.access_token);
// Send result to renderer process
this.sendSuccessToRenderer({
token,
user,
installations,
authMethod: 'web_oauth'
});
// Clear pending auth
this.pendingAuth = null;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[GitHub OAuth] Callback handling error:', error);
this.sendErrorToRenderer('token_exchange_failed', errorMessage);
}
}
/**
* Generate OAuth state for new flow
*/
generateOAuthState() {
const state = crypto.randomBytes(32).toString('hex');
const verifier = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
this.pendingAuth = {
state,
verifier,
createdAt: now,
expiresAt: now + 300000 // 5 minutes
};
return this.pendingAuth;
}
/**
* Validate OAuth state from callback
*/
validateState(receivedState) {
if (!this.pendingAuth) {
console.error('[GitHub OAuth] No pending auth state');
return false;
}
if (receivedState !== this.pendingAuth.state) {
console.error('[GitHub OAuth] State mismatch');
return false;
}
if (Date.now() > this.pendingAuth.expiresAt) {
console.error('[GitHub OAuth] State expired');
return false;
}
return true;
}
/**
* Exchange authorization code for access token
*/
async exchangeCodeForToken(code) {
console.log('[GitHub OAuth] Exchanging code for access token');
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`);
}
return data;
}
/**
* Fetch user information from GitHub
*/
async fetchUserInfo(token) {
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`);
}
return response.json();
}
/**
* Fetch installation information (orgs/repos user granted access to)
*/
async fetchInstallations(token) {
try {
// Fetch user installations
const response = await fetch('https://api.github.com/user/installations', {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
console.warn('[GitHub OAuth] Failed to fetch installations:', response.status);
return [];
}
const data = await response.json();
return data.installations || [];
} catch (error) {
console.warn('[GitHub OAuth] Error fetching installations:', error);
return [];
}
}
/**
* Send success to renderer process
*/
sendSuccessToRenderer(result) {
console.log('📤 [GitHub OAuth] ========================================');
console.log('📤 [GitHub OAuth] SENDING IPC EVENT: github-oauth-complete');
console.log('📤 [GitHub OAuth] User:', result.user.login);
console.log('📤 [GitHub OAuth] Installations:', result.installations.length);
console.log('📤 [GitHub OAuth] ========================================');
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-complete', result);
console.log('✅ [GitHub OAuth] IPC event sent to renderer');
} else {
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
}
}
/**
* Send error to renderer process
*/
sendErrorToRenderer(error, description) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send('github-oauth-error', {
error,
message: description || error
});
}
}
/**
* Get authorization URL for OAuth flow
*/
getAuthorizationUrl(state) {
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`,
scope: 'repo read:org read:user user:email',
state,
allow_signup: 'true'
});
return `https://github.com/login/oauth/authorize?${params}`;
}
/**
* Cancel pending OAuth flow
*/
cancelPendingAuth() {
this.pendingAuth = null;
console.log('[GitHub OAuth] Pending auth cancelled');
}
}
// Singleton instance
let handlerInstance = null;
/**
* Initialize GitHub OAuth IPC handlers and protocol handler
*/
function initializeGitHubOAuthHandlers(app) {
handlerInstance = new GitHubOAuthCallbackHandler();
// Register custom protocol handler
if (!app.isDefaultProtocolClient(OAUTH_PROTOCOL)) {
app.setAsDefaultProtocolClient(OAUTH_PROTOCOL);
console.log(`[GitHub OAuth] Registered ${OAUTH_PROTOCOL}:// protocol handler`);
}
// Handle protocol callback on macOS/Linux
app.on('open-url', (event, url) => {
event.preventDefault();
if (url.startsWith(`${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`)) {
handlerInstance.handleProtocolCallback(url);
}
});
// Handle protocol callback on Windows (second instance)
app.on('second-instance', (event, commandLine) => {
// Find the protocol URL in command line args
const protocolUrl = commandLine.find((arg) => arg.startsWith(`${OAUTH_PROTOCOL}://`));
if (protocolUrl && protocolUrl.includes(OAUTH_CALLBACK_PATH)) {
handlerInstance.handleProtocolCallback(protocolUrl);
}
// Focus the main window
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
if (windows[0].isMinimized()) windows[0].restore();
windows[0].focus();
}
});
// Handle start OAuth flow request from renderer
ipcMain.handle('github-oauth-start', async () => {
try {
const authState = handlerInstance.generateOAuthState();
const authUrl = handlerInstance.getAuthorizationUrl(authState.state);
return { success: true, authUrl };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
});
// Handle stop OAuth flow request from renderer
ipcMain.handle('github-oauth-stop', async () => {
handlerInstance.cancelPendingAuth();
return { success: true };
});
console.log('[GitHub OAuth] IPC handlers and protocol handler initialized');
}
module.exports = {
GitHubOAuthCallbackHandler,
initializeGitHubOAuthHandlers,
OAUTH_PROTOCOL
};

View File

@@ -10,6 +10,7 @@ const { startCloudFunctionServer, closeRuntimeWhenWindowCloses } = require('./sr
const DesignToolImportServer = require('./src/design-tool-import-server');
const jsonstorage = require('../shared/utils/jsonstorage');
const StorageApi = require('./src/StorageApi');
const { initializeGitHubOAuthHandlers } = require('./github-oauth-handler');
const { handleProjectMerge } = require('./src/merge-driver');
@@ -542,6 +543,9 @@ function launchApp() {
setupGitHubOAuthIpc();
// Initialize Web OAuth handlers for GitHub (with protocol handler)
initializeGitHubOAuthHandlers(app);
setupMainWindowControlIpc();
setupMenu();
@@ -565,27 +569,12 @@ function launchApp() {
console.log('open-url', uri);
event.preventDefault();
// Handle GitHub OAuth callback
if (uri.startsWith('noodl://github-callback')) {
try {
const url = new URL(uri);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (code && state) {
console.log('🔐 GitHub OAuth callback received');
win && win.webContents.send('github-oauth-callback', { code, state });
return;
}
} catch (error) {
console.error('Failed to parse GitHub OAuth callback:', error);
}
// GitHub OAuth callbacks are handled by github-oauth-handler.js
// Only handle other noodl:// URIs here
if (!uri.startsWith('noodl://github-callback')) {
win && win.webContents.send('open-noodl-uri', uri);
process.env.noodlURI = uri;
}
// Default noodl URI handling
win && win.webContents.send('open-noodl-uri', uri);
process.env.noodlURI = uri;
// logEverywhere("open-url# " + deeplinkingUrl)
});
});

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

@@ -28,6 +28,7 @@ function registerNodes(noodlRuntime) {
// Custom code
require('./src/nodes/std-library/expression'),
require('./src/nodes/std-library/simplejavascript'),
require('./src/nodes/std-library/logic-builder'),
// Records
require('./src/nodes/std-library/data/dbcollectionnode2'),

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

@@ -581,7 +581,7 @@ function generateNodeLibrary(nodeRegister) {
subCategories: [
{
name: '',
items: ['Expression', 'JavaScriptFunction', 'Javascript2', 'CSS Definition']
items: ['Expression', 'JavaScriptFunction', 'Javascript2', 'Logic Builder', 'CSS Definition']
}
]
},

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,97 @@ 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;
}
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
this._internal.scope[name] = 0;
this._inputValues[name] = 0;
this.registerInput(name, {
set: function (value) {
this._internal.scope[name] = value;
if (!this.isInputConnected('run')) this._scheduleEvaluateExpression();
}
});
},
_scheduleEvaluateExpression: function () {
var internal = this._internal;
if (internal.hasScheduledEvaluation === false) {
internal.hasScheduledEvaluation = true;
this.flagDirty();
this.scheduleAfterInputsHaveUpdated(function () {
var lastValue = internal.cachedValue;
internal.cachedValue = this._calculateExpression();
if (lastValue !== internal.cachedValue) {
this.flagOutputDirty('result');
this.flagOutputDirty('isTrue');
this.flagOutputDirty('isFalse');
}
if (internal.cachedValue) this.sendSignalOnOutput('isTrueEv');
else this.sendSignalOnOutput('isFalseEv');
internal.hasScheduledEvaluation = false;
});
}
},
_calculateExpression: function () {
var internal = this._internal;
if (!internal.compiledFunction) {
internal.compiledFunction = this._compileFunction();
}
for (var i = 0; i < internal.inputNames.length; ++i) {
var inputValue = internal.scope[internal.inputNames[i]];
internal.inputValues[i] = inputValue;
}
// Get proper Noodl API and append as last parameter for backward compatibility
const JavascriptNodeParser = require('../../javascriptnodeparser');
const noodlAPI = JavascriptNodeParser.createNoodlAPI(this.context && this.context.modelScope);
const argsWithNoodl = internal.inputValues.concat([noodlAPI]);
try {
return internal.compiledFunction.apply(null, argsWithNoodl);
} catch (e) {
console.error('Error in expression:', e.message);
}
return 0;
},
_compileFunction: function () {
var expression = this._internal.currentExpression;
var args = Object.keys(this._internal.scope);
// Add 'Noodl' as last parameter for backward compatibility
args.push('Noodl');
var key = expression + args.join(' ');
if (compiledFunctionsCache.hasOwnProperty(key) === false) {
args.push(expression);
try {
compiledFunctionsCache[key] = construct(Function, args);
} catch (e) {
console.error('Failed to compile JS function', e.message);
}
}
return compiledFunctionsCache[key];
}
},
getInspectInfo() {
return this._internal.cachedValue;
@@ -72,15 +162,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,83 +247,32 @@ const ExpressionNode = {
group: 'Events',
type: 'signal',
displayName: 'On False'
}
},
prototypeExtensions: {
registerInputIfNeeded: {
value: function (name) {
if (this.hasInput(name)) {
return;
}
this._internal.scope[name] = 0;
this._inputValues[name] = 0;
this.registerInput(name, {
set: function (value) {
this._internal.scope[name] = value;
if (!this.isInputConnected('run')) this._scheduleEvaluateExpression();
}
});
},
// 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) : '';
}
},
_scheduleEvaluateExpression: {
value: function () {
var internal = this._internal;
if (internal.hasScheduledEvaluation === false) {
internal.hasScheduledEvaluation = true;
this.flagDirty();
this.scheduleAfterInputsHaveUpdated(function () {
var lastValue = internal.cachedValue;
internal.cachedValue = this._calculateExpression();
if (lastValue !== internal.cachedValue) {
this.flagOutputDirty('result');
this.flagOutputDirty('isTrue');
this.flagOutputDirty('isFalse');
}
if (internal.cachedValue) this.sendSignalOnOutput('isTrueEv');
else this.sendSignalOnOutput('isFalseEv');
internal.hasScheduledEvaluation = false;
});
}
asNumber: {
group: 'Typed Results',
type: 'number',
displayName: 'As Number',
getter: function () {
const val = this._internal.cachedValue;
return typeof val === 'number' ? val : Number(val) || 0;
}
},
_calculateExpression: {
value: function () {
var internal = this._internal;
if (!internal.compiledFunction) {
internal.compiledFunction = this._compileFunction();
}
for (var i = 0; i < internal.inputNames.length; ++i) {
var inputValue = internal.scope[internal.inputNames[i]];
internal.inputValues[i] = inputValue;
}
try {
return internal.compiledFunction.apply(null, internal.inputValues);
} catch (e) {
console.error('Error in expression:', e.message);
}
return 0;
}
},
_compileFunction: {
value: function () {
var expression = this._internal.currentExpression;
var args = Object.keys(this._internal.scope);
var key = expression + args.join(' ');
if (compiledFunctionsCache.hasOwnProperty(key) === false) {
args.push(expression);
try {
compiledFunctionsCache[key] = construct(Function, args);
} catch (e) {
console.error('Failed to compile JS function', e.message);
}
}
return compiledFunctionsCache[key];
asBoolean: {
group: 'Typed Results',
type: 'boolean',
displayName: 'As Boolean',
getter: function () {
return !!this._internal.cachedValue;
}
}
}
@@ -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,415 @@
'use strict';
/**
* Logic Builder Node
*
* Visual logic building using Google Blockly.
* Allows users to create complex logic without writing JavaScript.
*
* The node:
* - Stores a Blockly workspace as JSON
* - Auto-detects inputs/outputs from blocks
* - Generates and executes JavaScript from blocks
* - Provides full Noodl API access (Variables, Objects, Arrays)
*/
const LogicBuilderNode = {
name: 'Logic Builder',
docs: 'https://docs.noodl.net/nodes/logic/logic-builder',
displayNodeName: 'Logic Builder',
shortDesc: 'Build logic visually with blocks',
category: 'CustomCode',
color: 'javascript',
nodeDoubleClickAction: {
focusPort: 'workspace'
},
searchTags: ['blockly', 'visual', 'logic', 'blocks', 'nocode'],
initialize: function () {
const internal = this._internal;
internal.workspace = ''; // Blockly workspace JSON
internal.compiledFunction = null;
internal.executionError = null;
internal.inputValues = {};
internal.outputValues = {};
},
methods: {
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
const internal = this._internal;
this.registerInput(name, {
set: function (value) {
internal.inputValues[name] = value;
// Don't auto-execute - wait for signal inputs
}
});
},
registerOutputIfNeeded: function (name, type) {
if (this.hasOutput(name)) {
return;
}
this.registerOutput(name, {
type: type || '*',
getter: function () {
return this._internal.outputValues[name];
}
});
},
registerSignalInputIfNeeded: function (name) {
if (this.hasInput(name)) {
return;
}
this.registerInput(name, {
type: 'signal',
valueChangedToTrue: function () {
this._executeLogic(name);
}
});
},
registerSignalOutputIfNeeded: function (name) {
if (this.hasOutput(name)) {
return;
}
this.registerOutput(name, {
type: 'signal'
});
},
_executeLogic: function (triggerSignal) {
const internal = this._internal;
// Compile function if needed
if (!internal.compiledFunction && internal.workspace) {
internal.compiledFunction = this._compileFunction();
}
if (!internal.compiledFunction) {
console.warn('[Logic Builder] No logic to execute');
return;
}
try {
// Create execution context
const context = this._createExecutionContext(triggerSignal);
// Execute generated code, passing context variables as parameters
internal.compiledFunction(
context.Inputs,
context.Outputs,
context.Noodl,
context.Variables,
context.Objects,
context.Arrays,
context.sendSignalOnOutput
);
// Update outputs
for (const outputName in context.Outputs) {
internal.outputValues[outputName] = context.Outputs[outputName];
this.flagOutputDirty(outputName);
}
internal.executionError = null;
} catch (error) {
console.error('[Logic Builder] Execution error:', error);
internal.executionError = error.message;
this.flagOutputDirty('error');
}
},
_createExecutionContext: function (triggerSignal) {
const internal = this._internal;
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
// Create context with Noodl APIs
// eslint-disable-next-line @typescript-eslint/no-var-requires
const JavascriptNodeParser = require('../../javascriptnodeparser');
const noodlAPI = JavascriptNodeParser.createNoodlAPI(this.context && this.context.modelScope);
return {
// Inputs object
Inputs: internal.inputValues,
// Outputs object (writable)
Outputs: {},
// Noodl global APIs
Noodl: noodlAPI,
Variables: noodlAPI.Variables,
Objects: noodlAPI.Objects,
Arrays: noodlAPI.Arrays,
// Signal sending
sendSignalOnOutput: function (name) {
self.sendSignalOnOutput(name);
},
// Convenience alias
this: {
sendSignalOnOutput: function (name) {
self.sendSignalOnOutput(name);
}
},
// Trigger signal name (for conditional logic)
__triggerSignal__: triggerSignal
};
},
_compileFunction: function () {
const internal = this._internal;
if (!internal.workspace) {
return null;
}
try {
// Generate JavaScript from Blockly workspace
// This will be done in the editor via code generation
// For now, we expect the 'generatedCode' parameter to be set
const code = internal.generatedCode || '';
if (!code) {
console.warn('[Logic Builder] No generated code available');
return null;
}
// Create function with parameters for context variables
// This makes Inputs, Outputs, Noodl, etc. available to the generated code
const fn = new Function(
'Inputs',
'Outputs',
'Noodl',
'Variables',
'Objects',
'Arrays',
'sendSignalOnOutput',
code
);
return fn;
} catch (error) {
console.error('[Logic Builder] Failed to compile function:', error);
return null;
}
}
},
getInspectInfo() {
const internal = this._internal;
if (internal.executionError) {
return `Error: ${internal.executionError}`;
}
return 'Logic Builder';
},
inputs: {
workspace: {
type: {
name: 'string',
allowEditOnly: true,
editorType: 'logic-builder-workspace'
},
displayName: 'Logic Blocks',
set: function (value) {
const internal = this._internal;
internal.workspace = value;
internal.compiledFunction = null; // Reset compiled function
}
},
generatedCode: {
type: 'string',
displayName: 'Generated Code',
group: 'Advanced',
editorName: 'Hidden', // Hide from property panel
set: function (value) {
const internal = this._internal;
internal.generatedCode = value;
internal.compiledFunction = null; // Reset compiled function
}
},
run: {
type: 'signal',
displayName: 'Run',
group: 'Signals',
valueChangedToTrue: function () {
this._executeLogic('run');
}
}
},
outputs: {
error: {
group: 'Status',
type: 'string',
displayName: 'Error',
getter: function () {
return this._internal.executionError || '';
}
}
}
};
/**
* Update dynamic ports based on workspace
* This function is injected by the editor's setup code
*/
let updatePortsImpl = null;
function updatePorts(nodeId, workspace, generatedCode, editorConnection) {
if (!workspace) {
editorConnection.sendDynamicPorts(nodeId, []);
return;
}
if (updatePortsImpl) {
updatePortsImpl(nodeId, workspace, generatedCode, editorConnection);
} else {
console.warn('[Logic Builder] updatePortsImpl not initialized - running in runtime mode?');
}
}
module.exports = {
node: LogicBuilderNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
// Inject the real updatePorts implementation
// This is set by the editor's initialization code
updatePortsImpl = function (nodeId, workspace, generatedCode, editorConnection) {
console.log('[Logic Builder] updatePortsImpl called for node:', nodeId);
console.log('[Logic Builder] Workspace length:', workspace ? workspace.length : 0);
console.log('[Logic Builder] Generated code length:', generatedCode ? generatedCode.length : 0);
try {
console.log('[Logic Builder] Parsing generated code for outputs...');
const detected = {
inputs: [],
outputs: [],
signalInputs: [],
signalOutputs: []
};
// Detect outputs from code like: Outputs["result"] = ...
const outputRegex = /Outputs\["([^"]+)"\]/g;
let match;
while ((match = outputRegex.exec(generatedCode)) !== null) {
const outputName = match[1];
if (!detected.outputs.find((o) => o.name === outputName)) {
detected.outputs.push({ name: outputName, type: '*' });
}
}
console.log('[Logic Builder] Detected outputs from code:', detected.outputs);
if (detected.outputs.length > 0) {
console.log('[Logic Builder] Detection results:', {
inputs: detected.inputs.length,
outputs: detected.outputs.length,
signalInputs: detected.signalInputs.length,
signalOutputs: detected.signalOutputs.length
});
console.log('[Logic Builder] Detected outputs:', detected.outputs);
const ports = [];
// Add detected inputs
detected.inputs.forEach((input) => {
console.log('[Logic Builder] Adding input port:', input.name);
ports.push({
name: input.name,
type: input.type,
plug: 'input',
group: 'Inputs',
displayName: input.name
});
});
// Add detected outputs
detected.outputs.forEach((output) => {
console.log('[Logic Builder] Adding output port:', output.name);
ports.push({
name: output.name,
type: output.type,
plug: 'output',
group: 'Outputs',
displayName: output.name
});
});
// Add detected signal inputs
detected.signalInputs.forEach((signalName) => {
console.log('[Logic Builder] Adding signal input:', signalName);
ports.push({
name: signalName,
type: 'signal',
plug: 'input',
group: 'Signal Inputs',
displayName: signalName
});
});
// Add detected signal outputs
detected.signalOutputs.forEach((signalName) => {
console.log('[Logic Builder] Adding signal output:', signalName);
ports.push({
name: signalName,
type: 'signal',
plug: 'output',
group: 'Signal Outputs',
displayName: signalName
});
});
console.log('[Logic Builder] Sending', ports.length, 'ports to editor');
editorConnection.sendDynamicPorts(nodeId, ports);
console.log('[Logic Builder] Ports sent successfully');
} else {
console.warn('[Logic Builder] IODetector not available in editor context');
}
} catch (error) {
console.error('[Logic Builder] Failed to update ports:', error);
console.error('[Logic Builder] Error stack:', error.stack);
}
};
graphModel.on('nodeAdded.Logic Builder', function (node) {
console.log('[Logic Builder] Node added:', node.id);
if (node.parameters.workspace) {
console.log('[Logic Builder] Node has workspace, updating ports...');
updatePorts(node.id, node.parameters.workspace, node.parameters.generatedCode, context.editorConnection);
}
node.on('parameterUpdated', function (event) {
console.log('[Logic Builder] Parameter updated:', event.name, 'for node:', node.id);
// Trigger port update when workspace OR generatedCode changes
if (event.name === 'workspace' || event.name === 'generatedCode') {
console.log('[Logic Builder] Triggering port update for:', event.name);
console.log('[Logic Builder] Workspace value:', node.parameters.workspace ? 'exists' : 'empty');
console.log('[Logic Builder] Generated code value:', node.parameters.generatedCode ? 'exists' : 'empty');
updatePorts(node.id, node.parameters.workspace, node.parameters.generatedCode, context.editorConnection);
}
});
});
},
// Export for editor to set the implementation
setUpdatePortsImpl: function (impl) {
updatePortsImpl = impl;
}
};

View File

@@ -132,11 +132,18 @@ const SimpleJavascriptNode = {
}
}
// Create Noodl API and augment with Inputs/Outputs for backward compatibility
// Legacy code used: Noodl.Outputs.foo = 'bar'
// New code uses: Outputs.foo = 'bar' (direct parameter)
const noodlAPI = JavascriptNodeParser.createNoodlAPI(this.nodeScope.modelScope);
noodlAPI.Inputs = inputs;
noodlAPI.Outputs = outputs;
try {
await func.apply(this._internal._this, [
inputs,
outputs,
JavascriptNodeParser.createNoodlAPI(this.nodeScope.modelScope),
noodlAPI,
JavascriptNodeParser.getComponentScopeForNode(this)
]);
} catch (e) {

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();
});
});
});
});