Merge origin/cline-dev - kept local version of LEARNINGS.md
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 682 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,36 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = async function (params) {
|
||||
if (process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.appleIdPassword) {
|
||||
console.log('apple password not set, skipping notarization');
|
||||
return;
|
||||
}
|
||||
|
||||
const appId = 'com.opennoodl.app';
|
||||
|
||||
const appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`);
|
||||
if (!fs.existsSync(appPath)) {
|
||||
throw new Error(`Cannot find application at: ${appPath}`);
|
||||
}
|
||||
|
||||
console.log(`Notarizing ${appId} found at ${appPath}`);
|
||||
|
||||
try {
|
||||
const electron_notarize = require('electron-notarize');
|
||||
await electron_notarize.notarize({
|
||||
appBundleId: appId,
|
||||
appPath: appPath,
|
||||
appleId: process.env.appleId,
|
||||
appleIdPassword: process.env.appleIdPassword
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
console.log(`Done notarizing ${appId}`);
|
||||
};
|
||||
@@ -60,6 +60,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@blockly/theme-dark": "^8.0.3",
|
||||
"@electron/remote": "^2.1.3",
|
||||
"@jaames/iro": "^5.5.2",
|
||||
"@microlink/react-json-view": "^1.27.0",
|
||||
@@ -68,10 +69,13 @@
|
||||
"@noodl/noodl-parse-dashboard": "file:../noodl-parse-dashboard",
|
||||
"@noodl/platform": "file:../noodl-platform",
|
||||
"@noodl/platform-electron": "file:../noodl-platform-electron",
|
||||
"@octokit/auth-oauth-device": "^7.1.5",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
"about-window": "^1.15.2",
|
||||
"algoliasearch": "^5.35.0",
|
||||
"archiver": "^5.3.2",
|
||||
"async": "^3.2.6",
|
||||
"blockly": "^12.3.1",
|
||||
"classnames": "^2.5.1",
|
||||
"dagre": "^0.8.5",
|
||||
"diff3": "0.0.4",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||
|
||||
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
|
||||
|
||||
/**
|
||||
* Tab types supported by the canvas tab system
|
||||
*/
|
||||
export type TabType = 'logic-builder';
|
||||
|
||||
/**
|
||||
* Tab data structure
|
||||
*/
|
||||
export interface Tab {
|
||||
/** Unique tab identifier */
|
||||
id: string;
|
||||
/** Type of tab */
|
||||
type: TabType;
|
||||
/** Node ID (for logic-builder tabs) */
|
||||
nodeId?: string;
|
||||
/** Node name for display (for logic-builder tabs) */
|
||||
nodeName?: string;
|
||||
/** Blockly workspace JSON (for logic-builder tabs) */
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value shape
|
||||
*/
|
||||
export interface CanvasTabsContextValue {
|
||||
/** All open tabs */
|
||||
tabs: Tab[];
|
||||
/** Currently active tab ID */
|
||||
activeTabId: string;
|
||||
/** Open a new tab or switch to existing */
|
||||
openTab: (tab: Omit<Tab, 'id'> & { id?: string }) => void;
|
||||
/** Close a tab by ID */
|
||||
closeTab: (tabId: string) => void;
|
||||
/** Switch to a different tab */
|
||||
switchTab: (tabId: string) => void;
|
||||
/** Update tab data */
|
||||
updateTab: (tabId: string, updates: Partial<Tab>) => void;
|
||||
/** Get tab by ID */
|
||||
getTab: (tabId: string) => Tab | undefined;
|
||||
}
|
||||
|
||||
const CanvasTabsContext = createContext<CanvasTabsContextValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Hook to access canvas tabs context
|
||||
*/
|
||||
export function useCanvasTabs(): CanvasTabsContextValue {
|
||||
const context = useContext(CanvasTabsContext);
|
||||
if (!context) {
|
||||
throw new Error('useCanvasTabs must be used within a CanvasTabsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface CanvasTabsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for canvas tabs state
|
||||
*/
|
||||
export function CanvasTabsProvider({ children }: CanvasTabsProviderProps) {
|
||||
// Start with no tabs - Logic Builder tabs are opened on demand
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
|
||||
const [activeTabId, setActiveTabId] = useState<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Open a new tab or switch to existing one
|
||||
*/
|
||||
const openTab = useCallback((newTab: Omit<Tab, 'id'> & { id?: string }) => {
|
||||
// Generate ID if not provided
|
||||
const tabId = newTab.id || `${newTab.type}-${newTab.nodeId || Date.now()}`;
|
||||
|
||||
setTabs((prevTabs) => {
|
||||
// Check if tab already exists
|
||||
const existingTab = prevTabs.find((t) => t.id === tabId);
|
||||
if (existingTab) {
|
||||
// Tab exists, just switch to it
|
||||
setActiveTabId(tabId);
|
||||
return prevTabs;
|
||||
}
|
||||
|
||||
// Add new tab
|
||||
const tab: Tab = {
|
||||
...newTab,
|
||||
id: tabId
|
||||
};
|
||||
|
||||
const newTabs = [...prevTabs, tab];
|
||||
|
||||
// Emit event that a Logic Builder tab was opened (first tab)
|
||||
if (prevTabs.length === 0) {
|
||||
EventDispatcher.instance.emit('LogicBuilder.TabOpened');
|
||||
}
|
||||
|
||||
return newTabs;
|
||||
});
|
||||
|
||||
// Switch to the new/existing tab
|
||||
setActiveTabId(tabId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Listen for Logic Builder tab open requests from property panel
|
||||
*/
|
||||
useEffect(() => {
|
||||
const context = {};
|
||||
|
||||
const handleOpenTab = (data: { nodeId: string; nodeName: string; workspace: string }) => {
|
||||
console.log('[CanvasTabsContext] Received LogicBuilder.OpenTab event:', data);
|
||||
openTab({
|
||||
type: 'logic-builder',
|
||||
nodeId: data.nodeId,
|
||||
nodeName: data.nodeName,
|
||||
workspace: data.workspace
|
||||
});
|
||||
};
|
||||
|
||||
EventDispatcher.instance.on('LogicBuilder.OpenTab', handleOpenTab, context);
|
||||
|
||||
return () => {
|
||||
EventDispatcher.instance.off(context);
|
||||
};
|
||||
}, [openTab]);
|
||||
|
||||
/**
|
||||
* Close a tab by ID
|
||||
*/
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => {
|
||||
setTabs((prevTabs) => {
|
||||
const tabIndex = prevTabs.findIndex((t) => t.id === tabId);
|
||||
if (tabIndex === -1) {
|
||||
return prevTabs;
|
||||
}
|
||||
|
||||
const newTabs = prevTabs.filter((t) => t.id !== tabId);
|
||||
|
||||
// If closing the active tab, switch to another tab or clear active
|
||||
if (activeTabId === tabId) {
|
||||
if (newTabs.length > 0) {
|
||||
setActiveTabId(newTabs[newTabs.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId(undefined);
|
||||
// Emit event that all Logic Builder tabs are closed
|
||||
EventDispatcher.instance.emit('LogicBuilder.AllTabsClosed');
|
||||
}
|
||||
}
|
||||
|
||||
return newTabs;
|
||||
});
|
||||
},
|
||||
[activeTabId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Switch to a different tab
|
||||
*/
|
||||
const switchTab = useCallback((tabId: string) => {
|
||||
setTabs((prevTabs) => {
|
||||
// Verify tab exists
|
||||
const tab = prevTabs.find((t) => t.id === tabId);
|
||||
if (!tab) {
|
||||
console.warn(`[CanvasTabs] Tab ${tabId} not found`);
|
||||
return prevTabs;
|
||||
}
|
||||
|
||||
setActiveTabId(tabId);
|
||||
return prevTabs;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update tab data
|
||||
*/
|
||||
const updateTab = useCallback((tabId: string, updates: Partial<Tab>) => {
|
||||
setTabs((prevTabs) => {
|
||||
return prevTabs.map((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
return { ...tab, ...updates };
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get tab by ID
|
||||
*/
|
||||
const getTab = useCallback(
|
||||
(tabId: string): Tab | undefined => {
|
||||
return tabs.find((t) => t.id === tabId);
|
||||
},
|
||||
[tabs]
|
||||
);
|
||||
|
||||
const value: CanvasTabsContextValue = {
|
||||
tabs,
|
||||
activeTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
switchTab,
|
||||
updateTab,
|
||||
getTab
|
||||
};
|
||||
|
||||
return <CanvasTabsContext.Provider value={value}>{children}</CanvasTabsContext.Provider>;
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
LauncherProjectData
|
||||
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
|
||||
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
|
||||
import { GitHubUser } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
|
||||
|
||||
import { useEventListener } from '../../hooks/useEventListener';
|
||||
import { IRouteProps } from '../../pages/AppRoute';
|
||||
import { GitHubOAuthService } from '../../services/GitHubOAuthService';
|
||||
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
|
||||
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
|
||||
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
|
||||
@@ -49,11 +47,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
// Real projects from LocalProjectsModel
|
||||
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
|
||||
|
||||
// GitHub OAuth state
|
||||
const [githubUser, setGithubUser] = useState<GitHubUser | null>(null);
|
||||
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState<boolean>(false);
|
||||
const [githubIsConnecting, setGithubIsConnecting] = useState<boolean>(false);
|
||||
|
||||
// Create project modal state
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
|
||||
@@ -62,17 +55,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
// Switch main window size to editor size
|
||||
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
|
||||
|
||||
// Initialize GitHub OAuth service
|
||||
const initGitHub = async () => {
|
||||
console.log('🔧 Initializing GitHub OAuth service...');
|
||||
await GitHubOAuthService.instance.initialize();
|
||||
const user = GitHubOAuthService.instance.getCurrentUser();
|
||||
const isAuth = GitHubOAuthService.instance.isAuthenticated();
|
||||
setGithubUser(user);
|
||||
setGithubIsAuthenticated(isAuth);
|
||||
console.log('✅ GitHub OAuth initialized. Authenticated:', isAuth);
|
||||
};
|
||||
|
||||
// Load projects
|
||||
const loadProjects = async () => {
|
||||
await LocalProjectsModel.instance.fetch();
|
||||
@@ -80,31 +62,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
setRealProjects(projects.map(mapProjectToLauncherData));
|
||||
};
|
||||
|
||||
initGitHub();
|
||||
loadProjects();
|
||||
|
||||
// Set up IPC listener for OAuth callback
|
||||
const handleOAuthCallback = (_event: any, { code, state }: { code: string; state: string }) => {
|
||||
console.log('🔄 Received GitHub OAuth callback from main process');
|
||||
setGithubIsConnecting(true);
|
||||
GitHubOAuthService.instance
|
||||
.handleCallback(code, state)
|
||||
.then(() => {
|
||||
console.log('✅ OAuth callback handled successfully');
|
||||
setGithubIsConnecting(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ OAuth callback failed:', error);
|
||||
setGithubIsConnecting(false);
|
||||
ToastLayer.showError('GitHub authentication failed');
|
||||
});
|
||||
};
|
||||
|
||||
ipcRenderer.on('github-oauth-callback', handleOAuthCallback);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener('github-oauth-callback', handleOAuthCallback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to project list changes
|
||||
@@ -114,44 +72,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
setRealProjects(projects.map(mapProjectToLauncherData));
|
||||
});
|
||||
|
||||
// Subscribe to GitHub OAuth state changes
|
||||
useEventListener(GitHubOAuthService.instance, 'oauth-success', (data: { user: GitHubUser }) => {
|
||||
console.log('🎉 GitHub OAuth success:', data.user.login);
|
||||
setGithubUser(data.user);
|
||||
setGithubIsAuthenticated(true);
|
||||
setGithubIsConnecting(false);
|
||||
ToastLayer.showSuccess(`Connected to GitHub as ${data.user.login}`);
|
||||
});
|
||||
|
||||
useEventListener(GitHubOAuthService.instance, 'auth-state-changed', (data: { authenticated: boolean }) => {
|
||||
console.log('🔐 GitHub auth state changed:', data.authenticated);
|
||||
setGithubIsAuthenticated(data.authenticated);
|
||||
if (data.authenticated) {
|
||||
const user = GitHubOAuthService.instance.getCurrentUser();
|
||||
setGithubUser(user);
|
||||
} else {
|
||||
setGithubUser(null);
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener(GitHubOAuthService.instance, 'oauth-started', () => {
|
||||
console.log('🚀 GitHub OAuth flow started');
|
||||
setGithubIsConnecting(true);
|
||||
});
|
||||
|
||||
useEventListener(GitHubOAuthService.instance, 'oauth-error', (data: { error: string }) => {
|
||||
console.error('❌ GitHub OAuth error:', data.error);
|
||||
setGithubIsConnecting(false);
|
||||
ToastLayer.showError(`GitHub authentication failed: ${data.error}`);
|
||||
});
|
||||
|
||||
useEventListener(GitHubOAuthService.instance, 'disconnected', () => {
|
||||
console.log('👋 GitHub disconnected');
|
||||
setGithubUser(null);
|
||||
setGithubIsAuthenticated(false);
|
||||
ToastLayer.showSuccess('Disconnected from GitHub');
|
||||
});
|
||||
|
||||
const handleCreateProject = useCallback(() => {
|
||||
setIsCreateModalVisible(true);
|
||||
}, []);
|
||||
@@ -336,17 +256,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// GitHub OAuth handlers
|
||||
const handleGitHubConnect = useCallback(() => {
|
||||
console.log('🔗 Initiating GitHub OAuth...');
|
||||
GitHubOAuthService.instance.initiateOAuth();
|
||||
}, []);
|
||||
|
||||
const handleGitHubDisconnect = useCallback(() => {
|
||||
console.log('🔌 Disconnecting GitHub...');
|
||||
GitHubOAuthService.instance.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Launcher
|
||||
@@ -357,11 +266,11 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
onOpenProjectFolder={handleOpenProjectFolder}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
projectOrganizationService={ProjectOrganizationService.instance}
|
||||
githubUser={githubUser}
|
||||
githubIsAuthenticated={githubIsAuthenticated}
|
||||
githubIsConnecting={githubIsConnecting}
|
||||
onGitHubConnect={handleGitHubConnect}
|
||||
onGitHubDisconnect={handleGitHubDisconnect}
|
||||
githubUser={null}
|
||||
githubIsAuthenticated={false}
|
||||
githubIsConnecting={false}
|
||||
onGitHubConnect={() => {}}
|
||||
onGitHubDisconnect={() => {}}
|
||||
/>
|
||||
|
||||
<CreateProjectModal
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* GitHubAuth
|
||||
*
|
||||
* Handles GitHub OAuth authentication using Web OAuth Flow.
|
||||
* Web OAuth Flow allows users to select which organizations and repositories
|
||||
* to grant access to, providing better permission control.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
import { ipcRenderer, shell } from 'electron';
|
||||
|
||||
import { GitHubTokenStore } from './GitHubTokenStore';
|
||||
import type {
|
||||
GitHubAuthState,
|
||||
GitHubDeviceCode,
|
||||
GitHubToken,
|
||||
GitHubAuthError,
|
||||
GitHubUser,
|
||||
GitHubInstallation
|
||||
} from './GitHubTypes';
|
||||
|
||||
/**
|
||||
* Scopes required for GitHub integration
|
||||
* - repo: Full control of private repositories (for issues, PRs)
|
||||
* - read:org: Read organization membership
|
||||
* - read:user: Read user profile data
|
||||
* - user:email: Read user email addresses
|
||||
*/
|
||||
const REQUIRED_SCOPES = ['repo', 'read:org', 'read:user', 'user:email'];
|
||||
|
||||
/**
|
||||
* GitHubAuth
|
||||
*
|
||||
* Manages GitHub OAuth authentication using Device Flow.
|
||||
* Provides methods to authenticate, check status, and disconnect.
|
||||
*/
|
||||
export class GitHubAuth {
|
||||
/**
|
||||
* Initiate GitHub Web OAuth flow
|
||||
*
|
||||
* Opens browser to GitHub authorization page where user can select
|
||||
* which organizations and repositories to grant access to.
|
||||
*
|
||||
* @param onProgress - Callback for progress updates
|
||||
* @returns Promise that resolves when authentication completes
|
||||
*
|
||||
* @throws {GitHubAuthError} If OAuth flow fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await GitHubAuth.startWebOAuthFlow((message) => {
|
||||
* console.log(message);
|
||||
* });
|
||||
* console.log('Successfully authenticated!');
|
||||
* ```
|
||||
*/
|
||||
static async startWebOAuthFlow(onProgress?: (message: string) => void): Promise<void> {
|
||||
try {
|
||||
onProgress?.('Starting GitHub authentication...');
|
||||
|
||||
// Request OAuth flow from main process
|
||||
const result = await ipcRenderer.invoke('github-oauth-start');
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to start OAuth flow');
|
||||
}
|
||||
|
||||
onProgress?.('Opening GitHub in your browser...');
|
||||
|
||||
// Open browser to GitHub authorization page
|
||||
shell.openExternal(result.authUrl);
|
||||
|
||||
// Wait for OAuth callback from main process
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Authentication timed out after 5 minutes'));
|
||||
}, 300000); // 5 minutes
|
||||
|
||||
const handleSuccess = async (_event: Electron.IpcRendererEvent, data: any) => {
|
||||
console.log('🎉 [GitHub Auth] ========================================');
|
||||
console.log('🎉 [GitHub Auth] IPC EVENT RECEIVED: github-oauth-complete');
|
||||
console.log('🎉 [GitHub Auth] Data:', data);
|
||||
console.log('🎉 [GitHub Auth] ========================================');
|
||||
cleanup();
|
||||
|
||||
try {
|
||||
onProgress?.('Authentication successful, fetching details...');
|
||||
|
||||
// Save token and user info
|
||||
const token: GitHubToken = {
|
||||
access_token: data.token.access_token,
|
||||
token_type: data.token.token_type,
|
||||
scope: data.token.scope
|
||||
};
|
||||
|
||||
const installations = data.installations as GitHubInstallation[];
|
||||
|
||||
GitHubTokenStore.saveToken(token, data.user.login, data.user.email, installations);
|
||||
|
||||
onProgress?.(`Successfully authenticated as ${data.user.login}`);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (_event: Electron.IpcRendererEvent, data: any) => {
|
||||
cleanup();
|
||||
reject(new Error(data.message || 'Authentication failed'));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
ipcRenderer.removeListener('github-oauth-complete', handleSuccess);
|
||||
ipcRenderer.removeListener('github-oauth-error', handleError);
|
||||
};
|
||||
|
||||
ipcRenderer.once('github-oauth-complete', handleSuccess);
|
||||
ipcRenderer.once('github-oauth-error', handleError);
|
||||
});
|
||||
} catch (error) {
|
||||
const authError: GitHubAuthError = new Error(
|
||||
`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
authError.code = error instanceof Error && 'code' in error ? (error as { code?: string }).code : undefined;
|
||||
|
||||
console.error('[GitHub] Authentication error:', authError);
|
||||
throw authError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use startWebOAuthFlow instead. Device Flow kept for backward compatibility.
|
||||
*/
|
||||
static async startDeviceFlow(onProgress?: (message: string) => void): Promise<GitHubDeviceCode> {
|
||||
console.warn('[GitHub] startDeviceFlow is deprecated, using startWebOAuthFlow instead');
|
||||
await this.startWebOAuthFlow(onProgress);
|
||||
|
||||
// Return empty device code for backward compatibility
|
||||
return {
|
||||
device_code: '',
|
||||
user_code: '',
|
||||
verification_uri: '',
|
||||
expires_in: 0,
|
||||
interval: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user information from GitHub API
|
||||
*
|
||||
* @param token - Access token
|
||||
* @returns User information
|
||||
*
|
||||
* @throws {Error} If API request fails
|
||||
*/
|
||||
private static async fetchUserInfo(token: string): Promise<GitHubUser> {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authentication state
|
||||
*
|
||||
* @returns Current auth state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const state = GitHubAuth.getAuthState();
|
||||
* if (state.isAuthenticated) {
|
||||
* console.log('Connected as:', state.username);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static getAuthState(): GitHubAuthState {
|
||||
const storedAuth = GitHubTokenStore.getToken();
|
||||
|
||||
if (!storedAuth) {
|
||||
return {
|
||||
isAuthenticated: false
|
||||
};
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (GitHubTokenStore.isTokenExpired()) {
|
||||
console.warn('[GitHub] Token is expired');
|
||||
return {
|
||||
isAuthenticated: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
username: storedAuth.user.login,
|
||||
email: storedAuth.user.email || undefined,
|
||||
token: storedAuth.token,
|
||||
authenticatedAt: storedAuth.storedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently authenticated
|
||||
*
|
||||
* @returns True if authenticated and token is valid
|
||||
*/
|
||||
static isAuthenticated(): boolean {
|
||||
return this.getAuthState().isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the username of authenticated user
|
||||
*
|
||||
* @returns Username or null if not authenticated
|
||||
*/
|
||||
static getUsername(): string | null {
|
||||
return this.getAuthState().username || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token
|
||||
*
|
||||
* @returns Access token or null if not authenticated
|
||||
*/
|
||||
static getAccessToken(): string | null {
|
||||
const state = this.getAuthState();
|
||||
return state.token?.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from GitHub
|
||||
*
|
||||
* Clears stored authentication data. User will need to re-authenticate.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* GitHubAuth.disconnect();
|
||||
* console.log('Disconnected from GitHub');
|
||||
* ```
|
||||
*/
|
||||
static disconnect(): void {
|
||||
GitHubTokenStore.clearToken();
|
||||
console.log('[GitHub] User disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate current token by making a test API call
|
||||
*
|
||||
* @returns True if token is valid, false otherwise
|
||||
*/
|
||||
static async validateToken(): Promise<boolean> {
|
||||
const token = this.getAccessToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('[GitHub] Token validation failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh user information from GitHub
|
||||
*
|
||||
* Useful for updating cached user data
|
||||
*
|
||||
* @returns Updated auth state
|
||||
* @throws {Error} If not authenticated or refresh fails
|
||||
*/
|
||||
static async refreshUserInfo(): Promise<GitHubAuthState> {
|
||||
const token = this.getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const user = await this.fetchUserInfo(token);
|
||||
|
||||
// Update stored auth with new user info
|
||||
const storedAuth = GitHubTokenStore.getToken();
|
||||
if (storedAuth) {
|
||||
GitHubTokenStore.saveToken(storedAuth.token, user.login, user.email);
|
||||
}
|
||||
|
||||
return this.getAuthState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* GitHubClient
|
||||
*
|
||||
* Wrapper around Octokit REST API client with authentication and rate limiting.
|
||||
* Provides convenient methods for GitHub API operations needed by OpenNoodl.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
import { GitHubAuth } from './GitHubAuth';
|
||||
import type { GitHubRepository, GitHubRateLimit, GitHubUser } from './GitHubTypes';
|
||||
|
||||
/**
|
||||
* GitHubClient
|
||||
*
|
||||
* Main client for GitHub API interactions.
|
||||
* Automatically uses authenticated token from GitHubAuth.
|
||||
* Handles rate limiting and provides typed API methods.
|
||||
*/
|
||||
export class GitHubClient {
|
||||
private octokit: Octokit | null = null;
|
||||
private lastRateLimit: GitHubRateLimit | null = null;
|
||||
|
||||
/**
|
||||
* Initialize Octokit instance with current auth token
|
||||
*
|
||||
* @returns Octokit instance or null if not authenticated
|
||||
*/
|
||||
private getOctokit(): Octokit | null {
|
||||
const token = GitHubAuth.getAccessToken();
|
||||
if (!token) {
|
||||
console.warn('[GitHub Client] Not authenticated');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new instance if token changed or doesn't exist
|
||||
if (!this.octokit) {
|
||||
this.octokit = new Octokit({
|
||||
auth: token,
|
||||
userAgent: 'OpenNoodl/1.1.0'
|
||||
});
|
||||
}
|
||||
|
||||
return this.octokit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client is ready (authenticated)
|
||||
*
|
||||
* @returns True if client has valid auth token
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return GitHubAuth.isAuthenticated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
*
|
||||
* @returns Rate limit information
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async getRateLimit(): Promise<GitHubRateLimit> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
const response = await octokit.rateLimit.get();
|
||||
const core = response.data.resources.core;
|
||||
|
||||
const rateLimit: GitHubRateLimit = {
|
||||
limit: core.limit,
|
||||
remaining: core.remaining,
|
||||
reset: core.reset,
|
||||
resource: 'core'
|
||||
};
|
||||
|
||||
this.lastRateLimit = rateLimit;
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're approaching rate limit
|
||||
*
|
||||
* @returns True if remaining requests < 100
|
||||
*/
|
||||
isApproachingRateLimit(): boolean {
|
||||
if (!this.lastRateLimit) {
|
||||
return false;
|
||||
}
|
||||
return this.lastRateLimit.remaining < 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated user's information
|
||||
*
|
||||
* @returns User information
|
||||
* @throws {Error} If not authenticated or API call fails
|
||||
*/
|
||||
async getAuthenticatedUser(): Promise<GitHubUser> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
const response = await octokit.users.getAuthenticated();
|
||||
return response.data as GitHubUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository information
|
||||
*
|
||||
* @param owner - Repository owner
|
||||
* @param repo - Repository name
|
||||
* @returns Repository information
|
||||
* @throws {Error} If repository not found or API call fails
|
||||
*/
|
||||
async getRepository(owner: string, repo: string): Promise<GitHubRepository> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
const response = await octokit.repos.get({ owner, repo });
|
||||
return response.data as GitHubRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's repositories
|
||||
*
|
||||
* @param options - Listing options
|
||||
* @returns Array of repositories
|
||||
* @throws {Error} If not authenticated or API call fails
|
||||
*/
|
||||
async listRepositories(options?: {
|
||||
visibility?: 'all' | 'public' | 'private';
|
||||
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
|
||||
per_page?: number;
|
||||
}): Promise<GitHubRepository[]> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
const response = await octokit.repos.listForAuthenticatedUser({
|
||||
visibility: options?.visibility || 'all',
|
||||
sort: options?.sort || 'updated',
|
||||
per_page: options?.per_page || 30
|
||||
});
|
||||
|
||||
return response.data as GitHubRepository[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a repository exists and user has access
|
||||
*
|
||||
* @param owner - Repository owner
|
||||
* @param repo - Repository name
|
||||
* @returns True if repository exists and accessible
|
||||
*/
|
||||
async repositoryExists(owner: string, repo: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getRepository(owner, repo);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse repository URL to owner/repo
|
||||
*
|
||||
* Handles various GitHub URL formats:
|
||||
* - https://github.com/owner/repo
|
||||
* - git@github.com:owner/repo.git
|
||||
* - https://github.com/owner/repo.git
|
||||
*
|
||||
* @param url - GitHub repository URL
|
||||
* @returns Object with owner and repo, or null if invalid
|
||||
*/
|
||||
static parseRepoUrl(url: string): { owner: string; repo: string } | null {
|
||||
try {
|
||||
// Remove .git suffix if present
|
||||
const cleanUrl = url.replace(/\.git$/, '');
|
||||
|
||||
// Handle SSH format: git@github.com:owner/repo
|
||||
if (cleanUrl.includes('git@github.com:')) {
|
||||
const parts = cleanUrl.split('git@github.com:')[1].split('/');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HTTPS format: https://github.com/owner/repo
|
||||
if (cleanUrl.includes('github.com/')) {
|
||||
const parts = cleanUrl.split('github.com/')[1].split('/');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[GitHub Client] Error parsing repo URL:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository from local Git remote URL
|
||||
*
|
||||
* Useful for getting GitHub repo info from current project's git remote.
|
||||
*
|
||||
* @param remoteUrl - Git remote URL
|
||||
* @returns Repository information if GitHub repo, null otherwise
|
||||
*/
|
||||
async getRepositoryFromRemoteUrl(remoteUrl: string): Promise<GitHubRepository | null> {
|
||||
const parsed = GitHubClient.parseRepoUrl(remoteUrl);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.getRepository(parsed.owner, parsed.repo);
|
||||
} catch (error) {
|
||||
console.error('[GitHub Client] Error fetching repository:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset client state
|
||||
*
|
||||
* Call this when user disconnects or token changes.
|
||||
*/
|
||||
reset(): void {
|
||||
this.octokit = null;
|
||||
this.lastRateLimit = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of GitHubClient
|
||||
* Use this for all GitHub API operations
|
||||
*/
|
||||
export const githubClient = new GitHubClient();
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* GitHubTokenStore
|
||||
*
|
||||
* Secure storage for GitHub OAuth tokens using Electron Store.
|
||||
* Tokens are stored encrypted using Electron's safeStorage API.
|
||||
* This provides OS-level encryption (Keychain on macOS, Credential Manager on Windows).
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
import ElectronStore from 'electron-store';
|
||||
|
||||
import type { StoredGitHubAuth, GitHubToken, GitHubInstallation } from './GitHubTypes';
|
||||
|
||||
/**
|
||||
* Store key for GitHub authentication data
|
||||
*/
|
||||
const GITHUB_AUTH_KEY = 'github.auth';
|
||||
|
||||
/**
|
||||
* Electron store instance for GitHub credentials
|
||||
* Uses encryption for sensitive data
|
||||
*/
|
||||
const store = new ElectronStore<{
|
||||
'github.auth'?: StoredGitHubAuth;
|
||||
}>({
|
||||
name: 'github-credentials',
|
||||
// Encrypt the entire store for security
|
||||
encryptionKey: 'opennoodl-github-credentials'
|
||||
});
|
||||
|
||||
/**
|
||||
* GitHubTokenStore
|
||||
*
|
||||
* Manages secure storage and retrieval of GitHub OAuth tokens.
|
||||
* Provides methods to save, retrieve, and clear authentication data.
|
||||
*/
|
||||
export class GitHubTokenStore {
|
||||
/**
|
||||
* Save GitHub authentication data to secure storage
|
||||
*
|
||||
* @param token - OAuth access token
|
||||
* @param username - GitHub username
|
||||
* @param email - User's email (nullable)
|
||||
* @param installations - Optional list of installations (orgs/repos with access)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await GitHubTokenStore.saveToken(
|
||||
* { access_token: 'gho_...', token_type: 'bearer', scope: 'repo' },
|
||||
* 'octocat',
|
||||
* 'octocat@github.com',
|
||||
* installations
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
static saveToken(
|
||||
token: GitHubToken,
|
||||
username: string,
|
||||
email: string | null,
|
||||
installations?: GitHubInstallation[]
|
||||
): void {
|
||||
const authData: StoredGitHubAuth = {
|
||||
token,
|
||||
user: {
|
||||
login: username,
|
||||
email
|
||||
},
|
||||
installations,
|
||||
storedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
store.set(GITHUB_AUTH_KEY, authData);
|
||||
|
||||
if (installations && installations.length > 0) {
|
||||
const orgNames = installations.map((i) => i.account.login).join(', ');
|
||||
console.log(`[GitHub] Token saved for user: ${username} with access to: ${orgNames}`);
|
||||
} else {
|
||||
console.log('[GitHub] Token saved for user:', username);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installations (organizations/repos with access)
|
||||
*
|
||||
* @returns List of installations if authenticated, empty array otherwise
|
||||
*/
|
||||
static getInstallations(): GitHubInstallation[] {
|
||||
const authData = this.getToken();
|
||||
return authData?.installations || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve stored GitHub authentication data
|
||||
*
|
||||
* @returns Stored auth data if exists, null otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const authData = GitHubTokenStore.getToken();
|
||||
* if (authData) {
|
||||
* console.log('Authenticated as:', authData.user.login);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static getToken(): StoredGitHubAuth | null {
|
||||
try {
|
||||
const authData = store.get(GITHUB_AUTH_KEY);
|
||||
return authData || null;
|
||||
} catch (error) {
|
||||
console.error('[GitHub] Error reading token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a valid token exists
|
||||
*
|
||||
* @returns True if token exists, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (GitHubTokenStore.hasToken()) {
|
||||
* // User is authenticated
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static hasToken(): boolean {
|
||||
const authData = this.getToken();
|
||||
return authData !== null && !!authData.token.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the username of the authenticated user
|
||||
*
|
||||
* @returns Username if authenticated, null otherwise
|
||||
*/
|
||||
static getUsername(): string | null {
|
||||
const authData = this.getToken();
|
||||
return authData?.user.login || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token string
|
||||
*
|
||||
* @returns Access token if exists, null otherwise
|
||||
*/
|
||||
static getAccessToken(): string | null {
|
||||
const authData = this.getToken();
|
||||
return authData?.token.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored authentication data
|
||||
* Call this when user disconnects their GitHub account
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* GitHubTokenStore.clearToken();
|
||||
* console.log('User disconnected from GitHub');
|
||||
* ```
|
||||
*/
|
||||
static clearToken(): void {
|
||||
store.delete(GITHUB_AUTH_KEY);
|
||||
console.log('[GitHub] Token cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired (if expiration is set)
|
||||
*
|
||||
* @returns True if token is expired, false if valid or no expiration
|
||||
*/
|
||||
static isTokenExpired(): boolean {
|
||||
const authData = this.getToken();
|
||||
if (!authData || !authData.token.expires_at) {
|
||||
// No expiration set - assume valid
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(authData.token.expires_at);
|
||||
const now = new Date();
|
||||
|
||||
return now >= expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update token (for refresh scenarios)
|
||||
*
|
||||
* @param token - New OAuth token
|
||||
*/
|
||||
static updateToken(token: GitHubToken): void {
|
||||
const existing = this.getToken();
|
||||
if (!existing) {
|
||||
throw new Error('Cannot update token: No existing auth data found');
|
||||
}
|
||||
|
||||
const updated: StoredGitHubAuth = {
|
||||
...existing,
|
||||
token,
|
||||
storedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
store.set(GITHUB_AUTH_KEY, updated);
|
||||
console.log('[GitHub] Token updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored GitHub data (for debugging)
|
||||
* WARNING: Contains sensitive data - use carefully
|
||||
*
|
||||
* @returns All stored data
|
||||
*/
|
||||
static _debug_getAllData(): StoredGitHubAuth | null {
|
||||
return this.getToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* GitHubTypes
|
||||
*
|
||||
* TypeScript type definitions for GitHub OAuth and API integration.
|
||||
* These types define the structure of tokens, authentication state, and API responses.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuth device code response from GitHub
|
||||
* Returned when initiating device flow authorization
|
||||
*/
|
||||
export interface GitHubDeviceCode {
|
||||
/** The device verification code */
|
||||
device_code: string;
|
||||
/** The user verification code (8-character code) */
|
||||
user_code: string;
|
||||
/** URL where user enters the code */
|
||||
verification_uri: string;
|
||||
/** Expiration time in seconds (default: 900) */
|
||||
expires_in: number;
|
||||
/** Polling interval in seconds (default: 5) */
|
||||
interval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth access token
|
||||
* Stored securely and used for API authentication
|
||||
*/
|
||||
export interface GitHubToken {
|
||||
/** The OAuth access token */
|
||||
access_token: string;
|
||||
/** Token type (always 'bearer' for GitHub) */
|
||||
token_type: string;
|
||||
/** Granted scopes (comma-separated) */
|
||||
scope: string;
|
||||
/** Token expiration timestamp (ISO 8601) - undefined if no expiration */
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current GitHub authentication state
|
||||
* Used by React components to display connection status
|
||||
*/
|
||||
export interface GitHubAuthState {
|
||||
/** Whether user is authenticated with GitHub */
|
||||
isAuthenticated: boolean;
|
||||
/** GitHub username if authenticated */
|
||||
username?: string;
|
||||
/** User's primary email if authenticated */
|
||||
email?: string;
|
||||
/** Current token (for internal use only) */
|
||||
token?: GitHubToken;
|
||||
/** Timestamp of last successful authentication */
|
||||
authenticatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub user information
|
||||
* Retrieved from /user API endpoint
|
||||
*/
|
||||
export interface GitHubUser {
|
||||
/** GitHub username */
|
||||
login: string;
|
||||
/** GitHub user ID */
|
||||
id: number;
|
||||
/** User's display name */
|
||||
name: string | null;
|
||||
/** User's primary email */
|
||||
email: string | null;
|
||||
/** Avatar URL */
|
||||
avatar_url: string;
|
||||
/** Profile URL */
|
||||
html_url: string;
|
||||
/** User type (User or Organization) */
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub repository information
|
||||
* Basic repo details for issue/PR association
|
||||
*/
|
||||
export interface GitHubRepository {
|
||||
/** Repository ID */
|
||||
id: number;
|
||||
/** Repository name (without owner) */
|
||||
name: string;
|
||||
/** Full repository name (owner/repo) */
|
||||
full_name: string;
|
||||
/** Repository owner */
|
||||
owner: {
|
||||
login: string;
|
||||
id: number;
|
||||
avatar_url: string;
|
||||
};
|
||||
/** Whether repo is private */
|
||||
private: boolean;
|
||||
/** Repository URL */
|
||||
html_url: string;
|
||||
/** Default branch */
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub App installation information
|
||||
* Represents organizations/accounts where the app was installed
|
||||
*/
|
||||
export interface GitHubInstallation {
|
||||
/** Installation ID */
|
||||
id: number;
|
||||
/** Account where app is installed */
|
||||
account: {
|
||||
login: string;
|
||||
type: 'User' | 'Organization';
|
||||
avatar_url: string;
|
||||
};
|
||||
/** Repository selection type */
|
||||
repository_selection: 'all' | 'selected';
|
||||
/** List of repositories (if selected) */
|
||||
repositories?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit information from GitHub API
|
||||
* Used to prevent hitting API limits
|
||||
*/
|
||||
export interface GitHubRateLimit {
|
||||
/** Maximum requests allowed per hour */
|
||||
limit: number;
|
||||
/** Remaining requests in current window */
|
||||
remaining: number;
|
||||
/** Timestamp when rate limit resets (Unix epoch) */
|
||||
reset: number;
|
||||
/** Resource type (core, search, graphql) */
|
||||
resource: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from GitHub API
|
||||
*/
|
||||
export interface GitHubError {
|
||||
/** HTTP status code */
|
||||
status: number;
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Detailed documentation URL if available */
|
||||
documentation_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth authorization error
|
||||
* Thrown during device flow authorization
|
||||
*/
|
||||
export interface GitHubAuthError extends Error {
|
||||
/** Error code from GitHub */
|
||||
code?: string;
|
||||
/** HTTP status if applicable */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored token data (persisted format)
|
||||
* Encrypted and stored in Electron's secure storage
|
||||
*/
|
||||
export interface StoredGitHubAuth {
|
||||
/** OAuth token */
|
||||
token: GitHubToken;
|
||||
/** Associated user info */
|
||||
user: {
|
||||
login: string;
|
||||
email: string | null;
|
||||
};
|
||||
/** Installation information (organizations/repos with access) */
|
||||
installations?: GitHubInstallation[];
|
||||
/** Timestamp when stored */
|
||||
storedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* GitHub Services
|
||||
*
|
||||
* Public exports for GitHub OAuth authentication and API integration.
|
||||
* This module provides everything needed to connect to GitHub,
|
||||
* authenticate users, and interact with the GitHub API.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { GitHubAuth, githubClient } from '@noodl-services/github';
|
||||
*
|
||||
* // Check if authenticated
|
||||
* if (GitHubAuth.isAuthenticated()) {
|
||||
* // Fetch user repos
|
||||
* const repos = await githubClient.listRepositories();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Authentication
|
||||
export { GitHubAuth } from './GitHubAuth';
|
||||
export { GitHubTokenStore } from './GitHubTokenStore';
|
||||
|
||||
// API Client
|
||||
export { GitHubClient, githubClient } from './GitHubClient';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
GitHubDeviceCode,
|
||||
GitHubToken,
|
||||
GitHubAuthState,
|
||||
GitHubUser,
|
||||
GitHubRepository,
|
||||
GitHubRateLimit,
|
||||
GitHubError,
|
||||
GitHubAuthError,
|
||||
StoredGitHubAuth
|
||||
} from './GitHubTypes';
|
||||
@@ -232,8 +232,8 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--popup-layer-tooltip-border-color: var(--theme-color-secondary);
|
||||
--popup-layer-tooltip-background-color: var(--theme-color-secondary);
|
||||
--popup-layer-tooltip-border-color: var(--theme-color-border-default);
|
||||
--popup-layer-tooltip-background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
.popup-layer-tooltip {
|
||||
@@ -244,7 +244,7 @@
|
||||
border-color: var(--popup-layer-tooltip-border-color);
|
||||
border-width: 1px;
|
||||
padding: 12px 16px;
|
||||
color: var(--theme-color-fg-highlight);
|
||||
color: var(--theme-color-fg-default);
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.3s;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="nodegrapgeditor-bg nodegrapheditor-canvas" style="width: 100%; height: 100%">
|
||||
<!-- Canvas Tabs Root (for React component) -->
|
||||
<div id="canvas-tabs-root" style="position: absolute; width: 100%; height: 100%; z-index: 100; pointer-events: none;"></div>
|
||||
|
||||
<!--
|
||||
wrap in a div to not trigger chromium bug where comments "scrolls" all the siblings
|
||||
if comments are below the bottom of the parent
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Blockly Editor Globals
|
||||
*
|
||||
* Exposes Blockly-related utilities to the global scope for use by runtime nodes
|
||||
*/
|
||||
|
||||
import { generateCode } from '../views/BlocklyEditor/NoodlGenerators';
|
||||
import { detectIO } from './IODetector';
|
||||
|
||||
// Extend window interface
|
||||
declare global {
|
||||
interface Window {
|
||||
NoodlEditor?: {
|
||||
detectIO?: typeof detectIO;
|
||||
generateBlocklyCode?: typeof generateCode;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Blockly editor globals
|
||||
* This makes IODetector and code generation available to runtime nodes
|
||||
*/
|
||||
export function initBlocklyEditorGlobals() {
|
||||
console.log('🔍 [BlocklyGlobals] initBlocklyEditorGlobals called');
|
||||
console.log('🔍 [BlocklyGlobals] window undefined?', typeof window === 'undefined');
|
||||
|
||||
// Create NoodlEditor namespace if it doesn't exist
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('🔍 [BlocklyGlobals] window.NoodlEditor before:', window.NoodlEditor);
|
||||
|
||||
if (!window.NoodlEditor) {
|
||||
window.NoodlEditor = {};
|
||||
console.log('🔍 [BlocklyGlobals] Created new window.NoodlEditor');
|
||||
}
|
||||
|
||||
// Expose IODetector
|
||||
window.NoodlEditor.detectIO = detectIO;
|
||||
console.log('🔍 [BlocklyGlobals] Assigned detectIO:', typeof window.NoodlEditor.detectIO);
|
||||
|
||||
// Expose code generator
|
||||
window.NoodlEditor.generateBlocklyCode = generateCode;
|
||||
console.log('🔍 [BlocklyGlobals] Assigned generateBlocklyCode:', typeof window.NoodlEditor.generateBlocklyCode);
|
||||
|
||||
console.log('✅ [Blockly] Editor globals initialized');
|
||||
console.log('🔍 [BlocklyGlobals] window.NoodlEditor after:', window.NoodlEditor);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize when module loads
|
||||
initBlocklyEditorGlobals();
|
||||
189
packages/noodl-editor/src/editor/src/utils/IODetector.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* IODetector
|
||||
*
|
||||
* Utility for detecting inputs, outputs, and signals from Blockly workspaces.
|
||||
* Scans workspace JSON to find Input/Output definition blocks and extracts their configuration.
|
||||
*
|
||||
* @module utils
|
||||
*/
|
||||
|
||||
export interface DetectedInput {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DetectedOutput {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DetectedIO {
|
||||
inputs: DetectedInput[];
|
||||
outputs: DetectedOutput[];
|
||||
signalInputs: string[];
|
||||
signalOutputs: string[];
|
||||
}
|
||||
|
||||
interface BlocklyBlock {
|
||||
type?: string;
|
||||
fields?: Record<string, string>;
|
||||
inputs?: Record<string, { block?: BlocklyBlock }>;
|
||||
next?: { block?: BlocklyBlock };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all I/O from a Blockly workspace JSON
|
||||
*
|
||||
* @param workspaceJson - Serialized Blockly workspace (JSON string or object)
|
||||
* @returns Detected inputs, outputs, and signals
|
||||
*/
|
||||
export function detectIO(workspaceJson: string | object): DetectedIO {
|
||||
const result: DetectedIO = {
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
signalInputs: [],
|
||||
signalOutputs: []
|
||||
};
|
||||
|
||||
try {
|
||||
const workspace = typeof workspaceJson === 'string' ? JSON.parse(workspaceJson) : workspaceJson;
|
||||
|
||||
if (!workspace || !workspace.blocks || !workspace.blocks.blocks) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const blocks = workspace.blocks.blocks;
|
||||
|
||||
for (const block of blocks) {
|
||||
processBlock(block, result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[IODetector] Failed to parse workspace:', error);
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
result.inputs = uniqueBy(result.inputs, 'name');
|
||||
result.outputs = uniqueBy(result.outputs, 'name');
|
||||
result.signalInputs = Array.from(new Set(result.signalInputs));
|
||||
result.signalOutputs = Array.from(new Set(result.signalOutputs));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively process a block and its children
|
||||
*/
|
||||
function processBlock(block: BlocklyBlock, result: DetectedIO): void {
|
||||
if (!block || !block.type) return;
|
||||
|
||||
switch (block.type) {
|
||||
case 'noodl_define_input':
|
||||
// Extract input definition
|
||||
if (block.fields && block.fields.NAME && block.fields.TYPE) {
|
||||
result.inputs.push({
|
||||
name: block.fields.NAME,
|
||||
type: block.fields.TYPE
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noodl_get_input':
|
||||
// Auto-detect input from usage
|
||||
if (block.fields && block.fields.NAME) {
|
||||
const name = block.fields.NAME;
|
||||
if (!result.inputs.find((i) => i.name === name)) {
|
||||
result.inputs.push({
|
||||
name: name,
|
||||
type: '*' // Default type
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noodl_define_output':
|
||||
// Extract output definition
|
||||
if (block.fields && block.fields.NAME && block.fields.TYPE) {
|
||||
result.outputs.push({
|
||||
name: block.fields.NAME,
|
||||
type: block.fields.TYPE
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noodl_set_output':
|
||||
// Auto-detect output from usage
|
||||
if (block.fields && block.fields.NAME) {
|
||||
const name = block.fields.NAME;
|
||||
if (!result.outputs.find((o) => o.name === name)) {
|
||||
result.outputs.push({
|
||||
name: name,
|
||||
type: '*' // Default type
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noodl_define_signal_input':
|
||||
// Extract signal input definition
|
||||
if (block.fields && block.fields.NAME) {
|
||||
result.signalInputs.push(block.fields.NAME);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noodl_on_signal':
|
||||
// Auto-detect signal input from event handler
|
||||
if (block.fields && block.fields.SIGNAL) {
|
||||
const name = block.fields.SIGNAL;
|
||||
if (!result.signalInputs.includes(name)) {
|
||||
result.signalInputs.push(name);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noodl_define_signal_output':
|
||||
// Extract signal output definition
|
||||
if (block.fields && block.fields.NAME) {
|
||||
result.signalOutputs.push(block.fields.NAME);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'noodl_send_signal':
|
||||
// Auto-detect signal output from send blocks
|
||||
if (block.fields && block.fields.NAME) {
|
||||
const name = block.fields.NAME;
|
||||
if (!result.signalOutputs.includes(name)) {
|
||||
result.signalOutputs.push(name);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Process nested blocks (inputs, next, etc.)
|
||||
if (block.inputs) {
|
||||
for (const inputKey in block.inputs) {
|
||||
const input = block.inputs[inputKey];
|
||||
if (input && input.block) {
|
||||
processBlock(input.block, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (block.next && block.next.block) {
|
||||
processBlock(block.next.block, result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicates from array based on key
|
||||
*/
|
||||
function uniqueBy<T>(array: T[], key: keyof T): T[] {
|
||||
const seen = new Set();
|
||||
return array.filter((item) => {
|
||||
const k = item[key];
|
||||
if (seen.has(k)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import Model from '../../../shared/model';
|
||||
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
|
||||
import { RuntimeVersionInfo } from '../models/migration/types';
|
||||
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
|
||||
import { GitHubAuth } from '../services/github';
|
||||
import FileSystem from './filesystem';
|
||||
import { tracker } from './tracker';
|
||||
import { guid } from './utils';
|
||||
@@ -119,6 +120,10 @@ export class LocalProjectsModel extends Model {
|
||||
project.name = projectEntry.name; // Also assign the name
|
||||
this.touchProject(projectEntry);
|
||||
this.bindProject(project);
|
||||
|
||||
// Initialize Git authentication for this project
|
||||
this.setCurrentGlobalGitAuth(projectEntry.id);
|
||||
|
||||
resolve(project);
|
||||
});
|
||||
});
|
||||
@@ -329,13 +334,34 @@ export class LocalProjectsModel extends Model {
|
||||
setCurrentGlobalGitAuth(projectId: string) {
|
||||
const func = async (endpoint: string) => {
|
||||
if (endpoint.includes('github.com')) {
|
||||
// Priority 1: Check for global OAuth token
|
||||
const authState = GitHubAuth.getAuthState();
|
||||
if (authState.isAuthenticated && authState.token) {
|
||||
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint);
|
||||
return {
|
||||
username: authState.username || 'oauth',
|
||||
password: authState.token.access_token // Extract actual access token string
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Fall back to project-specific PAT
|
||||
const config = await GitStore.get('github', projectId);
|
||||
//username is not used by github when using a token, but git will still ask for it. Just set it to "noodl"
|
||||
if (config?.password) {
|
||||
console.log('[Git Auth] Using project PAT for:', endpoint);
|
||||
return {
|
||||
username: 'noodl',
|
||||
password: config.password
|
||||
};
|
||||
}
|
||||
|
||||
// No credentials available
|
||||
console.warn('[Git Auth] No GitHub credentials found for:', endpoint);
|
||||
return {
|
||||
username: 'noodl',
|
||||
password: config?.password
|
||||
password: ''
|
||||
};
|
||||
} else {
|
||||
// Non-GitHub providers use project-specific credentials only
|
||||
const config = await GitStore.get('unknown', projectId);
|
||||
return {
|
||||
username: config?.username,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* BlocklyWorkspace Styles
|
||||
*
|
||||
* Styling for the Blockly visual programming workspace.
|
||||
* Uses theme tokens for consistent integration with Noodl editor.
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.BlocklyContainer {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
/* Ensure Blockly SVG fills container */
|
||||
& > .injectionDiv {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Override Blockly default styles to match Noodl theme */
|
||||
:global {
|
||||
/* Toolbox styling */
|
||||
.blocklyToolboxDiv {
|
||||
background-color: var(--theme-color-bg-2) !important;
|
||||
border-right: 1px solid var(--theme-color-border-default) !important;
|
||||
}
|
||||
|
||||
.blocklyTreeLabel {
|
||||
color: var(--theme-color-fg-default) !important;
|
||||
font-family: var(--theme-font-family) !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.blocklyTreeRow:hover {
|
||||
background-color: var(--theme-color-bg-3) !important;
|
||||
}
|
||||
|
||||
.blocklyTreeSelected {
|
||||
background-color: var(--theme-color-primary) !important;
|
||||
}
|
||||
|
||||
/* Flyout styling */
|
||||
.blocklyFlyoutBackground {
|
||||
fill: var(--theme-color-bg-2) !important;
|
||||
fill-opacity: 0.95 !important;
|
||||
}
|
||||
|
||||
/* Block styling - keep default Blockly colors for now */
|
||||
/* May customize later to match Noodl node colors */
|
||||
|
||||
/* Zoom controls */
|
||||
.blocklyZoom {
|
||||
& image {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Trashcan */
|
||||
.blocklyTrash {
|
||||
& image {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Context menu */
|
||||
.blocklyContextMenu {
|
||||
background-color: var(--theme-color-bg-3) !important;
|
||||
border: 1px solid var(--theme-color-border-default) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.blocklyContextMenu .blocklyMenuItem {
|
||||
color: var(--theme-color-fg-default) !important;
|
||||
font-family: var(--theme-font-family) !important;
|
||||
font-size: 13px !important;
|
||||
padding: 6px 12px !important;
|
||||
|
||||
&:hover,
|
||||
&:hover * {
|
||||
background-color: var(--theme-color-bg-4) !important;
|
||||
color: var(--theme-color-fg-default) !important;
|
||||
}
|
||||
|
||||
&.blocklyMenuItemDisabled {
|
||||
color: var(--theme-color-fg-default-shy) !important;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
.blocklyScrollbarHandle {
|
||||
fill: var(--theme-color-border-default) !important;
|
||||
}
|
||||
|
||||
/* Field editor backgrounds (dropdowns, text inputs, etc.) */
|
||||
/* NOTE: blocklyWidgetDiv and blocklyDropDownDiv are rendered at document root! */
|
||||
.blocklyWidgetDiv,
|
||||
.blocklyDropDownDiv {
|
||||
z-index: 10000 !important; /* Ensure it's above everything */
|
||||
}
|
||||
|
||||
/* Blockly dropdown container - DARK BACKGROUND */
|
||||
.blocklyDropDownDiv,
|
||||
:global(.blocklyDropDownDiv) {
|
||||
background-color: var(--theme-color-bg-3) !important; /* DARK background */
|
||||
max-height: 400px !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
|
||||
/* Inner scrollable container */
|
||||
& > div {
|
||||
background-color: var(--theme-color-bg-3) !important;
|
||||
max-height: 400px !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* SVG containers inside dropdown */
|
||||
& svg {
|
||||
background-color: var(--theme-color-bg-3) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text input fields */
|
||||
.blocklyWidgetDiv input,
|
||||
.blocklyHtmlInput {
|
||||
background-color: var(--theme-color-bg-3) !important;
|
||||
color: var(--theme-color-fg-default) !important;
|
||||
border: 1px solid var(--theme-color-border-default) !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-family: var(--theme-font-family) !important;
|
||||
}
|
||||
|
||||
/* Dropdown menus - DARK BACKGROUND with WHITE TEXT (matches Noodl theme) */
|
||||
/* Target ACTUAL Blockly classes: .blocklyMenuItem not .goog-menuitem */
|
||||
|
||||
.goog-menu,
|
||||
:global(.goog-menu) {
|
||||
background-color: var(--theme-color-bg-3) !important; /* DARK background */
|
||||
border: 1px solid var(--theme-color-border-default) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||
max-height: 400px !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
z-index: 10001 !important;
|
||||
}
|
||||
|
||||
/* Target Blockly's ACTUAL menu item class - DROPDOWN MENUS */
|
||||
.blocklyDropDownDiv .blocklyMenuItem,
|
||||
:global(.blocklyDropDownDiv) :global(.blocklyMenuItem) {
|
||||
color: #ffffff !important; /* WHITE text */
|
||||
background-color: transparent !important;
|
||||
padding: 6px 12px !important;
|
||||
cursor: pointer !important;
|
||||
font-family: var(--theme-font-family) !important;
|
||||
font-size: 13px !important;
|
||||
|
||||
/* ALL children white */
|
||||
& *,
|
||||
& div,
|
||||
& span {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* HOVER - Keep white text with lighter background */
|
||||
&:hover,
|
||||
&:hover *,
|
||||
&:hover div,
|
||||
&:hover span {
|
||||
background-color: var(--theme-color-bg-4) !important;
|
||||
color: #ffffff !important; /* WHITE on hover */
|
||||
}
|
||||
|
||||
&[aria-selected='true'],
|
||||
&[aria-selected='true'] * {
|
||||
background-color: var(--theme-color-primary) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'],
|
||||
&[aria-disabled='true'] * {
|
||||
color: #999999 !important;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Target Blockly's ACTUAL content class */
|
||||
.blocklyMenuItemContent,
|
||||
:global(.blocklyMenuItemContent) {
|
||||
color: #ffffff !important;
|
||||
|
||||
& *,
|
||||
& div,
|
||||
& span {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for goog- classes if they exist */
|
||||
.goog-menuitem,
|
||||
.goog-option,
|
||||
:global(.goog-menuitem),
|
||||
:global(.goog-option) {
|
||||
color: #ffffff !important;
|
||||
background-color: transparent !important;
|
||||
padding: 6px 12px !important;
|
||||
font-family: var(--theme-font-family) !important;
|
||||
font-size: 13px !important;
|
||||
|
||||
& *,
|
||||
& div,
|
||||
& span {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:hover * {
|
||||
background-color: var(--theme-color-bg-4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.goog-menuitem-content,
|
||||
:global(.goog-menuitem-content) {
|
||||
color: #ffffff !important;
|
||||
|
||||
& * {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Blockly dropdown content container */
|
||||
:global(.blocklyDropDownContent) {
|
||||
max-height: 400px !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* BlocklyWorkspace Component
|
||||
*
|
||||
* React wrapper for Google Blockly visual programming workspace.
|
||||
* Provides integration with Noodl's node system for visual logic building.
|
||||
*
|
||||
* @module BlocklyEditor
|
||||
*/
|
||||
|
||||
import DarkTheme from '@blockly/theme-dark';
|
||||
import * as Blockly from 'blockly';
|
||||
import { javascriptGenerator } from 'blockly/javascript';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import css from './BlocklyWorkspace.module.scss';
|
||||
import { initBlocklyIntegration } from './index';
|
||||
|
||||
export interface BlocklyWorkspaceProps {
|
||||
/** Initial workspace JSON (for loading saved state) */
|
||||
initialWorkspace?: string;
|
||||
/** Toolbox configuration */
|
||||
toolbox?: Blockly.utils.toolbox.ToolboxDefinition;
|
||||
/** Callback when workspace changes */
|
||||
onChange?: (workspace: Blockly.WorkspaceSvg, json: string, code: string) => void;
|
||||
/** Read-only mode */
|
||||
readOnly?: boolean;
|
||||
/** Custom theme */
|
||||
theme?: Blockly.Theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* BlocklyWorkspace - React component for Blockly integration
|
||||
*
|
||||
* Handles:
|
||||
* - Blockly workspace initialization
|
||||
* - Workspace persistence (save/load)
|
||||
* - Change detection and callbacks
|
||||
* - Cleanup on unmount
|
||||
*/
|
||||
export function BlocklyWorkspace({
|
||||
initialWorkspace,
|
||||
toolbox,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
theme
|
||||
}: BlocklyWorkspaceProps) {
|
||||
const blocklyDiv = useRef<HTMLDivElement>(null);
|
||||
const workspaceRef = useRef<Blockly.WorkspaceSvg | null>(null);
|
||||
const changeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Initialize Blockly workspace
|
||||
useEffect(() => {
|
||||
if (!blocklyDiv.current) return;
|
||||
|
||||
// Initialize custom Noodl blocks and generators before creating workspace
|
||||
initBlocklyIntegration();
|
||||
|
||||
console.log('🔧 [Blockly] Initializing workspace');
|
||||
|
||||
// Inject Blockly with dark theme
|
||||
const workspace = Blockly.inject(blocklyDiv.current, {
|
||||
toolbox: toolbox || getDefaultToolbox(),
|
||||
theme: theme || DarkTheme,
|
||||
readOnly: readOnly,
|
||||
trashcan: true,
|
||||
zoom: {
|
||||
controls: true,
|
||||
wheel: true,
|
||||
startScale: 1.0,
|
||||
maxScale: 3,
|
||||
minScale: 0.3,
|
||||
scaleSpeed: 1.2
|
||||
},
|
||||
grid: {
|
||||
spacing: 20,
|
||||
length: 3,
|
||||
colour: '#ccc',
|
||||
snap: true
|
||||
}
|
||||
});
|
||||
|
||||
workspaceRef.current = workspace;
|
||||
|
||||
// Load initial workspace if provided
|
||||
if (initialWorkspace) {
|
||||
try {
|
||||
const json = JSON.parse(initialWorkspace);
|
||||
Blockly.serialization.workspaces.load(json, workspace);
|
||||
console.log('✅ [Blockly] Loaded initial workspace');
|
||||
} catch (error) {
|
||||
console.error('❌ [Blockly] Failed to load initial workspace:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes - filter to only respond to finished workspace changes,
|
||||
// not UI events like dragging or moving blocks
|
||||
const changeListener = (event: Blockly.Events.Abstract) => {
|
||||
if (!onChange || !workspace) return;
|
||||
|
||||
// Ignore UI events that don't change the workspace structure
|
||||
// These fire constantly during drags and can cause state corruption
|
||||
if (event.type === Blockly.Events.BLOCK_DRAG) return;
|
||||
if (event.type === Blockly.Events.BLOCK_MOVE && !event.isUiEvent) return; // Allow programmatic moves
|
||||
if (event.type === Blockly.Events.SELECTED) return;
|
||||
if (event.type === Blockly.Events.CLICK) return;
|
||||
if (event.type === Blockly.Events.VIEWPORT_CHANGE) return;
|
||||
if (event.type === Blockly.Events.TOOLBOX_ITEM_SELECT) return;
|
||||
if (event.type === Blockly.Events.THEME_CHANGE) return;
|
||||
if (event.type === Blockly.Events.TRASHCAN_OPEN) return;
|
||||
|
||||
// For UI events that DO change the workspace, debounce them
|
||||
const isUiEvent = event.isUiEvent;
|
||||
|
||||
if (isUiEvent) {
|
||||
// Clear any pending timeout for UI events
|
||||
if (changeTimeoutRef.current) {
|
||||
clearTimeout(changeTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce UI-initiated changes (user editing)
|
||||
changeTimeoutRef.current = setTimeout(() => {
|
||||
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
|
||||
const code = javascriptGenerator.workspaceToCode(workspace);
|
||||
console.log('[Blockly] Generated code:', code);
|
||||
onChange(workspace, json, code);
|
||||
}, 300);
|
||||
} else {
|
||||
// Programmatic changes fire immediately (e.g., undo/redo, loading)
|
||||
const json = JSON.stringify(Blockly.serialization.workspaces.save(workspace));
|
||||
const code = javascriptGenerator.workspaceToCode(workspace);
|
||||
console.log('[Blockly] Generated code:', code);
|
||||
onChange(workspace, json, code);
|
||||
}
|
||||
};
|
||||
|
||||
workspace.addChangeListener(changeListener);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
console.log('🧹 [Blockly] Disposing workspace');
|
||||
|
||||
// Clear any pending debounced calls
|
||||
if (changeTimeoutRef.current) {
|
||||
clearTimeout(changeTimeoutRef.current);
|
||||
}
|
||||
|
||||
workspace.removeChangeListener(changeListener);
|
||||
workspace.dispose();
|
||||
workspaceRef.current = null;
|
||||
};
|
||||
}, [toolbox, theme, readOnly]);
|
||||
|
||||
// NOTE: Do NOT reload workspace on initialWorkspace changes!
|
||||
// The initialWorkspace prop changes on every save, which would cause corruption.
|
||||
// Workspace is loaded ONCE on mount above, and changes are saved via onChange callback.
|
||||
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<div ref={blocklyDiv} className={css.BlocklyContainer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default toolbox with Noodl-specific blocks
|
||||
*/
|
||||
function getDefaultToolbox(): Blockly.utils.toolbox.ToolboxDefinition {
|
||||
return {
|
||||
kind: 'categoryToolbox',
|
||||
contents: [
|
||||
// Noodl I/O Category
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Noodl Inputs/Outputs',
|
||||
colour: '230',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'noodl_define_input' },
|
||||
{ kind: 'block', type: 'noodl_get_input' },
|
||||
{ kind: 'block', type: 'noodl_define_output' },
|
||||
{ kind: 'block', type: 'noodl_set_output' }
|
||||
]
|
||||
},
|
||||
// Noodl Signals Category
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Noodl Signals',
|
||||
colour: '180',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'noodl_define_signal_input' },
|
||||
{ kind: 'block', type: 'noodl_define_signal_output' },
|
||||
{ kind: 'block', type: 'noodl_send_signal' }
|
||||
]
|
||||
},
|
||||
// Noodl Variables Category
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Noodl Variables',
|
||||
colour: '330',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'noodl_get_variable' },
|
||||
{ kind: 'block', type: 'noodl_set_variable' }
|
||||
]
|
||||
},
|
||||
// Noodl Objects Category
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Noodl Objects',
|
||||
colour: '20',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'noodl_get_object' },
|
||||
{ kind: 'block', type: 'noodl_get_object_property' },
|
||||
{ kind: 'block', type: 'noodl_set_object_property' }
|
||||
]
|
||||
},
|
||||
// Noodl Arrays Category
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Noodl Arrays',
|
||||
colour: '260',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'noodl_get_array' },
|
||||
{ kind: 'block', type: 'noodl_array_length' },
|
||||
{ kind: 'block', type: 'noodl_array_add' }
|
||||
]
|
||||
},
|
||||
// Standard Logic blocks (useful for conditionals)
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Logic',
|
||||
colour: '210',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'controls_if' },
|
||||
{ kind: 'block', type: 'logic_compare' },
|
||||
{ kind: 'block', type: 'logic_operation' },
|
||||
{ kind: 'block', type: 'logic_negate' },
|
||||
{ kind: 'block', type: 'logic_boolean' }
|
||||
]
|
||||
},
|
||||
// Standard Math blocks
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Math',
|
||||
colour: '230',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'math_number' },
|
||||
{ kind: 'block', type: 'math_arithmetic' },
|
||||
{ kind: 'block', type: 'math_single' }
|
||||
]
|
||||
},
|
||||
// Standard Text blocks
|
||||
{
|
||||
kind: 'category',
|
||||
name: 'Text',
|
||||
colour: '160',
|
||||
contents: [
|
||||
{ kind: 'block', type: 'text' },
|
||||
{ kind: 'block', type: 'text_join' },
|
||||
{ kind: 'block', type: 'text_length' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Noodl Custom Blocks for Blockly
|
||||
*
|
||||
* Defines custom blocks for Noodl-specific functionality:
|
||||
* - Inputs/Outputs (node I/O)
|
||||
* - Variables (Noodl.Variables)
|
||||
* - Objects (Noodl.Objects)
|
||||
* - Arrays (Noodl.Arrays)
|
||||
* - Events/Signals
|
||||
*
|
||||
* @module BlocklyEditor
|
||||
*/
|
||||
|
||||
import * as Blockly from 'blockly';
|
||||
|
||||
/**
|
||||
* Initialize all Noodl custom blocks
|
||||
*/
|
||||
export function initNoodlBlocks() {
|
||||
console.log('🔧 [Blockly] Initializing Noodl custom blocks');
|
||||
|
||||
// Input/Output blocks
|
||||
defineInputOutputBlocks();
|
||||
|
||||
// Variable blocks
|
||||
defineVariableBlocks();
|
||||
|
||||
// Object blocks (basic - will expand later)
|
||||
defineObjectBlocks();
|
||||
|
||||
// Array blocks (basic - will expand later)
|
||||
defineArrayBlocks();
|
||||
|
||||
console.log('✅ [Blockly] Noodl blocks initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Input/Output Blocks
|
||||
*/
|
||||
function defineInputOutputBlocks() {
|
||||
// Define Input block - declares an input port
|
||||
Blockly.Blocks['noodl_define_input'] = {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('📥 Define input')
|
||||
.appendField(new Blockly.FieldTextInput('myInput'), 'NAME')
|
||||
.appendField('type')
|
||||
.appendField(
|
||||
new Blockly.FieldDropdown([
|
||||
['any', '*'],
|
||||
['string', 'string'],
|
||||
['number', 'number'],
|
||||
['boolean', 'boolean'],
|
||||
['object', 'object'],
|
||||
['array', 'array']
|
||||
]),
|
||||
'TYPE'
|
||||
);
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(230);
|
||||
this.setTooltip('Defines an input port that appears on the node');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Get Input block - gets value from an input
|
||||
Blockly.Blocks['noodl_get_input'] = {
|
||||
init: function () {
|
||||
this.appendDummyInput().appendField('📥 get input').appendField(new Blockly.FieldTextInput('value'), 'NAME');
|
||||
this.setOutput(true, null);
|
||||
this.setColour(230);
|
||||
this.setTooltip('Gets the value from an input port');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Define Output block - declares an output port
|
||||
Blockly.Blocks['noodl_define_output'] = {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('📤 Define output')
|
||||
.appendField(new Blockly.FieldTextInput('result'), 'NAME')
|
||||
.appendField('type')
|
||||
.appendField(
|
||||
new Blockly.FieldDropdown([
|
||||
['any', '*'],
|
||||
['string', 'string'],
|
||||
['number', 'number'],
|
||||
['boolean', 'boolean'],
|
||||
['object', 'object'],
|
||||
['array', 'array']
|
||||
]),
|
||||
'TYPE'
|
||||
);
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(230);
|
||||
this.setTooltip('Defines an output port that appears on the node');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Set Output block - sets value on an output
|
||||
Blockly.Blocks['noodl_set_output'] = {
|
||||
init: function () {
|
||||
this.appendValueInput('VALUE')
|
||||
.setCheck(null)
|
||||
.appendField('📤 set output')
|
||||
.appendField(new Blockly.FieldTextInput('result'), 'NAME')
|
||||
.appendField('to');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(230);
|
||||
this.setTooltip('Sets the value of an output port');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Define Signal Input block
|
||||
Blockly.Blocks['noodl_define_signal_input'] = {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('⚡ Define signal input')
|
||||
.appendField(new Blockly.FieldTextInput('trigger'), 'NAME');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(180);
|
||||
this.setTooltip('Defines a signal input that can trigger logic');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Define Signal Output block
|
||||
Blockly.Blocks['noodl_define_signal_output'] = {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('⚡ Define signal output')
|
||||
.appendField(new Blockly.FieldTextInput('done'), 'NAME');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(180);
|
||||
this.setTooltip('Defines a signal output that can trigger other nodes');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Send Signal block
|
||||
Blockly.Blocks['noodl_send_signal'] = {
|
||||
init: function () {
|
||||
this.appendDummyInput().appendField('⚡ send signal').appendField(new Blockly.FieldTextInput('done'), 'NAME');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(180);
|
||||
this.setTooltip('Sends a signal to connected nodes');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable Blocks
|
||||
*/
|
||||
function defineVariableBlocks() {
|
||||
// Get Variable block
|
||||
Blockly.Blocks['noodl_get_variable'] = {
|
||||
init: function () {
|
||||
this.appendDummyInput()
|
||||
.appendField('📖 get variable')
|
||||
.appendField(new Blockly.FieldTextInput('myVariable'), 'NAME');
|
||||
this.setOutput(true, null);
|
||||
this.setColour(330);
|
||||
this.setTooltip('Gets the value of a global Noodl variable');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Set Variable block
|
||||
Blockly.Blocks['noodl_set_variable'] = {
|
||||
init: function () {
|
||||
this.appendValueInput('VALUE')
|
||||
.setCheck(null)
|
||||
.appendField('✏️ set variable')
|
||||
.appendField(new Blockly.FieldTextInput('myVariable'), 'NAME')
|
||||
.appendField('to');
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(330);
|
||||
this.setTooltip('Sets the value of a global Noodl variable');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Object Blocks (basic set - will expand in Phase E)
|
||||
*/
|
||||
function defineObjectBlocks() {
|
||||
// Get Object block
|
||||
Blockly.Blocks['noodl_get_object'] = {
|
||||
init: function () {
|
||||
this.appendValueInput('ID').setCheck('String').appendField('📦 get object');
|
||||
this.setOutput(true, 'Object');
|
||||
this.setColour(20);
|
||||
this.setTooltip('Gets a Noodl Object by its ID');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Get Object Property block
|
||||
Blockly.Blocks['noodl_get_object_property'] = {
|
||||
init: function () {
|
||||
this.appendValueInput('OBJECT')
|
||||
.setCheck(null)
|
||||
.appendField('📖 get')
|
||||
.appendField(new Blockly.FieldTextInput('name'), 'PROPERTY')
|
||||
.appendField('from object');
|
||||
this.setOutput(true, null);
|
||||
this.setColour(20);
|
||||
this.setTooltip('Gets a property value from an object');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Set Object Property block
|
||||
Blockly.Blocks['noodl_set_object_property'] = {
|
||||
init: function () {
|
||||
this.appendValueInput('OBJECT')
|
||||
.setCheck(null)
|
||||
.appendField('✏️ set')
|
||||
.appendField(new Blockly.FieldTextInput('name'), 'PROPERTY')
|
||||
.appendField('on object');
|
||||
this.appendValueInput('VALUE').setCheck(null).appendField('to');
|
||||
this.setInputsInline(false);
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(20);
|
||||
this.setTooltip('Sets a property value on an object');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Array Blocks (basic set - will expand in Phase E)
|
||||
*/
|
||||
function defineArrayBlocks() {
|
||||
// Get Array block
|
||||
Blockly.Blocks['noodl_get_array'] = {
|
||||
init: function () {
|
||||
this.appendDummyInput().appendField('📋 get array').appendField(new Blockly.FieldTextInput('myArray'), 'NAME');
|
||||
this.setOutput(true, 'Array');
|
||||
this.setColour(260);
|
||||
this.setTooltip('Gets a Noodl Array by name');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Array Length block
|
||||
Blockly.Blocks['noodl_array_length'] = {
|
||||
init: function () {
|
||||
this.appendValueInput('ARRAY').setCheck('Array').appendField('🔢 length of array');
|
||||
this.setOutput(true, 'Number');
|
||||
this.setColour(260);
|
||||
this.setTooltip('Gets the number of items in an array');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
// Array Add block
|
||||
Blockly.Blocks['noodl_array_add'] = {
|
||||
init: function () {
|
||||
this.appendValueInput('ITEM').setCheck(null).appendField('➕ add');
|
||||
this.appendValueInput('ARRAY').setCheck('Array').appendField('to array');
|
||||
this.setInputsInline(true);
|
||||
this.setPreviousStatement(true, null);
|
||||
this.setNextStatement(true, null);
|
||||
this.setColour(260);
|
||||
this.setTooltip('Adds an item to the end of an array');
|
||||
this.setHelpUrl('');
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Noodl Code Generators for Blockly
|
||||
*
|
||||
* Converts Blockly blocks into executable JavaScript code for the Noodl runtime.
|
||||
* Generated code has access to:
|
||||
* - Inputs: Input values from connections
|
||||
* - Outputs: Output values to connections
|
||||
* - Noodl.Variables: Global variables
|
||||
* - Noodl.Objects: Global objects
|
||||
* - Noodl.Arrays: Global arrays
|
||||
*
|
||||
* @module BlocklyEditor
|
||||
*/
|
||||
|
||||
import * as Blockly from 'blockly';
|
||||
import { javascriptGenerator, Order } from 'blockly/javascript';
|
||||
|
||||
/**
|
||||
* Initialize all Noodl code generators
|
||||
*/
|
||||
export function initNoodlGenerators() {
|
||||
console.log('🔧 [Blockly] Initializing Noodl code generators');
|
||||
|
||||
// Input/Output generators
|
||||
initInputOutputGenerators();
|
||||
|
||||
// Variable generators
|
||||
initVariableGenerators();
|
||||
|
||||
// Object generators
|
||||
initObjectGenerators();
|
||||
|
||||
// Array generators
|
||||
initArrayGenerators();
|
||||
|
||||
console.log('✅ [Blockly] Noodl generators initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Input/Output Generators
|
||||
*/
|
||||
function initInputOutputGenerators() {
|
||||
// Define Input - no runtime code (used for I/O detection only)
|
||||
javascriptGenerator.forBlock['noodl_define_input'] = function () {
|
||||
return '';
|
||||
};
|
||||
|
||||
// Get Input - generates: Inputs["name"]
|
||||
javascriptGenerator.forBlock['noodl_get_input'] = function (block) {
|
||||
const name = block.getFieldValue('NAME');
|
||||
const code = `Inputs["${name}"]`;
|
||||
return [code, Order.MEMBER];
|
||||
};
|
||||
|
||||
// Define Output - no runtime code (used for I/O detection only)
|
||||
javascriptGenerator.forBlock['noodl_define_output'] = function () {
|
||||
return '';
|
||||
};
|
||||
|
||||
// Set Output - generates: Outputs["name"] = value;
|
||||
javascriptGenerator.forBlock['noodl_set_output'] = function (block) {
|
||||
const name = block.getFieldValue('NAME');
|
||||
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
|
||||
return `Outputs["${name}"] = ${value};\n`;
|
||||
};
|
||||
|
||||
// Define Signal Input - no runtime code
|
||||
javascriptGenerator.forBlock['noodl_define_signal_input'] = function () {
|
||||
return '';
|
||||
};
|
||||
|
||||
// Define Signal Output - no runtime code
|
||||
javascriptGenerator.forBlock['noodl_define_signal_output'] = function () {
|
||||
return '';
|
||||
};
|
||||
|
||||
// Send Signal - generates: this.sendSignalOnOutput("name");
|
||||
javascriptGenerator.forBlock['noodl_send_signal'] = function (block) {
|
||||
const name = block.getFieldValue('NAME');
|
||||
return `this.sendSignalOnOutput("${name}");\n`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable Generators
|
||||
*/
|
||||
function initVariableGenerators() {
|
||||
// Get Variable - generates: Noodl.Variables["name"]
|
||||
javascriptGenerator.forBlock['noodl_get_variable'] = function (block) {
|
||||
const name = block.getFieldValue('NAME');
|
||||
const code = `Noodl.Variables["${name}"]`;
|
||||
return [code, Order.MEMBER];
|
||||
};
|
||||
|
||||
// Set Variable - generates: Noodl.Variables["name"] = value;
|
||||
javascriptGenerator.forBlock['noodl_set_variable'] = function (block) {
|
||||
const name = block.getFieldValue('NAME');
|
||||
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
|
||||
return `Noodl.Variables["${name}"] = ${value};\n`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Object Generators
|
||||
*/
|
||||
function initObjectGenerators() {
|
||||
// Get Object - generates: Noodl.Objects[id]
|
||||
javascriptGenerator.forBlock['noodl_get_object'] = function (block) {
|
||||
const id = javascriptGenerator.valueToCode(block, 'ID', Order.NONE) || '""';
|
||||
const code = `Noodl.Objects[${id}]`;
|
||||
return [code, Order.MEMBER];
|
||||
};
|
||||
|
||||
// Get Object Property - generates: object["property"]
|
||||
javascriptGenerator.forBlock['noodl_get_object_property'] = function (block) {
|
||||
const property = block.getFieldValue('PROPERTY');
|
||||
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Order.MEMBER) || '{}';
|
||||
const code = `${object}["${property}"]`;
|
||||
return [code, Order.MEMBER];
|
||||
};
|
||||
|
||||
// Set Object Property - generates: object["property"] = value;
|
||||
javascriptGenerator.forBlock['noodl_set_object_property'] = function (block) {
|
||||
const property = block.getFieldValue('PROPERTY');
|
||||
const object = javascriptGenerator.valueToCode(block, 'OBJECT', Order.MEMBER) || '{}';
|
||||
const value = javascriptGenerator.valueToCode(block, 'VALUE', Order.ASSIGNMENT) || 'null';
|
||||
return `${object}["${property}"] = ${value};\n`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Array Generators
|
||||
*/
|
||||
function initArrayGenerators() {
|
||||
// Get Array - generates: Noodl.Arrays["name"]
|
||||
javascriptGenerator.forBlock['noodl_get_array'] = function (block) {
|
||||
const name = block.getFieldValue('NAME');
|
||||
const code = `Noodl.Arrays["${name}"]`;
|
||||
return [code, Order.MEMBER];
|
||||
};
|
||||
|
||||
// Array Length - generates: array.length
|
||||
javascriptGenerator.forBlock['noodl_array_length'] = function (block) {
|
||||
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Order.MEMBER) || '[]';
|
||||
const code = `${array}.length`;
|
||||
return [code, Order.MEMBER];
|
||||
};
|
||||
|
||||
// Array Add - generates: array.push(item);
|
||||
javascriptGenerator.forBlock['noodl_array_add'] = function (block) {
|
||||
const item = javascriptGenerator.valueToCode(block, 'ITEM', Order.NONE) || 'null';
|
||||
const array = javascriptGenerator.valueToCode(block, 'ARRAY', Order.MEMBER) || '[]';
|
||||
return `${array}.push(${item});\n`;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete JavaScript code from workspace
|
||||
*
|
||||
* @param workspace - The Blockly workspace
|
||||
* @returns Generated JavaScript code
|
||||
*/
|
||||
export function generateCode(workspace: Blockly.WorkspaceSvg): string {
|
||||
return javascriptGenerator.workspaceToCode(workspace);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* BlocklyEditor Module
|
||||
*
|
||||
* Entry point for Blockly integration in Noodl.
|
||||
* Exports components, blocks, and generators for visual logic building.
|
||||
*
|
||||
* @module BlocklyEditor
|
||||
*/
|
||||
|
||||
import { initNoodlBlocks } from './NoodlBlocks';
|
||||
import { initNoodlGenerators } from './NoodlGenerators';
|
||||
// Initialize globals (IODetector, code generation)
|
||||
import '../../utils/BlocklyEditorGlobals';
|
||||
|
||||
// Main component
|
||||
export { BlocklyWorkspace } from './BlocklyWorkspace';
|
||||
export type { BlocklyWorkspaceProps } from './BlocklyWorkspace';
|
||||
|
||||
// Block definitions and generators
|
||||
export { initNoodlBlocks } from './NoodlBlocks';
|
||||
export { initNoodlGenerators, generateCode } from './NoodlGenerators';
|
||||
|
||||
// Track initialization to prevent double-registration
|
||||
let blocklyInitialized = false;
|
||||
|
||||
/**
|
||||
* Initialize all Noodl Blockly extensions
|
||||
* Call this once at app startup before using Blockly components
|
||||
* Safe to call multiple times - will only initialize once
|
||||
*/
|
||||
export function initBlocklyIntegration() {
|
||||
if (blocklyInitialized) {
|
||||
console.log('⏭️ [Blockly] Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔧 [Blockly] Initializing Noodl Blockly integration');
|
||||
|
||||
// Initialize custom blocks
|
||||
initNoodlBlocks();
|
||||
|
||||
// Initialize code generators
|
||||
initNoodlGenerators();
|
||||
|
||||
// Note: BlocklyEditorGlobals auto-initializes via side-effect import above
|
||||
|
||||
blocklyInitialized = true;
|
||||
console.log('✅ [Blockly] Integration initialized');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Canvas Tabs Styling
|
||||
*
|
||||
* Theme-aware styling for canvas/Blockly tab system
|
||||
*/
|
||||
|
||||
.CanvasTabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
pointer-events: all; /* Enable clicks on tabs */
|
||||
}
|
||||
|
||||
.TabBar {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-2);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.Tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
border-radius: var(--radius-default) var(--radius-default) 0 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
}
|
||||
|
||||
&.isActive {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
color: var(--theme-color-fg-highlight);
|
||||
border-color: var(--theme-color-border-default);
|
||||
border-bottom-color: var(--theme-color-bg-1); // Hide bottom border
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--theme-color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.TabLabel {
|
||||
font-size: var(--theme-font-size-default);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.TabCloseButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-s);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-5);
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
}
|
||||
}
|
||||
|
||||
.TabContent {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
.CanvasContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.BlocklyContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useCanvasTabs } from '../../contexts/CanvasTabsContext';
|
||||
import { BlocklyWorkspace } from '../BlocklyEditor';
|
||||
import css from './CanvasTabs.module.scss';
|
||||
|
||||
export interface CanvasTabsProps {
|
||||
/** Callback when workspace changes */
|
||||
onWorkspaceChange?: (nodeId: string, workspace: string, code: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas Tabs Component
|
||||
*
|
||||
* Manages tabs for Logic Builder (Blockly) editors.
|
||||
* The canvas itself is NOT managed here - it's always visible in the background
|
||||
* unless a Logic Builder tab is open.
|
||||
*/
|
||||
export function CanvasTabs({ onWorkspaceChange }: CanvasTabsProps) {
|
||||
const { tabs, activeTabId, switchTab, closeTab, updateTab } = useCanvasTabs();
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
|
||||
/**
|
||||
* Handle workspace changes from Blockly editor
|
||||
*/
|
||||
const handleWorkspaceChange = (_workspaceSvg: unknown, json: string, code: string) => {
|
||||
if (!activeTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tab's workspace with JSON
|
||||
updateTab(activeTab.id, { workspace: json });
|
||||
|
||||
// Notify parent (pass both workspace JSON and generated code)
|
||||
if (onWorkspaceChange && activeTab.nodeId) {
|
||||
onWorkspaceChange(activeTab.nodeId, json, code);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle tab click
|
||||
*/
|
||||
const handleTabClick = (tabId: string) => {
|
||||
switchTab(tabId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle tab close
|
||||
*/
|
||||
const handleTabClose = (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation(); // Don't trigger tab switch
|
||||
closeTab(tabId);
|
||||
};
|
||||
|
||||
// Don't render anything if no tabs are open
|
||||
if (tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css['CanvasTabs']}>
|
||||
{/* Tab Bar */}
|
||||
<div className={css['TabBar']}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`${css['Tab']} ${isActive ? css['isActive'] : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className={css['TabLabel']}>Logic Builder: {tab.nodeName || 'Unnamed'}</span>
|
||||
|
||||
<button
|
||||
className={css['TabCloseButton']}
|
||||
onClick={(e) => handleTabClose(e, tab.id)}
|
||||
aria-label="Close tab"
|
||||
title="Close tab"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className={css['TabContent']}>
|
||||
{activeTab && (
|
||||
<div className={css['BlocklyContainer']}>
|
||||
<BlocklyWorkspace initialWorkspace={activeTab.workspace || undefined} onChange={handleWorkspaceChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* CanvasTabs Module
|
||||
*
|
||||
* Exports canvas tab system components
|
||||
*/
|
||||
|
||||
export { CanvasTabs } from './CanvasTabs';
|
||||
export type { CanvasTabsProps } from './CanvasTabs';
|
||||
@@ -24,6 +24,7 @@ import { PopupToolbar, PopupToolbarProps } from '@noodl-core-ui/components/popup
|
||||
|
||||
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
|
||||
import View from '../../../shared/view';
|
||||
import { CanvasTabsProvider } from '../contexts/CanvasTabsContext';
|
||||
import { ComponentModel } from '../models/componentmodel';
|
||||
import {
|
||||
Connection,
|
||||
@@ -36,10 +37,13 @@ import { NodeLibrary } from '../models/nodelibrary';
|
||||
import { ProjectModel } from '../models/projectmodel';
|
||||
import { WarningsModel } from '../models/warningsmodel';
|
||||
import { HighlightManager } from '../services/HighlightManager';
|
||||
// Initialize Blockly globals early (must run before runtime nodes load)
|
||||
import { initBlocklyEditorGlobals } from '../utils/BlocklyEditorGlobals';
|
||||
import DebugInspector from '../utils/debuginspector';
|
||||
import { rectanglesOverlap, guid } from '../utils/utils';
|
||||
import { ViewerConnection } from '../ViewerConnection';
|
||||
import { HighlightOverlay } from './CanvasOverlays/HighlightOverlay';
|
||||
import { CanvasTabs } from './CanvasTabs';
|
||||
import CommentLayer from './commentlayer';
|
||||
// Import test utilities for console debugging (dev only)
|
||||
import '../services/HighlightManager/test-highlights';
|
||||
@@ -57,6 +61,8 @@ import PopupLayer from './popuplayer';
|
||||
import { showContextMenuInPopup } from './ShowContextMenuInPopup';
|
||||
import { ToastLayer } from './ToastLayer/ToastLayer';
|
||||
|
||||
initBlocklyEditorGlobals();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const NodeGraphEditorTemplate = require('../templates/nodegrapheditor.html');
|
||||
|
||||
@@ -234,6 +240,7 @@ export class NodeGraphEditor extends View {
|
||||
toolbarRoots: Root[] = [];
|
||||
titleRoot: Root = null;
|
||||
highlightOverlayRoot: Root = null;
|
||||
canvasTabsRoot: Root = null;
|
||||
|
||||
constructor(args) {
|
||||
super();
|
||||
@@ -297,6 +304,36 @@ export class NodeGraphEditor extends View {
|
||||
this
|
||||
);
|
||||
|
||||
// Listen for Logic Builder tab opened - hide canvas
|
||||
EventDispatcher.instance.on(
|
||||
'LogicBuilder.TabOpened',
|
||||
() => {
|
||||
console.log('[NodeGraphEditor] Logic Builder tab opened - hiding canvas');
|
||||
this.setCanvasVisibility(false);
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
// Listen for all Logic Builder tabs closed - show canvas
|
||||
EventDispatcher.instance.on(
|
||||
'LogicBuilder.AllTabsClosed',
|
||||
() => {
|
||||
console.log('[NodeGraphEditor] All Logic Builder tabs closed - showing canvas');
|
||||
this.setCanvasVisibility(true);
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
// Listen for Logic Builder tab open requests (for opening tabs from property panel)
|
||||
EventDispatcher.instance.on(
|
||||
'LogicBuilder.OpenTab',
|
||||
(args: { nodeId: string; nodeName: string; workspace: string }) => {
|
||||
console.log('[NodeGraphEditor] Opening Logic Builder tab for node:', args.nodeId);
|
||||
// The CanvasTabs context will handle the actual tab opening
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
if (import.meta.webpackHot) {
|
||||
import.meta.webpackHot.accept('./createnewnodepanel');
|
||||
}
|
||||
@@ -411,6 +448,11 @@ export class NodeGraphEditor extends View {
|
||||
this.highlightOverlayRoot = null;
|
||||
}
|
||||
|
||||
if (this.canvasTabsRoot) {
|
||||
this.canvasTabsRoot.unmount();
|
||||
this.canvasTabsRoot = null;
|
||||
}
|
||||
|
||||
SidebarModel.instance.off(this);
|
||||
|
||||
this.reset();
|
||||
@@ -881,12 +923,66 @@ export class NodeGraphEditor extends View {
|
||||
this.renderHighlightOverlay();
|
||||
}, 1);
|
||||
|
||||
// Render the canvas tabs
|
||||
setTimeout(() => {
|
||||
this.renderCanvasTabs();
|
||||
}, 1);
|
||||
|
||||
this.relayout();
|
||||
this.repaint();
|
||||
|
||||
return this.el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the CanvasTabs React component
|
||||
*/
|
||||
renderCanvasTabs() {
|
||||
const tabsElement = this.el.find('#canvas-tabs-root').get(0);
|
||||
if (!tabsElement) {
|
||||
console.warn('Canvas tabs root not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create React root if it doesn't exist
|
||||
if (!this.canvasTabsRoot) {
|
||||
this.canvasTabsRoot = createRoot(tabsElement);
|
||||
}
|
||||
|
||||
// Render the tabs with provider
|
||||
this.canvasTabsRoot.render(
|
||||
React.createElement(
|
||||
CanvasTabsProvider,
|
||||
null,
|
||||
React.createElement(CanvasTabs, {
|
||||
onWorkspaceChange: this.handleBlocklyWorkspaceChange.bind(this)
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workspace changes from Blockly editor
|
||||
*/
|
||||
handleBlocklyWorkspaceChange(nodeId: string, workspace: string, code: string) {
|
||||
console.log(`[NodeGraphEditor] Workspace changed for node ${nodeId}`);
|
||||
|
||||
const node = this.findNodeWithId(nodeId);
|
||||
if (!node) {
|
||||
console.warn(`[NodeGraphEditor] Node ${nodeId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save workspace JSON to node model
|
||||
node.model.setParameter('workspace', workspace);
|
||||
|
||||
// Save generated JavaScript code to node model
|
||||
// This triggers the runtime's parameterUpdated listener which calls updatePorts()
|
||||
node.model.setParameter('generatedCode', code);
|
||||
|
||||
console.log(`[NodeGraphEditor] Saved workspace and generated code for node ${nodeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node bounds for the highlight overlay
|
||||
* Maps node IDs to their screen coordinates
|
||||
@@ -945,6 +1041,35 @@ export class NodeGraphEditor extends View {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set canvas visibility (hide when Logic Builder is open, show when closed)
|
||||
*/
|
||||
setCanvasVisibility(visible: boolean) {
|
||||
const canvasElement = this.el.find('#nodegraphcanvas');
|
||||
const commentLayerBg = this.el.find('#comment-layer-bg');
|
||||
const commentLayerFg = this.el.find('#comment-layer-fg');
|
||||
const highlightOverlay = this.el.find('#highlight-overlay-layer');
|
||||
const componentTrail = this.el.find('.nodegraph-component-trail-root');
|
||||
|
||||
if (visible) {
|
||||
// Show canvas and related elements
|
||||
canvasElement.css('display', 'block');
|
||||
commentLayerBg.css('display', 'block');
|
||||
commentLayerFg.css('display', 'block');
|
||||
highlightOverlay.css('display', 'block');
|
||||
componentTrail.css('display', 'flex');
|
||||
this.domElementContainer.style.display = '';
|
||||
} else {
|
||||
// Hide canvas and related elements
|
||||
canvasElement.css('display', 'none');
|
||||
commentLayerBg.css('display', 'none');
|
||||
commentLayerFg.css('display', 'none');
|
||||
highlightOverlay.css('display', 'none');
|
||||
componentTrail.css('display', 'none');
|
||||
this.domElementContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// This is called by the parent view (frames view) when the size and position
|
||||
// changes
|
||||
resize(layout) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GitProvider } from '@noodl/git';
|
||||
|
||||
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { TextButton } from '@noodl-core-ui/components/inputs/TextButton';
|
||||
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
|
||||
import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import { GitHubAuth, type GitHubAuthState } from '../../../../../../services/github';
|
||||
|
||||
type CredentialsSectionProps = {
|
||||
provider: GitProvider;
|
||||
username: string;
|
||||
@@ -25,39 +29,120 @@ export function CredentialsSection({
|
||||
|
||||
const [hidePassword, setHidePassword] = useState(true);
|
||||
|
||||
// OAuth state management
|
||||
const [authState, setAuthState] = useState<GitHubAuthState>(GitHubAuth.getAuthState());
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check auth state on mount
|
||||
useEffect(() => {
|
||||
if (provider === 'github') {
|
||||
setAuthState(GitHubAuth.getAuthState());
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
setProgressMessage('Initiating GitHub authentication...');
|
||||
|
||||
try {
|
||||
await GitHubAuth.startWebOAuthFlow((message) => {
|
||||
setProgressMessage(message);
|
||||
});
|
||||
|
||||
// Update state after successful auth
|
||||
setAuthState(GitHubAuth.getAuthState());
|
||||
setProgressMessage('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed');
|
||||
setProgressMessage('');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
GitHubAuth.disconnect();
|
||||
setAuthState(GitHubAuth.getAuthState());
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title={getTitle(provider)} variant={SectionVariant.InModal} hasGutter>
|
||||
{showUsername && (
|
||||
<>
|
||||
{/* OAuth Section - GitHub Only */}
|
||||
{provider === 'github' && (
|
||||
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
|
||||
{authState.isAuthenticated ? (
|
||||
// Connected state
|
||||
<>
|
||||
<Text hasBottomSpacing>
|
||||
✓ Connected as <strong>{authState.username}</strong>
|
||||
</Text>
|
||||
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
|
||||
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />
|
||||
</>
|
||||
) : (
|
||||
// Not connected state
|
||||
<>
|
||||
<Text hasBottomSpacing>
|
||||
Connect your GitHub account for the best experience. This enables advanced features and is more secure
|
||||
than Personal Access Tokens.
|
||||
</Text>
|
||||
|
||||
{isConnecting && progressMessage && <Text hasBottomSpacing>{progressMessage}</Text>}
|
||||
|
||||
{error && <Text hasBottomSpacing>{error}</Text>}
|
||||
|
||||
<PrimaryButton
|
||||
label={isConnecting ? 'Connecting...' : 'Connect GitHub Account'}
|
||||
onClick={handleConnect}
|
||||
isDisabled={isConnecting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* PAT Section - Existing, now as fallback for GitHub */}
|
||||
<Section
|
||||
title={provider === 'github' ? 'Or use Personal Access Token' : getTitle(provider)}
|
||||
variant={SectionVariant.InModal}
|
||||
hasGutter
|
||||
>
|
||||
{showUsername && (
|
||||
<TextInput
|
||||
hasBottomSpacing
|
||||
label="Username"
|
||||
value={username}
|
||||
variant={TextInputVariant.InModal}
|
||||
onChange={(ev) => onUserNameChanged(ev.target.value)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
hasBottomSpacing
|
||||
label="Username"
|
||||
value={username}
|
||||
label={passwordLabel}
|
||||
type={hidePassword ? 'password' : 'text'}
|
||||
value={password}
|
||||
variant={TextInputVariant.InModal}
|
||||
onChange={(ev) => onUserNameChanged(ev.target.value)}
|
||||
onChange={(ev) => onPasswordChanged(ev.target.value)}
|
||||
onFocus={() => setHidePassword(false)}
|
||||
onBlur={() => setHidePassword(true)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
hasBottomSpacing
|
||||
label={passwordLabel}
|
||||
type={hidePassword ? 'password' : 'text'}
|
||||
value={password}
|
||||
variant={TextInputVariant.InModal}
|
||||
onChange={(ev) => onPasswordChanged(ev.target.value)}
|
||||
onFocus={() => setHidePassword(false)}
|
||||
onBlur={() => setHidePassword(true)}
|
||||
/>
|
||||
|
||||
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
|
||||
{provider === 'github' && !password?.length && (
|
||||
<a
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How to create a personal access token
|
||||
</a>
|
||||
)}
|
||||
</Section>
|
||||
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
|
||||
{provider === 'github' && !password?.length && (
|
||||
<a
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How to create a personal access token
|
||||
</a>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { EventDispatcher } from '../../../../../../shared/utils/EventDispatcher';
|
||||
import { TypeView } from '../TypeView';
|
||||
import { getEditType } from '../utils';
|
||||
|
||||
/**
|
||||
* Custom editor for Logic Builder workspace parameter
|
||||
* Shows an "Edit Blocks" button that opens the Blockly editor in a tab
|
||||
*/
|
||||
export class LogicBuilderWorkspaceType extends TypeView {
|
||||
el: TSFixme;
|
||||
editButton: JQuery;
|
||||
|
||||
static fromPort(args) {
|
||||
const view = new LogicBuilderWorkspaceType();
|
||||
|
||||
const p = args.port;
|
||||
const parent = args.parent;
|
||||
|
||||
view.port = p;
|
||||
view.displayName = p.displayName ? p.displayName : p.name;
|
||||
view.name = p.name;
|
||||
view.type = getEditType(p);
|
||||
view.group = p.group;
|
||||
view.tooltip = p.tooltip;
|
||||
view.value = parent.model.getParameter(p.name);
|
||||
view.parent = parent;
|
||||
view.isConnected = parent.model.isPortConnected(p.name, 'target');
|
||||
view.isDefault = parent.model.parameters[p.name] === undefined;
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
render() {
|
||||
// Create a simple container with a button
|
||||
const html = `
|
||||
<div class="property-basic-container logic-builder-workspace-editor" style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div class="property-label-container" style="display: flex; align-items: center; gap: 8px;">
|
||||
<div class="property-changed-dot" data-click="resetToDefault" style="display: none;"></div>
|
||||
<div class="property-label">${this.displayName}</div>
|
||||
</div>
|
||||
<button class="edit-blocks-button"
|
||||
style="
|
||||
padding: 8px 16px;
|
||||
background: var(--theme-color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
"
|
||||
onmouseover="this.style.backgroundColor='var(--theme-color-primary-hover)'"
|
||||
onmouseout="this.style.backgroundColor='var(--theme-color-primary)'">
|
||||
✨ Edit Logic Blocks
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.el = this.bindView($(html), this);
|
||||
|
||||
// Get reference to button
|
||||
this.editButton = this.el.find('.edit-blocks-button');
|
||||
|
||||
// Handle button click
|
||||
this.editButton.on('click', () => {
|
||||
this.onEditBlocksClicked();
|
||||
});
|
||||
|
||||
// Call parent render for common functionality (tooltips, etc.)
|
||||
TypeView.prototype.render.call(this);
|
||||
|
||||
// Show/hide the "changed" dot based on whether value is default
|
||||
this.updateChangedDot();
|
||||
|
||||
return this.el;
|
||||
}
|
||||
|
||||
onEditBlocksClicked() {
|
||||
// ModelProxy wraps the actual node model in a .model property
|
||||
const nodeId = this.parent?.model?.model?.id;
|
||||
const nodeName = this.parent?.model?.model?.label || this.parent?.model?.type?.displayName || 'Logic Builder';
|
||||
const workspace = this.parent?.model?.getParameter('workspace') || '';
|
||||
|
||||
console.log('[LogicBuilderWorkspaceType] Opening Logic Builder tab for node:', nodeId);
|
||||
|
||||
// Emit event to open Logic Builder tab
|
||||
EventDispatcher.instance.emit('LogicBuilder.OpenTab', {
|
||||
nodeId,
|
||||
nodeName,
|
||||
workspace
|
||||
});
|
||||
}
|
||||
|
||||
updateChangedDot() {
|
||||
const dot = this.el.find('.property-changed-dot');
|
||||
if (this.isDefault) {
|
||||
dot.hide();
|
||||
} else {
|
||||
dot.show();
|
||||
}
|
||||
}
|
||||
|
||||
resetToDefault() {
|
||||
// Reset workspace to empty
|
||||
this.parent.model.setParameter(this.name, undefined, {
|
||||
undo: true,
|
||||
label: 'reset workspace'
|
||||
});
|
||||
this.isDefault = true;
|
||||
this.updateChangedDot();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { FontType } from './FontType';
|
||||
import { IconType } from './IconType';
|
||||
import { IdentifierType } from './IdentifierType';
|
||||
import { ImageType } from './ImageType';
|
||||
import { LogicBuilderWorkspaceType } from './LogicBuilderWorkspaceType';
|
||||
import { MarginPaddingType } from './MarginPaddingType';
|
||||
import { NumberWithUnits } from './NumberWithUnits';
|
||||
import { PopoutGroup } from './PopoutGroup';
|
||||
@@ -220,6 +221,11 @@ export class Ports extends View {
|
||||
viewClassForPort(p) {
|
||||
const type = getEditType(p);
|
||||
|
||||
// Check for custom editorType
|
||||
if (typeof type === 'object' && type.editorType === 'logic-builder-workspace') {
|
||||
return LogicBuilderWorkspaceType;
|
||||
}
|
||||
|
||||
// Align tools types
|
||||
function isOfAlignToolsType() {
|
||||
return NodeLibrary.nameForPortType(type) === 'enum' && typeof type === 'object' && type.alignComp !== undefined;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
339
packages/noodl-editor/src/main/github-oauth-handler.js
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* GitHubOAuthCallbackHandler
|
||||
*
|
||||
* Handles GitHub OAuth callback in Electron main process using custom protocol handler.
|
||||
* This enables Web OAuth Flow with organization/repository selection UI.
|
||||
*
|
||||
* @module noodl-editor/main
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { ipcMain, BrowserWindow } = require('electron');
|
||||
|
||||
/**
|
||||
* GitHub OAuth credentials
|
||||
* Uses existing credentials from GitHubOAuthService
|
||||
*/
|
||||
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui';
|
||||
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375';
|
||||
|
||||
/**
|
||||
* Custom protocol for OAuth callback
|
||||
*/
|
||||
const OAUTH_PROTOCOL = 'noodl';
|
||||
const OAUTH_CALLBACK_PATH = 'github-callback';
|
||||
|
||||
/**
|
||||
* Manages GitHub OAuth using custom protocol handler
|
||||
*/
|
||||
class GitHubOAuthCallbackHandler {
|
||||
constructor() {
|
||||
this.pendingAuth = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle protocol callback from GitHub OAuth
|
||||
* Called when user is redirected to noodl://github-callback?code=XXX&state=YYY
|
||||
*/
|
||||
async handleProtocolCallback(url) {
|
||||
console.log('🔐 [GitHub OAuth] ========================================');
|
||||
console.log('🔐 [GitHub OAuth] PROTOCOL CALLBACK RECEIVED');
|
||||
console.log('🔐 [GitHub OAuth] URL:', url);
|
||||
console.log('🔐 [GitHub OAuth] ========================================');
|
||||
|
||||
try {
|
||||
// Parse the URL
|
||||
const parsedUrl = new URL(url);
|
||||
const params = parsedUrl.searchParams;
|
||||
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const error = params.get('error');
|
||||
const error_description = params.get('error_description');
|
||||
|
||||
// Handle OAuth error
|
||||
if (error) {
|
||||
console.error('[GitHub OAuth] Error from GitHub:', error, error_description);
|
||||
this.sendErrorToRenderer(error, error_description);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code || !state) {
|
||||
console.error('[GitHub OAuth] Missing code or state in callback');
|
||||
this.sendErrorToRenderer('invalid_request', 'Missing authorization code or state');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate state (CSRF protection)
|
||||
if (!this.validateState(state)) {
|
||||
throw new Error('Invalid OAuth state - possible CSRF attack or expired');
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
const token = await this.exchangeCodeForToken(code);
|
||||
|
||||
// Fetch user info
|
||||
const user = await this.fetchUserInfo(token.access_token);
|
||||
|
||||
// Fetch installation info (organizations/repos)
|
||||
const installations = await this.fetchInstallations(token.access_token);
|
||||
|
||||
// Send result to renderer process
|
||||
this.sendSuccessToRenderer({
|
||||
token,
|
||||
user,
|
||||
installations,
|
||||
authMethod: 'web_oauth'
|
||||
});
|
||||
|
||||
// Clear pending auth
|
||||
this.pendingAuth = null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[GitHub OAuth] Callback handling error:', error);
|
||||
this.sendErrorToRenderer('token_exchange_failed', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth state for new flow
|
||||
*/
|
||||
generateOAuthState() {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const now = Date.now();
|
||||
|
||||
this.pendingAuth = {
|
||||
state,
|
||||
verifier,
|
||||
createdAt: now,
|
||||
expiresAt: now + 300000 // 5 minutes
|
||||
};
|
||||
|
||||
return this.pendingAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OAuth state from callback
|
||||
*/
|
||||
validateState(receivedState) {
|
||||
if (!this.pendingAuth) {
|
||||
console.error('[GitHub OAuth] No pending auth state');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (receivedState !== this.pendingAuth.state) {
|
||||
console.error('[GitHub OAuth] State mismatch');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Date.now() > this.pendingAuth.expiresAt) {
|
||||
console.error('[GitHub OAuth] State expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
async exchangeCodeForToken(code) {
|
||||
console.log('[GitHub OAuth] Exchanging code for access token');
|
||||
|
||||
const response = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: GITHUB_CLIENT_ID,
|
||||
client_secret: GITHUB_CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user information from GitHub
|
||||
*/
|
||||
async fetchUserInfo(token) {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch installation information (orgs/repos user granted access to)
|
||||
*/
|
||||
async fetchInstallations(token) {
|
||||
try {
|
||||
// Fetch user installations
|
||||
const response = await fetch('https://api.github.com/user/installations', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[GitHub OAuth] Failed to fetch installations:', response.status);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.installations || [];
|
||||
} catch (error) {
|
||||
console.warn('[GitHub OAuth] Error fetching installations:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send success to renderer process
|
||||
*/
|
||||
sendSuccessToRenderer(result) {
|
||||
console.log('📤 [GitHub OAuth] ========================================');
|
||||
console.log('📤 [GitHub OAuth] SENDING IPC EVENT: github-oauth-complete');
|
||||
console.log('📤 [GitHub OAuth] User:', result.user.login);
|
||||
console.log('📤 [GitHub OAuth] Installations:', result.installations.length);
|
||||
console.log('📤 [GitHub OAuth] ========================================');
|
||||
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
windows[0].webContents.send('github-oauth-complete', result);
|
||||
console.log('✅ [GitHub OAuth] IPC event sent to renderer');
|
||||
} else {
|
||||
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error to renderer process
|
||||
*/
|
||||
sendErrorToRenderer(error, description) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
windows[0].webContents.send('github-oauth-error', {
|
||||
error,
|
||||
message: description || error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization URL for OAuth flow
|
||||
*/
|
||||
getAuthorizationUrl(state) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: GITHUB_CLIENT_ID,
|
||||
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`,
|
||||
scope: 'repo read:org read:user user:email',
|
||||
state,
|
||||
allow_signup: 'true'
|
||||
});
|
||||
|
||||
return `https://github.com/login/oauth/authorize?${params}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending OAuth flow
|
||||
*/
|
||||
cancelPendingAuth() {
|
||||
this.pendingAuth = null;
|
||||
console.log('[GitHub OAuth] Pending auth cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let handlerInstance = null;
|
||||
|
||||
/**
|
||||
* Initialize GitHub OAuth IPC handlers and protocol handler
|
||||
*/
|
||||
function initializeGitHubOAuthHandlers(app) {
|
||||
handlerInstance = new GitHubOAuthCallbackHandler();
|
||||
|
||||
// Register custom protocol handler
|
||||
if (!app.isDefaultProtocolClient(OAUTH_PROTOCOL)) {
|
||||
app.setAsDefaultProtocolClient(OAUTH_PROTOCOL);
|
||||
console.log(`[GitHub OAuth] Registered ${OAUTH_PROTOCOL}:// protocol handler`);
|
||||
}
|
||||
|
||||
// Handle protocol callback on macOS/Linux
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
if (url.startsWith(`${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`)) {
|
||||
handlerInstance.handleProtocolCallback(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle protocol callback on Windows (second instance)
|
||||
app.on('second-instance', (event, commandLine) => {
|
||||
// Find the protocol URL in command line args
|
||||
const protocolUrl = commandLine.find((arg) => arg.startsWith(`${OAUTH_PROTOCOL}://`));
|
||||
if (protocolUrl && protocolUrl.includes(OAUTH_CALLBACK_PATH)) {
|
||||
handlerInstance.handleProtocolCallback(protocolUrl);
|
||||
}
|
||||
|
||||
// Focus the main window
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
if (windows[0].isMinimized()) windows[0].restore();
|
||||
windows[0].focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle start OAuth flow request from renderer
|
||||
ipcMain.handle('github-oauth-start', async () => {
|
||||
try {
|
||||
const authState = handlerInstance.generateOAuthState();
|
||||
const authUrl = handlerInstance.getAuthorizationUrl(authState.state);
|
||||
|
||||
return { success: true, authUrl };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stop OAuth flow request from renderer
|
||||
ipcMain.handle('github-oauth-stop', async () => {
|
||||
handlerInstance.cancelPendingAuth();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log('[GitHub OAuth] IPC handlers and protocol handler initialized');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GitHubOAuthCallbackHandler,
|
||||
initializeGitHubOAuthHandlers,
|
||||
OAUTH_PROTOCOL
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const { startCloudFunctionServer, closeRuntimeWhenWindowCloses } = require('./sr
|
||||
const DesignToolImportServer = require('./src/design-tool-import-server');
|
||||
const jsonstorage = require('../shared/utils/jsonstorage');
|
||||
const StorageApi = require('./src/StorageApi');
|
||||
const { initializeGitHubOAuthHandlers } = require('./github-oauth-handler');
|
||||
|
||||
const { handleProjectMerge } = require('./src/merge-driver');
|
||||
|
||||
@@ -542,6 +543,9 @@ function launchApp() {
|
||||
|
||||
setupGitHubOAuthIpc();
|
||||
|
||||
// Initialize Web OAuth handlers for GitHub (with protocol handler)
|
||||
initializeGitHubOAuthHandlers(app);
|
||||
|
||||
setupMainWindowControlIpc();
|
||||
|
||||
setupMenu();
|
||||
@@ -565,27 +569,12 @@ function launchApp() {
|
||||
console.log('open-url', uri);
|
||||
event.preventDefault();
|
||||
|
||||
// Handle GitHub OAuth callback
|
||||
if (uri.startsWith('noodl://github-callback')) {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
if (code && state) {
|
||||
console.log('🔐 GitHub OAuth callback received');
|
||||
win && win.webContents.send('github-oauth-callback', { code, state });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse GitHub OAuth callback:', error);
|
||||
}
|
||||
// GitHub OAuth callbacks are handled by github-oauth-handler.js
|
||||
// Only handle other noodl:// URIs here
|
||||
if (!uri.startsWith('noodl://github-callback')) {
|
||||
win && win.webContents.send('open-noodl-uri', uri);
|
||||
process.env.noodlURI = uri;
|
||||
}
|
||||
|
||||
// Default noodl URI handling
|
||||
win && win.webContents.send('open-noodl-uri', uri);
|
||||
process.env.noodlURI = uri;
|
||||
// logEverywhere("open-url# " + deeplinkingUrl)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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
@@ -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';
|
||||
|
||||