mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
Phase 5 - BYOB Backend (TASK-007A/B): - LocalSQL Adapter with full CloudStore API compatibility - QueryBuilder translates Parse-style queries to SQL - SchemaManager with PostgreSQL/Supabase export - LocalBackendServer with REST endpoints - BackendManager with IPC handlers for Electron - In-memory fallback when better-sqlite3 unavailable Phase 3 - GitHub Panel (GIT-004): - Issues tab with list/detail views - Pull Requests tab with list/detail views - GitHub API client with OAuth support - Repository info hook integration Phase 3 - Editor UX Bugfixes (TASK-013): - Legacy runtime detection banners - Read-only enforcement for legacy projects - Code editor modal close improvements - Property panel stuck state fix - Blockly node deletion and UI polish Phase 11 - Cloud Functions Planning: - Architecture documentation for workflow automation - Execution history storage schema design - Canvas overlay concept for debugging Docs: Updated LEARNINGS.md and COMMON-ISSUES.md
442 lines
11 KiB
Markdown
442 lines
11 KiB
Markdown
# CF11-001: Logic Nodes (IF/Switch/ForEach/Merge)
|
|
|
|
## Metadata
|
|
|
|
| Field | Value |
|
|
| ------------------ | ------------------------------------ |
|
|
| **ID** | CF11-001 |
|
|
| **Phase** | Phase 11 |
|
|
| **Series** | 1 - Advanced Workflow Nodes |
|
|
| **Priority** | 🟡 High |
|
|
| **Difficulty** | 🟡 Medium |
|
|
| **Estimated Time** | 12-16 hours |
|
|
| **Prerequisites** | Phase 5 TASK-007C (Workflow Runtime) |
|
|
| **Branch** | `feature/cf11-001-logic-nodes` |
|
|
|
|
## Objective
|
|
|
|
Create advanced workflow logic nodes that enable conditional branching, multi-way routing, array iteration, and parallel execution paths - essential for building non-trivial automation workflows.
|
|
|
|
## Background
|
|
|
|
Current Noodl nodes are designed for UI and data flow but lack the control-flow constructs needed for workflow automation. To compete with n8n/Zapier, we need:
|
|
|
|
- **IF Node**: Route data based on conditions
|
|
- **Switch Node**: Multi-way branching (like a switch statement)
|
|
- **For Each Node**: Iterate over arrays
|
|
- **Merge Node**: Combine multiple execution paths
|
|
|
|
## Current State
|
|
|
|
- Basic condition node exists but isn't suited for workflows
|
|
- No iteration nodes
|
|
- No way to merge parallel execution paths
|
|
|
|
## Desired State
|
|
|
|
- IF node with visual expression builder
|
|
- Switch node with multiple case outputs
|
|
- For Each node for array iteration
|
|
- Merge node to combine paths
|
|
- All nodes work in CloudRunner workflow context
|
|
|
|
## Scope
|
|
|
|
### In Scope
|
|
|
|
- [ ] IF Node implementation
|
|
- [ ] Switch Node implementation
|
|
- [ ] For Each Node implementation
|
|
- [ ] Merge Node implementation
|
|
- [ ] Property editor integrations
|
|
- [ ] CloudRunner execution support
|
|
|
|
### Out of Scope
|
|
|
|
- UI runtime nodes (frontend-only)
|
|
- Visual expression builder (can use existing or defer)
|
|
|
|
## Technical Approach
|
|
|
|
### IF Node
|
|
|
|
Routes execution based on a boolean condition.
|
|
|
|
```typescript
|
|
// packages/noodl-viewer-cloud/src/nodes/logic/IfNode.ts
|
|
|
|
const IfNode = {
|
|
name: 'Workflow IF',
|
|
displayName: 'IF',
|
|
category: 'Workflow Logic',
|
|
color: 'logic',
|
|
|
|
inputs: {
|
|
condition: {
|
|
type: 'boolean',
|
|
displayName: 'Condition',
|
|
description: 'Boolean expression to evaluate'
|
|
},
|
|
data: {
|
|
type: '*',
|
|
displayName: 'Data',
|
|
description: 'Data to pass through'
|
|
},
|
|
run: {
|
|
type: 'signal',
|
|
displayName: 'Run',
|
|
description: 'Trigger to evaluate condition'
|
|
}
|
|
},
|
|
|
|
outputs: {
|
|
onTrue: {
|
|
type: 'signal',
|
|
displayName: 'True',
|
|
description: 'Triggered when condition is true'
|
|
},
|
|
onFalse: {
|
|
type: 'signal',
|
|
displayName: 'False',
|
|
description: 'Triggered when condition is false'
|
|
},
|
|
data: {
|
|
type: '*',
|
|
displayName: 'Data',
|
|
description: 'Pass-through data'
|
|
}
|
|
},
|
|
|
|
run(context) {
|
|
const condition = context.inputs.condition;
|
|
context.outputs.data = context.inputs.data;
|
|
|
|
if (condition) {
|
|
context.triggerOutput('onTrue');
|
|
} else {
|
|
context.triggerOutput('onFalse');
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
### Switch Node
|
|
|
|
Routes to one of multiple outputs based on value matching.
|
|
|
|
```typescript
|
|
// packages/noodl-viewer-cloud/src/nodes/logic/SwitchNode.ts
|
|
|
|
const SwitchNode = {
|
|
name: 'Workflow Switch',
|
|
displayName: 'Switch',
|
|
category: 'Workflow Logic',
|
|
color: 'logic',
|
|
|
|
inputs: {
|
|
value: {
|
|
type: '*',
|
|
displayName: 'Value',
|
|
description: 'Value to switch on'
|
|
},
|
|
data: {
|
|
type: '*',
|
|
displayName: 'Data'
|
|
},
|
|
run: {
|
|
type: 'signal',
|
|
displayName: 'Run'
|
|
}
|
|
},
|
|
|
|
outputs: {
|
|
default: {
|
|
type: 'signal',
|
|
displayName: 'Default',
|
|
description: 'Triggered if no case matches'
|
|
},
|
|
data: {
|
|
type: '*',
|
|
displayName: 'Data'
|
|
}
|
|
},
|
|
|
|
// Dynamic outputs for cases - configured via property panel
|
|
dynamicports: {
|
|
outputs: {
|
|
cases: {
|
|
type: 'signal'
|
|
// Generated from cases array: case_0, case_1, etc.
|
|
}
|
|
}
|
|
},
|
|
|
|
setup(context) {
|
|
// Register cases from configuration
|
|
const cases = context.parameters.cases || [];
|
|
cases.forEach((caseValue, index) => {
|
|
context.registerOutput(`case_${index}`, {
|
|
type: 'signal',
|
|
displayName: `Case: ${caseValue}`
|
|
});
|
|
});
|
|
},
|
|
|
|
run(context) {
|
|
const value = context.inputs.value;
|
|
const cases = context.parameters.cases || [];
|
|
context.outputs.data = context.inputs.data;
|
|
|
|
const matchIndex = cases.indexOf(value);
|
|
if (matchIndex >= 0) {
|
|
context.triggerOutput(`case_${matchIndex}`);
|
|
} else {
|
|
context.triggerOutput('default');
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
### For Each Node
|
|
|
|
Iterates over an array, executing the output for each item.
|
|
|
|
```typescript
|
|
// packages/noodl-viewer-cloud/src/nodes/logic/ForEachNode.ts
|
|
|
|
const ForEachNode = {
|
|
name: 'Workflow For Each',
|
|
displayName: 'For Each',
|
|
category: 'Workflow Logic',
|
|
color: 'logic',
|
|
|
|
inputs: {
|
|
items: {
|
|
type: 'array',
|
|
displayName: 'Items',
|
|
description: 'Array to iterate over'
|
|
},
|
|
run: {
|
|
type: 'signal',
|
|
displayName: 'Run'
|
|
}
|
|
},
|
|
|
|
outputs: {
|
|
iteration: {
|
|
type: 'signal',
|
|
displayName: 'For Each Item',
|
|
description: 'Triggered for each item'
|
|
},
|
|
currentItem: {
|
|
type: '*',
|
|
displayName: 'Current Item'
|
|
},
|
|
currentIndex: {
|
|
type: 'number',
|
|
displayName: 'Index'
|
|
},
|
|
completed: {
|
|
type: 'signal',
|
|
displayName: 'Completed',
|
|
description: 'Triggered when iteration is complete'
|
|
},
|
|
allResults: {
|
|
type: 'array',
|
|
displayName: 'Results',
|
|
description: 'Collected results from all iterations'
|
|
}
|
|
},
|
|
|
|
async run(context) {
|
|
const items = context.inputs.items || [];
|
|
const results = [];
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
context.outputs.currentItem = items[i];
|
|
context.outputs.currentIndex = i;
|
|
|
|
// Trigger and wait for downstream to complete
|
|
const result = await context.triggerOutputAndWait('iteration');
|
|
if (result !== undefined) {
|
|
results.push(result);
|
|
}
|
|
}
|
|
|
|
context.outputs.allResults = results;
|
|
context.triggerOutput('completed');
|
|
}
|
|
};
|
|
```
|
|
|
|
### Merge Node
|
|
|
|
Waits for all input paths before continuing.
|
|
|
|
```typescript
|
|
// packages/noodl-viewer-cloud/src/nodes/logic/MergeNode.ts
|
|
|
|
const MergeNode = {
|
|
name: 'Workflow Merge',
|
|
displayName: 'Merge',
|
|
category: 'Workflow Logic',
|
|
color: 'logic',
|
|
|
|
inputs: {
|
|
// Dynamic inputs based on configuration
|
|
},
|
|
|
|
outputs: {
|
|
merged: {
|
|
type: 'signal',
|
|
displayName: 'Merged',
|
|
description: 'Triggered when all inputs received'
|
|
},
|
|
data: {
|
|
type: 'object',
|
|
displayName: 'Data',
|
|
description: 'Combined data from all inputs'
|
|
}
|
|
},
|
|
|
|
dynamicports: {
|
|
inputs: {
|
|
branches: {
|
|
type: 'signal'
|
|
// Generated: branch_0, branch_1, etc.
|
|
},
|
|
branchData: {
|
|
type: '*'
|
|
// Generated: data_0, data_1, etc.
|
|
}
|
|
}
|
|
},
|
|
|
|
setup(context) {
|
|
const branchCount = context.parameters.branchCount || 2;
|
|
context._receivedBranches = new Set();
|
|
context._branchData = {};
|
|
|
|
for (let i = 0; i < branchCount; i++) {
|
|
context.registerInput(`branch_${i}`, {
|
|
type: 'signal',
|
|
displayName: `Branch ${i + 1}`
|
|
});
|
|
context.registerInput(`data_${i}`, {
|
|
type: '*',
|
|
displayName: `Data ${i + 1}`
|
|
});
|
|
}
|
|
},
|
|
|
|
onInputChange(context, inputName, value) {
|
|
if (inputName.startsWith('branch_')) {
|
|
const index = parseInt(inputName.split('_')[1]);
|
|
context._receivedBranches.add(index);
|
|
context._branchData[index] = context.inputs[`data_${index}`];
|
|
|
|
const branchCount = context.parameters.branchCount || 2;
|
|
if (context._receivedBranches.size >= branchCount) {
|
|
context.outputs.data = { ...context._branchData };
|
|
context.triggerOutput('merged');
|
|
|
|
// Reset for next execution
|
|
context._receivedBranches.clear();
|
|
context._branchData = {};
|
|
}
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
### Key Files to Create
|
|
|
|
| File | Purpose |
|
|
| ---------------------------- | ------------------------ |
|
|
| `nodes/logic/IfNode.ts` | IF node definition |
|
|
| `nodes/logic/SwitchNode.ts` | Switch node definition |
|
|
| `nodes/logic/ForEachNode.ts` | For Each node definition |
|
|
| `nodes/logic/MergeNode.ts` | Merge node definition |
|
|
| `nodes/logic/index.ts` | Module exports |
|
|
| `tests/logic-nodes.test.ts` | Unit tests |
|
|
|
|
## Implementation Steps
|
|
|
|
### Step 1: IF Node (3h)
|
|
|
|
1. Create node definition
|
|
2. Implement run logic
|
|
3. Add to node registry
|
|
4. Test with CloudRunner
|
|
|
|
### Step 2: Switch Node (4h)
|
|
|
|
1. Create node with dynamic ports
|
|
2. Implement case matching logic
|
|
3. Property editor for case configuration
|
|
4. Test edge cases
|
|
|
|
### Step 3: For Each Node (4h)
|
|
|
|
1. Create node definition
|
|
2. Implement async iteration
|
|
3. Handle `triggerOutputAndWait` pattern
|
|
4. Test with arrays and objects
|
|
|
|
### Step 4: Merge Node (3h)
|
|
|
|
1. Create node with dynamic inputs
|
|
2. Implement branch tracking
|
|
3. Handle reset logic
|
|
4. Test parallel paths
|
|
|
|
### Step 5: Integration & Testing (2h)
|
|
|
|
1. Register all nodes
|
|
2. Integration tests
|
|
3. Manual testing in editor
|
|
|
|
## Testing Plan
|
|
|
|
### Unit Tests
|
|
|
|
- [ ] IF Node routes correctly on true/false
|
|
- [ ] Switch Node matches correct case
|
|
- [ ] Switch Node triggers default when no match
|
|
- [ ] For Each iterates all items
|
|
- [ ] For Each handles empty arrays
|
|
- [ ] For Each collects results
|
|
- [ ] Merge waits for all branches
|
|
- [ ] Merge combines data correctly
|
|
|
|
### Integration Tests
|
|
|
|
- [ ] IF → downstream nodes execute correctly
|
|
- [ ] Switch → multiple paths work
|
|
- [ ] For Each → nested workflows work
|
|
- [ ] Merge → parallel execution converges
|
|
|
|
## Success Criteria
|
|
|
|
- [ ] IF Node works in CloudRunner
|
|
- [ ] Switch Node with dynamic cases works
|
|
- [ ] For Each iterates and collects results
|
|
- [ ] Merge combines parallel paths
|
|
- [ ] All nodes appear in node picker
|
|
- [ ] Property panels work correctly
|
|
- [ ] All tests pass
|
|
|
|
## Risks & Mitigations
|
|
|
|
| Risk | Mitigation |
|
|
| ------------------------ | --------------------------------------- |
|
|
| For Each performance | Limit max iterations, batch if needed |
|
|
| Merge race conditions | Use Set for tracking, atomic operations |
|
|
| Dynamic ports complexity | Follow existing patterns from BYOB |
|
|
|
|
## References
|
|
|
|
- [Node Patterns Guide](../../../reference/NODE-PATTERNS.md)
|
|
- [LEARNINGS-NODE-CREATION](../../../reference/LEARNINGS-NODE-CREATION.md)
|
|
- [n8n IF Node](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.if/) - Reference
|