mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
new code editor
This commit is contained in:
@@ -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'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
14
packages/noodl-core-ui/src/components/code-editor/index.ts
Normal file
14
packages/noodl-core-ui/src/components/code-editor/index.ts
Normal 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';
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExpressionInput } from './ExpressionInput';
|
||||
export type { ExpressionInputProps } from './ExpressionInput';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExpressionToggle } from './ExpressionToggle';
|
||||
export type { ExpressionToggleProps } from './ExpressionToggle';
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* CodeHistoryManager
|
||||
*
|
||||
* Manages automatic code snapshots for Expression, Function, and Script nodes.
|
||||
* Allows users to view history and restore previous versions.
|
||||
*
|
||||
* @module models
|
||||
*/
|
||||
|
||||
import { NodeGraphNode } from '@noodl-models/nodegraphmodel/NodeGraphNode';
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
|
||||
import Model from '../../../shared/model';
|
||||
|
||||
/**
|
||||
* A single code snapshot
|
||||
*/
|
||||
export interface CodeSnapshot {
|
||||
code: string;
|
||||
timestamp: string; // ISO 8601 format
|
||||
hash: string; // For deduplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata structure for code history
|
||||
*/
|
||||
export interface CodeHistoryMetadata {
|
||||
codeHistory?: CodeSnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages code history for nodes
|
||||
*/
|
||||
export class CodeHistoryManager extends Model {
|
||||
public static instance = new CodeHistoryManager();
|
||||
|
||||
private readonly MAX_SNAPSHOTS = 20;
|
||||
|
||||
/**
|
||||
* Save a code snapshot for a node
|
||||
* Only saves if code has actually changed (hash comparison)
|
||||
*/
|
||||
saveSnapshot(nodeId: string, parameterName: string, code: string): void {
|
||||
const node = this.getNode(nodeId);
|
||||
if (!node) {
|
||||
console.warn('CodeHistoryManager: Node not found:', nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't save empty code
|
||||
if (!code || code.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute hash for deduplication
|
||||
const hash = this.hashCode(code);
|
||||
|
||||
// Get existing history
|
||||
const history = this.getHistory(nodeId, parameterName);
|
||||
|
||||
// Check if last snapshot is identical (deduplication)
|
||||
if (history.length > 0) {
|
||||
const lastSnapshot = history[history.length - 1];
|
||||
if (lastSnapshot.hash === hash) {
|
||||
// Code hasn't changed, don't create duplicate snapshot
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new snapshot
|
||||
const snapshot: CodeSnapshot = {
|
||||
code,
|
||||
timestamp: new Date().toISOString(),
|
||||
hash
|
||||
};
|
||||
|
||||
// Add to history
|
||||
history.push(snapshot);
|
||||
|
||||
// Prune old snapshots
|
||||
if (history.length > this.MAX_SNAPSHOTS) {
|
||||
history.splice(0, history.length - this.MAX_SNAPSHOTS);
|
||||
}
|
||||
|
||||
// Save to node metadata
|
||||
this.saveHistory(node, parameterName, history);
|
||||
|
||||
console.log(`📸 Code snapshot saved for node ${nodeId}, param ${parameterName} (${history.length} total)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get code history for a node parameter
|
||||
*/
|
||||
getHistory(nodeId: string, parameterName: string): CodeSnapshot[] {
|
||||
const node = this.getNode(nodeId);
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const historyKey = this.getHistoryKey(parameterName);
|
||||
const metadata = node.metadata as CodeHistoryMetadata | undefined;
|
||||
|
||||
if (!metadata || !metadata[historyKey]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return metadata[historyKey] as CodeSnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a snapshot by timestamp
|
||||
* Returns the code from that snapshot
|
||||
*/
|
||||
restoreSnapshot(nodeId: string, parameterName: string, timestamp: string): string | undefined {
|
||||
const history = this.getHistory(nodeId, parameterName);
|
||||
const snapshot = history.find((s) => s.timestamp === timestamp);
|
||||
|
||||
if (!snapshot) {
|
||||
console.warn('CodeHistoryManager: Snapshot not found:', timestamp);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.log(`↩️ Restoring snapshot from ${timestamp}`);
|
||||
return snapshot.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific snapshot by timestamp
|
||||
*/
|
||||
getSnapshot(nodeId: string, parameterName: string, timestamp: string): CodeSnapshot | undefined {
|
||||
const history = this.getHistory(nodeId, parameterName);
|
||||
return history.find((s) => s.timestamp === timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all history for a node parameter
|
||||
*/
|
||||
clearHistory(nodeId: string, parameterName: string): void {
|
||||
const node = this.getNode(nodeId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const historyKey = this.getHistoryKey(parameterName);
|
||||
|
||||
if (node.metadata) {
|
||||
delete node.metadata[historyKey];
|
||||
}
|
||||
|
||||
console.log(`🗑️ Cleared history for node ${nodeId}, param ${parameterName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node from the current project
|
||||
*/
|
||||
private getNode(nodeId: string): NodeGraphNode | undefined {
|
||||
const project = ProjectModel.instance;
|
||||
if (!project) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Search all components for the node
|
||||
for (const component of project.getComponents()) {
|
||||
const graph = component.graph;
|
||||
if (!graph) continue;
|
||||
|
||||
const node = graph.findNodeWithId(nodeId);
|
||||
if (node) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save history to node metadata
|
||||
*/
|
||||
private saveHistory(node: NodeGraphNode, parameterName: string, history: CodeSnapshot[]): void {
|
||||
const historyKey = this.getHistoryKey(parameterName);
|
||||
|
||||
if (!node.metadata) {
|
||||
node.metadata = {};
|
||||
}
|
||||
|
||||
node.metadata[historyKey] = history;
|
||||
|
||||
// Notify that metadata changed (triggers project save)
|
||||
node.notifyListeners('metadataChanged');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the metadata key for a parameter's history
|
||||
* Uses a prefix to avoid conflicts with other metadata
|
||||
*/
|
||||
private getHistoryKey(parameterName: string): string {
|
||||
return `codeHistory_${parameterName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a simple hash of code for deduplication
|
||||
* Not cryptographic, just for detecting changes
|
||||
*/
|
||||
private hashCode(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display
|
||||
* Returns human-readable relative time ("5 minutes ago", "Yesterday")
|
||||
*/
|
||||
formatTimestamp(timestamp: string): string {
|
||||
const now = new Date();
|
||||
const then = new Date(timestamp);
|
||||
const diffMs = now.getTime() - then.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return 'just now';
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
} else if (diffDay === 1) {
|
||||
return 'yesterday at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffDay < 7) {
|
||||
return `${diffDay} days ago`;
|
||||
} else {
|
||||
// Full date for older snapshots
|
||||
return then.toLocaleDateString() + ' at ' + then.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Expression Parameter Types
|
||||
*
|
||||
* Defines types and helper functions for expression-based property values.
|
||||
* Allows properties to be set to JavaScript expressions that evaluate at runtime.
|
||||
*
|
||||
* @module ExpressionParameter
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* An expression parameter stores a JavaScript expression that evaluates at runtime
|
||||
*/
|
||||
export interface ExpressionParameter {
|
||||
/** Marker to identify expression parameters */
|
||||
mode: 'expression';
|
||||
|
||||
/** The JavaScript expression to evaluate */
|
||||
expression: string;
|
||||
|
||||
/** Fallback value if expression fails or is invalid */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallback?: any;
|
||||
|
||||
/** Expression system version for future migrations */
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parameter can be a simple value or an expression
|
||||
* Note: any is intentional - parameters can be any JSON-serializable value
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ParameterValue = any | ExpressionParameter;
|
||||
|
||||
/**
|
||||
* Type guard to check if a parameter value is an expression
|
||||
*
|
||||
* @param value - The parameter value to check
|
||||
* @returns True if value is an ExpressionParameter
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const param = node.getParameter('marginLeft');
|
||||
* if (isExpressionParameter(param)) {
|
||||
* console.log('Expression:', param.expression);
|
||||
* } else {
|
||||
* console.log('Fixed value:', param);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function isExpressionParameter(value: any): value is ExpressionParameter {
|
||||
return (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
typeof value === 'object' &&
|
||||
value.mode === 'expression' &&
|
||||
typeof value.expression === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display value for a parameter (for UI rendering)
|
||||
*
|
||||
* - For expression parameters: returns the expression string
|
||||
* - For simple values: returns the value as-is
|
||||
*
|
||||
* @param value - The parameter value
|
||||
* @returns Display value (expression string or simple value)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const expr = { mode: 'expression', expression: 'Variables.x * 2', fallback: 0 };
|
||||
* getParameterDisplayValue(expr); // Returns: 'Variables.x * 2'
|
||||
* getParameterDisplayValue(42); // Returns: 42
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function getParameterDisplayValue(value: ParameterValue): any {
|
||||
if (isExpressionParameter(value)) {
|
||||
return value.expression;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual value for a parameter (unwraps expression fallback)
|
||||
*
|
||||
* - For expression parameters: returns the fallback value
|
||||
* - For simple values: returns the value as-is
|
||||
*
|
||||
* This is useful when you need a concrete value for initialization
|
||||
* before the expression can be evaluated.
|
||||
*
|
||||
* @param value - The parameter value
|
||||
* @returns Actual value (fallback or simple value)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const expr = { mode: 'expression', expression: 'Variables.x', fallback: 100 };
|
||||
* getParameterActualValue(expr); // Returns: 100
|
||||
* getParameterActualValue(42); // Returns: 42
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function getParameterActualValue(value: ParameterValue): any {
|
||||
if (isExpressionParameter(value)) {
|
||||
return value.fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an expression parameter
|
||||
*
|
||||
* @param expression - The JavaScript expression string
|
||||
* @param fallback - Optional fallback value if expression fails
|
||||
* @param version - Expression system version (default: 1)
|
||||
* @returns A new ExpressionParameter object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple expression with fallback
|
||||
* const param = createExpressionParameter('Variables.count', 0);
|
||||
*
|
||||
* // Complex expression
|
||||
* const param = createExpressionParameter(
|
||||
* 'Variables.isAdmin ? "Admin" : "User"',
|
||||
* 'User'
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function createExpressionParameter(
|
||||
expression: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fallback?: any,
|
||||
version: number = 1
|
||||
): ExpressionParameter {
|
||||
return {
|
||||
mode: 'expression',
|
||||
expression,
|
||||
fallback,
|
||||
version
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to a parameter (for consistency)
|
||||
*
|
||||
* - Expression parameters are returned as-is
|
||||
* - Simple values are returned as-is
|
||||
*
|
||||
* This is mainly for type safety and consistency in parameter handling.
|
||||
*
|
||||
* @param value - The value to convert
|
||||
* @returns The value as a ParameterValue
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const expr = createExpressionParameter('Variables.x');
|
||||
* toParameter(expr); // Returns: expr (unchanged)
|
||||
* toParameter(42); // Returns: 42 (unchanged)
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function toParameter(value: any): ParameterValue {
|
||||
return value;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { UndoActionGroup, UndoQueue } from '@noodl-models/undo-queue-model';
|
||||
import { WarningsModel } from '@noodl-models/warningsmodel';
|
||||
|
||||
import Model from '../../../../shared/model';
|
||||
import { ParameterValueResolver } from '../../utils/ParameterValueResolver';
|
||||
|
||||
export type NodeGraphNodeParameters = {
|
||||
[key: string]: any;
|
||||
@@ -772,6 +773,28 @@ export class NodeGraphNode extends Model {
|
||||
return port ? port.default : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a parameter value formatted as a display string.
|
||||
* Handles expression parameter objects by resolving them to strings.
|
||||
*
|
||||
* @param name - The parameter name
|
||||
* @param args - Optional args (same as getParameter)
|
||||
* @returns A string representation of the parameter value, safe for UI display
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Regular value
|
||||
* node.getParameterDisplayValue('width') // '100'
|
||||
*
|
||||
* // Expression parameter object
|
||||
* node.getParameterDisplayValue('height') // '{height * 2}' (not '[object Object]')
|
||||
* ```
|
||||
*/
|
||||
getParameterDisplayValue(name: string, args?): string {
|
||||
const value = this.getParameter(name, args);
|
||||
return ParameterValueResolver.toString(value);
|
||||
}
|
||||
|
||||
// Sets the dynamic instance ports for this node
|
||||
setDynamicPorts(ports: NodeGrapPort[], options?: DynamicPortsOptions) {
|
||||
if (portsEqual(ports, this.dynamicports)) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* ParameterValueResolver
|
||||
*
|
||||
* Centralized utility for resolving parameter values from storage to their display/runtime values.
|
||||
* Handles the conversion of expression parameter objects to primitive values based on context.
|
||||
*
|
||||
* This is necessary because parameters can be stored as either:
|
||||
* 1. Primitive values (string, number, boolean)
|
||||
* 2. Expression parameter objects: { mode: 'expression', expression: '...', fallback: '...', version: 1 }
|
||||
*
|
||||
* Consumers need different values based on their context:
|
||||
* - Display (UI, canvas): Use fallback value
|
||||
* - Runtime: Use evaluated expression (handled separately by runtime)
|
||||
* - Serialization: Use raw value as-is
|
||||
*
|
||||
* @module noodl-editor/utils
|
||||
* @since TASK-006B
|
||||
*/
|
||||
|
||||
import { isExpressionParameter, ExpressionParameter } from '@noodl-models/ExpressionParameter';
|
||||
|
||||
/**
|
||||
* Context in which a parameter value is being used
|
||||
*/
|
||||
export enum ValueContext {
|
||||
/**
|
||||
* Display context - for UI rendering (property panel, canvas)
|
||||
* Returns the fallback value from expression parameters
|
||||
*/
|
||||
Display = 'display',
|
||||
|
||||
/**
|
||||
* Runtime context - for runtime evaluation
|
||||
* Returns the fallback value (actual evaluation happens in runtime)
|
||||
*/
|
||||
Runtime = 'runtime',
|
||||
|
||||
/**
|
||||
* Serialization context - for saving/loading
|
||||
* Returns the raw value unchanged
|
||||
*/
|
||||
Serialization = 'serialization'
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for primitive parameter values
|
||||
*/
|
||||
export type PrimitiveValue = string | number | boolean | undefined;
|
||||
|
||||
/**
|
||||
* ParameterValueResolver class
|
||||
*
|
||||
* Provides static methods to safely extract primitive values from parameters
|
||||
* that may be either primitives or expression parameter objects.
|
||||
*/
|
||||
export class ParameterValueResolver {
|
||||
/**
|
||||
* Resolves a parameter value to a primitive based on context.
|
||||
*
|
||||
* @param paramValue - The raw parameter value (could be primitive or expression object)
|
||||
* @param context - The context in which the value is being used
|
||||
* @returns A primitive value appropriate for the context
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Primitive value passes through
|
||||
* resolve('hello', ValueContext.Display) // => 'hello'
|
||||
*
|
||||
* // Expression parameter returns fallback
|
||||
* const expr = { mode: 'expression', expression: 'Variables.x', fallback: 'default', version: 1 };
|
||||
* resolve(expr, ValueContext.Display) // => 'default'
|
||||
* ```
|
||||
*/
|
||||
static resolve(paramValue: unknown, context: ValueContext): PrimitiveValue | ExpressionParameter {
|
||||
// If not an expression parameter, return as-is (assuming it's a primitive)
|
||||
if (!isExpressionParameter(paramValue)) {
|
||||
return paramValue as PrimitiveValue;
|
||||
}
|
||||
|
||||
// Handle expression parameters based on context
|
||||
switch (context) {
|
||||
case ValueContext.Display:
|
||||
// For display contexts (UI, canvas), use the fallback value
|
||||
return paramValue.fallback ?? '';
|
||||
|
||||
case ValueContext.Runtime:
|
||||
// For runtime, return fallback (actual evaluation happens in node runtime)
|
||||
// This prevents display code from trying to evaluate expressions
|
||||
return paramValue.fallback ?? '';
|
||||
|
||||
case ValueContext.Serialization:
|
||||
// For serialization, return the whole object unchanged
|
||||
return paramValue;
|
||||
|
||||
default:
|
||||
// Default to fallback value for safety
|
||||
return paramValue.fallback ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely converts any parameter value to a string for display.
|
||||
* Always returns a string, never an object.
|
||||
*
|
||||
* @param paramValue - The raw parameter value
|
||||
* @returns A string representation safe for display
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* toString('hello') // => 'hello'
|
||||
* toString(42) // => '42'
|
||||
* toString(null) // => ''
|
||||
* toString(undefined) // => ''
|
||||
* toString({ mode: 'expression', expression: '', fallback: 'test', version: 1 }) // => 'test'
|
||||
* ```
|
||||
*/
|
||||
static toString(paramValue: unknown): string {
|
||||
const resolved = this.resolve(paramValue, ValueContext.Display);
|
||||
|
||||
// If resolved is still an object (shouldn't happen, but defensive)
|
||||
if (typeof resolved === 'object' && resolved !== null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(resolved ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely converts any parameter value to a number for display.
|
||||
* Returns undefined if the value cannot be converted to a valid number.
|
||||
*
|
||||
* @param paramValue - The raw parameter value
|
||||
* @returns A number, or undefined if conversion fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* toNumber(42) // => 42
|
||||
* toNumber('42') // => 42
|
||||
* toNumber('hello') // => undefined
|
||||
* toNumber(null) // => undefined
|
||||
* toNumber({ mode: 'expression', expression: '', fallback: 123, version: 1 }) // => 123
|
||||
* ```
|
||||
*/
|
||||
static toNumber(paramValue: unknown): number | undefined {
|
||||
const resolved = this.resolve(paramValue, ValueContext.Display);
|
||||
|
||||
// If resolved is still an object (shouldn't happen, but defensive)
|
||||
if (typeof resolved === 'object' && resolved !== null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const num = Number(resolved);
|
||||
return isNaN(num) ? undefined : num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely converts any parameter value to a boolean for display.
|
||||
* Uses JavaScript truthiness rules.
|
||||
*
|
||||
* @param paramValue - The raw parameter value
|
||||
* @returns A boolean value
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* toBoolean(true) // => true
|
||||
* toBoolean('hello') // => true
|
||||
* toBoolean('') // => false
|
||||
* toBoolean(0) // => false
|
||||
* toBoolean({ mode: 'expression', expression: '', fallback: true, version: 1 }) // => true
|
||||
* ```
|
||||
*/
|
||||
static toBoolean(paramValue: unknown): boolean {
|
||||
const resolved = this.resolve(paramValue, ValueContext.Display);
|
||||
|
||||
// If resolved is still an object (shouldn't happen, but defensive)
|
||||
if (typeof resolved === 'object' && resolved !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a parameter value is an expression parameter.
|
||||
* Convenience method that delegates to the ExpressionParameter module.
|
||||
*
|
||||
* @param paramValue - The value to check
|
||||
* @returns True if the value is an expression parameter object
|
||||
*/
|
||||
static isExpression(paramValue: unknown): paramValue is ExpressionParameter {
|
||||
return isExpressionParameter(paramValue);
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,22 @@ function measureTextHeight(text, font, lineHeight, maxWidth) {
|
||||
ctx.font = font;
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
return textWordWrap(ctx, text, 0, 0, lineHeight, maxWidth);
|
||||
// Defensive: convert to string (handles expression objects, numbers, etc.)
|
||||
const textString = typeof text === 'string' ? text : String(text || '');
|
||||
|
||||
return textWordWrap(ctx, textString, 0, 0, lineHeight, maxWidth);
|
||||
}
|
||||
|
||||
function textWordWrap(context, text, x, y, lineHeight, maxWidth, cb?) {
|
||||
if (!text) return;
|
||||
// Defensive: ensure we have a string
|
||||
const textString = typeof text === 'string' ? text : String(text || '');
|
||||
|
||||
let words = text.split(' ');
|
||||
// Empty string still has height (return lineHeight, not undefined)
|
||||
if (!textString) {
|
||||
return lineHeight;
|
||||
}
|
||||
|
||||
let words = textString.split(' ');
|
||||
let currentLine = 0;
|
||||
let idx = 1;
|
||||
while (words.length > 0 && idx <= words.length) {
|
||||
|
||||
@@ -2,10 +2,13 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
|
||||
import { CodeHistoryManager } from '@noodl-models/CodeHistoryManager';
|
||||
import { WarningsModel } from '@noodl-models/warningsmodel';
|
||||
import { createModel } from '@noodl-utils/CodeEditor';
|
||||
import { EditorModel } from '@noodl-utils/CodeEditor/model/editorModel';
|
||||
|
||||
import { JavaScriptEditor, type ValidationType } from '@noodl-core-ui/components/code-editor';
|
||||
|
||||
import { TypeView } from '../TypeView';
|
||||
import { getEditType } from '../utils';
|
||||
import { CodeEditorProps } from './CodeEditor';
|
||||
@@ -204,19 +207,32 @@ export class CodeEditorType extends TypeView {
|
||||
|
||||
this.parent.hidePopout();
|
||||
|
||||
WarningsModel.instance.off(this);
|
||||
WarningsModel.instance.on(
|
||||
'warningsChanged',
|
||||
function () {
|
||||
_this.updateWarnings();
|
||||
},
|
||||
this
|
||||
);
|
||||
// Always use new JavaScriptEditor for JavaScript/TypeScript
|
||||
const isJavaScriptEditor = this.type.codeeditor === 'javascript' || this.type.codeeditor === 'typescript';
|
||||
|
||||
// Only set up Monaco warnings for Monaco-based editors
|
||||
if (!isJavaScriptEditor) {
|
||||
WarningsModel.instance.off(this);
|
||||
WarningsModel.instance.on(
|
||||
'warningsChanged',
|
||||
function () {
|
||||
_this.updateWarnings();
|
||||
},
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
function save() {
|
||||
let source = _this.model.getValue();
|
||||
// For JavaScriptEditor, use this.value (already updated in onChange)
|
||||
// For Monaco editor, get value from model
|
||||
let source = isJavaScriptEditor ? _this.value : _this.model.getValue();
|
||||
if (source === '') source = undefined;
|
||||
|
||||
// Save snapshot to history (before updating)
|
||||
if (source && nodeId) {
|
||||
CodeHistoryManager.instance.saveSnapshot(nodeId, scope.name, source);
|
||||
}
|
||||
|
||||
_this.value = source;
|
||||
_this.parent.setParameter(scope.name, source !== _this.default ? source : undefined);
|
||||
_this.isDefault = source === undefined;
|
||||
@@ -224,14 +240,17 @@ export class CodeEditorType extends TypeView {
|
||||
|
||||
const node = this.parent.model.model;
|
||||
|
||||
this.model = createModel(
|
||||
{
|
||||
type: this.type.name || this.type,
|
||||
value: this.value,
|
||||
codeeditor: this.type.codeeditor?.toLowerCase()
|
||||
},
|
||||
node
|
||||
);
|
||||
// Only create Monaco model for Monaco-based editors
|
||||
if (!isJavaScriptEditor) {
|
||||
this.model = createModel(
|
||||
{
|
||||
type: this.type.name || this.type,
|
||||
value: this.value,
|
||||
codeeditor: this.type.codeeditor?.toLowerCase()
|
||||
},
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
const props: CodeEditorProps = {
|
||||
nodeId,
|
||||
@@ -265,11 +284,62 @@ export class CodeEditorType extends TypeView {
|
||||
y: height
|
||||
};
|
||||
} catch (error) {}
|
||||
} else {
|
||||
// Default size: Make it wider (60% of viewport width, 70% of height)
|
||||
const b = document.body.getBoundingClientRect();
|
||||
props.initialSize = {
|
||||
x: Math.min(b.width * 0.6, b.width - 200), // 60% width, but leave some margin
|
||||
y: Math.min(b.height * 0.7, b.height - 200) // 70% height
|
||||
};
|
||||
}
|
||||
|
||||
this.popoutDiv = document.createElement('div');
|
||||
this.popoutRoot = createRoot(this.popoutDiv);
|
||||
this.popoutRoot.render(React.createElement(CodeEditor, props));
|
||||
|
||||
// Determine which editor to use
|
||||
if (isJavaScriptEditor) {
|
||||
console.log('✨ Using JavaScriptEditor for:', this.type.codeeditor);
|
||||
|
||||
// Determine validation type based on editor type
|
||||
let validationType: ValidationType = 'function';
|
||||
if (this.type.codeeditor === 'javascript') {
|
||||
// Could be expression or function - check type name for hints
|
||||
const typeName = (this.type.name || '').toLowerCase();
|
||||
if (typeName.includes('expression')) {
|
||||
validationType = 'expression';
|
||||
} else if (typeName.includes('script')) {
|
||||
validationType = 'script';
|
||||
} else {
|
||||
validationType = 'function';
|
||||
}
|
||||
} else if (this.type.codeeditor === 'typescript') {
|
||||
validationType = 'script';
|
||||
}
|
||||
|
||||
// Render JavaScriptEditor with proper sizing and history support
|
||||
this.popoutRoot.render(
|
||||
React.createElement(JavaScriptEditor, {
|
||||
value: this.value || '',
|
||||
onChange: (newValue) => {
|
||||
this.value = newValue;
|
||||
// Don't update Monaco model - JavaScriptEditor is independent
|
||||
// The old code triggered Monaco validation which caused errors
|
||||
},
|
||||
onSave: () => {
|
||||
save();
|
||||
},
|
||||
validationType,
|
||||
width: props.initialSize?.x || 800,
|
||||
height: props.initialSize?.y || 500,
|
||||
// Add history tracking
|
||||
nodeId: nodeId,
|
||||
parameterName: scope.name
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Use existing Monaco CodeEditor
|
||||
this.popoutRoot.render(React.createElement(CodeEditor, props));
|
||||
}
|
||||
|
||||
const popoutDiv = this.popoutDiv;
|
||||
this.parent.showPopout({
|
||||
@@ -303,7 +373,11 @@ export class CodeEditorType extends TypeView {
|
||||
}
|
||||
});
|
||||
|
||||
this.updateWarnings();
|
||||
// Only update warnings for Monaco-based editors
|
||||
if (!isJavaScriptEditor) {
|
||||
this.updateWarnings();
|
||||
}
|
||||
|
||||
evt.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
|
||||
import { isExpressionParameter, createExpressionParameter } from '@noodl-models/ExpressionParameter';
|
||||
import { NodeLibrary } from '@noodl-models/nodelibrary';
|
||||
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
|
||||
|
||||
import {
|
||||
PropertyPanelInput,
|
||||
PropertyPanelInputType
|
||||
} from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
|
||||
|
||||
import { TypeView } from '../TypeView';
|
||||
import { getEditType } from '../utils';
|
||||
@@ -7,8 +17,20 @@ function firstType(type) {
|
||||
return NodeLibrary.nameForPortType(type);
|
||||
}
|
||||
|
||||
function mapTypeToInputType(type: string): PropertyPanelInputType {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return PropertyPanelInputType.Number;
|
||||
case 'string':
|
||||
default:
|
||||
return PropertyPanelInputType.Text;
|
||||
}
|
||||
}
|
||||
|
||||
export class BasicType extends TypeView {
|
||||
el: TSFixme;
|
||||
private root: Root | null = null;
|
||||
|
||||
static fromPort(args) {
|
||||
const view = new BasicType();
|
||||
|
||||
@@ -28,12 +50,125 @@ export class BasicType extends TypeView {
|
||||
|
||||
return view;
|
||||
}
|
||||
render() {
|
||||
this.el = this.bindView(this.parent.cloneTemplate(firstType(this.type)), this);
|
||||
TypeView.prototype.render.call(this);
|
||||
|
||||
render() {
|
||||
// Create container for React component
|
||||
const div = document.createElement('div');
|
||||
div.style.width = '100%';
|
||||
|
||||
if (!this.root) {
|
||||
this.root = createRoot(div);
|
||||
}
|
||||
|
||||
this.renderReact();
|
||||
|
||||
this.el = div;
|
||||
return this.el;
|
||||
}
|
||||
|
||||
renderReact() {
|
||||
if (!this.root) return;
|
||||
|
||||
const paramValue = this.parent.model.getParameter(this.name);
|
||||
const isExprMode = isExpressionParameter(paramValue);
|
||||
|
||||
// Get display value - MUST be a primitive, never an object
|
||||
// Use ParameterValueResolver to defensively handle any value type,
|
||||
// including expression objects that might slip through during state transitions
|
||||
const rawValue = isExprMode ? paramValue.fallback : paramValue;
|
||||
const displayValue = ParameterValueResolver.toString(rawValue);
|
||||
|
||||
const props = {
|
||||
label: this.displayName,
|
||||
value: displayValue,
|
||||
inputType: mapTypeToInputType(firstType(this.type)),
|
||||
properties: undefined, // No special properties needed for basic types
|
||||
isChanged: !this.isDefault,
|
||||
isConnected: this.isConnected,
|
||||
onChange: (value: unknown) => {
|
||||
// Handle standard value change
|
||||
if (firstType(this.type) === 'number') {
|
||||
const numValue = parseFloat(String(value));
|
||||
this.parent.setParameter(this.name, isNaN(numValue) ? undefined : numValue, {
|
||||
undo: true,
|
||||
label: `change ${this.displayName}`
|
||||
});
|
||||
} else {
|
||||
this.parent.setParameter(this.name, value, {
|
||||
undo: true,
|
||||
label: `change ${this.displayName}`
|
||||
});
|
||||
}
|
||||
this.isDefault = false;
|
||||
},
|
||||
|
||||
// Expression support
|
||||
supportsExpression: true,
|
||||
expressionMode: isExprMode ? ('expression' as const) : ('fixed' as const),
|
||||
expression: isExprMode ? paramValue.expression : '',
|
||||
|
||||
onExpressionModeChange: (mode: 'fixed' | 'expression') => {
|
||||
const currentParam = this.parent.model.getParameter(this.name);
|
||||
|
||||
if (mode === 'expression') {
|
||||
// Convert to expression parameter
|
||||
const currentValue = isExpressionParameter(currentParam) ? currentParam.fallback : currentParam;
|
||||
|
||||
const exprParam = createExpressionParameter(String(currentValue || ''), currentValue, 1);
|
||||
|
||||
this.parent.setParameter(this.name, exprParam, {
|
||||
undo: true,
|
||||
label: `enable expression for ${this.displayName}`
|
||||
});
|
||||
} else {
|
||||
// Convert back to fixed value
|
||||
const fixedValue = isExpressionParameter(currentParam) ? currentParam.fallback : currentParam;
|
||||
|
||||
this.parent.setParameter(this.name, fixedValue, {
|
||||
undo: true,
|
||||
label: `disable expression for ${this.displayName}`
|
||||
});
|
||||
}
|
||||
|
||||
this.isDefault = false;
|
||||
// Re-render to update UI
|
||||
setTimeout(() => this.renderReact(), 0);
|
||||
},
|
||||
|
||||
onExpressionChange: (expression: string) => {
|
||||
const currentParam = this.parent.model.getParameter(this.name);
|
||||
|
||||
if (isExpressionParameter(currentParam)) {
|
||||
// Update the expression
|
||||
this.parent.setParameter(
|
||||
this.name,
|
||||
{
|
||||
...currentParam,
|
||||
expression
|
||||
},
|
||||
{
|
||||
undo: true,
|
||||
label: `change ${this.displayName} expression`
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.isDefault = false;
|
||||
}
|
||||
};
|
||||
|
||||
this.root.render(React.createElement(PropertyPanelInput, props));
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Legacy method kept for compatibility
|
||||
onPropertyChanged(scope, el) {
|
||||
if (firstType(scope.type) === 'number') {
|
||||
const value = parseFloat(el.val());
|
||||
@@ -42,7 +177,6 @@ export class BasicType extends TypeView {
|
||||
this.parent.setParameter(scope.name, el.val());
|
||||
}
|
||||
|
||||
// Update current value and if it is default or not
|
||||
const current = this.getCurrentValue();
|
||||
el.val(current.value);
|
||||
this.isDefault = current.isDefault;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { platform } from '@noodl/platform';
|
||||
import { Keybindings } from '@noodl-constants/Keybindings';
|
||||
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||
import getDocsEndpoint from '@noodl-utils/getDocsEndpoint';
|
||||
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
|
||||
import { tracker } from '@noodl-utils/tracker';
|
||||
|
||||
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
@@ -22,14 +23,16 @@ export interface NodeLabelProps {
|
||||
export function NodeLabel({ model, showHelp = true }: NodeLabelProps) {
|
||||
const labelInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isEditingLabel, setIsEditingLabel] = useState(false);
|
||||
const [label, setLabel] = useState(model.label);
|
||||
// Defensive: convert label to string (handles expression parameter objects)
|
||||
const [label, setLabel] = useState(ParameterValueResolver.toString(model.label));
|
||||
|
||||
// Listen for label changes on the model
|
||||
useEffect(() => {
|
||||
model.on(
|
||||
'labelChanged',
|
||||
() => {
|
||||
setLabel(model.label);
|
||||
// Defensive: convert label to string (handles expression parameter objects)
|
||||
setLabel(ParameterValueResolver.toString(model.label));
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
279
packages/noodl-editor/tests/models/expression-parameter.test.ts
Normal file
279
packages/noodl-editor/tests/models/expression-parameter.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Expression Parameter Types Tests
|
||||
*
|
||||
* Tests type definitions and helper functions for expression-based parameters
|
||||
*/
|
||||
|
||||
import {
|
||||
ExpressionParameter,
|
||||
isExpressionParameter,
|
||||
getParameterDisplayValue,
|
||||
getParameterActualValue,
|
||||
createExpressionParameter,
|
||||
toParameter
|
||||
} from '../../src/editor/src/models/ExpressionParameter';
|
||||
|
||||
describe('Expression Parameter Types', () => {
|
||||
describe('isExpressionParameter', () => {
|
||||
it('identifies expression parameters', () => {
|
||||
const expr: ExpressionParameter = {
|
||||
mode: 'expression',
|
||||
expression: 'Variables.x + 1',
|
||||
fallback: 0
|
||||
};
|
||||
expect(isExpressionParameter(expr)).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies expression without fallback', () => {
|
||||
const expr: ExpressionParameter = {
|
||||
mode: 'expression',
|
||||
expression: 'Variables.x'
|
||||
};
|
||||
expect(isExpressionParameter(expr)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects simple values', () => {
|
||||
expect(isExpressionParameter(42)).toBe(false);
|
||||
expect(isExpressionParameter('hello')).toBe(false);
|
||||
expect(isExpressionParameter(true)).toBe(false);
|
||||
expect(isExpressionParameter(null)).toBe(false);
|
||||
expect(isExpressionParameter(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects objects without mode', () => {
|
||||
expect(isExpressionParameter({ expression: 'test' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects objects with wrong mode', () => {
|
||||
expect(isExpressionParameter({ mode: 'fixed', value: 42 })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects objects without expression', () => {
|
||||
expect(isExpressionParameter({ mode: 'expression' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects objects with non-string expression', () => {
|
||||
expect(isExpressionParameter({ mode: 'expression', expression: 42 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParameterDisplayValue', () => {
|
||||
it('returns expression string for expression parameters', () => {
|
||||
const expr: ExpressionParameter = {
|
||||
mode: 'expression',
|
||||
expression: 'Variables.x * 2',
|
||||
fallback: 0
|
||||
};
|
||||
expect(getParameterDisplayValue(expr)).toBe('Variables.x * 2');
|
||||
});
|
||||
|
||||
it('returns expression even without fallback', () => {
|
||||
const expr: ExpressionParameter = {
|
||||
mode: 'expression',
|
||||
expression: 'Variables.count'
|
||||
};
|
||||
expect(getParameterDisplayValue(expr)).toBe('Variables.count');
|
||||
});
|
||||
|
||||
it('returns value as-is for simple values', () => {
|
||||
expect(getParameterDisplayValue(42)).toBe(42);
|
||||
expect(getParameterDisplayValue('hello')).toBe('hello');
|
||||
expect(getParameterDisplayValue(true)).toBe(true);
|
||||
expect(getParameterDisplayValue(null)).toBe(null);
|
||||
expect(getParameterDisplayValue(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns value as-is for objects', () => {
|
||||
const obj = { a: 1, b: 2 };
|
||||
expect(getParameterDisplayValue(obj)).toBe(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParameterActualValue', () => {
|
||||
it('returns fallback for expression parameters', () => {
|
||||
const expr: ExpressionParameter = {
|
||||
mode: 'expression',
|
||||
expression: 'Variables.x * 2',
|
||||
fallback: 100
|
||||
};
|
||||
expect(getParameterActualValue(expr)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns undefined for expression without fallback', () => {
|
||||
const expr: ExpressionParameter = {
|
||||
mode: 'expression',
|
||||
expression: 'Variables.x'
|
||||
};
|
||||
expect(getParameterActualValue(expr)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns value as-is for simple values', () => {
|
||||
expect(getParameterActualValue(42)).toBe(42);
|
||||
expect(getParameterActualValue('hello')).toBe('hello');
|
||||
expect(getParameterActualValue(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createExpressionParameter', () => {
|
||||
it('creates expression parameter with all fields', () => {
|
||||
const expr = createExpressionParameter('Variables.count', 0, 2);
|
||||
expect(expr.mode).toBe('expression');
|
||||
expect(expr.expression).toBe('Variables.count');
|
||||
expect(expr.fallback).toBe(0);
|
||||
expect(expr.version).toBe(2);
|
||||
});
|
||||
|
||||
it('uses default version if not provided', () => {
|
||||
const expr = createExpressionParameter('Variables.x', 10);
|
||||
expect(expr.version).toBe(1);
|
||||
});
|
||||
|
||||
it('allows undefined fallback', () => {
|
||||
const expr = createExpressionParameter('Variables.x');
|
||||
expect(expr.fallback).toBeUndefined();
|
||||
expect(expr.version).toBe(1);
|
||||
});
|
||||
|
||||
it('allows null fallback', () => {
|
||||
const expr = createExpressionParameter('Variables.x', null);
|
||||
expect(expr.fallback).toBe(null);
|
||||
});
|
||||
|
||||
it('allows zero as fallback', () => {
|
||||
const expr = createExpressionParameter('Variables.x', 0);
|
||||
expect(expr.fallback).toBe(0);
|
||||
});
|
||||
|
||||
it('allows empty string as fallback', () => {
|
||||
const expr = createExpressionParameter('Variables.x', '');
|
||||
expect(expr.fallback).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toParameter', () => {
|
||||
it('passes through expression parameters', () => {
|
||||
const expr: ExpressionParameter = {
|
||||
mode: 'expression',
|
||||
expression: 'Variables.x',
|
||||
fallback: 0
|
||||
};
|
||||
expect(toParameter(expr)).toBe(expr);
|
||||
});
|
||||
|
||||
it('returns simple values as-is', () => {
|
||||
expect(toParameter(42)).toBe(42);
|
||||
expect(toParameter('hello')).toBe('hello');
|
||||
expect(toParameter(true)).toBe(true);
|
||||
expect(toParameter(null)).toBe(null);
|
||||
expect(toParameter(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns objects as-is', () => {
|
||||
const obj = { a: 1 };
|
||||
expect(toParameter(obj)).toBe(obj);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serialization', () => {
|
||||
it('expression parameters serialize to JSON correctly', () => {
|
||||
const expr = createExpressionParameter('Variables.count', 10);
|
||||
const json = JSON.stringify(expr);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.mode).toBe('expression');
|
||||
expect(parsed.expression).toBe('Variables.count');
|
||||
expect(parsed.fallback).toBe(10);
|
||||
expect(parsed.version).toBe(1);
|
||||
});
|
||||
|
||||
it('deserialized expression parameters are recognized', () => {
|
||||
const json = '{"mode":"expression","expression":"Variables.x","fallback":0,"version":1}';
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(isExpressionParameter(parsed)).toBe(true);
|
||||
expect(parsed.expression).toBe('Variables.x');
|
||||
expect(parsed.fallback).toBe(0);
|
||||
});
|
||||
|
||||
it('handles undefined fallback in serialization', () => {
|
||||
const expr = createExpressionParameter('Variables.x');
|
||||
const json = JSON.stringify(expr);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.fallback).toBeUndefined();
|
||||
expect(isExpressionParameter(parsed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backward Compatibility', () => {
|
||||
it('simple values in parameters object work', () => {
|
||||
const params = {
|
||||
marginLeft: 16,
|
||||
color: '#ff0000',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
expect(isExpressionParameter(params.marginLeft)).toBe(false);
|
||||
expect(isExpressionParameter(params.color)).toBe(false);
|
||||
expect(isExpressionParameter(params.enabled)).toBe(false);
|
||||
});
|
||||
|
||||
it('mixed parameters work', () => {
|
||||
const params = {
|
||||
marginLeft: createExpressionParameter('Variables.spacing', 16),
|
||||
marginRight: 8, // Simple value
|
||||
color: '#ff0000'
|
||||
};
|
||||
|
||||
expect(isExpressionParameter(params.marginLeft)).toBe(true);
|
||||
expect(isExpressionParameter(params.marginRight)).toBe(false);
|
||||
expect(isExpressionParameter(params.color)).toBe(false);
|
||||
});
|
||||
|
||||
it('old project parameters load correctly', () => {
|
||||
// Simulating loading old project
|
||||
const oldParams = {
|
||||
width: 200,
|
||||
height: 100,
|
||||
text: 'Hello'
|
||||
};
|
||||
|
||||
// None should be expressions
|
||||
Object.values(oldParams).forEach((value) => {
|
||||
expect(isExpressionParameter(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('new project with expressions loads correctly', () => {
|
||||
const newParams = {
|
||||
width: createExpressionParameter('Variables.width', 200),
|
||||
height: 100, // Mixed: some expression, some not
|
||||
text: 'Static text'
|
||||
};
|
||||
|
||||
expect(isExpressionParameter(newParams.width)).toBe(true);
|
||||
expect(isExpressionParameter(newParams.height)).toBe(false);
|
||||
expect(isExpressionParameter(newParams.text)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles complex expressions', () => {
|
||||
const expr = createExpressionParameter('Variables.isAdmin ? "Admin Panel" : "User Panel"', 'User Panel');
|
||||
expect(expr.expression).toBe('Variables.isAdmin ? "Admin Panel" : "User Panel"');
|
||||
});
|
||||
|
||||
it('handles multi-line expressions', () => {
|
||||
const multiLine = `Variables.items
|
||||
.filter(x => x.active)
|
||||
.length`;
|
||||
const expr = createExpressionParameter(multiLine, 0);
|
||||
expect(expr.expression).toBe(multiLine);
|
||||
});
|
||||
|
||||
it('handles expressions with special characters', () => {
|
||||
const expr = createExpressionParameter('Variables["my-variable"]', null);
|
||||
expect(expr.expression).toBe('Variables["my-variable"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
387
packages/noodl-editor/tests/utils/ParameterValueResolver.test.ts
Normal file
387
packages/noodl-editor/tests/utils/ParameterValueResolver.test.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Unit tests for ParameterValueResolver
|
||||
*
|
||||
* Tests the resolution of parameter values from storage (primitives or expression objects)
|
||||
* to display/runtime values based on context.
|
||||
*
|
||||
* @module noodl-editor/tests/utils
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
|
||||
import { createExpressionParameter, ExpressionParameter } from '../../src/editor/src/models/ExpressionParameter';
|
||||
import { ParameterValueResolver, ValueContext } from '../../src/editor/src/utils/ParameterValueResolver';
|
||||
|
||||
describe('ParameterValueResolver', () => {
|
||||
describe('resolve()', () => {
|
||||
describe('with primitive values', () => {
|
||||
it('should return string values as-is', () => {
|
||||
expect(ParameterValueResolver.resolve('hello', ValueContext.Display)).toBe('hello');
|
||||
expect(ParameterValueResolver.resolve('', ValueContext.Display)).toBe('');
|
||||
expect(ParameterValueResolver.resolve('123', ValueContext.Display)).toBe('123');
|
||||
});
|
||||
|
||||
it('should return number values as-is', () => {
|
||||
expect(ParameterValueResolver.resolve(42, ValueContext.Display)).toBe(42);
|
||||
expect(ParameterValueResolver.resolve(0, ValueContext.Display)).toBe(0);
|
||||
expect(ParameterValueResolver.resolve(-42.5, ValueContext.Display)).toBe(-42.5);
|
||||
});
|
||||
|
||||
it('should return boolean values as-is', () => {
|
||||
expect(ParameterValueResolver.resolve(true, ValueContext.Display)).toBe(true);
|
||||
expect(ParameterValueResolver.resolve(false, ValueContext.Display)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined as-is', () => {
|
||||
expect(ParameterValueResolver.resolve(undefined, ValueContext.Display)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle null', () => {
|
||||
expect(ParameterValueResolver.resolve(null, ValueContext.Display)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with expression parameters', () => {
|
||||
it('should extract fallback from expression parameter in Display context', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
|
||||
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('default');
|
||||
});
|
||||
|
||||
it('should extract fallback from expression parameter in Runtime context', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
|
||||
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Runtime)).toBe('default');
|
||||
});
|
||||
|
||||
it('should return full object in Serialization context', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
|
||||
const result = ParameterValueResolver.resolve(exprParam, ValueContext.Serialization);
|
||||
expect(result).toBe(exprParam);
|
||||
expect((result as ExpressionParameter).mode).toBe('expression');
|
||||
});
|
||||
|
||||
it('should handle expression parameter with undefined fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
|
||||
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle expression parameter with numeric fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.count', 42, 1);
|
||||
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe(42);
|
||||
});
|
||||
|
||||
it('should handle expression parameter with boolean fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.flag', true, 1);
|
||||
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle expression parameter with empty string fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', '', 1);
|
||||
expect(ParameterValueResolver.resolve(exprParam, ValueContext.Display)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle objects that are not expression parameters', () => {
|
||||
const regularObj = { foo: 'bar' };
|
||||
// Should return as-is since it's not an expression parameter
|
||||
expect(ParameterValueResolver.resolve(regularObj, ValueContext.Display)).toBe(regularObj);
|
||||
});
|
||||
|
||||
it('should default to fallback for unknown context', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
|
||||
// Cast to any to test invalid context
|
||||
expect(ParameterValueResolver.resolve(exprParam, 'invalid' as any)).toBe('default');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toString()', () => {
|
||||
describe('with primitive values', () => {
|
||||
it('should convert string to string', () => {
|
||||
expect(ParameterValueResolver.toString('hello')).toBe('hello');
|
||||
expect(ParameterValueResolver.toString('')).toBe('');
|
||||
});
|
||||
|
||||
it('should convert number to string', () => {
|
||||
expect(ParameterValueResolver.toString(42)).toBe('42');
|
||||
expect(ParameterValueResolver.toString(0)).toBe('0');
|
||||
expect(ParameterValueResolver.toString(-42.5)).toBe('-42.5');
|
||||
});
|
||||
|
||||
it('should convert boolean to string', () => {
|
||||
expect(ParameterValueResolver.toString(true)).toBe('true');
|
||||
expect(ParameterValueResolver.toString(false)).toBe('false');
|
||||
});
|
||||
|
||||
it('should convert undefined to empty string', () => {
|
||||
expect(ParameterValueResolver.toString(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('should convert null to empty string', () => {
|
||||
expect(ParameterValueResolver.toString(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with expression parameters', () => {
|
||||
it('should extract fallback as string from expression parameter', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', 'test', 1);
|
||||
expect(ParameterValueResolver.toString(exprParam)).toBe('test');
|
||||
});
|
||||
|
||||
it('should convert numeric fallback to string', () => {
|
||||
const exprParam = createExpressionParameter('Variables.count', 42, 1);
|
||||
expect(ParameterValueResolver.toString(exprParam)).toBe('42');
|
||||
});
|
||||
|
||||
it('should convert boolean fallback to string', () => {
|
||||
const exprParam = createExpressionParameter('Variables.flag', true, 1);
|
||||
expect(ParameterValueResolver.toString(exprParam)).toBe('true');
|
||||
});
|
||||
|
||||
it('should handle expression parameter with undefined fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
|
||||
expect(ParameterValueResolver.toString(exprParam)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle expression parameter with null fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', null, 1);
|
||||
expect(ParameterValueResolver.toString(exprParam)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle objects that are not expression parameters', () => {
|
||||
const regularObj = { foo: 'bar' };
|
||||
// Should return empty string for safety (defensive behavior)
|
||||
expect(ParameterValueResolver.toString(regularObj)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
expect(ParameterValueResolver.toString([1, 2, 3])).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNumber()', () => {
|
||||
describe('with primitive values', () => {
|
||||
it('should return number as-is', () => {
|
||||
expect(ParameterValueResolver.toNumber(42)).toBe(42);
|
||||
expect(ParameterValueResolver.toNumber(0)).toBe(0);
|
||||
expect(ParameterValueResolver.toNumber(-42.5)).toBe(-42.5);
|
||||
});
|
||||
|
||||
it('should convert numeric string to number', () => {
|
||||
expect(ParameterValueResolver.toNumber('42')).toBe(42);
|
||||
expect(ParameterValueResolver.toNumber('0')).toBe(0);
|
||||
expect(ParameterValueResolver.toNumber('-42.5')).toBe(-42.5);
|
||||
});
|
||||
|
||||
it('should return undefined for non-numeric string', () => {
|
||||
expect(ParameterValueResolver.toNumber('hello')).toBe(undefined);
|
||||
expect(ParameterValueResolver.toNumber('not a number')).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for undefined', () => {
|
||||
expect(ParameterValueResolver.toNumber(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for null', () => {
|
||||
expect(ParameterValueResolver.toNumber(null)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should convert boolean to number', () => {
|
||||
expect(ParameterValueResolver.toNumber(true)).toBe(1);
|
||||
expect(ParameterValueResolver.toNumber(false)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with expression parameters', () => {
|
||||
it('should extract numeric fallback from expression parameter', () => {
|
||||
const exprParam = createExpressionParameter('Variables.count', 42, 1);
|
||||
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
|
||||
});
|
||||
|
||||
it('should convert string fallback to number', () => {
|
||||
const exprParam = createExpressionParameter('Variables.count', '42', 1);
|
||||
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
|
||||
});
|
||||
|
||||
it('should return undefined for non-numeric fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.text', 'hello', 1);
|
||||
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle expression parameter with undefined fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
|
||||
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle expression parameter with null fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', null, 1);
|
||||
expect(ParameterValueResolver.toNumber(exprParam)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle objects that are not expression parameters', () => {
|
||||
const regularObj = { foo: 'bar' };
|
||||
expect(ParameterValueResolver.toNumber(regularObj)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
expect(ParameterValueResolver.toNumber([1, 2, 3])).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(ParameterValueResolver.toNumber('')).toBe(0); // Empty string converts to 0
|
||||
});
|
||||
|
||||
it('should handle whitespace string', () => {
|
||||
expect(ParameterValueResolver.toNumber(' ')).toBe(0); // Whitespace converts to 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toBoolean()', () => {
|
||||
describe('with primitive values', () => {
|
||||
it('should return boolean as-is', () => {
|
||||
expect(ParameterValueResolver.toBoolean(true)).toBe(true);
|
||||
expect(ParameterValueResolver.toBoolean(false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should convert truthy strings to true', () => {
|
||||
expect(ParameterValueResolver.toBoolean('hello')).toBe(true);
|
||||
expect(ParameterValueResolver.toBoolean('0')).toBe(true); // Non-empty string is truthy
|
||||
expect(ParameterValueResolver.toBoolean('false')).toBe(true); // Non-empty string is truthy
|
||||
});
|
||||
|
||||
it('should convert empty string to false', () => {
|
||||
expect(ParameterValueResolver.toBoolean('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should convert numbers using truthiness', () => {
|
||||
expect(ParameterValueResolver.toBoolean(1)).toBe(true);
|
||||
expect(ParameterValueResolver.toBoolean(42)).toBe(true);
|
||||
expect(ParameterValueResolver.toBoolean(0)).toBe(false);
|
||||
expect(ParameterValueResolver.toBoolean(-1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert undefined to false', () => {
|
||||
expect(ParameterValueResolver.toBoolean(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should convert null to false', () => {
|
||||
expect(ParameterValueResolver.toBoolean(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with expression parameters', () => {
|
||||
it('should extract boolean fallback from expression parameter', () => {
|
||||
const exprParam = createExpressionParameter('Variables.flag', true, 1);
|
||||
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert string fallback to boolean', () => {
|
||||
const exprParamTruthy = createExpressionParameter('Variables.text', 'hello', 1);
|
||||
expect(ParameterValueResolver.toBoolean(exprParamTruthy)).toBe(true);
|
||||
|
||||
const exprParamFalsy = createExpressionParameter('Variables.text', '', 1);
|
||||
expect(ParameterValueResolver.toBoolean(exprParamFalsy)).toBe(false);
|
||||
});
|
||||
|
||||
it('should convert numeric fallback to boolean', () => {
|
||||
const exprParamTruthy = createExpressionParameter('Variables.count', 42, 1);
|
||||
expect(ParameterValueResolver.toBoolean(exprParamTruthy)).toBe(true);
|
||||
|
||||
const exprParamFalsy = createExpressionParameter('Variables.count', 0, 1);
|
||||
expect(ParameterValueResolver.toBoolean(exprParamFalsy)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle expression parameter with undefined fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', undefined, 1);
|
||||
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle expression parameter with null fallback', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', null, 1);
|
||||
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle objects that are not expression parameters', () => {
|
||||
const regularObj = { foo: 'bar' };
|
||||
// Non-expression objects should return false (defensive behavior)
|
||||
expect(ParameterValueResolver.toBoolean(regularObj)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle arrays', () => {
|
||||
expect(ParameterValueResolver.toBoolean([1, 2, 3])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpression()', () => {
|
||||
it('should return true for expression parameters', () => {
|
||||
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
|
||||
expect(ParameterValueResolver.isExpression(exprParam)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for primitive values', () => {
|
||||
expect(ParameterValueResolver.isExpression('hello')).toBe(false);
|
||||
expect(ParameterValueResolver.isExpression(42)).toBe(false);
|
||||
expect(ParameterValueResolver.isExpression(true)).toBe(false);
|
||||
expect(ParameterValueResolver.isExpression(undefined)).toBe(false);
|
||||
expect(ParameterValueResolver.isExpression(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for regular objects', () => {
|
||||
const regularObj = { foo: 'bar' };
|
||||
expect(ParameterValueResolver.isExpression(regularObj)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for arrays', () => {
|
||||
expect(ParameterValueResolver.isExpression([1, 2, 3])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('should handle converting expression parameter through all type conversions', () => {
|
||||
const exprParam = createExpressionParameter('Variables.count', 42, 1);
|
||||
|
||||
expect(ParameterValueResolver.toString(exprParam)).toBe('42');
|
||||
expect(ParameterValueResolver.toNumber(exprParam)).toBe(42);
|
||||
expect(ParameterValueResolver.toBoolean(exprParam)).toBe(true);
|
||||
expect(ParameterValueResolver.isExpression(exprParam)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle canvas rendering scenario (text.split prevention)', () => {
|
||||
// This is the actual bug we're fixing - canvas tries to call .split() on a parameter
|
||||
const exprParam = createExpressionParameter('Variables.text', 'Hello\nWorld', 1);
|
||||
|
||||
// Before fix: this would return the object, causing text.split() to crash
|
||||
// After fix: this returns a string that can be safely split
|
||||
const text = ParameterValueResolver.toString(exprParam);
|
||||
expect(typeof text).toBe('string');
|
||||
expect(() => text.split('\n')).not.toThrow();
|
||||
expect(text.split('\n')).toEqual(['Hello', 'World']);
|
||||
});
|
||||
|
||||
it('should handle property panel display scenario', () => {
|
||||
// Property panel needs to show fallback value while user edits expression
|
||||
const exprParam = createExpressionParameter('2 + 2', '4', 1);
|
||||
|
||||
const displayValue = ParameterValueResolver.resolve(exprParam, ValueContext.Display);
|
||||
expect(displayValue).toBe('4');
|
||||
});
|
||||
|
||||
it('should handle serialization scenario', () => {
|
||||
// When saving, we need the full object preserved
|
||||
const exprParam = createExpressionParameter('Variables.x', 'default', 1);
|
||||
|
||||
const serialized = ParameterValueResolver.resolve(exprParam, ValueContext.Serialization);
|
||||
expect(serialized).toBe(exprParam);
|
||||
expect((serialized as ExpressionParameter).expression).toBe('Variables.x');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from './ParameterValueResolver.test';
|
||||
export * from './verify-json.spec';
|
||||
|
||||
314
packages/noodl-runtime/src/expression-evaluator.js
Normal file
314
packages/noodl-runtime/src/expression-evaluator.js
Normal 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
|
||||
};
|
||||
111
packages/noodl-runtime/src/expression-type-coercion.js
Normal file
111
packages/noodl-runtime/src/expression-type-coercion.js
Normal 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
|
||||
};
|
||||
@@ -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') {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const difference = require('lodash.difference');
|
||||
|
||||
//const Model = require('./data/model');
|
||||
const ExpressionEvaluator = require('../../expression-evaluator');
|
||||
|
||||
const ExpressionNode = {
|
||||
name: 'Expression',
|
||||
@@ -26,6 +25,19 @@ const ExpressionNode = {
|
||||
internal.compiledFunction = undefined;
|
||||
internal.inputNames = [];
|
||||
internal.inputValues = [];
|
||||
|
||||
// New: Expression evaluator integration
|
||||
internal.noodlDependencies = { variables: [], objects: [], arrays: [] };
|
||||
internal.unsubscribe = null;
|
||||
},
|
||||
methods: {
|
||||
_onNodeDeleted: function () {
|
||||
// Clean up reactive subscriptions to prevent memory leaks
|
||||
if (this._internal.unsubscribe) {
|
||||
this._internal.unsubscribe();
|
||||
this._internal.unsubscribe = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
getInspectInfo() {
|
||||
return this._internal.cachedValue;
|
||||
@@ -72,15 +84,31 @@ const ExpressionNode = {
|
||||
self._inputValues[name] = 0;
|
||||
});
|
||||
|
||||
/* if(value.indexOf('Vars') !== -1 || value.indexOf('Variables') !== -1) {
|
||||
// This expression is using variables, it should listen for changes
|
||||
this._internal.onVariablesChangedCallback = (args) => {
|
||||
this._scheduleEvaluateExpression()
|
||||
}
|
||||
// Detect dependencies for reactive updates
|
||||
internal.noodlDependencies = ExpressionEvaluator.detectDependencies(value);
|
||||
|
||||
Model.get('--ndl--global-variables').off('change',this._internal.onVariablesChangedCallback)
|
||||
Model.get('--ndl--global-variables').on('change',this._internal.onVariablesChangedCallback)
|
||||
}*/
|
||||
// Clean up old subscription
|
||||
if (internal.unsubscribe) {
|
||||
internal.unsubscribe();
|
||||
internal.unsubscribe = null;
|
||||
}
|
||||
|
||||
// Subscribe to Noodl global changes if expression uses them
|
||||
if (
|
||||
internal.noodlDependencies.variables.length > 0 ||
|
||||
internal.noodlDependencies.objects.length > 0 ||
|
||||
internal.noodlDependencies.arrays.length > 0
|
||||
) {
|
||||
internal.unsubscribe = ExpressionEvaluator.subscribeToChanges(
|
||||
internal.noodlDependencies,
|
||||
function () {
|
||||
if (!self.isInputConnected('run')) {
|
||||
self._scheduleEvaluateExpression();
|
||||
}
|
||||
},
|
||||
self.context && self.context.modelScope
|
||||
);
|
||||
}
|
||||
|
||||
internal.inputNames = Object.keys(internal.scope);
|
||||
if (!this.isInputConnected('run')) this._scheduleEvaluateExpression();
|
||||
@@ -141,6 +169,33 @@ const ExpressionNode = {
|
||||
group: 'Events',
|
||||
type: 'signal',
|
||||
displayName: 'On False'
|
||||
},
|
||||
// New typed outputs for better downstream compatibility
|
||||
asString: {
|
||||
group: 'Typed Results',
|
||||
type: 'string',
|
||||
displayName: 'As String',
|
||||
getter: function () {
|
||||
const val = this._internal.cachedValue;
|
||||
return val !== undefined && val !== null ? String(val) : '';
|
||||
}
|
||||
},
|
||||
asNumber: {
|
||||
group: 'Typed Results',
|
||||
type: 'number',
|
||||
displayName: 'As Number',
|
||||
getter: function () {
|
||||
const val = this._internal.cachedValue;
|
||||
return typeof val === 'number' ? val : Number(val) || 0;
|
||||
}
|
||||
},
|
||||
asBoolean: {
|
||||
group: 'Typed Results',
|
||||
type: 'boolean',
|
||||
displayName: 'As Boolean',
|
||||
getter: function () {
|
||||
return !!this._internal.cachedValue;
|
||||
}
|
||||
}
|
||||
},
|
||||
prototypeExtensions: {
|
||||
@@ -235,8 +290,19 @@ var functionPreamble = [
|
||||
' floor = Math.floor,' +
|
||||
' ceil = Math.ceil,' +
|
||||
' abs = Math.abs,' +
|
||||
' random = Math.random;'
|
||||
/* ' Vars = Variables = Noodl.Object.get("--ndl--global-variables");' */
|
||||
' random = Math.random,' +
|
||||
' pow = Math.pow,' +
|
||||
' log = Math.log,' +
|
||||
' exp = Math.exp;' +
|
||||
// Add Noodl global context
|
||||
'try {' +
|
||||
' var NoodlContext = (typeof Noodl !== "undefined") ? Noodl : (typeof global !== "undefined" && global.Noodl) || {};' +
|
||||
' var Variables = NoodlContext.Variables || {};' +
|
||||
' var Objects = NoodlContext.Objects || {};' +
|
||||
' var Arrays = NoodlContext.Arrays || {};' +
|
||||
'} catch (e) {' +
|
||||
' var Variables = {}, Objects = {}, Arrays = {};' +
|
||||
'}'
|
||||
].join('');
|
||||
|
||||
//Since apply cannot be used on constructors (i.e. new Something) we need this hax
|
||||
@@ -264,11 +330,19 @@ var portsToIgnore = [
|
||||
'ceil',
|
||||
'abs',
|
||||
'random',
|
||||
'pow',
|
||||
'log',
|
||||
'exp',
|
||||
'Math',
|
||||
'window',
|
||||
'document',
|
||||
'undefined',
|
||||
'Vars',
|
||||
'Variables',
|
||||
'Objects',
|
||||
'Arrays',
|
||||
'Noodl',
|
||||
'NoodlContext',
|
||||
'true',
|
||||
'false',
|
||||
'null',
|
||||
@@ -326,13 +400,43 @@ function updatePorts(nodeId, expression, editorConnection) {
|
||||
}
|
||||
|
||||
function evalCompileWarnings(editorConnection, node) {
|
||||
try {
|
||||
new Function(node.parameters.expression);
|
||||
const expression = node.parameters.expression;
|
||||
if (!expression) {
|
||||
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate expression syntax
|
||||
const validation = ExpressionEvaluator.validateExpression(expression);
|
||||
|
||||
if (!validation.valid) {
|
||||
editorConnection.sendWarning(node.component.name, node.id, 'expression-compile-error', {
|
||||
message: e.message
|
||||
message: 'Syntax error: ' + validation.error
|
||||
});
|
||||
} else {
|
||||
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
|
||||
|
||||
// Optionally show detected dependencies as info (helpful for users)
|
||||
const deps = ExpressionEvaluator.detectDependencies(expression);
|
||||
const depCount = deps.variables.length + deps.objects.length + deps.arrays.length;
|
||||
|
||||
if (depCount > 0) {
|
||||
const depList = [];
|
||||
if (deps.variables.length > 0) {
|
||||
depList.push('Variables: ' + deps.variables.join(', '));
|
||||
}
|
||||
if (deps.objects.length > 0) {
|
||||
depList.push('Objects: ' + deps.objects.join(', '));
|
||||
}
|
||||
if (deps.arrays.length > 0) {
|
||||
depList.push('Arrays: ' + deps.arrays.join(', '));
|
||||
}
|
||||
|
||||
// This is just informational, not an error
|
||||
// Could be shown in a future info panel
|
||||
// For now, we'll just log it
|
||||
console.log('[Expression Node] Reactive dependencies detected:', depList.join('; '));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
357
packages/noodl-runtime/test/expression-evaluator.test.js
Normal file
357
packages/noodl-runtime/test/expression-evaluator.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
211
packages/noodl-runtime/test/expression-type-coercion.test.js
Normal file
211
packages/noodl-runtime/test/expression-type-coercion.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
345
packages/noodl-runtime/test/node-expression-evaluation.test.js
Normal file
345
packages/noodl-runtime/test/node-expression-evaluation.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user