diff --git a/.clinerules b/.clinerules index 2765713..378c2e6 100644 --- a/.clinerules +++ b/.clinerules @@ -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) | + +--- diff --git a/dev-docs/reference/LEARNINGS-NODE-CREATION.md b/dev-docs/reference/LEARNINGS-NODE-CREATION.md index 74a8301..3fce516 100644 --- a/dev-docs/reference/LEARNINGS-NODE-CREATION.md +++ b/dev-docs/reference/LEARNINGS-NODE-CREATION.md @@ -34,39 +34,39 @@ packages/noodl-runtime/src/nodes/std-library/ var MyNode = { // REQUIRED: Unique identifier for the node name: 'net.noodl.MyNode', - + // REQUIRED: Display name in Node Picker and canvas displayNodeName: 'My Node', - + // OPTIONAL: Documentation URL docs: 'https://docs.noodl.net/nodes/category/my-node', - + // REQUIRED: Category for organization (Data, Visual, Logic, etc.) category: 'Data', - + // OPTIONAL: Node color theme // Options: 'data' (green), 'visual' (blue), 'component' (purple), 'javascript' (pink), 'default' (gray) color: 'data', - + // OPTIONAL: Search keywords for Node Picker searchTags: ['my', 'node', 'custom', 'example'], - + // OPTIONAL: Called when node instance is created initialize: function () { this._internal.myData = {}; }, - + // OPTIONAL: Data shown in debug inspector getInspectInfo() { return this._internal.inspectData; }, - + // 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: { @@ -75,7 +75,7 @@ var MyNode = { group: 'Actions' } }, - + // REQUIRED: Define output ports outputs: { outputValue: { @@ -94,7 +94,7 @@ var MyNode = { group: 'Events' } }, - + // OPTIONAL: Methods to handle input changes methods: { setInputName: function (value) { @@ -102,19 +102,19 @@ var MyNode = { // Optionally trigger output update this.flagOutputDirty('outputValue'); }, - + // Signal handler - name must match input name with 'Trigger' suffix doActionTrigger: function () { // Perform the action const result = this.processInput(this._internal.inputName); this._internal.outputValue = result; - + // Update outputs this.flagOutputDirty('outputValue'); this.sendSignalOnOutput('success'); } }, - + // OPTIONAL: Return output values getOutputValue: function (name) { if (name === 'outputValue') { @@ -126,7 +126,7 @@ var MyNode = { // REQUIRED: Export the node module.exports = { node: MyNode, - + // OPTIONAL: Setup function for dynamic ports setup: function (context, graphModel) { // See "Dynamic Ports" section below @@ -144,10 +144,10 @@ Add the node to the `registerNodes` function in `packages/noodl-runtime/noodl-ru 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 @@ -239,7 +239,7 @@ Dynamic ports are created at runtime based on configuration. This is useful when ```javascript module.exports = { node: MyNode, - + setup: function (context, graphModel) { // Only run in editor, not deployed if (!context.editorConnection || !context.editorConnection.isRunningLocally()) { @@ -248,7 +248,7 @@ module.exports = { function updatePorts(nodeId, parameters, editorConnection) { const ports = []; - + // Always include base ports from node definition // Add dynamic ports based on parameters if (parameters.items) { @@ -262,7 +262,7 @@ module.exports = { }); }); } - + // Send ports to editor editorConnection.sendDynamicPorts(nodeId, ports); } @@ -348,7 +348,7 @@ For asynchronous operations (API calls, file I/O), use standard async patterns: methods: { fetchTrigger: function () { const self = this; - + fetch(this._internal.url) .then(response => response.json()) .then(data => { @@ -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_ diff --git a/dev-docs/reference/LEARNINGS.md b/dev-docs/reference/LEARNINGS.md index ef30a52..8ad42b6 100644 --- a/dev-docs/reference/LEARNINGS.md +++ b/dev-docs/reference/LEARNINGS.md @@ -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 diff --git a/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/ComponentItem.tsx b/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/ComponentItem.tsx index d880b4d..be9d19f 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/ComponentItem.tsx +++ b/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/ComponentItem.tsx @@ -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] diff --git a/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/FolderItem.tsx b/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/FolderItem.tsx index 5b59515..66eb47e 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/FolderItem.tsx +++ b/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/components/FolderItem.tsx @@ -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] diff --git a/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts b/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts index fb8910a..f3cc6a4 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts +++ b/packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentActions.ts @@ -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({ diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/StringList/stringlist.js b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/StringList/stringlist.js index 90cb1b5..c4eab5f 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/StringList/stringlist.js +++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/StringList/stringlist.js @@ -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; } } diff --git a/packages/noodl-runtime/noodl-runtime.js b/packages/noodl-runtime/noodl-runtime.js index 246a597..5d53491 100644 --- a/packages/noodl-runtime/noodl-runtime.js +++ b/packages/noodl-runtime/noodl-runtime.js @@ -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'), diff --git a/packages/noodl-runtime/src/edgetriggeredinput.js b/packages/noodl-runtime/src/edgetriggeredinput.js index e2c5c87..0dbab46 100644 --- a/packages/noodl-runtime/src/edgetriggeredinput.js +++ b/packages/noodl-runtime/src/edgetriggeredinput.js @@ -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 -}; \ No newline at end of file + createSetter: createSetter +}; diff --git a/packages/noodl-runtime/src/node.js b/packages/noodl-runtime/src/node.js index cbe4e54..a2d285b 100644 --- a/packages/noodl-runtime/src/node.js +++ b/packages/noodl-runtime/src/node.js @@ -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 diff --git a/packages/noodl-runtime/src/nodedefinition.js b/packages/noodl-runtime/src/nodedefinition.js index 99cc542..8b76ef9 100644 --- a/packages/noodl-runtime/src/nodedefinition.js +++ b/packages/noodl-runtime/src/nodedefinition.js @@ -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 diff --git a/packages/noodl-runtime/src/nodes/std-library/data/httpnode.js b/packages/noodl-runtime/src/nodes/std-library/data/httpnode.js index 48adda4..2aeca1b 100644 --- a/packages/noodl-runtime/src/nodes/std-library/data/httpnode.js +++ b/packages/noodl-runtime/src/nodes/std-library/data/httpnode.js @@ -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); } diff --git a/packages/noodl-viewer-react/src/register-nodes.js b/packages/noodl-viewer-react/src/register-nodes.js index 90981d6..6c660bc 100644 --- a/packages/noodl-viewer-react/src/register-nodes.js +++ b/packages/noodl-viewer-react/src/register-nodes.js @@ -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'),