Finished HTTP node creation and extensive node creation documentation in project

This commit is contained in:
Richard Osborne
2025-12-29 08:56:46 +01:00
parent fad9f1006d
commit 6fd59e83e6
13 changed files with 1008 additions and 247 deletions

View File

@@ -927,3 +927,155 @@ If you don't see the log, the subscription isn't working.
_Last Updated: December 2025_
```
---
## 14. Node Creation Checklist
> **🚨 CRITICAL:** Before creating or modifying runtime nodes, read `dev-docs/reference/LEARNINGS-NODE-CREATION.md`
Creating nodes in OpenNoodl is deceptively tricky. This checklist prevents the most common (and hardest to debug) issues.
### 14.1 Pre-Flight Checklist
Before writing any node code:
- [ ] Read `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (especially the CRITICAL GOTCHAS section)
- [ ] Study an existing working node of similar complexity (e.g., `restnode.js` for data nodes)
- [ ] Understand the difference between `inputs` (static) vs `prototypeExtensions` (instance methods)
- [ ] Know where your node should be registered (noodl-runtime vs noodl-viewer-react)
### 14.2 Input Handler Rules
```javascript
// ✅ CORRECT: Signal inputs use valueChangedToTrue
inputs: {
fetch: {
type: 'signal',
valueChangedToTrue: function() {
this.scheduleFetch();
}
}
}
// ❌ WRONG: Signal inputs with set() - NEVER TRIGGERS
inputs: {
fetch: {
type: 'signal',
set: function(value) { // ☠️ Never called for signals
this.scheduleFetch();
}
}
}
```
### 14.3 Never Override setInputValue
```javascript
// ❌ BREAKS EVERYTHING - Never define setInputValue in prototypeExtensions
prototypeExtensions: {
setInputValue: function(name, value) { // ☠️ Overrides base - signals stop working
// ...
}
}
// ✅ Use a different name for custom storage
prototypeExtensions: {
_storeInputValue: function(name, value) { // ✅ Doesn't override anything
this._internal.inputValues[name] = value;
}
}
```
### 14.4 Dynamic Ports Must Include Static Ports
```javascript
// ❌ WRONG - Static ports disappear
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Only adds dynamic ports...
editorConnection.sendDynamicPorts(nodeId, ports); // Static inputs gone!
}
// ✅ CORRECT - Include all ports
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [
// Re-add static inputs
{ name: 'url', displayName: 'URL', type: 'string', plug: 'input', group: 'Request' },
{ name: 'fetch', displayName: 'Fetch', type: 'signal', plug: 'input', group: 'Actions' },
// Then add dynamic ports...
];
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
### 14.5 Register Config Inputs Explicitly
```javascript
// Config inputs (from stringlist editors) need explicit registration
registerInputIfNeeded: function(name) {
if (this.hasInput(name)) return;
// Map config names to their setters
const configSetters = {
'method': this.setMethod.bind(this),
'headers': this.setHeaders.bind(this),
'queryParams': this.setQueryParams.bind(this)
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Handle prefixed dynamic inputs
if (name.startsWith('header-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
```
### 14.6 Export Format Matters
```javascript
// ✅ CORRECT: Export with setup function
module.exports = {
node: MyNode,
setup: function (context, graphModel) {
// Port management goes here
}
};
// ❌ WRONG: Direct export (setup never runs)
module.exports = MyNode;
```
### 14.7 Post-Creation Verification
After creating a node:
1. **Check ports appear**: All static AND dynamic inputs/outputs visible in editor?
2. **Check signals work**: Add console.log in `valueChangedToTrue` - does it print?
3. **Check config inputs work**: Change dropdown/stringlist values - does setter get called?
4. **Clear caches if needed**: `npm run clean:all` and restart
### 14.8 Quick Reference
| Input Type | Handler | Callback Format |
| ---------------------------- | --------------------------- | --------------------------- |
| Signal | `valueChangedToTrue` | `function() { ... }` |
| Value (string, number, etc.) | `set` | `function(value) { ... }` |
| Enum (dropdown) | `set` | `function(value) { ... }` |
| StringList (config) | Needs explicit registration | Via `registerInputIfNeeded` |
### 14.9 Where to Find Examples
| Pattern | Example File |
| ------------------------------------ | ---------------------------------------------------------------- |
| Complex data node with dynamic ports | `noodl-runtime/src/nodes/std-library/data/restnode.js` |
| HTTP node (fixed, working) | `noodl-runtime/src/nodes/std-library/data/httpnode.js` |
| Simple value node | `noodl-runtime/src/nodes/std-library/variables/numbernode.js` |
| Signal-based node | `noodl-runtime/src/nodes/std-library/timer.js` (in viewer-react) |
---

View File

@@ -64,9 +64,9 @@ var MyNode = {
// REQUIRED: Define input ports
inputs: {
inputName: {
type: 'string', // See "Port Types" section below
type: 'string', // See "Port Types" section below
displayName: 'Input Name',
group: 'General', // Group in property panel
group: 'General', // Group in property panel
default: 'default value'
},
doAction: {
@@ -146,7 +146,7 @@ function registerNodes(noodlRuntime) {
// ... existing nodes ...
// Add your new node
require('./src/nodes/std-library/data/mynode'),
require('./src/nodes/std-library/data/mynode')
// ... more nodes ...
].forEach((node) => noodlRuntime.registerNode(node));
@@ -174,10 +174,10 @@ const coreNodes = [
// ... other subcategories ...
{
name: 'External Data',
items: ['net.noodl.MyNode', 'REST2'] // Add your node name here
items: ['net.noodl.MyNode', 'REST2'] // Add your node name here
}
]
},
}
// ... more categories ...
];
```
@@ -188,24 +188,24 @@ const coreNodes = [
### Common Input/Output Types
| Type | Description | Example Use |
|------|-------------|-------------|
| `string` | Text value | URLs, names, content |
| `number` | Numeric value | Counts, sizes, coordinates |
| `boolean` | True/false | Toggles, conditions |
| `signal` | Trigger without data | Action buttons, events |
| `object` | JSON object | API responses, data structures |
| `array` | List of items | Collections, results |
| `color` | Color value | Styling |
| `*` | Any type | Generic ports |
| Type | Description | Example Use |
| --------- | -------------------- | ------------------------------ |
| `string` | Text value | URLs, names, content |
| `number` | Numeric value | Counts, sizes, coordinates |
| `boolean` | True/false | Toggles, conditions |
| `signal` | Trigger without data | Action buttons, events |
| `object` | JSON object | API responses, data structures |
| `array` | List of items | Collections, results |
| `color` | Color value | Styling |
| `*` | Any type | Generic ports |
### Input-Specific Types
| Type | Description |
|------|-------------|
| `{ name: 'enum', enums: [...] }` | Dropdown selection |
| `{ name: 'stringlist' }` | List of strings (comma-separated) |
| `{ name: 'number', min, max }` | Number with constraints |
| Type | Description |
| -------------------------------- | --------------------------------- |
| `{ name: 'enum', enums: [...] }` | Dropdown selection |
| `{ name: 'stringlist' }` | List of strings (comma-separated) |
| `{ name: 'number', min, max }` | Number with constraints |
### Example Enum Input
@@ -394,6 +394,348 @@ getInspectInfo() {
---
## ⚠️ CRITICAL GOTCHAS - READ BEFORE CREATING NODES
These issues will cause silent failures with NO error messages. They were discovered during the HTTP node debugging session (December 2024) and cost hours of debugging time.
---
### 🔴 GOTCHA #1: Never Override `setInputValue` in prototypeExtensions
**THE BUG:**
```javascript
// ❌ DEADLY - This silently breaks ALL signal inputs
prototypeExtensions: {
setInputValue: function (name, value) {
this._internal.inputValues[name] = value; // Signal setters NEVER called!
}
}
```
**WHY IT BREAKS:**
- `prototypeExtensions.setInputValue` **completely overrides** `Node.prototype.setInputValue`
- The base method contains `input.set.call(this, value)` which triggers signal callbacks
- Without it, signals never fire - NO errors, just silent failure
**THE FIX:**
```javascript
// ✅ SAFE - Use a different method name for storing dynamic values
prototypeExtensions: {
_storeInputValue: function (name, value) {
this._internal.inputValues[name] = value;
},
registerInputIfNeeded: function (name) {
// Register dynamic inputs with _storeInputValue, not setInputValue
if (name.startsWith('body-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
}
```
---
### 🔴 GOTCHA #2: Dynamic Ports REPLACE Static Ports
**THE BUG:**
```javascript
// Node has static inputs defined in inputs: {}
inputs: {
url: { type: 'string', set: function(v) {...} },
fetch: { type: 'signal', valueChangedToTrue: function() {...} }
},
// But setup function only sends dynamic ports
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [
{ name: 'headers', ... },
{ name: 'queryParams', ... }
];
// ❌ MISSING url, fetch - they won't appear in editor!
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
**WHY IT BREAKS:**
- `sendDynamicPorts()` tells the editor "these are ALL the ports for this node"
- Static `inputs` are NOT automatically merged
- The editor only shows dynamic ports, connections to static ports fail
**THE FIX:**
```javascript
// ✅ SAFE - Include ALL ports in dynamic ports array
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Dynamic configuration ports
ports.push({ name: 'headers', type: {...}, plug: 'input' });
ports.push({ name: 'queryParams', type: {...}, plug: 'input' });
// MUST include static ports too!
ports.push({ name: 'url', type: 'string', plug: 'input', group: 'Request' });
ports.push({ name: 'fetch', type: 'signal', plug: 'input', group: 'Actions' });
ports.push({ name: 'cancel', type: 'signal', plug: 'input', group: 'Actions' });
// Include outputs too
ports.push({ name: 'success', type: 'signal', plug: 'output', group: 'Events' });
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
---
### 🔴 GOTCHA #3: Configuration Inputs Need Explicit Registration
**THE BUG:**
```javascript
// Dynamic ports send method, bodyType, etc. to editor
ports.push({ name: 'method', type: { name: 'enum', ... } });
ports.push({ name: 'bodyType', type: { name: 'enum', ... } });
// ❌ Values never reach runtime - no setter registered!
// User selects POST in editor, but doFetch() always uses GET
doFetch: function() {
const method = this._internal.method || 'GET'; // Always undefined!
}
```
**WHY IT BREAKS:**
- Dynamic port values are sent to runtime as input values via `setInputValue`
- But `registerInputIfNeeded` is only called for ports not in static `inputs`
- If there's no setter, the value is lost
**THE FIX:**
```javascript
// ✅ SAFE - Register setters for all config inputs
prototypeExtensions: {
// Setter methods
setMethod: function (value) { this._internal.method = value || 'GET'; },
setBodyType: function (value) { this._internal.bodyType = value; },
setHeaders: function (value) { this._internal.headers = value || ''; },
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Configuration inputs - bind to their setters
const configSetters = {
method: this.setMethod.bind(this),
bodyType: this.setBodyType.bind(this),
headers: this.setHeaders.bind(this),
timeout: this.setTimeout.bind(this)
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Dynamic inputs for prefixed ports
if (name.startsWith('body-') || name.startsWith('header-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
}
```
---
### 🔴 GOTCHA #4: Signal Inputs Use `valueChangedToTrue`, Not `set`
**THE BUG:**
```javascript
// ❌ WRONG - This won't trigger on signal
inputs: {
fetch: {
type: 'signal',
set: function() {
this.doFetch(); // Never called!
}
}
}
```
**WHY IT BREAKS:**
- Signal inputs don't use `set` - they use `valueChangedToTrue`
- The runtime wraps signals with `EdgeTriggeredInput.createSetter()` which tracks state transitions
- Signals only fire on FALSE → TRUE transition
**THE FIX:**
```javascript
// ✅ CORRECT - Use valueChangedToTrue for signals
inputs: {
fetch: {
type: 'signal',
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
this.scheduleFetch();
}
},
cancel: {
type: 'signal',
displayName: 'Cancel',
group: 'Actions',
valueChangedToTrue: function () {
this.cancelFetch();
}
}
}
```
---
### 🔴 GOTCHA #5: Node Registration Path Matters
**THE BUG:**
- Nodes in `noodl-runtime/noodl-runtime.js` → Go through `defineNode()`
- Nodes in `noodl-viewer-react/register-nodes.js` → Go through `defineNode()`
- Raw node object passed directly → Does NOT go through `defineNode()`
**WHY IT MATTERS:**
- `defineNode()` in `nodedefinition.js` wraps signal inputs with `EdgeTriggeredInput.createSetter()`
- Without `defineNode()`, signals are registered but never fire
- The `{node, setup}` export format automatically calls `defineNode()`
**THE FIX:**
```javascript
// ✅ CORRECT - Always export with {node, setup} format
module.exports = {
node: MyNode, // Goes through defineNode()
setup: function (context, graphModel) {
// Dynamic port setup
}
};
// ✅ ALSO CORRECT - Call defineNode explicitly
const NodeDefinition = require('./nodedefinition');
module.exports = NodeDefinition.defineNode(MyNode);
```
---
## Complete Working Pattern (HTTP Node Reference)
Here's the proven pattern from the HTTP node that handles all gotchas:
```javascript
var MyNode = {
name: 'net.noodl.MyNode',
displayNodeName: 'My Node',
category: 'Data',
color: 'data',
initialize: function () {
this._internal.inputValues = {}; // For dynamic input storage
this._internal.method = 'GET'; // Config defaults
},
// Static inputs - signals and essential ports
inputs: {
url: {
type: 'string',
set: function (value) { this._internal.url = value; }
},
fetch: {
type: 'signal',
valueChangedToTrue: function () { this.scheduleFetch(); }
}
},
outputs: {
response: { type: '*', getter: function() { return this._internal.response; } },
success: { type: 'signal' },
failure: { type: 'signal' }
},
prototypeExtensions: {
// Store dynamic values WITHOUT overriding setInputValue
_storeInputValue: function (name, value) {
this._internal.inputValues[name] = value;
},
// Configuration setters
setMethod: function (value) { this._internal.method = value || 'GET'; },
setHeaders: function (value) { this._internal.headers = value || ''; },
// Register ALL dynamic inputs
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Config inputs
const configSetters = {
method: this.setMethod.bind(this),
headers: this.setHeaders.bind(this)
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Prefixed dynamic inputs
if (name.startsWith('header-') || name.startsWith('body-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
},
scheduleFetch: function () {
this.scheduleAfterInputsHaveUpdated(this.doFetch.bind(this));
},
doFetch: function () {
const method = this._internal.method; // Now correctly captured!
// ... fetch implementation
}
}
};
module.exports = {
node: MyNode,
setup: function (context, graphModel) {
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Config ports
ports.push({ name: 'method', type: { name: 'enum', enums: [...] }, plug: 'input' });
ports.push({ name: 'headers', type: { name: 'stringlist' }, plug: 'input' });
// MUST include static ports!
ports.push({ name: 'url', type: 'string', plug: 'input' });
ports.push({ name: 'fetch', type: 'signal', plug: 'input' });
// Outputs
ports.push({ name: 'response', type: '*', plug: 'output' });
ports.push({ name: 'success', type: 'signal', plug: 'output' });
editorConnection.sendDynamicPorts(nodeId, ports);
}
// ... rest of setup
}
};
```
---
## Common Issues
### Node Not Appearing in Node Picker
@@ -416,9 +758,13 @@ getInspectInfo() {
### Signal Not Firing
**Cause:** Method name doesn't match pattern `inputName + 'Trigger'`.
**Cause #1:** Method name pattern wrong - use `valueChangedToTrue`, not `inputName + 'Trigger'`.
**Fix:** Ensure signal handler method is named correctly (e.g., `fetchTrigger` for input `fetch`).
**Cause #2:** Custom `setInputValue` overriding base - see GOTCHA #1.
**Cause #3:** Signal not in dynamic ports - see GOTCHA #2.
**Fix:** Review ALL gotchas above!
---
@@ -437,14 +783,14 @@ getInspectInfo() {
When creating new nodes, reference these existing nodes for patterns:
| Node | File | Good Example Of |
|------|------|-----------------|
| REST | `data/restnode.js` | Full-featured data node with scripts |
| HTTP | `data/httpnode.js` | Dynamic ports, configuration |
| String | `variables/string.js` | Simple variable node |
| Counter | `counter.js` | Stateful logic node |
| Condition | `condition.js` | Boolean logic |
| Node | File | Good Example Of |
| --------- | --------------------- | ------------------------------------ |
| REST | `data/restnode.js` | Full-featured data node with scripts |
| HTTP | `data/httpnode.js` | Dynamic ports, configuration |
| String | `variables/string.js` | Simple variable node |
| Counter | `counter.js` | Stateful logic node |
| Condition | `condition.js` | Boolean logic |
---
*Last Updated: December 2024*
_Last Updated: December 2024_

View File

@@ -852,6 +852,145 @@ const handleTreeMouseUp = useCallback(() => {
---
## 🔥 CRITICAL: Runtime Node Creation - The Unholy Trinity of Silent Failures (Dec 2025)
### The HTTP Node Debugging Saga: 3+ Hours of Silent Failures
**Context**: Creating an HTTP node (httpnode.js) as a modern, no-code-friendly alternative to the script-based REST node. Everything looked correct but nothing worked. Signals never fired, config values never set, and no errors anywhere.
**The Problems Found** (each took significant debugging time):
#### Problem 1: Signal Input Using `set` Instead of `valueChangedToTrue`
```javascript
// ❌ WHAT I WROTE (never triggers)
inputs: {
fetch: {
type: 'signal',
set: function(value) {
this.scheduleFetch(); // ☠️ Never called
}
}
}
// ✅ WHAT IT SHOULD BE
inputs: {
fetch: {
type: 'signal',
valueChangedToTrue: function() {
this.scheduleFetch(); // ✓ Works!
}
}
}
```
**Why**: Signals use `EdgeTriggeredInput.createSetter()` which wraps the callback and only calls `valueChangedToTrue` when value transitions from falsy to truthy. The `set` callback is never used.
#### Problem 2: Custom `setInputValue` Overriding Base Method
```javascript
// ❌ WHAT I WROTE (breaks ALL inputs including signals)
prototypeExtensions: {
setInputValue: function(name, value) {
this._internal.inputValues[name] = value; // ☠️ Overrides Node.prototype.setInputValue
}
}
// ✅ WHAT IT SHOULD BE
prototypeExtensions: {
_storeInputValue: function(name, value) { // Different name!
this._internal.inputValues[name] = value;
}
}
```
**Why**: `prototypeExtensions` methods are merged into node prototype. Defining `setInputValue` completely replaces the base implementation, which handles signal detection, input.set() calls, and event emission.
#### Problem 3: Dynamic Ports Replacing Static Ports
```javascript
// ❌ WHAT I WROTE (static ports disappear)
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// Only add dynamic header/query param ports...
if (parameters.headers) { /* add header ports */ }
editorConnection.sendDynamicPorts(nodeId, ports); // Static inputs GONE!
}
// ✅ WHAT IT SHOULD BE
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [
// Re-add ALL static inputs
{ name: 'url', displayName: 'URL', type: 'string', plug: 'input', group: 'Request' },
{ name: 'fetch', displayName: 'Fetch', type: 'signal', plug: 'input', group: 'Actions' },
{ name: 'cancel', displayName: 'Cancel', type: 'signal', plug: 'input', group: 'Actions' },
// Then dynamic ports...
];
editorConnection.sendDynamicPorts(nodeId, ports);
}
```
**Why**: `sendDynamicPorts` REPLACES all ports, not merges. The `inputs` object in node definition is only for default setup - once dynamic ports are sent, they're the only ports.
#### Problem 4: Config Inputs (StringList/Enum) Need Explicit Registration
```javascript
// ❌ MISSING (config values never reach setters)
// StringList inputs like "headers", "queryParams" appear in editor but their
// values never reach the node because there's no registered input handler
// ✅ WHAT IT NEEDS
registerInputIfNeeded: function(name) {
if (this.hasInput(name)) return;
const configSetters = {
'method': this.setMethod.bind(this),
'headers': this.setHeaders.bind(this),
'queryParams': this.setQueryParams.bind(this),
'bodyType': this.setBodyType.bind(this),
// ... all config inputs
};
if (configSetters[name]) {
return this.registerInput(name, { set: configSetters[name] });
}
// Dynamic inputs (header-X, query-Y, etc.)
if (name.startsWith('header-')) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
```
**Why**: Inputs defined in the `inputs` object get registered automatically. Dynamic ports don't - they need `registerInputIfNeeded` to create runtime handlers.
**Why This Was So Hard to Debug**:
1. **No errors** - Everything appeared to work, logs said "success", but nothing happened
2. **Partial success** - Some things worked (node appeared in palette) making it seem close
3. **Multiple bugs** - Each fix revealed the next bug, each taking time to diagnose
4. **No TypeScript** - Runtime code is JS, no compile-time checking
5. **Unfamiliar patterns** - `valueChangedToTrue`, `registerInputIfNeeded`, etc. aren't obvious
**Time Lost**: 3+ hours debugging what should have been a straightforward node
**Prevention**:
- Created `dev-docs/reference/LEARNINGS-NODE-CREATION.md` with Critical Gotchas section
- Added Node Creation Checklist to `.clinerules` Section 14
- These gotchas are now documented with THE BUG / WHY IT BREAKS / THE FIX patterns
**Location**:
- `packages/noodl-runtime/src/nodes/std-library/data/httpnode.js` (fixed)
- `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (new documentation)
**Keywords**: node creation, signal, valueChangedToTrue, setInputValue, prototypeExtensions, sendDynamicPorts, registerInputIfNeeded, stringlist, enum, config inputs, HTTP node, runtime
---
## 🔥 CRITICAL: PopupLayer.dragCompleted() - Not endDrag() (Dec 2025)
### Wrong Method Name Causes TypeError

View File

@@ -116,10 +116,14 @@ export function ComponentItem({
// If this item is a valid drop target, execute the drop
if (isDropTarget && onDrop) {
e.stopPropagation(); // Prevent bubble to Tree (for root drop fallback)
// End drag IMMEDIATELY at event handler level (before action chain)
// This matches the working root drop pattern
PopupLayer.instance.dragCompleted();
const node: TreeNode = { type: 'component', data: component };
onDrop(node);
setIsDropTarget(false);
// Note: dragCompleted() is called by handleDropOn - don't call it here
}
},
[isDropTarget, component, onDrop]

View File

@@ -111,10 +111,14 @@ export function FolderItem({
// If this folder is a valid drop target, execute the drop
if (isDropTarget && onDrop) {
e.stopPropagation(); // Prevent bubble to Tree (for root drop fallback)
// End drag IMMEDIATELY at event handler level (before action chain)
// This matches the working root drop pattern
PopupLayer.instance.dragCompleted();
const node: TreeNode = { type: 'folder', data: folder };
onDrop(node);
setIsDropTarget(false);
// Note: dragCompleted() is called by handleDropOn - don't call it here
}
},
[isDropTarget, folder, onDrop]

View File

@@ -523,15 +523,12 @@ export function useComponentActions() {
// Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists in that folder`);
PopupLayer.instance.dragCompleted();
return;
}
const oldName = component.name;
// End drag operation FIRST - before the rename triggers a re-render
// This prevents the drag state from persisting across the tree rebuild
PopupLayer.instance.dragCompleted();
// Note: dragCompleted() now called by ComponentItem.handleMouseUp before this action
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
@@ -554,7 +551,6 @@ export function useComponentActions() {
// Prevent moving folder into itself
if (targetPath.startsWith(sourcePath + '/') || targetPath === sourcePath) {
alert('Cannot move folder into itself');
PopupLayer.instance.dragCompleted();
return;
}
@@ -565,7 +561,6 @@ export function useComponentActions() {
if (!componentsToMove || componentsToMove.length === 0) {
console.log('Folder is empty, nothing to move');
PopupLayer.instance.dragCompleted();
return;
}
@@ -584,8 +579,7 @@ export function useComponentActions() {
renames.push({ component: comp, oldName: comp.name, newName });
});
// End drag operation FIRST - before the rename triggers a re-render
PopupLayer.instance.dragCompleted();
// Note: dragCompleted() now called by FolderItem.handleMouseUp before this action
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
@@ -612,14 +606,12 @@ export function useComponentActions() {
// Check for naming conflicts
if (ProjectModel.instance?.getComponentWithName(newName)) {
alert(`Component "${newName}" already exists`);
PopupLayer.instance.dragCompleted();
return;
}
const oldName = component.name;
// End drag operation FIRST - before the rename triggers a re-render
PopupLayer.instance.dragCompleted();
// Note: dragCompleted() now called by ComponentItem.handleMouseUp before this action
UndoQueue.instance.pushAndDo(
new UndoActionGroup({
@@ -646,7 +638,6 @@ export function useComponentActions() {
if (!componentsToMove || componentsToMove.length === 0) {
console.log('Folder is empty, nothing to move');
PopupLayer.instance.dragCompleted();
return;
}
@@ -670,12 +661,10 @@ export function useComponentActions() {
if (hasConflict) {
alert(`Some components would conflict with existing names`);
PopupLayer.instance.dragCompleted();
return;
}
// End drag operation FIRST - before the rename triggers a re-render
PopupLayer.instance.dragCompleted();
// Note: dragCompleted() now called by ComponentItem.handleMouseUp before this action
UndoQueue.instance.pushAndDo(
new UndoActionGroup({

View File

@@ -3,11 +3,26 @@ import StringListTemplate from '../../../../../templates/propertyeditor/stringli
import PopupLayer from '../../../../popuplayer';
import { ToastLayer } from '../../../../ToastLayer/ToastLayer';
// Helper to normalize list input - handles both string and array formats
function normalizeList(value) {
if (value === undefined || value === null || value === '') {
return [];
}
if (Array.isArray(value)) {
// Already an array (legacy proplist data) - extract labels if objects
return value.map((item) => (typeof item === 'object' && item.label ? item.label : String(item)));
}
if (typeof value === 'string') {
return value.split(',').filter(Boolean);
}
return [];
}
var StringList = function (args) {
View.call(this);
this.parent = args.parent;
this.list = args.list !== undefined && args.list !== '' ? args.list.split(',') : [];
this.list = normalizeList(args.list);
this.type = args.type;
this.plug = args.plug;
this.title = args.title;
@@ -32,7 +47,7 @@ StringList.prototype.listUpdated = function () {
if (this.onUpdate) {
var updated = this.onUpdate(this.list.join(','));
if (updated) {
this.list = updated.value ? updated.value.split(',') : [];
this.list = normalizeList(updated.value);
this.isDefault = updated.isDefault;
}
}

View File

@@ -23,7 +23,7 @@ function registerNodes(noodlRuntime) {
// Data
require('./src/nodes/std-library/data/restnode'),
require('./src/nodes/std-library/data/httpnode'),
// require('./src/nodes/std-library/data/httpnode'), // moved to viewer for debugging
// Custom code
require('./src/nodes/std-library/expression'),

View File

@@ -1,19 +1,22 @@
"use strict";
'use strict';
function createSetter(args) {
console.log('[EdgeTriggeredInput] 🔧 createSetter called for signal input');
var currentValue = false;
var currentValue = false;
return function(value) {
value = value ? true : false;
//value changed from false to true
if(value && currentValue === false) {
args.valueChangedToTrue.call(this);
}
currentValue = value;
};
return function (value) {
console.log('[EdgeTriggeredInput] ⚡ setter called with value:', value, 'currentValue:', currentValue);
value = value ? true : false;
//value changed from false to true
if (value && currentValue === false) {
console.log('[EdgeTriggeredInput] ✅ Triggering valueChangedToTrue!');
args.valueChangedToTrue.call(this);
}
currentValue = value;
};
}
module.exports = {
createSetter: createSetter
createSetter: createSetter
};

View File

@@ -84,12 +84,22 @@ Node.prototype.registerInputIfNeeded = function () {
};
Node.prototype.setInputValue = function (name, value) {
// DEBUG: Track input value setting for HTTP node
if (this.name === 'net.noodl.HTTP') {
console.log('[Node.setInputValue] 🎯 HTTP node setting input:', name, 'value:', value);
}
const input = this.getInput(name);
if (!input) {
console.log("node doesn't have input", name);
console.log("node doesn't have input", name, 'for node:', this.name);
return;
}
// DEBUG: Track if setter exists
if (this.name === 'net.noodl.HTTP' && name === 'fetch') {
console.log('[Node.setInputValue] 🎯 HTTP fetch input found, calling setter');
}
//inputs with units always expect objects in the shape of {value, unit, ...}
//these inputs might sometimes get raw numbers without units, and in those cases
//Noodl should just update the value and not the other parameters

View File

@@ -200,6 +200,7 @@ function defineNode(opts) {
Object.keys(opts.inputs).forEach(function (name) {
var input = opts.inputs[name];
if (input.valueChangedToTrue) {
console.log('[NodeDefinition] 📌 Registering signal input:', name, 'for node:', opts.name);
node._inputs[name] = {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: input.valueChangedToTrue

View File

@@ -19,6 +19,9 @@
// Note: This file uses CommonJS module format to match the noodl-runtime pattern
// DEBUG: Confirm module is loaded
console.log('[HTTP Node Module] 📦 httpnode.js MODULE LOADED');
/**
* Extract value from object using JSONPath-like syntax
* Supports: $.data.users, $.items[0].name, $.meta.pagination.total
@@ -88,12 +91,13 @@ var HttpNode = {
searchTags: ['http', 'request', 'fetch', 'api', 'rest', 'curl'],
initialize: function () {
console.log('[HTTP Node] 🚀 INITIALIZE called - node instance created');
this._internal.inputValues = {};
this._internal.outputValues = {};
this._internal.headers = [];
this._internal.queryParams = [];
this._internal.bodyFields = [];
this._internal.responseMapping = [];
this._internal.headers = '';
this._internal.queryParams = '';
this._internal.bodyFields = '';
this._internal.responseMapping = '';
this._internal.inspectData = null;
},
@@ -105,6 +109,7 @@ var HttpNode = {
},
inputs: {
// Static inputs - these don't need to trigger port regeneration
url: {
type: 'string',
displayName: 'URL',
@@ -114,40 +119,12 @@ var HttpNode = {
this._internal.url = value;
}
},
method: {
type: {
name: 'enum',
enums: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' }
]
},
displayName: 'Method',
group: 'Request',
default: 'GET',
set: function (value) {
this._internal.method = value;
}
},
timeout: {
type: 'number',
displayName: 'Timeout (ms)',
group: 'Request',
default: 30000,
set: function (value) {
this._internal.timeout = value;
}
},
fetch: {
type: 'signal',
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
console.log('[HTTP Node] ⚡ FETCH SIGNAL RECEIVED - valueChangedToTrue triggered!');
this.scheduleFetch();
}
},
@@ -159,6 +136,7 @@ var HttpNode = {
this.cancelFetch();
}
}
// Note: method, timeout, and config ports are now dynamic (in updatePorts)
},
outputs: {
@@ -212,7 +190,9 @@ var HttpNode = {
},
prototypeExtensions: {
setInputValue: function (name, value) {
// Store values for dynamic inputs only - static inputs (including signals)
// use the base Node.prototype.setInputValue which calls input.set()
_storeInputValue: function (name, value) {
this._internal.inputValues[name] = value;
},
@@ -233,25 +213,45 @@ var HttpNode = {
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Dynamic inputs for path params, headers, query params, body fields, auth
const inputSetters = {
path: this.setInputValue.bind(this),
header: this.setInputValue.bind(this),
query: this.setInputValue.bind(this),
body: this.setInputValue.bind(this),
auth: this.setInputValue.bind(this)
// Configuration inputs - these set internal state
const configSetters = {
method: this.setMethod.bind(this),
timeout: this.setTimeout.bind(this),
headers: this.setHeaders.bind(this),
queryParams: this.setQueryParams.bind(this),
bodyType: this.setBodyType.bind(this),
bodyFields: this.setBodyFields.bind(this),
authType: this.setAuthType.bind(this),
responseMapping: this.setResponseMapping.bind(this)
};
for (const [prefix, setter] of Object.entries(inputSetters)) {
if (name.startsWith(prefix + '-')) {
return this.registerInput(name, { set: setter.bind(this, name) });
if (configSetters[name]) {
return this.registerInput(name, {
set: configSetters[name]
});
}
// Dynamic inputs for path params, headers, query params, body fields, auth, mapping paths
// These use _storeInputValue to just store values (no signal trigger needed)
const dynamicPrefixes = ['path-', 'header-', 'query-', 'body-', 'auth-', 'mapping-path-'];
for (const prefix of dynamicPrefixes) {
if (name.startsWith(prefix)) {
return this.registerInput(name, {
set: this._storeInputValue.bind(this, name)
});
}
}
},
scheduleFetch: function () {
if (this._internal.hasScheduledFetch) return;
console.log('[HTTP Node] scheduleFetch called');
if (this._internal.hasScheduledFetch) {
console.log('[HTTP Node] Already scheduled, skipping');
return;
}
this._internal.hasScheduledFetch = true;
console.log('[HTTP Node] Scheduling doFetch after inputs update');
this.scheduleAfterInputsHaveUpdated(this.doFetch.bind(this));
},
@@ -278,12 +278,16 @@ var HttpNode = {
// Add query parameters
const queryParams = {};
// From visual config
// From visual config (stringlist format)
if (this._internal.queryParams) {
for (const qp of this._internal.queryParams) {
const value = this._internal.inputValues['query-' + qp.key];
const queryList = this._internal.queryParams
.split(',')
.map((q) => q.trim())
.filter(Boolean);
for (const qp of queryList) {
const value = this._internal.inputValues['query-' + qp];
if (value !== undefined && value !== null && value !== '') {
queryParams[qp.key] = value;
queryParams[qp] = value;
}
}
}
@@ -312,12 +316,16 @@ var HttpNode = {
buildHeaders: function () {
const headers = {};
// From visual config
// From visual config (stringlist format)
if (this._internal.headers) {
for (const h of this._internal.headers) {
const value = this._internal.inputValues['header-' + h.key];
const headerList = this._internal.headers
.split(',')
.map((h) => h.trim())
.filter(Boolean);
for (const h of headerList) {
const value = this._internal.inputValues['header-' + h];
if (value !== undefined && value !== null) {
headers[h.key] = String(value);
headers[h] = String(value);
}
}
}
@@ -341,32 +349,38 @@ var HttpNode = {
}
const bodyType = this._internal.bodyType || 'json';
const bodyFields = this._internal.bodyFields || [];
const bodyFieldsStr = this._internal.bodyFields || '';
// Parse stringlist format
const bodyFields = bodyFieldsStr
.split(',')
.map((f) => f.trim())
.filter(Boolean);
if (bodyType === 'json') {
const body = {};
for (const field of bodyFields) {
const value = this._internal.inputValues['body-' + field.key];
const value = this._internal.inputValues['body-' + field];
if (value !== undefined) {
body[field.key] = value;
body[field] = value;
}
}
return Object.keys(body).length > 0 ? JSON.stringify(body) : undefined;
} else if (bodyType === 'form') {
const formData = new FormData();
for (const field of bodyFields) {
const value = this._internal.inputValues['body-' + field.key];
const value = this._internal.inputValues['body-' + field];
if (value !== undefined && value !== null) {
formData.append(field.key, value);
formData.append(field, value);
}
}
return formData;
} else if (bodyType === 'urlencoded') {
const params = new URLSearchParams();
for (const field of bodyFields) {
const value = this._internal.inputValues['body-' + field.key];
const value = this._internal.inputValues['body-' + field];
if (value !== undefined && value !== null) {
params.append(field.key, String(value));
params.append(field, String(value));
}
}
return params.toString();
@@ -390,10 +404,20 @@ var HttpNode = {
this._internal.responseHeaders = responseHeaders;
// Process response mappings
const mappings = this._internal.responseMapping || [];
for (const mapping of mappings) {
const outputName = 'out-' + mapping.name;
const value = extractByPath(responseBody, mapping.path);
// Output names are in responseMapping (comma-separated)
// Path for each output is in inputValues['mapping-path-{name}']
const mappingStr = this._internal.responseMapping || '';
const outputNames = mappingStr
.split(',')
.map((m) => m.trim())
.filter(Boolean);
for (const name of outputNames) {
// Get the path from the corresponding input port
const path = this._internal.inputValues['mapping-path-' + name] || '$';
const outputName = 'out-' + name;
const value = extractByPath(responseBody, path);
this.registerOutputIfNeeded(outputName);
this._internal.outputValues[outputName] = value;
@@ -415,6 +439,7 @@ var HttpNode = {
},
doFetch: function () {
console.log('[HTTP Node] doFetch executing');
this._internal.hasScheduledFetch = false;
const url = this.buildUrl();
@@ -423,11 +448,20 @@ var HttpNode = {
const body = this.buildBody();
const timeout = this._internal.timeout || 30000;
console.log('[HTTP Node] Request details:', {
url,
method,
headers,
body: body ? '(has body)' : '(no body)',
timeout
});
// Store for inspect
this._internal.lastRequestUrl = url;
// Validate URL
if (!url) {
console.log('[HTTP Node] No URL provided, sending failure');
this._internal.error = 'URL is required';
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
@@ -503,11 +537,11 @@ var HttpNode = {
// Configuration setters called from setup function
setHeaders: function (value) {
this._internal.headers = value || [];
this._internal.headers = value || '';
},
setQueryParams: function (value) {
this._internal.queryParams = value || [];
this._internal.queryParams = value || '';
},
setBodyType: function (value) {
@@ -515,15 +549,23 @@ var HttpNode = {
},
setBodyFields: function (value) {
this._internal.bodyFields = value || [];
this._internal.bodyFields = value || '';
},
setResponseMapping: function (value) {
this._internal.responseMapping = value || [];
this._internal.responseMapping = value || '';
},
setAuthType: function (value) {
this._internal.authType = value;
},
setMethod: function (value) {
this._internal.method = value || 'GET';
},
setTimeout: function (value) {
this._internal.timeout = value || 30000;
}
}
};
@@ -534,36 +576,6 @@ var HttpNode = {
function updatePorts(nodeId, parameters, editorConnection) {
const ports = [];
// URL input (already defined in static inputs, but we want it first)
ports.push({
name: 'url',
displayName: 'URL',
type: 'string',
plug: 'input',
group: 'Request'
});
// Method input
ports.push({
name: 'method',
displayName: 'Method',
type: {
name: 'enum',
enums: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' }
]
},
default: 'GET',
plug: 'input',
group: 'Request'
});
// Parse URL for path parameters: /users/{userId} → userId port
if (parameters.url) {
const pathParams = parameters.url.match(/\{([A-Za-z0-9_]+)\}/g) || [];
@@ -580,7 +592,7 @@ function updatePorts(nodeId, parameters, editorConnection) {
}
}
// Headers configuration
// Headers configuration - comma-separated list
ports.push({
name: 'headers',
displayName: 'Headers',
@@ -590,21 +602,23 @@ function updatePorts(nodeId, parameters, editorConnection) {
});
// Generate input ports for each header
if (parameters.headers && Array.isArray(parameters.headers)) {
for (const header of parameters.headers) {
if (header.key) {
ports.push({
name: 'header-' + header.key,
displayName: header.key,
type: 'string',
plug: 'input',
group: 'Headers'
});
}
if (parameters.headers) {
const headerList = parameters.headers
.split(',')
.map((h) => h.trim())
.filter(Boolean);
for (const header of headerList) {
ports.push({
name: 'header-' + header,
displayName: header,
type: 'string',
plug: 'input',
group: 'Headers'
});
}
}
// Query parameters configuration
// Query parameters configuration - comma-separated list
ports.push({
name: 'queryParams',
displayName: 'Query Parameters',
@@ -614,20 +628,44 @@ function updatePorts(nodeId, parameters, editorConnection) {
});
// Generate input ports for each query param
if (parameters.queryParams && Array.isArray(parameters.queryParams)) {
for (const param of parameters.queryParams) {
if (param.key) {
ports.push({
name: 'query-' + param.key,
displayName: param.key,
type: '*',
plug: 'input',
group: 'Query Parameters'
});
}
if (parameters.queryParams) {
const queryList = parameters.queryParams
.split(',')
.map((q) => q.trim())
.filter(Boolean);
for (const param of queryList) {
ports.push({
name: 'query-' + param,
displayName: param,
type: 'string',
plug: 'input',
group: 'Query Parameters'
});
}
}
// Method selector as dynamic port (so we can track parameter changes properly)
ports.push({
name: 'method',
displayName: 'Method',
type: {
name: 'enum',
enums: [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' }
],
allowEditOnly: true
},
default: 'GET',
plug: 'input',
group: 'Request'
});
// Body type selector (only shown for POST/PUT/PATCH)
const method = parameters.method || 'GET';
if (['POST', 'PUT', 'PATCH'].includes(method)) {
@@ -649,7 +687,7 @@ function updatePorts(nodeId, parameters, editorConnection) {
group: 'Body'
});
// Body fields configuration
// Body fields configuration - comma-separated list
const bodyType = parameters.bodyType || 'json';
if (bodyType === 'json' || bodyType === 'form' || bodyType === 'urlencoded') {
ports.push({
@@ -660,25 +698,56 @@ function updatePorts(nodeId, parameters, editorConnection) {
group: 'Body'
});
// Generate input ports for each body field
if (parameters.bodyFields && Array.isArray(parameters.bodyFields)) {
for (const field of parameters.bodyFields) {
if (field.key) {
ports.push({
name: 'body-' + field.key,
displayName: field.key,
type: '*',
plug: 'input',
group: 'Body'
});
}
// Type options for body fields
const _types = [
{ label: 'String', value: 'string' },
{ label: 'Number', value: 'number' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Array', value: 'array' },
{ label: 'Object', value: 'object' },
{ label: 'Any', value: '*' }
];
// Generate type selector and value input ports for each body field
if (parameters.bodyFields) {
const fieldList = parameters.bodyFields
.split(',')
.map((f) => f.trim())
.filter(Boolean);
for (const field of fieldList) {
// Get the selected type for this field (default to string)
const fieldType = parameters['body-type-' + field] || 'string';
// Type selector for this field
ports.push({
name: 'body-type-' + field,
displayName: field + ' Type',
type: {
name: 'enum',
enums: _types,
allowEditOnly: true
},
default: 'string',
plug: 'input',
group: 'Body'
});
// Value input for this field (type matches selected type)
ports.push({
name: 'body-' + field,
displayName: field,
type: fieldType,
plug: 'input',
group: 'Body'
});
}
}
} else if (bodyType === 'raw') {
// Raw body - use code editor with JSON syntax
ports.push({
name: 'body-raw',
displayName: 'Body',
type: 'string',
type: { name: 'string', allowEditOnly: true, codeeditor: 'json' },
plug: 'input',
group: 'Body'
});
@@ -760,41 +829,28 @@ function updatePorts(nodeId, parameters, editorConnection) {
});
}
// Response mapping
ports.push({
name: 'responseMapping',
displayName: 'Response Mapping',
type: { name: 'stringlist', allowEditOnly: true },
plug: 'input',
group: 'Response Mapping'
});
// Generate output ports for each response mapping
if (parameters.responseMapping && Array.isArray(parameters.responseMapping)) {
for (const mapping of parameters.responseMapping) {
if (mapping.name) {
ports.push({
name: 'out-' + mapping.name,
displayName: mapping.name,
type: '*',
plug: 'output',
group: 'Response'
});
}
}
}
// Timeout
// Timeout setting
ports.push({
name: 'timeout',
displayName: 'Timeout (ms)',
type: 'number',
default: 30000,
plug: 'input',
group: 'Settings'
group: 'Request'
});
// Actions
// Response mapping - add output names, then specify JSONPath for each
// User adds output names like: userId, userName, totalCount
// For each name, we generate a "Path" input and an output port
ports.push({
name: 'responseMapping',
displayName: 'Output Fields',
type: { name: 'stringlist', allowEditOnly: true },
plug: 'input',
group: 'Response Mapping'
});
// Signal inputs - MUST be in dynamic ports for editor to show them
ports.push({
name: 'fetch',
displayName: 'Fetch',
@@ -811,6 +867,44 @@ function updatePorts(nodeId, parameters, editorConnection) {
group: 'Actions'
});
// URL input - also needs to be in dynamic ports
ports.push({
name: 'url',
displayName: 'URL',
type: 'string',
plug: 'input',
group: 'Request'
});
// Generate path input ports and output ports for each response mapping
if (parameters.responseMapping && typeof parameters.responseMapping === 'string') {
const outputNames = parameters.responseMapping
.split(',')
.map((m) => m.trim())
.filter(Boolean);
for (const name of outputNames) {
// Path input port for specifying JSONPath (e.g., $.data.id)
ports.push({
name: 'mapping-path-' + name,
displayName: name + ' Path',
type: 'string',
default: '$',
plug: 'input',
group: 'Response Mapping'
});
// Output port for the extracted value
ports.push({
name: 'out-' + name,
displayName: name,
type: '*',
plug: 'output',
group: 'Response'
});
}
}
// Standard outputs
ports.push({
name: 'response',
@@ -891,7 +985,8 @@ module.exports = {
event.name === 'bodyType' ||
event.name === 'bodyFields' ||
event.name === 'authType' ||
event.name === 'responseMapping'
event.name === 'responseMapping' ||
event.name.startsWith('body-type-') // Body field type changes
) {
updatePorts(node.id, node.parameters, context.editorConnection);
}

View File

@@ -60,6 +60,9 @@ export default function registerNodes(noodlRuntime) {
require('./nodes/std-library/colorblend'),
require('./nodes/std-library/animate-to-value'),
// HTTP node - temporarily here for debugging (normally in noodl-runtime)
require('@noodl/runtime/src/nodes/std-library/data/httpnode'),
//require('./nodes/std-library/variables/number'), // moved to runtime
//require('./nodes/std-library/variables/string'),
//require('./nodes/std-library/variables/boolean'),