From ae7d3b8a8bba7ade5adde34d996ec9e5377e465a Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Tue, 30 Dec 2025 11:55:30 +0100 Subject: [PATCH] New data query node for Directus backend integration --- .clinerules | 1 + dev-docs/reference/LEARNINGS-NODE-CREATION.md | 100 +- dev-docs/reference/LEARNINGS.md | 122 ++ .../CONFIG-001-infrastructure.md | 546 +++++++++ .../CONFIG-002-app-setup-panel.md | 944 +++++++++++++++ .../CONFIG-003-app-config-node.md | 522 +++++++++ .../CONFIG-004-seo-integration.md | 397 +++++++ .../CONFIG-005-pwa-manifest.md | 471 ++++++++ .../CONFIG-006-expression-integration.md | 403 +++++++ .../TASK-007-app-config/README.md | 367 ++++++ .../AGENT-001-sse-node-task.md | 739 ++++++++++++ .../AGENT-002-websocket-node-task.md | 923 +++++++++++++++ .../AGENT-003-global-state-store-task.md | 1042 +++++++++++++++++ .../AGENT-004-optimistic-updates-task.md | 883 ++++++++++++++ .../AGENT-005-action-dispatcher-task.md | 1003 ++++++++++++++++ .../AGENT-006-state-history-task.md | 786 +++++++++++++ .../AGENT-007-stream-parser-utilities-task.md | 877 ++++++++++++++ .../noodl-erleah-capability-analysis.md | 826 +++++++++++++ .../01-byob-backend/README.md | 585 +++++---- .../TASK-001-backend-services-panel/README.md | 135 +++ .../TASK-002-data-nodes/FILTER-BUILDER.md | 205 ++++ .../TASK-002-data-nodes/NODES-SPEC.md | 213 ++++ .../TASK-002-data-nodes/README.md | 87 ++ .../TASK-003-schema-viewer/README.md | 158 +++ .../TASK-004-edit-backend-dialog/README.md | 183 +++ .../TASK-005-local-docker-wizard/README.md | 270 +++++ .../models/BackendServices/BackendServices.ts | 627 ++++++++++ .../src/models/BackendServices/index.ts | 13 + .../src/models/BackendServices/presets.ts | 293 +++++ .../src/models/BackendServices/types.ts | 313 +++++ .../src/editor/src/router.setup.ts | 12 +- .../AddBackendDialog.module.scss | 151 +++ .../AddBackendDialog/AddBackendDialog.tsx | 341 ++++++ .../BackendCard/BackendCard.module.scss | 59 + .../BackendCard/BackendCard.tsx | 146 +++ .../BackendServicesPanel.tsx | 172 +++ .../DataTypes/ByobFilterType.ts | 99 ++ .../panels/propertyeditor/DataTypes/Ports.ts | 6 + .../ByobFilterBuilder.module.scss | 350 ++++++ .../ByobFilterBuilder/ByobFilterBuilder.tsx | 288 +++++ .../ByobFilterBuilder/DragContext.tsx | 167 +++ .../ByobFilterBuilder/FilterBuilderButton.tsx | 72 ++ .../ByobFilterBuilder/FilterBuilderModal.tsx | 82 ++ .../ByobFilterBuilder/FilterCondition.tsx | 241 ++++ .../ByobFilterBuilder/FilterGroup.tsx | 347 ++++++ .../components/ByobFilterBuilder/converter.ts | 220 ++++ .../components/ByobFilterBuilder/index.ts | 51 + .../components/ByobFilterBuilder/operators.ts | 185 +++ .../components/ByobFilterBuilder/types.ts | 236 ++++ .../noodl-runtime/src/nodelibraryexport.js | 4 + .../nodes/std-library/data/byob-query-data.js | 835 +++++++++++++ .../noodl-viewer-react/src/register-nodes.js | 3 + 52 files changed, 17798 insertions(+), 303 deletions(-) create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-001-infrastructure.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-002-app-setup-panel.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-003-app-config-node.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-004-seo-integration.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-005-pwa-manifest.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-006-expression-integration.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/README.md create mode 100644 dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-001-sse-node-task.md create mode 100644 dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-002-websocket-node-task.md create mode 100644 dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-003-global-state-store-task.md create mode 100644 dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-004-optimistic-updates-task.md create mode 100644 dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-005-action-dispatcher-task.md create mode 100644 dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-006-state-history-task.md create mode 100644 dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-007-stream-parser-utilities-task.md create mode 100644 dev-docs/tasks/phase-3.5-realtime-agentic-ui/noodl-erleah-capability-analysis.md create mode 100644 dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-001-backend-services-panel/README.md create mode 100644 dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/FILTER-BUILDER.md create mode 100644 dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/NODES-SPEC.md create mode 100644 dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/README.md create mode 100644 dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-003-schema-viewer/README.md create mode 100644 dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-004-edit-backend-dialog/README.md create mode 100644 dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-005-local-docker-wizard/README.md create mode 100644 packages/noodl-editor/src/editor/src/models/BackendServices/BackendServices.ts create mode 100644 packages/noodl-editor/src/editor/src/models/BackendServices/index.ts create mode 100644 packages/noodl-editor/src/editor/src/models/BackendServices/presets.ts create mode 100644 packages/noodl-editor/src/editor/src/models/BackendServices/types.ts create mode 100644 packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/AddBackendDialog/AddBackendDialog.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/AddBackendDialog/AddBackendDialog.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendCard/BackendCard.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendCard/BackendCard.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendServicesPanel.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ByobFilterType.ts create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/ByobFilterBuilder.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/ByobFilterBuilder.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/DragContext.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/FilterBuilderButton.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/FilterBuilderModal.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/FilterCondition.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/FilterGroup.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/converter.ts create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/index.ts create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/operators.ts create mode 100644 packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/types.ts create mode 100644 packages/noodl-runtime/src/nodes/std-library/data/byob-query-data.js diff --git a/.clinerules b/.clinerules index 378c2e6..9e8814d 100644 --- a/.clinerules +++ b/.clinerules @@ -941,6 +941,7 @@ Creating nodes in OpenNoodl is deceptively tricky. This checklist prevents the m Before writing any node code: - [ ] Read `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (especially the CRITICAL GOTCHAS section) +- [ ] Check `dev-docs/reference/LEARNINGS.md` for recent node-related discoveries (search for "node", "runtime", "coreNodes") - [ ] 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) diff --git a/dev-docs/reference/LEARNINGS-NODE-CREATION.md b/dev-docs/reference/LEARNINGS-NODE-CREATION.md index 3fce516..3872ba4 100644 --- a/dev-docs/reference/LEARNINGS-NODE-CREATION.md +++ b/dev-docs/reference/LEARNINGS-NODE-CREATION.md @@ -601,7 +601,7 @@ inputs: { --- -### 🔴 GOTCHA #5: Node Registration Path Matters +### 🔴 GOTCHA #5: Node Registration Path Matters (Signals Not Wrapping) **THE BUG:** @@ -633,6 +633,104 @@ module.exports = NodeDefinition.defineNode(MyNode); --- +### 🔴 GOTCHA #6: Signal in Static `inputs` + Dynamic Ports = Duplicate Ports (Dec 2025) + +**THE BUG:** + +```javascript +// Signal defined in static inputs with handler +inputs: { + fetch: { + type: 'signal', + valueChangedToTrue: function() { this.scheduleFetch(); } + } +} + +// updatePorts() ALSO adds fetch - CAUSES DUPLICATE! +function updatePorts(nodeId, parameters, editorConnection) { + const ports = []; + // ... other ports ... + ports.push({ name: 'fetch', type: 'signal', plug: 'input' }); // ❌ Duplicate! + editorConnection.sendDynamicPorts(nodeId, ports); +} +``` + +**SYMPTOM:** When trying to connect to the node, TWO "Fetch" signals appear in the connection popup. + +**WHY IT BREAKS:** + +- GOTCHA #2 says "include static ports in dynamic ports" which is true for MOST ports +- But signals with `valueChangedToTrue` handlers ALREADY have a runtime registration +- Adding them again in `updatePorts()` creates a duplicate visual port +- The handler still works, but UX is confusing + +**THE FIX:** + +```javascript +// ✅ CORRECT - Only define signal in static inputs, NOT in updatePorts() +inputs: { + fetch: { + type: 'signal', + valueChangedToTrue: function() { this.scheduleFetch(); } + } +} + +function updatePorts(nodeId, parameters, editorConnection) { + const ports = []; + // ... dynamic ports ... + + // NOTE: 'fetch' signal is defined in static inputs (with valueChangedToTrue handler) + // DO NOT add it here again or it will appear twice in the connection popup + + // ... other ports ... + editorConnection.sendDynamicPorts(nodeId, ports); +} +``` + +**RULE:** Signals with `valueChangedToTrue` handlers → ONLY in static `inputs`. All other ports (value inputs, outputs) → in `updatePorts()` dynamic ports. + +--- + +### 🔴 GOTCHA #7: Require Path Depth for noodl-runtime (Dec 2025) + +**THE BUG:** + +```javascript +// File: src/nodes/std-library/data/mynode.js +// Trying to require noodl-runtime.js at package root + +const NoodlRuntime = require('../../../noodl-runtime'); // ❌ WRONG - only 3 levels +// This breaks the entire runtime with "Cannot find module" error +``` + +**WHY IT MATTERS:** + +- From `src/nodes/std-library/data/` you need to go UP 4 levels to reach the package root +- Path: data → std-library → nodes → src → (package root) +- One wrong `../` and the entire app fails to load + +**THE FIX:** + +```javascript +// ✅ CORRECT - Count the directories carefully +// From src/nodes/std-library/data/mynode.js: +const NoodlRuntime = require('../../../../noodl-runtime'); // 4 levels + +// Reference: cloudstore.js at src/api/ uses 2 levels: +const NoodlRuntime = require('../../noodl-runtime'); // 2 levels from src/api/ +``` + +**Quick Reference:** + +| File Location | Levels to Package Root | Require Path | +| ----------------------------- | ---------------------- | --------------------------- | +| `src/api/` | 2 | `../../noodl-runtime` | +| `src/nodes/` | 2 | `../../noodl-runtime` | +| `src/nodes/std-library/` | 3 | `../../../noodl-runtime` | +| `src/nodes/std-library/data/` | 4 | `../../../../noodl-runtime` | + +--- + ## Complete Working Pattern (HTTP Node Reference) Here's the proven pattern from the HTTP node that handles all gotchas: diff --git a/dev-docs/reference/LEARNINGS.md b/dev-docs/reference/LEARNINGS.md index 8ad42b6..c184342 100644 --- a/dev-docs/reference/LEARNINGS.md +++ b/dev-docs/reference/LEARNINGS.md @@ -1071,3 +1071,125 @@ PopupLayer.prototype.dragCompleted = function () { **Keywords**: PopupLayer, dragCompleted, endDrag, TypeError, drag-and-drop, method name, API --- + +## NoodlRuntime.instance.getMetaData() Pattern for Project Data (Dec 2025) + +### How Runtime Nodes Access Project Metadata + +**Context**: BYOB Query Data node needed to access backend services configuration (URLs, tokens, schema) from runtime code. + +**The Pattern**: Use `NoodlRuntime.instance.getMetaData(key)` to access project metadata stored in graphModel. + +**Working Example** (from byob-query-data.js): + +```javascript +const NoodlRuntime = require('../../../../noodl-runtime'); + +resolveBackend: function() { + // Get metadata - same pattern as cloudstore.js uses for cloudservices + const backendServices = NoodlRuntime.instance.getMetaData('backendServices'); + + if (!backendServices || !backendServices.backends) { + console.log('[BYOB Query Data] No backend services metadata found'); + console.log('[BYOB Query Data] Available metadata keys:', + Object.keys(NoodlRuntime.instance.metadata || {})); + return null; + } + + // Access the data + const backends = backendServices.backends || []; + const activeBackendId = backendServices.activeBackendId; + + // Find and use the backend config... +} +``` + +**Reference Implementation** (from `src/api/cloudstore.js`): + +```javascript +const NoodlRuntime = require('../../noodl-runtime'); + +// Access cloud services config +const cloudServices = NoodlRuntime.instance.getMetaData('cloudservices'); +``` + +**How It Works**: + +- `NoodlRuntime.prototype.getMetaData(key)` delegates to `this.graphModel.getMetaData(key)` +- Metadata is stored in the project file and loaded into graphModel +- Editor components set metadata via `graphModel.setMetaData(key, value)` + +**Available Metadata Keys** (varies by project): + +- `cloudservices` - Parse/Firebase cloud settings +- `backendServices` - BYOB backend configurations +- Project-specific settings + +**Location**: + +- NoodlRuntime API: `packages/noodl-runtime/noodl-runtime.js` (line 299) +- Pattern reference: `packages/noodl-runtime/src/api/cloudstore.js` +- BYOB usage: `packages/noodl-runtime/src/nodes/std-library/data/byob-query-data.js` + +**Keywords**: NoodlRuntime, getMetaData, project metadata, runtime, backend config, cloudservices + +--- + +## 🔴 Runtime Nodes Must Be in coreNodes Index (Dec 2025) + +### Problem: Node Module Loads But Doesn't Appear in Node Picker + +**Context**: TASK-002 BYOB Data Nodes - Created `byob-query-data.js`, registered in `register-nodes.js`, console showed "Module loaded" but node never appeared in Node Picker. + +**Root Cause**: Runtime nodes need to be registered in THREE places: + +1. ✅ Node file created (`noodl-runtime/src/nodes/std-library/data/byob-query-data.js`) +2. ✅ Registered in `register-nodes.js` via `require()` +3. ❌ **MISSING** - Added to `coreNodes` index in `nodelibraryexport.js` + +**The Hidden Requirement**: + +```javascript +// In nodelibraryexport.js, the coreNodes array determines Node Picker organization +const coreNodes = [ + { + name: 'Read & Write Data', + subCategories: [ + { + name: 'External Data', + items: ['net.noodl.HTTP', 'REST2'] // HTTP appears because it's HERE + } + // Node not in this array = not in Node Picker! + ] + } +]; +``` + +**The Fix**: + +```javascript +// Add node to appropriate category in coreNodes +{ + name: 'BYOB Data', + items: ['noodl.byob.QueryData'] // Now appears in Node Picker! +} +``` + +**Why This Is Easy to Miss**: + +- Module loads fine (console log appears) +- No errors anywhere +- Node IS registered in `nodeRegister._constructors` +- Node IS in `nodetypes` array exported to editor +- But Node Picker uses `coreNodes` index for organization + +**Critical Rule**: After creating a node, ALWAYS add it to `nodelibraryexport.js` coreNodes array. + +**Location**: + +- `packages/noodl-runtime/src/nodelibraryexport.js` (coreNodes array) +- Documented in: `dev-docs/reference/LEARNINGS-NODE-CREATION.md` (Step 3) + +**Keywords**: node picker, coreNodes, nodelibraryexport, runtime node, silent failure, node not appearing + +--- diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-001-infrastructure.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-001-infrastructure.md new file mode 100644 index 0000000..4aa1ac6 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-001-infrastructure.md @@ -0,0 +1,546 @@ +# CONFIG-001: Core Infrastructure + +## Overview + +Implement the foundational `Noodl.Config` namespace, data model, project storage, and runtime initialization. + +**Estimated effort:** 14-18 hours +**Dependencies:** None +**Blocks:** All other CONFIG subtasks + +--- + +## Objectives + +1. Create `Noodl.Config` as an immutable runtime namespace +2. Define TypeScript interfaces for configuration data +3. Implement storage in project.json metadata +4. Initialize config values at app startup +5. Establish default values for required fields + +--- + +## Files to Create + +### 1. Type Definitions + +**File:** `packages/noodl-runtime/src/config/types.ts` + +```typescript +export type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object'; + +export interface ConfigVariable { + key: string; + type: ConfigType; + value: any; + description?: string; + category?: string; + validation?: ConfigValidation; +} + +export interface ConfigValidation { + required?: boolean; + pattern?: string; + min?: number; + max?: number; +} + +export interface AppIdentity { + appName: string; + description: string; + coverImage?: string; +} + +export interface AppSEO { + ogTitle?: string; + ogDescription?: string; + ogImage?: string; + favicon?: string; + themeColor?: string; +} + +export interface AppPWA { + enabled: boolean; + shortName?: string; + startUrl: string; + display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser'; + backgroundColor?: string; + sourceIcon?: string; +} + +export interface AppConfig { + identity: AppIdentity; + seo: AppSEO; + pwa?: AppPWA; + variables: ConfigVariable[]; +} + +export const DEFAULT_APP_CONFIG: AppConfig = { + identity: { + appName: 'My Noodl App', + description: '' + }, + seo: {}, + variables: [] +}; +``` + +### 2. Config Manager (Runtime) + +**File:** `packages/noodl-runtime/src/config/config-manager.ts` + +```typescript +import { AppConfig, ConfigVariable, DEFAULT_APP_CONFIG } from './types'; + +class ConfigManager { + private static instance: ConfigManager; + private config: AppConfig = DEFAULT_APP_CONFIG; + private frozenConfig: Readonly> | null = null; + + static getInstance(): ConfigManager { + if (!ConfigManager.instance) { + ConfigManager.instance = new ConfigManager(); + } + return ConfigManager.instance; + } + + /** + * Initialize config from project metadata. + * Called once at app startup. + */ + initialize(config: Partial): void { + this.config = { + ...DEFAULT_APP_CONFIG, + ...config, + identity: { + ...DEFAULT_APP_CONFIG.identity, + ...config.identity + }, + seo: { + ...DEFAULT_APP_CONFIG.seo, + ...config.seo + } + }; + + // Build the frozen public config object + this.frozenConfig = this.buildFrozenConfig(); + } + + /** + * Get the immutable Noodl.Config object. + */ + getConfig(): Readonly> { + if (!this.frozenConfig) { + this.frozenConfig = this.buildFrozenConfig(); + } + return this.frozenConfig; + } + + /** + * Get raw config for editor use. + */ + getRawConfig(): AppConfig { + return this.config; + } + + /** + * Get a specific variable definition. + */ + getVariable(key: string): ConfigVariable | undefined { + return this.config.variables.find(v => v.key === key); + } + + /** + * Get all variable keys for autocomplete. + */ + getVariableKeys(): string[] { + return this.config.variables.map(v => v.key); + } + + private buildFrozenConfig(): Readonly> { + const config: Record = { + // Identity fields + appName: this.config.identity.appName, + description: this.config.identity.description, + coverImage: this.config.identity.coverImage, + + // SEO fields (with defaults) + ogTitle: this.config.seo.ogTitle || this.config.identity.appName, + ogDescription: this.config.seo.ogDescription || this.config.identity.description, + ogImage: this.config.seo.ogImage || this.config.identity.coverImage, + favicon: this.config.seo.favicon, + themeColor: this.config.seo.themeColor, + + // PWA fields + pwaEnabled: this.config.pwa?.enabled ?? false, + pwaShortName: this.config.pwa?.shortName, + pwaDisplay: this.config.pwa?.display, + pwaStartUrl: this.config.pwa?.startUrl, + pwaBackgroundColor: this.config.pwa?.backgroundColor + }; + + // Add custom variables + for (const variable of this.config.variables) { + config[variable.key] = variable.value; + } + + // Freeze to prevent mutation + return Object.freeze(config); + } +} + +export const configManager = ConfigManager.getInstance(); +``` + +### 3. Noodl.Config API + +**File:** `packages/noodl-viewer-react/src/config-api.ts` + +```typescript +import { configManager } from '@noodl/runtime/src/config/config-manager'; + +/** + * Create the Noodl.Config proxy object. + * Returns an immutable object that throws on write attempts. + */ +export function createConfigAPI(): Readonly> { + const config = configManager.getConfig(); + + return new Proxy(config, { + get(target, prop: string) { + if (prop in target) { + return target[prop]; + } + console.warn(`Noodl.Config.${prop} is not defined`); + return undefined; + }, + + set(target, prop: string, value) { + console.error( + `Cannot set Noodl.Config.${prop} - Config values are immutable. ` + + `Use Noodl.Variables for runtime-changeable values.` + ); + return false; + }, + + deleteProperty(target, prop: string) { + console.error(`Cannot delete Noodl.Config.${prop} - Config values are immutable.`); + return false; + } + }); +} +``` + +### 4. Update Noodl JS API + +**File:** `packages/noodl-viewer-react/src/noodl-js-api.js` (modify) + +Add Config to the Noodl namespace: + +```javascript +// Add import +import { createConfigAPI } from './config-api'; + +// In the Noodl object initialization +const Noodl = { + // ... existing properties + Config: createConfigAPI(), + // ... +}; +``` + +--- + +## Files to Modify + +### 1. Project Model + +**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts` + +Add methods for app config: + +```typescript +// Add to ProjectModel class + +getAppConfig(): AppConfig { + return this.getMetaData('appConfig') || DEFAULT_APP_CONFIG; +} + +setAppConfig(config: AppConfig): void { + this.setMetaData('appConfig', config); +} + +updateAppConfig(updates: Partial): void { + const current = this.getAppConfig(); + this.setAppConfig({ + ...current, + ...updates + }); +} + +// Config variables helpers +getConfigVariables(): ConfigVariable[] { + return this.getAppConfig().variables; +} + +setConfigVariable(variable: ConfigVariable): void { + const config = this.getAppConfig(); + const index = config.variables.findIndex(v => v.key === variable.key); + + if (index >= 0) { + config.variables[index] = variable; + } else { + config.variables.push(variable); + } + + this.setAppConfig(config); +} + +removeConfigVariable(key: string): void { + const config = this.getAppConfig(); + config.variables = config.variables.filter(v => v.key !== key); + this.setAppConfig(config); +} +``` + +### 2. Runtime Initialization + +**File:** `packages/noodl-viewer-react/src/index.js` (or equivalent entry) + +Initialize config from project data: + +```javascript +// During app initialization +import { configManager } from '@noodl/runtime/src/config/config-manager'; + +// After project data is loaded +const appConfig = projectData.metadata?.appConfig; +if (appConfig) { + configManager.initialize(appConfig); +} +``` + +### 3. Type Declarations + +**File:** `packages/noodl-viewer-react/static/viewer/global.d.ts.keep` + +Add Config type declarations: + +```typescript +declare namespace Noodl { + // ... existing declarations + + /** + * App configuration values defined in App Setup. + * These values are static and cannot be changed at runtime. + * + * @example + * // Access a config value + * const color = Noodl.Config.primaryColor; + * + * // This will throw an error: + * Noodl.Config.primaryColor = "#000"; // ❌ Cannot modify + */ + const Config: Readonly<{ + // Identity + appName: string; + description: string; + coverImage?: string; + + // SEO + ogTitle: string; + ogDescription: string; + ogImage?: string; + favicon?: string; + themeColor?: string; + + // PWA + pwaEnabled: boolean; + pwaShortName?: string; + pwaDisplay?: string; + pwaStartUrl?: string; + pwaBackgroundColor?: string; + + // Custom variables (dynamic) + [key: string]: any; + }>; +} +``` + +--- + +## Validation Logic + +**File:** `packages/noodl-runtime/src/config/validation.ts` + +```typescript +import { ConfigVariable, ConfigValidation } from './types'; + +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +export function validateConfigKey(key: string): ValidationResult { + const errors: string[] = []; + + // Must be valid JS identifier + if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) { + errors.push('Key must be a valid JavaScript identifier'); + } + + // Reserved keys + const reserved = [ + 'appName', 'description', 'coverImage', + 'ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor', + 'pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor' + ]; + + if (reserved.includes(key)) { + errors.push(`"${key}" is a reserved configuration key`); + } + + return { valid: errors.length === 0, errors }; +} + +export function validateConfigValue( + value: any, + type: string, + validation?: ConfigValidation +): ValidationResult { + const errors: string[] = []; + + // Required check + if (validation?.required && (value === undefined || value === null || value === '')) { + errors.push('This field is required'); + return { valid: false, errors }; + } + + // Type-specific validation + switch (type) { + case 'number': + if (typeof value !== 'number' || isNaN(value)) { + errors.push('Value must be a number'); + } else { + if (validation?.min !== undefined && value < validation.min) { + errors.push(`Value must be at least ${validation.min}`); + } + if (validation?.max !== undefined && value > validation.max) { + errors.push(`Value must be at most ${validation.max}`); + } + } + break; + + case 'string': + if (typeof value !== 'string') { + errors.push('Value must be a string'); + } else if (validation?.pattern) { + const regex = new RegExp(validation.pattern); + if (!regex.test(value)) { + errors.push('Value does not match required pattern'); + } + } + break; + + case 'boolean': + if (typeof value !== 'boolean') { + errors.push('Value must be true or false'); + } + break; + + case 'color': + if (typeof value !== 'string' || !/^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(value)) { + errors.push('Value must be a valid hex color (e.g., #ff0000)'); + } + break; + + case 'array': + if (!Array.isArray(value)) { + errors.push('Value must be an array'); + } + break; + + case 'object': + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + errors.push('Value must be an object'); + } + break; + } + + return { valid: errors.length === 0, errors }; +} +``` + +--- + +## Testing Checklist + +### Unit Tests + +- [ ] ConfigManager initializes with defaults +- [ ] ConfigManager merges provided config with defaults +- [ ] Frozen config includes all identity fields +- [ ] Frozen config includes all SEO fields with defaults +- [ ] Frozen config includes all custom variables +- [ ] Config object is truly immutable (Object.isFrozen) +- [ ] Proxy prevents writes and logs error +- [ ] Validation rejects invalid keys +- [ ] Validation rejects reserved keys +- [ ] Type validation works for all types + +### Integration Tests + +- [ ] ProjectModel saves config to metadata +- [ ] ProjectModel loads config from metadata +- [ ] Runtime initializes config on app load +- [ ] Noodl.Config accessible in Function nodes +- [ ] Config values correct in running app + +--- + +## Implementation Order + +1. Create type definitions +2. Create ConfigManager +3. Create validation utilities +4. Update ProjectModel with config methods +5. Create Noodl.Config API proxy +6. Add to Noodl namespace +7. Update type declarations +8. Add runtime initialization +9. Write tests + +--- + +## Notes for Implementer + +### Freezing Behavior + +The config object must be deeply frozen to prevent any mutation: + +```typescript +function deepFreeze(obj: T): Readonly { + Object.keys(obj).forEach(key => { + const value = (obj as any)[key]; + if (value && typeof value === 'object') { + deepFreeze(value); + } + }); + return Object.freeze(obj); +} +``` + +### Error Messages + +When users try to modify config values, provide helpful error messages that guide them to use Variables instead: + +```javascript +console.error( + `Cannot set Noodl.Config.${prop} - Config values are immutable. ` + + `Use Noodl.Variables for runtime-changeable values.` +); +``` + +### Cloud Function Context + +Ensure ConfigManager works in both browser and cloud function contexts. The cloud functions have a separate noodl-js-api that will need similar updates. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-002-app-setup-panel.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-002-app-setup-panel.md new file mode 100644 index 0000000..d95e8a2 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-002-app-setup-panel.md @@ -0,0 +1,944 @@ +# CONFIG-002: App Setup Panel UI + +## Overview + +Create a new "App Setup" top-level sidebar panel for editing app configuration, SEO metadata, PWA settings, and custom variables. + +**Estimated effort:** 18-24 hours +**Dependencies:** CONFIG-001 +**Blocks:** CONFIG-004, CONFIG-005 + +--- + +## Objectives + +1. Add new "App Setup" tab to sidebar navigation +2. Create panel sections for Identity, SEO, PWA, and Custom Variables +3. Integrate existing port type editors for type-aware editing +4. Implement add/edit/remove flows for custom variables +5. Support category grouping for variables +6. Migrate relevant settings from Project Settings panel + +--- + +## Files to Create + +### 1. App Setup Panel + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/AppSetupPanel.tsx` + +```tsx +import React, { useState, useEffect, useCallback } from 'react'; +import { ProjectModel } from '@noodl-models/projectmodel'; +import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel'; +import { AppConfig } from '@noodl/runtime/src/config/types'; + +import { IdentitySection } from './sections/IdentitySection'; +import { SEOSection } from './sections/SEOSection'; +import { PWASection } from './sections/PWASection'; +import { VariablesSection } from './sections/VariablesSection'; + +export function AppSetupPanel() { + const [config, setConfig] = useState( + ProjectModel.instance.getAppConfig() + ); + + const updateConfig = useCallback((updates: Partial) => { + const newConfig = { ...config, ...updates }; + setConfig(newConfig); + ProjectModel.instance.setAppConfig(newConfig); + }, [config]); + + const updateIdentity = useCallback((identity: Partial) => { + updateConfig({ + identity: { ...config.identity, ...identity } + }); + }, [config, updateConfig]); + + const updateSEO = useCallback((seo: Partial) => { + updateConfig({ + seo: { ...config.seo, ...seo } + }); + }, [config, updateConfig]); + + const updatePWA = useCallback((pwa: Partial) => { + updateConfig({ + pwa: { ...config.pwa, ...pwa } as AppConfig['pwa'] + }); + }, [config, updateConfig]); + + return ( + + + + + + + + updateConfig({ variables })} + /> + + ); +} +``` + +### 2. Identity Section + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/IdentitySection.tsx` + +```tsx +import React from 'react'; +import { AppIdentity } from '@noodl/runtime/src/config/types'; + +import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection'; +import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput'; +import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput'; +import { PropertyPanelTextArea } from '@noodl-core-ui/components/property-panel/PropertyPanelTextArea'; +import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker'; + +interface IdentitySectionProps { + identity: AppIdentity; + onChange: (updates: Partial) => void; +} + +export function IdentitySection({ identity, onChange }: IdentitySectionProps) { + return ( + + + onChange({ appName: value })} + placeholder="My Noodl App" + /> + + + + onChange({ description: value })} + placeholder="Describe your app..." + rows={3} + /> + + + + onChange({ coverImage: value })} + placeholder="Select cover image..." + /> + + + ); +} +``` + +### 3. SEO Section + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/SEOSection.tsx` + +```tsx +import React from 'react'; +import { AppSEO, AppIdentity } from '@noodl/runtime/src/config/types'; + +import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection'; +import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput'; +import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput'; +import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker'; +import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker'; +import { Text } from '@noodl-core-ui/components/typography/Text'; +import { Box } from '@noodl-core-ui/components/layout/Box'; + +interface SEOSectionProps { + seo: AppSEO; + identity: AppIdentity; + onChange: (updates: Partial) => void; +} + +export function SEOSection({ seo, identity, onChange }: SEOSectionProps) { + return ( + + + onChange({ ogTitle: value || undefined })} + placeholder={identity.appName || 'Defaults to App Name'} + /> + + + Defaults to App Name if empty + + + + onChange({ ogDescription: value || undefined })} + placeholder={identity.description ? 'Defaults to Description' : 'Enter description...'} + /> + + + Defaults to Description if empty + + + + onChange({ ogImage: value })} + placeholder="Defaults to Cover Image" + /> + + + + onChange({ favicon: value })} + placeholder="Select favicon..." + accept=".ico,.png,.svg" + /> + + + + onChange({ themeColor: value })} + /> + + + ); +} +``` + +### 4. PWA Section + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/PWASection.tsx` + +```tsx +import React from 'react'; +import { AppPWA } from '@noodl/runtime/src/config/types'; + +import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection'; +import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput'; +import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput'; +import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox'; +import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput'; +import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker'; +import { ImagePicker } from '../../propertyeditor/DataTypes/ImagePicker'; +import { Text } from '@noodl-core-ui/components/typography/Text'; +import { Box } from '@noodl-core-ui/components/layout/Box'; + +interface PWASectionProps { + pwa?: AppPWA; + onChange: (updates: Partial) => void; +} + +const DISPLAY_OPTIONS = [ + { value: 'standalone', label: 'Standalone' }, + { value: 'fullscreen', label: 'Fullscreen' }, + { value: 'minimal-ui', label: 'Minimal UI' }, + { value: 'browser', label: 'Browser' } +]; + +export function PWASection({ pwa, onChange }: PWASectionProps) { + const enabled = pwa?.enabled ?? false; + + return ( + onChange({ enabled: value })} + /> + } + > + {enabled && ( + <> + + onChange({ shortName: value })} + placeholder="Short app name for home screen" + /> + + + + onChange({ display: value as AppPWA['display'] })} + /> + + + + onChange({ startUrl: value || '/' })} + placeholder="/" + /> + + + + onChange({ backgroundColor: value })} + /> + + + + onChange({ sourceIcon: value })} + placeholder="512x512 PNG recommended" + accept=".png" + /> + + + + Provide a 512x512 PNG. Smaller sizes will be generated automatically. + + + + )} + + ); +} +``` + +### 5. Variables Section + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariablesSection.tsx` + +```tsx +import React, { useState, useMemo } from 'react'; +import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types'; + +import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection'; +import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton'; +import { IconName } from '@noodl-core-ui/components/common/Icon'; + +import { VariableGroup } from './VariableGroup'; +import { AddVariableDialog } from '../dialogs/AddVariableDialog'; + +interface VariablesSectionProps { + variables: ConfigVariable[]; + onChange: (variables: ConfigVariable[]) => void; +} + +export function VariablesSection({ variables, onChange }: VariablesSectionProps) { + const [showAddDialog, setShowAddDialog] = useState(false); + const [editingVariable, setEditingVariable] = useState(null); + + // Group variables by category + const groupedVariables = useMemo(() => { + const groups: Record = {}; + + for (const variable of variables) { + const category = variable.category || 'Custom'; + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(variable); + } + + return groups; + }, [variables]); + + const handleAddVariable = (variable: ConfigVariable) => { + onChange([...variables, variable]); + setShowAddDialog(false); + }; + + const handleUpdateVariable = (key: string, updates: Partial) => { + onChange(variables.map(v => + v.key === key ? { ...v, ...updates } : v + )); + }; + + const handleDeleteVariable = (key: string) => { + onChange(variables.filter(v => v.key !== key)); + }; + + const handleEditVariable = (variable: ConfigVariable) => { + setEditingVariable(variable); + setShowAddDialog(true); + }; + + const handleSaveEdit = (variable: ConfigVariable) => { + if (editingVariable) { + // If key changed, remove old and add new + if (editingVariable.key !== variable.key) { + onChange([ + ...variables.filter(v => v.key !== editingVariable.key), + variable + ]); + } else { + handleUpdateVariable(variable.key, variable); + } + } + setEditingVariable(null); + setShowAddDialog(false); + }; + + return ( + setShowAddDialog(true)} + label="Add" + /> + } + > + {Object.entries(groupedVariables).map(([category, vars]) => ( + + ))} + + {variables.length === 0 && ( + + No custom variables defined. Click "Add" to create one. + + )} + + {showAddDialog && ( + v.key)} + editingVariable={editingVariable} + onSave={editingVariable ? handleSaveEdit : handleAddVariable} + onCancel={() => { + setShowAddDialog(false); + setEditingVariable(null); + }} + /> + )} + + ); +} +``` + +### 6. Variable Group Component + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableGroup.tsx` + +```tsx +import React from 'react'; +import { ConfigVariable } from '@noodl/runtime/src/config/types'; + +import { Box } from '@noodl-core-ui/components/layout/Box'; +import { Text } from '@noodl-core-ui/components/typography/Text'; + +import { VariableRow } from './VariableRow'; + +import css from './VariableGroup.module.scss'; + +interface VariableGroupProps { + category: string; + variables: ConfigVariable[]; + onUpdate: (key: string, updates: Partial) => void; + onDelete: (key: string) => void; + onEdit: (variable: ConfigVariable) => void; +} + +export function VariableGroup({ + category, + variables, + onUpdate, + onDelete, + onEdit +}: VariableGroupProps) { + return ( + + {category} +
+ {variables.map(variable => ( + onUpdate(variable.key, updates)} + onDelete={() => onDelete(variable.key)} + onEdit={() => onEdit(variable)} + /> + ))} +
+
+ ); +} +``` + +### 7. Variable Row Component + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableRow.tsx` + +```tsx +import React from 'react'; +import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types'; + +import { IconName, Icon } from '@noodl-core-ui/components/common/Icon'; +import { MenuDialog, MenuDialogItem } from '@noodl-core-ui/components/popups/MenuDialog'; +import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip'; + +import { TypeEditor } from './TypeEditor'; + +import css from './VariableRow.module.scss'; + +interface VariableRowProps { + variable: ConfigVariable; + onUpdate: (updates: Partial) => void; + onDelete: () => void; + onEdit: () => void; +} + +const TYPE_LABELS: Record = { + string: 'String', + number: 'Number', + boolean: 'Boolean', + color: 'Color', + array: 'Array', + object: 'Object' +}; + +export function VariableRow({ variable, onUpdate, onDelete, onEdit }: VariableRowProps) { + const menuItems: MenuDialogItem[] = [ + { label: 'Edit', icon: IconName.Pencil, onClick: onEdit }, + { label: 'Delete', icon: IconName.Trash, isDangerousAction: true, onClick: onDelete } + ]; + + return ( +
+
+ + {variable.key} + + {variable.type} +
+ +
+ onUpdate({ value })} + /> +
+ + + + +
+ ); +} +``` + +### 8. Type Editor Component + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/TypeEditor.tsx` + +```tsx +import React from 'react'; +import { ConfigType } from '@noodl/runtime/src/config/types'; + +import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput'; +import { PropertyPanelNumberInput } from '@noodl-core-ui/components/property-panel/PropertyPanelNumberInput'; +import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox'; +import { PropertyPanelColorPicker } from '@noodl-core-ui/components/property-panel/PropertyPanelColorPicker'; +import { PropertyPanelButton } from '@noodl-core-ui/components/property-panel/PropertyPanelButton'; + +interface TypeEditorProps { + type: ConfigType; + value: any; + onChange: (value: any) => void; +} + +export function TypeEditor({ type, value, onChange }: TypeEditorProps) { + switch (type) { + case 'string': + return ( + + ); + + case 'number': + return ( + + ); + + case 'boolean': + return ( + + ); + + case 'color': + return ( + + ); + + case 'array': + return ( + { + // Open array editor popup + // Reuse existing array editor from port types + }} + /> + ); + + case 'object': + return ( + { + // Open object editor popup + // Reuse existing object editor from port types + }} + /> + ); + + default: + return Unknown type; + } +} +``` + +### 9. Add Variable Dialog + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/dialogs/AddVariableDialog.tsx` + +```tsx +import React, { useState } from 'react'; +import { ConfigVariable, ConfigType } from '@noodl/runtime/src/config/types'; +import { validateConfigKey } from '@noodl/runtime/src/config/validation'; + +import { DialogRender } from '@noodl-core-ui/components/layout/DialogRender'; +import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton'; +import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput'; +import { PropertyPanelTextInput } from '@noodl-core-ui/components/property-panel/PropertyPanelTextInput'; +import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput'; +import { Text } from '@noodl-core-ui/components/typography/Text'; + +interface AddVariableDialogProps { + existingKeys: string[]; + editingVariable?: ConfigVariable | null; + onSave: (variable: ConfigVariable) => void; + onCancel: () => void; +} + +const TYPE_OPTIONS = [ + { value: 'string', label: 'String' }, + { value: 'number', label: 'Number' }, + { value: 'boolean', label: 'Boolean' }, + { value: 'color', label: 'Color' }, + { value: 'array', label: 'Array' }, + { value: 'object', label: 'Object' } +]; + +export function AddVariableDialog({ + existingKeys, + editingVariable, + onSave, + onCancel +}: AddVariableDialogProps) { + const [key, setKey] = useState(editingVariable?.key || ''); + const [type, setType] = useState(editingVariable?.type || 'string'); + const [description, setDescription] = useState(editingVariable?.description || ''); + const [category, setCategory] = useState(editingVariable?.category || ''); + const [error, setError] = useState(null); + + const isEditing = !!editingVariable; + + const handleSave = () => { + // Validate key + const validation = validateConfigKey(key); + if (!validation.valid) { + setError(validation.errors[0]); + return; + } + + // Check for duplicates (unless editing same key) + if (!isEditing || key !== editingVariable?.key) { + if (existingKeys.includes(key)) { + setError('A variable with this key already exists'); + return; + } + } + + const variable: ConfigVariable = { + key, + type, + value: editingVariable?.value ?? getDefaultValue(type), + description: description || undefined, + category: category || undefined + }; + + onSave(variable); + }; + + return ( + + + + + } + > + + { setKey(v); setError(null); }} + placeholder="myVariable" + hasError={!!error} + /> + + {error && {error}} + + + setType(v as ConfigType)} + isDisabled={isEditing} // Can't change type when editing + /> + + + + + + + + + + + ); +} + +function getDefaultValue(type: ConfigType): any { + switch (type) { + case 'string': return ''; + case 'number': return 0; + case 'boolean': return false; + case 'color': return '#000000'; + case 'array': return []; + case 'object': return {}; + } +} +``` + +--- + +## Files to Modify + +### 1. Sidebar Navigation + +**File:** `packages/noodl-editor/src/editor/src/views/SidePanel/SidePanel.tsx` + +Add App Setup tab to sidebar: + +```tsx +// Add to sidebar tabs +{ + id: 'app-setup', + label: 'App Setup', + icon: IconName.Settings, // or appropriate icon + panel: AppSetupPanel +} +``` + +### 2. Migrate Project Settings + +**File:** `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/` + +Move the following to App Setup: +- Project name (becomes App Name in Identity) +- Consider migrating other relevant settings + +--- + +## Styles + +### Variable Group Styles + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableGroup.module.scss` + +```scss +.Root { + background-color: var(--theme-color-bg-3); + border: 1px solid var(--theme-color-border-default); + border-radius: var(--radius-default); + padding: var(--spacing-2); + margin-bottom: var(--spacing-2); +} + +.CategoryLabel { + font-size: var(--text-label-size); + font-weight: var(--text-label-weight); + letter-spacing: var(--text-label-letter-spacing); + color: var(--theme-color-fg-muted); + text-transform: uppercase; + margin-bottom: var(--spacing-2); +} + +.VariablesList { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} +``` + +### Variable Row Styles + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/sections/VariableRow.module.scss` + +```scss +.Root { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-1) 0; +} + +.KeyColumn { + flex: 0 0 120px; + display: flex; + flex-direction: column; +} + +.Key { + font-weight: var(--font-weight-medium); + color: var(--theme-color-fg-default); +} + +.Type { + font-size: var(--font-size-xs); + color: var(--theme-color-fg-muted); +} + +.ValueColumn { + flex: 1; + min-width: 0; +} + +.MenuButton { + flex: 0 0 24px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: var(--radius-default); + color: var(--theme-color-fg-muted); + cursor: pointer; + + &:hover { + background-color: var(--theme-color-bg-hover); + color: var(--theme-color-fg-default); + } +} +``` + +--- + +## Testing Checklist + +### Functional Tests + +- [ ] App Setup panel appears in sidebar +- [ ] Can edit App Name +- [ ] Can edit Description (multiline) +- [ ] Can upload/select Cover Image +- [ ] SEO fields show defaults when empty +- [ ] Can override SEO fields +- [ ] Can enable/disable PWA section +- [ ] PWA fields editable when enabled +- [ ] Can add new variable +- [ ] Can edit existing variable +- [ ] Can delete variable +- [ ] Type editor matches variable type +- [ ] Color picker works +- [ ] Array editor opens +- [ ] Object editor opens +- [ ] Categories group correctly +- [ ] Uncategorized → "Custom" +- [ ] Validation prevents duplicate keys +- [ ] Validation prevents reserved keys +- [ ] Validation prevents invalid key names + +### Visual Tests + +- [ ] Panel matches design mockup +- [ ] Sections collapsible +- [ ] Proper spacing and alignment +- [ ] Dark theme compatible +- [ ] Responsive to panel width + +--- + +## Notes for Implementer + +### Reusing Port Type Editors + +The existing port type editors in `views/panels/propertyeditor/DataTypes/Ports.ts` handle most input types. Consider extracting shared components or directly importing the editor logic for consistency. + +### Image Picker + +Create a shared ImagePicker component that can: +- Browse project files +- Upload new images +- Accept URL input +- Show preview thumbnail + +### Array/Object Editors + +Reuse the existing popup editors for array and object types. These are used in the Static Array node and Function node. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-003-app-config-node.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-003-app-config-node.md new file mode 100644 index 0000000..09a036a --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-003-app-config-node.md @@ -0,0 +1,522 @@ +# CONFIG-003: App Config Node + +## Overview + +Create an "App Config" node that provides selected configuration values as outputs, for users who prefer visual programming over expressions. + +**Estimated effort:** 10-14 hours +**Dependencies:** CONFIG-001 +**Blocks:** None + +--- + +## Objectives + +1. Create App Config node with selectable variable outputs +2. Preserve output types (color outputs as color, etc.) +3. Integrate with node picker +4. Rename existing "Config" node to "Noodl Cloud Config" +5. Hide cloud data nodes when no backend connected + +--- + +## Node Design + +### App Config Node + +``` +┌──────────────────────────────┐ +│ App Config │ +├──────────────────────────────┤ +│ Variables │ +│ ┌──────────────────────────┐ │ +│ │ ☑ appName │ │ +│ │ ☑ primaryColor │ │ +│ │ ☐ description │ │ +│ │ ☑ apiBaseUrl │ │ +│ │ ☑ menuItems │ │ +│ └──────────────────────────┘ │ +├──────────────────────────────┤ +│ ○ appName │──→ string +│ ○ primaryColor │──→ color +│ ○ apiBaseUrl │──→ string +│ ○ menuItems │──→ array +└──────────────────────────────┘ +``` + +--- + +## Files to Create + +### 1. App Config Node + +**File:** `packages/noodl-runtime/src/nodes/std-library/data/appconfignode.js` + +```javascript +'use strict'; + +const { configManager } = require('../../../config/config-manager'); + +const AppConfigNode = { + name: 'App Config', + displayName: 'App Config', + docs: 'https://docs.noodl.net/nodes/data/app-config', + shortDesc: 'Access app configuration values defined in App Setup.', + category: 'Variables', + color: 'data', + + initialize: function() { + this._internal.outputValues = {}; + }, + + getInspectInfo() { + const selected = this._internal.selectedVariables || []; + if (selected.length === 0) { + return [{ type: 'text', value: 'No variables selected' }]; + } + + return selected.map(key => ({ + type: 'text', + value: `${key}: ${JSON.stringify(this._internal.outputValues[key])}` + })); + }, + + inputs: { + variables: { + type: { + name: 'stringlist', + allowEditOnly: true, + multiline: true + }, + displayName: 'Variables', + group: 'General', + set: function(value) { + // Parse selected variables from stringlist + const selected = Array.isArray(value) ? value : + (typeof value === 'string' ? value.split(',').map(s => s.trim()).filter(Boolean) : []); + + this._internal.selectedVariables = selected; + + // Update output values + const config = configManager.getConfig(); + for (const key of selected) { + this._internal.outputValues[key] = config[key]; + } + + // Flag all outputs as dirty + selected.forEach(key => { + if (this.hasOutput('out-' + key)) { + this.flagOutputDirty('out-' + key); + } + }); + } + } + }, + + outputs: {}, + + methods: { + registerOutputIfNeeded: function(name) { + if (this.hasOutput(name)) return; + + if (name.startsWith('out-')) { + const key = name.substring(4); + const config = configManager.getConfig(); + const variable = configManager.getVariable(key); + + // Determine output type based on variable type or infer from value + let outputType = '*'; + if (variable) { + outputType = variable.type; + } else if (key in config) { + // Built-in config - infer type + const value = config[key]; + if (typeof value === 'string') { + outputType = key.toLowerCase().includes('color') ? 'color' : 'string'; + } else if (typeof value === 'number') { + outputType = 'number'; + } else if (typeof value === 'boolean') { + outputType = 'boolean'; + } else if (Array.isArray(value)) { + outputType = 'array'; + } else if (typeof value === 'object') { + outputType = 'object'; + } + } + + this.registerOutput(name, { + type: outputType, + getter: function() { + return this._internal.outputValues[key]; + } + }); + } + } + } +}; + +function updatePorts(nodeId, parameters, editorConnection, graphModel) { + const ports = []; + + // Get available config keys + const allKeys = getAvailableConfigKeys(graphModel); + + // Get selected variables + const selected = parameters.variables ? + (Array.isArray(parameters.variables) ? parameters.variables : + parameters.variables.split(',').map(s => s.trim()).filter(Boolean)) : []; + + // Create output ports for selected variables + selected.forEach(key => { + const variable = graphModel.getMetaData('appConfig')?.variables?.find(v => v.key === key); + let type = '*'; + + if (variable) { + type = variable.type; + } else if (isBuiltInKey(key)) { + type = getBuiltInType(key); + } + + ports.push({ + name: 'out-' + key, + displayName: key, + plug: 'output', + type: type, + group: 'Values' + }); + }); + + editorConnection.sendDynamicPorts(nodeId, ports); +} + +function getAvailableConfigKeys(graphModel) { + const config = graphModel.getMetaData('appConfig') || {}; + const keys = []; + + // Built-in keys + keys.push('appName', 'description', 'coverImage'); + keys.push('ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor'); + keys.push('pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor'); + + // Custom variables + if (config.variables) { + config.variables.forEach(v => keys.push(v.key)); + } + + return keys; +} + +function isBuiltInKey(key) { + const builtIn = [ + 'appName', 'description', 'coverImage', + 'ogTitle', 'ogDescription', 'ogImage', 'favicon', 'themeColor', + 'pwaEnabled', 'pwaShortName', 'pwaDisplay', 'pwaStartUrl', 'pwaBackgroundColor' + ]; + return builtIn.includes(key); +} + +function getBuiltInType(key) { + const colorKeys = ['themeColor', 'pwaBackgroundColor']; + const booleanKeys = ['pwaEnabled']; + + if (colorKeys.includes(key)) return 'color'; + if (booleanKeys.includes(key)) return 'boolean'; + return 'string'; +} + +module.exports = { + node: AppConfigNode, + setup: function(context, graphModel) { + if (!context.editorConnection || !context.editorConnection.isRunningLocally()) { + return; + } + + function _managePortsForNode(node) { + updatePorts(node.id, node.parameters, context.editorConnection, graphModel); + + node.on('parameterUpdated', function(event) { + if (event.name === 'variables') { + updatePorts(node.id, node.parameters, context.editorConnection, graphModel); + } + }); + + // Also update when app config changes + graphModel.on('metadataChanged.appConfig', function() { + updatePorts(node.id, node.parameters, context.editorConnection, graphModel); + }); + } + + graphModel.on('editorImportComplete', () => { + graphModel.on('nodeAdded.App Config', function(node) { + _managePortsForNode(node); + }); + + for (const node of graphModel.getNodesWithType('App Config')) { + _managePortsForNode(node); + } + }); + } +}; +``` + +### 2. Variable Selector Property Editor + +**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ConfigVariableSelector.tsx` + +```tsx +import React, { useState, useMemo } from 'react'; +import { ProjectModel } from '@noodl-models/projectmodel'; +import { Checkbox } from '@noodl-core-ui/components/inputs/Checkbox'; +import { Box } from '@noodl-core-ui/components/layout/Box'; +import { Text } from '@noodl-core-ui/components/typography/Text'; + +import css from './ConfigVariableSelector.module.scss'; + +interface ConfigVariableSelectorProps { + value: string[]; + onChange: (value: string[]) => void; +} + +interface VariableOption { + key: string; + type: string; + category: string; +} + +export function ConfigVariableSelector({ value, onChange }: ConfigVariableSelectorProps) { + const selected = new Set(value); + + const options = useMemo(() => { + const config = ProjectModel.instance.getAppConfig(); + const opts: VariableOption[] = []; + + // Built-in: Identity + opts.push( + { key: 'appName', type: 'string', category: 'Identity' }, + { key: 'description', type: 'string', category: 'Identity' }, + { key: 'coverImage', type: 'string', category: 'Identity' } + ); + + // Built-in: SEO + opts.push( + { key: 'ogTitle', type: 'string', category: 'SEO' }, + { key: 'ogDescription', type: 'string', category: 'SEO' }, + { key: 'ogImage', type: 'string', category: 'SEO' }, + { key: 'favicon', type: 'string', category: 'SEO' }, + { key: 'themeColor', type: 'color', category: 'SEO' } + ); + + // Built-in: PWA + opts.push( + { key: 'pwaEnabled', type: 'boolean', category: 'PWA' }, + { key: 'pwaShortName', type: 'string', category: 'PWA' }, + { key: 'pwaDisplay', type: 'string', category: 'PWA' }, + { key: 'pwaStartUrl', type: 'string', category: 'PWA' }, + { key: 'pwaBackgroundColor', type: 'color', category: 'PWA' } + ); + + // Custom variables + config.variables.forEach(v => { + opts.push({ + key: v.key, + type: v.type, + category: v.category || 'Custom' + }); + }); + + return opts; + }, []); + + // Group by category + const grouped = useMemo(() => { + const groups: Record = {}; + options.forEach(opt => { + if (!groups[opt.category]) { + groups[opt.category] = []; + } + groups[opt.category].push(opt); + }); + return groups; + }, [options]); + + const toggleVariable = (key: string) => { + const newSelected = new Set(selected); + if (newSelected.has(key)) { + newSelected.delete(key); + } else { + newSelected.add(key); + } + onChange(Array.from(newSelected)); + }; + + return ( +
+ {Object.entries(grouped).map(([category, vars]) => ( +
+ {category} + {vars.map(v => ( + + ))} +
+ ))} +
+ ); +} +``` + +--- + +## Files to Modify + +### 1. Rename Existing Config Node + +**File:** `packages/noodl-runtime/src/nodes/std-library/data/confignode.js` + +```javascript +// Change: +name: 'Config', +displayName: 'Config', + +// To: +name: 'Noodl Cloud Config', +displayName: 'Noodl Cloud Config', +shortDesc: 'Access configuration from Noodl Cloud Service.', +``` + +### 2. Update Node Library Export + +**File:** `packages/noodl-runtime/src/nodelibraryexport.js` + +```javascript +// Add App Config node +require('./src/nodes/std-library/data/appconfignode'), + +// Update node picker categories +{ + name: 'Variables', + items: [ + 'Variable2', + 'SetVariable', + 'App Config', // Add here + // ... + ] +} +``` + +### 3. Cloud Nodes Visibility + +**File:** `packages/noodl-editor/src/editor/src/views/nodepicker/NodePicker.tsx` (or relevant file) + +Add logic to hide cloud data nodes when no backend is connected: + +```typescript +function shouldShowCloudNodes(): boolean { + const cloudService = ProjectModel.instance.getMetaData('cloudservices'); + return cloudService && cloudService.endpoint; +} + +function getFilteredCategories(categories: Category[]): Category[] { + const showCloud = shouldShowCloudNodes(); + + return categories.map(category => { + if (category.name === 'Cloud Data' || category.name === 'Read & Write Data') { + // Filter out cloud-specific nodes if no backend + if (!showCloud) { + const cloudNodes = [ + 'DbCollection2', + 'DbModel2', + 'Noodl Cloud Config', // Renamed node + // ... other cloud nodes + ]; + + const filteredItems = category.items.filter( + item => !cloudNodes.includes(item) + ); + + // Add warning message if category is now empty or reduced + return { + ...category, + items: filteredItems, + message: showCloud ? undefined : 'Please add a backend service to use cloud data nodes.' + }; + } + } + return category; + }); +} +``` + +### 4. Register Node Type Editor + +**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts` + +Add handler for the variables property in App Config node: + +```typescript +// In the port type handlers +if (nodeName === 'App Config' && portName === 'variables') { + return { + component: ConfigVariableSelector, + // ... props + }; +} +``` + +--- + +## Testing Checklist + +### App Config Node + +- [ ] Node appears in node picker under Variables +- [ ] Can select/deselect variables in property panel +- [ ] Selected variables appear as outputs +- [ ] Output types match variable types +- [ ] Color variables output as color type +- [ ] Array variables output as array type +- [ ] Values are correct at runtime +- [ ] Node inspector shows current values +- [ ] Updates when app config changes in editor + +### Cloud Node Visibility + +- [ ] Cloud nodes hidden when no backend +- [ ] Warning message shows in category +- [ ] Cloud nodes visible when backend connected +- [ ] Existing cloud nodes in projects still work +- [ ] Renamed node appears as "Noodl Cloud Config" + +### Variable Selector + +- [ ] Shows all built-in config keys +- [ ] Shows custom variables +- [ ] Grouped by category +- [ ] Shows variable type +- [ ] Checkbox toggles selection +- [ ] Multiple selection works + +--- + +## Notes for Implementer + +### Dynamic Port Pattern + +This node uses the dynamic port pattern where outputs are created based on the `variables` property. Study existing nodes like `DbCollection2` for reference on how to: +- Parse the stringlist input +- Create dynamic output ports +- Handle port updates when parameters change + +### Type Preservation + +It's important that output types match the declared variable types so that connections in the graph validate correctly. A color output should only connect to color inputs, etc. + +### Cloud Service Detection + +The cloud service information is stored in project metadata under `cloudservices`. Check for both the existence of the object and a valid endpoint URL. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-004-seo-integration.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-004-seo-integration.md new file mode 100644 index 0000000..e89a158 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-004-seo-integration.md @@ -0,0 +1,397 @@ +# CONFIG-004: SEO Build Integration + +## Overview + +Integrate app configuration values into the HTML build process to generate proper SEO meta tags, Open Graph tags, and favicon links. + +**Estimated effort:** 8-10 hours +**Dependencies:** CONFIG-001, CONFIG-002 +**Blocks:** None + +--- + +## Objectives + +1. Inject `` tag from appName +2. Inject meta description tag +3. Inject Open Graph tags (og:title, og:description, og:image) +4. Inject Twitter Card tags +5. Inject favicon link +6. Inject theme-color meta tag +7. Support canonical URL (optional) + +--- + +## Generated HTML Structure + +```html +<!DOCTYPE html> +<html> +<head> + <!-- Basic Meta --> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>My Amazing App + + + + + + + + + + + + + + + + + + + + + + + + + + {{#customHeadCode#}} + +``` + +--- + +## Files to Modify + +### 1. HTML Processor + +**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/processors/html-processor.ts` + +Extend the existing processor: + +```typescript +import { ProjectModel } from '@noodl-models/projectmodel'; +import { AppConfig } from '@noodl/runtime/src/config/types'; + +export interface HtmlProcessorParameters { + title?: string; + headCode?: string; + indexJsPath?: string; + baseUrl?: string; + envVariables?: Record; +} + +export class HtmlProcessor { + constructor(public readonly project: ProjectModel) {} + + public async process(content: string, parameters: HtmlProcessorParameters): Promise { + const settings = this.project.getSettings(); + const appConfig = this.project.getAppConfig(); + + let baseUrl = parameters.baseUrl || settings.baseUrl || '/'; + if (!baseUrl.endsWith('/')) { + baseUrl = baseUrl + '/'; + } + + // Title from app config, falling back to settings, then default + const title = parameters.title || appConfig.identity.appName || settings.htmlTitle || 'Noodl App'; + + // Build head code with SEO tags + let headCode = this.generateSEOTags(appConfig, baseUrl); + headCode += settings.headCode || ''; + if (parameters.headCode) { + headCode += parameters.headCode; + } + + if (baseUrl !== '/') { + headCode = `\n` + headCode; + } + + // Inject into template + let injected = await this.injectIntoHtml(content, baseUrl); + injected = injected.replace('{{#title#}}', this.escapeHtml(title)); + injected = injected.replace('{{#customHeadCode#}}', headCode); + injected = injected.replace(/%baseUrl%/g, baseUrl); + + // ... rest of existing processing + + return injected; + } + + private generateSEOTags(config: AppConfig, baseUrl: string): string { + const tags: string[] = []; + + // Description + const description = config.seo.ogDescription || config.identity.description; + if (description) { + tags.push(``); + } + + // Theme color + if (config.seo.themeColor) { + tags.push(``); + } + + // Favicon + if (config.seo.favicon) { + const faviconPath = this.resolveAssetPath(config.seo.favicon, baseUrl); + const faviconType = this.getFaviconType(config.seo.favicon); + tags.push(``); + + // Apple touch icon (use og:image or favicon) + const touchIcon = config.seo.ogImage || config.identity.coverImage || config.seo.favicon; + if (touchIcon) { + tags.push(``); + } + } + + // Open Graph + tags.push(...this.generateOpenGraphTags(config, baseUrl)); + + // Twitter Card + tags.push(...this.generateTwitterTags(config, baseUrl)); + + // PWA Manifest + if (config.pwa?.enabled) { + tags.push(``); + } + + return tags.join('\n ') + '\n'; + } + + private generateOpenGraphTags(config: AppConfig, baseUrl: string): string[] { + const tags: string[] = []; + + tags.push(``); + + const ogTitle = config.seo.ogTitle || config.identity.appName; + if (ogTitle) { + tags.push(``); + } + + const ogDescription = config.seo.ogDescription || config.identity.description; + if (ogDescription) { + tags.push(``); + } + + const ogImage = config.seo.ogImage || config.identity.coverImage; + if (ogImage) { + // OG image should be absolute URL + const imagePath = this.resolveAssetPath(ogImage, baseUrl); + tags.push(``); + } + + // og:url would need the deployment URL - could be added via env variable + // tags.push(``); + + return tags; + } + + private generateTwitterTags(config: AppConfig, baseUrl: string): string[] { + const tags: string[] = []; + + // Use large image card if we have an image + const hasImage = config.seo.ogImage || config.identity.coverImage; + tags.push(``); + + const title = config.seo.ogTitle || config.identity.appName; + if (title) { + tags.push(``); + } + + const description = config.seo.ogDescription || config.identity.description; + if (description) { + tags.push(``); + } + + if (hasImage) { + const imagePath = this.resolveAssetPath( + config.seo.ogImage || config.identity.coverImage!, + baseUrl + ); + tags.push(``); + } + + return tags; + } + + private resolveAssetPath(path: string, baseUrl: string): string { + if (!path) return ''; + + // Already absolute URL + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + + // Relative path - prepend base URL + const cleanPath = path.startsWith('/') ? path.substring(1) : path; + return baseUrl + cleanPath; + } + + private getFaviconType(path: string): string { + if (path.endsWith('.ico')) return 'image/x-icon'; + if (path.endsWith('.png')) return 'image/png'; + if (path.endsWith('.svg')) return 'image/svg+xml'; + return 'image/png'; + } + + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); + } + + private escapeAttr(text: string): string { + return text + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + } + + private injectIntoHtml(template: string, pathPrefix: string) { + return new Promise((resolve) => { + ProjectModules.instance.injectIntoHtml( + this.project._retainedProjectDirectory, + template, + pathPrefix, + resolve + ); + }); + } +} +``` + +### 2. Update HTML Template + +**File:** `packages/noodl-viewer-react/static/index.html` (or equivalent) + +Ensure template has the right placeholders: + +```html + + + + + + {{#title#}} + {{#customHeadCode#}} + + +
+ <%index_js%> + + +``` + +--- + +## Asset Handling + +### Image Resolution + +Images referenced in config (coverImage, ogImage, favicon) can be: + +1. **Project file paths**: `/assets/images/cover.png` - copied to build output +2. **External URLs**: `https://example.com/image.jpg` - used as-is +3. **Uploaded files**: Handled through the existing project file system + +**File:** Add to build process in `packages/noodl-editor/src/editor/src/utils/compilation/build/` + +```typescript +async function copyConfigAssets(project: ProjectModel, outputDir: string): Promise { + const config = project.getAppConfig(); + const assets: string[] = []; + + // Collect asset paths + if (config.identity.coverImage && !isExternalUrl(config.identity.coverImage)) { + assets.push(config.identity.coverImage); + } + if (config.seo.ogImage && !isExternalUrl(config.seo.ogImage)) { + assets.push(config.seo.ogImage); + } + if (config.seo.favicon && !isExternalUrl(config.seo.favicon)) { + assets.push(config.seo.favicon); + } + + // Copy each asset to output + for (const asset of assets) { + const sourcePath = path.join(project._retainedProjectDirectory, asset); + const destPath = path.join(outputDir, asset); + + if (await fileExists(sourcePath)) { + await ensureDir(path.dirname(destPath)); + await copyFile(sourcePath, destPath); + } + } +} + +function isExternalUrl(path: string): boolean { + return path.startsWith('http://') || path.startsWith('https://'); +} +``` + +--- + +## Testing Checklist + +### Build Output Tests + +- [ ] Title tag contains app name +- [ ] Meta description present +- [ ] Theme color meta tag present +- [ ] Favicon link correct type and path +- [ ] Apple touch icon link present +- [ ] og:type is "website" +- [ ] og:title matches config +- [ ] og:description matches config +- [ ] og:image URL correct +- [ ] Twitter card type correct +- [ ] Twitter tags mirror OG tags +- [ ] Manifest link present when PWA enabled +- [ ] Manifest link absent when PWA disabled + +### Asset Handling Tests + +- [ ] Local image paths copied to build +- [ ] External URLs used as-is +- [ ] Missing assets don't break build +- [ ] Paths correct with custom baseUrl + +### Edge Cases + +- [ ] Empty description doesn't create empty tag +- [ ] Missing favicon doesn't break build +- [ ] Special characters escaped in meta content +- [ ] Very long descriptions truncated appropriately + +--- + +## Notes for Implementer + +### OG Image Requirements + +Open Graph images have specific requirements: +- Minimum 200x200 pixels +- Recommended 1200x630 pixels +- Maximum 8MB file size + +Consider adding validation in the App Setup panel. + +### Canonical URL + +The canonical URL (`og:url`) requires knowing the deployment URL, which isn't always known at build time. Options: +1. Add a `canonicalUrl` field to app config +2. Inject via environment variable at deploy time +3. Use JavaScript to set dynamically (loses SEO benefit) + +### Testing SEO Output + +Use tools like: +- https://developers.facebook.com/tools/debug/ (OG tags) +- https://cards-dev.twitter.com/validator (Twitter cards) +- Browser DevTools to inspect head tags diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-005-pwa-manifest.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-005-pwa-manifest.md new file mode 100644 index 0000000..8a95346 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-005-pwa-manifest.md @@ -0,0 +1,471 @@ +# CONFIG-005: PWA Manifest Generation + +## Overview + +Generate a valid `manifest.json` file and auto-scale app icons from a single source image for Progressive Web App support. + +**Estimated effort:** 10-14 hours +**Dependencies:** CONFIG-001, CONFIG-002 +**Blocks:** None (enables Phase 5 Capacitor integration) + +--- + +## Objectives + +1. Generate `manifest.json` from app config +2. Auto-scale icons from single 512x512 source +3. Copy icons to build output +4. Validate PWA requirements +5. Generate service worker stub (optional) + +--- + +## Generated manifest.json + +```json +{ + "name": "My Amazing App", + "short_name": "My App", + "description": "A visual programming app that makes building easy", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#d21f3c", + "icons": [ + { + "src": "/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png" + }, + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} +``` + +--- + +## Files to Create + +### 1. Manifest Generator + +**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/processors/manifest-processor.ts` + +```typescript +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { ProjectModel } from '@noodl-models/projectmodel'; +import { AppConfig } from '@noodl/runtime/src/config/types'; + +// Standard PWA icon sizes +const ICON_SIZES = [72, 96, 128, 144, 152, 192, 384, 512]; + +export interface ManifestProcessorOptions { + baseUrl?: string; + outputDir: string; +} + +export class ManifestProcessor { + constructor(public readonly project: ProjectModel) {} + + public async process(options: ManifestProcessorOptions): Promise { + const config = this.project.getAppConfig(); + + // Only generate if PWA is enabled + if (!config.pwa?.enabled) { + return; + } + + const baseUrl = options.baseUrl || '/'; + + // Generate icons from source + await this.generateIcons(config, options.outputDir, baseUrl); + + // Generate manifest.json + const manifest = this.generateManifest(config, baseUrl); + const manifestPath = path.join(options.outputDir, 'manifest.json'); + await fs.writeJson(manifestPath, manifest, { spaces: 2 }); + } + + private generateManifest(config: AppConfig, baseUrl: string): object { + const manifest: Record = { + name: config.identity.appName, + short_name: config.pwa?.shortName || config.identity.appName, + description: config.identity.description || '', + start_url: config.pwa?.startUrl || '/', + display: config.pwa?.display || 'standalone', + background_color: config.pwa?.backgroundColor || '#ffffff', + theme_color: config.seo.themeColor || '#000000', + icons: this.generateIconsManifest(baseUrl) + }; + + // Optional fields + if (config.identity.description) { + manifest.description = config.identity.description; + } + + return manifest; + } + + private generateIconsManifest(baseUrl: string): object[] { + return ICON_SIZES.map(size => ({ + src: `${baseUrl}icons/icon-${size}x${size}.png`, + sizes: `${size}x${size}`, + type: 'image/png' + })); + } + + private async generateIcons( + config: AppConfig, + outputDir: string, + baseUrl: string + ): Promise { + const sourceIcon = config.pwa?.sourceIcon; + + if (!sourceIcon) { + console.warn('PWA enabled but no source icon provided. Using placeholder.'); + await this.generatePlaceholderIcons(outputDir); + return; + } + + // Resolve source icon path + const sourcePath = this.resolveAssetPath(sourceIcon); + + if (!await fs.pathExists(sourcePath)) { + console.warn(`Source icon not found: ${sourcePath}. Using placeholder.`); + await this.generatePlaceholderIcons(outputDir); + return; + } + + // Ensure icons directory exists + const iconsDir = path.join(outputDir, 'icons'); + await fs.ensureDir(iconsDir); + + // Generate each size + for (const size of ICON_SIZES) { + const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`); + await this.resizeIcon(sourcePath, outputPath, size); + } + } + + private async resizeIcon( + sourcePath: string, + outputPath: string, + size: number + ): Promise { + // Use sharp for image resizing + const sharp = require('sharp'); + + try { + await sharp(sourcePath) + .resize(size, size, { + fit: 'contain', + background: { r: 255, g: 255, b: 255, alpha: 0 } + }) + .png() + .toFile(outputPath); + } catch (error) { + console.error(`Failed to resize icon to ${size}x${size}:`, error); + throw error; + } + } + + private async generatePlaceholderIcons(outputDir: string): Promise { + const iconsDir = path.join(outputDir, 'icons'); + await fs.ensureDir(iconsDir); + + // Generate simple colored placeholder icons + const sharp = require('sharp'); + + for (const size of ICON_SIZES) { + const outputPath = path.join(iconsDir, `icon-${size}x${size}.png`); + + // Create a simple colored square as placeholder + await sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 100, g: 100, b: 100, alpha: 1 } + } + }) + .png() + .toFile(outputPath); + } + } + + private resolveAssetPath(assetPath: string): string { + if (path.isAbsolute(assetPath)) { + return assetPath; + } + return path.join(this.project._retainedProjectDirectory, assetPath); + } +} +``` + +### 2. Icon Validation + +**File:** `packages/noodl-editor/src/editor/src/views/panels/AppSetupPanel/utils/icon-validation.ts` + +```typescript +export interface IconValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + dimensions?: { width: number; height: number }; +} + +export async function validatePWAIcon(filePath: string): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + try { + const sharp = require('sharp'); + const metadata = await sharp(filePath).metadata(); + + const { width, height, format } = metadata; + + // Check format + if (format !== 'png') { + errors.push('Icon must be a PNG file'); + } + + // Check dimensions + if (!width || !height) { + errors.push('Could not read image dimensions'); + } else { + // Check if square + if (width !== height) { + errors.push(`Icon must be square. Current: ${width}x${height}`); + } + + // Check minimum size + if (width < 512 || height < 512) { + errors.push(`Icon must be at least 512x512 pixels. Current: ${width}x${height}`); + } + + // Warn if not exactly 512x512 + if (width !== 512 && width > 512) { + warnings.push(`Icon will be scaled down from ${width}x${height} to required sizes`); + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + dimensions: width && height ? { width, height } : undefined + }; + + } catch (error) { + return { + valid: false, + errors: [`Failed to read image: ${error.message}`], + warnings: [] + }; + } +} +``` + +### 3. Service Worker Stub (Optional) + +**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/templates/sw.js` + +```javascript +// Basic service worker for PWA install prompt +// This is a minimal stub - users can replace with their own + +const CACHE_NAME = 'app-cache-v1'; +const ASSETS_TO_CACHE = [ + '/', + '/index.html' +]; + +// Install event - cache basic assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(ASSETS_TO_CACHE)) + ); +}); + +// Fetch event - network first, fallback to cache +self.addEventListener('fetch', (event) => { + event.respondWith( + fetch(event.request) + .catch(() => caches.match(event.request)) + ); +}); +``` + +--- + +## Files to Modify + +### 1. Build Pipeline + +**File:** `packages/noodl-editor/src/editor/src/utils/compilation/build/build.ts` (or equivalent) + +Add manifest processing to build pipeline: + +```typescript +import { ManifestProcessor } from './processors/manifest-processor'; + +async function buildProject(project: ProjectModel, options: BuildOptions): Promise { + // ... existing build steps ... + + // Generate PWA manifest and icons + const manifestProcessor = new ManifestProcessor(project); + await manifestProcessor.process({ + baseUrl: options.baseUrl, + outputDir: options.outputDir + }); + + // ... rest of build ... +} +``` + +### 2. Deploy Build + +Ensure the deploy process also runs manifest generation: + +**File:** `packages/noodl-editor/src/editor/src/utils/compilation/deploy/` + +```typescript +// Include manifest generation in deploy build +await manifestProcessor.process({ + baseUrl: deployConfig.baseUrl || '/', + outputDir: deployOutputDir +}); +``` + +--- + +## Icon Size Reference + +| Size | Usage | +|------|-------| +| 72x72 | Android home screen (legacy) | +| 96x96 | Android home screen | +| 128x128 | Chrome Web Store | +| 144x144 | IE/Edge pinned sites | +| 152x152 | iPad home screen | +| 192x192 | Android home screen (modern), Chrome install | +| 384x384 | Android splash screen | +| 512x512 | Android splash screen, PWA install prompt | + +--- + +## Testing Checklist + +### Manifest Generation + +- [ ] manifest.json created when PWA enabled +- [ ] manifest.json not created when PWA disabled +- [ ] name field matches appName +- [ ] short_name matches config or falls back to name +- [ ] description present if configured +- [ ] start_url correct +- [ ] display mode correct +- [ ] background_color correct +- [ ] theme_color correct +- [ ] icons array has all 8 sizes + +### Icon Generation + +- [ ] All 8 icon sizes generated +- [ ] Icons are valid PNG files +- [ ] Icons maintain aspect ratio +- [ ] Icons have correct dimensions +- [ ] Placeholder icons generated when no source +- [ ] Warning shown when source icon missing +- [ ] Error shown for invalid source format + +### Validation + +- [ ] Non-square icons rejected +- [ ] Non-PNG icons rejected +- [ ] Too-small icons rejected +- [ ] Valid icons pass validation +- [ ] Warnings for oversized icons + +### Integration + +- [ ] manifest.json linked in HTML head +- [ ] Icons accessible at correct paths +- [ ] PWA install prompt appears in Chrome +- [ ] App installs correctly +- [ ] Installed app shows correct icon +- [ ] Splash screen displays correctly + +--- + +## Notes for Implementer + +### Sharp Dependency + +The `sharp` library is required for image processing. It should already be available in the editor dependencies. If not: + +```bash +npm install sharp --save +``` + +### Service Worker Considerations + +The basic service worker stub is intentionally minimal. Advanced features like: +- Offline caching strategies +- Push notifications +- Background sync + +...should be implemented by users through custom code or future Noodl features. + +### Capacitor Integration (Phase 5) + +The icon generation logic will be reused for Capacitor builds: +- iOS requires specific sizes: 1024, 180, 167, 152, 120, 76, 60, 40, 29, 20 +- Android uses adaptive icons with foreground/background layers + +Consider making icon generation configurable for different target platforms. + +### Testing PWA Installation + +Test PWA installation on: +- Chrome (desktop and mobile) +- Edge (desktop) +- Safari (iOS - via Add to Home Screen) +- Firefox (Android) + +Use Chrome DevTools > Application > Manifest to verify manifest is valid. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-006-expression-integration.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-006-expression-integration.md new file mode 100644 index 0000000..d535b61 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/CONFIG-006-expression-integration.md @@ -0,0 +1,403 @@ +# CONFIG-006: Expression System Integration + +## Overview + +Integrate `Noodl.Config` access into the expression evaluator with autocomplete support for config keys. + +**Estimated effort:** 4-6 hours +**Dependencies:** CONFIG-001, TASK-006 (Expression System Overhaul) +**Blocks:** None + +--- + +## Objectives + +1. Make `Noodl.Config` accessible in Expression nodes +2. Add autocomplete for config keys in expression editor +3. Provide type hints for config values +4. Handle undefined config keys gracefully + +--- + +## Expression Usage Examples + +```javascript +// Simple value access +Noodl.Config.appName + +// Color in style binding +Noodl.Config.primaryColor + +// Conditional with config +Noodl.Config.pwaEnabled ? "Install App" : "Use in Browser" + +// Template literal +`Welcome to ${Noodl.Config.appName}!` + +// Array access +Noodl.Config.menuItems[0].label + +// Object property +Noodl.Config.apiSettings.timeout +``` + +--- + +## Files to Modify + +### 1. Expression Evaluator Context + +**File:** `packages/noodl-runtime/src/nodes/std-library/expression.js` (or new expression-evaluator module from TASK-006) + +Add Noodl.Config to the expression context: + +```javascript +// In the expression preamble or context setup +const { configManager } = require('../../../config/config-manager'); + +function createExpressionContext() { + return { + // Math helpers + min: Math.min, + max: Math.max, + cos: Math.cos, + sin: Math.sin, + // ... other existing helpers ... + + // Noodl globals + Noodl: { + Variables: Model.get('--ndl--global-variables'), + Objects: objectsProxy, + Arrays: arraysProxy, + Config: configManager.getConfig() // Add Config access + } + }; +} +``` + +If following TASK-006's enhanced expression node pattern: + +**File:** `packages/noodl-runtime/src/expression-evaluator.ts` + +```typescript +import { configManager } from './config/config-manager'; + +export function createExpressionContext(nodeContext: NodeContext): ExpressionContext { + return { + // ... existing context ... + + Noodl: { + // ... existing Noodl properties ... + Config: configManager.getConfig() + } + }; +} +``` + +### 2. Expression Editor Autocomplete + +**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/CodeEditor/` (or relevant autocomplete module) + +Add Config keys to autocomplete suggestions: + +```typescript +import { ProjectModel } from '@noodl-models/projectmodel'; +import { AppConfig } from '@noodl/runtime/src/config/types'; + +interface AutocompleteItem { + label: string; + kind: 'property' | 'method' | 'variable'; + detail?: string; + documentation?: string; +} + +function getConfigAutocompletions(prefix: string): AutocompleteItem[] { + const config = ProjectModel.instance.getAppConfig(); + const items: AutocompleteItem[] = []; + + // Built-in config keys + const builtInKeys = [ + { key: 'appName', type: 'string', doc: 'Application name' }, + { key: 'description', type: 'string', doc: 'Application description' }, + { key: 'coverImage', type: 'string', doc: 'Cover image path' }, + { key: 'ogTitle', type: 'string', doc: 'Open Graph title' }, + { key: 'ogDescription', type: 'string', doc: 'Open Graph description' }, + { key: 'ogImage', type: 'string', doc: 'Open Graph image' }, + { key: 'favicon', type: 'string', doc: 'Favicon path' }, + { key: 'themeColor', type: 'color', doc: 'Theme color' }, + { key: 'pwaEnabled', type: 'boolean', doc: 'PWA enabled state' }, + { key: 'pwaShortName', type: 'string', doc: 'PWA short name' }, + { key: 'pwaDisplay', type: 'string', doc: 'PWA display mode' }, + { key: 'pwaStartUrl', type: 'string', doc: 'PWA start URL' }, + { key: 'pwaBackgroundColor', type: 'color', doc: 'PWA background color' } + ]; + + // Add built-in keys + for (const { key, type, doc } of builtInKeys) { + if (key.startsWith(prefix)) { + items.push({ + label: key, + kind: 'property', + detail: type, + documentation: doc + }); + } + } + + // Add custom variables + for (const variable of config.variables) { + if (variable.key.startsWith(prefix)) { + items.push({ + label: variable.key, + kind: 'property', + detail: variable.type, + documentation: variable.description + }); + } + } + + return items; +} + +// In the autocomplete provider +function provideCompletions( + model: editor.ITextModel, + position: Position +): CompletionList { + const lineContent = model.getLineContent(position.lineNumber); + const beforeCursor = lineContent.substring(0, position.column - 1); + + // Check if typing after "Noodl.Config." + const configMatch = beforeCursor.match(/Noodl\.Config\.(\w*)$/); + if (configMatch) { + const prefix = configMatch[1]; + const items = getConfigAutocompletions(prefix); + + return { + suggestions: items.map(item => ({ + label: item.label, + kind: monaco.languages.CompletionItemKind.Property, + detail: item.detail, + documentation: item.documentation, + insertText: item.label, + range: new Range( + position.lineNumber, + position.column - prefix.length, + position.lineNumber, + position.column + ) + })) + }; + } + + // Check if typing "Noodl." + const noodlMatch = beforeCursor.match(/Noodl\.(\w*)$/); + if (noodlMatch) { + const prefix = noodlMatch[1]; + const noodlProps = ['Variables', 'Objects', 'Arrays', 'Config']; + + return { + suggestions: noodlProps + .filter(p => p.startsWith(prefix)) + .map(prop => ({ + label: prop, + kind: monaco.languages.CompletionItemKind.Module, + insertText: prop, + range: new Range( + position.lineNumber, + position.column - prefix.length, + position.lineNumber, + position.column + ) + })) + }; + } + + return { suggestions: [] }; +} +``` + +### 3. Type Hints + +**File:** Add type inference for config values + +```typescript +function getConfigValueType(key: string): string { + const config = ProjectModel.instance.getAppConfig(); + + // Check custom variables first + const variable = config.variables.find(v => v.key === key); + if (variable) { + return variable.type; + } + + // Built-in type mappings + const typeMap: Record = { + appName: 'string', + description: 'string', + coverImage: 'string', + ogTitle: 'string', + ogDescription: 'string', + ogImage: 'string', + favicon: 'string', + themeColor: 'color', + pwaEnabled: 'boolean', + pwaShortName: 'string', + pwaDisplay: 'string', + pwaStartUrl: 'string', + pwaBackgroundColor: 'color' + }; + + return typeMap[key] || 'any'; +} +``` + +### 4. Error Handling + +**File:** `packages/noodl-runtime/src/config/config-manager.ts` + +Ensure graceful handling of undefined keys: + +```typescript +// In the config proxy (from CONFIG-001) +get(target, prop: string) { + if (prop in target) { + return target[prop]; + } + + // Log warning in development + if (process.env.NODE_ENV !== 'production') { + console.warn( + `Noodl.Config.${prop} is not defined. ` + + `Add it in App Setup or check for typos.` + ); + } + + return undefined; +} +``` + +--- + +## Type Declarations Update + +**File:** `packages/noodl-viewer-react/static/viewer/global.d.ts.keep` + +Enhance the Config type declaration with JSDoc: + +```typescript +declare namespace Noodl { + /** + * App configuration values defined in App Setup. + * + * Config values are static and cannot be modified at runtime. + * Use `Noodl.Variables` for values that need to change. + * + * @example + * // Access app name + * const name = Noodl.Config.appName; + * + * // Use in template literal + * const greeting = `Welcome to ${Noodl.Config.appName}!`; + * + * // Access custom variable + * const color = Noodl.Config.primaryColor; + * + * @see https://docs.noodl.net/config + */ + const Config: Readonly<{ + /** Application name */ + readonly appName: string; + + /** Application description */ + readonly description: string; + + /** Cover image path or URL */ + readonly coverImage?: string; + + /** Open Graph title (defaults to appName) */ + readonly ogTitle: string; + + /** Open Graph description (defaults to description) */ + readonly ogDescription: string; + + /** Open Graph image path or URL */ + readonly ogImage?: string; + + /** Favicon path or URL */ + readonly favicon?: string; + + /** Theme color (hex format) */ + readonly themeColor?: string; + + /** Whether PWA is enabled */ + readonly pwaEnabled: boolean; + + /** PWA short name */ + readonly pwaShortName?: string; + + /** PWA display mode */ + readonly pwaDisplay?: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser'; + + /** PWA start URL */ + readonly pwaStartUrl?: string; + + /** PWA background color */ + readonly pwaBackgroundColor?: string; + + /** Custom configuration variables */ + readonly [key: string]: any; + }>; +} +``` + +--- + +## Testing Checklist + +### Expression Access + +- [ ] `Noodl.Config.appName` returns correct value +- [ ] `Noodl.Config.primaryColor` returns color value +- [ ] `Noodl.Config.menuItems` returns array +- [ ] Nested access works: `Noodl.Config.menuItems[0].label` +- [ ] Template literals work: `` `Hello ${Noodl.Config.appName}` `` +- [ ] Undefined keys return undefined (no crash) +- [ ] Warning logged for undefined keys in dev + +### Autocomplete + +- [ ] Typing "Noodl." suggests "Config" +- [ ] Typing "Noodl.Config." shows all config keys +- [ ] Built-in keys show correct types +- [ ] Custom variables appear in suggestions +- [ ] Variable descriptions show in autocomplete +- [ ] Autocomplete filters as you type + +### Integration + +- [ ] Works in Expression nodes +- [ ] Works in Function nodes +- [ ] Works in Script nodes +- [ ] Works in inline expressions (if TASK-006 complete) +- [ ] Values update when config changes (editor refresh) + +--- + +## Notes for Implementer + +### TASK-006 Dependency + +This task should be implemented alongside or after TASK-006 (Expression System Overhaul). If TASK-006 introduces a new expression evaluator module, integrate Config there. Otherwise, update the existing expression.js. + +### Monaco Editor + +The autocomplete examples assume Monaco editor is used. Adjust the implementation based on the actual editor component used in the expression input fields. + +### Performance + +The config object is frozen and created once at startup. Accessing it in expressions should have minimal performance impact. However, avoid calling `getConfig()` repeatedly - cache the reference. + +### Cloud Functions + +Remember to also update `packages/noodl-viewer-cloud/src/noodl-js-api.js` if cloud functions need Config access. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/README.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/README.md new file mode 100644 index 0000000..c4736ee --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-007-app-config/README.md @@ -0,0 +1,367 @@ +# TASK-007: App Configuration & Environment System + +## Overview + +A new "App Setup" sidebar panel for defining app-wide configuration values (`Noodl.Config`), SEO metadata, PWA manifest generation, and an App Config node for accessing values without expressions. + +**Estimated effort:** 64-86 hours +**Priority:** High - Foundation for theming, SEO, PWA, and deployment features +**Phase:** 3 (Editor UX Overhaul) + +--- + +## Problem Statement + +### Current Pain Points + +1. **No central place for app metadata**: App name, description, and SEO values are scattered or missing +2. **Global variables require node clutter**: Users create Object/Variables nodes at App level and wire them everywhere, creating visual noise +3. **No PWA support**: Users must manually create manifest.json and configure service workers +4. **SEO is an afterthought**: No built-in way to set meta tags, Open Graph, or favicon +5. **Theming is manual**: No standard pattern for defining color palettes or configuration values + +### User Stories + +- *"I want to define my app's primary color once and reference it everywhere"* +- *"I want my app to appear correctly when shared on social media"* +- *"I want to add my app to home screen on mobile with proper icons"* +- *"I want a simple way to access config values without writing expressions"* + +--- + +## Solution + +### New Namespace: `Noodl.Config` + +A **static, immutable** configuration namespace separate from `Noodl.Variables`: + +```javascript +// Static - set once at app initialization, cannot change at runtime +Noodl.Config.appName // "My Amazing App" +Noodl.Config.primaryColor // "#d21f3c" +Noodl.Config.apiBaseUrl // "https://api.example.com" +Noodl.Config.menuItems // [{name: "Home", path: "/"}, ...] + +// This is NOT allowed (throws or is ignored): +Noodl.Config.primaryColor = "#000000"; // ❌ Config is immutable +``` + +### Design Principles + +1. **Config is static**: Values are "baked in" at app load - use Variables for runtime changes +2. **Type-aware editing**: Color picker for colors, array editor for arrays, etc. +3. **Required defaults**: Core metadata (name, description) always exists +4. **Expression + Node access**: Both `Noodl.Config.x` in expressions AND an App Config node +5. **Build-time injection**: SEO tags and PWA manifest generated during build/deploy + +--- + +## Architecture + +### Data Model + +```typescript +interface AppConfig { + // Required sections (non-deletable) + identity: { + appName: string; + description: string; + coverImage?: string; // Path or URL + }; + + seo: { + ogTitle?: string; // Defaults to appName + ogDescription?: string; // Defaults to description + ogImage?: string; // Defaults to coverImage + favicon?: string; + themeColor?: string; + }; + + // Optional section + pwa?: { + enabled: boolean; + shortName?: string; + startUrl: string; // Default: "/" + display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser'; + backgroundColor?: string; + sourceIcon?: string; // Single source, auto-scaled to required sizes + }; + + // User-defined variables + variables: ConfigVariable[]; +} + +interface ConfigVariable { + key: string; // Valid JS identifier + type: ConfigType; + value: any; + description?: string; // Tooltip in UI + category?: string; // Grouping (defaults to "Custom") + validation?: { + required?: boolean; + pattern?: string; // Regex for strings + min?: number; // For numbers + max?: number; + }; +} + +type ConfigType = 'string' | 'number' | 'boolean' | 'color' | 'array' | 'object'; +``` + +### Storage Location + +Stored in `project.json` under metadata: + +```json +{ + "metadata": { + "appConfig": { + "identity": { + "appName": "My App", + "description": "An amazing app" + }, + "seo": { + "themeColor": "#d21f3c" + }, + "pwa": { + "enabled": true, + "display": "standalone" + }, + "variables": [ + { + "key": "primaryColor", + "type": "color", + "value": "#d21f3c", + "category": "Theme" + } + ] + } + } +} +``` + +--- + +## UI Design + +### App Setup Panel + +New top-level sidebar tab (migrating some project settings here): + +``` +┌─────────────────────────────────────────────────┐ +│ App Setup [?] │ +├─────────────────────────────────────────────────┤ +│ │ +│ APP IDENTITY │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ App Name [My Amazing App ] │ │ +│ │ Description [A visual programming... ] │ │ +│ │ [that makes building easy ] │ │ +│ │ Cover Image [🖼️ cover.png ][Browse] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ SEO & METADATA │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ OG Title [ ] │ │ +│ │ └ defaults to App Name │ │ +│ │ OG Description [ ] │ │ +│ │ └ defaults to Description │ │ +│ │ OG Image [🖼️ ][Browse] │ │ +│ │ └ defaults to Cover Image │ │ +│ │ Favicon [🖼️ favicon.ico ][Browse] │ │ +│ │ Theme Color [■ #d21f3c ] │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ▶ PWA CONFIGURATION [Enable] │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ Short Name [My App ] │ │ +│ │ Display Mode [Standalone ▼ ] │ │ +│ │ Start URL [/ ] │ │ +│ │ Background [■ #ffffff ] │ │ +│ │ App Icon [🖼️ icon-512.png][Browse] │ │ +│ │ └ 512x512 PNG, auto-scaled │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ CONFIGURATION VARIABLES [+ Add New] │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ ┌─ Theme ─────────────────────────────────┐ │ │ +│ │ │ primaryColor color [■ #d21f3c ][⋮]│ │ │ +│ │ │ secondaryColor color [■ #1f3cd2 ][⋮]│ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ ┌─ API ───────────────────────────────────┐ │ │ +│ │ │ apiBaseUrl string [https://... ][⋮]│ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ ┌─ Custom ────────────────────────────────┐ │ │ +│ │ │ maxUploadSize number [10 ][⋮]│ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +### App Config Node + +``` +┌──────────────────────────────┐ +│ App Config │ +├──────────────────────────────┤ +│ Variables │ +│ ┌──────────────────────────┐ │ +│ │ ☑ primaryColor │ │ +│ │ ☑ apiBaseUrl │ │ +│ │ ☐ secondaryColor │ │ +│ │ ☑ menuItems │ │ +│ └──────────────────────────┘ │ +├──────────────────────────────┤ +│ ○ primaryColor │──→ +│ ○ apiBaseUrl │──→ +│ ○ menuItems │──→ +└──────────────────────────────┘ +``` + +--- + +## Subtasks + +| Task | Description | Estimate | Dependencies | +|------|-------------|----------|--------------| +| [CONFIG-001](./CONFIG-001-infrastructure.md) | Core Infrastructure | 14-18h | None | +| [CONFIG-002](./CONFIG-002-app-setup-panel.md) | App Setup Panel UI | 18-24h | CONFIG-001 | +| [CONFIG-003](./CONFIG-003-app-config-node.md) | App Config Node | 10-14h | CONFIG-001 | +| [CONFIG-004](./CONFIG-004-seo-integration.md) | SEO Build Integration | 8-10h | CONFIG-001, CONFIG-002 | +| [CONFIG-005](./CONFIG-005-pwa-manifest.md) | PWA Manifest Generation | 10-14h | CONFIG-001, CONFIG-002 | +| [CONFIG-006](./CONFIG-006-expression-integration.md) | Expression System Integration | 4-6h | CONFIG-001, TASK-006 | + +**Total: 64-86 hours** + +--- + +## Cloud Service Node UX Improvement + +**Prerequisite cleanup task** (can be done as part of CONFIG-003 or separately): + +### Problem +The existing "Config" node accesses Noodl Cloud Service (Parse Server) configuration. This will cause confusion with the new App Config system. + +### Solution + +1. **Rename existing node**: `Config` → `Noodl Cloud Config` +2. **Hide cloud nodes when no backend**: If no cloud service is connected, hide ALL nodes in the "Cloud Data" category from the node picker +3. **Show guidance**: Display "Please add a backend service" message in that category until a service is connected +4. **Preserve existing nodes**: Don't break legacy projects - existing nodes continue to work, just hidden from picker + +### Files to modify: +- `packages/noodl-runtime/src/nodes/std-library/data/confignode.js` - Rename +- `packages/noodl-editor/src/editor/src/views/nodepicker/` - Category visibility logic +- `packages/noodl-runtime/src/nodelibraryexport.js` - Update node name in exports + +--- + +## Integration Points + +### With DEPLOY-003 (Environment Profiles) + +DEPLOY-003 can **override** Config values per environment: + +``` +App Setup Panel DEPLOY-003 Profiles +───────────────── ─────────────────── +apiBaseUrl: "dev" → Production: "https://api.prod.com" + Staging: "https://api.staging.com" + Development: (uses default) +``` + +The App Setup panel shows canonical/default values. DEPLOY-003 shows environment-specific overrides. + +### With TASK-006 (Expression System) + +The enhanced expression system will provide: +- `Noodl.Config.xxx` access in expressions +- Autocomplete for config keys +- Type hints + +### With Phase 5 (Capacitor Deployment) + +PWA icon generation will be reused for Capacitor app icons: +- Same 512x512 source image +- Generate iOS/Android required sizes +- Include in platform-specific builds + +--- + +## Component Export Behavior + +When exporting a component that uses `Noodl.Config.primaryColor`: + +| Variable Type | Export Behavior | +|---------------|-----------------| +| **Custom variables** | Hard-coded to current values | +| **Default variables** (appName, etc.) | Reference preserved | + +The importer is responsible for updating hard-coded values if needed. + +--- + +## Testing Checklist + +### Manual Testing + +- [ ] App Setup panel appears in sidebar +- [ ] Can edit all identity fields +- [ ] Can edit SEO fields (with defaults shown) +- [ ] Can enable/disable PWA section +- [ ] Can add custom variables with different types +- [ ] Color picker works for color type +- [ ] Array editor works for array type +- [ ] Categories group variables correctly +- [ ] App Config node shows all custom variables +- [ ] Selected variables appear as outputs +- [ ] Output types match variable types +- [ ] `Noodl.Config.xxx` accessible in expressions +- [ ] SEO tags appear in built HTML +- [ ] manifest.json generated with correct values +- [ ] PWA icons auto-scaled from source + +### Build Testing + +- [ ] Export includes correct meta tags +- [ ] manifest.json valid and complete +- [ ] Icons generated at all required sizes +- [ ] Theme color in HTML head +- [ ] Open Graph tags render correctly + +--- + +## Success Criteria + +1. **Discoverable**: New users find App Setup easily in sidebar +2. **Complete**: SEO and PWA work without manual file editing +3. **Type-safe**: Color variables show color pickers, arrays show array editors +4. **Non-breaking**: Existing projects work, cloud nodes still function +5. **Extensible**: DEPLOY-003 can override values per environment + +--- + +## Open Questions (Resolved) + +| Question | Decision | +|----------|----------| +| Namespace name? | `Noodl.Config` | +| Static or reactive? | Static (immutable at runtime) | +| Panel location? | New top-level sidebar tab | +| PWA icons? | Single 512x512 source, auto-scaled | +| Categories required? | Optional, ungrouped → "Custom" | +| Loaded signal on node? | No, overkill for static values | +| ENV node name? | "App Config" | + +--- + +## References + +- Existing project settings: `views/panels/ProjectSettingsPanel/` +- Port type editors: `views/panels/propertyeditor/DataTypes/Ports.ts` +- HTML build processor: `utils/compilation/build/processors/html-processor.ts` +- Expression system: `TASK-006-expressions-overhaul/` +- Deploy settings: `TASK-005-deployment-automation/DEPLOY-003-deploy-settings.md` +- Cloud config node: `noodl-runtime/src/nodes/std-library/data/confignode.js` diff --git a/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-001-sse-node-task.md b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-001-sse-node-task.md new file mode 100644 index 0000000..d3d71e6 --- /dev/null +++ b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-001-sse-node-task.md @@ -0,0 +1,739 @@ +# AGENT-001: Server-Sent Events (SSE) Node + +## Overview + +Create a new runtime node that establishes and manages Server-Sent Events (SSE) connections, enabling real-time streaming data from servers to Noodl applications. This is critical for agentic UI patterns where AI backends stream responses incrementally. + +**Phase:** 3.5 (Real-Time Agentic UI) +**Priority:** CRITICAL (blocks Erleah development) +**Effort:** 3-5 days +**Risk:** Medium + +--- + +## Problem Statement + +### Current Limitation + +The existing HTTP Request node only supports request-response patterns: + +``` +User Action → HTTP Request → Wait → Response → UI Update +``` + +This doesn't work for streaming use cases: + +``` +User Action → SSE Connect → Stream messages → Progressive UI Updates + ↓ + Message 1 → UI Update + Message 2 → UI Update + Message 3 → UI Update + ... +``` + +### Real-World Use Cases + +1. **AI Chat Responses** - Stream tokens as they generate (Erleah) +2. **Live Notifications** - Server pushes updates without polling +3. **Real-Time Dashboards** - Continuous metric updates +4. **Progress Updates** - Long-running backend operations +5. **Event Feeds** - News, social media, activity streams + +--- + +## Goals + +1. ✅ Establish and maintain SSE connections +2. ✅ Parse incoming messages (text and JSON) +3. ✅ Handle connection lifecycle (open, error, close) +4. ✅ Auto-reconnect on connection loss +5. ✅ Clean disconnection on node deletion +6. ✅ Support custom event types (via addEventListener) + +--- + +## Technical Design + +### Node Specification + +```javascript +{ + name: 'net.noodl.SSE', + displayNodeName: 'Server-Sent Events', + category: 'Data', + color: 'data', + docs: 'https://docs.noodl.net/nodes/data/sse', + searchTags: ['sse', 'stream', 'server-sent', 'events', 'realtime', 'websocket'] +} +``` + +### Port Schema + +#### Inputs + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| `url` | string | Connection | SSE endpoint URL | +| `connect` | signal | Actions | Establish connection | +| `disconnect` | signal | Actions | Close connection | +| `autoReconnect` | boolean | Connection | Auto-reconnect on disconnect (default: true) | +| `reconnectDelay` | number | Connection | Delay before reconnect (ms, default: 3000) | +| `withCredentials` | boolean | Connection | Include credentials (default: false) | +| `customHeaders` | object | Connection | Custom headers (EventSource doesn't support, document limitation) | + +#### Outputs + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| `message` | object | Data | Raw message event object | +| `data` | * | Data | Parsed message data (JSON or text) | +| `eventType` | string | Data | Event type (if custom events used) | +| `connected` | signal | Events | Fired when connection opens | +| `disconnected` | signal | Events | Fired when connection closes | +| `error` | string | Events | Error message | +| `isConnected` | boolean | Status | Current connection state | +| `lastMessageTime` | number | Status | Timestamp of last message (ms) | + +### State Machine + +``` + ┌─────────────┐ + START │ │ + ────┬─→│ DISCONNECTED│←──┐ + │ │ │ │ + │ └─────────────┘ │ + │ │ │ + │ [connect] │ + │ │ │ + │ ▼ │ + │ ┌─────────────┐ │ + │ │ CONNECTING │ │ + │ └─────────────┘ │ + │ │ │ + │ [onopen] │ + │ │ │ + │ ▼ │ + │ ┌─────────────┐ │ + └──│ CONNECTED │ │ + │ │ │ + └─────────────┘ │ + │ │ │ + [onmessage] │ + │ │ │ + [onerror/close] │ + │ │ + └───────────┘ + [autoReconnect + after delay] +``` + +--- + +## Implementation Details + +### File Structure + +``` +packages/noodl-runtime/src/nodes/std-library/data/ +├── ssenode.js # Main node implementation +└── ssenode.test.js # Unit tests +``` + +### Core Implementation + +```javascript +var SSENode = { + name: 'net.noodl.SSE', + displayNodeName: 'Server-Sent Events', + category: 'Data', + color: 'data', + + initialize: function() { + this._internal.eventSource = null; + this._internal.isConnected = false; + this._internal.messageBuffer = []; + this._internal.reconnectTimer = null; + this._internal.customEventListeners = new Map(); + }, + + inputs: { + url: { + type: 'string', + displayName: 'URL', + group: 'Connection', + set: function(value) { + this._internal.url = value; + } + }, + + connect: { + type: 'signal', + displayName: 'Connect', + group: 'Actions', + valueChangedToTrue: function() { + this.doConnect(); + } + }, + + disconnect: { + type: 'signal', + displayName: 'Disconnect', + group: 'Actions', + valueChangedToTrue: function() { + this.doDisconnect(); + } + }, + + autoReconnect: { + type: 'boolean', + displayName: 'Auto Reconnect', + group: 'Connection', + default: true, + set: function(value) { + this._internal.autoReconnect = value; + } + }, + + reconnectDelay: { + type: 'number', + displayName: 'Reconnect Delay (ms)', + group: 'Connection', + default: 3000, + set: function(value) { + this._internal.reconnectDelay = value; + } + }, + + withCredentials: { + type: 'boolean', + displayName: 'With Credentials', + group: 'Connection', + default: false, + set: function(value) { + this._internal.withCredentials = value; + } + }, + + // For custom event types (beyond 'message') + eventType: { + type: 'string', + displayName: 'Listen for Event Type', + group: 'Connection', + set: function(value) { + this._internal.customEventType = value; + if (this._internal.eventSource && value) { + this.addCustomEventListener(value); + } + } + } + }, + + outputs: { + message: { + type: 'object', + displayName: 'Message', + group: 'Data', + getter: function() { + return this._internal.lastMessage; + } + }, + + data: { + type: '*', + displayName: 'Data', + group: 'Data', + getter: function() { + return this._internal.lastData; + } + }, + + eventType: { + type: 'string', + displayName: 'Event Type', + group: 'Data', + getter: function() { + return this._internal.lastEventType; + } + }, + + connected: { + type: 'signal', + displayName: 'Connected', + group: 'Events' + }, + + disconnected: { + type: 'signal', + displayName: 'Disconnected', + group: 'Events' + }, + + messageReceived: { + type: 'signal', + displayName: 'Message Received', + group: 'Events' + }, + + error: { + type: 'string', + displayName: 'Error', + group: 'Events', + getter: function() { + return this._internal.lastError; + } + }, + + isConnected: { + type: 'boolean', + displayName: 'Is Connected', + group: 'Status', + getter: function() { + return this._internal.isConnected; + } + }, + + lastMessageTime: { + type: 'number', + displayName: 'Last Message Time', + group: 'Status', + getter: function() { + return this._internal.lastMessageTime; + } + } + }, + + methods: { + doConnect: function() { + // Disconnect existing connection if any + if (this._internal.eventSource) { + this.doDisconnect(); + } + + const url = this._internal.url; + if (!url) { + this.setError('URL is required'); + return; + } + + try { + // Create EventSource with options + const options = { + withCredentials: this._internal.withCredentials || false + }; + + const eventSource = new EventSource(url, options); + this._internal.eventSource = eventSource; + + // Connection opened + eventSource.onopen = () => { + this._internal.isConnected = true; + this._internal.lastError = null; + this.flagOutputDirty('isConnected'); + this.flagOutputDirty('error'); + this.sendSignalOnOutput('connected'); + + console.log('[SSE Node] Connected to', url); + }; + + // Message received (default event type) + eventSource.onmessage = (event) => { + this.handleMessage(event, 'message'); + }; + + // Connection error/closed + eventSource.onerror = (error) => { + console.error('[SSE Node] Connection error:', error); + + const wasConnected = this._internal.isConnected; + this._internal.isConnected = false; + this.flagOutputDirty('isConnected'); + + if (wasConnected) { + this.sendSignalOnOutput('disconnected'); + } + + // Check if connection is permanently closed + if (eventSource.readyState === EventSource.CLOSED) { + this.setError('Connection closed'); + + // Auto-reconnect if enabled + if (this._internal.autoReconnect) { + const delay = this._internal.reconnectDelay || 3000; + console.log(`[SSE Node] Reconnecting in ${delay}ms...`); + + this._internal.reconnectTimer = setTimeout(() => { + this.doConnect(); + }, delay); + } + } + }; + + // Add custom event listener if specified + if (this._internal.customEventType) { + this.addCustomEventListener(this._internal.customEventType); + } + + } catch (e) { + this.setError(e.message); + console.error('[SSE Node] Failed to connect:', e); + } + }, + + doDisconnect: function() { + // Clear reconnect timer + if (this._internal.reconnectTimer) { + clearTimeout(this._internal.reconnectTimer); + this._internal.reconnectTimer = null; + } + + // Close connection + if (this._internal.eventSource) { + this._internal.eventSource.close(); + this._internal.eventSource = null; + this._internal.isConnected = false; + this.flagOutputDirty('isConnected'); + this.sendSignalOnOutput('disconnected'); + + console.log('[SSE Node] Disconnected'); + } + }, + + addCustomEventListener: function(eventType) { + if (!this._internal.eventSource || !eventType) return; + + // Remove old listener if exists + const oldListener = this._internal.customEventListeners.get(eventType); + if (oldListener) { + this._internal.eventSource.removeEventListener(eventType, oldListener); + } + + // Add new listener + const listener = (event) => { + this.handleMessage(event, eventType); + }; + + this._internal.eventSource.addEventListener(eventType, listener); + this._internal.customEventListeners.set(eventType, listener); + }, + + handleMessage: function(event, type) { + this._internal.lastMessageTime = Date.now(); + this._internal.lastEventType = type || 'message'; + this._internal.lastMessage = { + data: event.data, + lastEventId: event.lastEventId, + type: type || 'message' + }; + + // Try to parse as JSON + try { + this._internal.lastData = JSON.parse(event.data); + } catch (e) { + // Not JSON, use raw string + this._internal.lastData = event.data; + } + + this.flagOutputDirty('message'); + this.flagOutputDirty('data'); + this.flagOutputDirty('eventType'); + this.flagOutputDirty('lastMessageTime'); + this.sendSignalOnOutput('messageReceived'); + }, + + setError: function(message) { + this._internal.lastError = message; + this.flagOutputDirty('error'); + }, + + _onNodeDeleted: function() { + this.doDisconnect(); + } + }, + + getInspectInfo: function() { + if (this._internal.isConnected) { + return { + type: 'value', + value: { + status: 'Connected', + url: this._internal.url, + lastMessage: this._internal.lastData, + messageCount: this._internal.messageCount || 0 + } + }; + } + return { + type: 'text', + value: this._internal.isConnected ? 'Connected' : 'Disconnected' + }; + } +}; + +module.exports = { + node: SSENode +}; +``` + +--- + +## Usage Examples + +### Example 1: AI Chat Streaming + +``` +[Text Input: "Hello AI"] + → [Send] signal + → [HTTP Request] POST /chat/start → returns chatId + → [String Format] "/chat/{chatId}/stream" + → [SSE Node] url + → [SSE Node] connect + +[SSE Node] data + → [Array] accumulate messages + → [Repeater] render messages + +[SSE Node] messageReceived + → [Scroll To Bottom] in chat container +``` + +### Example 2: Live Notifications + +``` +[Component Mounted] signal + → [SSE Node] connect to "/notifications/stream" + +[SSE Node] data + → [Show Toast] notification + +[SSE Node] connected + → [Variable] isLive = true → [Visual indicator] + +[SSE Node] disconnected + → [Variable] isLive = false → [Show offline banner] +``` + +### Example 3: Progress Tracker + +``` +[Start Upload] signal + → [HTTP Request] POST /upload → returns uploadId + → [SSE Node] connect to "/upload/{uploadId}/progress" + +[SSE Node] data → { percent: number } + → [Progress Bar] value + +[SSE Node] data → { status: "complete" } + → [SSE Node] disconnect + → [Navigate] to success page +``` + +--- + +## Testing Checklist + +### Functional Tests + +- [ ] Connection establishes to valid SSE endpoint +- [ ] Connection fails gracefully with invalid URL +- [ ] Messages are received and parsed correctly +- [ ] JSON messages parse to objects +- [ ] Plain text messages output as strings +- [ ] `connected` signal fires on open +- [ ] `disconnected` signal fires on close +- [ ] `messageReceived` signal fires for each message +- [ ] `isConnected` reflects current state +- [ ] Auto-reconnect works after disconnect +- [ ] Reconnect delay is respected +- [ ] Manual disconnect stops auto-reconnect +- [ ] Custom event types are received +- [ ] withCredentials flag works correctly +- [ ] Node cleanup works (no memory leaks) + +### Edge Cases + +- [ ] Handles rapid connect/disconnect cycles +- [ ] Handles very large messages (>1MB) +- [ ] Handles malformed JSON gracefully +- [ ] Handles server sending error events +- [ ] Handles network going offline/online +- [ ] Handles multiple SSE nodes simultaneously +- [ ] Connection closes cleanly on component unmount +- [ ] Reconnect timer clears on manual disconnect + +### Performance + +- [ ] Memory usage stable over 1000+ messages +- [ ] No visible UI lag during streaming +- [ ] Message parsing doesn't block main thread +- [ ] Multiple connections don't interfere + +--- + +## Browser Compatibility + +EventSource (SSE) is supported in: + +| Browser | Support | +|---------|---------| +| Chrome | ✅ Yes | +| Firefox | ✅ Yes | +| Safari | ✅ Yes | +| Edge | ✅ Yes | +| IE 11 | ❌ No (polyfill available) | + +For IE 11 support, can use: [event-source-polyfill](https://www.npmjs.com/package/event-source-polyfill) + +--- + +## Limitations & Workarounds + +### Limitation 1: No Custom Headers +EventSource API doesn't support custom headers (like Authorization). + +**Workaround:** +- Send auth token as query parameter: `/stream?token=abc123` +- Use cookie-based authentication (withCredentials: true) +- Or use WebSocket instead (AGENT-002) + +### Limitation 2: No Request Body +SSE is GET-only, can't send request body. + +**Workaround:** +- Create session/channel via POST first +- Connect SSE to session-specific URL: `/stream/{sessionId}` + +### Limitation 3: One-Way Communication +Server → Client only. Client can't send messages over same connection. + +**Workaround:** +- Use separate HTTP requests for client → server +- Or use WebSocket for bidirectional (AGENT-002) + +--- + +## Documentation Requirements + +### User-Facing Docs + +Create: `docs/nodes/data/sse.md` + +```markdown +# Server-Sent Events + +Stream real-time data from your server to your Noodl app using Server-Sent Events (SSE). Perfect for live notifications, AI chat responses, progress updates, and real-time dashboards. + +## When to Use + +- **AI Chat**: Stream tokens as they generate +- **Notifications**: Push updates without polling +- **Dashboards**: Continuous metric updates +- **Progress**: Long-running operation status + +## Basic Usage + +1. Add SSE node to your component +2. Set the `URL` to your SSE endpoint +3. Send the `Connect` signal +4. Use the `data` output to access messages +5. Listen to `messageReceived` to trigger actions + +## Example: Live Chat + +[Screenshot showing SSE node connected to chat UI] + +## Authentication + +Since EventSource doesn't support custom headers, use query parameters: + +``` +URL: https://api.example.com/stream?token={authToken} +``` + +Or enable `With Credentials` for cookie-based auth. + +## Auto-Reconnect + +By default, SSE nodes auto-reconnect if connection drops. Configure: +- `Auto Reconnect`: Enable/disable +- `Reconnect Delay`: Wait time in milliseconds + +## Custom Events + +Servers can send named events. Use the `Event Type` input to listen for specific events. + +```javascript +// Server sends: +event: notification +data: {"message": "New user signed up"} + +// Noodl receives on 'notification' event type +``` +``` + +### Technical Docs + +Add to: `dev-docs/reference/NODE-PATTERNS.md` + +Section on streaming nodes. + +--- + +## Success Criteria + +1. ✅ SSE node successfully streams data in test app +2. ✅ Auto-reconnect works reliably +3. ✅ No memory leaks over extended usage +4. ✅ Clear documentation with examples +5. ✅ Works in Erleah prototype for AI chat streaming + +--- + +## Future Enhancements + +Post-MVP features to consider: + +1. **Message Buffering** - Store last N messages for replays +2. **Rate Limiting** - Throttle message processing +3. **Event Filtering** - Filter messages by criteria before output +4. **Last Event ID** - Resume from last message on reconnect +5. **Connection Pooling** - Share connection across components + +--- + +## References + +- [MDN: EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) +- [HTML Living Standard: SSE](https://html.spec.whatwg.org/multipage/server-sent-events.html) +- [SSE vs WebSocket](https://ably.com/topic/server-sent-events-vs-websockets) + +--- + +## Dependencies + +- None (uses native EventSource API) + +## Blocked By + +- None + +## Blocks + +- AGENT-007 (Stream Parser Utilities) - needs SSE for testing +- Erleah development - requires streaming AI responses + +--- + +## Estimated Effort Breakdown + +| Phase | Estimate | Description | +|-------|----------|-------------| +| Core Implementation | 1 day | Basic SSE node with connect/disconnect/message | +| Error Handling | 0.5 day | Graceful failures, reconnect logic | +| Testing | 1 day | Unit tests, integration tests, edge cases | +| Documentation | 0.5 day | User docs, technical docs, examples | +| Edge Cases & Polish | 0.5-1 day | Performance, memory, browser compat | + +**Total: 3.5-4 days** + +Buffer: +1 day for unexpected issues = **4-5 days total** diff --git a/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-002-websocket-node-task.md b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-002-websocket-node-task.md new file mode 100644 index 0000000..bc61b93 --- /dev/null +++ b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-002-websocket-node-task.md @@ -0,0 +1,923 @@ +# AGENT-002: WebSocket Node + +## Overview + +Create a new runtime node that establishes and manages WebSocket connections, enabling bidirectional real-time communication between Noodl applications and servers. This complements SSE (AGENT-001) by supporting two-way messaging patterns. + +**Phase:** 3.5 (Real-Time Agentic UI) +**Priority:** HIGH +**Effort:** 3-5 days +**Risk:** Medium + +--- + +## Problem Statement + +### Current Limitation + +The existing HTTP Request node and SSE node (AGENT-001) only support one-way communication: + +``` +HTTP: Client → Server → Response (one-shot) +SSE: Server → Client (one-way stream) +``` + +WebSocket enables true bidirectional communication: + +``` +WebSocket: Client ⇄ Server (continuous two-way) +``` + +### Real-World Use Cases + +1. **Collaborative Editing** - Multiple users editing same document +2. **Gaming** - Real-time multiplayer interactions +3. **Chat Applications** - Send and receive messages +4. **Live Cursors** - Show where other users are pointing +5. **Device Control** - Send commands, receive telemetry +6. **Trading Platforms** - Real-time price updates + order placement + +--- + +## Goals + +1. ✅ Establish and maintain WebSocket connections +2. ✅ Send messages (text and binary) +3. ✅ Receive messages (text and binary) +4. ✅ Handle connection lifecycle (open, error, close) +5. ✅ Auto-reconnect with exponential backoff +6. ✅ Ping/pong heartbeat for connection health +7. ✅ Queue messages when disconnected + +--- + +## Technical Design + +### Node Specification + +```javascript +{ + name: 'net.noodl.WebSocket', + displayNodeName: 'WebSocket', + category: 'Data', + color: 'data', + docs: 'https://docs.noodl.net/nodes/data/websocket', + searchTags: ['websocket', 'ws', 'realtime', 'bidirectional', 'socket'] +} +``` + +### Port Schema + +#### Inputs + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| `url` | string | Connection | WebSocket URL (ws:// or wss://) | +| `connect` | signal | Actions | Establish connection | +| `disconnect` | signal | Actions | Close connection | +| `send` | signal | Actions | Send message | +| `message` | * | Message | Data to send (JSON serialized if object) | +| `messageType` | enum | Message | 'text' or 'binary' (default: text) | +| `autoReconnect` | boolean | Connection | Auto-reconnect on disconnect (default: true) | +| `reconnectDelay` | number | Connection | Initial delay (ms, default: 1000) | +| `maxReconnectDelay` | number | Connection | Max delay with backoff (ms, default: 30000) | +| `protocols` | string | Connection | Comma-separated subprotocols | +| `queueWhenDisconnected` | boolean | Message | Queue messages while offline (default: true) | +| `heartbeatInterval` | number | Connection | Ping interval (ms, 0=disabled, default: 30000) | + +#### Outputs + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| `received` | * | Data | Received message data | +| `receivedRaw` | string | Data | Raw message string | +| `messageReceived` | signal | Events | Fired when message arrives | +| `messageSent` | signal | Events | Fired after send succeeds | +| `connected` | signal | Events | Fired when connection opens | +| `disconnected` | signal | Events | Fired when connection closes | +| `error` | string | Events | Error message | +| `isConnected` | boolean | Status | Current connection state | +| `queueSize` | number | Status | Messages waiting to send | +| `latency` | number | Status | Round-trip time (ms) | + +### State Machine + +``` + ┌─────────────┐ + START │ │ + ────┬─→│ DISCONNECTED│←──┐ + │ │ │ │ + │ └─────────────┘ │ + │ │ │ + │ [connect] │ + │ │ │ + │ ▼ │ + │ ┌─────────────┐ │ + │ │ CONNECTING │ │ + │ └─────────────┘ │ + │ │ │ + │ [onopen] │ + │ │ │ + │ ▼ │ + │ ┌─────────────┐ │ + └──│ CONNECTED │ │ + │ │ │ + │ [send/recv]│ │ + │ │ │ + └─────────────┘ │ + │ │ │ + [onclose/error] │ + │ │ + └───────────┘ + [autoReconnect + with backoff] +``` + +--- + +## Implementation Details + +### File Structure + +``` +packages/noodl-runtime/src/nodes/std-library/data/ +├── websocketnode.js # Main node implementation +└── websocketnode.test.js # Unit tests +``` + +### Core Implementation + +```javascript +var WebSocketNode = { + name: 'net.noodl.WebSocket', + displayNodeName: 'WebSocket', + category: 'Data', + color: 'data', + + initialize: function() { + this._internal.socket = null; + this._internal.isConnected = false; + this._internal.messageQueue = []; + this._internal.reconnectAttempts = 0; + this._internal.reconnectTimer = null; + this._internal.heartbeatTimer = null; + this._internal.lastPingTime = 0; + }, + + inputs: { + url: { + type: 'string', + displayName: 'URL', + group: 'Connection', + set: function(value) { + this._internal.url = value; + } + }, + + connect: { + type: 'signal', + displayName: 'Connect', + group: 'Actions', + valueChangedToTrue: function() { + this.doConnect(); + } + }, + + disconnect: { + type: 'signal', + displayName: 'Disconnect', + group: 'Actions', + valueChangedToTrue: function() { + this.doDisconnect(); + } + }, + + send: { + type: 'signal', + displayName: 'Send', + group: 'Actions', + valueChangedToTrue: function() { + this.doSend(); + } + }, + + message: { + type: '*', + displayName: 'Message', + group: 'Message', + set: function(value) { + this._internal.messageToSend = value; + } + }, + + messageType: { + type: { + name: 'enum', + enums: [ + { label: 'Text', value: 'text' }, + { label: 'Binary', value: 'binary' } + ] + }, + displayName: 'Message Type', + group: 'Message', + default: 'text', + set: function(value) { + this._internal.messageType = value; + } + }, + + autoReconnect: { + type: 'boolean', + displayName: 'Auto Reconnect', + group: 'Connection', + default: true, + set: function(value) { + this._internal.autoReconnect = value; + } + }, + + reconnectDelay: { + type: 'number', + displayName: 'Reconnect Delay (ms)', + group: 'Connection', + default: 1000, + set: function(value) { + this._internal.reconnectDelay = value; + } + }, + + maxReconnectDelay: { + type: 'number', + displayName: 'Max Reconnect Delay (ms)', + group: 'Connection', + default: 30000, + set: function(value) { + this._internal.maxReconnectDelay = value; + } + }, + + protocols: { + type: 'string', + displayName: 'Protocols', + group: 'Connection', + set: function(value) { + this._internal.protocols = value; + } + }, + + queueWhenDisconnected: { + type: 'boolean', + displayName: 'Queue When Disconnected', + group: 'Message', + default: true, + set: function(value) { + this._internal.queueWhenDisconnected = value; + } + }, + + heartbeatInterval: { + type: 'number', + displayName: 'Heartbeat Interval (ms)', + group: 'Connection', + default: 30000, + set: function(value) { + this._internal.heartbeatInterval = value; + if (this._internal.isConnected) { + this.startHeartbeat(); + } + } + } + }, + + outputs: { + received: { + type: '*', + displayName: 'Received', + group: 'Data', + getter: function() { + return this._internal.lastReceived; + } + }, + + receivedRaw: { + type: 'string', + displayName: 'Received Raw', + group: 'Data', + getter: function() { + return this._internal.lastReceivedRaw; + } + }, + + messageReceived: { + type: 'signal', + displayName: 'Message Received', + group: 'Events' + }, + + messageSent: { + type: 'signal', + displayName: 'Message Sent', + group: 'Events' + }, + + connected: { + type: 'signal', + displayName: 'Connected', + group: 'Events' + }, + + disconnected: { + type: 'signal', + displayName: 'Disconnected', + group: 'Events' + }, + + error: { + type: 'string', + displayName: 'Error', + group: 'Events', + getter: function() { + return this._internal.lastError; + } + }, + + isConnected: { + type: 'boolean', + displayName: 'Is Connected', + group: 'Status', + getter: function() { + return this._internal.isConnected; + } + }, + + queueSize: { + type: 'number', + displayName: 'Queue Size', + group: 'Status', + getter: function() { + return this._internal.messageQueue.length; + } + }, + + latency: { + type: 'number', + displayName: 'Latency (ms)', + group: 'Status', + getter: function() { + return this._internal.latency || 0; + } + } + }, + + methods: { + doConnect: function() { + // Disconnect existing + if (this._internal.socket) { + this.doDisconnect(); + } + + const url = this._internal.url; + if (!url) { + this.setError('URL is required'); + return; + } + + if (!url.startsWith('ws://') && !url.startsWith('wss://')) { + this.setError('URL must start with ws:// or wss://'); + return; + } + + try { + // Parse protocols + const protocols = this._internal.protocols + ? this._internal.protocols.split(',').map(p => p.trim()) + : undefined; + + const socket = new WebSocket(url, protocols); + this._internal.socket = socket; + + // Connection opened + socket.onopen = () => { + this._internal.isConnected = true; + this._internal.reconnectAttempts = 0; + this._internal.lastError = null; + + this.flagOutputDirty('isConnected'); + this.flagOutputDirty('error'); + this.sendSignalOnOutput('connected'); + + // Start heartbeat + this.startHeartbeat(); + + // Flush queued messages + this.flushMessageQueue(); + + console.log('[WebSocket] Connected to', url); + }; + + // Message received + socket.onmessage = (event) => { + this.handleMessage(event.data); + }; + + // Connection closed + socket.onclose = (event) => { + console.log('[WebSocket] Closed:', event.code, event.reason); + + this._internal.isConnected = false; + this.flagOutputDirty('isConnected'); + this.sendSignalOnOutput('disconnected'); + + this.stopHeartbeat(); + + // Auto-reconnect + if (this._internal.autoReconnect && !event.wasClean) { + this.scheduleReconnect(); + } + }; + + // Connection error + socket.onerror = (error) => { + console.error('[WebSocket] Error:', error); + this.setError('Connection error'); + }; + + } catch (e) { + this.setError(e.message); + console.error('[WebSocket] Failed to connect:', e); + } + }, + + doDisconnect: function() { + // Clear timers + if (this._internal.reconnectTimer) { + clearTimeout(this._internal.reconnectTimer); + this._internal.reconnectTimer = null; + } + this.stopHeartbeat(); + + // Close socket + if (this._internal.socket) { + this._internal.socket.close(1000, 'Client disconnect'); + this._internal.socket = null; + this._internal.isConnected = false; + this.flagOutputDirty('isConnected'); + + console.log('[WebSocket] Disconnected'); + } + }, + + doSend: function() { + const message = this._internal.messageToSend; + if (message === undefined || message === null) { + return; + } + + // If not connected, queue or drop + if (!this._internal.isConnected) { + if (this._internal.queueWhenDisconnected) { + this._internal.messageQueue.push(message); + this.flagOutputDirty('queueSize'); + console.log('[WebSocket] Message queued (disconnected)'); + } else { + this.setError('Cannot send: not connected'); + } + return; + } + + try { + const socket = this._internal.socket; + const messageType = this._internal.messageType || 'text'; + + // Serialize based on type + let data; + if (messageType === 'binary') { + // Convert to ArrayBuffer or Blob + if (typeof message === 'string') { + data = new TextEncoder().encode(message); + } else { + data = message; + } + } else { + // Text mode - serialize objects as JSON + if (typeof message === 'object') { + data = JSON.stringify(message); + } else { + data = String(message); + } + } + + socket.send(data); + this.sendSignalOnOutput('messageSent'); + + } catch (e) { + this.setError('Send failed: ' + e.message); + } + }, + + handleMessage: function(data) { + this._internal.lastReceivedRaw = data; + + // Try to parse as JSON + try { + this._internal.lastReceived = JSON.parse(data); + } catch (e) { + // Not JSON, use raw + this._internal.lastReceived = data; + } + + // Check if it's a pong response + if (data === 'pong' && this._internal.lastPingTime) { + this._internal.latency = Date.now() - this._internal.lastPingTime; + this.flagOutputDirty('latency'); + return; // Don't emit messageReceived for pong + } + + this.flagOutputDirty('received'); + this.flagOutputDirty('receivedRaw'); + this.sendSignalOnOutput('messageReceived'); + }, + + flushMessageQueue: function() { + const queue = this._internal.messageQueue; + if (queue.length === 0) return; + + console.log(`[WebSocket] Flushing ${queue.length} queued messages`); + + while (queue.length > 0) { + this._internal.messageToSend = queue.shift(); + this.doSend(); + } + + this.flagOutputDirty('queueSize'); + }, + + scheduleReconnect: function() { + const baseDelay = this._internal.reconnectDelay || 1000; + const maxDelay = this._internal.maxReconnectDelay || 30000; + const attempts = this._internal.reconnectAttempts; + + // Exponential backoff: baseDelay * 2^attempts + const delay = Math.min(baseDelay * Math.pow(2, attempts), maxDelay); + + console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${attempts + 1})`); + + this._internal.reconnectTimer = setTimeout(() => { + this._internal.reconnectAttempts++; + this.doConnect(); + }, delay); + }, + + startHeartbeat: function() { + this.stopHeartbeat(); + + const interval = this._internal.heartbeatInterval; + if (!interval || interval <= 0) return; + + this._internal.heartbeatTimer = setInterval(() => { + if (this._internal.isConnected) { + this._internal.lastPingTime = Date.now(); + try { + this._internal.socket.send('ping'); + } catch (e) { + console.error('[WebSocket] Heartbeat failed:', e); + } + } + }, interval); + }, + + stopHeartbeat: function() { + if (this._internal.heartbeatTimer) { + clearInterval(this._internal.heartbeatTimer); + this._internal.heartbeatTimer = null; + } + }, + + setError: function(message) { + this._internal.lastError = message; + this.flagOutputDirty('error'); + }, + + _onNodeDeleted: function() { + this.doDisconnect(); + } + }, + + getInspectInfo: function() { + if (this._internal.isConnected) { + return { + type: 'value', + value: { + status: 'Connected', + url: this._internal.url, + latency: this._internal.latency + 'ms', + queueSize: this._internal.messageQueue.length + } + }; + } + return { + type: 'text', + value: 'Disconnected' + }; + } +}; + +module.exports = { + node: WebSocketNode +}; +``` + +--- + +## Usage Examples + +### Example 1: Chat Application + +``` +[Text Input: message] + → [WebSocket] message + +[Send Button] clicked + → [WebSocket] send signal + +[WebSocket] connected + → [Variable] isOnline = true + +[WebSocket] received + → [Array] add to messages + → [Repeater] render chat + +[WebSocket] disconnected + → [Show Toast] "Connection lost, reconnecting..." +``` + +### Example 2: Collaborative Cursors + +``` +[Mouse Move Event] + → [Debounce] 100ms + → [Object] { x, y, userId } + → [WebSocket] message + → [WebSocket] send + +[WebSocket] received → { x, y, userId } + → [Array Filter] exclude own cursor + → [Repeater] render other cursors +``` + +### Example 3: Real-Time Game + +``` +// Send player action +[Keyboard Event: space] + → [Object] { action: "jump", timestamp: Date.now() } + → [WebSocket] message + → [WebSocket] send + +// Receive game state +[WebSocket] received → { players: [], score: 100 } + → [For Each] in players + → [Sprite] update positions +``` + +### Example 4: IoT Device Control + +``` +// Send command +[Toggle Switch] changed + → [Object] { device: "light-1", state: value } + → [WebSocket] send + +// Receive telemetry +[WebSocket] received → { temperature: 72, humidity: 45 } + → [Number] temperature + → [Gauge] display +``` + +--- + +## Testing Checklist + +### Functional Tests + +- [ ] Connection establishes to valid WebSocket server +- [ ] Connection fails gracefully with invalid URL +- [ ] Can send text messages +- [ ] Can send JSON objects (auto-serialized) +- [ ] Can receive text messages +- [ ] Can receive JSON messages (auto-parsed) +- [ ] `connected` signal fires on open +- [ ] `disconnected` signal fires on close +- [ ] `messageSent` signal fires after send +- [ ] `messageReceived` signal fires on receive +- [ ] `isConnected` reflects current state +- [ ] Auto-reconnect works after disconnect +- [ ] Exponential backoff increases delay +- [ ] Messages queue when disconnected +- [ ] Queued messages flush on reconnect +- [ ] Manual disconnect stops auto-reconnect +- [ ] Heartbeat sends ping messages +- [ ] Latency calculated from ping/pong +- [ ] Subprotocols negotiated correctly +- [ ] Node cleanup works (no memory leaks) + +### Edge Cases + +- [ ] Handles rapid connect/disconnect cycles +- [ ] Handles very large messages (>1MB) +- [ ] Handles binary data correctly +- [ ] Handles server closing connection unexpectedly +- [ ] Handles network going offline/online +- [ ] Queue doesn't grow unbounded +- [ ] Multiple WebSocket nodes don't interfere +- [ ] Connection closes cleanly on component unmount + +### Performance + +- [ ] Memory usage stable over 1000+ messages +- [ ] No visible UI lag during high-frequency messages +- [ ] Queue processing doesn't block main thread +- [ ] Heartbeat doesn't impact performance + +--- + +## WebSocket vs SSE Decision Guide + +Help users choose between WebSocket (AGENT-002) and SSE (AGENT-001): + +| Feature | SSE | WebSocket | +|---------|-----|-----------| +| **Direction** | Server → Client only | Bidirectional | +| **Protocol** | HTTP/1.1, HTTP/2 | WebSocket protocol | +| **Auto-reconnect** | Native browser behavior | Manual implementation | +| **Message format** | Text (typically JSON) | Text or Binary | +| **Firewall friendly** | ✅ Yes (uses HTTP) | ⚠️ Sometimes blocked | +| **Complexity** | Simpler | More complex | +| **Use when** | Server pushes updates | Client needs to send data | + +**Rule of thumb:** +- Need to **receive** updates → SSE +- Need to **send and receive** → WebSocket + +--- + +## Browser Compatibility + +WebSocket is supported in all modern browsers: + +| Browser | Support | +|---------|---------| +| Chrome | ✅ Yes | +| Firefox | ✅ Yes | +| Safari | ✅ Yes | +| Edge | ✅ Yes | +| IE 11 | ⚠️ Partial (no binary frames) | + +--- + +## Security Considerations + +### 1. Use WSS (WebSocket Secure) +Always use `wss://` in production, not `ws://`. This encrypts traffic. + +### 2. Authentication +WebSocket doesn't support custom headers in browser. Options: +- Send auth token as first message after connect +- Include token in URL query: `wss://example.com/ws?token=abc123` +- Use cookie-based authentication + +### 3. Message Validation +Always validate received messages server-side. Don't trust client data. + +### 4. Rate Limiting +Implement server-side rate limiting to prevent abuse. + +--- + +## Documentation Requirements + +### User-Facing Docs + +Create: `docs/nodes/data/websocket.md` + +```markdown +# WebSocket + +Enable real-time bidirectional communication with WebSocket servers. Perfect for chat, collaborative editing, gaming, and live dashboards. + +## When to Use + +- **Chat**: Send and receive messages +- **Collaboration**: Real-time multi-user editing +- **Gaming**: Multiplayer interactions +- **IoT**: Device control and telemetry +- **Trading**: Price updates + order placement + +## WebSocket vs Server-Sent Events + +- Use **SSE** when server pushes updates, client doesn't send +- Use **WebSocket** when client needs to send data to server + +## Basic Usage + +1. Add WebSocket node +2. Set `URL` to your WebSocket endpoint (wss://...) +3. Send `Connect` signal +4. To send: Set `message`, trigger `send` signal +5. To receive: Listen to `messageReceived`, read `received` + +## Example: Chat + +[Screenshot showing WebSocket in chat application] + +## Queuing + +When disconnected, messages can queue automatically: +- Enable `Queue When Disconnected` +- Messages send when reconnected + +## Heartbeat + +Keep connection alive with automatic ping/pong: +- Set `Heartbeat Interval` (milliseconds) +- Monitor `Latency` for connection health + +## Security + +Always use `wss://` (secure) in production, not `ws://`. + +For authentication: +```javascript +// Send auth on connect +[WebSocket] connected + → [Object] { type: "auth", token: authToken } + → [WebSocket] message + → [WebSocket] send +``` +``` + +--- + +## Success Criteria + +1. ✅ WebSocket node successfully connects and exchanges messages +2. ✅ Auto-reconnect works reliably with exponential backoff +3. ✅ Message queuing prevents data loss during disconnects +4. ✅ Heartbeat detects dead connections +5. ✅ No memory leaks over extended usage +6. ✅ Clear documentation with examples +7. ✅ Works in Erleah for real-time backend communication + +--- + +## Future Enhancements + +Post-MVP features to consider: + +1. **Compression** - Enable permessage-deflate extension +2. **Binary Frames** - Better binary data support +3. **Subprotocol Handling** - React to negotiated protocol +4. **Message Buffering** - Batch sends for performance +5. **Connection Pooling** - Share socket across components +6. **Custom Heartbeat Messages** - Beyond ping/pong + +--- + +## References + +- [MDN: WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) +- [WebSocket Protocol RFC 6455](https://tools.ietf.org/html/rfc6455) +- [WebSocket vs SSE](https://ably.com/topic/websockets-vs-sse) + +--- + +## Dependencies + +- None (uses native WebSocket API) + +## Blocked By + +- None + +## Blocks + +- Erleah development - requires bidirectional communication + +--- + +## Estimated Effort Breakdown + +| Phase | Estimate | Description | +|-------|----------|-------------| +| Core Implementation | 1.5 days | Basic WebSocket with send/receive | +| Auto-reconnect & Queue | 1 day | Exponential backoff, message queuing | +| Heartbeat | 0.5 day | Ping/pong latency tracking | +| Testing | 1 day | Unit tests, integration tests, edge cases | +| Documentation | 0.5 day | User docs, technical docs, examples | +| Polish | 0.5 day | Error handling, performance, cleanup | + +**Total: 5 days** + +Buffer: None needed (straightforward implementation) + +**Final: 3-5 days** (depending on testing depth) diff --git a/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-003-global-state-store-task.md b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-003-global-state-store-task.md new file mode 100644 index 0000000..ea1d957 --- /dev/null +++ b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-003-global-state-store-task.md @@ -0,0 +1,1042 @@ +# AGENT-003: Global State Store + +## Overview + +Create a global observable store system that enables reactive state sharing across components, similar to Zustand or Redux. This replaces the current pattern of passing state through Component Inputs/Outputs or using Send/Receive Event for every state update. + +**Phase:** 3.5 (Real-Time Agentic UI) +**Priority:** CRITICAL (core infrastructure) +**Effort:** 2-3 days +**Risk:** Low + +--- + +## Problem Statement + +### Current Limitation + +Noodl currently requires manual state synchronization: + +``` +Component A changes value + → Send Event "valueChanged" + → Component B Receive Event "valueChanged" + → Manually update Component B's state + → Component C also needs Receive Event + → Component D also needs Receive Event + ... (scales poorly) +``` + +Or through prop drilling: + +``` +Root Component + ├─ Component A (receives prop) + │ ├─ Component B (receives prop) + │ │ └─ Component C (finally uses prop!) + │ └─ Component D (also receives prop) + └─ Component E (receives prop) +``` + +### Desired Pattern: Observable Store + +``` +[Global Store "app"] + ├─ Component A subscribes → auto-updates + ├─ Component B subscribes → auto-updates + ├─ Component C subscribes → auto-updates + └─ Timeline View subscribes → auto-updates + +Component A changes value + → Store updates + → ALL subscribers react automatically +``` + +### Real-World Use Cases (Erleah) + +1. **Timeline + Parking Lot** - Both views show same agenda items +2. **Chat + Timeline** - Chat updates trigger timeline changes +3. **User Session** - Current user, auth state, preferences +4. **Real-time Sync** - Backend updates propagate to all views +5. **Draft State** - Unsaved changes visible across UI + +--- + +## Goals + +1. ✅ Create named stores with typed state +2. ✅ Subscribe components to stores (auto-update on changes) +3. ✅ Update store values (trigger all subscriptions) +4. ✅ Selective subscriptions (only listen to specific keys) +5. ✅ Transaction support (batch multiple updates) +6. ✅ Computed values (derived state) +7. ✅ Persistence (optional localStorage sync) + +--- + +## Technical Design + +### Node Specifications + +We'll create THREE nodes for complete store functionality: + +1. **Global Store** - Creates/accesses a store, outputs state +2. **Global Store Set** - Updates store values +3. **Global Store Subscribe** - Listens to specific keys + +### Global Store Node + +```javascript +{ + name: 'net.noodl.GlobalStore', + displayNodeName: 'Global Store', + category: 'Data', + color: 'purple', + docs: 'https://docs.noodl.net/nodes/data/global-store' +} +``` + +#### Ports: Global Store + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `storeName` | string | Store | Name of store to access (default: "app") | +| `initialState` | object | Store | Initial state if store doesn't exist | +| `persist` | boolean | Store | Save to localStorage (default: false) | +| `storageKey` | string | Store | localStorage key (default: storeName) | +| **Outputs** | +| `state` | object | Data | Current complete state | +| `stateChanged` | signal | Events | Fires when any value changes | +| `ready` | signal | Events | Fires when store initialized | +| `storeId` | string | Info | Unique store identifier | + +### Global Store Set Node + +```javascript +{ + name: 'net.noodl.GlobalStore.Set', + displayNodeName: 'Set Global Store', + category: 'Data', + color: 'purple' +} +``` + +#### Ports: Global Store Set + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `storeName` | string | Store | Name of store to update | +| `set` | signal | Actions | Trigger update | +| `key` | string | Update | Key to update | +| `value` | * | Update | New value | +| `merge` | boolean | Update | Merge object (default: false) | +| `transaction` | boolean | Update | Batch with other updates | +| **Outputs** | +| `completed` | signal | Events | Fires after update | +| `error` | string | Events | Error message if update fails | + +### Global Store Subscribe Node + +```javascript +{ + name: 'net.noodl.GlobalStore.Subscribe', + displayNodeName: 'Subscribe to Store', + category: 'Data', + color: 'purple' +} +``` + +#### Ports: Global Store Subscribe + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `storeName` | string | Store | Name of store to watch | +| `keys` | string | Subscribe | Comma-separated keys to watch (blank = all) | +| **Outputs** | +| `value` | * | Data | Current value of watched key(s) | +| `changed` | signal | Events | Fires when watched keys change | +| `previousValue` | * | Data | Value before change | + +--- + +## Implementation Details + +### File Structure + +``` +packages/noodl-runtime/src/nodes/std-library/data/ +├── globalstore.js # Global Store singleton +├── globalstorenode.js # Global Store node +├── globalstoresetnode.js # Set node +├── globalstoresubscribenode.js # Subscribe node +└── globalstore.test.js # Unit tests +``` + +### Core Store Implementation + +```javascript +// globalstore.js - Singleton store manager + +class GlobalStoreManager { + constructor() { + this.stores = new Map(); + this.subscribers = new Map(); + } + + /** + * Get or create a store + */ + getStore(name, initialState = {}) { + if (!this.stores.has(name)) { + this.stores.set(name, { ...initialState }); + this.subscribers.set(name, new Map()); + + console.log(`[GlobalStore] Created store: ${name}`); + } + return this.stores.get(name); + } + + /** + * Get current state + */ + getState(name) { + return this.stores.get(name) || {}; + } + + /** + * Update state (triggers subscribers) + */ + setState(name, updates, options = {}) { + const current = this.getStore(name); + const { merge = false, transaction = false } = options; + + let next; + if (merge && typeof updates === 'object' && typeof current === 'object') { + next = { ...current, ...updates }; + } else { + next = updates; + } + + this.stores.set(name, next); + + // Notify subscribers (unless in transaction) + if (!transaction) { + this.notify(name, next, current, updates); + } + + // Persist if enabled + this.persistStore(name); + } + + /** + * Update a specific key + */ + setKey(name, key, value, options = {}) { + const current = this.getStore(name); + const updates = { [key]: value }; + + const next = { ...current, ...updates }; + this.stores.set(name, next); + + if (!options.transaction) { + this.notify(name, next, current, updates); + } + + this.persistStore(name); + } + + /** + * Subscribe to store changes + */ + subscribe(name, callback, keys = []) { + if (!this.subscribers.has(name)) { + this.subscribers.set(name, new Map()); + } + + const subscriberId = Math.random().toString(36); + const storeSubscribers = this.subscribers.get(name); + + storeSubscribers.set(subscriberId, { callback, keys }); + + // Return unsubscribe function + return () => { + storeSubscribers.delete(subscriberId); + }; + } + + /** + * Notify subscribers of changes + */ + notify(name, nextState, prevState, updates) { + const subscribers = this.subscribers.get(name); + if (!subscribers) return; + + const changedKeys = Object.keys(updates); + + subscribers.forEach(({ callback, keys }) => { + // If no specific keys, always notify + if (!keys || keys.length === 0) { + callback(nextState, prevState, changedKeys); + return; + } + + // Check if any watched key changed + const hasChange = keys.some(key => changedKeys.includes(key)); + if (hasChange) { + callback(nextState, prevState, changedKeys); + } + }); + } + + /** + * Persist store to localStorage + */ + persistStore(name) { + const storeMeta = this.storeMeta.get(name); + if (!storeMeta || !storeMeta.persist) return; + + try { + const state = this.stores.get(name); + const key = storeMeta.storageKey || name; + localStorage.setItem(`noodl_store_${key}`, JSON.stringify(state)); + } catch (e) { + console.error(`[GlobalStore] Failed to persist ${name}:`, e); + } + } + + /** + * Load store from localStorage + */ + loadStore(name, storageKey) { + try { + const key = storageKey || name; + const data = localStorage.getItem(`noodl_store_${key}`); + if (data) { + return JSON.parse(data); + } + } catch (e) { + console.error(`[GlobalStore] Failed to load ${name}:`, e); + } + return null; + } + + /** + * Configure store options + */ + configureStore(name, options = {}) { + if (!this.storeMeta) { + this.storeMeta = new Map(); + } + this.storeMeta.set(name, options); + + // Load persisted state if enabled + if (options.persist) { + const persisted = this.loadStore(name, options.storageKey); + if (persisted) { + this.stores.set(name, persisted); + } + } + } + + /** + * Transaction: batch multiple updates + */ + transaction(name, updateFn) { + const current = this.getStore(name); + const updates = updateFn(current); + + const next = { ...current, ...updates }; + this.stores.set(name, next); + + this.notify(name, next, current, updates); + this.persistStore(name); + } + + /** + * Clear a store + */ + clearStore(name) { + this.stores.delete(name); + this.subscribers.delete(name); + + if (this.storeMeta?.has(name)) { + const meta = this.storeMeta.get(name); + if (meta.persist) { + const key = meta.storageKey || name; + localStorage.removeItem(`noodl_store_${key}`); + } + } + } +} + +// Singleton instance +const globalStoreManager = new GlobalStoreManager(); + +module.exports = { globalStoreManager }; +``` + +### Global Store Node Implementation + +```javascript +// globalstorenode.js + +const { globalStoreManager } = require('./globalstore'); + +var GlobalStoreNode = { + name: 'net.noodl.GlobalStore', + displayNodeName: 'Global Store', + category: 'Data', + color: 'purple', + + initialize: function() { + this._internal.storeName = 'app'; + this._internal.unsubscribe = null; + }, + + inputs: { + storeName: { + type: 'string', + displayName: 'Store Name', + group: 'Store', + default: 'app', + set: function(value) { + if (this._internal.unsubscribe) { + this._internal.unsubscribe(); + } + this._internal.storeName = value; + this.setupStore(); + } + }, + + initialState: { + type: 'object', + displayName: 'Initial State', + group: 'Store', + set: function(value) { + this._internal.initialState = value; + this.setupStore(); + } + }, + + persist: { + type: 'boolean', + displayName: 'Persist', + group: 'Store', + default: false, + set: function(value) { + this._internal.persist = value; + globalStoreManager.configureStore(this._internal.storeName, { + persist: value, + storageKey: this._internal.storageKey + }); + } + }, + + storageKey: { + type: 'string', + displayName: 'Storage Key', + group: 'Store', + set: function(value) { + this._internal.storageKey = value; + } + } + }, + + outputs: { + state: { + type: 'object', + displayName: 'State', + group: 'Data', + getter: function() { + return globalStoreManager.getState(this._internal.storeName); + } + }, + + stateChanged: { + type: 'signal', + displayName: 'State Changed', + group: 'Events' + }, + + ready: { + type: 'signal', + displayName: 'Ready', + group: 'Events' + }, + + storeId: { + type: 'string', + displayName: 'Store ID', + group: 'Info', + getter: function() { + return this._internal.storeName; + } + } + }, + + methods: { + setupStore: function() { + const storeName = this._internal.storeName; + const initialState = this._internal.initialState || {}; + + // Configure persistence + globalStoreManager.configureStore(storeName, { + persist: this._internal.persist, + storageKey: this._internal.storageKey + }); + + // Get or create store + globalStoreManager.getStore(storeName, initialState); + + // Subscribe to changes + this._internal.unsubscribe = globalStoreManager.subscribe( + storeName, + (nextState, prevState, changedKeys) => { + this.flagOutputDirty('state'); + this.sendSignalOnOutput('stateChanged'); + } + ); + + // Trigger initial state output + this.flagOutputDirty('state'); + this.sendSignalOnOutput('ready'); + }, + + _onNodeDeleted: function() { + if (this._internal.unsubscribe) { + this._internal.unsubscribe(); + } + } + }, + + getInspectInfo: function() { + const state = globalStoreManager.getState(this._internal.storeName); + return { + type: 'value', + value: state + }; + } +}; + +module.exports = { + node: GlobalStoreNode +}; +``` + +### Global Store Set Node Implementation + +```javascript +// globalstoresetnode.js + +const { globalStoreManager } = require('./globalstore'); + +var GlobalStoreSetNode = { + name: 'net.noodl.GlobalStore.Set', + displayNodeName: 'Set Global Store', + category: 'Data', + color: 'purple', + + inputs: { + storeName: { + type: 'string', + displayName: 'Store Name', + group: 'Store', + default: 'app' + }, + + set: { + type: 'signal', + displayName: 'Set', + group: 'Actions', + valueChangedToTrue: function() { + this.doSet(); + } + }, + + key: { + type: 'string', + displayName: 'Key', + group: 'Update' + }, + + value: { + type: '*', + displayName: 'Value', + group: 'Update' + }, + + merge: { + type: 'boolean', + displayName: 'Merge Object', + group: 'Update', + default: false + }, + + transaction: { + type: 'boolean', + displayName: 'Transaction', + group: 'Update', + default: false + } + }, + + outputs: { + completed: { + type: 'signal', + displayName: 'Completed', + group: 'Events' + }, + + error: { + type: 'string', + displayName: 'Error', + group: 'Events' + } + }, + + methods: { + doSet: function() { + const storeName = this._internal.storeName || 'app'; + const key = this._internal.key; + const value = this._internal.value; + + if (!key) { + this._internal.error = 'Key is required'; + this.flagOutputDirty('error'); + return; + } + + try { + const options = { + merge: this._internal.merge, + transaction: this._internal.transaction + }; + + globalStoreManager.setKey(storeName, key, value, options); + + this.sendSignalOnOutput('completed'); + } catch (e) { + this._internal.error = e.message; + this.flagOutputDirty('error'); + } + } + } +}; + +module.exports = { + node: GlobalStoreSetNode +}; +``` + +### Global Store Subscribe Node Implementation + +```javascript +// globalstoresubscribenode.js + +const { globalStoreManager } = require('./globalstore'); + +var GlobalStoreSubscribeNode = { + name: 'net.noodl.GlobalStore.Subscribe', + displayNodeName: 'Subscribe to Store', + category: 'Data', + color: 'purple', + + initialize: function() { + this._internal.unsubscribe = null; + }, + + inputs: { + storeName: { + type: 'string', + displayName: 'Store Name', + group: 'Store', + default: 'app', + set: function(value) { + if (this._internal.unsubscribe) { + this._internal.unsubscribe(); + } + this._internal.storeName = value; + this.setupSubscription(); + } + }, + + keys: { + type: 'string', + displayName: 'Keys', + group: 'Subscribe', + set: function(value) { + if (this._internal.unsubscribe) { + this._internal.unsubscribe(); + } + this._internal.keys = value; + this.setupSubscription(); + } + } + }, + + outputs: { + value: { + type: '*', + displayName: 'Value', + group: 'Data', + getter: function() { + const storeName = this._internal.storeName || 'app'; + const state = globalStoreManager.getState(storeName); + + const keys = this._internal.keys; + if (!keys) return state; + + // Return specific key(s) + const keyList = keys.split(',').map(k => k.trim()); + if (keyList.length === 1) { + return state[keyList[0]]; + } else { + // Multiple keys - return object + const result = {}; + keyList.forEach(key => { + result[key] = state[key]; + }); + return result; + } + } + }, + + changed: { + type: 'signal', + displayName: 'Changed', + group: 'Events' + }, + + previousValue: { + type: '*', + displayName: 'Previous Value', + group: 'Data', + getter: function() { + return this._internal.previousValue; + } + } + }, + + methods: { + setupSubscription: function() { + const storeName = this._internal.storeName || 'app'; + const keysStr = this._internal.keys; + const keys = keysStr ? keysStr.split(',').map(k => k.trim()) : []; + + this._internal.unsubscribe = globalStoreManager.subscribe( + storeName, + (nextState, prevState, changedKeys) => { + this._internal.previousValue = this._internal.value; + this.flagOutputDirty('value'); + this.flagOutputDirty('previousValue'); + this.sendSignalOnOutput('changed'); + }, + keys + ); + + // Trigger initial value + this.flagOutputDirty('value'); + }, + + _onNodeDeleted: function() { + if (this._internal.unsubscribe) { + this._internal.unsubscribe(); + } + } + } +}; + +module.exports = { + node: GlobalStoreSubscribeNode +}; +``` + +--- + +## Usage Examples + +### Example 1: Simple Shared Counter + +``` +// Component A - Display +[Global Store: "app"] + → state + → [Get Value] key: "count" + → [Text] text: "{count}" + +// Component B - Increment +[Button] clicked + → [Global Store: "app"] state + → [Get Value] key: "count" + → [Expression] value + 1 + → [Set Global Store] key: "count", value + → [Set Global Store] set signal +``` + +### Example 2: User Session (Erleah) + +``` +// Initialize store +[Component Mounted] + → [Global Store: "session"] + → initialState: { user: null, isAuthenticated: false } + +// Login updates +[Login Success] → userData + → [Set Global Store] storeName: "session", key: "user", value + → [Set Global Store] key: "isAuthenticated", value: true + → [Set Global Store] set + +// All components auto-update +[Subscribe to Store] storeName: "session", keys: "user" + → value + → [User Avatar] display + +[Subscribe to Store] storeName: "session", keys: "isAuthenticated" + → value + → [Condition] if true → show Dashboard +``` + +### Example 3: Timeline + Parking Lot Sync (Erleah) + +``` +// Global store holds agenda +[Global Store: "agenda"] + → initialState: { + timeline: [], + pendingConnections: [], + maybes: [] + } + +// Timeline View subscribes +[Subscribe to Store] storeName: "agenda", keys: "timeline" + → value + → [Repeater] render timeline items + +// Parking Lot subscribes +[Subscribe to Store] storeName: "agenda", keys: "pendingConnections,maybes" + → value + → [Repeater] render pending items + +// AI Agent updates via SSE +[SSE] data → { type: "ADD_TO_TIMELINE", item: {...} } + → [Set Global Store] storeName: "agenda", key: "timeline" + → [Array] push item + → [Set Global Store] set + + // Both views update automatically! +``` + +### Example 4: Draft State Persistence + +``` +[Global Store: "drafts"] + → persist: true + → storageKey: "user-drafts" + → initialState: {} + +// Save draft +[Text Input] changed → content + → [Set Global Store] storeName: "drafts" + → key: "message-{id}" + → value: content + → [Set Global Store] set + +// Load on mount +[Component Mounted] + → [Subscribe to Store] storeName: "drafts", keys: "message-{id}" + → value + → [Text Input] text +``` + +--- + +## Testing Checklist + +### Functional Tests + +- [ ] Store created with initial state +- [ ] Can set values in store +- [ ] Can get values from store +- [ ] Subscribers notified on changes +- [ ] Multiple components subscribe to same store +- [ ] Selective subscription (specific keys) works +- [ ] Store persists to localStorage when enabled +- [ ] Store loads from localStorage on init +- [ ] Transaction batches multiple updates +- [ ] Merge option works correctly +- [ ] Unsubscribe works on node deletion +- [ ] Multiple stores don't interfere + +### Edge Cases + +- [ ] Setting undefined/null values +- [ ] Setting non-serializable values (with persist) +- [ ] Very large states (>1MB) +- [ ] Rapid updates (100+ per second) +- [ ] Circular references in state +- [ ] localStorage quota exceeded +- [ ] Store name with special characters + +### Performance + +- [ ] Memory usage stable with many subscribers +- [ ] No visible lag with 100+ subscribers +- [ ] Persistence doesn't block main thread +- [ ] Selective subscriptions only trigger when needed + +--- + +## Documentation Requirements + +### User-Facing Docs + +Create: `docs/nodes/data/global-store.md` + +```markdown +# Global Store + +Share state across components with automatic reactivity. When the store updates, all subscribed components update automatically. + +## When to Use + +- **Shared State**: Multiple components need same data +- **Real-time Sync**: Backend updates propagate everywhere +- **User Session**: Auth, preferences, current user +- **Draft State**: Unsaved changes across UI +- **Theme/Settings**: App-wide configuration + +## vs Component Inputs/Outputs + +Traditional approach (prop drilling): +``` +Root → Child A → Child B → Child C (finally uses value!) +``` + +Global Store approach: +``` +Root sets value → All children subscribe → Auto-update +``` + +## Basic Usage + +**Step 1: Create Store** +``` +[Global Store] + storeName: "app" + initialState: { count: 0 } +``` + +**Step 2: Update Store** +``` +[Button] clicked + → [Set Global Store] + storeName: "app" + key: "count" + value: 5 + → set signal +``` + +**Step 3: Subscribe** +``` +[Subscribe to Store] + storeName: "app" + keys: "count" +→ value +→ [Text] "Count: {value}" +``` + +All subscribed components update when store changes! + +## Persistence + +Save store to browser storage: +``` +[Global Store] + persist: true + storageKey: "my-app-data" +``` + +Survives page refreshes! + +## Best Practices + +1. **Name stores by domain**: "user", "agenda", "settings" +2. **Use specific keys**: Subscribe to "user.name" not entire "user" +3. **Initialize early**: Create store in root component +4. **Persist carefully**: Don't persist sensitive data + +## Example: Shopping Cart + +[Full example with add/remove/persist] +``` + +--- + +## Success Criteria + +1. ✅ Store successfully synchronizes state across components +2. ✅ Selective subscriptions work (only trigger on relevant changes) +3. ✅ Persistence works without blocking UI +4. ✅ No memory leaks with many subscribers +5. ✅ Clear documentation with examples +6. ✅ Works in Erleah for Timeline/Parking Lot sync + +--- + +## Future Enhancements + +Post-MVP features to consider: + +1. **Computed Properties** - Derived state (e.g., `fullName` from `firstName + lastName`) +2. **Middleware** - Intercept updates (logging, validation) +3. **Time Travel** - Undo/redo (connects to AGENT-006) +4. **Async Actions** - Built-in async state management +5. **Devtools** - Browser extension for debugging stores +6. **Store Composition** - Nested/related stores + +--- + +## References + +- [Zustand](https://github.com/pmndrs/zustand) - Inspiration for API design +- [Redux](https://redux.js.org/) - State management concepts +- [Recoil](https://recoiljs.org/) - Atom-based state + +--- + +## Dependencies + +- None + +## Blocked By + +- None + +## Blocks + +- AGENT-004 (Optimistic Updates) - needs store for state management +- AGENT-005 (Action Dispatcher) - uses store for UI state +- Erleah development - requires synchronized state + +--- + +## Estimated Effort Breakdown + +| Phase | Estimate | Description | +|-------|----------|-------------| +| Store Manager | 0.5 day | Core singleton with pub/sub | +| Global Store Node | 0.5 day | Main node implementation | +| Set/Subscribe Nodes | 0.5 day | Helper nodes | +| Persistence | 0.5 day | localStorage integration | +| Testing | 0.5 day | Unit tests, edge cases | +| Documentation | 0.5 day | User docs, examples | + +**Total: 3 days** + +Buffer: None needed (straightforward implementation) + +**Final: 2-3 days** diff --git a/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-004-optimistic-updates-task.md b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-004-optimistic-updates-task.md new file mode 100644 index 0000000..9a687aa --- /dev/null +++ b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-004-optimistic-updates-task.md @@ -0,0 +1,883 @@ +# AGENT-004: Optimistic Update Pattern + +## Overview + +Create a pattern and helper nodes for implementing optimistic UI updates - updating the UI immediately before the server confirms the change, then rolling back if the server rejects it. This creates a more responsive user experience for network operations. + +**Phase:** 3.5 (Real-Time Agentic UI) +**Priority:** MEDIUM +**Effort:** 2-3 days +**Risk:** Low + +--- + +## Problem Statement + +### Current Pattern: Slow & Blocking + +``` +User clicks "Accept Connection" + ↓ +Show loading spinner + ↓ +Wait for server... (300-1000ms) + ↓ +Update UI to show "Connected" + ↓ +Hide spinner +``` + +**Problem:** User waits, UI feels sluggish. + +### Desired Pattern: Fast & Optimistic + +``` +User clicks "Accept Connection" + ↓ +Immediately show "Connected" (optimistic) + ↓ +Send request to server (background) + ↓ +IF success: Do nothing (already updated!) + ↓ +IF failure: Roll back to "Pending", show error +``` + +**Benefit:** UI feels instant, even with slow network. + +### Real-World Use Cases (Erleah) + +1. **Accept Connection Request** - Show "Accepted" immediately +2. **Add to Timeline** - Item appears instantly +3. **Send Chat Message** - Message shows while sending +4. **Toggle Bookmark** - Star fills immediately +5. **Drag-Drop Reorder** - Items reorder before server confirms + +--- + +## Goals + +1. ✅ Apply optimistic update to variable/store +2. ✅ Commit if backend succeeds +3. ✅ Rollback if backend fails +4. ✅ Show pending state (optional) +5. ✅ Queue multiple optimistic updates +6. ✅ Handle race conditions (out-of-order responses) +7. ✅ Integrate with Global Store (AGENT-003) + +--- + +## Technical Design + +### Node Specification + +```javascript +{ + name: 'net.noodl.OptimisticUpdate', + displayNodeName: 'Optimistic Update', + category: 'Data', + color: 'orange', + docs: 'https://docs.noodl.net/nodes/data/optimistic-update' +} +``` + +### Port Schema + +#### Inputs + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| `apply` | signal | Actions | Apply optimistic update | +| `commit` | signal | Actions | Confirm update succeeded | +| `rollback` | signal | Actions | Revert update | +| `optimisticValue` | * | Update | Value to apply optimistically | +| `storeName` | string | Store | Global store name (if using store) | +| `key` | string | Store | Store key to update | +| `variableName` | string | Variable | Or Variable node to update | +| `transactionId` | string | Transaction | Unique ID for this update | +| `timeout` | number | Config | Auto-rollback after ms (default: 30000) | + +#### Outputs + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| `value` | * | Data | Current value (optimistic or committed) | +| `isPending` | boolean | Status | Update awaiting confirmation | +| `isCommitted` | boolean | Status | Update confirmed | +| `isRolledBack` | boolean | Status | Update reverted | +| `applied` | signal | Events | Fires after optimistic apply | +| `committed` | signal | Events | Fires after commit | +| `rolledBack` | signal | Events | Fires after rollback | +| `timedOut` | signal | Events | Fires if timeout reached | +| `previousValue` | * | Data | Value before optimistic update | +| `error` | string | Events | Error message on rollback | + +### State Machine + +``` + ┌─────────┐ + START │ │ + ────┬─→│ IDLE │←──────────┐ + │ │ │ │ + │ └─────────┘ │ + │ │ │ + │ [apply] │ + │ │ │ + │ ▼ │ + │ ┌─────────┐ [commit] + │ │ PENDING │───────────┘ + │ │ │ + │ └─────────┘ + │ │ + │ [rollback] + │ [timeout] + │ │ + │ ▼ + │ ┌─────────┐ + └──│ROLLED │ + │BACK │ + └─────────┘ +``` + +--- + +## Implementation Details + +### File Structure + +``` +packages/noodl-runtime/src/nodes/std-library/data/ +├── optimisticupdatenode.js # Main node +├── optimisticmanager.js # Transaction manager +└── optimisticupdate.test.js # Tests +``` + +### Transaction Manager + +```javascript +// optimisticmanager.js + +class OptimisticUpdateManager { + constructor() { + this.transactions = new Map(); + } + + /** + * Apply optimistic update + */ + apply(transactionId, currentValue, optimisticValue, options = {}) { + if (this.transactions.has(transactionId)) { + throw new Error(`Transaction ${transactionId} already exists`); + } + + const transaction = { + id: transactionId, + previousValue: currentValue, + optimisticValue: optimisticValue, + appliedAt: Date.now(), + status: 'pending', + timeout: options.timeout || 30000, + timer: null + }; + + // Set timeout for auto-rollback + if (transaction.timeout > 0) { + transaction.timer = setTimeout(() => { + this.timeout(transactionId); + }, transaction.timeout); + } + + this.transactions.set(transactionId, transaction); + + console.log(`[OptimisticUpdate] Applied: ${transactionId}`); + + return { + value: optimisticValue, + isPending: true + }; + } + + /** + * Commit transaction (success) + */ + commit(transactionId) { + const transaction = this.transactions.get(transactionId); + if (!transaction) { + console.warn(`[OptimisticUpdate] Transaction not found: ${transactionId}`); + return null; + } + + // Clear timeout + if (transaction.timer) { + clearTimeout(transaction.timer); + } + + transaction.status = 'committed'; + this.transactions.delete(transactionId); + + console.log(`[OptimisticUpdate] Committed: ${transactionId}`); + + return { + value: transaction.optimisticValue, + isPending: false, + isCommitted: true + }; + } + + /** + * Rollback transaction (failure) + */ + rollback(transactionId, error = null) { + const transaction = this.transactions.get(transactionId); + if (!transaction) { + console.warn(`[OptimisticUpdate] Transaction not found: ${transactionId}`); + return null; + } + + // Clear timeout + if (transaction.timer) { + clearTimeout(transaction.timer); + } + + transaction.status = 'rolled_back'; + transaction.error = error; + + const result = { + value: transaction.previousValue, + isPending: false, + isRolledBack: true, + error: error + }; + + this.transactions.delete(transactionId); + + console.log(`[OptimisticUpdate] Rolled back: ${transactionId}`, error); + + return result; + } + + /** + * Auto-rollback on timeout + */ + timeout(transactionId) { + const transaction = this.transactions.get(transactionId); + if (!transaction) return null; + + console.warn(`[OptimisticUpdate] Timeout: ${transactionId}`); + + return this.rollback(transactionId, 'Request timed out'); + } + + /** + * Check if transaction is pending + */ + isPending(transactionId) { + const transaction = this.transactions.get(transactionId); + return transaction && transaction.status === 'pending'; + } + + /** + * Get transaction info + */ + getTransaction(transactionId) { + return this.transactions.get(transactionId); + } + + /** + * Clear all transactions (cleanup) + */ + clear() { + this.transactions.forEach(transaction => { + if (transaction.timer) { + clearTimeout(transaction.timer); + } + }); + this.transactions.clear(); + } +} + +const optimisticUpdateManager = new OptimisticUpdateManager(); + +module.exports = { optimisticUpdateManager }; +``` + +### Optimistic Update Node + +```javascript +// optimisticupdatenode.js + +const { optimisticUpdateManager } = require('./optimisticmanager'); +const { globalStoreManager } = require('./globalstore'); + +var OptimisticUpdateNode = { + name: 'net.noodl.OptimisticUpdate', + displayNodeName: 'Optimistic Update', + category: 'Data', + color: 'orange', + + initialize: function() { + this._internal.transactionId = null; + this._internal.currentValue = null; + this._internal.isPending = false; + }, + + inputs: { + apply: { + type: 'signal', + displayName: 'Apply', + group: 'Actions', + valueChangedToTrue: function() { + this.doApply(); + } + }, + + commit: { + type: 'signal', + displayName: 'Commit', + group: 'Actions', + valueChangedToTrue: function() { + this.doCommit(); + } + }, + + rollback: { + type: 'signal', + displayName: 'Rollback', + group: 'Actions', + valueChangedToTrue: function() { + this.doRollback(); + } + }, + + optimisticValue: { + type: '*', + displayName: 'Optimistic Value', + group: 'Update', + set: function(value) { + this._internal.optimisticValue = value; + } + }, + + storeName: { + type: 'string', + displayName: 'Store Name', + group: 'Store', + set: function(value) { + this._internal.storeName = value; + } + }, + + key: { + type: 'string', + displayName: 'Key', + group: 'Store', + set: function(value) { + this._internal.key = value; + } + }, + + transactionId: { + type: 'string', + displayName: 'Transaction ID', + group: 'Transaction', + set: function(value) { + this._internal.transactionId = value; + } + }, + + timeout: { + type: 'number', + displayName: 'Timeout (ms)', + group: 'Config', + default: 30000, + set: function(value) { + this._internal.timeout = value; + } + } + }, + + outputs: { + value: { + type: '*', + displayName: 'Value', + group: 'Data', + getter: function() { + return this._internal.currentValue; + } + }, + + isPending: { + type: 'boolean', + displayName: 'Is Pending', + group: 'Status', + getter: function() { + return this._internal.isPending; + } + }, + + isCommitted: { + type: 'boolean', + displayName: 'Is Committed', + group: 'Status', + getter: function() { + return this._internal.isCommitted; + } + }, + + isRolledBack: { + type: 'boolean', + displayName: 'Is Rolled Back', + group: 'Status', + getter: function() { + return this._internal.isRolledBack; + } + }, + + applied: { + type: 'signal', + displayName: 'Applied', + group: 'Events' + }, + + committed: { + type: 'signal', + displayName: 'Committed', + group: 'Events' + }, + + rolledBack: { + type: 'signal', + displayName: 'Rolled Back', + group: 'Events' + }, + + timedOut: { + type: 'signal', + displayName: 'Timed Out', + group: 'Events' + }, + + previousValue: { + type: '*', + displayName: 'Previous Value', + group: 'Data', + getter: function() { + return this._internal.previousValue; + } + }, + + error: { + type: 'string', + displayName: 'Error', + group: 'Events', + getter: function() { + return this._internal.error; + } + } + }, + + methods: { + doApply: function() { + const transactionId = this._internal.transactionId || this.generateTransactionId(); + const optimisticValue = this._internal.optimisticValue; + + // Get current value from store + let currentValue; + if (this._internal.storeName && this._internal.key) { + const store = globalStoreManager.getState(this._internal.storeName); + currentValue = store[this._internal.key]; + } else { + currentValue = this._internal.currentValue; + } + + // Apply optimistic update + const result = optimisticUpdateManager.apply( + transactionId, + currentValue, + optimisticValue, + { timeout: this._internal.timeout } + ); + + // Update store if configured + if (this._internal.storeName && this._internal.key) { + globalStoreManager.setKey( + this._internal.storeName, + this._internal.key, + optimisticValue + ); + } + + // Update internal state + this._internal.previousValue = currentValue; + this._internal.currentValue = optimisticValue; + this._internal.isPending = true; + this._internal.isCommitted = false; + this._internal.isRolledBack = false; + this._internal.transactionId = transactionId; + + this.flagOutputDirty('value'); + this.flagOutputDirty('isPending'); + this.flagOutputDirty('previousValue'); + this.sendSignalOnOutput('applied'); + }, + + doCommit: function() { + const transactionId = this._internal.transactionId; + if (!transactionId) { + console.warn('[OptimisticUpdate] No transaction to commit'); + return; + } + + const result = optimisticUpdateManager.commit(transactionId); + if (!result) return; + + this._internal.isPending = false; + this._internal.isCommitted = true; + + this.flagOutputDirty('isPending'); + this.flagOutputDirty('isCommitted'); + this.sendSignalOnOutput('committed'); + }, + + doRollback: function(error = null) { + const transactionId = this._internal.transactionId; + if (!transactionId) { + console.warn('[OptimisticUpdate] No transaction to rollback'); + return; + } + + const result = optimisticUpdateManager.rollback(transactionId, error); + if (!result) return; + + // Revert store if configured + if (this._internal.storeName && this._internal.key) { + globalStoreManager.setKey( + this._internal.storeName, + this._internal.key, + result.value + ); + } + + this._internal.currentValue = result.value; + this._internal.isPending = false; + this._internal.isRolledBack = true; + this._internal.error = result.error; + + this.flagOutputDirty('value'); + this.flagOutputDirty('isPending'); + this.flagOutputDirty('isRolledBack'); + this.flagOutputDirty('error'); + this.sendSignalOnOutput('rolledBack'); + + if (result.error === 'Request timed out') { + this.sendSignalOnOutput('timedOut'); + } + }, + + generateTransactionId: function() { + return 'tx_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + }, + + _onNodeDeleted: function() { + // Clean up pending transaction + if (this._internal.transactionId) { + optimisticUpdateManager.rollback(this._internal.transactionId); + } + } + }, + + getInspectInfo: function() { + return { + type: 'value', + value: { + status: this._internal.isPending ? 'Pending' : 'Idle', + value: this._internal.currentValue, + transactionId: this._internal.transactionId + } + }; + } +}; + +module.exports = { + node: OptimisticUpdateNode +}; +``` + +--- + +## Usage Examples + +### Example 1: Accept Connection (Erleah) + +``` +[Button: "Accept"] clicked + ↓ +[Optimistic Update] + optimisticValue: { status: "accepted" } + storeName: "connections" + key: "connection-{id}" + timeout: 5000 + ↓ +[Optimistic Update] apply signal + ↓ +[HTTP Request] POST /connections/{id}/accept + ↓ +[HTTP Request] success + → [Optimistic Update] commit + +[HTTP Request] failure + → [Optimistic Update] rollback + → [Show Toast] "Failed to accept connection" +``` + +### Example 2: Add to Timeline + +``` +[AI Agent] suggests session + ↓ +[Button: "Add to Timeline"] clicked + ↓ +[Optimistic Update] + optimisticValue: { ...sessionData, id: tempId } + storeName: "agenda" + key: "timeline" + ↓ +[Optimistic Update] value + → [Array] push to timeline array + → [Optimistic Update] optimisticValue + → [Optimistic Update] apply + +// Timeline immediately shows item! + +[HTTP Request] POST /agenda/sessions + → returns real session ID + ↓ +[Success] + → [Replace temp ID with real ID] + → [Optimistic Update] commit + +[Failure] + → [Optimistic Update] rollback + → [Show Error] "Could not add session" +``` + +### Example 3: Chat Message Send + +``` +[Text Input] → messageText +[Send Button] clicked + ↓ +[Object] create message + id: tempId + text: messageText + status: "sending" + timestamp: now + ↓ +[Optimistic Update] + storeName: "chat" + key: "messages" + ↓ +[Optimistic Update] value (current messages) + → [Array] push new message + → [Optimistic Update] optimisticValue + → [Optimistic Update] apply + +// Message appears immediately with "sending" indicator + +[HTTP Request] POST /messages + ↓ +[Success] → real message from server + → [Update message status to "sent"] + → [Optimistic Update] commit + +[Failure] + → [Optimistic Update] rollback + → [Update message status to "failed"] + → [Show Retry Button] +``` + +### Example 4: Toggle Bookmark + +``` +[Star Icon] clicked + ↓ +[Variable: isBookmarked] current value + → [Expression] !value (toggle) + → [Optimistic Update] optimisticValue + ↓ +[Optimistic Update] apply + ↓ +[Star Icon] filled = [Optimistic Update] value + +// Star fills immediately + +[HTTP Request] POST /bookmarks + ↓ +[Success] + → [Optimistic Update] commit + +[Failure] + → [Optimistic Update] rollback + → [Show Toast] "Couldn't save bookmark" + +// Star unfills on failure +``` + +--- + +## Testing Checklist + +### Functional Tests + +- [ ] Apply sets value optimistically +- [ ] Commit keeps optimistic value +- [ ] Rollback reverts to previous value +- [ ] Timeout triggers automatic rollback +- [ ] isPending reflects correct state +- [ ] Store integration works +- [ ] Multiple transactions don't interfere +- [ ] Transaction IDs are unique +- [ ] Signals fire at correct times + +### Edge Cases + +- [ ] Commit without apply (no-op) +- [ ] Rollback without apply (no-op) +- [ ] Apply twice with same transaction ID (error) +- [ ] Commit after timeout (no-op) +- [ ] Very fast success (commit before timeout) +- [ ] Network reconnect scenarios +- [ ] Component unmount cleans up transaction + +### Performance + +- [ ] No memory leaks with many transactions +- [ ] Timeout cleanup works correctly +- [ ] Multiple optimistic updates in quick succession + +--- + +## Documentation Requirements + +### User-Facing Docs + +Create: `docs/nodes/data/optimistic-update.md` + +```markdown +# Optimistic Update + +Make your UI feel instant by updating immediately, then confirming with the server later. Perfect for actions like liking, bookmarking, or accepting requests. + +## The Problem + +Without optimistic updates: +``` +User clicks button → Show spinner → Wait... → Update UI + (feels slow) +``` + +With optimistic updates: +``` +User clicks button → Update UI immediately → Confirm in background + (feels instant!) +``` + +## Basic Pattern + +1. Apply optimistic update (UI changes immediately) +2. Send request to server (background) +3. If success: Commit (keep the change) +4. If failure: Rollback (undo the change) + +## Example: Like Button + +[Full example with visual diagrams] + +## With Global Store + +For shared state, use with Global Store: + +``` +[Optimistic Update] + storeName: "posts" + key: "likes" + optimisticValue: likesCount + 1 +``` + +All components subscribing to the store update automatically! + +## Timeout + +If server doesn't respond: +``` +[Optimistic Update] + timeout: 5000 // Auto-rollback after 5s +``` + +## Best Practices + +1. **Always handle rollback**: Show error message +2. **Show pending state**: "Saving..." indicator (optional) +3. **Use unique IDs**: Let node generate, or provide your own +4. **Set reasonable timeout**: 5-30 seconds depending on operation +``` + +--- + +## Success Criteria + +1. ✅ Optimistic updates apply immediately +2. ✅ Rollback works on failure +3. ✅ Timeout prevents stuck pending states +4. ✅ Integrates with Global Store +5. ✅ No memory leaks +6. ✅ Clear documentation with examples +7. ✅ Works in Erleah for responsive interactions + +--- + +## Future Enhancements + +1. **Retry Logic** - Auto-retry failed operations +2. **Conflict Resolution** - Handle concurrent updates +3. **Offline Queue** - Queue updates when offline +4. **Animation Hooks** - Smooth transitions on rollback +5. **Batch Commits** - Commit multiple related transactions + +--- + +## References + +- [Optimistic UI](https://www.apollographql.com/docs/react/performance/optimistic-ui/) - Apollo GraphQL docs +- [React Query Optimistic Updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates) + +--- + +## Dependencies + +- AGENT-003 (Global State Store) - for store integration + +## Blocked By + +- AGENT-003 + +## Blocks + +- None (optional enhancement for Erleah) + +--- + +## Estimated Effort Breakdown + +| Phase | Estimate | Description | +|-------|----------|-------------| +| Transaction Manager | 0.5 day | Core state machine | +| Optimistic Update Node | 1 day | Main node with store integration | +| Testing | 0.5 day | Unit tests, edge cases | +| Documentation | 0.5 day | User docs, examples | + +**Total: 2.5 days** + +Buffer: +0.5 day for edge cases = **3 days** + +**Final: 2-3 days** diff --git a/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-005-action-dispatcher-task.md b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-005-action-dispatcher-task.md new file mode 100644 index 0000000..39b56e1 --- /dev/null +++ b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-005-action-dispatcher-task.md @@ -0,0 +1,1003 @@ +# AGENT-005: Action Dispatcher + +## Overview + +Create a system for backend-to-frontend action dispatching, allowing the server (AI agent) to trigger UI actions directly. This enables agentic UIs where the backend can open views, navigate pages, highlight elements, and control the application flow. + +**Phase:** 3.5 (Real-Time Agentic UI) +**Priority:** HIGH +**Effort:** 2-4 days +**Risk:** Medium + +--- + +## Problem Statement + +### Current Limitation + +Backends can only send data, not commands: + +``` +Backend sends: { sessions: [...], attendees: [...] } +Frontend must: Parse data → Decide what to do → Update UI manually +``` + +The AI agent can't directly control the UI. + +### Desired Pattern: Action-Based Control + +``` +Backend sends: { + type: "OPEN_VIEW", + view: "agenda", + data: { sessionId: "123" } +} + +Frontend: Automatically opens agenda view with session 123 +``` + +### Real-World Use Cases (Erleah) + +1. **AI Agent Navigation** - "Let me open that session for you" +2. **Guided Tours** - AI walks user through interface +3. **Smart Responses** - Agent opens relevant views based on query +4. **Proactive Suggestions** - "I see you're looking at this, let me show you related items" +5. **Workflow Automation** - Multi-step UI flows triggered by backend + +--- + +## Goals + +1. ✅ Define action vocabulary (open view, navigate, highlight, etc.) +2. ✅ Dispatch actions from backend messages +3. ✅ Execute actions safely in sandboxed context +4. ✅ Queue actions when dependencies not ready +5. ✅ Register custom action handlers +6. ✅ Integrate with SSE/WebSocket for real-time actions +7. ✅ Provide action history/logging + +--- + +## Technical Design + +### Node Specifications + +We'll create TWO nodes: + +1. **Action Dispatcher** - Receives and executes actions +2. **Register Action Handler** - Defines custom action types + +### Action Vocabulary + +Built-in actions: + +```typescript +// Navigation Actions +{ + type: "OPEN_VIEW", + view: "agenda" | "chat" | "parkingLot", + params: { id?: string } +} + +{ + type: "NAVIGATE", + component: "SessionDetail", + params: { sessionId: "123" } +} + +{ + type: "NAVIGATE_BACK" +} + +// State Actions +{ + type: "SET_STORE", + storeName: "app", + key: "currentView", + value: "agenda" +} + +{ + type: "SET_VARIABLE", + variable: "selectedSession", + value: { id: "123", name: "AI Session" } +} + +// UI Actions +{ + type: "SHOW_TOAST", + message: "Session added to timeline", + variant: "success" | "error" | "info" +} + +{ + type: "HIGHLIGHT_ELEMENT", + elementId: "session-123", + duration: 3000, + message: "This is the session I found" +} + +{ + type: "SCROLL_TO", + elementId: "timeline-item-5" +} + +// Data Actions +{ + type: "FETCH", + url: "/api/sessions/123", + method: "GET" +} + +{ + type: "TRIGGER_SIGNAL", + signalName: "refreshData" +} + +// Custom Actions +{ + type: "CUSTOM", + handler: "myCustomAction", + data: { ... } +} +``` + +### Action Dispatcher Node + +```javascript +{ + name: 'net.noodl.ActionDispatcher', + displayNodeName: 'Action Dispatcher', + category: 'Events', + color: 'purple', + docs: 'https://docs.noodl.net/nodes/events/action-dispatcher' +} +``` + +#### Ports: Action Dispatcher + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `action` | object | Action | Action object to dispatch | +| `dispatch` | signal | Actions | Trigger dispatch | +| `queueWhenBusy` | boolean | Config | Queue if action in progress (default: true) | +| `timeout` | number | Config | Max execution time (ms, default: 30000) | +| **Outputs** | +| `actionType` | string | Data | Type of current action | +| `actionData` | object | Data | Data from current action | +| `dispatched` | signal | Events | Fires when action starts | +| `completed` | signal | Events | Fires when action completes | +| `failed` | signal | Events | Fires if action fails | +| `queueSize` | number | Status | Actions waiting in queue | +| `isExecuting` | boolean | Status | Action currently running | +| `error` | string | Events | Error message | +| `result` | * | Data | Result from action execution | + +### Register Action Handler Node + +```javascript +{ + name: 'net.noodl.ActionDispatcher.RegisterHandler', + displayNodeName: 'Register Action Handler', + category: 'Events', + color: 'purple' +} +``` + +#### Ports: Register Action Handler + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `actionType` | string | Handler | Action type to handle (e.g., "OPEN_DASHBOARD") | +| `register` | signal | Actions | Register this handler | +| **Outputs** | +| `trigger` | signal | Events | Fires when this action type dispatched | +| `actionData` | object | Data | Data from the action | +| `complete` | signal | Actions | Signal back to dispatcher when done | + +--- + +## Implementation Details + +### File Structure + +``` +packages/noodl-runtime/src/nodes/std-library/events/ +├── actiondispatcher.js # Core dispatcher singleton +├── actiondispatchernode.js # Dispatcher node +├── registeractionhandlernode.js # Handler registration node +└── actiondispatcher.test.js # Tests +``` + +### Core Dispatcher Implementation + +```javascript +// actiondispatcher.js + +const { globalStoreManager } = require('../data/globalstore'); + +class ActionDispatcherManager { + constructor() { + this.handlers = new Map(); + this.queue = []; + this.isExecuting = false; + this.actionHistory = []; + } + + /** + * Register a handler for an action type + */ + registerHandler(actionType, handler) { + if (!this.handlers.has(actionType)) { + this.handlers.set(actionType, []); + } + + this.handlers.get(actionType).push(handler); + + console.log(`[ActionDispatcher] Registered handler for: ${actionType}`); + + // Return unregister function + return () => { + const handlers = this.handlers.get(actionType); + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + }; + } + + /** + * Dispatch an action + */ + async dispatch(action, options = {}) { + if (!action || !action.type) { + throw new Error('Action must have a type'); + } + + // Queue if busy and queueing enabled + if (this.isExecuting && options.queueWhenBusy) { + this.queue.push(action); + console.log(`[ActionDispatcher] Queued: ${action.type}`); + return { queued: true, queueSize: this.queue.length }; + } + + this.isExecuting = true; + + try { + console.log(`[ActionDispatcher] Dispatching: ${action.type}`, action); + + // Add to history + this.actionHistory.push({ + action, + timestamp: Date.now(), + status: 'executing' + }); + + // Try built-in handlers first + let result; + if (this.builtInHandlers[action.type]) { + result = await this.builtInHandlers[action.type](action); + } + + // Then custom handlers + const handlers = this.handlers.get(action.type); + if (handlers && handlers.length > 0) { + for (const handler of handlers) { + await handler(action.data || action); + } + } + + // Update history + const historyEntry = this.actionHistory[this.actionHistory.length - 1]; + historyEntry.status = 'completed'; + historyEntry.result = result; + + this.isExecuting = false; + + // Process queue + this.processQueue(); + + return { completed: true, result }; + + } catch (error) { + console.error(`[ActionDispatcher] Failed: ${action.type}`, error); + + // Update history + const historyEntry = this.actionHistory[this.actionHistory.length - 1]; + historyEntry.status = 'failed'; + historyEntry.error = error.message; + + this.isExecuting = false; + + // Process queue + this.processQueue(); + + return { failed: true, error: error.message }; + } + } + + /** + * Process queued actions + */ + async processQueue() { + if (this.queue.length === 0) return; + + const action = this.queue.shift(); + await this.dispatch(action, { queueWhenBusy: true }); + } + + /** + * Built-in action handlers + */ + builtInHandlers = { + 'SET_STORE': async (action) => { + const { storeName, key, value } = action; + if (!storeName || !key) { + throw new Error('SET_STORE requires storeName and key'); + } + globalStoreManager.setKey(storeName, key, value); + return { success: true }; + }, + + 'SHOW_TOAST': async (action) => { + const { message, variant = 'info' } = action; + // Emit event that toast component can listen to + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('noodl:toast', { + detail: { message, variant } + })); + } + return { success: true }; + }, + + 'TRIGGER_SIGNAL': async (action) => { + const { signalName } = action; + // Emit as custom event + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(`noodl:signal:${signalName}`)); + } + return { success: true }; + }, + + 'FETCH': async (action) => { + const { url, method = 'GET', body, headers = {} } = action; + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: body ? JSON.stringify(body) : undefined + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return { success: true, data }; + } + }; + + /** + * Get action history + */ + getHistory(limit = 10) { + return this.actionHistory.slice(-limit); + } + + /** + * Clear action queue + */ + clearQueue() { + this.queue = []; + } + + /** + * Get queue size + */ + getQueueSize() { + return this.queue.length; + } +} + +const actionDispatcherManager = new ActionDispatcherManager(); + +module.exports = { actionDispatcherManager }; +``` + +### Action Dispatcher Node Implementation + +```javascript +// actiondispatchernode.js + +const { actionDispatcherManager } = require('./actiondispatcher'); + +var ActionDispatcherNode = { + name: 'net.noodl.ActionDispatcher', + displayNodeName: 'Action Dispatcher', + category: 'Events', + color: 'purple', + + initialize: function() { + this._internal.isExecuting = false; + this._internal.queueSize = 0; + }, + + inputs: { + action: { + type: 'object', + displayName: 'Action', + group: 'Action', + set: function(value) { + this._internal.action = value; + } + }, + + dispatch: { + type: 'signal', + displayName: 'Dispatch', + group: 'Actions', + valueChangedToTrue: function() { + this.doDispatch(); + } + }, + + queueWhenBusy: { + type: 'boolean', + displayName: 'Queue When Busy', + group: 'Config', + default: true, + set: function(value) { + this._internal.queueWhenBusy = value; + } + }, + + timeout: { + type: 'number', + displayName: 'Timeout (ms)', + group: 'Config', + default: 30000, + set: function(value) { + this._internal.timeout = value; + } + } + }, + + outputs: { + actionType: { + type: 'string', + displayName: 'Action Type', + group: 'Data', + getter: function() { + return this._internal.actionType; + } + }, + + actionData: { + type: 'object', + displayName: 'Action Data', + group: 'Data', + getter: function() { + return this._internal.actionData; + } + }, + + dispatched: { + type: 'signal', + displayName: 'Dispatched', + group: 'Events' + }, + + completed: { + type: 'signal', + displayName: 'Completed', + group: 'Events' + }, + + failed: { + type: 'signal', + displayName: 'Failed', + group: 'Events' + }, + + queueSize: { + type: 'number', + displayName: 'Queue Size', + group: 'Status', + getter: function() { + return actionDispatcherManager.getQueueSize(); + } + }, + + isExecuting: { + type: 'boolean', + displayName: 'Is Executing', + group: 'Status', + getter: function() { + return this._internal.isExecuting; + } + }, + + error: { + type: 'string', + displayName: 'Error', + group: 'Events', + getter: function() { + return this._internal.error; + } + }, + + result: { + type: '*', + displayName: 'Result', + group: 'Data', + getter: function() { + return this._internal.result; + } + } + }, + + methods: { + async doDispatch() { + const action = this._internal.action; + + if (!action) { + this.setError('No action provided'); + return; + } + + this._internal.actionType = action.type; + this._internal.actionData = action; + this._internal.isExecuting = true; + this._internal.error = null; + this._internal.result = null; + + this.flagOutputDirty('actionType'); + this.flagOutputDirty('actionData'); + this.flagOutputDirty('isExecuting'); + this.sendSignalOnOutput('dispatched'); + + try { + const result = await actionDispatcherManager.dispatch(action, { + queueWhenBusy: this._internal.queueWhenBusy, + timeout: this._internal.timeout + }); + + if (result.queued) { + // Action was queued + this.flagOutputDirty('queueSize'); + return; + } + + if (result.completed) { + this._internal.result = result.result; + this._internal.isExecuting = false; + + this.flagOutputDirty('result'); + this.flagOutputDirty('isExecuting'); + this.sendSignalOnOutput('completed'); + } else if (result.failed) { + this.setError(result.error); + this._internal.isExecuting = false; + this.flagOutputDirty('isExecuting'); + this.sendSignalOnOutput('failed'); + } + + } catch (e) { + this.setError(e.message); + this._internal.isExecuting = false; + this.flagOutputDirty('isExecuting'); + this.sendSignalOnOutput('failed'); + } + }, + + setError: function(message) { + this._internal.error = message; + this.flagOutputDirty('error'); + } + }, + + getInspectInfo: function() { + return { + type: 'value', + value: { + lastAction: this._internal.actionType, + isExecuting: this._internal.isExecuting, + queueSize: actionDispatcherManager.getQueueSize() + } + }; + } +}; + +module.exports = { + node: ActionDispatcherNode +}; +``` + +### Register Action Handler Node + +```javascript +// registeractionhandlernode.js + +const { actionDispatcherManager } = require('./actiondispatcher'); + +var RegisterActionHandlerNode = { + name: 'net.noodl.ActionDispatcher.RegisterHandler', + displayNodeName: 'Register Action Handler', + category: 'Events', + color: 'purple', + + initialize: function() { + this._internal.unregister = null; + this._internal.pendingComplete = null; + }, + + inputs: { + actionType: { + type: 'string', + displayName: 'Action Type', + group: 'Handler', + set: function(value) { + // Re-register if type changes + if (this._internal.unregister) { + this._internal.unregister(); + } + this._internal.actionType = value; + this.registerHandler(); + } + }, + + register: { + type: 'signal', + displayName: 'Register', + group: 'Actions', + valueChangedToTrue: function() { + this.registerHandler(); + } + }, + + complete: { + type: 'signal', + displayName: 'Complete', + group: 'Actions', + valueChangedToTrue: function() { + if (this._internal.pendingComplete) { + this._internal.pendingComplete(); + this._internal.pendingComplete = null; + } + } + } + }, + + outputs: { + trigger: { + type: 'signal', + displayName: 'Trigger', + group: 'Events' + }, + + actionData: { + type: 'object', + displayName: 'Action Data', + group: 'Data', + getter: function() { + return this._internal.actionData; + } + } + }, + + methods: { + registerHandler: function() { + const actionType = this._internal.actionType; + if (!actionType) return; + + // Unregister previous + if (this._internal.unregister) { + this._internal.unregister(); + } + + // Register handler + this._internal.unregister = actionDispatcherManager.registerHandler( + actionType, + (data) => { + return new Promise((resolve) => { + // Store data + this._internal.actionData = data; + this.flagOutputDirty('actionData'); + + // Trigger handler + this.sendSignalOnOutput('trigger'); + + // Wait for complete signal + this._internal.pendingComplete = resolve; + }); + } + ); + + console.log(`[RegisterActionHandler] Registered: ${actionType}`); + }, + + _onNodeDeleted: function() { + if (this._internal.unregister) { + this._internal.unregister(); + } + } + } +}; + +module.exports = { + node: RegisterActionHandlerNode +}; +``` + +--- + +## Usage Examples + +### Example 1: SSE Actions (Erleah AI Agent) + +``` +[SSE] connected to "/ai/stream" + ↓ +[SSE] data → { type: "OPEN_VIEW", view: "agenda" } + ↓ +[Action Dispatcher] action + ↓ +[Action Dispatcher] dispatch + +// Built-in handler automatically opens agenda view! +``` + +### Example 2: Custom Action Handler + +``` +// Register handler for custom action +[Component Mounted] + ↓ +[Register Action Handler] + actionType: "OPEN_SESSION_DETAIL" + register + +[Register Action Handler] trigger + ↓ +[Register Action Handler] actionData → { sessionId } + ↓ +[Navigate to Component] "SessionDetail" + ↓ +[Set Variable] selectedSession = actionData + ↓ +[Register Action Handler] complete signal + +// Elsewhere, dispatch the action +[Action Dispatcher] + action: { type: "OPEN_SESSION_DETAIL", sessionId: "123" } + dispatch +``` + +### Example 3: Multi-Step Flow + +``` +// AI agent triggers workflow +[SSE] data → [ + { type: "SET_STORE", storeName: "app", key: "view", value: "timeline" }, + { type: "HIGHLIGHT_ELEMENT", elementId: "session-123" }, + { type: "SHOW_TOAST", message: "Here's the session I found" } +] + ↓ +[For Each] action in array + ↓ +[Action Dispatcher] action +[Action Dispatcher] dispatch + ↓ +[Action Dispatcher] completed + → continue to next action +``` + +### Example 4: Guided Tour + +``` +// Backend provides tour steps +tourSteps = [ + { type: "HIGHLIGHT_ELEMENT", elementId: "search-bar", message: "Start by searching" }, + { type: "HIGHLIGHT_ELEMENT", elementId: "add-button", message: "Then add to timeline" }, + { type: "SHOW_TOAST", message: "Tour complete!" } +] + +[Button: "Start Tour"] clicked + ↓ +[For Each Step] + → [Delay] 2000ms + → [Action Dispatcher] dispatch + → wait for completed + → next step +``` + +--- + +## Testing Checklist + +### Functional Tests + +- [ ] Built-in actions execute correctly +- [ ] Custom handlers register and trigger +- [ ] Actions queue when busy +- [ ] Queue processes in order +- [ ] Timeout works for slow actions +- [ ] Multiple handlers for same action type +- [ ] Handler unregistration works +- [ ] Action history records events +- [ ] Error handling works + +### Edge Cases + +- [ ] Dispatch without action object +- [ ] Action without type field +- [ ] Handler throws error +- [ ] Register duplicate action type +- [ ] Unregister non-existent handler +- [ ] Very long queue (100+ actions) +- [ ] Rapid dispatch (stress test) + +### Performance + +- [ ] No memory leaks with many actions +- [ ] Queue doesn't grow unbounded +- [ ] Handler execution is async-safe + +--- + +## Security Considerations + +### 1. Action Validation + +Always validate actions from untrusted sources: + +```javascript +// In dispatcher +if (!isValidActionType(action.type)) { + throw new Error('Invalid action type'); +} + +if (!hasPermission(user, action.type)) { + throw new Error('Unauthorized action'); +} +``` + +### 2. Rate Limiting + +Prevent action flooding: + +```javascript +// Max 100 actions per minute per user +if (rateLimiter.check(userId) > 100) { + throw new Error('Rate limit exceeded'); +} +``` + +### 3. Sandboxing + +Custom handlers run in controlled context - they can't access arbitrary code. + +--- + +## Documentation Requirements + +### User-Facing Docs + +Create: `docs/nodes/events/action-dispatcher.md` + +```markdown +# Action Dispatcher + +Let your backend control your frontend. Perfect for AI agents, guided tours, and automated workflows. + +## The Pattern + +Instead of: +``` +Backend: "Here's data" +Frontend: "What should I do with it?" +``` + +Do this: +``` +Backend: "Open this view with this data" +Frontend: "Done!" +``` + +## Built-in Actions + +- `OPEN_VIEW` - Navigate to view +- `SET_STORE` - Update global store +- `SHOW_TOAST` - Display notification +- `HIGHLIGHT_ELEMENT` - Draw attention +- `TRIGGER_SIGNAL` - Fire signal +- `FETCH` - Make HTTP request + +## Custom Actions + +Create your own: + +``` +[Register Action Handler] + actionType: "MY_ACTION" +→ trigger +→ [Your logic here] +→ complete signal +``` + +## Example: AI Agent Navigation + +[Full example with SSE + Action Dispatcher] + +## Security + +Actions from untrusted sources should be validated server-side before sending to frontend. +``` + +--- + +## Success Criteria + +1. ✅ Built-in actions work out of box +2. ✅ Custom handlers easy to register +3. ✅ Queue prevents action conflicts +4. ✅ Integrates with SSE/WebSocket +5. ✅ No security vulnerabilities +6. ✅ Clear documentation +7. ✅ Works in Erleah for AI agent control + +--- + +## Future Enhancements + +1. **Action Middleware** - Intercept/transform actions +2. **Conditional Actions** - If/then logic in actions +3. **Action Composition** - Combine multiple actions +4. **Undo/Redo** - Reversible actions (with AGENT-006) +5. **Action Recording** - Record/replay user flows +6. **Permissions System** - Role-based action control + +--- + +## References + +- [Flux Architecture](https://facebook.github.io/flux/) - Action pattern inspiration +- [Redux Actions](https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow) + +--- + +## Dependencies + +- AGENT-003 (Global State Store) - for SET_STORE action + +## Blocked By + +- AGENT-001 or AGENT-002 (for SSE/WebSocket integration) +- AGENT-003 (for store actions) + +## Blocks + +- None (enhances Erleah experience) + +--- + +## Estimated Effort Breakdown + +| Phase | Estimate | Description | +|-------|----------|-------------| +| Core Dispatcher | 1 day | Manager with built-in actions | +| Dispatcher Node | 0.5 day | Node implementation | +| Handler Node | 0.5 day | Registration system | +| Testing | 0.5 day | Unit tests, security tests | +| Documentation | 0.5 day | User docs, examples | +| Polish | 0.5 day | Edge cases, error handling | + +**Total: 3.5 days** + +Buffer: +0.5 day for security review = **4 days** + +**Final: 2-4 days** (depending on built-in actions scope) diff --git a/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-006-state-history-task.md b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-006-state-history-task.md new file mode 100644 index 0000000..22dd278 --- /dev/null +++ b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-006-state-history-task.md @@ -0,0 +1,786 @@ +# AGENT-006: State History & Time Travel + +## Overview + +Create a state history tracking system that enables undo/redo, time-travel debugging, and state snapshots. This helps users recover from mistakes and developers debug complex state interactions. + +**Phase:** 3.5 (Real-Time Agentic UI) +**Priority:** LOW (nice-to-have) +**Effort:** 1-2 days +**Risk:** Low + +--- + +## Problem Statement + +### Current Limitation + +State changes are permanent: + +``` +User makes mistake → State changes → Can't undo +Developer debugging → State changed 10 steps ago → Can't replay +``` + +No way to go back in time. + +### Desired Pattern + +``` +User action → State snapshot → Change state +User: "Undo" → Restore previous snapshot +Developer: "Go back 5 steps" → Time travel to that state +``` + +### Real-World Use Cases + +1. **Undo Mistakes** - User accidentally removes item from timeline +2. **Debug State** - Developer replays sequence that caused bug +3. **A/B Comparison** - Save state, test changes, restore to compare +4. **Session Recovery** - Reload state after browser crash +5. **Feature Flags** - Toggle features on/off with instant rollback + +--- + +## Goals + +1. ✅ Track state changes automatically +2. ✅ Undo/redo state changes +3. ✅ Jump to specific state in history +4. ✅ Save/restore state snapshots +5. ✅ Limit history size (memory management) +6. ✅ Integrate with Global Store (AGENT-003) +7. ✅ Export/import history (debugging) + +--- + +## Technical Design + +### Node Specifications + +We'll create THREE nodes: + +1. **State History** - Track and manage history +2. **Undo** - Revert to previous state +3. **State Snapshot** - Save/restore snapshots + +### State History Node + +```javascript +{ + name: 'net.noodl.StateHistory', + displayNodeName: 'State History', + category: 'Data', + color: 'blue', + docs: 'https://docs.noodl.net/nodes/data/state-history' +} +``` + +#### Ports: State History + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `storeName` | string | Store | Global store to track | +| `trackKeys` | string | Config | Comma-separated keys to track (blank = all) | +| `maxHistory` | number | Config | Max history entries (default: 50) | +| `enabled` | boolean | Config | Enable/disable tracking (default: true) | +| `clearHistory` | signal | Actions | Clear history | +| **Outputs** | +| `historySize` | number | Status | Number of entries in history | +| `canUndo` | boolean | Status | Can go back | +| `canRedo` | boolean | Status | Can go forward | +| `currentIndex` | number | Status | Position in history | +| `history` | array | Data | Full history array | +| `stateChanged` | signal | Events | Fires on any state change | + +### Undo Node + +```javascript +{ + name: 'net.noodl.StateHistory.Undo', + displayNodeName: 'Undo', + category: 'Data', + color: 'blue' +} +``` + +#### Ports: Undo Node + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `storeName` | string | Store | Store to undo | +| `undo` | signal | Actions | Go back one step | +| `redo` | signal | Actions | Go forward one step | +| `jumpTo` | signal | Actions | Jump to specific index | +| `targetIndex` | number | Jump | Index to jump to | +| **Outputs** | +| `undone` | signal | Events | Fires after undo | +| `redone` | signal | Events | Fires after redo | +| `jumped` | signal | Events | Fires after jump | + +### State Snapshot Node + +```javascript +{ + name: 'net.noodl.StateSnapshot', + displayNodeName: 'State Snapshot', + category: 'Data', + color: 'blue' +} +``` + +#### Ports: State Snapshot + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `storeName` | string | Store | Store to snapshot | +| `save` | signal | Actions | Save current state | +| `restore` | signal | Actions | Restore saved state | +| `snapshotName` | string | Snapshot | Name for this snapshot | +| `snapshotData` | object | Snapshot | Snapshot to restore (from export) | +| **Outputs** | +| `snapshot` | object | Data | Current saved snapshot | +| `saved` | signal | Events | Fires after save | +| `restored` | signal | Events | Fires after restore | + +--- + +## Implementation Details + +### File Structure + +``` +packages/noodl-runtime/src/nodes/std-library/data/ +├── statehistorymanager.js # History tracking +├── statehistorynode.js # State History node +├── undonode.js # Undo/Redo node +├── statesnapshotnode.js # Snapshot node +└── statehistory.test.js # Tests +``` + +### State History Manager + +```javascript +// statehistorymanager.js + +const { globalStoreManager } = require('./globalstore'); + +class StateHistoryManager { + constructor() { + this.histories = new Map(); // storeName -> history + this.snapshots = new Map(); // snapshotName -> state + } + + /** + * Start tracking a store + */ + trackStore(storeName, options = {}) { + if (this.histories.has(storeName)) { + return; // Already tracking + } + + const { maxHistory = 50, trackKeys = [] } = options; + + const history = { + entries: [], + currentIndex: -1, + maxHistory, + trackKeys, + unsubscribe: null + }; + + // Get initial state + const initialState = globalStoreManager.getState(storeName); + history.entries.push({ + state: JSON.parse(JSON.stringify(initialState)), + timestamp: Date.now(), + description: 'Initial state' + }); + history.currentIndex = 0; + + // Subscribe to changes + history.unsubscribe = globalStoreManager.subscribe( + storeName, + (nextState, prevState, changedKeys) => { + this.recordStateChange(storeName, nextState, changedKeys); + }, + trackKeys + ); + + this.histories.set(storeName, history); + + console.log(`[StateHistory] Tracking store: ${storeName}`); + } + + /** + * Stop tracking a store + */ + stopTracking(storeName) { + const history = this.histories.get(storeName); + if (!history) return; + + if (history.unsubscribe) { + history.unsubscribe(); + } + + this.histories.delete(storeName); + } + + /** + * Record a state change + */ + recordStateChange(storeName, newState, changedKeys) { + const history = this.histories.get(storeName); + if (!history) return; + + // If we're not at the end, truncate future + if (history.currentIndex < history.entries.length - 1) { + history.entries = history.entries.slice(0, history.currentIndex + 1); + } + + // Add new entry + history.entries.push({ + state: JSON.parse(JSON.stringify(newState)), + timestamp: Date.now(), + changedKeys: changedKeys, + description: `Changed: ${changedKeys.join(', ')}` + }); + + // Enforce max history + if (history.entries.length > history.maxHistory) { + history.entries.shift(); + } else { + history.currentIndex++; + } + + console.log(`[StateHistory] Recorded change in ${storeName}`, changedKeys); + } + + /** + * Undo (go back) + */ + undo(storeName) { + const history = this.histories.get(storeName); + if (!history || history.currentIndex <= 0) { + return null; // Can't undo + } + + history.currentIndex--; + const entry = history.entries[history.currentIndex]; + + // Restore state + globalStoreManager.setState(storeName, entry.state); + + console.log(`[StateHistory] Undo in ${storeName} to index ${history.currentIndex}`); + + return { + state: entry.state, + index: history.currentIndex, + canUndo: history.currentIndex > 0, + canRedo: history.currentIndex < history.entries.length - 1 + }; + } + + /** + * Redo (go forward) + */ + redo(storeName) { + const history = this.histories.get(storeName); + if (!history || history.currentIndex >= history.entries.length - 1) { + return null; // Can't redo + } + + history.currentIndex++; + const entry = history.entries[history.currentIndex]; + + // Restore state + globalStoreManager.setState(storeName, entry.state); + + console.log(`[StateHistory] Redo in ${storeName} to index ${history.currentIndex}`); + + return { + state: entry.state, + index: history.currentIndex, + canUndo: history.currentIndex > 0, + canRedo: history.currentIndex < history.entries.length - 1 + }; + } + + /** + * Jump to specific point in history + */ + jumpTo(storeName, index) { + const history = this.histories.get(storeName); + if (!history || index < 0 || index >= history.entries.length) { + return null; + } + + history.currentIndex = index; + const entry = history.entries[index]; + + // Restore state + globalStoreManager.setState(storeName, entry.state); + + console.log(`[StateHistory] Jump to index ${index} in ${storeName}`); + + return { + state: entry.state, + index: history.currentIndex, + canUndo: history.currentIndex > 0, + canRedo: history.currentIndex < history.entries.length - 1 + }; + } + + /** + * Get history info + */ + getHistoryInfo(storeName) { + const history = this.histories.get(storeName); + if (!history) return null; + + return { + size: history.entries.length, + currentIndex: history.currentIndex, + canUndo: history.currentIndex > 0, + canRedo: history.currentIndex < history.entries.length - 1, + entries: history.entries.map((e, i) => ({ + index: i, + timestamp: e.timestamp, + description: e.description, + isCurrent: i === history.currentIndex + })) + }; + } + + /** + * Clear history + */ + clearHistory(storeName) { + const history = this.histories.get(storeName); + if (!history) return; + + const currentState = globalStoreManager.getState(storeName); + history.entries = [{ + state: JSON.parse(JSON.stringify(currentState)), + timestamp: Date.now(), + description: 'Reset' + }]; + history.currentIndex = 0; + } + + /** + * Save snapshot + */ + saveSnapshot(snapshotName, storeName) { + const state = globalStoreManager.getState(storeName); + const snapshot = { + name: snapshotName, + storeName: storeName, + state: JSON.parse(JSON.stringify(state)), + timestamp: Date.now() + }; + + this.snapshots.set(snapshotName, snapshot); + + console.log(`[StateHistory] Saved snapshot: ${snapshotName}`); + + return snapshot; + } + + /** + * Restore snapshot + */ + restoreSnapshot(snapshotName) { + const snapshot = this.snapshots.get(snapshotName); + if (!snapshot) { + throw new Error(`Snapshot not found: ${snapshotName}`); + } + + globalStoreManager.setState(snapshot.storeName, snapshot.state); + + console.log(`[StateHistory] Restored snapshot: ${snapshotName}`); + + return snapshot; + } + + /** + * Export history (for debugging) + */ + exportHistory(storeName) { + const history = this.histories.get(storeName); + if (!history) return null; + + return { + storeName, + entries: history.entries, + currentIndex: history.currentIndex, + exportedAt: Date.now() + }; + } + + /** + * Import history (for debugging) + */ + importHistory(historyData) { + const { storeName, entries, currentIndex } = historyData; + + // Stop current tracking + this.stopTracking(storeName); + + // Create new history + const history = { + entries: entries, + currentIndex: currentIndex, + maxHistory: 50, + trackKeys: [], + unsubscribe: null + }; + + this.histories.set(storeName, history); + + // Restore to current index + const entry = entries[currentIndex]; + globalStoreManager.setState(storeName, entry.state); + } +} + +const stateHistoryManager = new StateHistoryManager(); + +module.exports = { stateHistoryManager }; +``` + +### State History Node (abbreviated) + +```javascript +// statehistorynode.js + +const { stateHistoryManager } = require('./statehistorymanager'); + +var StateHistoryNode = { + name: 'net.noodl.StateHistory', + displayNodeName: 'State History', + category: 'Data', + color: 'blue', + + initialize: function() { + this._internal.tracking = false; + }, + + inputs: { + storeName: { + type: 'string', + displayName: 'Store Name', + default: 'app', + set: function(value) { + this._internal.storeName = value; + this.startTracking(); + } + }, + + enabled: { + type: 'boolean', + displayName: 'Enabled', + default: true, + set: function(value) { + if (value) { + this.startTracking(); + } else { + this.stopTracking(); + } + } + }, + + maxHistory: { + type: 'number', + displayName: 'Max History', + default: 50 + }, + + clearHistory: { + type: 'signal', + displayName: 'Clear History', + valueChangedToTrue: function() { + stateHistoryManager.clearHistory(this._internal.storeName); + this.updateOutputs(); + } + } + }, + + outputs: { + historySize: { + type: 'number', + displayName: 'History Size', + getter: function() { + const info = stateHistoryManager.getHistoryInfo(this._internal.storeName); + return info ? info.size : 0; + } + }, + + canUndo: { + type: 'boolean', + displayName: 'Can Undo', + getter: function() { + const info = stateHistoryManager.getHistoryInfo(this._internal.storeName); + return info ? info.canUndo : false; + } + }, + + canRedo: { + type: 'boolean', + displayName: 'Can Redo', + getter: function() { + const info = stateHistoryManager.getHistoryInfo(this._internal.storeName); + return info ? info.canRedo : false; + } + } + }, + + methods: { + startTracking: function() { + if (this._internal.tracking) return; + + stateHistoryManager.trackStore(this._internal.storeName, { + maxHistory: this._internal.maxHistory, + trackKeys: this._internal.trackKeys + }); + + this._internal.tracking = true; + this.updateOutputs(); + }, + + stopTracking: function() { + if (!this._internal.tracking) return; + + stateHistoryManager.stopTracking(this._internal.storeName); + this._internal.tracking = false; + }, + + updateOutputs: function() { + this.flagOutputDirty('historySize'); + this.flagOutputDirty('canUndo'); + this.flagOutputDirty('canRedo'); + } + } +}; +``` + +--- + +## Usage Examples + +### Example 1: Undo Button + +``` +[Button: "Undo"] clicked + ↓ +[Undo] + storeName: "app" + undo signal + ↓ +[Undo] undone + → [Show Toast] "Undone" + +[State History] canUndo + → [Button] disabled = !canUndo +``` + +### Example 2: Timeline Slider + +``` +[State History] + storeName: "app" + historySize → maxValue + currentIndex → value + +[Slider] value changed + → [Undo] targetIndex + → [Undo] jumpTo signal + +// User can scrub through history! +``` + +### Example 3: Save/Restore Checkpoint + +``` +[Button: "Save Checkpoint"] clicked + ↓ +[State Snapshot] + storeName: "app" + snapshotName: "checkpoint-1" + save + +// Later... +[Button: "Restore Checkpoint"] clicked + ↓ +[State Snapshot] + snapshotName: "checkpoint-1" + restore +``` + +### Example 4: Debug Mode + +``` +// Dev tools panel +[State History] history + → [Repeater] show each entry + +[Entry] clicked + → [Undo] jumpTo with entry.index + +[Button: "Export History"] + → [State History] exportHistory + → [File Download] history.json +``` + +--- + +## Testing Checklist + +### Functional Tests + +- [ ] History tracks state changes +- [ ] Undo reverts to previous state +- [ ] Redo goes forward +- [ ] Jump to specific index works +- [ ] Max history limit enforced +- [ ] Clear history works +- [ ] Snapshots save/restore correctly +- [ ] Export/import preserves history + +### Edge Cases + +- [ ] Undo at beginning (no-op) +- [ ] Redo at end (no-op) +- [ ] Jump to invalid index +- [ ] Change state while not at end (truncate future) +- [ ] Track empty store +- [ ] Very rapid state changes +- [ ] Large state objects (>1MB) + +### Performance + +- [ ] No memory leaks with long history +- [ ] History doesn't slow down app +- [ ] Deep cloning doesn't block UI + +--- + +## Documentation Requirements + +### User-Facing Docs + +Create: `docs/nodes/data/state-history.md` + +```markdown +# State History + +Add undo/redo and time travel to your app. Track state changes and let users go back in time. + +## Use Cases + +- **Undo Mistakes**: User accidentally deletes something +- **Debug Complex State**: Developer traces bug through history +- **A/B Testing**: Save state, test, restore to compare +- **Session Recovery**: Reload after crash + +## Basic Usage + +**Step 1: Track State** +``` +[State History] + storeName: "app" + maxHistory: 50 +``` + +**Step 2: Add Undo** +``` +[Button: "Undo"] clicked +→ [Undo] storeName: "app", undo signal +``` + +**Step 3: Disable When Can't Undo** +``` +[State History] canUndo +→ [Button] disabled = !canUndo +``` + +## Time Travel + +Build a history slider: +``` +[State History] history → entries +→ [Slider] 0 to historySize +→ value changed +→ [Undo] jumpTo +``` + +## Snapshots + +Save points you can return to: +``` +[State Snapshot] save → checkpoint +[State Snapshot] restore ← checkpoint +``` + +## Best Practices + +1. **Limit history size**: 50 entries prevents memory issues +2. **Track only what you need**: Use trackKeys for large stores +3. **Disable in production**: Enable only for dev/debug +``` + +--- + +## Success Criteria + +1. ✅ Undo/redo works reliably +2. ✅ History doesn't leak memory +3. ✅ Snapshots save/restore correctly +4. ✅ Export/import for debugging +5. ✅ Clear documentation +6. ✅ Optional enhancement for Erleah + +--- + +## Future Enhancements + +1. **Diff Viewer** - Show what changed between states +2. **Branching History** - Tree instead of linear +3. **Selective Undo** - Undo specific changes only +4. **Persistence** - Save history to localStorage +5. **Collaborative Undo** - Undo others' changes + +--- + +## Dependencies + +- AGENT-003 (Global State Store) + +## Blocked By + +- AGENT-003 + +## Blocks + +- None (optional feature) + +--- + +## Estimated Effort Breakdown + +| Phase | Estimate | Description | +|-------|----------|-------------| +| History Manager | 0.5 day | Core tracking system | +| Undo/Redo Node | 0.5 day | Node implementation | +| Snapshot Node | 0.5 day | Save/restore system | +| Testing | 0.5 day | Edge cases, memory leaks | +| Documentation | 0.5 day | User docs, examples | + +**Total: 2.5 days** + +Buffer: None needed + +**Final: 1-2 days** (if scope kept minimal) diff --git a/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-007-stream-parser-utilities-task.md b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-007-stream-parser-utilities-task.md new file mode 100644 index 0000000..9bdc587 --- /dev/null +++ b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-007-stream-parser-utilities-task.md @@ -0,0 +1,877 @@ +# AGENT-007: Stream Parser Utilities + +## Overview + +Create utility nodes for parsing streaming data, particularly JSON streams, chunked responses, and incremental text accumulation. These utilities help process SSE and WebSocket messages that arrive in fragments. + +**Phase:** 3.5 (Real-Time Agentic UI) +**Priority:** MEDIUM +**Effort:** 2-3 days +**Risk:** Low + +--- + +## Problem Statement + +### Current Limitation + +Streaming data often arrives in fragments: + +``` +Chunk 1: {"type":"mes +Chunk 2: sage","conte +Chunk 3: nt":"Hello"} +``` + +Without parsing utilities, developers must manually: +1. Accumulate chunks +2. Detect message boundaries +3. Parse JSON safely +4. Handle malformed data + +This is error-prone and repetitive. + +### Desired Pattern + +``` +[SSE] data → raw chunks + ↓ +[Stream Parser] accumulate & parse + ↓ +[Complete JSON Object] → use in app +``` + +### Real-World Use Cases (Erleah) + +1. **AI Chat Streaming** - Accumulate tokens into messages +2. **JSON Streaming** - Parse newline-delimited JSON (NDJSON) +3. **Progress Updates** - Extract percentages from stream +4. **CSV Streaming** - Parse CSV row-by-row +5. **Log Streaming** - Parse structured logs + +--- + +## Goals + +1. ✅ Accumulate text chunks into complete messages +2. ✅ Parse NDJSON (newline-delimited JSON) +3. ✅ Parse JSON chunks safely (handle incomplete JSON) +4. ✅ Extract values from streaming text (regex patterns) +5. ✅ Detect message boundaries (delimiters) +6. ✅ Buffer and flush patterns +7. ✅ Handle encoding/decoding + +--- + +## Technical Design + +### Node Specifications + +We'll create FOUR utility nodes: + +1. **Text Accumulator** - Accumulate chunks into complete text +2. **JSON Stream Parser** - Parse NDJSON or chunked JSON +3. **Pattern Extractor** - Extract values using regex +4. **Stream Buffer** - Buffer with custom flush logic + +### Text Accumulator Node + +```javascript +{ + name: 'net.noodl.TextAccumulator', + displayNodeName: 'Text Accumulator', + category: 'Data', + color: 'green', + docs: 'https://docs.noodl.net/nodes/data/text-accumulator' +} +``` + +#### Ports: Text Accumulator + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `chunk` | string | Data | Text chunk to add | +| `add` | signal | Actions | Add chunk to buffer | +| `clear` | signal | Actions | Clear accumulated text | +| `delimiter` | string | Config | Message delimiter (default: "\\n") | +| `maxLength` | number | Config | Max buffer size (default: 1MB) | +| **Outputs** | +| `accumulated` | string | Data | Current accumulated text | +| `messages` | array | Data | Complete messages (split by delimiter) | +| `messageCount` | number | Status | Number of complete messages | +| `messageReceived` | signal | Events | Fires when complete message | +| `bufferSize` | number | Status | Current buffer size (bytes) | +| `cleared` | signal | Events | Fires after clear | + +### JSON Stream Parser Node + +```javascript +{ + name: 'net.noodl.JSONStreamParser', + displayNodeName: 'JSON Stream Parser', + category: 'Data', + color: 'green' +} +``` + +#### Ports: JSON Stream Parser + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `chunk` | string | Data | JSON chunk | +| `parse` | signal | Actions | Trigger parse | +| `clear` | signal | Actions | Clear buffer | +| `format` | enum | Config | 'ndjson', 'array', 'single' | +| **Outputs** | +| `parsed` | * | Data | Parsed object | +| `success` | signal | Events | Fires on successful parse | +| `error` | string | Events | Parse error message | +| `isComplete` | boolean | Status | Object is complete | + +### Pattern Extractor Node + +```javascript +{ + name: 'net.noodl.PatternExtractor', + displayNodeName: 'Pattern Extractor', + category: 'Data', + color: 'green' +} +``` + +#### Ports: Pattern Extractor + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `text` | string | Data | Text to extract from | +| `pattern` | string | Pattern | Regex pattern | +| `extract` | signal | Actions | Trigger extraction | +| `extractAll` | boolean | Config | Extract all matches vs first | +| **Outputs** | +| `match` | string | Data | Matched text | +| `matches` | array | Data | All matches | +| `groups` | array | Data | Capture groups | +| `found` | signal | Events | Fires when match found | +| `notFound` | signal | Events | Fires when no match | + +### Stream Buffer Node + +```javascript +{ + name: 'net.noodl.StreamBuffer', + displayNodeName: 'Stream Buffer', + category: 'Data', + color: 'green' +} +``` + +#### Ports: Stream Buffer + +| Port Name | Type | Group | Description | +|-----------|------|-------|-------------| +| **Inputs** | +| `data` | * | Data | Data to buffer | +| `add` | signal | Actions | Add to buffer | +| `flush` | signal | Actions | Flush buffer | +| `clear` | signal | Actions | Clear buffer | +| `flushSize` | number | Config | Auto-flush after N items | +| `flushInterval` | number | Config | Auto-flush after ms | +| **Outputs** | +| `buffer` | array | Data | Current buffer contents | +| `bufferSize` | number | Status | Items in buffer | +| `flushed` | signal | Events | Fires after flush | +| `flushedData` | array | Data | Data that was flushed | + +--- + +## Implementation Details + +### File Structure + +``` +packages/noodl-runtime/src/nodes/std-library/data/ +├── textaccumulatornode.js # Text accumulator +├── jsonstreamparsernode.js # JSON parser +├── patternextractornode.js # Regex extractor +├── streambuffernode.js # Generic buffer +└── streamutils.test.js # Tests +``` + +### Text Accumulator Implementation + +```javascript +// textaccumulatornode.js + +var TextAccumulatorNode = { + name: 'net.noodl.TextAccumulator', + displayNodeName: 'Text Accumulator', + category: 'Data', + color: 'green', + + initialize: function() { + this._internal.buffer = ''; + this._internal.messages = []; + }, + + inputs: { + chunk: { + type: 'string', + displayName: 'Chunk', + group: 'Data', + set: function(value) { + this._internal.pendingChunk = value; + } + }, + + add: { + type: 'signal', + displayName: 'Add', + group: 'Actions', + valueChangedToTrue: function() { + this.addChunk(); + } + }, + + clear: { + type: 'signal', + displayName: 'Clear', + group: 'Actions', + valueChangedToTrue: function() { + this.clearBuffer(); + } + }, + + delimiter: { + type: 'string', + displayName: 'Delimiter', + group: 'Config', + default: '\n', + set: function(value) { + this._internal.delimiter = value; + } + }, + + maxLength: { + type: 'number', + displayName: 'Max Length (bytes)', + group: 'Config', + default: 1024 * 1024, // 1MB + set: function(value) { + this._internal.maxLength = value; + } + } + }, + + outputs: { + accumulated: { + type: 'string', + displayName: 'Accumulated', + group: 'Data', + getter: function() { + return this._internal.buffer; + } + }, + + messages: { + type: 'array', + displayName: 'Messages', + group: 'Data', + getter: function() { + return this._internal.messages; + } + }, + + messageCount: { + type: 'number', + displayName: 'Message Count', + group: 'Status', + getter: function() { + return this._internal.messages.length; + } + }, + + messageReceived: { + type: 'signal', + displayName: 'Message Received', + group: 'Events' + }, + + bufferSize: { + type: 'number', + displayName: 'Buffer Size', + group: 'Status', + getter: function() { + return new Blob([this._internal.buffer]).size; + } + }, + + cleared: { + type: 'signal', + displayName: 'Cleared', + group: 'Events' + } + }, + + methods: { + addChunk: function() { + const chunk = this._internal.pendingChunk; + if (!chunk) return; + + // Add to buffer + this._internal.buffer += chunk; + + // Check max length + const maxLength = this._internal.maxLength || (1024 * 1024); + if (this._internal.buffer.length > maxLength) { + console.warn('[TextAccumulator] Buffer overflow, truncating'); + this._internal.buffer = this._internal.buffer.slice(-maxLength); + } + + // Check for complete messages + const delimiter = this._internal.delimiter || '\n'; + const parts = this._internal.buffer.split(delimiter); + + // Keep last incomplete part in buffer + this._internal.buffer = parts.pop(); + + // Add complete messages + if (parts.length > 0) { + this._internal.messages = this._internal.messages.concat(parts); + this.flagOutputDirty('messages'); + this.flagOutputDirty('messageCount'); + this.sendSignalOnOutput('messageReceived'); + } + + this.flagOutputDirty('accumulated'); + this.flagOutputDirty('bufferSize'); + }, + + clearBuffer: function() { + this._internal.buffer = ''; + this._internal.messages = []; + + this.flagOutputDirty('accumulated'); + this.flagOutputDirty('messages'); + this.flagOutputDirty('messageCount'); + this.flagOutputDirty('bufferSize'); + this.sendSignalOnOutput('cleared'); + } + }, + + getInspectInfo: function() { + return { + type: 'value', + value: { + bufferSize: this._internal.buffer.length, + messageCount: this._internal.messages.length, + lastMessage: this._internal.messages[this._internal.messages.length - 1] + } + }; + } +}; + +module.exports = { + node: TextAccumulatorNode +}; +``` + +### JSON Stream Parser Implementation + +```javascript +// jsonstreamparsernode.js + +var JSONStreamParserNode = { + name: 'net.noodl.JSONStreamParser', + displayNodeName: 'JSON Stream Parser', + category: 'Data', + color: 'green', + + initialize: function() { + this._internal.buffer = ''; + }, + + inputs: { + chunk: { + type: 'string', + displayName: 'Chunk', + group: 'Data', + set: function(value) { + this._internal.chunk = value; + } + }, + + parse: { + type: 'signal', + displayName: 'Parse', + group: 'Actions', + valueChangedToTrue: function() { + this.doParse(); + } + }, + + clear: { + type: 'signal', + displayName: 'Clear', + group: 'Actions', + valueChangedToTrue: function() { + this._internal.buffer = ''; + } + }, + + format: { + type: { + name: 'enum', + enums: [ + { label: 'NDJSON', value: 'ndjson' }, + { label: 'JSON Array', value: 'array' }, + { label: 'Single Object', value: 'single' } + ] + }, + displayName: 'Format', + group: 'Config', + default: 'ndjson' + } + }, + + outputs: { + parsed: { + type: '*', + displayName: 'Parsed', + group: 'Data', + getter: function() { + return this._internal.parsed; + } + }, + + success: { + type: 'signal', + displayName: 'Success', + group: 'Events' + }, + + error: { + type: 'string', + displayName: 'Error', + group: 'Events', + getter: function() { + return this._internal.error; + } + }, + + isComplete: { + type: 'boolean', + displayName: 'Is Complete', + group: 'Status', + getter: function() { + return this._internal.isComplete; + } + } + }, + + methods: { + doParse: function() { + const chunk = this._internal.chunk; + if (!chunk) return; + + this._internal.buffer += chunk; + + const format = this._internal.format || 'ndjson'; + + try { + if (format === 'ndjson') { + this.parseNDJSON(); + } else if (format === 'single') { + this.parseSingleJSON(); + } else if (format === 'array') { + this.parseJSONArray(); + } + } catch (e) { + this._internal.error = e.message; + this._internal.isComplete = false; + this.flagOutputDirty('error'); + this.flagOutputDirty('isComplete'); + } + }, + + parseNDJSON: function() { + // NDJSON: one JSON per line + const lines = this._internal.buffer.split('\n'); + + // Keep last incomplete line + this._internal.buffer = lines.pop(); + + // Parse complete lines + const parsed = []; + for (const line of lines) { + if (line.trim()) { + try { + parsed.push(JSON.parse(line)); + } catch (e) { + console.warn('[JSONStreamParser] Failed to parse line:', line); + } + } + } + + if (parsed.length > 0) { + this._internal.parsed = parsed; + this._internal.isComplete = true; + this.flagOutputDirty('parsed'); + this.flagOutputDirty('isComplete'); + this.sendSignalOnOutput('success'); + } + }, + + parseSingleJSON: function() { + // Try to parse complete JSON object + try { + const parsed = JSON.parse(this._internal.buffer); + this._internal.parsed = parsed; + this._internal.isComplete = true; + this._internal.buffer = ''; + + this.flagOutputDirty('parsed'); + this.flagOutputDirty('isComplete'); + this.sendSignalOnOutput('success'); + } catch (e) { + // Not complete yet, wait for more chunks + this._internal.isComplete = false; + this.flagOutputDirty('isComplete'); + } + }, + + parseJSONArray: function() { + // JSON array, possibly incomplete: [{"a":1},{"b":2}... + // Try to parse as-is, or add closing bracket + + let buffer = this._internal.buffer.trim(); + + // Try parsing complete array + try { + const parsed = JSON.parse(buffer); + if (Array.isArray(parsed)) { + this._internal.parsed = parsed; + this._internal.isComplete = true; + this._internal.buffer = ''; + + this.flagOutputDirty('parsed'); + this.flagOutputDirty('isComplete'); + this.sendSignalOnOutput('success'); + return; + } + } catch (e) { + // Not complete, try adding closing bracket + try { + const parsed = JSON.parse(buffer + ']'); + if (Array.isArray(parsed)) { + this._internal.parsed = parsed; + this._internal.isComplete = false; // Partial + + this.flagOutputDirty('parsed'); + this.flagOutputDirty('isComplete'); + this.sendSignalOnOutput('success'); + } + } catch (e2) { + // Still not parseable + this._internal.isComplete = false; + this.flagOutputDirty('isComplete'); + } + } + } + } +}; + +module.exports = { + node: JSONStreamParserNode +}; +``` + +### Pattern Extractor Implementation (abbreviated) + +```javascript +// patternextractornode.js + +var PatternExtractorNode = { + name: 'net.noodl.PatternExtractor', + displayNodeName: 'Pattern Extractor', + category: 'Data', + color: 'green', + + inputs: { + text: { type: 'string', displayName: 'Text' }, + pattern: { type: 'string', displayName: 'Pattern' }, + extract: { type: 'signal', displayName: 'Extract' }, + extractAll: { type: 'boolean', displayName: 'Extract All', default: false } + }, + + outputs: { + match: { type: 'string', displayName: 'Match' }, + matches: { type: 'array', displayName: 'Matches' }, + groups: { type: 'array', displayName: 'Groups' }, + found: { type: 'signal', displayName: 'Found' }, + notFound: { type: 'signal', displayName: 'Not Found' } + }, + + methods: { + doExtract: function() { + const text = this._internal.text; + const pattern = this._internal.pattern; + + if (!text || !pattern) return; + + try { + const regex = new RegExp(pattern, this._internal.extractAll ? 'g' : ''); + const matches = this._internal.extractAll + ? [...text.matchAll(new RegExp(pattern, 'g'))] + : [text.match(regex)]; + + if (matches && matches[0]) { + this._internal.match = matches[0][0]; + this._internal.matches = matches.map(m => m[0]); + this._internal.groups = matches[0].slice(1); + + this.flagOutputDirty('match'); + this.flagOutputDirty('matches'); + this.flagOutputDirty('groups'); + this.sendSignalOnOutput('found'); + } else { + this.sendSignalOnOutput('notFound'); + } + } catch (e) { + console.error('[PatternExtractor] Invalid regex:', e); + } + } + } +}; +``` + +--- + +## Usage Examples + +### Example 1: AI Chat Streaming (Erleah) + +``` +[SSE] data → text chunks + ↓ +[Text Accumulator] + delimiter: "" // No delimiter, accumulate all + chunk: data + add + ↓ +[Text Accumulator] accumulated + → [Text] display streaming message + +// Real-time text appears as AI types! +``` + +### Example 2: NDJSON Stream + +``` +[SSE] data → NDJSON chunks + ↓ +[JSON Stream Parser] + format: "ndjson" + chunk: data + parse + ↓ +[JSON Stream Parser] parsed → array of objects + ↓ +[For Each] object in array + → [Process each object] +``` + +### Example 3: Extract Progress + +``` +[SSE] data → "Processing... 45% complete" + ↓ +[Pattern Extractor] + text: data + pattern: "(\d+)%" + extract + ↓ +[Pattern Extractor] groups → [0] = "45" + → [Number] 45 + → [Progress Bar] value +``` + +### Example 4: Buffered Updates + +``` +[SSE] data → frequent updates + ↓ +[Stream Buffer] + data: item + add + flushInterval: 1000 // Flush every second + ↓ +[Stream Buffer] flushed +[Stream Buffer] flushedData → batched items + → [Process batch at once] + +// Reduces processing overhead +``` + +--- + +## Testing Checklist + +### Functional Tests + +- [ ] Text accumulator handles chunks correctly +- [ ] NDJSON parser splits on newlines +- [ ] Single JSON waits for complete object +- [ ] Array JSON handles incomplete arrays +- [ ] Pattern extractor finds matches +- [ ] Capture groups extracted correctly +- [ ] Buffer flushes on size/interval +- [ ] Clear operations work + +### Edge Cases + +- [ ] Empty chunks +- [ ] Very large chunks (>1MB) +- [ ] Malformed JSON +- [ ] Invalid regex patterns +- [ ] No matches found +- [ ] Buffer overflow +- [ ] Rapid chunks (stress test) +- [ ] Unicode/emoji handling + +### Performance + +- [ ] No memory leaks with long streams +- [ ] Regex doesn't cause ReDoS +- [ ] Large buffer doesn't freeze UI + +--- + +## Documentation Requirements + +### User-Facing Docs + +Create: `docs/nodes/data/stream-utilities.md` + +```markdown +# Stream Utilities + +Tools for working with streaming data from SSE, WebSocket, or chunked HTTP responses. + +## Text Accumulator + +Collect text chunks into complete messages: + +``` +[SSE] → chunks +[Text Accumulator] → complete messages +``` + +Use cases: +- AI chat streaming +- Log streaming +- Progress messages + +## JSON Stream Parser + +Parse streaming JSON in various formats: + +- **NDJSON**: One JSON per line +- **Single**: Wait for complete object +- **Array**: Parse partial JSON arrays + +## Pattern Extractor + +Extract values using regex: + +``` +Text: "Status: 45% complete" +Pattern: "(\d+)%" +→ Match: "45" +``` + +Use cases: +- Extract progress percentages +- Parse structured logs +- Find specific values + +## Stream Buffer + +Batch frequent updates: + +``` +[Rapid Updates] → [Buffer] → [Batch Process] +``` + +Reduces processing overhead. + +## Best Practices + +1. **Set max lengths**: Prevent memory issues +2. **Handle parse errors**: JSON might be incomplete +3. **Use delimiters**: For message boundaries +4. **Batch when possible**: Reduce processing +``` + +--- + +## Success Criteria + +1. ✅ Handles streaming data reliably +2. ✅ Parses NDJSON correctly +3. ✅ Regex extraction works +4. ✅ No memory leaks +5. ✅ Clear documentation +6. ✅ Works with AGENT-001 (SSE) for Erleah + +--- + +## Future Enhancements + +1. **XML Stream Parser** - Parse chunked XML +2. **CSV Stream Parser** - Parse CSV row-by-row +3. **Binary Parsers** - Protocol buffers, msgpack +4. **Compression** - Decompress gzip/deflate streams +5. **Encoding Detection** - Auto-detect UTF-8/UTF-16 + +--- + +## References + +- [NDJSON Spec](http://ndjson.org/) +- [JSON Streaming](https://en.wikipedia.org/wiki/JSON_streaming) + +--- + +## Dependencies + +- None (pure utilities) + +## Blocked By + +- None (can be developed independently) + +## Blocks + +- None (but enhances AGENT-001, AGENT-002) + +--- + +## Estimated Effort Breakdown + +| Phase | Estimate | Description | +|-------|----------|-------------| +| Text Accumulator | 0.5 day | Basic chunking logic | +| JSON Parser | 1 day | NDJSON, single, array formats | +| Pattern Extractor | 0.5 day | Regex wrapper | +| Stream Buffer | 0.5 day | Time/size-based flushing | +| Testing | 0.5 day | Edge cases, performance | +| Documentation | 0.5 day | User docs, examples | + +**Total: 3.5 days** + +Buffer: None needed + +**Final: 2-3 days** diff --git a/dev-docs/tasks/phase-3.5-realtime-agentic-ui/noodl-erleah-capability-analysis.md b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/noodl-erleah-capability-analysis.md new file mode 100644 index 0000000..bef2929 --- /dev/null +++ b/dev-docs/tasks/phase-3.5-realtime-agentic-ui/noodl-erleah-capability-analysis.md @@ -0,0 +1,826 @@ +# Can Noodl Build the New Agentic Erleah? +## Strategic Analysis & Roadmap + +**Date:** December 30, 2025 +**Author:** Strategic Analysis +**Status:** Recommendation - YES, with Phase 3.5 additions + +--- + +## Executive Summary + +**TL;DR:** Yes, Noodl CAN build the new agentic Erleah, but it requires adding a focused "Phase 3.5: Real-Time Agentic UI" series of nodes and features. This is actually a PERFECT test case for Noodl's capabilities and would result in features that benefit the entire Noodl ecosystem. + +**Key Insights:** +1. **Foundation is solid** - Phases 1 & 2 created a modern React 19 + TypeScript base +2. **Core patterns exist** - HTTP nodes, state management, and event systems are already there +3. **Missing pieces are specific** - SSE streams, optimistic updates, and action dispatching +4. **High ROI** - Building these features makes Noodl better for ALL modern web apps +5. **Validation opportunity** - If Noodl can build Erleah, it proves the platform's maturity + +--- + +## Current Capabilities Assessment + +### ✅ What Noodl ALREADY Has + +#### 1. Modern Foundation (Phase 1 Complete) +- **React 19** in both editor and runtime +- **TypeScript 5** with full type inference +- **Modern tooling** - webpack 5, Storybook 8 +- **Performance** - Build times improved, hot reload snappy + +#### 2. HTTP & API Integration (Phase 2 In Progress) +```javascript +// Current HTTP Node capabilities: +- ✅ GET/POST/PUT/DELETE/PATCH methods +- ✅ Authentication presets (Bearer, Basic, API Key) +- ✅ JSONPath response mapping +- ✅ Header and query parameter management +- ✅ Form data and URL-encoded bodies +- ✅ Timeout configuration +- ✅ Cancel requests +``` + +#### 3. State Management +```javascript +- ✅ Variable nodes for local state +- ✅ Object/Array manipulation nodes +- ✅ Component Inputs/Outputs for prop drilling +- ✅ Send Event/Receive Event for pub-sub +- ✅ States node for state machines +``` + +#### 4. Visual Components +```javascript +- ✅ Full React component library +- ✅ Responsive breakpoints (planned in NODES-001) +- ✅ Visual states (hover, pressed, disabled) +- ✅ Conditional rendering +- ✅ Repeater for dynamic lists +``` + +#### 5. Event System +```javascript +- ✅ Signal-based event propagation +- ✅ EventDispatcher for pub-sub patterns +- ✅ Connection-based data flow +- ✅ Debounce/Delay nodes for timing +``` + +### ❌ What Noodl Is MISSING for Erleah + +#### 1. **Server-Sent Events (SSE) Support** +**Current Gap:** HTTP node only does request-response, no streaming + +**Erleah Needs:** +```javascript +// Chat messages streaming in real-time +AI Agent: "I'm searching attendees..." [streaming] +AI Agent: "Found 8 matches..." [streaming] +AI Agent: "Adding to your plan..." [streaming] +``` + +**What's Required:** +- SSE connection node +- Stream parsing (JSON chunks) +- Progressive message accumulation +- Automatic reconnection on disconnect + +#### 2. **WebSocket Support** +**Current Gap:** No WebSocket node exists + +**Erleah Needs:** +```javascript +// Real-time bidirectional communication +User → Backend: "Add this to timeline" +Backend → User: "Timeline updated" [instant] +Backend → User: "Connection request accepted" [push] +``` + +#### 3. **Optimistic UI Updates** +**Current Gap:** No pattern for "update UI first, sync later" + +**Erleah Needs:** +```javascript +// Click "Accept" → immediate UI feedback +// Then backend call → roll back if it fails +``` + +**What's Required:** +- Transaction/rollback state management +- Pending state indicators +- Error recovery patterns + +#### 4. **Action Dispatcher Pattern** +**Current Gap:** No concept of backend-triggered UI actions + +**Erleah Needs:** +```javascript +// Backend (AI Agent) sends: +{ + type: "OPEN_VIEW", + view: "agenda", + id: "session-123" +} + +// Frontend automatically navigates +``` + +**What's Required:** +- Action queue/processor +- UI action vocabulary +- Safe execution sandbox + +#### 5. **State Synchronization Across Views** +**Current Gap:** Component state is isolated, no global reactive store + +**Erleah Needs:** +```javascript +// Chat sidebar updates → Timeline view updates +// Timeline view updates → Parking Lot updates +// All views stay in sync automatically +``` + +**What's Required:** +- Global observable store (like Zustand) +- Subscription mechanism +- Selective re-rendering + +--- + +## Gap Analysis: Erleah Requirements vs Noodl Capabilities + +### Feature Comparison Matrix + +| Erleah Feature | Noodl Today | Gap Size | Effort to Add | +|----------------|-------------|----------|---------------| +| **Timeline View** | ✅ Repeater + Cards | None | 0 days | +| **Chat Sidebar** | ✅ Components | None | 0 days | +| **Parking Lot Sidebar** | ✅ Components | None | 0 days | +| **Card Layouts** | ✅ Visual nodes | None | 0 days | +| **HTTP API Calls** | ✅ HTTP Node | None | 0 days | +| **Authentication** | ✅ Auth presets | None | 0 days | +| **SSE Streaming** | ❌ None | Large | 3-5 days | +| **WebSocket** | ❌ None | Large | 3-5 days | +| **Optimistic Updates** | ❌ None | Medium | 2-3 days | +| **Action Dispatcher** | ⚠️ Partial | Medium | 2-4 days | +| **Global State** | ⚠️ Workarounds | Small | 2-3 days | +| **State History** | ❌ None | Small | 1-2 days | +| **Real-time Preview** | ✅ Existing | None | 0 days | + +**Total New Development:** ~15-24 days + +--- + +## Proposed Phase 3.5: Real-Time Agentic UI + +Insert this between current Phase 3 and the rest of the roadmap. + +### Task Series: AGENT (AI Agent Integration) + +**Total Estimated:** 15-24 days (3-4 weeks) + +| Task ID | Name | Estimate | Description | +|---------|------|----------|-------------| +| **AGENT-001** | Server-Sent Events Node | 3-5 days | SSE connection, streaming, auto-reconnect | +| **AGENT-002** | WebSocket Node | 3-5 days | Bidirectional real-time communication | +| **AGENT-003** | Global State Store | 2-3 days | Observable store like Zustand, cross-component | +| **AGENT-004** | Optimistic Update Pattern | 2-3 days | Transaction wrapper, rollback support | +| **AGENT-005** | Action Dispatcher | 2-4 days | Backend-to-frontend command execution | +| **AGENT-006** | State History & Time Travel | 1-2 days | Undo/redo, state snapshots | +| **AGENT-007** | Stream Parser Utilities | 2-3 days | JSON streaming, chunk assembly | + +### AGENT-001: Server-Sent Events Node + +**File:** `packages/noodl-runtime/src/nodes/std-library/data/ssenode.js` + +```javascript +var SSENode = { + name: 'net.noodl.SSE', + displayNodeName: 'Server-Sent Events', + docs: 'https://docs.noodl.net/nodes/data/sse', + category: 'Data', + color: 'data', + searchTags: ['sse', 'stream', 'server-sent', 'events', 'realtime'], + + initialize: function() { + this._internal.eventSource = null; + this._internal.isConnected = false; + this._internal.messageBuffer = []; + }, + + inputs: { + url: { + type: 'string', + displayName: 'URL', + group: 'Connection', + set: function(value) { + this._internal.url = value; + } + }, + + connect: { + type: 'signal', + displayName: 'Connect', + group: 'Actions', + valueChangedToTrue: function() { + this.doConnect(); + } + }, + + disconnect: { + type: 'signal', + displayName: 'Disconnect', + group: 'Actions', + valueChangedToTrue: function() { + this.doDisconnect(); + } + }, + + autoReconnect: { + type: 'boolean', + displayName: 'Auto Reconnect', + group: 'Connection', + default: true + }, + + reconnectDelay: { + type: 'number', + displayName: 'Reconnect Delay (ms)', + group: 'Connection', + default: 3000 + } + }, + + outputs: { + message: { + type: 'object', + displayName: 'Message', + group: 'Data' + }, + + data: { + type: '*', + displayName: 'Parsed Data', + group: 'Data' + }, + + connected: { + type: 'signal', + displayName: 'Connected', + group: 'Events' + }, + + disconnected: { + type: 'signal', + displayName: 'Disconnected', + group: 'Events' + }, + + error: { + type: 'string', + displayName: 'Error', + group: 'Events' + }, + + isConnected: { + type: 'boolean', + displayName: 'Is Connected', + group: 'Status', + getter: function() { + return this._internal.isConnected; + } + } + }, + + methods: { + doConnect: function() { + if (this._internal.eventSource) { + this.doDisconnect(); + } + + const url = this._internal.url; + if (!url) { + this.setError('URL is required'); + return; + } + + try { + const eventSource = new EventSource(url); + this._internal.eventSource = eventSource; + + eventSource.onopen = () => { + this._internal.isConnected = true; + this.flagOutputDirty('isConnected'); + this.sendSignalOnOutput('connected'); + }; + + eventSource.onmessage = (event) => { + this.handleMessage(event); + }; + + eventSource.onerror = (error) => { + this._internal.isConnected = false; + this.flagOutputDirty('isConnected'); + this.sendSignalOnOutput('disconnected'); + + if (this._internal.autoReconnect) { + setTimeout(() => { + if (!this._internal.eventSource || this._internal.eventSource.readyState === EventSource.CLOSED) { + this.doConnect(); + } + }, this._internal.reconnectDelay || 3000); + } + }; + + } catch (e) { + this.setError(e.message); + } + }, + + doDisconnect: function() { + if (this._internal.eventSource) { + this._internal.eventSource.close(); + this._internal.eventSource = null; + this._internal.isConnected = false; + this.flagOutputDirty('isConnected'); + this.sendSignalOnOutput('disconnected'); + } + }, + + handleMessage: function(event) { + try { + // Try to parse as JSON + const data = JSON.parse(event.data); + this._internal.message = event; + this._internal.data = data; + } catch (e) { + // Not JSON, use raw data + this._internal.message = event; + this._internal.data = event.data; + } + + this.flagOutputDirty('message'); + this.flagOutputDirty('data'); + }, + + setError: function(message) { + this._internal.error = message; + this.flagOutputDirty('error'); + }, + + _onNodeDeleted: function() { + this.doDisconnect(); + } + } +}; + +module.exports = { + node: SSENode +}; +``` + +### AGENT-003: Global State Store Node + +**File:** `packages/noodl-runtime/src/nodes/std-library/data/globalstorenode.js` + +```javascript +// Global store instance (singleton) +class GlobalStore { + constructor() { + this.stores = new Map(); + this.subscribers = new Map(); + } + + createStore(name, initialState = {}) { + if (!this.stores.has(name)) { + this.stores.set(name, initialState); + this.subscribers.set(name, new Set()); + } + return this.stores.get(name); + } + + getState(name) { + return this.stores.get(name) || {}; + } + + setState(name, updates) { + const current = this.stores.get(name) || {}; + const next = { ...current, ...updates }; + this.stores.set(name, next); + this.notify(name, next); + } + + subscribe(name, callback) { + if (!this.subscribers.has(name)) { + this.subscribers.set(name, new Set()); + } + this.subscribers.get(name).add(callback); + + // Return unsubscribe function + return () => { + this.subscribers.get(name).delete(callback); + }; + } + + notify(name, state) { + const subscribers = this.subscribers.get(name); + if (subscribers) { + subscribers.forEach(cb => cb(state)); + } + } +} + +const globalStoreInstance = new GlobalStore(); + +var GlobalStoreNode = { + name: 'net.noodl.GlobalStore', + displayNodeName: 'Global Store', + category: 'Data', + color: 'data', + + initialize: function() { + this._internal.storeName = 'default'; + this._internal.unsubscribe = null; + }, + + inputs: { + storeName: { + type: 'string', + displayName: 'Store Name', + default: 'default', + set: function(value) { + if (this._internal.unsubscribe) { + this._internal.unsubscribe(); + } + this._internal.storeName = value; + this.setupSubscription(); + } + }, + + set: { + type: 'signal', + displayName: 'Set', + valueChangedToTrue: function() { + this.doSet(); + } + }, + + key: { + type: 'string', + displayName: 'Key' + }, + + value: { + type: '*', + displayName: 'Value' + } + }, + + outputs: { + state: { + type: 'object', + displayName: 'State', + getter: function() { + return globalStoreInstance.getState(this._internal.storeName); + } + }, + + stateChanged: { + type: 'signal', + displayName: 'State Changed' + } + }, + + methods: { + setupSubscription: function() { + const storeName = this._internal.storeName; + + this._internal.unsubscribe = globalStoreInstance.subscribe( + storeName, + (newState) => { + this.flagOutputDirty('state'); + this.sendSignalOnOutput('stateChanged'); + } + ); + + // Trigger initial state + this.flagOutputDirty('state'); + }, + + doSet: function() { + const key = this._internal.key; + const value = this._internal.value; + const storeName = this._internal.storeName; + + if (key) { + globalStoreInstance.setState(storeName, { [key]: value }); + } + }, + + _onNodeDeleted: function() { + if (this._internal.unsubscribe) { + this._internal.unsubscribe(); + } + } + } +}; +``` + +### AGENT-005: Action Dispatcher Node + +**File:** `packages/noodl-runtime/src/nodes/std-library/data/actiondispatchernode.js` + +```javascript +var ActionDispatcherNode = { + name: 'net.noodl.ActionDispatcher', + displayNodeName: 'Action Dispatcher', + category: 'Events', + color: 'purple', + + initialize: function() { + this._internal.actionHandlers = new Map(); + this._internal.pendingActions = []; + }, + + inputs: { + action: { + type: 'object', + displayName: 'Action', + set: function(value) { + this._internal.currentAction = value; + this.dispatch(value); + } + }, + + // Register handlers + registerHandler: { + type: 'signal', + displayName: 'Register Handler', + valueChangedToTrue: function() { + this.doRegisterHandler(); + } + }, + + handlerType: { + type: 'string', + displayName: 'Handler Type' + }, + + handlerCallback: { + type: 'signal', + displayName: 'Handler Callback' + } + }, + + outputs: { + actionType: { + type: 'string', + displayName: 'Action Type' + }, + + actionData: { + type: 'object', + displayName: 'Action Data' + }, + + dispatched: { + type: 'signal', + displayName: 'Dispatched' + } + }, + + methods: { + dispatch: function(action) { + if (!action || !action.type) return; + + this._internal.actionType = action.type; + this._internal.actionData = action.data || {}; + + this.flagOutputDirty('actionType'); + this.flagOutputDirty('actionData'); + this.sendSignalOnOutput('dispatched'); + + // Execute registered handlers + const handler = this._internal.actionHandlers.get(action.type); + if (handler) { + handler(action.data); + } + }, + + doRegisterHandler: function() { + const type = this._internal.handlerType; + if (!type) return; + + this._internal.actionHandlers.set(type, (data) => { + this._internal.actionData = data; + this.flagOutputDirty('actionData'); + this.sendSignalOnOutput('handlerCallback'); + }); + } + } +}; +``` + +--- + +## Implementation Strategy: Phases 1-2-3.5-3-4-5 + +### Revised Roadmap + +``` +Phase 1: Foundation ✅ COMPLETE + ├─ React 19 migration + ├─ TypeScript 5 upgrade + └─ Storybook 8 migration + +Phase 2: Core Features ⚙️ IN PROGRESS + ├─ HTTP Node improvements ✅ COMPLETE + ├─ Responsive breakpoints 🔄 ACTIVE + ├─ Component migrations 🔄 ACTIVE + └─ EventDispatcher React bridge ⚠️ BLOCKED + +Phase 3.5: Real-Time Agentic UI 🆕 PROPOSED + ├─ AGENT-001: SSE Node (3-5 days) + ├─ AGENT-002: WebSocket Node (3-5 days) + ├─ AGENT-003: Global State Store (2-3 days) + ├─ AGENT-004: Optimistic Updates (2-3 days) + ├─ AGENT-005: Action Dispatcher (2-4 days) + ├─ AGENT-006: State History (1-2 days) + └─ AGENT-007: Stream Utilities (2-3 days) + + Total: 15-24 days (3-4 weeks) + +Phase 3: Advanced Features + ├─ Dashboard UX (DASH series) + ├─ Git Integration (GIT series) + ├─ Shared Components (COMP series) + ├─ AI Features (AI series) + └─ Deployment (DEPLOY series) +``` + +### Critical Path for Erleah + +To build Erleah, this is the minimum required path: + +**Week 1-2: Phase 3.5 Core** +- AGENT-001 (SSE) - Absolutely critical for streaming AI responses +- AGENT-003 (Global Store) - Required for synchronized state +- AGENT-007 (Stream Utils) - Need to parse SSE JSON chunks + +**Week 3: Phase 3.5 Enhancement** +- AGENT-004 (Optimistic Updates) - Better UX for user interactions +- AGENT-005 (Action Dispatcher) - AI agent can control UI + +**Week 4: Erleah Development** +- Build Timeline view +- Build Chat sidebar with SSE +- Build Parking Lot sidebar +- Connect to backend + +**Total:** 4 weeks to validated Erleah prototype in Noodl + +--- + +## Why This Is GOOD for Noodl + +### 1. **Validates Modern Architecture** +Building a complex, agentic UI proves that Noodl's React 19 + TypeScript migration was worth it. This is a real-world stress test. + +### 2. **Features Benefit Everyone** +SSE, WebSocket, and Global Store aren't "Erleah-specific" - every modern web app needs these: +- Chat applications +- Real-time dashboards +- Collaborative tools +- Live notifications +- Streaming data visualization + +### 3. **Competitive Advantage** +Flutterflow, Bubble, Webflow - none have agentic UI patterns built in. This would be a differentiator. + +### 4. **Dogfooding** +Using Noodl to build a complex AI-powered app exposes UX issues and missing features that users face daily. + +### 5. **Marketing Asset** +"Built with Noodl" becomes a powerful case study. Erleah is a sophisticated, modern web app that competes with pure-code solutions. + +--- + +## Risks & Mitigations + +### Risk 1: "We're adding too much complexity" +**Mitigation:** Phase 3.5 features are optional. Existing Noodl projects continue working. These are additive, not disruptive. + +### Risk 2: "What if we hit a fundamental limitation?" +**Mitigation:** Start with Phase 3.5 AGENT-001 (SSE) as a proof-of-concept. If that works smoothly, continue. If it's a nightmare, reconsider. + +### Risk 3: "We're delaying Phase 3 features" +**Mitigation:** Phase 3.5 is only 3-4 weeks. The learnings will inform Phase 3 (especially AI-001 AI Project Scaffolding). + +### Risk 4: "SSE/WebSocket are complex to implement correctly" +**Mitigation:** Leverage existing libraries (EventSource is native, WebSocket is native). Focus on the Noodl integration layer, not reinventing protocols. + +--- + +## Alternative: Hybrid Approach + +If pure Noodl feels too risky, consider: + +### Option A: Noodl Editor + React Runtime +- Build most of Erleah in Noodl +- Write 1-2 custom React components for SSE streaming in pure code +- Import as "Custom React Components" (already supported in Noodl) + +**Pros:** +- Faster initial development +- No waiting for Phase 3.5 +- Still validates Noodl for 90% of the app + +**Cons:** +- Doesn't push Noodl forward +- Misses opportunity to build reusable features + +### Option B: Erleah 1.0 in Code, Erleah 2.0 in Noodl +- Ship current Erleah version in pure React +- Use learnings to design Phase 3.5 +- Rebuild Erleah 2.0 in Noodl with Phase 3.5 features + +**Pros:** +- No business risk +- Informs Phase 3.5 design with real requirements +- Validates Noodl with second implementation + +**Cons:** +- Slower validation loop +- Two separate codebases to maintain initially + +--- + +## Recommendation + +### ✅ Go Forward with Phase 3.5 + +**Rationale:** +1. **Timing is right** - Phases 1 & 2 created the foundation +2. **Scope is focused** - 7 tasks, 3-4 weeks, clear boundaries +3. **Value is high** - Erleah validates Noodl, features benefit everyone +4. **Risk is manageable** - Start with AGENT-001, can pivot if needed + +### 📋 Action Plan + +**Immediate (Next 2 weeks):** +1. Create AGENT-001 (SSE Node) task document +2. Implement SSE Node as proof-of-concept +3. Build simple streaming chat UI in Noodl to test +4. Evaluate: Did this feel natural? Were there blockers? + +**If POC succeeds (Week 3-4):** +5. Complete AGENT-003 (Global Store) +6. Complete AGENT-007 (Stream Utils) +7. Build Erleah Timeline prototype in Noodl + +**If POC struggles:** +8. Document specific pain points +9. Consider hybrid approach +10. Inform future node design + +### 🎯 Success Criteria + +Phase 3.5 is successful if: +- ✅ SSE Node can stream AI responses smoothly +- ✅ Global Store keeps views synchronized +- ✅ Building Erleah in Noodl feels productive, not painful +- ✅ The resulting app performs well (no visible lag) +- ✅ Code is maintainable (not a tangled node spaghetti) + +--- + +## Conclusion + +**Can Noodl build the new agentic Erleah?** + +Yes - but only with Phase 3.5 additions. Without SSE, Global Store, and Action Dispatcher patterns, you'd be fighting the platform. With them, Noodl becomes a powerful tool for building modern, reactive, AI-powered web apps. + +**Should you do it?** + +Yes - this is a perfect validation moment. You've invested heavily in modernizing Noodl. Now prove it can build something cutting-edge. If Noodl struggles with Erleah, that's valuable feedback. If it succeeds, you have a compelling case study and a suite of new features. + +**Timeline:** +- **Phase 3.5 Development:** 3-4 weeks +- **Erleah Prototype:** 1-2 weeks +- **Total to Validation:** 4-6 weeks + +This is a strategic investment that pays dividends beyond just Erleah. + +--- + +## Next Steps + +1. **Review this document** with the team +2. **Decide on approach**: Full Phase 3.5, Hybrid, or Pure Code +3. **If Phase 3.5**: Start with AGENT-001 task creation +4. **If Hybrid**: Design the boundary between Noodl and custom React +5. **If Pure Code**: Document learnings for future Noodl improvements + +**Question to answer:** What would prove to you that Noodl CAN'T build Erleah? Define failure criteria upfront so you can pivot quickly if needed. diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/README.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/README.md index f6ea244..3ba24d5 100644 --- a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/README.md +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/README.md @@ -110,78 +110,62 @@ Noodl's original cloud services were tightly coupled to a specific backend imple interface BackendConfig { /** Unique identifier for this backend config */ id: string; - + /** User-friendly name (e.g., "Production Directus", "Local Dev") */ name: string; - + /** Backend platform type */ type: BackendType; - + /** Base URL of the backend */ url: string; - + /** Authentication configuration */ auth: AuthConfig; - + /** Cached schema from introspection */ schema: SchemaCache | null; - + /** Last successful schema sync */ lastSynced: Date | null; - + /** Connection status */ status: ConnectionStatus; - + /** Platform-specific configuration */ platformConfig?: PlatformSpecificConfig; } -type BackendType = - | 'directus' - | 'supabase' - | 'pocketbase' - | 'parse' - | 'firebase' - | 'custom-rest' - | 'custom-graphql'; +type BackendType = 'directus' | 'supabase' | 'pocketbase' | 'parse' | 'firebase' | 'custom-rest' | 'custom-graphql'; interface AuthConfig { /** Authentication method */ method: AuthMethod; - + /** Static API token (stored encrypted) */ staticToken?: string; - + /** OAuth/JWT configuration */ oauth?: OAuthConfig; - + /** API key header name (for custom backends) */ apiKeyHeader?: string; - + /** Whether to use runtime user auth (pass through user's token) */ useRuntimeAuth?: boolean; } -type AuthMethod = - | 'none' - | 'static-token' - | 'api-key' - | 'oauth' - | 'runtime'; // Use currently logged-in user's token +type AuthMethod = 'none' | 'static-token' | 'api-key' | 'oauth' | 'runtime'; // Use currently logged-in user's token interface OAuthConfig { clientId: string; - clientSecret?: string; // Encrypted + clientSecret?: string; // Encrypted authorizationUrl: string; tokenUrl: string; scopes: string[]; } -type ConnectionStatus = - | 'connected' - | 'disconnected' - | 'error' - | 'unknown'; +type ConnectionStatus = 'connected' | 'disconnected' | 'error' | 'unknown'; /** * Platform-specific configuration options @@ -192,7 +176,7 @@ interface PlatformSpecificConfig { /** Use static tokens or Directus auth flow */ authMode: 'static' | 'directus-auth'; }; - + // Supabase supabase?: { /** Anon key for client-side access */ @@ -200,30 +184,30 @@ interface PlatformSpecificConfig { /** Enable realtime subscriptions */ realtimeEnabled: boolean; }; - + // Pocketbase pocketbase?: { /** Admin email for schema introspection */ adminEmail?: string; }; - + // Firebase firebase?: { projectId: string; apiKey: string; authDomain: string; }; - + // Custom REST customRest?: { /** Endpoint patterns */ endpoints: { - list: string; // GET /api/{table} - get: string; // GET /api/{table}/{id} - create: string; // POST /api/{table} - update: string; // PATCH /api/{table}/{id} - delete: string; // DELETE /api/{table}/{id} - schema?: string; // GET /api/schema (optional) + list: string; // GET /api/{table} + get: string; // GET /api/{table}/{id} + create: string; // POST /api/{table} + update: string; // PATCH /api/{table}/{id} + delete: string; // DELETE /api/{table}/{id} + schema?: string; // GET /api/schema (optional) }; /** Response data path (e.g., "data.items" for nested responses) */ dataPath?: string; @@ -242,13 +226,13 @@ interface PlatformSpecificConfig { interface SchemaCache { /** Schema version/hash for cache invalidation */ version: string; - + /** When this schema was fetched */ fetchedAt: Date; - + /** Collections/tables in the backend */ collections: CollectionSchema[]; - + /** Global types/enums if the backend defines them */ types?: TypeDefinition[]; } @@ -259,28 +243,28 @@ interface SchemaCache { interface CollectionSchema { /** Internal collection name */ name: string; - + /** Display name (if different from name) */ displayName?: string; - + /** Collection description */ description?: string; - + /** Fields in this collection */ fields: FieldSchema[]; - + /** Primary key field name */ primaryKey: string; - + /** Timestamps configuration */ timestamps?: { createdAt?: string; updatedAt?: string; }; - + /** Whether this is a system collection (hidden by default) */ isSystem?: boolean; - + /** Relations to other collections */ relations?: RelationSchema[]; } @@ -291,37 +275,37 @@ interface CollectionSchema { interface FieldSchema { /** Field name */ name: string; - + /** Display name */ displayName?: string; - + /** Field type (normalized across platforms) */ type: FieldType; - + /** Original platform-specific type */ nativeType: string; - + /** Whether field is required */ required: boolean; - + /** Whether field is unique */ unique: boolean; - + /** Default value */ defaultValue?: any; - + /** Validation rules */ validation?: ValidationRule[]; - + /** For enum types, the allowed values */ enumValues?: string[]; - + /** For relation types, the target collection */ relationTarget?: string; - + /** Whether field is read-only (system-generated) */ readOnly?: boolean; - + /** Field description */ description?: string; } @@ -332,7 +316,7 @@ interface FieldSchema { type FieldType = // Primitives | 'string' - | 'text' // Long text / rich text + | 'text' // Long text / rich text | 'number' | 'integer' | 'float' @@ -340,23 +324,23 @@ type FieldType = | 'date' | 'datetime' | 'time' - + // Complex | 'json' | 'array' | 'enum' | 'uuid' - + // Files | 'file' | 'image' - + // Relations | 'relation-one' | 'relation-many' - + // Special - | 'password' // Hashed, never returned + | 'password' // Hashed, never returned | 'email' | 'url' | 'unknown'; @@ -367,16 +351,16 @@ type FieldType = interface RelationSchema { /** Field that holds this relation */ field: string; - + /** Target collection */ targetCollection: string; - + /** Target field (usually primary key) */ targetField: string; - + /** Relation type */ type: 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'; - + /** Junction table for many-to-many */ junctionTable?: string; } @@ -400,61 +384,61 @@ interface ValidationRule { interface BackendAdapter { /** Backend type identifier */ readonly type: BackendType; - + /** Display name for UI */ readonly displayName: string; - + /** Icon for UI */ readonly icon: string; - + /** Initialize adapter with configuration */ initialize(config: BackendConfig): Promise; - + /** Test connection to backend */ testConnection(): Promise; - + /** Introspect schema from backend */ introspectSchema(): Promise; - + // CRUD Operations - + /** Query records from a collection */ query(params: QueryParams): Promise; - + /** Get single record by ID */ getById(collection: string, id: string): Promise; - + /** Create a new record */ create(collection: string, data: object): Promise; - + /** Update an existing record */ update(collection: string, id: string, data: object): Promise; - + /** Delete a record */ delete(collection: string, id: string): Promise; - + // Batch Operations - + /** Create multiple records */ createMany?(collection: string, data: object[]): Promise; - + /** Update multiple records */ updateMany?(collection: string, ids: string[], data: object): Promise; - + /** Delete multiple records */ deleteMany?(collection: string, ids: string[]): Promise; - + // Advanced Operations (optional) - + /** Execute raw query (platform-specific) */ rawQuery?(query: string, params?: object): Promise; - + /** Subscribe to realtime changes */ subscribe?(collection: string, callback: ChangeCallback): Unsubscribe; - + /** Upload file */ uploadFile?(file: File, options?: UploadOptions): Promise; - + /** Call server function/action */ callFunction?(name: string, params?: object): Promise; } @@ -465,24 +449,24 @@ interface BackendAdapter { interface QueryParams { /** Collection to query */ collection: string; - + /** Filter conditions */ filter?: FilterGroup; - + /** Fields to return (null = all) */ fields?: string[] | null; - + /** Sort order */ sort?: SortSpec[]; - + /** Pagination */ limit?: number; offset?: number; cursor?: string; - + /** Relations to expand/populate */ expand?: string[]; - + /** Search query (full-text search) */ search?: string; } @@ -502,15 +486,15 @@ interface FilterCondition { } type FilterOperator = - | 'eq' // equals - | 'neq' // not equals - | 'gt' // greater than - | 'gte' // greater than or equal - | 'lt' // less than - | 'lte' // less than or equal - | 'in' // in array - | 'nin' // not in array - | 'contains' // string contains + | 'eq' // equals + | 'neq' // not equals + | 'gt' // greater than + | 'gte' // greater than or equal + | 'lt' // less than + | 'lte' // less than or equal + | 'in' // in array + | 'nin' // not in array + | 'contains' // string contains | 'startsWith' | 'endsWith' | 'isNull' @@ -522,13 +506,13 @@ type FilterOperator = interface QueryResult { /** Returned records */ data: Record[]; - + /** Total count (if available) */ totalCount?: number; - + /** Pagination cursor for next page */ nextCursor?: string; - + /** Whether more records exist */ hasMore: boolean; } @@ -539,7 +523,7 @@ interface QueryResult { interface Record { /** Primary key (always present) */ id: string; - + /** All other fields */ [field: string]: any; } @@ -558,42 +542,40 @@ class DirectusAdapter implements BackendAdapter { readonly type = 'directus'; readonly displayName = 'Directus'; readonly icon = 'directus-logo'; - + private sdk: DirectusSDK; private config: BackendConfig; - + async initialize(config: BackendConfig): Promise { this.config = config; - this.sdk = createDirectus(config.url) - .with(rest()) - .with(staticToken(config.auth.staticToken!)); + this.sdk = createDirectus(config.url).with(rest()).with(staticToken(config.auth.staticToken!)); } - + async introspectSchema(): Promise { // Fetch collections const collections = await this.sdk.request(readCollections()); - + // Fetch fields for each collection const fields = await this.sdk.request(readFields()); - + // Fetch relations const relations = await this.sdk.request(readRelations()); - + return this.mapToSchemaCache(collections, fields, relations); } - + async query(params: QueryParams): Promise { const items = await this.sdk.request( readItems(params.collection, { filter: this.mapFilter(params.filter), fields: params.fields || ['*'], - sort: params.sort?.map(s => `${s.desc ? '-' : ''}${s.field}`), + sort: params.sort?.map((s) => `${s.desc ? '-' : ''}${s.field}`), limit: params.limit, offset: params.offset, - deep: params.expand ? this.buildDeep(params.expand) : undefined, + deep: params.expand ? this.buildDeep(params.expand) : undefined }) ); - + // Get total count if needed let totalCount: number | undefined; if (params.limit) { @@ -605,20 +587,20 @@ class DirectusAdapter implements BackendAdapter { ); totalCount = countResult[0]?.count; } - + return { data: items, totalCount, - hasMore: params.limit ? items.length === params.limit : false, + hasMore: params.limit ? items.length === params.limit : false }; } - + private mapFilter(filter?: FilterGroup): object | undefined { if (!filter) return undefined; - + // Map our normalized filter to Directus filter format // Directus: { _and: [{ field: { _eq: value } }] } - const conditions = filter.conditions.map(c => { + const conditions = filter.conditions.map((c) => { if ('operator' in c && 'conditions' in c) { // Nested group return this.mapFilter(c as FilterGroup); @@ -630,29 +612,29 @@ class DirectusAdapter implements BackendAdapter { } }; }); - + return { [`_${filter.operator}`]: conditions }; } - + private mapOperator(op: FilterOperator): string { const mapping: Record = { - 'eq': 'eq', - 'neq': 'neq', - 'gt': 'gt', - 'gte': 'gte', - 'lt': 'lt', - 'lte': 'lte', - 'in': 'in', - 'nin': 'nin', - 'contains': 'contains', - 'startsWith': 'starts_with', - 'endsWith': 'ends_with', - 'isNull': 'null', - 'isNotNull': 'nnull', + eq: 'eq', + neq: 'neq', + gt: 'gt', + gte: 'gte', + lt: 'lt', + lte: 'lte', + in: 'in', + nin: 'nin', + contains: 'contains', + startsWith: 'starts_with', + endsWith: 'ends_with', + isNull: 'null', + isNotNull: 'nnull' }; return mapping[op]; } - + // ... other methods } ``` @@ -668,60 +650,54 @@ class SupabaseAdapter implements BackendAdapter { readonly type = 'supabase'; readonly displayName = 'Supabase'; readonly icon = 'supabase-logo'; - + private client: SupabaseClient; private config: BackendConfig; - + async initialize(config: BackendConfig): Promise { this.config = config; const supabaseConfig = config.platformConfig?.supabase; - - this.client = createClient( - config.url, - supabaseConfig?.anonKey || config.auth.staticToken!, - { - auth: { - persistSession: false, - }, - realtime: { - enabled: supabaseConfig?.realtimeEnabled ?? false, - }, + + this.client = createClient(config.url, supabaseConfig?.anonKey || config.auth.staticToken!, { + auth: { + persistSession: false + }, + realtime: { + enabled: supabaseConfig?.realtimeEnabled ?? false } - ); + }); } - + async introspectSchema(): Promise { // Supabase provides schema via PostgREST // Use the /rest/v1/ endpoint with OpenAPI spec const response = await fetch(`${this.config.url}/rest/v1/`, { headers: { - 'apikey': this.config.auth.staticToken!, - 'Authorization': `Bearer ${this.config.auth.staticToken}`, - }, + apikey: this.config.auth.staticToken!, + Authorization: `Bearer ${this.config.auth.staticToken}` + } }); - + // Parse OpenAPI spec to extract tables and columns const openApiSpec = await response.json(); return this.mapOpenApiToSchema(openApiSpec); } - + async query(params: QueryParams): Promise { - let query = this.client - .from(params.collection) - .select(params.fields?.join(',') || '*', { count: 'exact' }); - + let query = this.client.from(params.collection).select(params.fields?.join(',') || '*', { count: 'exact' }); + // Apply filters if (params.filter) { query = this.applyFilters(query, params.filter); } - + // Apply sort if (params.sort) { for (const sort of params.sort) { query = query.order(sort.field, { ascending: !sort.desc }); } } - + // Apply pagination if (params.limit) { query = query.limit(params.limit); @@ -729,40 +705,36 @@ class SupabaseAdapter implements BackendAdapter { if (params.offset) { query = query.range(params.offset, params.offset + (params.limit || 100) - 1); } - + const { data, count, error } = await query; - + if (error) throw new BackendError(error.message, error); - + return { data: data || [], totalCount: count ?? undefined, - hasMore: params.limit ? (data?.length || 0) === params.limit : false, + hasMore: params.limit ? (data?.length || 0) === params.limit : false }; } - + // Realtime subscription (Supabase-specific feature) subscribe(collection: string, callback: ChangeCallback): Unsubscribe { const channel = this.client .channel(`${collection}_changes`) - .on( - 'postgres_changes', - { event: '*', schema: 'public', table: collection }, - (payload) => { - callback({ - type: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE', - record: payload.new as Record, - oldRecord: payload.old as Record, - }); - } - ) + .on('postgres_changes', { event: '*', schema: 'public', table: collection }, (payload) => { + callback({ + type: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE', + record: payload.new as Record, + oldRecord: payload.old as Record + }); + }) .subscribe(); - + return () => { this.client.removeChannel(channel); }; } - + // ... other methods } ``` @@ -778,35 +750,32 @@ class PocketbaseAdapter implements BackendAdapter { readonly type = 'pocketbase'; readonly displayName = 'Pocketbase'; readonly icon = 'pocketbase-logo'; - + private client: PocketBase; private config: BackendConfig; - + async initialize(config: BackendConfig): Promise { this.config = config; this.client = new PocketBase(config.url); - + // Authenticate if admin credentials provided const pbConfig = config.platformConfig?.pocketbase; if (pbConfig?.adminEmail && config.auth.staticToken) { - await this.client.admins.authWithPassword( - pbConfig.adminEmail, - config.auth.staticToken - ); + await this.client.admins.authWithPassword(pbConfig.adminEmail, config.auth.staticToken); } } - + async introspectSchema(): Promise { // Pocketbase provides collection schema via admin API const collections = await this.client.collections.getFullList(); - + return { version: Date.now().toString(), fetchedAt: new Date(), - collections: collections.map(c => this.mapCollection(c)), + collections: collections.map((c) => this.mapCollection(c)) }; } - + private mapCollection(pb: any): CollectionSchema { return { name: pb.name, @@ -815,27 +784,27 @@ class PocketbaseAdapter implements BackendAdapter { fields: pb.schema.map((f: any) => this.mapField(f)), timestamps: { createdAt: 'created', - updatedAt: 'updated', + updatedAt: 'updated' }, - isSystem: pb.system, + isSystem: pb.system }; } - + private mapField(field: any): FieldSchema { const typeMap: Record = { - 'text': 'string', - 'editor': 'text', - 'number': 'number', - 'bool': 'boolean', - 'email': 'email', - 'url': 'url', - 'date': 'datetime', - 'select': 'enum', - 'json': 'json', - 'file': 'file', - 'relation': field.options?.maxSelect === 1 ? 'relation-one' : 'relation-many', + text: 'string', + editor: 'text', + number: 'number', + bool: 'boolean', + email: 'email', + url: 'url', + date: 'datetime', + select: 'enum', + json: 'json', + file: 'file', + relation: field.options?.maxSelect === 1 ? 'relation-one' : 'relation-many' }; - + return { name: field.name, type: typeMap[field.type] || 'unknown', @@ -843,32 +812,30 @@ class PocketbaseAdapter implements BackendAdapter { required: field.required, unique: field.unique, enumValues: field.type === 'select' ? field.options?.values : undefined, - relationTarget: field.options?.collectionId, + relationTarget: field.options?.collectionId }; } - + async query(params: QueryParams): Promise { - const result = await this.client.collection(params.collection).getList( - params.offset ? Math.floor(params.offset / (params.limit || 20)) + 1 : 1, - params.limit || 20, - { + const result = await this.client + .collection(params.collection) + .getList(params.offset ? Math.floor(params.offset / (params.limit || 20)) + 1 : 1, params.limit || 20, { filter: params.filter ? this.buildFilter(params.filter) : undefined, - sort: params.sort?.map(s => `${s.desc ? '-' : ''}${s.field}`).join(','), - expand: params.expand?.join(','), - } - ); - + sort: params.sort?.map((s) => `${s.desc ? '-' : ''}${s.field}`).join(','), + expand: params.expand?.join(',') + }); + return { data: result.items, totalCount: result.totalItems, - hasMore: result.page < result.totalPages, + hasMore: result.page < result.totalPages }; } - + private buildFilter(filter: FilterGroup): string { // Pocketbase uses a custom filter syntax // e.g., "name = 'John' && age > 18" - const parts = filter.conditions.map(c => { + const parts = filter.conditions.map((c) => { if ('operator' in c && 'conditions' in c) { return `(${this.buildFilter(c as FilterGroup)})`; } @@ -877,11 +844,11 @@ class PocketbaseAdapter implements BackendAdapter { const value = typeof cond.value === 'string' ? `'${cond.value}'` : cond.value; return `${cond.field} ${op} ${value}`; }); - + const joiner = filter.operator === 'and' ? ' && ' : ' || '; return parts.join(joiner); } - + // ... other methods } ``` @@ -1063,11 +1030,13 @@ This replaces the current Cloud Services UI with a flexible multi-backend config ## Implementation Phases ### Phase A.1: HTTP Node Foundation (5-7 days) -*Prerequisite: TASK-002 HTTP Node* + +_Prerequisite: TASK-002 HTTP Node_ The HTTP Node provides the underlying request capability that all backend adapters will use. **Tasks:** + - [ ] Implement robust HTTP node with all methods (GET, POST, PUT, PATCH, DELETE) - [ ] Add authentication header configuration - [ ] Add request/response transformation options @@ -1077,6 +1046,7 @@ The HTTP Node provides the underlying request capability that all backend adapte ### Phase A.2: Backend Configuration System (1 week) **Tasks:** + - [ ] Design `BackendConfig` data model - [ ] Implement encrypted credential storage - [ ] Create Backend Configuration Hub UI @@ -1085,6 +1055,7 @@ The HTTP Node provides the underlying request capability that all backend adapte - [ ] Store backend configs in project metadata **Files to Create:** + ``` packages/noodl-editor/src/editor/src/ ├── models/ @@ -1107,15 +1078,17 @@ packages/noodl-editor/src/editor/src/ ### Phase A.3: Schema Introspection Engine (1 week) **Tasks:** + - [ ] Define unified `SchemaCache` interface - [ ] Implement schema introspection for Directus -- [ ] Implement schema introspection for Supabase +- [ ] Implement schema introspection for Supabase - [ ] Implement schema introspection for Pocketbase - [ ] Create schema caching mechanism - [ ] Implement schema diff detection (for sync) - [ ] Add manual schema refresh **Files to Create:** + ``` packages/noodl-runtime/src/backends/ ├── types.ts # Shared type definitions @@ -1132,6 +1105,7 @@ packages/noodl-runtime/src/backends/ ### Phase A.4: Directus Adapter (1 week) **Tasks:** + - [ ] Implement full CRUD operations via Directus SDK - [ ] Map Directus filter syntax to unified format - [ ] Handle Directus-specific field types @@ -1142,6 +1116,7 @@ packages/noodl-runtime/src/backends/ ### Phase A.5: Data Node Updates (1 week) **Tasks:** + - [ ] Add backend selector to all data nodes - [ ] Populate collection dropdown from schema - [ ] Generate field inputs from collection schema @@ -1152,6 +1127,7 @@ packages/noodl-runtime/src/backends/ - [ ] Add loading and error outputs **Nodes to Modify:** + - `Query Records` - Add backend selector, dynamic fields - `Create Record` - Schema-aware field inputs - `Update Record` - Schema-aware field inputs @@ -1169,40 +1145,35 @@ describe('DirectusAdapter', () => { it('should fetch and map collections correctly', async () => { const adapter = new DirectusAdapter(); await adapter.initialize(mockConfig); - + const schema = await adapter.introspectSchema(); - + expect(schema.collections).toHaveLength(3); expect(schema.collections[0].name).toBe('users'); - expect(schema.collections[0].fields).toContainEqual( - expect.objectContaining({ name: 'email', type: 'email' }) - ); + expect(schema.collections[0].fields).toContainEqual(expect.objectContaining({ name: 'email', type: 'email' })); }); }); - + describe('query', () => { it('should translate filters correctly', async () => { const spy = jest.spyOn(directusSdk, 'request'); - + await adapter.query({ collection: 'posts', filter: { operator: 'and', conditions: [ { field: 'status', operator: 'eq', value: 'published' }, - { field: 'views', operator: 'gt', value: 100 }, - ], - }, + { field: 'views', operator: 'gt', value: 100 } + ] + } }); - + expect(spy).toHaveBeenCalledWith( expect.objectContaining({ filter: { - _and: [ - { status: { _eq: 'published' } }, - { views: { _gt: 100 } }, - ], - }, + _and: [{ status: { _eq: 'published' } }, { views: { _gt: 100 } }] + } }) ); }); @@ -1216,30 +1187,30 @@ describe('DirectusAdapter', () => { describe('Backend Integration', () => { // These require actual backend instances // Run with: npm test:integration - + describe('Directus', () => { const adapter = new DirectusAdapter(); - + beforeAll(async () => { await adapter.initialize({ url: process.env.DIRECTUS_URL!, - auth: { method: 'static-token', staticToken: process.env.DIRECTUS_TOKEN }, + auth: { method: 'static-token', staticToken: process.env.DIRECTUS_TOKEN } }); }); - + it('should create, read, update, delete a record', async () => { // Create const created = await adapter.create('test_items', { name: 'Test' }); expect(created.id).toBeDefined(); - + // Read const fetched = await adapter.getById('test_items', created.id); expect(fetched?.name).toBe('Test'); - + // Update const updated = await adapter.update('test_items', created.id, { name: 'Updated' }); expect(updated.name).toBe('Updated'); - + // Delete await adapter.delete('test_items', created.id); const deleted = await adapter.getById('test_items', created.id); @@ -1258,36 +1229,33 @@ All sensitive credentials (API tokens, passwords) must be encrypted at rest: ```typescript class CredentialManager { private encryptionKey: Buffer; - + constructor() { // Derive key from machine-specific identifier this.encryptionKey = this.deriveKey(); } - + async encrypt(value: string): Promise { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv); - - const encrypted = Buffer.concat([ - cipher.update(value, 'utf8'), - cipher.final(), - ]); - + + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); - + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); } - + async decrypt(encrypted: string): Promise { const buffer = Buffer.from(encrypted, 'base64'); - + const iv = buffer.subarray(0, 16); const authTag = buffer.subarray(16, 32); const data = buffer.subarray(32); - + const decipher = crypto.createDecipheriv('aes-256-gcm', this.encryptionKey, iv); decipher.setAuthTag(authTag); - + return decipher.update(data) + decipher.final('utf8'); } } @@ -1301,10 +1269,10 @@ When using runtime authentication (passing through user's token): interface RuntimeAuthContext { /** Get current user's auth token */ getToken(): Promise; - + /** Refresh token if expired */ refreshToken(): Promise; - + /** Clear auth state (logout) */ clearAuth(): void; } @@ -1312,7 +1280,7 @@ interface RuntimeAuthContext { // In backend adapter async query(params: QueryParams, authContext?: RuntimeAuthContext): Promise { const headers: Record = {}; - + if (this.config.auth.useRuntimeAuth && authContext) { const token = await authContext.getToken(); if (token) { @@ -1321,7 +1289,7 @@ async query(params: QueryParams, authContext?: RuntimeAuthContext): Promise; - + /** Subscribe to specific record changes */ subscribeToRecord(collection: string, id: string): Observable; } @@ -1354,12 +1322,12 @@ class GraphQLAdapter implements BackendAdapter { async introspectSchema(): Promise { // Use GraphQL introspection query const introspectionResult = await this.client.query({ - query: getIntrospectionQuery(), + query: getIntrospectionQuery() }); - + return this.mapGraphQLSchemaToCache(introspectionResult); } - + async query(params: QueryParams): Promise { // Generate GraphQL query from params const query = this.buildQuery(params); @@ -1392,33 +1360,48 @@ interface FieldChange { ## Success Metrics -| Metric | Target | Measurement | -|--------|--------|-------------| -| Backend connection time | < 2s | Time from "Add Backend" to "Connected" | -| Schema introspection time | < 5s | Time to fetch and parse full schema | -| Query execution overhead | < 50ms | Time added by adapter vs raw HTTP | -| Node property panel render | < 100ms | Time to render schema-aware dropdowns | -| User satisfaction | > 4/5 | Survey of users migrating from cloud services | +| Metric | Target | Measurement | +| -------------------------- | ------- | --------------------------------------------- | +| Backend connection time | < 2s | Time from "Add Backend" to "Connected" | +| Schema introspection time | < 5s | Time to fetch and parse full schema | +| Query execution overhead | < 50ms | Time added by adapter vs raw HTTP | +| Node property panel render | < 100ms | Time to render schema-aware dropdowns | +| User satisfaction | > 4/5 | Survey of users migrating from cloud services | ## Appendix: Backend Comparison Matrix -| Feature | Directus | Supabase | Pocketbase | Parse | Firebase | -|---------|----------|----------|------------|-------|----------| -| **Schema Introspection** | ✅ REST API | ✅ OpenAPI | ✅ Admin API | ✅ REST | ⚠️ Manual | -| **CRUD Operations** | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full | -| **Filtering** | ✅ Rich | ✅ Rich | ✅ Good | ✅ Rich | ⚠️ Limited | -| **Relations** | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ⚠️ Manual | -| **File Upload** | ✅ Built-in | ✅ Storage | ✅ Built-in | ✅ Files | ✅ Storage | -| **Realtime** | ⚠️ Extension | ✅ Built-in | ✅ SSE | ✅ LiveQuery | ✅ Built-in | -| **Auth Integration** | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full | -| **Self-Hostable** | ✅ Docker | ⚠️ Complex | ✅ Single binary | ✅ Docker | ❌ No | -| **Open Source** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No | +| Feature | Directus | Supabase | Pocketbase | Parse | Firebase | +| ------------------------ | ------------ | ----------- | ---------------- | ------------ | ----------- | +| **Schema Introspection** | ✅ REST API | ✅ OpenAPI | ✅ Admin API | ✅ REST | ⚠️ Manual | +| **CRUD Operations** | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full | +| **Filtering** | ✅ Rich | ✅ Rich | ✅ Good | ✅ Rich | ⚠️ Limited | +| **Relations** | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ⚠️ Manual | +| **File Upload** | ✅ Built-in | ✅ Storage | ✅ Built-in | ✅ Files | ✅ Storage | +| **Realtime** | ⚠️ Extension | ✅ Built-in | ✅ SSE | ✅ LiveQuery | ✅ Built-in | +| **Auth Integration** | ✅ Full | ✅ Full | ✅ Full | ✅ Full | ✅ Full | +| **Self-Hostable** | ✅ Docker | ⚠️ Complex | ✅ Single binary | ✅ Docker | ❌ No | +| **Open Source** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No | + +## Task Index + +| Task | Name | Status | Priority | Description | +| ---------------------------------------------- | ---------------------- | ----------- | ----------- | -------------------------------------------------------------- | +| [TASK-001](./TASK-001-backend-services-panel/) | Backend Services Panel | ✅ Complete | 🔴 Critical | Sidebar panel for configuring backends | +| [TASK-002](./TASK-002-data-nodes/) | Data Nodes Integration | Not Started | 🔴 Critical | Query, Create, Update, Delete nodes with Visual Filter Builder | +| [TASK-003](./TASK-003-schema-viewer/) | Schema Viewer | Not Started | 🟡 Medium | Interactive tree view of backend schema | +| [TASK-004](./TASK-004-edit-backend-dialog/) | Edit Backend Dialog | Not Started | 🟡 Medium | Edit existing backend configurations | +| [TASK-005](./TASK-005-local-docker-wizard/) | Local Docker Wizard | Not Started | 🟢 Low | Spin up local backends via Docker | + +## Recommended Implementation Order + +``` +TASK-001 ✅ → TASK-002 (Critical) → TASK-003 → TASK-004 → TASK-005 + ↑ + Hero Feature: Visual Filter Builder +``` ## Document Index - [README.md](./README.md) - This overview document -- [SCHEMA-SPEC.md](./SCHEMA-SPEC.md) - Detailed schema specification -- [ADAPTER-GUIDE.md](./ADAPTER-GUIDE.md) - Guide for implementing new adapters -- [UI-DESIGNS.md](./UI-DESIGNS.md) - Detailed UI mockups and interactions -- [TESTING.md](./TESTING.md) - Testing strategy and test cases -- [MIGRATION.md](./MIGRATION.md) - Migrating from old cloud services +- [SCHEMA-SPEC.md](./SCHEMA-SPEC.md) - Detailed schema specification (planned) +- [ADAPTER-GUIDE.md](./ADAPTER-GUIDE.md) - Guide for implementing new adapters (planned) diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-001-backend-services-panel/README.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-001-backend-services-panel/README.md new file mode 100644 index 0000000..409441d --- /dev/null +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-001-backend-services-panel/README.md @@ -0,0 +1,135 @@ +# TASK-001: Backend Services Panel + +**Task ID:** TASK-001 +**Phase:** 5 - Multi-Target Deployment +**Priority:** 🔴 Critical +**Estimated Duration:** 1 week +**Status:** ✅ Complete (Phase 1) +**Created:** 2025-12-29 +**Completed:** 2025-12-29 +**Branch:** `feature/byob-backend` + +## Overview + +Create a new "Backend Services" sidebar panel that allows users to configure external backend databases (Directus, Supabase, Pocketbase, or any custom REST API). This panel will sit alongside the existing "Cloud Services" panel, giving users flexibility to choose their data backend. + +## Goals + +1. **New Sidebar Panel** - "Backend Services" tab in the far left menu ✅ +2. **Backend List** - Display configured backends with connection status ✅ +3. **Add Backend Dialog** - Configure new backends with presets or custom ✅ +4. **Edit/Delete** - Manage existing backend configurations ✅ +5. **Schema Browser** - View tables/collections from connected backends ✅ (basic) + +## What Was Implemented + +### Model Layer + +- `types.ts` - Complete TypeScript interfaces for BackendConfig, schemas, events +- `presets.ts` - Preset configurations for Directus, Supabase, Pocketbase, Custom REST +- `BackendServices.ts` - Singleton model with CRUD, connection testing, schema introspection +- `index.ts` - Clean exports + +### UI Components + +- `BackendServicesPanel.tsx` - Main panel with backend list and actions +- `BackendCard/BackendCard.tsx` - Individual backend card with status, actions +- `AddBackendDialog/AddBackendDialog.tsx` - Modal for adding new backends +- All components use proper design tokens (no hardcoded colors) + +### Key Features + +- **Preset Selection**: Easy setup for Directus, Supabase, Pocketbase +- **Custom REST API**: Full configurability for any REST API +- **Connection Testing**: Test button validates connectivity +- **Schema Introspection**: Fetches and caches table/field definitions +- **Active Backend**: One backend can be marked as active +- **Persistence**: Saves to project metadata + +## Implementation Steps + +### Step 1: Create Backend Model ✅ + +- [x] Create `BackendServices/types.ts` with TypeScript interfaces +- [x] Create `BackendServices/BackendServices.ts` singleton model +- [x] Create `BackendServices/presets.ts` for preset configurations + +### Step 2: Create Panel UI ✅ + +- [x] Create `BackendServicesPanel.tsx` base panel +- [x] Create `BackendCard.tsx` for displaying a backend +- [x] Add panel to sidebar registry in `router.setup.ts` + +### Step 3: Add Backend Dialog ✅ + +- [x] Create `AddBackendDialog.tsx` with preset selection +- [x] Create backend preset configurations (Directus, Supabase, etc.) +- [x] Implement connection testing +- [x] Implement save/cancel logic + +### Step 4: Schema Introspection ✅ (Basic) + +- [x] Implement Directus schema parsing +- [x] Implement Supabase schema parsing (OpenAPI) +- [x] Implement Pocketbase schema parsing +- [x] Cache schema in backend config + +### Step 5: Integration ✅ + +- [x] Add "activeBackendId" to project settings +- [x] Emit events when backends change + +## Files Created + +``` +packages/noodl-editor/src/editor/src/ +├── models/BackendServices/ +│ ├── types.ts # TypeScript interfaces +│ ├── presets.ts # Directus, Supabase, Pocketbase presets +│ ├── BackendServices.ts # Main singleton model +│ └── index.ts # Exports +└── views/panels/BackendServicesPanel/ + ├── BackendServicesPanel.tsx + ├── BackendCard/ + │ ├── BackendCard.tsx + │ └── BackendCard.module.scss + └── AddBackendDialog/ + ├── AddBackendDialog.tsx + └── AddBackendDialog.module.scss +``` + +## Files Modified + +``` +packages/noodl-editor/src/editor/src/router.setup.ts # Register panel +``` + +## Testing Status + +- [x] Editor builds and runs without errors +- [ ] Manual testing of panel (pending user verification) +- [ ] Test with real Directus instance +- [ ] Test with real Supabase instance + +## Next Steps (Future Tasks) + +1. **TASK-002: Data Node Integration** - Update data nodes to use Backend Services +2. **SchemaViewer Component** - Dedicated component to browse schema +3. **Edit Backend Dialog** - Edit existing backends (currently only add/delete) +4. **Local Docker Wizard** - Spin up backends locally + +## Notes + +- Keep existing Cloud Services panel untouched (Option A from planning) +- Use design tokens for all styling (no hardcoded colors) +- Follow existing patterns from CloudServicePanel closely +- Panel icon: `RestApi` (Database icon didn't exist in IconName enum) + +## Related Tasks + +| Task | Name | Status | +| -------- | ------------------------------------------------------- | ----------- | +| TASK-002 | [Data Nodes Integration](../TASK-002-data-nodes/) | Not Started | +| TASK-003 | [Schema Viewer Component](../TASK-003-schema-viewer/) | Not Started | +| TASK-004 | [Edit Backend Dialog](../TASK-004-edit-backend-dialog/) | Not Started | +| TASK-005 | [Local Docker Wizard](../TASK-005-local-docker-wizard/) | Not Started | diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/FILTER-BUILDER.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/FILTER-BUILDER.md new file mode 100644 index 0000000..1247203 --- /dev/null +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/FILTER-BUILDER.md @@ -0,0 +1,205 @@ +# Visual Filter Builder Specification + +The Visual Filter Builder is the **hero feature** of TASK-002. It transforms the painful experience of writing Directus filter JSON into an intuitive visual interface. + +## The Problem + +Directus filters require complex nested JSON: + +```json +{ + "_and": [ + { "status": { "_eq": "published" } }, + { "author": { "name": { "_contains": "John" } } }, + { "_or": [{ "views": { "_gt": 100 } }, { "featured": { "_eq": true } }] } + ] +} +``` + +This is error-prone and requires memorizing operator names. + +## The Solution + +A visual builder that generates this JSON automatically: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ FILTER CONDITIONS [+ Add Rule] │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌─ AND ─────────────────────────────────────────────────────── [×] ─┐ +│ │ │ +│ │ [status ▾] [equals ▾] [published ▾] [×] │ +│ │ │ +│ │ [author.name ▾] [contains ▾] [John ] [×] │ +│ │ │ +│ │ ┌─ OR ───────────────────────────────────────────── [×] ─┐ │ +│ │ │ [views ▾] [greater than ▾] [100 ] [×] │ │ +│ │ │ [featured ▾] [equals ▾] [true ▾] [×] │ │ +│ │ │ [+ Add Condition] │ │ +│ │ └────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ [+ Add Condition] [+ Add Group] │ +│ └───────────────────────────────────────────────────────────────────┘ +│ │ +│ ▶ Preview JSON │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Key Features + +### 1. Schema-Aware Field Dropdown + +Fields populated from cached schema with: + +- Nested relation traversal (`author.name`, `category.parent.name`) +- Field type icons +- Smart search/filtering + +### 2. Type-Aware Operator Selection + +| Field Type | Available Operators | +| ---------- | ---------------------------------------------------------------------------- | +| String | equals, not equals, contains, starts with, ends with, is empty, is not empty | +| Number | equals, not equals, greater than, less than, >=, <=, between | +| Boolean | equals, not equals | +| Date | equals, before, after, between, is empty | +| Enum | equals, not equals, in, not in | +| Relation | equals (ID), is empty, is not empty | + +### 3. Type-Aware Value Input + +| Field Type | Value UI | +| ---------- | ------------------------------------- | +| String | Text input | +| Number | Number input with validation | +| Boolean | Toggle or dropdown (true/false/null) | +| Date | Date picker | +| Enum | Dropdown with schema-defined values | +| Relation | Search/select from related collection | + +### 4. AND/OR Grouping + +- Drag-and-drop reordering +- Unlimited nesting depth +- Visual indentation +- Collapse/expand groups + +### 5. JSON Preview + +- Toggle to see generated Directus filter +- Syntax highlighted +- Copy button +- Edit JSON directly (advanced mode) + +## Data Model + +```typescript +interface FilterGroup { + id: string; + type: 'and' | 'or'; + conditions: (FilterCondition | FilterGroup)[]; +} + +interface FilterCondition { + id: string; + field: string; // e.g., "status" or "author.name" + operator: FilterOperator; + value: any; +} + +type FilterOperator = + | '_eq' + | '_neq' // equals, not equals + | '_gt' + | '_gte' // greater than (or equal) + | '_lt' + | '_lte' // less than (or equal) + | '_contains' + | '_ncontains' + | '_starts_with' + | '_ends_with' + | '_in' + | '_nin' // in array, not in array + | '_null' + | '_nnull' // is null, is not null + | '_between'; // between two values +``` + +## Implementation Approach + +### Component Structure + +``` +FilterBuilder/ +├── FilterBuilder.tsx # Main container +├── FilterBuilder.module.scss +├── FilterGroup.tsx # AND/OR group (recursive) +├── FilterCondition.tsx # Single condition row +├── FieldSelector.tsx # Schema-aware field dropdown +├── OperatorSelector.tsx # Type-aware operator dropdown +├── ValueInput.tsx # Type-aware value input +├── JsonPreview.tsx # Generated JSON preview +└── types.ts # TypeScript interfaces +``` + +### State Management + +```typescript +// In the Query Records node property panel +const [filter, setFilter] = useState({ + id: 'root', + type: 'and', + conditions: [] +}); + +// Convert to Directus format on change +useEffect(() => { + const directusFilter = convertToDirectusFilter(filter); + node.setParameter('filter', directusFilter); +}, [filter]); +``` + +## Directus Filter Conversion + +```typescript +function convertToDirectusFilter(group: FilterGroup): object { + const key = `_${group.type}`; // _and or _or + + const conditions = group.conditions.map((item) => { + if ('type' in item) { + // Nested group + return convertToDirectusFilter(item); + } else { + // Single condition + return convertCondition(item); + } + }); + + return { [key]: conditions }; +} + +function convertCondition(cond: FilterCondition): object { + // Handle nested fields like "author.name" + const parts = cond.field.split('.'); + + let result: any = { [cond.operator]: cond.value }; + + // Build nested structure from inside out + for (let i = parts.length - 1; i >= 0; i--) { + result = { [parts[i]]: result }; + } + + return result; +} +``` + +## Success Criteria + +- [ ] Users can build filters without knowing Directus JSON syntax +- [ ] Field dropdown shows all fields from schema +- [ ] Nested relations work (author.name) +- [ ] Operators change based on field type +- [ ] Value inputs match field type +- [ ] AND/OR grouping works with nesting +- [ ] Generated JSON is valid Directus filter +- [ ] JSON preview shows the output diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/NODES-SPEC.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/NODES-SPEC.md new file mode 100644 index 0000000..cf15213 --- /dev/null +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/NODES-SPEC.md @@ -0,0 +1,213 @@ +# Data Node Specifications + +This document defines the four data nodes for BYOB backends. + +## 1. Query Records Node + +The primary node for fetching data from backends. + +### Inputs + +| Input | Type | Description | +| ---------- | ------------- | ----------------------------------------------- | +| Backend | dropdown | Select configured backend (or "Active Backend") | +| Collection | dropdown | Select table/collection from schema | +| Filter | FilterBuilder | Visual filter builder (see FILTER-BUILDER.md) | +| Sort Field | dropdown | Field to sort by | +| Sort Order | enum | Ascending / Descending | +| Limit | number | Max records to return | +| Offset | number | Records to skip (pagination) | +| Fields | multi-select | Fields to return (default: all) | +| Do | signal | Trigger the query | + +### Outputs + +| Output | Type | Description | +| ------------ | ------- | --------------------------------------- | +| Records | array | Array of record objects | +| First Record | object | First record (convenience) | +| Count | number | Number of records returned | +| Total Count | number | Total matching records (for pagination) | +| Loading | boolean | True while request in progress | +| Error | object | Error details if failed | +| Done | signal | Fires when query completes | +| Failed | signal | Fires on error | + +### Property Panel UI + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Query Records │ +├─────────────────────────────────────────────────────────────────────┤ +│ BACKEND │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ ○ Active Backend (Production Directus) │ │ +│ │ ● Specific Backend: [Production Directus ▾] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ COLLECTION │ +│ [posts ▾] │ +│ │ +│ ▼ FILTER │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ [Visual Filter Builder - see FILTER-BUILDER.md] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ▼ SORT │ +│ [created_at ▾] [Descending ▾] │ +│ │ +│ ▼ PAGINATION │ +│ Limit: [20 ] Offset: [0 ] │ +│ │ +│ ▶ FIELDS (optional) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Create Record Node + +Creates a new record in a collection. + +### Inputs + +| Input | Type | Description | +| ---------- | -------- | --------------------------------------- | +| Backend | dropdown | Select configured backend | +| Collection | dropdown | Select table/collection | +| Data | object | Record data (dynamic ports from schema) | +| Do | signal | Trigger creation | + +### Dynamic Inputs + +When a collection is selected, ports are dynamically generated for each writable field: + +- `field_title` (string) +- `field_content` (text) +- `field_author` (relation → user ID) +- etc. + +### Outputs + +| Output | Type | Description | +| ------- | ------ | -------------------- | +| Record | object | The created record | +| ID | string | ID of created record | +| Success | signal | Fires on success | +| Failed | signal | Fires on error | +| Error | object | Error details | + +--- + +## 3. Update Record Node + +Updates an existing record. + +### Inputs + +| Input | Type | Description | +| ---------- | -------- | -------------------------------- | +| Backend | dropdown | Select configured backend | +| Collection | dropdown | Select table/collection | +| Record ID | string | ID of record to update | +| Data | object | Fields to update (dynamic ports) | +| Do | signal | Trigger update | + +### Dynamic Inputs + +Same as Create Record - schema-driven field inputs. + +### Outputs + +| Output | Type | Description | +| ------- | ------ | ------------------ | +| Record | object | The updated record | +| Success | signal | Fires on success | +| Failed | signal | Fires on error | +| Error | object | Error details | + +--- + +## 4. Delete Record Node + +Deletes a record from a collection. + +### Inputs + +| Input | Type | Description | +| ---------- | -------- | ------------------------- | +| Backend | dropdown | Select configured backend | +| Collection | dropdown | Select table/collection | +| Record ID | string | ID of record to delete | +| Do | signal | Trigger deletion | + +### Outputs + +| Output | Type | Description | +| ------- | ------ | ---------------- | +| Success | signal | Fires on success | +| Failed | signal | Fires on error | +| Error | object | Error details | + +--- + +## Implementation Files + +``` +packages/noodl-runtime/src/nodes/std-library/data/ +├── directus/ +│ ├── query-records.js # Query Records runtime +│ ├── create-record.js # Create Record runtime +│ ├── update-record.js # Update Record runtime +│ ├── delete-record.js # Delete Record runtime +│ └── utils.js # Shared utilities + +packages/noodl-editor/src/editor/src/views/propertyeditor/ +├── DataNodePropertyEditor/ +│ ├── BackendSelector.tsx # Backend dropdown +│ ├── CollectionSelector.tsx # Collection dropdown +│ └── DynamicFieldInputs.tsx # Schema-driven field inputs +``` + +## Node Registration + +```javascript +// In node index/registration +module.exports = { + node: QueryRecordsNode, + setup: function (context, graphModel) { + // Register dynamic ports based on schema + graphModel.on('editorImportComplete', () => { + // Set up schema-aware dropdowns + }); + } +}; +``` + +## HTTP Request Format (Directus) + +### Query + +``` +GET /items/{collection}?filter={json}&sort={field}&limit={n}&offset={n} +``` + +### Create + +``` +POST /items/{collection} +Body: { field1: value1, field2: value2 } +``` + +### Update + +``` +PATCH /items/{collection}/{id} +Body: { field1: newValue } +``` + +### Delete + +``` +DELETE /items/{collection}/{id} +``` diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/README.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/README.md new file mode 100644 index 0000000..e525db9 --- /dev/null +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-002-data-nodes/README.md @@ -0,0 +1,87 @@ +# TASK-002: Data Nodes Integration + +**Task ID:** TASK-002 +**Phase:** 5 - Multi-Target Deployment (BYOB) +**Priority:** 🔴 Critical +**Difficulty:** 🔴 Hard +**Estimated Time:** 1-2 weeks +**Prerequisites:** TASK-001 (Backend Services Panel) +**Branch:** `feature/byob-data-nodes` + +## Objective + +Create visual data nodes (Query, Create, Update, Delete) that connect to the configured Backend Services, with a **Visual Filter Builder** as the hero feature. + +## Background + +Users can now configure backend connections (TASK-001), but there's no way to actually USE them. This task bridges that gap by creating data nodes that: + +- Read from the cached schema to populate field dropdowns +- Execute queries against the configured backend +- Provide a visual way to build complex filters (the key differentiator) + +The **Visual Filter Builder** eliminates the pain of writing Directus filter JSON manually. + +## User Story + +> As a Noodl user, I want to visually build queries against my Directus backend, so I don't have to learn the complex filter JSON syntax. + +## Current State + +- Backend Services Panel exists (TASK-001 ✅) +- Schema introspection works for Directus +- No data nodes exist for BYOB backends +- Users would have to use raw HTTP nodes + +## Desired State + +- Query Records node with visual filter builder +- Create/Update/Delete Record nodes +- All nodes populate dropdowns from cached schema +- Filters generate correct Directus JSON + +## Scope + +### In Scope + +- Query Records node (most complex - has filter builder) +- Create Record node +- Update Record node +- Delete Record node +- Directus backend support (primary focus) + +### Out of Scope + +- Supabase/Pocketbase adapters (future task) +- Realtime subscriptions +- Batch operations +- File upload nodes + +--- + +## Known Limitations (Dec 2025) + +### Directus System Collections Not Supported + +**Issue**: The Query Data node only uses `/items/{collection}` API endpoint, which doesn't work for Directus system tables. + +**Affected Collections**: + +- `directus_users` - User management (use `/users` endpoint) +- `directus_roles` - Role management (use `/roles` endpoint) +- `directus_files` - File management (use `/files` endpoint) +- `directus_folders` - Folder management (use `/folders` endpoint) +- `directus_activity` - Activity log (use `/activity` endpoint) +- `directus_permissions` - Permissions (use `/permissions` endpoint) +- And other `directus_*` system tables + +**Current Behavior**: These collections may appear in the Collection dropdown (if schema introspection includes them), but queries will fail with 404 or forbidden errors. + +**Future Enhancement**: Add an "API Path Type" dropdown to the Query node: + +- **Items** (default) - Uses `/items/{collection}` for user collections +- **System** - Uses `/{collection_without_directus_prefix}` for system tables + +**Alternative Workaround**: Use the HTTP Request node with manual endpoint construction for system table access. + +**Related**: This limitation also affects Create/Update/Delete Record nodes (when implemented). diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-003-schema-viewer/README.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-003-schema-viewer/README.md new file mode 100644 index 0000000..dbb870f --- /dev/null +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-003-schema-viewer/README.md @@ -0,0 +1,158 @@ +# TASK-003: Schema Viewer Component + +**Task ID:** TASK-003 +**Phase:** 5 - Multi-Target Deployment (BYOB) +**Priority:** 🟡 Medium +**Difficulty:** 🟢 Easy +**Estimated Time:** 2-3 days +**Prerequisites:** TASK-001 (Backend Services Panel) +**Branch:** `feature/byob-schema-viewer` + +## Objective + +Create a dedicated Schema Viewer component that displays the cached schema from connected backends in an interactive, collapsible tree view. + +## Background + +TASK-001 implemented basic schema introspection, but the current UI only shows collection names in a simple list. Users need to: + +- See all fields in each collection +- Understand field types and constraints +- Easily copy field names for use in nodes +- Refresh schema when backend changes + +## User Story + +> As a Noodl user, I want to browse my backend's data schema visually, so I can understand what data is available and use correct field names. + +## Current State + +- Schema is fetched and cached (TASK-001 ✅) +- Only collection names shown in Backend Services Panel +- No field details visible +- No way to copy field paths + +## Desired State + +- Expandable tree view of collections → fields +- Field type icons and badges +- Copy field path on click +- Relation indicators +- Search/filter collections +- Refresh button + +## UI Design + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ SCHEMA BROWSER [🔍 Search] [↻ Refresh] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ▼ users (12 fields) │ +│ ├─ 🔑 id (uuid) PRIMARY │ +│ ├─ 📧 email (email) REQUIRED UNIQUE │ +│ ├─ 📝 name (string) │ +│ ├─ 🖼️ avatar (image) │ +│ ├─ 🔗 role → roles (relation-one) │ +│ ├─ 📅 created_at (datetime) READ-ONLY │ +│ └─ 📅 updated_at (datetime) READ-ONLY │ +│ │ +│ ▶ posts (8 fields) │ +│ ▶ comments (6 fields) │ +│ ▶ categories (4 fields) │ +│ ▶ media (7 fields) │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Features + +### 1. Collapsible Tree Structure + +- Collections at root level +- Fields as children +- Expand/collapse with animation +- "Expand All" / "Collapse All" buttons + +### 2. Field Information Display + +Each field shows: + +- Name +- Type (with icon) +- Constraints (REQUIRED, UNIQUE, PRIMARY) +- Default value (if set) +- Relation target (for relation fields) + +### 3. Field Type Icons + +| Type | Icon | Color | +| -------------- | ---- | ------- | +| string | 📝 | default | +| text | 📄 | default | +| number/integer | 🔢 | blue | +| boolean | ✅ | green | +| datetime | 📅 | purple | +| email | 📧 | blue | +| url | 🔗 | blue | +| image/file | 🖼️ | orange | +| relation | 🔗 | cyan | +| json | {} | gray | +| uuid | 🔑 | yellow | + +### 4. Copy Field Path + +- Click on field name → copies to clipboard +- For nested paths: `collection.field` +- Toast notification: "Copied: author.name" + +### 5. Search/Filter + +- Filter collections by name +- Filter fields within collections +- Highlight matching text + +### 6. Refresh Schema + +- Manual refresh button +- Shows last synced timestamp +- Loading indicator during refresh + +## Implementation + +### File Structure + +``` +packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/ +├── SchemaViewer/ +│ ├── SchemaViewer.tsx # Main component +│ ├── SchemaViewer.module.scss +│ ├── CollectionTree.tsx # Collection list +│ ├── FieldRow.tsx # Single field display +│ ├── FieldTypeIcon.tsx # Type icon component +│ └── types.ts +``` + +### Component API + +```typescript +interface SchemaViewerProps { + backend: BackendConfig; + onRefresh: () => Promise; +} +``` + +### Integration Point + +The SchemaViewer should be embedded in the BackendServicesPanel, shown when a backend is selected or expanded. + +## Success Criteria + +- [ ] Tree view displays all collections from schema +- [ ] Fields expand/collapse per collection +- [ ] Field types shown with appropriate icons +- [ ] Constraints (REQUIRED, UNIQUE) visible +- [ ] Click to copy field path works +- [ ] Search filters collections and fields +- [ ] Refresh button fetches fresh schema +- [ ] Last synced timestamp displayed diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-004-edit-backend-dialog/README.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-004-edit-backend-dialog/README.md new file mode 100644 index 0000000..5c667d0 --- /dev/null +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-004-edit-backend-dialog/README.md @@ -0,0 +1,183 @@ +# TASK-004: Edit Backend Dialog + +**Task ID:** TASK-004 +**Phase:** 5 - Multi-Target Deployment (BYOB) +**Priority:** 🟡 Medium +**Difficulty:** 🟢 Easy +**Estimated Time:** 1-2 days +**Prerequisites:** TASK-001 (Backend Services Panel) +**Branch:** `feature/byob-edit-backend` + +## Objective + +Add the ability to edit existing backend configurations. Currently users can only add new backends or delete them - there's no way to update URL, credentials, or settings. + +## Background + +TASK-001 implemented Add Backend and Delete Backend functionality. Users frequently need to: + +- Update API tokens when they rotate +- Change URLs between environments +- Modify authentication settings +- Rename backends for clarity + +## User Story + +> As a Noodl user, I want to edit my backend configurations, so I can update credentials or settings without recreating the entire configuration. + +## Current State + +- Add Backend Dialog exists (TASK-001 ✅) +- Delete backend works +- No edit capability - must delete and recreate +- `updateBackend()` method exists in model but no UI + +## Desired State + +- "Edit" button on BackendCard +- Opens pre-filled dialog with current values +- Save updates existing config +- Preserve schema cache and connection history + +## UI Design + +The Edit dialog reuses AddBackendDialog with modifications: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Edit Backend [×] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ NAME │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Production Directus │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ TYPE │ +│ [Directus ▾] ← Disabled (can't change type) │ +│ │ +│ URL │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ https://api.myapp.com │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ AUTHENTICATION │ +│ Method: [Bearer Token ▾] │ +│ Token: [••••••••••••••••••••••••••• ] [👁] [Update] │ +│ │ +│ ⚠️ Changing URL or credentials will require re-syncing schema │ +│ │ +│ [Cancel] [Test] [Save Changes] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Implementation + +### Option A: Modify AddBackendDialog (Recommended) + +Add an `editMode` prop to the existing dialog: + +```typescript +interface AddBackendDialogProps { + isOpen: boolean; + onClose: () => void; + onSave: (backend: BackendConfig) => void; + editBackend?: BackendConfig; // If provided, dialog is in edit mode +} +``` + +### Option B: Create Separate EditBackendDialog + +Create a new component specifically for editing. More code duplication but cleaner separation. + +### Recommendation + +**Option A** - Reusing AddBackendDialog is simpler since 90% of the UI is identical. + +### Key Differences in Edit Mode + +| Aspect | Add Mode | Edit Mode | +| -------------- | ----------------- | ---------------------------- | +| Title | "Add Backend" | "Edit Backend" | +| Type selector | Enabled | Disabled (can't change type) | +| Submit button | "Add Backend" | "Save Changes" | +| Initial values | Empty/defaults | Populated from editBackend | +| On submit | `createBackend()` | `updateBackend()` | + +### Changes Required + +1. **AddBackendDialog.tsx** + + - Accept optional `editBackend` prop + - Initialize form with existing values + - Disable type selector in edit mode + - Change button text/behavior + +2. **BackendCard.tsx** + + - Add "Edit" button to actions + - Open dialog with `editBackend` prop + +3. **BackendServicesPanel.tsx** + - Handle edit dialog state + - Pass selected backend to dialog + +## Code Changes + +### BackendCard Actions + +```tsx +// Current + + +// After + + +``` + +### Dialog Mode Detection + +```tsx +function AddBackendDialog({ editBackend, onSave, ... }) { + const isEditMode = !!editBackend; + + const [name, setName] = useState(editBackend?.name || ''); + const [url, setUrl] = useState(editBackend?.url || ''); + // ... etc + + const handleSubmit = async () => { + if (isEditMode) { + await BackendServices.instance.updateBackend({ + id: editBackend.id, + name, + url, + auth + }); + } else { + await BackendServices.instance.createBackend({ ... }); + } + onSave(); + }; + + return ( + + {/* ... form fields ... */} + + ); +} +``` + +## Success Criteria + +- [ ] Edit button appears on BackendCard +- [ ] Clicking Edit opens dialog with pre-filled values +- [ ] Backend type selector is disabled in edit mode +- [ ] URL can be changed +- [ ] Credentials can be updated +- [ ] Save calls `updateBackend()` method +- [ ] Schema cache is preserved after edit +- [ ] Connection status updates after credential change diff --git a/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-005-local-docker-wizard/README.md b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-005-local-docker-wizard/README.md new file mode 100644 index 0000000..f9b967a --- /dev/null +++ b/dev-docs/tasks/phase-5-multi-target-deployment/01-byob-backend/TASK-005-local-docker-wizard/README.md @@ -0,0 +1,270 @@ +# TASK-005: Local Docker Backend Wizard + +**Task ID:** TASK-005 +**Phase:** 5 - Multi-Target Deployment (BYOB) +**Priority:** 🟢 Low +**Difficulty:** 🟡 Medium +**Estimated Time:** 3-5 days +**Prerequisites:** TASK-001, Docker installed on user's machine +**Branch:** `feature/byob-docker-wizard` + +## Objective + +Create a wizard that helps users spin up local backend instances (Directus, Pocketbase, Supabase) via Docker, and automatically configures the connection in Noodl. + +## Background + +Many users want to develop locally before deploying to production backends. Currently they must: + +1. Manually install Docker +2. Find and run the correct Docker commands +3. Wait for the backend to start +4. Manually configure the connection in Noodl + +This wizard automates steps 2-4. + +## User Story + +> As a Noodl developer, I want to quickly spin up a local backend for development, so I can start building without setting up cloud infrastructure. + +## Desired State + +- "Start Local Backend" button in Backend Services Panel +- Wizard to select backend type and configure ports +- One-click Docker container launch +- Automatic backend configuration after startup +- Status monitoring and stop/restart controls + +## UI Design + +### Step 1: Select Backend Type + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Start Local Backend [×] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Select a backend to run locally via Docker: │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ [Directus] │ │ [Pocketbase]│ │ [Supabase] │ │ +│ │ │ │ │ │ │ │ +│ │ Directus │ │ Pocketbase │ │ Supabase │ │ +│ │ ● Selected │ │ │ │ (complex) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ ℹ️ Requires Docker to be installed and running │ +│ │ +│ [Cancel] [Next →] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Step 2: Configure Options + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Configure Directus [×] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ CONTAINER NAME │ +│ [noodl-directus ] │ +│ │ +│ PORT │ +│ [8055 ] (default: 8055) │ +│ │ +│ ADMIN CREDENTIALS │ +│ Email: [admin@example.com ] │ +│ Password: [•••••••• ] │ +│ │ +│ DATABASE │ +│ ○ SQLite (simple, no extra setup) │ +│ ● PostgreSQL (recommended for production parity) │ +│ │ +│ DATA PERSISTENCE │ +│ ☑ Persist data between restarts (uses Docker volume) │ +│ │ +│ [← Back] [Start Backend] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Step 3: Starting / Progress + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Starting Directus... [×] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░] 45% │ +│ │ +│ ✅ Checking Docker... │ +│ ✅ Pulling directus/directus:latest... │ +│ ⏳ Starting container... │ +│ ○ Waiting for health check... │ +│ ○ Configuring connection... │ +│ │ +│ ───────────────────────────────────────────────────────────────────│ +│ $ docker run -d --name noodl-directus -p 8055:8055 ... │ +│ │ +│ [Cancel] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Step 4: Success + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Backend Ready! 🎉 [×] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ✅ Directus is running at http://localhost:8055 │ +│ │ +│ ADMIN PANEL │ +│ URL: http://localhost:8055/admin │ +│ Email: admin@example.com │ +│ Password: (as configured) │ +│ │ +│ CONNECTION │ +│ ✅ "Local Directus" backend added to your project │ +│ ✅ Schema synced (0 collections - add some in admin panel) │ +│ │ +│ [Open Admin Panel] [Done] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Docker Commands + +### Directus (SQLite) + +```bash +docker run -d \ + --name noodl-directus \ + -p 8055:8055 \ + -e KEY="noodl-directus-key" \ + -e SECRET="noodl-directus-secret" \ + -e ADMIN_EMAIL="admin@example.com" \ + -e ADMIN_PASSWORD="password123" \ + -e DB_CLIENT="sqlite3" \ + -e DB_FILENAME="/directus/database/data.db" \ + -v noodl-directus-data:/directus/database \ + -v noodl-directus-uploads:/directus/uploads \ + directus/directus:latest +``` + +### Pocketbase + +```bash +docker run -d \ + --name noodl-pocketbase \ + -p 8090:8090 \ + -v noodl-pocketbase-data:/pb_data \ + ghcr.io/muchobien/pocketbase:latest +``` + +### Supabase (docker-compose required) + +Supabase is more complex and requires multiple containers. Consider either: + +- Linking to official Supabase local dev docs +- Providing a bundled docker-compose.yml +- Skipping Supabase for initial implementation + +## Implementation + +### File Structure + +``` +packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/ +├── LocalDockerWizard/ +│ ├── LocalDockerWizard.tsx # Main wizard component +│ ├── LocalDockerWizard.module.scss +│ ├── steps/ +│ │ ├── SelectBackendStep.tsx +│ │ ├── ConfigureStep.tsx +│ │ ├── ProgressStep.tsx +│ │ └── SuccessStep.tsx +│ ├── docker/ +│ │ ├── dockerCommands.ts # Docker command builders +│ │ ├── directus.ts # Directus-specific config +│ │ └── pocketbase.ts # Pocketbase-specific config +│ └── types.ts +``` + +### Docker Detection + +```typescript +async function checkDockerAvailable(): Promise { + try { + const { stdout } = await exec('docker --version'); + return stdout.includes('Docker version'); + } catch { + return false; + } +} + +async function checkDockerRunning(): Promise { + try { + await exec('docker info'); + return true; + } catch { + return false; + } +} +``` + +### Container Management + +```typescript +interface DockerContainer { + name: string; + image: string; + ports: Record; + env: Record; + volumes: string[]; +} + +async function startContainer(config: DockerContainer): Promise { + const args = [ + 'run', + '-d', + '--name', + config.name, + ...Object.entries(config.ports).flatMap(([h, c]) => ['-p', `${h}:${c}`]), + ...Object.entries(config.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]), + ...config.volumes.flatMap((v) => ['-v', v]), + config.image + ]; + + await exec(`docker ${args.join(' ')}`); +} + +async function waitForHealthy(url: string, timeout = 60000): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + const res = await fetch(url); + if (res.ok) return true; + } catch {} + await new Promise((r) => setTimeout(r, 1000)); + } + return false; +} +``` + +## Success Criteria + +- [ ] Docker availability check works +- [ ] Directus container can be started +- [ ] Pocketbase container can be started +- [ ] Health check waits for backend to be ready +- [ ] Backend config auto-created after startup +- [ ] Container name/port configurable +- [ ] Data persists with Docker volumes +- [ ] Error handling for common issues (port in use, etc.) + +## Future Enhancements + +- Container status in Backend Services Panel +- Stop/Restart/Delete buttons +- View container logs +- Supabase support (via docker-compose) +- Auto-start containers when project opens diff --git a/packages/noodl-editor/src/editor/src/models/BackendServices/BackendServices.ts b/packages/noodl-editor/src/editor/src/models/BackendServices/BackendServices.ts new file mode 100644 index 0000000..37a3f41 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/models/BackendServices/BackendServices.ts @@ -0,0 +1,627 @@ +/** + * Backend Services Model + * + * Manages backend configurations for the BYOB (Bring Your Own Backend) system. + * Handles CRUD operations, connection testing, and schema introspection. + * + * @module BackendServices + * @since 1.2.0 + */ + +import { ProjectModel } from '@noodl-models/projectmodel'; +import { Model } from '@noodl-utils/model'; + +import { getPreset } from './presets'; +import { + BackendConfig, + BackendConfigSerialized, + BackendServicesEvent, + BackendServicesEvents, + BackendServicesMetadata, + CachedSchema, + ConnectionStatus, + ConnectionTestResult, + CreateBackendRequest, + IBackendServices, + UpdateBackendRequest +} from './types'; + +/** + * Generate a unique ID for a new backend + */ +function generateId(): string { + return `backend_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Serialize a BackendConfig for storage + */ +function serializeBackend(backend: BackendConfig): BackendConfigSerialized { + return { + ...backend, + schema: backend.schema + ? { + ...backend.schema, + fetchedAt: backend.schema.fetchedAt.toISOString() + } + : undefined, + lastSynced: backend.lastSynced?.toISOString(), + createdAt: backend.createdAt.toISOString(), + updatedAt: backend.updatedAt.toISOString() + }; +} + +/** + * Deserialize a BackendConfig from storage + */ +function deserializeBackend(data: BackendConfigSerialized): BackendConfig { + return { + ...data, + schema: data.schema + ? { + ...data.schema, + fetchedAt: new Date(data.schema.fetchedAt) + } + : undefined, + lastSynced: data.lastSynced ? new Date(data.lastSynced) : undefined, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt) + }; +} + +/** + * Backend Services singleton + * Manages all backend configurations for the current project + */ +export class BackendServices extends Model implements IBackendServices { + public static instance: BackendServices = new BackendServices(); + + private _isLoading = false; + private _backends: BackendConfig[] = []; + private _activeBackendId: string | undefined; + + // ============================================================================ + // Getters + // ============================================================================ + + get isLoading(): boolean { + return this._isLoading; + } + + get backends(): BackendConfig[] { + return this._backends; + } + + get activeBackendId(): string | undefined { + return this._activeBackendId; + } + + get activeBackend(): BackendConfig | undefined { + if (!this._activeBackendId) return undefined; + return this._backends.find((b) => b.id === this._activeBackendId); + } + + // ============================================================================ + // Initialization + // ============================================================================ + + /** + * Initialize and load backends from the current project + */ + async initialize(): Promise { + this._isLoading = true; + + try { + const project = ProjectModel.instance; + if (!project) { + console.warn('[BackendServices] No project loaded'); + return; + } + + const metadata = project.getMetaData('backendServices') as BackendServicesMetadata | undefined; + + if (metadata) { + this._backends = metadata.backends.map(deserializeBackend); + this._activeBackendId = metadata.activeBackendId; + } else { + this._backends = []; + this._activeBackendId = undefined; + } + + this.notifyListeners(BackendServicesEvent.BackendsChanged); + } finally { + this._isLoading = false; + } + } + + /** + * Reset the service (for project switching) + */ + reset(): void { + this._backends = []; + this._activeBackendId = undefined; + this._isLoading = false; + this.notifyListeners(BackendServicesEvent.BackendsChanged); + } + + // ============================================================================ + // Persistence + // ============================================================================ + + /** + * Save backends to project metadata + */ + private saveToProject(): void { + const project = ProjectModel.instance; + if (!project) { + console.warn('[BackendServices] Cannot save - no project loaded'); + return; + } + + const metadata: BackendServicesMetadata = { + backends: this._backends.map(serializeBackend), + activeBackendId: this._activeBackendId + }; + + project.setMetaData('backendServices', metadata); + project.notifyListeners('backendServicesChanged'); + } + + // ============================================================================ + // CRUD Operations + // ============================================================================ + + /** + * Get a backend by ID + */ + getBackend(id: string): BackendConfig | undefined { + return this._backends.find((b) => b.id === id); + } + + /** + * Create a new backend configuration + */ + async createBackend(request: CreateBackendRequest): Promise { + const preset = getPreset(request.type); + const now = new Date(); + + const backend: BackendConfig = { + id: generateId(), + name: request.name, + type: request.type, + url: request.url.replace(/\/$/, ''), // Remove trailing slash + auth: { + ...preset.defaultAuth, + ...request.auth + }, + endpoints: { + ...preset.endpoints, + ...request.endpoints + }, + responseConfig: { + ...preset.responseConfig, + ...request.responseConfig + }, + status: 'disconnected', + createdAt: now, + updatedAt: now + }; + + this._backends.push(backend); + + // If this is the first backend, make it active + if (this._backends.length === 1) { + this._activeBackendId = backend.id; + this.notifyListeners(BackendServicesEvent.ActiveBackendChanged, backend.id); + } + + this.saveToProject(); + this.notifyListeners(BackendServicesEvent.BackendsChanged); + + return backend; + } + + /** + * Update an existing backend configuration + */ + async updateBackend(request: UpdateBackendRequest): Promise { + const index = this._backends.findIndex((b) => b.id === request.id); + if (index === -1) { + throw new Error(`Backend not found: ${request.id}`); + } + + const existing = this._backends[index]; + const updated: BackendConfig = { + ...existing, + name: request.name ?? existing.name, + url: request.url ? request.url.replace(/\/$/, '') : existing.url, + auth: request.auth ? { ...existing.auth, ...request.auth } : existing.auth, + endpoints: request.endpoints ? { ...existing.endpoints, ...request.endpoints } : existing.endpoints, + responseConfig: request.responseConfig + ? { ...existing.responseConfig, ...request.responseConfig } + : existing.responseConfig, + updatedAt: new Date() + }; + + this._backends[index] = updated; + this.saveToProject(); + this.notifyListeners(BackendServicesEvent.BackendsChanged); + + return updated; + } + + /** + * Delete a backend configuration + */ + async deleteBackend(id: string): Promise { + const index = this._backends.findIndex((b) => b.id === id); + if (index === -1) { + return false; + } + + this._backends.splice(index, 1); + + // If we deleted the active backend, clear it or pick another + if (this._activeBackendId === id) { + this._activeBackendId = this._backends.length > 0 ? this._backends[0].id : undefined; + this.notifyListeners(BackendServicesEvent.ActiveBackendChanged, this._activeBackendId); + } + + this.saveToProject(); + this.notifyListeners(BackendServicesEvent.BackendsChanged); + + return true; + } + + /** + * Set the active backend + */ + setActiveBackend(id: string | undefined): void { + if (id && !this._backends.find((b) => b.id === id)) { + console.warn(`[BackendServices] Backend not found: ${id}`); + return; + } + + this._activeBackendId = id; + this.saveToProject(); + this.notifyListeners(BackendServicesEvent.ActiveBackendChanged, id); + } + + // ============================================================================ + // Connection Testing + // ============================================================================ + + /** + * Test connection to a backend + */ + async testConnection(backendOrConfig: BackendConfig | CreateBackendRequest): Promise { + const startTime = Date.now(); + + // Determine URL and auth + const url = backendOrConfig.url.replace(/\/$/, ''); + const auth = backendOrConfig.auth; + const preset = getPreset(backendOrConfig.type); + const endpoints = 'endpoints' in backendOrConfig ? backendOrConfig.endpoints : preset.endpoints; + + // Build headers - use adminToken for schema introspection (editor-only) + const headers: Record = { + 'Content-Type': 'application/json' + }; + + // Use adminToken for testing connection (schema access requires admin permissions) + const token = auth.adminToken; + + if (auth.method === 'bearer' && token) { + headers['Authorization'] = `Bearer ${token}`; + } else if (auth.method === 'api-key' && token) { + const headerName = auth.apiKeyHeader || 'X-API-Key'; + headers[headerName] = token; + } else if (auth.method === 'basic' && auth.username && auth.password) { + const encoded = btoa(`${auth.username}:${auth.password}`); + headers['Authorization'] = `Basic ${encoded}`; + } + + try { + // Try to fetch schema endpoint as a connectivity test (requires admin token) + const schemaUrl = `${url}${endpoints.schema}`; + const response = await fetch(schemaUrl, { + method: 'GET', + headers + }); + + const responseTime = Date.now() - startTime; + + if (response.ok) { + // Update status if this is an existing backend + if ('id' in backendOrConfig) { + this.updateBackendStatus(backendOrConfig.id, 'connected'); + } + + return { + success: true, + message: `Connected successfully (${responseTime}ms)`, + responseTime + }; + } else { + // Get error details for better error messages + const errorBody = await response.text().catch(() => ''); + const message = + response.status === 401 || response.status === 403 + ? `Authentication failed${errorBody ? `: ${errorBody.slice(0, 100)}` : ''}` + : `HTTP ${response.status}: ${response.statusText}`; + + if ('id' in backendOrConfig) { + this.updateBackendStatus(backendOrConfig.id, 'error', message); + } + + return { + success: false, + message, + responseTime + }; + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Connection failed'; + + if ('id' in backendOrConfig) { + this.updateBackendStatus(backendOrConfig.id, 'error', message); + } + + return { + success: false, + message: `Connection error: ${message}` + }; + } + } + + /** + * Update the connection status of a backend + */ + private updateBackendStatus(id: string, status: ConnectionStatus, errorMessage?: string): void { + const backend = this._backends.find((b) => b.id === id); + if (!backend) return; + + backend.status = status; + backend.lastError = errorMessage; + backend.updatedAt = new Date(); + + this.saveToProject(); + this.notifyListeners(BackendServicesEvent.StatusChanged, id, status); + } + + // ============================================================================ + // Schema Introspection + // ============================================================================ + + /** + * Fetch schema from a backend + */ + async fetchSchema(backendId: string): Promise { + const backend = this._backends.find((b) => b.id === backendId); + if (!backend) { + throw new Error(`Backend not found: ${backendId}`); + } + + // Build headers - use adminToken for schema introspection (editor-only) + const headers: Record = { + 'Content-Type': 'application/json' + }; + + // Use adminToken for schema fetching (requires admin/service permissions) + const token = backend.auth.adminToken; + + if (backend.auth.method === 'bearer' && token) { + headers['Authorization'] = `Bearer ${token}`; + } else if (backend.auth.method === 'api-key' && token) { + const headerName = backend.auth.apiKeyHeader || 'X-API-Key'; + headers[headerName] = token; + } + + try { + const schemaUrl = `${backend.url}${backend.endpoints.schema}`; + const response = await fetch(schemaUrl, { + method: 'GET', + headers + }); + + if (!response.ok) { + throw new Error(`Failed to fetch schema: HTTP ${response.status}`); + } + + const data = await response.json(); + + // Parse schema based on backend type + const schema = this.parseSchemaResponse(backend.type, data); + + // Update backend with schema + backend.schema = schema; + backend.lastSynced = new Date(); + backend.status = 'connected'; + backend.updatedAt = new Date(); + + this.saveToProject(); + this.notifyListeners(BackendServicesEvent.SchemaUpdated, backendId); + + return schema; + } catch (error) { + const message = error instanceof Error ? error.message : 'Schema fetch failed'; + this.updateBackendStatus(backendId, 'error', message); + throw error; + } + } + + /** + * Parse schema response based on backend type + */ + private parseSchemaResponse(type: string, data: unknown): CachedSchema { + const now = new Date(); + + // Default empty schema + const schema: CachedSchema = { + version: now.getTime().toString(), + fetchedAt: now, + collections: [] + }; + + if (type === 'directus') { + return this.parseDirectusSchema(data, schema); + } else if (type === 'supabase') { + return this.parseSupabaseSchema(data, schema); + } else if (type === 'pocketbase') { + return this.parsePocketbaseSchema(data, schema); + } + + // For custom, try to parse as a generic schema format + return this.parseGenericSchema(data, schema); + } + + /** + * Parse Directus schema from /fields endpoint + */ + private parseDirectusSchema(data: unknown, schema: CachedSchema): CachedSchema { + // Directus returns: { data: [{ collection, field, type, ... }] } + const fieldsData = (data as { data?: unknown[] })?.data || []; + + const collectionMap = new Map(); + + for (const field of fieldsData as Array<{ + collection: string; + field: string; + type: string; + schema?: { is_nullable?: boolean; is_primary_key?: boolean; is_unique?: boolean; default_value?: unknown }; + meta?: { options?: { choices?: Array<{ value: string }> }; special?: string[] }; + }>) { + if (!field.collection || field.collection.startsWith('directus_')) continue; + + if (!collectionMap.has(field.collection)) { + collectionMap.set(field.collection, { + name: field.collection, + displayName: field.collection, + fields: [], + primaryKey: 'id' + }); + } + + const collection = collectionMap.get(field.collection)!; + collection.fields.push({ + name: field.field, + displayName: field.field, + type: field.type, + nativeType: field.type, + required: field.schema?.is_nullable === false, + primaryKey: field.schema?.is_primary_key, + unique: field.schema?.is_unique, + defaultValue: field.schema?.default_value, + enumValues: field.meta?.options?.choices?.map((c) => c.value) + }); + + if (field.schema?.is_primary_key) { + collection.primaryKey = field.field; + } + } + + schema.collections = Array.from(collectionMap.values()); + return schema; + } + + /** + * Parse Supabase schema from OpenAPI spec + */ + private parseSupabaseSchema(data: unknown, schema: CachedSchema): CachedSchema { + // Supabase returns OpenAPI spec with definitions/paths + const openApi = data as { + definitions?: Record }>; + }; + + if (openApi.definitions) { + for (const [tableName, tableDef] of Object.entries(openApi.definitions)) { + const fields = + tableDef.properties && + Object.entries(tableDef.properties).map(([fieldName, fieldDef]) => ({ + name: fieldName, + displayName: fieldName, + type: fieldDef.type || 'unknown', + nativeType: fieldDef.format || fieldDef.type || 'unknown', + required: false + })); + + schema.collections.push({ + name: tableName, + displayName: tableName, + fields: fields || [], + primaryKey: 'id' + }); + } + } + + return schema; + } + + /** + * Parse Pocketbase schema from /api/collections + */ + private parsePocketbaseSchema(data: unknown, schema: CachedSchema): CachedSchema { + // Pocketbase returns: [{ id, name, schema: [{ name, type, required, ... }] }] + const collections = Array.isArray(data) ? data : (data as { items?: unknown[] })?.items || []; + + for (const col of collections as Array<{ + name: string; + schema?: Array<{ name: string; type: string; required?: boolean; options?: { values?: string[] } }>; + system?: boolean; + }>) { + if (col.system) continue; + + schema.collections.push({ + name: col.name, + displayName: col.name, + fields: + col.schema?.map((f) => ({ + name: f.name, + displayName: f.name, + type: f.type, + nativeType: f.type, + required: f.required || false, + enumValues: f.options?.values + })) || [], + primaryKey: 'id' + }); + } + + return schema; + } + + /** + * Parse generic schema format + */ + private parseGenericSchema(data: unknown, schema: CachedSchema): CachedSchema { + // Try to parse as array of collections or object with collections property + const collections = Array.isArray(data) + ? data + : (data as { collections?: unknown[]; tables?: unknown[] })?.collections || + (data as { tables?: unknown[] })?.tables || + []; + + for (const col of collections as Array<{ + name: string; + fields?: Array<{ name: string; type?: string }>; + }>) { + if (!col.name) continue; + + schema.collections.push({ + name: col.name, + displayName: col.name, + fields: + col.fields?.map((f) => ({ + name: f.name, + displayName: f.name, + type: f.type || 'unknown', + nativeType: f.type || 'unknown', + required: false + })) || [], + primaryKey: 'id' + }); + } + + return schema; + } +} diff --git a/packages/noodl-editor/src/editor/src/models/BackendServices/index.ts b/packages/noodl-editor/src/editor/src/models/BackendServices/index.ts new file mode 100644 index 0000000..605b1d1 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/models/BackendServices/index.ts @@ -0,0 +1,13 @@ +/** + * Backend Services Module + * + * BYOB (Bring Your Own Backend) system for connecting to external databases. + * Supports Directus, Supabase, Pocketbase, and custom REST APIs. + * + * @module BackendServices + * @since 1.2.0 + */ + +export { BackendServices } from './BackendServices'; +export * from './types'; +export * from './presets'; diff --git a/packages/noodl-editor/src/editor/src/models/BackendServices/presets.ts b/packages/noodl-editor/src/editor/src/models/BackendServices/presets.ts new file mode 100644 index 0000000..f527727 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/models/BackendServices/presets.ts @@ -0,0 +1,293 @@ +/** + * Backend Presets + * + * Pre-configured endpoint patterns for popular BaaS platforms. + * These can be customized by users after selection. + * + * @module BackendServices + * @since 1.2.0 + */ + +import { BackendAuthConfig, BackendEndpoints, BackendType, ResponseConfig } from './types'; + +/** + * Preset configuration for a backend type + */ +export interface BackendPreset { + type: BackendType; + displayName: string; + description: string; + icon: string; + docsUrl: string; + defaultAuth: BackendAuthConfig; + endpoints: BackendEndpoints; + responseConfig: ResponseConfig; + /** Placeholder URL to show in the input */ + urlPlaceholder: string; + /** Short help text shown inline */ + authHelpText: string; + /** Detailed help for admin API key (shown in popup) */ + adminKeyHelp: string; + /** Detailed help for public API key (shown in popup) */ + publicKeyHelp: string; +} + +/** + * Directus preset configuration + * @see https://docs.directus.io/reference/introduction.html + */ +export const directusPreset: BackendPreset = { + type: 'directus', + displayName: 'Directus', + description: 'Open source headless CMS with powerful REST API and admin panel', + icon: 'directus', + docsUrl: 'https://docs.directus.io/reference/introduction.html', + urlPlaceholder: 'https://your-directus-instance.com', + authHelpText: 'Configure tokens from Directus Admin > Settings > Access Tokens', + adminKeyHelp: `**Admin API Key Setup (Directus)** + +1. Go to **Settings → Access Tokens** in your Directus admin panel +2. Click **Create Token** and give it a descriptive name (e.g., "Noodl Schema Access") +3. Set the role to **Admin** or a custom role with: + - Read access to \`directus_fields\` (required for schema) + - Read access to \`directus_collections\` (required for table list) +4. Copy the generated token + +**Note:** This token is only used in the editor for fetching your database structure. It will NOT be published to your deployed app.`, + publicKeyHelp: `**Public API Key Setup (Directus)** + +1. Go to **Settings → Access Tokens** in your Directus admin panel +2. Create a new token with the **Public** role (or a custom limited role) +3. Configure which collections this role can access: + - Go to **Settings → Roles & Permissions → Public** + - Enable **read** permissions only on collections you want public + - Example: Allow reading "products" but not "users" +4. Copy the generated token + +**⚠️ Warning:** This token WILL be visible in your deployed app. Only grant access to data that should be publicly readable.`, + defaultAuth: { + method: 'bearer', + adminToken: '', + publicToken: '' + }, + endpoints: { + list: '/items/{table}', + get: '/items/{table}/{id}', + create: '/items/{table}', + update: '/items/{table}/{id}', + delete: '/items/{table}/{id}', + schema: '/fields' + }, + responseConfig: { + dataPath: 'data', + totalCountPath: 'meta.total_count', + paginationType: 'offset', + offsetParam: 'offset', + limitParam: 'limit' + } +}; + +/** + * Supabase preset configuration + * @see https://supabase.com/docs/guides/api + */ +export const supabasePreset: BackendPreset = { + type: 'supabase', + displayName: 'Supabase', + description: 'Open source Firebase alternative with PostgreSQL database', + icon: 'supabase', + docsUrl: 'https://supabase.com/docs/guides/api', + urlPlaceholder: 'https://your-project.supabase.co', + authHelpText: 'Find your keys in Supabase Dashboard > Settings > API', + adminKeyHelp: `**Admin API Key Setup (Supabase)** + +1. Go to your Supabase project dashboard +2. Navigate to **Settings → API** +3. Under "Project API Keys", find the **service_role** key +4. Click "Reveal" and copy the key + +**Important:** The service_role key bypasses Row Level Security (RLS). This gives full database access which is needed for schema introspection. + +**Note:** This key is only used in the editor. It will NOT be published to your deployed app.`, + publicKeyHelp: `**Public API Key Setup (Supabase)** + +1. Go to your Supabase project dashboard +2. Navigate to **Settings → API** +3. Under "Project API Keys", find the **anon** (public) key +4. Copy this key + +**Security with RLS:** +The anon key works with Row Level Security (RLS) policies. Make sure to: +- Enable RLS on tables that should have restricted access +- Create policies for what anonymous users can read +- Example policy: Allow reading products where \`is_published = true\` + +**⚠️ Warning:** This key WILL be visible in your deployed app. Use RLS policies to control data access.`, + defaultAuth: { + method: 'api-key', + apiKeyHeader: 'apikey', + adminToken: '', + publicToken: '' + }, + endpoints: { + list: '/rest/v1/{table}', + get: '/rest/v1/{table}?id=eq.{id}', + create: '/rest/v1/{table}', + update: '/rest/v1/{table}?id=eq.{id}', + delete: '/rest/v1/{table}?id=eq.{id}', + schema: '/rest/v1/' // Returns OpenAPI spec + }, + responseConfig: { + dataPath: '', // Supabase returns array directly + totalCountPath: '', // Needs special header: Prefer: count=exact + paginationType: 'offset', + offsetParam: 'offset', + limitParam: 'limit' + } +}; + +/** + * Pocketbase preset configuration + * @see https://pocketbase.io/docs/api-records/ + */ +export const pocketbasePreset: BackendPreset = { + type: 'pocketbase', + displayName: 'Pocketbase', + description: 'Lightweight backend in a single binary with SQLite', + icon: 'pocketbase', + docsUrl: 'https://pocketbase.io/docs/api-records/', + urlPlaceholder: 'http://localhost:8090', + authHelpText: 'Use admin credentials for schema access', + adminKeyHelp: `**Admin API Key Setup (Pocketbase)** + +1. Start your Pocketbase instance and go to the admin UI (typically /_/) +2. Navigate to **Settings → Admins** +3. You can either: + - Use your admin email/password for authentication, OR + - Create an admin API token if your version supports it + +**Alternative - Using Admin Auth:** +For Pocketbase, you may need to use admin email + password authentication instead of a static token. The editor will handle this appropriately. + +**Note:** Admin access is only used in the editor for fetching collections and their schemas. It will NOT be published.`, + publicKeyHelp: `**Public API Key Setup (Pocketbase)** + +Pocketbase uses **API Rules** instead of API keys for public access: + +1. Go to your Pocketbase admin UI +2. Navigate to **Collections** and select a collection +3. Click **API Rules** tab +4. Configure rules for anonymous access: + - **List rule:** Leave empty for public read, or add conditions + - **View rule:** Leave empty for public single-item read + - Example: \`is_published = true\` to only show published items + +**No Public Token Needed:** +If you've configured API rules to allow anonymous access, you can leave this field empty. Pocketbase will allow access based on your rules. + +**⚠️ Note:** API rules determine what unauthenticated users can access in your deployed app.`, + defaultAuth: { + method: 'bearer', + adminToken: '', + publicToken: '' + }, + endpoints: { + list: '/api/collections/{table}/records', + get: '/api/collections/{table}/records/{id}', + create: '/api/collections/{table}/records', + update: '/api/collections/{table}/records/{id}', + delete: '/api/collections/{table}/records/{id}', + schema: '/api/collections' + }, + responseConfig: { + dataPath: 'items', + totalCountPath: 'totalItems', + paginationType: 'page', + offsetParam: 'page', + limitParam: 'perPage' + } +}; + +/** + * Custom REST API preset (starting point for manual configuration) + */ +export const customPreset: BackendPreset = { + type: 'custom', + displayName: 'Custom REST API', + description: 'Configure any REST API with custom endpoints', + icon: 'api', + docsUrl: '', + urlPlaceholder: 'https://api.example.com', + authHelpText: 'Configure authentication based on your API requirements', + adminKeyHelp: `**Admin API Key Setup (Custom REST)** + +For schema introspection, you need an API key or token that has permission to: +- Read your API's schema endpoint +- Access metadata about tables/collections and their fields + +This will depend on your specific API implementation. Common patterns: +- Bearer token with admin scope +- API key with read-all permissions +- Service account credentials + +**Note:** This key is only used in the editor for development. It will NOT be published to your deployed app.`, + publicKeyHelp: `**Public API Key Setup (Custom REST)** + +If your API supports unauthenticated or limited-access requests, configure: +- A public API key with restricted permissions +- A read-only token for specific resources +- Or leave empty if your API handles public access differently + +**⚠️ Warning:** If you provide a public key, it WILL be included in your deployed app and visible to end users. Only use keys that are safe to expose publicly.`, + defaultAuth: { + method: 'bearer', + adminToken: '', + publicToken: '' + }, + endpoints: { + list: '/{table}', + get: '/{table}/{id}', + create: '/{table}', + update: '/{table}/{id}', + delete: '/{table}/{id}', + schema: '/schema' + }, + responseConfig: { + dataPath: 'data', + totalCountPath: 'total', + paginationType: 'offset', + offsetParam: 'offset', + limitParam: 'limit' + } +}; + +/** + * All available presets + */ +export const backendPresets: Record = { + directus: directusPreset, + supabase: supabasePreset, + pocketbase: pocketbasePreset, + custom: customPreset +}; + +/** + * Get a preset by type + */ +export function getPreset(type: BackendType): BackendPreset { + return backendPresets[type] || customPreset; +} + +/** + * Get all presets as an array (useful for UI) + */ +export function getAllPresets(): BackendPreset[] { + return Object.values(backendPresets); +} + +/** + * Get presets excluding custom (for preset selection UI) + */ +export function getPresetOptions(): BackendPreset[] { + return [directusPreset, supabasePreset, pocketbasePreset]; +} diff --git a/packages/noodl-editor/src/editor/src/models/BackendServices/types.ts b/packages/noodl-editor/src/editor/src/models/BackendServices/types.ts new file mode 100644 index 0000000..b7e1445 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/models/BackendServices/types.ts @@ -0,0 +1,313 @@ +/** + * Backend Services Types + * + * Type definitions for the BYOB (Bring Your Own Backend) system. + * Supports Directus, Supabase, Pocketbase, and custom REST APIs. + * + * @module BackendServices + * @since 1.2.0 + */ + +import { IModel } from '@noodl-utils/model'; + +// ============================================================================ +// Backend Configuration Types +// ============================================================================ + +/** + * Supported backend types with preset configurations + */ +export type BackendType = 'directus' | 'supabase' | 'pocketbase' | 'custom'; + +/** + * Authentication methods supported by backends + */ +export type AuthMethod = 'none' | 'bearer' | 'api-key' | 'basic'; + +/** + * Connection status of a backend + */ +export type ConnectionStatus = 'connected' | 'disconnected' | 'error' | 'checking'; + +/** + * Pagination styles supported by different backends + */ +export type PaginationType = 'offset' | 'cursor' | 'page'; + +/** + * Authentication configuration for a backend + */ +export interface BackendAuthConfig { + /** Authentication method */ + method: AuthMethod; + /** + * Admin token for schema introspection (editor-only). + * This token is NOT published to the deployed app. + * Should have permissions to read schema/fields. + */ + adminToken?: string; + /** + * Public token for unauthenticated user access. + * This token WILL be published and visible in the deployed app. + * Should only have limited permissions for public data access. + */ + publicToken?: string; + /** Custom header name for API key authentication (e.g., "X-API-Key", "apikey") */ + apiKeyHeader?: string; + /** Username for basic auth */ + username?: string; + /** Password for basic auth */ + password?: string; +} + +/** + * Endpoint configuration for CRUD operations + * Supports {table} and {id} placeholders + */ +export interface BackendEndpoints { + /** List records: GET /items/{table} */ + list: string; + /** Get single record: GET /items/{table}/{id} */ + get: string; + /** Create record: POST /items/{table} */ + create: string; + /** Update record: PATCH /items/{table}/{id} */ + update: string; + /** Delete record: DELETE /items/{table}/{id} */ + delete: string; + /** Schema introspection endpoint */ + schema: string; +} + +/** + * Response parsing configuration + */ +export interface ResponseConfig { + /** Path to data in response (e.g., "data" for Directus, "items" for custom) */ + dataPath?: string; + /** Path to total count in response */ + totalCountPath?: string; + /** Pagination style */ + paginationType: PaginationType; + /** Field name for pagination offset/skip */ + offsetParam?: string; + /** Field name for pagination limit */ + limitParam?: string; +} + +/** + * Schema field definition + */ +export interface SchemaField { + name: string; + displayName?: string; + type: string; + nativeType: string; + required: boolean; + unique?: boolean; + primaryKey?: boolean; + defaultValue?: unknown; + enumValues?: string[]; + relationTarget?: string; + relationType?: 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many'; +} + +/** + * Schema collection/table definition + */ +export interface SchemaCollection { + name: string; + displayName?: string; + description?: string; + fields: SchemaField[]; + primaryKey: string; + isSystem?: boolean; +} + +/** + * Cached schema from backend introspection + */ +export interface CachedSchema { + version: string; + fetchedAt: Date; + collections: SchemaCollection[]; +} + +/** + * Full backend configuration + */ +export interface BackendConfig { + /** Unique identifier */ + id: string; + /** User-friendly name */ + name: string; + /** Backend type (preset or custom) */ + type: BackendType; + /** Base URL of the backend */ + url: string; + /** Authentication configuration */ + auth: BackendAuthConfig; + /** Endpoint patterns */ + endpoints: BackendEndpoints; + /** Response parsing configuration */ + responseConfig: ResponseConfig; + /** Cached schema from introspection */ + schema?: CachedSchema; + /** Connection status */ + status: ConnectionStatus; + /** Last successful schema sync */ + lastSynced?: Date; + /** Last error message */ + lastError?: string; + /** When this backend was created */ + createdAt: Date; + /** When this backend was last updated */ + updatedAt: Date; +} + +/** + * Serialized backend config for storage in project metadata + */ +export interface BackendConfigSerialized { + id: string; + name: string; + type: BackendType; + url: string; + auth: BackendAuthConfig; + endpoints: BackendEndpoints; + responseConfig: ResponseConfig; + schema?: { + version: string; + fetchedAt: string; + collections: SchemaCollection[]; + }; + status: ConnectionStatus; + lastSynced?: string; + lastError?: string; + createdAt: string; + updatedAt: string; +} + +/** + * Project metadata for backend services + */ +export interface BackendServicesMetadata { + /** List of configured backends */ + backends: BackendConfigSerialized[]; + /** ID of the active backend (used by data nodes by default) */ + activeBackendId?: string; +} + +// ============================================================================ +// Request/Response Types +// ============================================================================ + +/** + * Request to create a new backend + */ +export interface CreateBackendRequest { + name: string; + type: BackendType; + url: string; + auth: BackendAuthConfig; + endpoints?: Partial; + responseConfig?: Partial; +} + +/** + * Request to update an existing backend + */ +export interface UpdateBackendRequest { + id: string; + name?: string; + url?: string; + auth?: Partial; + endpoints?: Partial; + responseConfig?: Partial; +} + +/** + * Result of a connection test + */ +export interface ConnectionTestResult { + success: boolean; + message: string; + responseTime?: number; + serverVersion?: string; +} + +// ============================================================================ +// Event Types +// ============================================================================ + +/** + * Events emitted by BackendServices + */ +export enum BackendServicesEvent { + /** A backend was added, updated, or deleted */ + BackendsChanged = 'BackendsChanged', + /** The active backend changed */ + ActiveBackendChanged = 'ActiveBackendChanged', + /** Schema was fetched for a backend */ + SchemaUpdated = 'SchemaUpdated', + /** Connection status changed for a backend */ + StatusChanged = 'StatusChanged' +} + +/** + * Event handler signatures + */ +export type BackendServicesEvents = { + [BackendServicesEvent.BackendsChanged]: () => void; + [BackendServicesEvent.ActiveBackendChanged]: (backendId: string | undefined) => void; + [BackendServicesEvent.SchemaUpdated]: (backendId: string) => void; + [BackendServicesEvent.StatusChanged]: (backendId: string, status: ConnectionStatus) => void; +}; + +// ============================================================================ +// Service Interface +// ============================================================================ + +/** + * Backend Services interface + */ +export interface IBackendServices extends IModel { + /** Whether the service is currently loading */ + readonly isLoading: boolean; + + /** All configured backends */ + readonly backends: BackendConfig[]; + + /** The currently active backend (if any) */ + readonly activeBackend: BackendConfig | undefined; + + /** ID of the active backend */ + readonly activeBackendId: string | undefined; + + /** Initialize and load backends from project */ + initialize(): Promise; + + /** Get a backend by ID */ + getBackend(id: string): BackendConfig | undefined; + + /** Create a new backend */ + createBackend(request: CreateBackendRequest): Promise; + + /** Update an existing backend */ + updateBackend(request: UpdateBackendRequest): Promise; + + /** Delete a backend */ + deleteBackend(id: string): Promise; + + /** Set the active backend */ + setActiveBackend(id: string | undefined): void; + + /** Test connection to a backend */ + testConnection(backendOrConfig: BackendConfig | CreateBackendRequest): Promise; + + /** Fetch schema from a backend */ + fetchSchema(backendId: string): Promise; + + /** Reset the service (for project switching) */ + reset(): void; +} diff --git a/packages/noodl-editor/src/editor/src/router.setup.ts b/packages/noodl-editor/src/editor/src/router.setup.ts index 4297783..653426f 100644 --- a/packages/noodl-editor/src/editor/src/router.setup.ts +++ b/packages/noodl-editor/src/editor/src/router.setup.ts @@ -8,6 +8,7 @@ import { IconName } from '@noodl-core-ui/components/common/Icon'; import config from '../../shared/config/config'; import { ComponentDiffDocumentProvider } from './views/documents/ComponentDiffDocument'; import { EditorDocumentProvider } from './views/documents/EditorDocument'; +import { BackendServicesPanel } from './views/panels/BackendServicesPanel/BackendServicesPanel'; import { CloudFunctionsPanel } from './views/panels/CloudFunctionsPanel/CloudFunctionsPanel'; import { CloudServicePanel } from './views/panels/CloudServicePanel/CloudServicePanel'; import { ComponentPortsComponent } from './views/panels/componentports'; @@ -101,10 +102,19 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) { panel: CloudFunctionsPanel }); + SidebarModel.instance.register({ + id: 'backend-services', + name: 'Backend Services', + isDisabled: isLesson === true, + order: 8, + icon: IconName.RestApi, + panel: BackendServicesPanel + }); + SidebarModel.instance.register({ id: 'settings', name: 'Project settings', - order: 8, + order: 9, icon: IconName.Setting, panel: ProjectSettingsPanel }); diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/AddBackendDialog/AddBackendDialog.module.scss b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/AddBackendDialog/AddBackendDialog.module.scss new file mode 100644 index 0000000..7b85d76 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/AddBackendDialog/AddBackendDialog.module.scss @@ -0,0 +1,151 @@ +/** + * Add Backend Dialog Styles + * + * @module BackendServicesPanel + */ + +.PresetGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 16px; +} + +.PresetCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px; + background-color: var(--theme-color-bg-3); + border: 1px solid var(--theme-color-border-default); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; + + &:hover { + background-color: var(--theme-color-bg-2); + border-color: var(--theme-color-border-highlight); + } + + &.Selected { + border-color: var(--theme-color-primary); + background-color: var(--theme-color-primary-bg); + } +} + +.PresetIcon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--theme-color-bg-1); + border-radius: 8px; + font-size: 16px; + font-weight: 600; + color: var(--theme-color-fg-highlight); + margin-bottom: 4px; +} + +.TestResult { + padding: 8px 12px; + border-radius: 4px; + border: 1px solid; +} + +// Field note with icon +.FieldNote { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding: 4px 0; +} + +.LockIcon, +.WarningIcon { + font-size: 12px; + line-height: 1; +} + +// Help Popup Overlay +.HelpPopupOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +// Help Popup Content +.HelpPopup { + background-color: var(--theme-color-bg-2); + border: 1px solid var(--theme-color-border-default); + border-radius: 8px; + max-width: 480px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.HelpPopupHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--theme-color-border-default); + background-color: var(--theme-color-bg-3); +} + +.HelpPopupContent { + padding: 16px; + overflow-y: auto; + line-height: 1.6; + + p { + margin: 0 0 12px 0; + color: var(--theme-color-fg-default); + font-size: 13px; + + &:last-child { + margin-bottom: 0; + } + + &:empty { + margin-bottom: 8px; + } + } + + strong { + color: var(--theme-color-fg-highlight); + font-weight: 600; + } +} + +.HelpHeading { + font-size: 14px !important; + margin-top: 16px !important; + margin-bottom: 8px !important; + + &:first-child { + margin-top: 0 !important; + } +} + +.InlineCode { + background-color: var(--theme-color-bg-1); + color: var(--theme-color-primary); + padding: 2px 6px; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 12px; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/AddBackendDialog/AddBackendDialog.tsx b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/AddBackendDialog/AddBackendDialog.tsx new file mode 100644 index 0000000..103ff00 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/AddBackendDialog/AddBackendDialog.tsx @@ -0,0 +1,341 @@ +/** + * Add Backend Dialog + * + * Modal dialog for adding new backend configurations. + * Supports preset selection (Directus, Supabase, Pocketbase) and custom REST APIs. + * + * @module BackendServicesPanel + * @since 1.2.0 + */ + +import React, { useState, useCallback } from 'react'; + +import { BackendServices, BackendType, CreateBackendRequest } from '@noodl-models/BackendServices'; +import { getPreset, getPresetOptions, BackendPreset } from '@noodl-models/BackendServices/presets'; + +import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon'; +import { IconButton } from '@noodl-core-ui/components/inputs/IconButton'; +import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton'; +import { TextInput } from '@noodl-core-ui/components/inputs/TextInput'; +import { Box } from '@noodl-core-ui/components/layout/Box'; +import { Modal } from '@noodl-core-ui/components/layout/Modal'; +import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack'; +import { Text, TextType } from '@noodl-core-ui/components/typography/Text'; + +import { ToastLayer } from '../../../ToastLayer/ToastLayer'; +import css from './AddBackendDialog.module.scss'; + +export interface AddBackendDialogProps { + isVisible: boolean; + onClose: () => void; + onCreated: () => void; +} + +interface HelpPopupProps { + content: string; + onClose: () => void; +} + +function HelpPopup({ content, onClose }: HelpPopupProps) { + return ( +
+
e.stopPropagation()}> +
+ Setup Instructions + +
+
+ {content.split('\n').map((line, i) => { + // Handle bold text with **text** + const parts = line.split(/(\*\*[^*]+\*\*)/g); + return ( +

+ {parts.map((part, j) => { + if (part.startsWith('**') && part.endsWith('**')) { + return {part.slice(2, -2)}; + } + // Handle inline code with `text` + const codeParts = part.split(/(`[^`]+`)/g); + return codeParts.map((codePart, k) => { + if (codePart.startsWith('`') && codePart.endsWith('`')) { + return ( + + {codePart.slice(1, -1)} + + ); + } + return codePart; + }); + })} +

+ ); + })} +
+
+
+ ); +} + +export function AddBackendDialog({ isVisible, onClose, onCreated }: AddBackendDialogProps) { + const [selectedType, setSelectedType] = useState('directus'); + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [adminToken, setAdminToken] = useState(''); + const [publicToken, setPublicToken] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [showAdminHelp, setShowAdminHelp] = useState(false); + const [showPublicHelp, setShowPublicHelp] = useState(false); + + const presetOptions = getPresetOptions(); + const selectedPreset = getPreset(selectedType); + + // Reset form when dialog opens + React.useEffect(() => { + if (isVisible) { + setSelectedType('directus'); + setName(''); + setUrl(''); + setAdminToken(''); + setPublicToken(''); + setTestResult(null); + setShowAdminHelp(false); + setShowPublicHelp(false); + } + }, [isVisible]); + + const handleSelectPreset = useCallback((preset: BackendPreset) => { + setSelectedType(preset.type); + setTestResult(null); + }, []); + + const handleTestConnection = useCallback(async () => { + if (!url) { + setTestResult({ success: false, message: 'Please enter a URL' }); + return; + } + if (!adminToken) { + setTestResult({ success: false, message: 'Please enter an Admin API Key to test connection' }); + return; + } + + setIsLoading(true); + setTestResult(null); + + try { + const request: CreateBackendRequest = { + name: name || 'Test', + type: selectedType, + url, + auth: { + method: selectedPreset.defaultAuth.method, + adminToken, + publicToken, + apiKeyHeader: selectedPreset.defaultAuth.apiKeyHeader + } + }; + + const result = await BackendServices.instance.testConnection(request); + setTestResult(result); + } catch (error) { + setTestResult({ + success: false, + message: error instanceof Error ? error.message : 'Connection test failed' + }); + } finally { + setIsLoading(false); + } + }, [url, name, selectedType, selectedPreset, adminToken, publicToken]); + + const handleCreate = useCallback(async () => { + if (!name || !url) { + ToastLayer.showError('Please fill in all required fields'); + return; + } + if (!adminToken) { + ToastLayer.showError('Admin API Key is required for schema introspection'); + return; + } + + setIsLoading(true); + + try { + const request: CreateBackendRequest = { + name, + type: selectedType, + url, + auth: { + method: selectedPreset.defaultAuth.method, + adminToken, + publicToken, + apiKeyHeader: selectedPreset.defaultAuth.apiKeyHeader + } + }; + + await BackendServices.instance.createBackend(request); + ToastLayer.showSuccess(`Backend "${name}" created successfully`); + onCreated(); + } catch (error) { + ToastLayer.showError(error instanceof Error ? error.message : 'Failed to create backend'); + } finally { + setIsLoading(false); + } + }, [name, url, selectedType, selectedPreset, adminToken, publicToken, onCreated]); + + const isFormValid = name.trim().length > 0 && url.trim().length > 0 && adminToken.trim().length > 0; + + return ( + + + {/* Preset Selection */} + + + Select a backend type + +
+ {presetOptions.map((preset) => ( + + ))} + +
+
+ + {/* Name */} + + setName(e.target.value)} + placeholder={`My ${selectedPreset.displayName} Backend`} + hasBottomSpacing + /> + + + {/* URL */} + + setUrl(e.target.value)} + placeholder={selectedPreset.urlPlaceholder} + hasBottomSpacing + /> + + + {/* Admin API Key (Required) */} + + + + Admin API Key (Required) + + setShowAdminHelp(true)} + testId="admin-key-help" + /> + + setAdminToken(e.target.value)} + type="password" + placeholder="Enter your admin/service API key" + /> +
+ 🔒 + + Editor only — not published to deployed app + +
+
+ + {/* Public API Key (Optional) */} + + + + Public API Key (Optional) + + setShowPublicHelp(true)} + testId="public-key-help" + /> + + setPublicToken(e.target.value)} + type="password" + placeholder="Enter your public/anon API key" + /> +
+ ⚠️ + + Will be visible in deployed app — scope for public access only + +
+
+ + {/* Test Result */} + {testResult && ( + +
+ + {testResult.success ? '✓ ' : '✗ '} + {testResult.message} + +
+
+ )} + + {/* Actions */} + + + + + +
+ + {/* Help Popups */} + {showAdminHelp && setShowAdminHelp(false)} />} + {showPublicHelp && setShowPublicHelp(false)} />} +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendCard/BackendCard.module.scss b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendCard/BackendCard.module.scss new file mode 100644 index 0000000..70b320c --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendCard/BackendCard.module.scss @@ -0,0 +1,59 @@ +.Root { + background-color: var(--theme-color-bg-3); + border: 1px solid var(--theme-color-border-default); + border-radius: 6px; + padding: 12px; + margin-bottom: 8px; + transition: border-color 0.2s ease; + + &:hover { + border-color: var(--theme-color-border-hover); + } + + &.Active { + border-color: var(--theme-color-primary); + background-color: var(--theme-color-bg-2); + } +} + +.Header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; +} + +.TypeIcon { + width: 32px; + height: 32px; + border-radius: 6px; + background-color: var(--theme-color-primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ActiveBadge { + background-color: var(--theme-color-primary); + color: var(--theme-color-fg-on-primary); + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.Status { + margin-bottom: 8px; + padding-top: 8px; + border-top: 1px solid var(--theme-color-border-default); +} + +.SchemaInfo { + margin-bottom: 8px; +} + +.Actions { + padding-top: 8px; + border-top: 1px solid var(--theme-color-border-default); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendCard/BackendCard.tsx b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendCard/BackendCard.tsx new file mode 100644 index 0000000..06fd789 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendCard/BackendCard.tsx @@ -0,0 +1,146 @@ +/** + * Backend Card Component + * + * Displays a single backend configuration with status and actions. + * + * @module BackendServicesPanel + * @since 1.2.0 + */ + +import React from 'react'; + +import { BackendConfig, ConnectionStatus } from '@noodl-models/BackendServices'; +import { getPreset } from '@noodl-models/BackendServices/presets'; + +import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon'; +import { IconButton } from '@noodl-core-ui/components/inputs/IconButton'; +import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton'; +import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack'; +import { Text, TextType } from '@noodl-core-ui/components/typography/Text'; + +import css from './BackendCard.module.scss'; + +export interface BackendCardProps { + backend: BackendConfig; + isActive: boolean; + onSetActive: () => void; + onDelete: () => void; + onTestConnection: () => void; + onFetchSchema: () => void; +} + +/** + * Get status icon and color based on connection status + */ +function getStatusDisplay(status: ConnectionStatus): { icon: IconName; color: string; text: string } { + switch (status) { + case 'connected': + return { icon: IconName.Check, color: 'var(--theme-color-success)', text: 'Connected' }; + case 'disconnected': + return { icon: IconName.CircleOpen, color: 'var(--theme-color-fg-default-shy)', text: 'Disconnected' }; + case 'error': + return { icon: IconName.WarningTriangle, color: 'var(--theme-color-danger)', text: 'Error' }; + case 'checking': + return { icon: IconName.Refresh, color: 'var(--theme-color-primary)', text: 'Checking...' }; + default: + return { icon: IconName.CircleOpen, color: 'var(--theme-color-fg-default-shy)', text: 'Unknown' }; + } +} + +export function BackendCard({ + backend, + isActive, + onSetActive, + onDelete, + onTestConnection, + onFetchSchema +}: BackendCardProps) { + const preset = getPreset(backend.type); + const statusDisplay = getStatusDisplay(backend.status); + + return ( +
+ {/* Header */} +
+ +
+ {preset.displayName.charAt(0).toUpperCase()} +
+ + {backend.name} + + {preset.displayName} • {backend.url} + + +
+ + {isActive && ( +
+ + ACTIVE + +
+ )} +
+ + {/* Status */} +
+ + + {statusDisplay.text} + {backend.lastSynced && ( + + • Last sync: {new Date(backend.lastSynced).toLocaleTimeString()} + + )} + + {backend.lastError && ( + + {backend.lastError} + + )} +
+ + {/* Schema Info */} + {backend.schema && backend.schema.collections.length > 0 && ( +
+ + {backend.schema.collections.length} collection{backend.schema.collections.length !== 1 ? 's' : ''} + +
+ )} + + {/* Actions */} +
+ + {!isActive && ( + + )} + + + + +
+
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendServicesPanel.tsx b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendServicesPanel.tsx new file mode 100644 index 0000000..b4ff96d --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/BackendServicesPanel.tsx @@ -0,0 +1,172 @@ +/** + * Backend Services Panel + * + * Main panel for managing backend database connections (BYOB). + * Similar in structure to CloudServicePanel. + * + * @module BackendServicesPanel + * @since 1.2.0 + */ + +import { useEventListener } from '@noodl-hooks/useEventListener'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { BackendServices, BackendServicesEvent } from '@noodl-models/BackendServices'; + +import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator'; +import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon'; +import { IconButton } from '@noodl-core-ui/components/inputs/IconButton'; +import { Box } from '@noodl-core-ui/components/layout/Box'; +import { Container } from '@noodl-core-ui/components/layout/Container'; +import { VStack } from '@noodl-core-ui/components/layout/Stack'; +import { useConfirmationDialog } from '@noodl-core-ui/components/popups/ConfirmationDialog/ConfirmationDialog.hooks'; +import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel'; +import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section'; +import { Text, TextType } from '@noodl-core-ui/components/typography/Text'; + +import { AddBackendDialog } from './AddBackendDialog/AddBackendDialog'; +import { BackendCard } from './BackendCard/BackendCard'; + +export function BackendServicesPanel() { + const [backends, setBackends] = useState(BackendServices.instance.backends); + const [activeBackendId, setActiveBackendId] = useState(BackendServices.instance.activeBackendId); + const [isLoading, setIsLoading] = useState(false); + const [isAddDialogVisible, setIsAddDialogVisible] = useState(false); + const [hasActivity, setHasActivity] = useState(false); + + // Initialize backends on mount + useEffect(() => { + setIsLoading(true); + BackendServices.instance.initialize().finally(() => { + setBackends(BackendServices.instance.backends); + setActiveBackendId(BackendServices.instance.activeBackendId); + setIsLoading(false); + }); + }, []); + + // Listen for backend changes + useEventListener(BackendServices.instance, BackendServicesEvent.BackendsChanged, () => { + setBackends([...BackendServices.instance.backends]); + }); + + useEventListener(BackendServices.instance, BackendServicesEvent.ActiveBackendChanged, (id: string | null) => { + setActiveBackendId(id); + }); + + // Delete confirmation dialog + const [DeleteDialog, confirmDelete] = useConfirmationDialog({ + title: 'Delete Backend', + message: 'Are you sure you want to delete this backend configuration? This cannot be undone.', + isDangerousAction: true + }); + + // Handle delete backend + const handleDeleteBackend = useCallback( + async (id: string) => { + try { + await confirmDelete(); + // If we get here, user confirmed + setHasActivity(true); + try { + await BackendServices.instance.deleteBackend(id); + } finally { + setHasActivity(false); + } + } catch { + // User cancelled - do nothing + } + }, + [confirmDelete] + ); + + // Handle set active backend + const handleSetActive = useCallback((id: string) => { + BackendServices.instance.setActiveBackend(id); + }, []); + + // Handle test connection + const handleTestConnection = useCallback(async (id: string) => { + setHasActivity(true); + try { + const backend = BackendServices.instance.getBackend(id); + if (backend) { + await BackendServices.instance.testConnection(backend); + setBackends([...BackendServices.instance.backends]); + } + } finally { + setHasActivity(false); + } + }, []); + + // Handle fetch schema + const handleFetchSchema = useCallback(async (id: string) => { + setHasActivity(true); + try { + await BackendServices.instance.fetchSchema(id); + setBackends([...BackendServices.instance.backends]); + } finally { + setHasActivity(false); + } + }, []); + + return ( + + + + {isLoading ? ( + + + + ) : ( + <> +
setIsAddDialogVisible(true)} + testId="add-backend-button" + /> + } + > + {backends.length > 0 ? ( + + {backends.map((backend) => ( + handleSetActive(backend.id)} + onDelete={() => handleDeleteBackend(backend.id)} + onTestConnection={() => handleTestConnection(backend.id)} + onFetchSchema={() => handleFetchSchema(backend.id)} + /> + ))} + + ) : ( + + No backends configured + + + Click the + button to add a Directus, Supabase, or custom REST backend. + + + + )} +
+ + )} + + setIsAddDialogVisible(false)} + onCreated={() => { + setIsAddDialogVisible(false); + setBackends([...BackendServices.instance.backends]); + }} + /> +
+ ); +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ByobFilterType.ts b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ByobFilterType.ts new file mode 100644 index 0000000..e7bc298 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ByobFilterType.ts @@ -0,0 +1,99 @@ +/** + * BYOB Filter Type + * + * Property editor type for the BYOB Visual Filter Builder. + * Renders a button in the property panel that opens a modal with the full builder. + */ + +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; + +import { FilterBuilderButton } from '../components/ByobFilterBuilder'; +import { fromDirectusFilter } from '../components/ByobFilterBuilder/converter'; +import { FilterGroup, SchemaCollection } from '../components/ByobFilterBuilder/types'; +import { TypeView } from '../TypeView'; +import { getEditType } from '../utils'; + +export class ByobFilterType extends TypeView { + private root: Root | null = null; + + static fromPort(args: TSFixme): ByobFilterType { + const view = new ByobFilterType(); + + const p = args.port; + const parent = args.parent; + + view.port = p; + view.displayName = p.displayName ? p.displayName : p.name; + view.name = p.name; + view.type = getEditType(p); + view.default = p.default; + view.group = p.group; + view.value = parent.model.getParameter(p.name); + view.parent = parent; + view.isConnected = parent.model.isPortConnected(p.name, 'target'); + view.isDefault = parent.model.parameters[p.name] === undefined; + + return view; + } + + render() { + const div = document.createElement('div'); + + // Parse value from JSON string - extracted so it can be called on render and re-render + const parseValue = (): FilterGroup | null => { + if (this.value) { + try { + const parsed = JSON.parse(this.value as string); + // Check if it's already our visual builder format (has 'conditions' array) + if (parsed.conditions) { + return parsed as FilterGroup; + } else { + // It's a legacy Directus format, convert it + return fromDirectusFilter(parsed); + } + } catch (e) { + console.warn('[ByobFilterType] Failed to parse existing filter:', e); + } + } + return null; + }; + + // Render the button component + const renderButton = (filterValue: FilterGroup | null) => { + // Get schema from port type (populated by node's updatePorts) + const schema: SchemaCollection | null = (this.type as TSFixme)?.schema || null; + + const props = { + value: filterValue, + schema: schema, + onChange: (filter: FilterGroup) => { + // Store the full visual builder format (with conditions array, IDs, etc.) + // The runtime will convert to Directus format at fetch time + const jsonString = JSON.stringify(filter); + + console.log('[ByobFilterType] Saving filter:', jsonString); + + const undoArgs = { undo: true, label: 'filter changed', oldValue: this.value }; + this.value = jsonString; + this.parent.model.setParameter(this.name, jsonString, undoArgs); + this.isDefault = false; + + // Re-render the button with the new value so UI updates immediately + renderButton(filter); + } + }; + + if (!this.root) { + this.root = createRoot(div); + } + this.root.render(React.createElement(FilterBuilderButton, props)); + }; + + renderButton(parseValue()); + + this.el = $(div); + + return this.el; + } +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts index 4e89e00..e998749 100644 --- a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts +++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts @@ -10,6 +10,7 @@ import { getEditType } from '../utils'; import { AlignToolsType } from './AlignTools/AlignToolsType'; import { BasicType } from './BasicType'; import { BooleanType } from './BooleanType'; +import { ByobFilterType } from './ByobFilterType'; import { ColorType } from './ColorPicker/ColorType'; import { ComponentType } from './ComponentType'; import { CurveType } from './CurveEditor/CurveType'; @@ -344,6 +345,10 @@ export class Ports extends View { return NodeLibrary.nameForPortType(type) === 'query-sorting'; } + function isOfByobFilterType() { + return NodeLibrary.nameForPortType(type) === 'byob-filter'; + } + // Is of pages type function isOfPagesType() { return NodeLibrary.nameForPortType(type) === 'pages'; @@ -379,6 +384,7 @@ export class Ports extends View { else if (isOfCurveType()) return CurveType; else if (isOfQueryFilterType()) return QueryFilterType; else if (isOfQuerySortingType()) return QuerySortingType; + else if (isOfByobFilterType()) return ByobFilterType; else if (isOfPagesType()) return PagesType; else if (isOfPropListType()) return PropListType; } diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/ByobFilterBuilder.module.scss b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/ByobFilterBuilder.module.scss new file mode 100644 index 0000000..b9710c9 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/ByobFilterBuilder.module.scss @@ -0,0 +1,350 @@ +/** + * BYOB Filter Builder Styles + * + * Uses design tokens from theme variables + */ + +.ByobFilterBuilder { + display: flex; + flex-direction: column; + background-color: var(--theme-color-bg-2); + border-radius: 4px; + overflow: hidden; +} + +.ByobFilterBuilderHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background-color: var(--theme-color-bg-3); + border-bottom: 1px solid var(--theme-color-border-default); +} + +.ByobFilterBuilderActions { + display: flex; + gap: 4px; +} + +.ByobFilterBuilderContent { + padding: 8px; +} + +// Filter Group Styles +.FilterGroup { + background-color: var(--theme-color-bg-3); + border: 1px solid var(--theme-color-border-default); + border-radius: 4px; + overflow: hidden; + + &Root { + border: none; + background: transparent; + } + + // Nested groups get progressively lighter borders + &[data-depth='1'] { + background-color: var(--theme-color-bg-2); + } + + &[data-depth='2'] { + background-color: var(--theme-color-bg-1); + } +} + +.FilterGroupHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + background-color: var(--theme-color-bg-4); + border-bottom: 1px solid var(--theme-color-border-default); +} + +.FilterGroupHeaderLeft { + display: flex; + align-items: center; + gap: 8px; +} + +.FilterGroupDragHandle { + cursor: grab; + padding: 2px 4px; + border-radius: 2px; + opacity: 0.5; + transition: opacity 0.15s ease, background-color 0.15s ease; + + &:hover { + opacity: 1; + background-color: var(--theme-color-bg-2); + } + + &:active { + cursor: grabbing; + } +} + +.FilterGroupDragging { + opacity: 0.5; + border-style: dashed !important; +} + +.FilterGroupDragOver { + border-color: var(--theme-color-primary) !important; + box-shadow: 0 0 0 2px var(--theme-color-primary-25); +} + +.FilterGroupDropIndicator { + background-color: var(--theme-color-primary-10); + color: var(--theme-color-primary); + padding: 8px 12px; + text-align: center; + font-size: 12px; + font-weight: 500; + border-bottom: 1px solid var(--theme-color-primary-25); +} + +.FilterGroupCombinator { + background-color: var(--theme-color-primary); + color: var(--theme-color-fg-on-primary); + border: none; + padding: 4px 12px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease; + text-transform: uppercase; + letter-spacing: 0.5px; + + &:hover { + background-color: var(--theme-color-primary-highlight); + } +} + +.FilterGroupDelete { + opacity: 0.6; + + &:hover { + opacity: 1; + } +} + +.FilterGroupConditions { + padding: 8px; +} + +.FilterGroupCombinatorLabel { + text-align: center; + font-size: 10px; + font-weight: 600; + color: var(--theme-color-fg-default-shy); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 0; +} + +.FilterGroupEmpty { + padding: 16px; + text-align: center; + color: var(--theme-color-fg-default-shy); + font-size: 12px; + font-style: italic; +} + +.FilterGroupActions { + display: flex; + gap: 8px; +} + +// Filter Condition Styles +.FilterCondition { + background-color: var(--theme-color-bg-2); + border-radius: 4px; + padding: 4px; + border: 1px solid transparent; + transition: opacity 0.15s ease, border-color 0.15s ease; +} + +.FilterConditionDragging { + opacity: 0.5; + border-style: dashed !important; + border-color: var(--theme-color-border-default) !important; +} + +.FilterConditionDragHandle { + cursor: grab; + padding: 2px 4px; + border-radius: 2px; + opacity: 0.4; + flex-shrink: 0; + transition: opacity 0.15s ease, background-color 0.15s ease; + + &:hover { + opacity: 1; + background-color: var(--theme-color-bg-3); + } + + &:active { + cursor: grabbing; + } +} + +.FilterConditionFields { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.FilterConditionField { + flex: 1; + min-width: 120px; + max-width: 200px; +} + +.FilterConditionOperator { + flex: 1; + min-width: 100px; + max-width: 180px; +} + +.FilterConditionValue { + flex: 2; + min-width: 100px; +} + +.FilterConditionValueSmall { + flex: 1; + min-width: 60px; + max-width: 120px; +} + +.FilterConditionBetweenLabel { + color: var(--theme-color-fg-default-shy); + font-size: 12px; + padding: 0 4px; +} + +.FilterConditionDelete { + opacity: 0.5; + flex-shrink: 0; + + &:hover { + opacity: 1; + } +} + +// Connect toggle button - switches between static and connected mode +.FilterConditionConnectToggle { + flex-shrink: 0; + opacity: 0.6; + transition: opacity 0.15s ease, color 0.15s ease; + + &:hover { + opacity: 1; + } +} + +// Port indicator for connected mode +.FilterConditionPortIndicator { + display: flex; + align-items: center; + gap: 6px; + flex: 2; + min-width: 100px; + padding: 6px 10px; + background-color: var(--theme-color-primary-10); + border: 1px solid var(--theme-color-primary-25); + border-radius: 4px; + color: var(--theme-color-primary); +} + +.FilterConditionPortDot { + color: var(--theme-color-primary); +} + +.FilterConditionPortName { + font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; + font-size: 11px; + font-weight: 500; +} + +// JSON Preview Styles +.ByobFilterBuilderJson { + border-top: 1px solid var(--theme-color-border-default); + background-color: var(--theme-color-bg-1); +} + +.ByobFilterBuilderJsonHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 12px; + background-color: var(--theme-color-bg-2); + border-bottom: 1px solid var(--theme-color-border-default); +} + +.ByobFilterBuilderJsonActions { + display: flex; + gap: 4px; +} + +.ByobFilterBuilderJsonPreview { + margin: 0; + padding: 12px; + font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; + font-size: 11px; + color: var(--theme-color-fg-default); + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + +.ByobFilterBuilderJsonEditor { + width: 100%; + min-height: 150px; + padding: 12px; + font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; + font-size: 11px; + color: var(--theme-color-fg-default); + background-color: var(--theme-color-bg-1); + border: none; + resize: vertical; + outline: none; + + &:focus { + outline: 1px solid var(--theme-color-primary); + } +} + +.ByobFilterBuilderJsonError { + color: var(--theme-color-danger); + font-size: 12px; +} + +// Filter Builder Button (compact property panel view) +.FilterBuilderButton { + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--theme-color-bg-3); + border: 1px solid var(--theme-color-border-default); + border-radius: 4px; + padding: 8px 12px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background-color: var(--theme-color-bg-2); + border-color: var(--theme-color-border-highlight); + } +} + +.FilterBuilderButtonContent { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/ByobFilterBuilder.tsx b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/ByobFilterBuilder.tsx new file mode 100644 index 0000000..432f5da --- /dev/null +++ b/packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/ByobFilterBuilder/ByobFilterBuilder.tsx @@ -0,0 +1,288 @@ +/** + * BYOB Filter Builder + * + * Main component for building Directus-compatible filters visually. + * Includes JSON preview and the ability to edit JSON directly. + */ + +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react'; + +import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon'; +import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton'; +import { Box } from '@noodl-core-ui/components/layout/Box'; +import { Text, TextType } from '@noodl-core-ui/components/typography/Text'; + +import css from './ByobFilterBuilder.module.scss'; +import { DragProvider } from './DragContext'; +import { FilterGroup } from './FilterGroup'; +import { ByobFilterBuilderProps, createEmptyFilterGroup, FilterGroup as FilterGroupType, SchemaField } from './types'; + +/** + * Ref interface for imperative access to ByobFilterBuilder + * Used by FilterBuilderModal to save pending JSON edits before closing + */ +export interface ByobFilterBuilderRef { + /** + * If JSON edit mode is active, saves the current JSON text. + * Returns the saved filter if successful, or null if not in edit mode or invalid. + */ + saveJsonIfEditing: () => FilterGroupType | null; +} + +/** + * Convert filter to JSON string for display/editing (internal format) + * This preserves IDs and structure so edits can be saved correctly + */ +function filterToJsonString(filter: FilterGroupType | null, pretty = true): string { + if (!filter) return ''; + return JSON.stringify(filter, null, pretty ? 2 : 0); +} + +/** + * Parse JSON string to filter model (internal format) + * Validates the structure has the required fields + */ +function jsonStringToFilter(json: string): FilterGroupType | null { + try { + const parsed = JSON.parse(json); + // Validate it has the required structure + if (parsed && typeof parsed === 'object' && 'id' in parsed && 'type' in parsed && 'conditions' in parsed) { + if ((parsed.type === 'and' || parsed.type === 'or') && Array.isArray(parsed.conditions)) { + // Recursively validate conditions + if (validateFilterGroup(parsed)) { + return parsed as FilterGroupType; + } + } + } + return null; + } catch { + return null; + } +} + +/** + * Validate a filter group structure recursively + */ +function validateFilterGroup(group: unknown): boolean { + if (!group || typeof group !== 'object') return false; + + const g = group as Record; + if (!g.id || !g.type || !Array.isArray(g.conditions)) return false; + if (g.type !== 'and' && g.type !== 'or') return false; + + // Validate each condition + for (const item of g.conditions) { + if (!item || typeof item !== 'object') return false; + const i = item as Record; + if (!i.id) return false; + + // Is it a group? + if ('conditions' in i) { + if (!validateFilterGroup(i)) return false; + } else { + // It's a condition - must have field and operator + if (!('field' in i) || !('operator' in i)) return false; + } + } + + return true; +} + +export const ByobFilterBuilder = forwardRef(function ByobFilterBuilder( + { value, schema, onChange }, + ref +) { + // Initialize filter state + const [filter, setFilter] = useState(() => { + return value || createEmptyFilterGroup(); + }); + + // Show/hide JSON preview + const [showJson, setShowJson] = useState(false); + + // JSON editing mode + const [jsonEditMode, setJsonEditMode] = useState(false); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(null); + + // Expose imperative methods via ref + useImperativeHandle( + ref, + () => ({ + saveJsonIfEditing: (): FilterGroupType | null => { + if (jsonEditMode) { + try { + const parsed = jsonStringToFilter(jsonText); + if (parsed) { + setFilter(parsed); + onChange(parsed); + setJsonEditMode(false); + setJsonError(null); + return parsed; + } + } catch { + // Invalid JSON - return null + } + } + return null; + } + }), + [jsonEditMode, jsonText, onChange] + ); + + // Sync external value changes + useEffect(() => { + if (value) { + setFilter(value); + } + }, [value]); + + // Update JSON text when filter changes + useEffect(() => { + if (!jsonEditMode) { + setJsonText(filterToJsonString(filter)); + } + }, [filter, jsonEditMode]); + + // Handle filter changes from visual builder + const handleFilterChange = useCallback( + (newFilter: FilterGroupType) => { + setFilter(newFilter); + onChange(newFilter); + }, + [onChange] + ); + + // Toggle JSON preview + const handleToggleJson = useCallback(() => { + setShowJson((prev) => !prev); + setJsonEditMode(false); + setJsonError(null); + }, []); + + // Enter JSON edit mode + const handleEditJson = useCallback(() => { + setJsonEditMode(true); + setJsonText(filterToJsonString(filter)); + setJsonError(null); + }, [filter]); + + // Save JSON edits + const handleSaveJson = useCallback(() => { + try { + const parsed = jsonStringToFilter(jsonText); + if (parsed) { + setFilter(parsed); + onChange(parsed); + setJsonEditMode(false); + setJsonError(null); + } else { + setJsonError('Invalid filter JSON structure'); + } + } catch (e) { + setJsonError('Invalid JSON syntax'); + } + }, [jsonText, onChange]); + + // Cancel JSON edits + const handleCancelJson = useCallback(() => { + setJsonEditMode(false); + setJsonText(filterToJsonString(filter)); + setJsonError(null); + }, [filter]); + + // Copy JSON to clipboard + const handleCopyJson = useCallback(() => { + const json = filterToJsonString(filter); + navigator.clipboard.writeText(json).catch(console.error); + }, [filter]); + + // Get fields from schema + const fields: SchemaField[] = schema?.fields || []; + + return ( +
+ {/* Header */} +
+ Filter Conditions +
+ +
+
+ + {/* Visual Filter Builder - Wrapped in DragProvider for drag & drop */} + +
+ +
+
+ + {/* JSON Preview Panel */} + {showJson && ( +
+
+ Filter JSON (editable) +
+ {!jsonEditMode ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ + {jsonError && ( + + {jsonError} + + )} + + {jsonEditMode ? ( +