mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
Added some community improvement suggestions in docs
This commit is contained in:
341
dev-docs/future-projects/SSR-SUPPORT.md
Normal file
341
dev-docs/future-projects/SSR-SUPPORT.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# Future: Server-Side Rendering (SSR) Support
|
||||
|
||||
> **Status**: Planning
|
||||
> **Priority**: Medium
|
||||
> **Complexity**: High
|
||||
> **Prerequisites**: React 19 migration, HTTP node implementation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
OpenNoodl has substantial existing SSR infrastructure that was developed but never shipped by the original Noodl team. This document outlines a path to completing and exposing SSR as a user-facing feature, giving users the choice between client-side rendering (CSR), server-side rendering (SSR), and static site generation (SSG).
|
||||
|
||||
## Why SSR Matters
|
||||
|
||||
### The Problem with Pure CSR
|
||||
|
||||
Currently, Noodl apps are entirely client-side rendered:
|
||||
|
||||
1. **SEO Limitations**: Search engine crawlers see an empty `<div id="root"></div>` until JavaScript executes
|
||||
2. **Social Sharing**: Link previews on Twitter, Facebook, Slack, etc. show blank or generic content
|
||||
3. **First Paint Performance**: Users see a blank screen while the runtime loads and initializes
|
||||
4. **Core Web Vitals**: Poor Largest Contentful Paint (LCP) scores affect search rankings
|
||||
|
||||
### What SSR Provides
|
||||
|
||||
| Metric | CSR | SSR | SSG |
|
||||
|--------|-----|-----|-----|
|
||||
| SEO | Poor | Excellent | Excellent |
|
||||
| Social Previews | Broken | Working | Working |
|
||||
| First Paint | Slow | Fast | Fastest |
|
||||
| Hosting Requirements | Static | Node.js Server | Static |
|
||||
| Dynamic Content | Real-time | Real-time | Build-time |
|
||||
| Build Complexity | Low | Medium | Medium |
|
||||
|
||||
## Current State in Codebase
|
||||
|
||||
### What Already Exists
|
||||
|
||||
The original Noodl team built significant SSR infrastructure:
|
||||
|
||||
**SSR Server (`packages/noodl-viewer-react/static/ssr/`)**
|
||||
- Express server with route handling
|
||||
- `ReactDOMServer.renderToString()` integration
|
||||
- Browser API polyfills (localStorage, fetch, XMLHttpRequest, requestAnimationFrame)
|
||||
- Result caching via `node-cache`
|
||||
- Graceful fallback to CSR on errors
|
||||
|
||||
**SEO API (`Noodl.SEO`)**
|
||||
- `setTitle(value)` - Update document title
|
||||
- `setMeta(key, value)` - Set meta tags
|
||||
- `getMeta(key)` / `clearMeta()` - Manage meta tags
|
||||
- Designed specifically for SSR (no direct window access)
|
||||
|
||||
**Deploy Infrastructure**
|
||||
- `runtimeType` parameter supports `'ssr'` value
|
||||
- Separate deploy index for SSR files (`ssr/index.json`)
|
||||
- Commented-out UI code showing intended deployment flow
|
||||
|
||||
**Build Scripts**
|
||||
- `getPages()` API returns all routes with metadata
|
||||
- `createIndexPage()` generates HTML with custom meta tags
|
||||
- `expandPaths()` for dynamic route expansion
|
||||
- Sitemap generation support
|
||||
|
||||
### What's Incomplete
|
||||
|
||||
- SEO meta injection not implemented (`// TODO: Inject Noodl.SEO.meta`)
|
||||
- Page router issues (`// TODO: Maybe fix page router`)
|
||||
- No UI for selecting SSR deployment
|
||||
- No documentation or user guidance
|
||||
- Untested with modern component library
|
||||
- No hydration verification
|
||||
|
||||
## Proposed User Experience
|
||||
|
||||
### Option 1: Project-Level Setting
|
||||
|
||||
Add rendering mode selection in Project Settings:
|
||||
|
||||
```
|
||||
Rendering Mode:
|
||||
○ Client-Side (CSR) - Default, works with any static host
|
||||
○ Server-Side (SSR) - Better SEO, requires Node.js hosting
|
||||
○ Static Generation (SSG) - Best performance, pre-renders at build time
|
||||
```
|
||||
|
||||
**Pros**: Simple mental model, single source of truth
|
||||
**Cons**: All-or-nothing, can't mix approaches
|
||||
|
||||
### Option 2: Deploy-Time Selection
|
||||
|
||||
Add rendering mode choice in Deploy popup:
|
||||
|
||||
```
|
||||
Deploy Target:
|
||||
[Static Files (CSR)] [Node.js Server (SSR)] [Pre-rendered (SSG)]
|
||||
```
|
||||
|
||||
**Pros**: Flexible, same project can deploy differently
|
||||
**Cons**: Could be confusing, settings disconnect
|
||||
|
||||
### Option 3: Page-Level Configuration (Recommended)
|
||||
|
||||
Add per-page rendering configuration in Page Router settings:
|
||||
|
||||
```
|
||||
Page: /blog/{slug}
|
||||
Rendering: [SSR ▼]
|
||||
|
||||
Page: /dashboard
|
||||
Rendering: [CSR ▼]
|
||||
|
||||
Page: /about
|
||||
Rendering: [SSG ▼]
|
||||
```
|
||||
|
||||
**Pros**: Maximum flexibility, matches real-world needs
|
||||
**Cons**: More complex, requires smarter build system
|
||||
|
||||
### Recommended Approach
|
||||
|
||||
**Phase 1**: Start with Option 2 (Deploy-Time Selection) - simplest to implement
|
||||
**Phase 2**: Add Option 1 (Project Setting) for default behavior
|
||||
**Phase 3**: Consider Option 3 (Page-Level) based on user demand
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Phase 1: Complete Existing SSR Infrastructure
|
||||
|
||||
**1.1 Fix Page Router for SSR**
|
||||
- Ensure `globalThis.location` properly simulates browser location
|
||||
- Handle query parameters and hash fragments
|
||||
- Support Page Router navigation events
|
||||
|
||||
**1.2 Implement SEO Meta Injection**
|
||||
```javascript
|
||||
// In ssr/index.js buildPage()
|
||||
const result = htmlData
|
||||
.replace('<div id="root"></div>', `<div id="root">${output1}</div>`)
|
||||
.replace('</head>', `${generateMetaTags(noodlRuntime.SEO.meta)}</head>`);
|
||||
```
|
||||
|
||||
**1.3 Polyfill Audit**
|
||||
- Test all visual nodes in SSR context
|
||||
- Identify browser-only APIs that need polyfills
|
||||
- Create SSR compatibility matrix for nodes
|
||||
|
||||
### Phase 2: Deploy UI Integration
|
||||
|
||||
**2.1 Add SSR Option to Deploy Popup**
|
||||
```typescript
|
||||
// DeployToFolderTab.tsx
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'csr', label: 'Client-Side Rendering (Static)' },
|
||||
{ value: 'ssr', label: 'Server-Side Rendering (Node.js)' },
|
||||
{ value: 'ssg', label: 'Static Site Generation' }
|
||||
]}
|
||||
value={renderingMode}
|
||||
onChange={setRenderingMode}
|
||||
label="Rendering Mode"
|
||||
/>
|
||||
```
|
||||
|
||||
**2.2 SSR Deploy Flow**
|
||||
```typescript
|
||||
if (renderingMode === 'ssr') {
|
||||
// Deploy SSR server files to root
|
||||
await compilation.deployToFolder(direntry, {
|
||||
environment,
|
||||
runtimeType: 'ssr'
|
||||
});
|
||||
// Deploy static assets to /public
|
||||
await compilation.deployToFolder(direntry + '/public', {
|
||||
environment,
|
||||
runtimeType: 'deploy'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**2.3 SSG Build Flow**
|
||||
```typescript
|
||||
if (renderingMode === 'ssg') {
|
||||
// Deploy static files
|
||||
await compilation.deployToFolder(direntry, { environment });
|
||||
|
||||
// Pre-render each page
|
||||
const pages = await context.getPages({ expandPaths: ... });
|
||||
for (const page of pages) {
|
||||
const html = await prerenderPage(page.path);
|
||||
await writeFile(`${direntry}${page.path}/index.html`, html);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Enhanced SEO Tools
|
||||
|
||||
**3.1 SEO Node**
|
||||
Create a visual node for setting page metadata:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ SEO Settings │
|
||||
├─────────────────────────────┤
|
||||
│ ► Title [string] │
|
||||
│ ► Description [string] │
|
||||
│ ► Image URL [string] │
|
||||
│ ► Keywords [string] │
|
||||
│ ► Canonical URL [string] │
|
||||
│ ► Robots [string] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**3.2 Open Graph Support**
|
||||
Extend `Noodl.SEO` API:
|
||||
```javascript
|
||||
Noodl.SEO.setOpenGraph({
|
||||
title: 'My Page',
|
||||
description: 'Page description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
type: 'website'
|
||||
});
|
||||
|
||||
Noodl.SEO.setTwitterCard({
|
||||
card: 'summary_large_image',
|
||||
site: '@mysite'
|
||||
});
|
||||
```
|
||||
|
||||
**3.3 Structured Data**
|
||||
```javascript
|
||||
Noodl.SEO.setStructuredData({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": "My Article",
|
||||
"author": { "@type": "Person", "name": "Author" }
|
||||
});
|
||||
```
|
||||
|
||||
### Phase 4: Hosting Integration
|
||||
|
||||
**4.1 One-Click Deploy Targets**
|
||||
- Vercel (native SSR support)
|
||||
- Netlify (serverless functions for SSR)
|
||||
- Railway / Render (Node.js hosting)
|
||||
- Docker container export
|
||||
|
||||
**4.2 Deploy Configuration Generation**
|
||||
```javascript
|
||||
// Generate vercel.json
|
||||
{
|
||||
"builds": [
|
||||
{ "src": "server.js", "use": "@vercel/node" },
|
||||
{ "src": "public/**", "use": "@vercel/static" }
|
||||
],
|
||||
"routes": [
|
||||
{ "src": "/public/(.*)", "dest": "/public/$1" },
|
||||
{ "src": "/(.*)", "dest": "/server.js" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Component SSR Compatibility
|
||||
|
||||
### Compatibility Levels
|
||||
|
||||
**Level A: Full SSR Support**
|
||||
- Text, Group, Columns, Image (static src)
|
||||
- All layout nodes
|
||||
- Style properties
|
||||
|
||||
**Level B: Hydration Required**
|
||||
- Video, Animation
|
||||
- Interactive components
|
||||
- Event handlers
|
||||
|
||||
**Level C: Client-Only**
|
||||
- Camera, Geolocation
|
||||
- Local Storage operations
|
||||
- WebSocket connections
|
||||
|
||||
### Handling Incompatible Components
|
||||
|
||||
```javascript
|
||||
// In component definition
|
||||
{
|
||||
ssr: {
|
||||
supported: false,
|
||||
fallback: '<div class="placeholder">Loading video...</div>'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### SSR Test Suite
|
||||
1. **Render Tests**: Each node type renders correct HTML
|
||||
2. **Hydration Tests**: Client picks up server state correctly
|
||||
3. **SEO Tests**: Meta tags present in rendered output
|
||||
4. **Error Tests**: Graceful fallback on component errors
|
||||
5. **Performance Tests**: SSR response times under load
|
||||
|
||||
### Validation Checklist
|
||||
- [ ] All visual nodes render without errors
|
||||
- [ ] Page Router navigates correctly
|
||||
- [ ] SEO meta tags injected properly
|
||||
- [ ] Hydration completes without mismatch warnings
|
||||
- [ ] Fallback to CSR works when SSR fails
|
||||
- [ ] Build scripts continue to work
|
||||
- [ ] Cloud functions unaffected
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **React 19 First?** Should we complete React 19 migration before SSR work? The SSR code uses React 17's `renderToString` - React 19 has different streaming APIs.
|
||||
|
||||
2. **Streaming SSR?** React 18+ supports streaming SSR with Suspense. Should we support this for better TTFB?
|
||||
|
||||
3. **Edge Runtime?** Should we support edge deployment (Cloudflare Workers, Vercel Edge) for lower latency?
|
||||
|
||||
4. **Partial Hydration?** Should we implement islands architecture for selective hydration?
|
||||
|
||||
5. **Preview in Editor?** Can we show SSR output in the editor for SEO debugging?
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Adoption**: % of deploys using SSR/SSG modes
|
||||
- **SEO Improvement**: User-reported search ranking changes
|
||||
- **Performance**: Core Web Vitals improvements (LCP, FID, CLS)
|
||||
- **Developer Experience**: Time to deploy with SSR enabled
|
||||
|
||||
## Related Work
|
||||
|
||||
- [React 19 Migration](./FUTURE-react-19-migration.md)
|
||||
- [HTTP Node Implementation](./TASK-http-node.md)
|
||||
- [Deploy Automation](./FUTURE-deploy-automation.md)
|
||||
|
||||
## References
|
||||
|
||||
- Original SSR code: `packages/noodl-viewer-react/static/ssr/`
|
||||
- SEO API docs: `javascript/reference/seo/README.md`
|
||||
- Build scripts: `javascript/extending/build-script/`
|
||||
- Deploy infrastructure: `packages/noodl-editor/src/editor/src/utils/compilation/`
|
||||
@@ -621,6 +621,94 @@ The `copyFile` method in the platform API is specifically designed for copying f
|
||||
|
||||
---
|
||||
|
||||
## Preview & Web Server
|
||||
|
||||
### [2024-12-15] - Custom Fonts 404 Due to Missing MIME Types
|
||||
|
||||
**Context**: Custom fonts (TTF, OTF, WOFF, WOFF2) weren't loading in editor preview. Console showed 404 errors and "OTS parsing error: GDEF: misaligned table" messages. Users thought the dev server wasn't serving project files.
|
||||
|
||||
**Discovery**: The web server WAS already serving project directory files correctly (lines 166-172 in web-server.js already handle project path lookups). The real issue was the `getContentType()` function only had MIME types for `.ttf` fonts, not for modern formats:
|
||||
- `.otf` → Missing
|
||||
- `.woff` → Missing
|
||||
- `.woff2` → Missing
|
||||
|
||||
When browsers requested these files, they received them with the default `text/html` content-type. Browsers then tried to parse binary font data as HTML, which fails with confusing OTS parsing errors.
|
||||
|
||||
Also found a bug: the `.wav` case was missing a `break;` statement, causing it to fall through to `.mp4`.
|
||||
|
||||
**Fix**: Add missing MIME types to the switch statement:
|
||||
```javascript
|
||||
case '.otf':
|
||||
contentType = 'font/otf';
|
||||
break;
|
||||
case '.woff':
|
||||
contentType = 'font/woff';
|
||||
break;
|
||||
case '.woff2':
|
||||
contentType = 'font/woff2';
|
||||
break;
|
||||
```
|
||||
|
||||
**Key Insight**: The task documentation assumed we needed to add project file serving infrastructure (middleware, protocol handlers, etc.). The architecture was already correct - we just needed proper MIME type mapping. This turned a 4-6 hour task into a 5-minute fix.
|
||||
|
||||
**Location**: `packages/noodl-editor/src/main/src/web-server.js` (getContentType function)
|
||||
|
||||
**Keywords**: fonts, MIME types, 404, OTS parsing error, web server, preview, TTF, OTF, WOFF, WOFF2, content-type
|
||||
|
||||
---
|
||||
|
||||
### [2024-12-15] - Legacy Project Fonts Need Fallback Path Resolution
|
||||
|
||||
**Context**: After fixing MIME types, new projects loaded fonts correctly but legacy/migrated projects still showed 404 errors for fonts. Investigation revealed font URLs were being requested without folder prefixes.
|
||||
|
||||
**Discovery**: OpenNoodl stores font paths in project.json relative to the project root. The FontPicker component (fontpicker.js) generates these paths from `fileEntry.fullPath.substring(ProjectModel.instance._retainedProjectDirectory.length + 1)`:
|
||||
|
||||
- If font is at `/project/fonts/Inter.ttf` → stored as `fonts/Inter.ttf`
|
||||
- If font is at `/project/Inter.ttf` → stored as `Inter.ttf`
|
||||
|
||||
Legacy projects may have fonts stored in different locations or with different path conventions. When the viewer requests a font URL like `/Inter.ttf`, the server looks for `{projectDir}/Inter.ttf`, but the font might actually be at `{projectDir}/fonts/Inter.ttf`.
|
||||
|
||||
**The Font Loading Chain**:
|
||||
1. Node parameter stores fontFamily: `"Inter-Regular.ttf"`
|
||||
2. `node-shared-port-definitions.js` calls `FontLoader.instance.loadFont(family)`
|
||||
3. `fontloader.js` uses `getAbsoluteUrl(fontURL)` which prepends `Noodl.baseUrl` (usually `/`)
|
||||
4. Browser requests `GET /Inter-Regular.ttf`
|
||||
5. Server tries `projectDirectory + /Inter-Regular.ttf`
|
||||
6. If not found → 404
|
||||
|
||||
**Fix**: Added font fallback mechanism in web-server.js that searches common locations when a font isn't found:
|
||||
```javascript
|
||||
if (fontExtensions.includes(ext)) {
|
||||
const filename = path.split('/').pop();
|
||||
const fallbackPaths = [
|
||||
info.projectDirectory + '/fonts' + path, // /fonts/filename.ttf
|
||||
info.projectDirectory + '/fonts/' + filename, // /fonts/filename.ttf
|
||||
info.projectDirectory + '/' + filename, // /filename.ttf (root)
|
||||
info.projectDirectory + '/assets/fonts/' + filename // /assets/fonts/filename.ttf
|
||||
];
|
||||
|
||||
for (const fallbackPath of fallbackPaths) {
|
||||
if (fs.existsSync(fallbackPath)) {
|
||||
console.log(`Font fallback: ${path} -> ${fallbackPath}`);
|
||||
serveFile(fallbackPath, request, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Files**:
|
||||
- `packages/noodl-viewer-react/src/fontloader.js` - Runtime font loading
|
||||
- `packages/noodl-viewer-react/src/node-shared-port-definitions.js` - Where loadFont is called
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/fontpicker.js` - How font paths are stored
|
||||
- `packages/noodl-editor/src/main/src/web-server.js` - Server-side font resolution
|
||||
|
||||
**Location**: `packages/noodl-editor/src/main/src/web-server.js`
|
||||
|
||||
**Keywords**: fonts, legacy projects, fallback paths, font not found, 404, projectDirectory, font resolution, migration
|
||||
|
||||
---
|
||||
|
||||
## Template for Future Entries
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
# User Location Node Specification
|
||||
|
||||
## Overview
|
||||
|
||||
The **User Location** node provides user geolocation functionality with multiple precision levels and fallback strategies. It handles the browser Geolocation API, manages permissions gracefully, and provides clear status reporting for different location acquisition methods.
|
||||
|
||||
This is a **logic node** (non-visual) that responds to signal triggers and outputs location data with comprehensive error handling and status reporting.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Location-aware features**: Show nearby stores, events, or services
|
||||
- **Personalization**: Adapt content based on user's region
|
||||
- **Analytics**: Track geographic usage patterns (with user consent)
|
||||
- **Shipping/delivery**: Pre-fill location fields in forms
|
||||
- **Weather apps**: Get local weather based on position
|
||||
- **Progressive enhancement**: Start with coarse location, refine to precise GPS when available
|
||||
|
||||
## Technical Foundation
|
||||
|
||||
### Browser Geolocation API
|
||||
- **Primary method**: `navigator.geolocation.getCurrentPosition()`
|
||||
- **Permissions**: Requires user consent (browser prompt)
|
||||
- **Accuracy**: GPS on mobile (~5-10m), WiFi/IP on desktop (~100-1000m)
|
||||
- **Browser support**: Universal (Chrome, Firefox, Safari, Edge)
|
||||
- **HTTPS requirement**: Geolocation API requires secure context
|
||||
|
||||
### IP-based Fallback
|
||||
- **Service**: ipapi.co free tier (no API key required for basic usage)
|
||||
- **Accuracy**: City-level (~10-50km radius)
|
||||
- **Privacy**: Does not require user permission
|
||||
- **Limits**: 1,000 requests/day on free tier
|
||||
- **Fallback strategy**: Used when GPS unavailable or permission denied
|
||||
|
||||
## Node Interface
|
||||
|
||||
### Category & Metadata
|
||||
```javascript
|
||||
{
|
||||
name: 'User Location',
|
||||
category: 'Data',
|
||||
color: 'data',
|
||||
docs: 'https://docs.noodl.net/nodes/data/user-location',
|
||||
searchTags: ['geolocation', 'gps', 'position', 'coordinates', 'location'],
|
||||
displayName: 'User Location'
|
||||
}
|
||||
```
|
||||
|
||||
### Signal Inputs
|
||||
|
||||
#### `Get Location`
|
||||
Triggers location acquisition based on current accuracy mode setting.
|
||||
|
||||
**Behavior:**
|
||||
- Checks if geolocation is supported
|
||||
- Requests appropriate permission level
|
||||
- Executes location query
|
||||
- Sends appropriate output signal when complete
|
||||
|
||||
#### `Cancel`
|
||||
Aborts an in-progress location request.
|
||||
|
||||
**Behavior:**
|
||||
- Clears any pending geolocation watchPosition
|
||||
- Aborts any in-flight IP geolocation requests
|
||||
- Sends `Canceled` signal
|
||||
- Resets internal state
|
||||
|
||||
### Parameters
|
||||
|
||||
#### `Accuracy Mode`
|
||||
**Type:** Enum (dropdown)
|
||||
**Default:** `"precise"`
|
||||
**Options:**
|
||||
- `"precise"` - High accuracy GPS (mobile: ~5-10m, desktop: ~100m)
|
||||
- `"coarse"` - Lower accuracy, faster, better battery (mobile: ~100m-1km)
|
||||
- `"city"` - IP-based location, no permission required (~10-50km)
|
||||
|
||||
**Details:**
|
||||
- **Precise**: Uses `enableHighAccuracy: true`, ideal for navigation/directions
|
||||
- **Coarse**: Uses `enableHighAccuracy: false`, better for "nearby" features
|
||||
- **City**: Uses IP geolocation service, for region-level personalization
|
||||
|
||||
#### `Timeout`
|
||||
**Type:** Number
|
||||
**Default:** `10000` (10 seconds)
|
||||
**Unit:** Milliseconds
|
||||
**Range:** 1000-60000
|
||||
|
||||
Specifies how long to wait for location before timing out.
|
||||
|
||||
#### `Cache Age`
|
||||
**Type:** Number
|
||||
**Default:** `60000` (1 minute)
|
||||
**Unit:** Milliseconds
|
||||
**Range:** 0-3600000
|
||||
|
||||
Maximum age of a cached position. Setting to `0` forces a fresh location.
|
||||
|
||||
#### `Auto Request`
|
||||
**Type:** Boolean
|
||||
**Default:** `false`
|
||||
|
||||
If `true`, automatically requests location when node initializes (useful for apps that always need location).
|
||||
|
||||
**Warning:** Requesting location on load can be jarring to users. Best practice is to request only when needed.
|
||||
|
||||
### Data Outputs
|
||||
|
||||
#### `Latitude`
|
||||
**Type:** Number
|
||||
**Precision:** 6-8 decimal places
|
||||
**Example:** `59.3293`
|
||||
|
||||
Geographic latitude in decimal degrees.
|
||||
|
||||
#### `Longitude`
|
||||
**Type:** Number
|
||||
**Precision:** 6-8 decimal places
|
||||
**Example:** `18.0686`
|
||||
|
||||
Geographic longitude in decimal degrees.
|
||||
|
||||
#### `Accuracy`
|
||||
**Type:** Number
|
||||
**Unit:** Meters
|
||||
**Example:** `10.5`
|
||||
|
||||
Accuracy radius in meters. Represents confidence circle around the position.
|
||||
|
||||
#### `Altitude` (Optional)
|
||||
**Type:** Number
|
||||
**Unit:** Meters
|
||||
**Example:** `45.2`
|
||||
|
||||
Height above sea level. May be `null` if unavailable (common on desktop).
|
||||
|
||||
#### `Altitude Accuracy` (Optional)
|
||||
**Type:** Number
|
||||
**Unit:** Meters
|
||||
|
||||
Accuracy of altitude measurement. May be `null` if unavailable.
|
||||
|
||||
#### `Heading` (Optional)
|
||||
**Type:** Number
|
||||
**Unit:** Degrees (0-360)
|
||||
**Example:** `90.0` (East)
|
||||
|
||||
Direction of device movement. `null` when stationary or unavailable.
|
||||
|
||||
#### `Speed` (Optional)
|
||||
**Type:** Number
|
||||
**Unit:** Meters per second
|
||||
**Example:** `1.5` (walking pace)
|
||||
|
||||
Device movement speed. `null` when stationary or unavailable.
|
||||
|
||||
#### `Timestamp`
|
||||
**Type:** Number
|
||||
**Format:** Unix timestamp (milliseconds since epoch)
|
||||
**Example:** `1703001234567`
|
||||
|
||||
When the position was acquired.
|
||||
|
||||
#### `City`
|
||||
**Type:** String
|
||||
**Example:** `"Stockholm"`
|
||||
|
||||
City name (only available with IP-based location).
|
||||
|
||||
#### `Region`
|
||||
**Type:** String
|
||||
**Example:** `"Stockholm County"`
|
||||
|
||||
Region/state name (only available with IP-based location).
|
||||
|
||||
#### `Country`
|
||||
**Type:** String
|
||||
**Example:** `"Sweden"`
|
||||
|
||||
Country name (only available with IP-based location).
|
||||
|
||||
#### `Country Code`
|
||||
**Type:** String
|
||||
**Example:** `"SE"`
|
||||
|
||||
ISO 3166-1 alpha-2 country code (only available with IP-based location).
|
||||
|
||||
#### `Postal Code`
|
||||
**Type:** String
|
||||
**Example:** `"111 22"`
|
||||
|
||||
Postal/ZIP code (only available with IP-based location).
|
||||
|
||||
#### `Error Message`
|
||||
**Type:** String
|
||||
**Example:** `"User denied geolocation permission"`
|
||||
|
||||
Human-readable error message when location acquisition fails.
|
||||
|
||||
#### `Error Code`
|
||||
**Type:** Number
|
||||
**Values:**
|
||||
- `0` - No error
|
||||
- `1` - Permission denied
|
||||
- `2` - Position unavailable
|
||||
- `3` - Timeout
|
||||
- `4` - Browser not supported
|
||||
- `5` - Network error (IP geolocation)
|
||||
|
||||
Numeric error code for programmatic handling.
|
||||
|
||||
### Signal Outputs
|
||||
|
||||
#### `Success`
|
||||
Sent when location is successfully acquired.
|
||||
|
||||
**Guarantees:**
|
||||
- `Latitude` and `Longitude` are populated
|
||||
- `Accuracy` contains valid accuracy estimate
|
||||
- Other outputs populated based on method and device capabilities
|
||||
|
||||
#### `Permission Denied`
|
||||
Sent when user explicitly denies location permission.
|
||||
|
||||
**User recovery:**
|
||||
- Show message explaining why location is needed
|
||||
- Provide alternative (manual location entry)
|
||||
- Offer "Settings" link to browser permissions
|
||||
|
||||
#### `Position Unavailable`
|
||||
Sent when location service reports position cannot be determined.
|
||||
|
||||
**Causes:**
|
||||
- GPS signal lost (indoors, urban canyon)
|
||||
- WiFi/cell network unavailable
|
||||
- Location services disabled at OS level
|
||||
|
||||
#### `Timeout`
|
||||
Sent when location request exceeds configured timeout.
|
||||
|
||||
**Response:**
|
||||
- May succeed if retried with longer timeout
|
||||
- Consider falling back to IP-based location
|
||||
|
||||
#### `Not Supported`
|
||||
Sent when browser doesn't support geolocation.
|
||||
|
||||
**Response:**
|
||||
- Fall back to manual location entry
|
||||
- Use IP-based estimation
|
||||
- Show graceful degradation message
|
||||
|
||||
#### `Canceled`
|
||||
Sent when location request is explicitly canceled via `Cancel` signal.
|
||||
|
||||
#### `Network Error`
|
||||
Sent when IP geolocation service fails (only for city-level accuracy).
|
||||
|
||||
**Causes:**
|
||||
- Network connectivity issues
|
||||
- API rate limit exceeded
|
||||
- Service unavailable
|
||||
|
||||
## State Management
|
||||
|
||||
The node maintains internal state to track:
|
||||
|
||||
```javascript
|
||||
this._internal = {
|
||||
watchId: null, // Active geolocation watch ID
|
||||
abortController: null, // For canceling IP requests
|
||||
pendingRequest: false, // Is request in progress?
|
||||
lastPosition: null, // Cached position data
|
||||
lastError: null, // Last error encountered
|
||||
permissionState: 'prompt' // 'granted', 'denied', 'prompt'
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Permission Handling Strategy
|
||||
|
||||
1. **Check permission state** (if Permissions API available)
|
||||
2. **Request location** based on accuracy mode
|
||||
3. **Handle response** with appropriate success/error signal
|
||||
4. **Cache result** for subsequent requests within cache window
|
||||
|
||||
### Geolocation Options
|
||||
|
||||
```javascript
|
||||
// For "precise" mode
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: this._internal.timeout,
|
||||
maximumAge: this._internal.cacheAge
|
||||
}
|
||||
|
||||
// For "coarse" mode
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: this._internal.timeout,
|
||||
maximumAge: this._internal.cacheAge
|
||||
}
|
||||
```
|
||||
|
||||
### IP Geolocation Implementation
|
||||
|
||||
```javascript
|
||||
async function getIPLocation() {
|
||||
const controller = new AbortController();
|
||||
this._internal.abortController = controller;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://ipapi.co/json/', {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Populate outputs
|
||||
this.setOutputs({
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
accuracy: 50000, // ~50km city-level accuracy
|
||||
city: data.city,
|
||||
region: data.region,
|
||||
country: data.country_name,
|
||||
countryCode: data.country_code,
|
||||
postalCode: data.postal,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
this.sendSignalOnOutput('success');
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this.sendSignalOnOutput('canceled');
|
||||
} else {
|
||||
this._internal.lastError = error.message;
|
||||
this.flagOutputDirty('errorMessage');
|
||||
this.sendSignalOnOutput('networkError');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Mapping
|
||||
|
||||
```javascript
|
||||
function handleGeolocationError(error) {
|
||||
this._internal.lastError = error;
|
||||
this.setOutputValue('errorCode', error.code);
|
||||
|
||||
switch(error.code) {
|
||||
case 1: // PERMISSION_DENIED
|
||||
this.setOutputValue('errorMessage', 'User denied geolocation permission');
|
||||
this.sendSignalOnOutput('permissionDenied');
|
||||
break;
|
||||
|
||||
case 2: // POSITION_UNAVAILABLE
|
||||
this.setOutputValue('errorMessage', 'Position unavailable');
|
||||
this.sendSignalOnOutput('positionUnavailable');
|
||||
break;
|
||||
|
||||
case 3: // TIMEOUT
|
||||
this.setOutputValue('errorMessage', 'Location request timed out');
|
||||
this.sendSignalOnOutput('timeout');
|
||||
break;
|
||||
|
||||
default:
|
||||
this.setOutputValue('errorMessage', 'Unknown error occurred');
|
||||
this.sendSignalOnOutput('positionUnavailable');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security & Privacy Considerations
|
||||
|
||||
### User Privacy
|
||||
- **Explicit permission**: Always require user consent for GPS (precise/coarse)
|
||||
- **Clear purpose**: Document why location is needed in app UI
|
||||
- **Minimal data**: Only request accuracy level needed for feature
|
||||
- **No storage**: Don't store location unless explicitly needed
|
||||
- **User control**: Provide easy way to revoke/change location settings
|
||||
|
||||
### HTTPS Requirement
|
||||
- Geolocation API **requires HTTPS** in modern browsers
|
||||
- Will fail silently or throw error on HTTP pages
|
||||
- Development exception: `localhost` works over HTTP
|
||||
|
||||
### Rate Limiting
|
||||
- IP geolocation service has 1,000 requests/day limit (free tier)
|
||||
- Implement smart caching to reduce API calls
|
||||
- Consider upgrading to paid tier for high-traffic apps
|
||||
|
||||
### Permission Persistence
|
||||
- Browser remembers user's permission choice
|
||||
- Can be revoked at any time in browser settings
|
||||
- Node should gracefully handle permission changes
|
||||
|
||||
## User Experience Guidelines
|
||||
|
||||
### When to Request Location
|
||||
|
||||
**✅ DO:**
|
||||
- Request when user triggers location-dependent feature
|
||||
- Explain why location is needed before requesting
|
||||
- Provide fallback for users who decline
|
||||
|
||||
**❌ DON'T:**
|
||||
- Request on page load without context
|
||||
- Re-prompt immediately after denial
|
||||
- Block functionality if permission denied
|
||||
|
||||
### Error Handling UX
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Permission Denied │
|
||||
├─────────────────────────────────────┤
|
||||
│ We need your location to show │
|
||||
│ nearby stores. You can enable it │
|
||||
│ in your browser settings. │
|
||||
│ │
|
||||
│ [Enter Location Manually] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Progressive Enhancement
|
||||
|
||||
1. **Start coarse**: Request city-level (no permission)
|
||||
2. **Offer precise**: "Show exact location" button
|
||||
3. **Graceful degradation**: Manual entry fallback
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```javascript
|
||||
describe('User Location Node', () => {
|
||||
it('should request high accuracy location in precise mode', () => {
|
||||
// Mock navigator.geolocation.getCurrentPosition
|
||||
// Verify enableHighAccuracy: true
|
||||
});
|
||||
|
||||
it('should timeout after configured duration', () => {
|
||||
// Set timeout to 1000ms
|
||||
// Mock delayed response
|
||||
// Verify timeout signal fires
|
||||
});
|
||||
|
||||
it('should use cached location within cache age', () => {
|
||||
// Get location once
|
||||
// Get location again within cache window
|
||||
// Verify no new geolocation call made
|
||||
});
|
||||
|
||||
it('should fall back to IP location in city mode', () => {
|
||||
// Set mode to 'city'
|
||||
// Trigger get location
|
||||
// Verify fetch called to ipapi.co
|
||||
});
|
||||
|
||||
it('should handle permission denial gracefully', () => {
|
||||
// Mock permission denied error
|
||||
// Verify permissionDenied signal fires
|
||||
// Verify error message set
|
||||
});
|
||||
|
||||
it('should cancel in-progress requests', () => {
|
||||
// Start location request
|
||||
// Trigger cancel
|
||||
// Verify canceled signal fires
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Test on actual devices (mobile + desktop)
|
||||
- Test with/without GPS enabled
|
||||
- Test with permission granted/denied/prompt states
|
||||
- Test network failures for IP geolocation
|
||||
- Test timeout behavior with slow networks
|
||||
- Test HTTPS requirement enforcement
|
||||
|
||||
### Browser Compatibility Tests
|
||||
|
||||
| Browser | Version | Notes |
|
||||
|---------|---------|-------|
|
||||
| Chrome | 90+ | Full support |
|
||||
| Firefox | 88+ | Full support |
|
||||
| Safari | 14+ | Full support, may prompt per session |
|
||||
| Edge | 90+ | Full support |
|
||||
| Mobile Safari | iOS 14+ | High accuracy works well |
|
||||
| Mobile Chrome | Android 10+ | High accuracy works well |
|
||||
|
||||
## Example Usage Patterns
|
||||
|
||||
### Pattern 1: Simple Location Request
|
||||
|
||||
```
|
||||
[Button] → Click Signal
|
||||
↓
|
||||
[User Location] → Get Location
|
||||
↓
|
||||
Success → [Text] "Your location: {Latitude}, {Longitude}"
|
||||
Permission Denied → [Text] "Please enable location access"
|
||||
```
|
||||
|
||||
### Pattern 2: Progressive Enhancement
|
||||
|
||||
```
|
||||
[User Location] (mode: city)
|
||||
↓
|
||||
Success → [Text] "Shopping near {City}"
|
||||
↓
|
||||
[Button] "Show exact location"
|
||||
↓
|
||||
[User Location] (mode: precise) → Get Location
|
||||
↓
|
||||
Success → Update map with precise position
|
||||
```
|
||||
|
||||
### Pattern 3: Error Recovery Chain
|
||||
|
||||
```
|
||||
[User Location] (mode: precise)
|
||||
↓
|
||||
Permission Denied OR Timeout
|
||||
↓
|
||||
[User Location] (mode: city) → Get Location
|
||||
↓
|
||||
Success → Use coarse location
|
||||
Network Error → [Text] "Enter location manually"
|
||||
```
|
||||
|
||||
### Pattern 4: Map Integration
|
||||
|
||||
```
|
||||
[User Location]
|
||||
↓
|
||||
Success → [Object] Store lat/lng
|
||||
↓
|
||||
[Function] Call map API
|
||||
↓
|
||||
[HTML Element] Display map with user marker
|
||||
```
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
### Node Reference Page
|
||||
|
||||
1. **Overview section** explaining location acquisition
|
||||
2. **Permission explanation** with browser screenshots
|
||||
3. **Accuracy mode comparison** table
|
||||
4. **Common use cases** with visual examples
|
||||
5. **Error handling guide** with recovery strategies
|
||||
6. **Privacy best practices** section
|
||||
7. **HTTPS requirement** warning
|
||||
8. **Example implementations** for each pattern
|
||||
|
||||
### Tutorial Content
|
||||
|
||||
- "Building a Store Locator with User Location"
|
||||
- "Progressive Location Permissions"
|
||||
- "Handling Location Errors Gracefully"
|
||||
|
||||
## File Locations
|
||||
|
||||
### Implementation
|
||||
- **Path**: `/packages/noodl-runtime/src/nodes/std-library/data/userlocation.js`
|
||||
- **Registration**: Add to `/packages/noodl-runtime/src/nodes/std-library/index.js`
|
||||
|
||||
### Tests
|
||||
- **Unit**: `/packages/noodl-runtime/tests/nodes/data/userlocation.test.js`
|
||||
- **Integration**: Manual testing checklist document
|
||||
|
||||
### Documentation
|
||||
- **Main docs**: `/docs/nodes/data/user-location.md`
|
||||
- **Examples**: `/docs/examples/location-features.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Runtime Dependencies
|
||||
- Native browser APIs (no external dependencies)
|
||||
- Optional: `ipapi.co` for IP-based location (free service, no npm package needed)
|
||||
|
||||
### Development Dependencies
|
||||
- Jest for unit tests
|
||||
- Mock implementations of `navigator.geolocation`
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core GPS Location (2-3 days)
|
||||
- [ ] Basic node structure with inputs/outputs
|
||||
- [ ] GPS location acquisition (precise/coarse modes)
|
||||
- [ ] Permission handling
|
||||
- [ ] Error handling and signal outputs
|
||||
- [ ] Basic unit tests
|
||||
|
||||
### Phase 2: IP Fallback (1-2 days)
|
||||
- [ ] City mode implementation
|
||||
- [ ] IP geolocation API integration
|
||||
- [ ] Network error handling
|
||||
- [ ] Extended test coverage
|
||||
|
||||
### Phase 3: Polish & Edge Cases (1-2 days)
|
||||
- [ ] Cancel functionality
|
||||
- [ ] Cache management
|
||||
- [ ] Auto request feature
|
||||
- [ ] Browser compatibility testing
|
||||
- [ ] Permission state tracking
|
||||
|
||||
### Phase 4: Documentation (1-2 days)
|
||||
- [ ] Node reference documentation
|
||||
- [ ] Usage examples
|
||||
- [ ] Tutorial content
|
||||
- [ ] Privacy guidelines
|
||||
- [ ] Troubleshooting guide
|
||||
|
||||
**Total estimated effort:** 5-9 days
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Node successfully acquires location in all three accuracy modes
|
||||
- [ ] Permission states handled gracefully (grant/deny/prompt)
|
||||
- [ ] Clear error messages for all failure scenarios
|
||||
- [ ] Timeout and cancel functionality work correctly
|
||||
- [ ] Cache prevents unnecessary repeated requests
|
||||
- [ ] Works across major browsers and devices
|
||||
- [ ] Comprehensive unit test coverage (>80%)
|
||||
- [ ] Documentation complete with examples
|
||||
- [ ] Privacy considerations clearly documented
|
||||
- [ ] Community feedback incorporated
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Continuous Location Tracking
|
||||
Add `Watch Location` signal input that continuously monitors position changes. Useful for:
|
||||
- Navigation apps
|
||||
- Fitness tracking
|
||||
- Delivery tracking
|
||||
|
||||
**Implementation:** Use `navigator.geolocation.watchPosition()`
|
||||
|
||||
### Geofencing
|
||||
Add ability to define geographic boundaries and trigger signals when user enters/exits.
|
||||
|
||||
**Outputs:**
|
||||
- `Entered Geofence` signal
|
||||
- `Exited Geofence` signal
|
||||
- `Inside Geofence` boolean
|
||||
|
||||
### Custom IP Services
|
||||
Allow users to specify their own IP geolocation service URL and API key for:
|
||||
- Higher rate limits
|
||||
- Additional data (ISP, timezone, currency)
|
||||
- Enterprise requirements
|
||||
|
||||
### Location History
|
||||
Optional caching of location history with timestamp array output for:
|
||||
- Journey tracking
|
||||
- Location analytics
|
||||
- Movement patterns
|
||||
|
||||
### Distance Calculations
|
||||
Built-in distance calculation between user location and target coordinates:
|
||||
- Distance to store/event
|
||||
- Sorting by proximity
|
||||
- "Nearby" filtering
|
||||
|
||||
## Related Nodes
|
||||
|
||||
- **REST**: Can be used to send location data to APIs
|
||||
- **Object**: Store location data in app state
|
||||
- **Condition**: Branch logic based on error codes
|
||||
- **Function**: Calculate distances, format coordinates
|
||||
- **Array**: Store multiple location readings
|
||||
|
||||
## Questions for Community/Team
|
||||
|
||||
1. Should we include "Watch Location" in v1 or defer to v2?
|
||||
2. Do we need additional country/region data beyond what ipapi.co provides?
|
||||
3. Should we support other IP geolocation services?
|
||||
4. Is 1-minute default cache age appropriate?
|
||||
5. Should we add a "Remember Permission" feature?
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2024-12-16
|
||||
**Author:** AI Assistant (Claude)
|
||||
**Status:** RFC - Ready for Review
|
||||
@@ -12,7 +12,7 @@ This file tracks all changes made during TASK-006: Fix Custom Font Loading in Ed
|
||||
|
||||
## Changes
|
||||
|
||||
### [December 15, 2024] - Cline AI Assistant
|
||||
### [December 15, 2024] - Session 1 - Cline AI Assistant
|
||||
|
||||
**Summary**: Fixed custom font loading in editor preview by adding missing MIME types to web server configuration. The issue was simpler than expected - the server was already serving project files, but was missing MIME type mappings for modern font formats.
|
||||
|
||||
@@ -23,33 +23,40 @@ This file tracks all changes made during TASK-006: Fix Custom Font Loading in Ed
|
||||
- Added `.woff2` → `font/woff2`
|
||||
- Fixed `.wav` case missing `break;` statement (was falling through to `.mp4`)
|
||||
|
||||
**Files Created**:
|
||||
- None
|
||||
**Testing Notes**:
|
||||
- New projects: Fonts load correctly ✅
|
||||
- Legacy projects: Fonts still failing (needs investigation)
|
||||
|
||||
**Files Deleted**:
|
||||
- None
|
||||
---
|
||||
|
||||
**Configuration Changes**:
|
||||
- MIME types configured in `getContentType()` function
|
||||
- Font formats now properly recognized: `.ttf`, `.otf`, `.woff`, `.woff2`
|
||||
### [December 15, 2024] - Session 2 - Cline AI Assistant
|
||||
|
||||
**Summary**: Added font fallback mechanism to handle legacy projects that may store font paths differently. The issue was that legacy projects might store fontFamily as just the filename (e.g., `Inter-Regular.ttf`) while new projects store the full relative path (e.g., `fonts/Inter-Regular.ttf`).
|
||||
|
||||
**Files Modified**:
|
||||
- `packages/noodl-editor/src/main/src/web-server.js` - Added font fallback path resolution
|
||||
- When a font file isn't found at the requested path, the server now searches common locations:
|
||||
1. `/fonts{originalPath}` - prepend fonts folder
|
||||
2. `/fonts/{filename}` - fonts folder + just filename
|
||||
3. `/{filename}` - project root level
|
||||
4. `/assets/fonts/{filename}` - assets/fonts folder
|
||||
- Added console logging for fallback resolution debugging
|
||||
- Fixed ESLint unused variable error in `server.on('listening')` callback
|
||||
|
||||
**Technical Details**:
|
||||
- Font path resolution flow:
|
||||
1. First tries exact path: `projectDirectory + requestPath`
|
||||
2. If not found and it's a font file (.ttf, .otf, .woff, .woff2), tries fallback locations
|
||||
3. Logs successful fallback resolutions to console for debugging
|
||||
4. Returns 404 only if all fallback paths fail
|
||||
|
||||
**Breaking Changes**:
|
||||
- None - this fix only affects development preview server
|
||||
- None - this enhancement only adds fallback behavior when files aren't found
|
||||
|
||||
**Testing Notes**:
|
||||
- Manual testing required: Create project with custom fonts and verify they load in preview
|
||||
- Test all font formats: TTF, OTF, WOFF, WOFF2
|
||||
- Verify no 404 errors in console
|
||||
- Verify no "OTS parsing error" messages
|
||||
|
||||
**Known Issues**:
|
||||
- None expected - changes are minimal and isolated to MIME type configuration
|
||||
|
||||
**Next Steps**:
|
||||
- Build and test editor with changes
|
||||
- Create test project with multiple font formats
|
||||
- Verify fonts load correctly in preview
|
||||
- Test project switching behavior
|
||||
- Requires rebuild and restart of editor
|
||||
- Check console for "Font fallback:" messages to verify mechanism is working
|
||||
- Test with legacy projects that have fonts in various locations
|
||||
|
||||
#### [Date] - [Developer Name]
|
||||
|
||||
|
||||
@@ -183,9 +183,23 @@ OTS parsing error: GDEF: misaligned table
|
||||
|
||||
### Console After Fix
|
||||
|
||||
```
|
||||
(Document whether errors are resolved)
|
||||
```
|
||||
**Important**: The fix requires restarting the dev server!
|
||||
|
||||
Steps to test:
|
||||
1. Stop current `npm run dev` with Ctrl+C
|
||||
2. Run `npm run dev` again to recompile with new code
|
||||
3. Open a project with custom fonts
|
||||
4. Check console - should see NO 404 errors or OTS parsing errors
|
||||
|
||||
**First Test Results** (Dev server not restarted):
|
||||
- Still seeing 404 errors - this is EXPECTED
|
||||
- Old compiled code still running in Electron
|
||||
- Changes in source files don't apply until recompilation
|
||||
|
||||
**After Restart** (To be documented):
|
||||
- Fonts should load successfully
|
||||
- No 404 errors
|
||||
- No "OTS parsing error" messages
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
# TASK: Enhanced Expression Node & Expression Evaluator Foundation
|
||||
|
||||
## Overview
|
||||
|
||||
Upgrade the existing Expression node to support full JavaScript expressions with access to `Noodl.Variables`, `Noodl.Objects`, and `Noodl.Arrays`, plus reactive dependency tracking. This establishes the foundation for Phase 2 (inline expression properties throughout the editor).
|
||||
|
||||
**Estimated effort:** 2-3 weeks
|
||||
**Priority:** High - Foundation for Expression Properties feature
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
## Background & Motivation
|
||||
|
||||
### Current Expression Node Limitations
|
||||
|
||||
The existing Expression node (`packages/noodl-runtime/src/nodes/std-library/expression.js`):
|
||||
|
||||
1. **Limited context** - Only provides Math helpers (min, max, cos, sin, etc.)
|
||||
2. **No Noodl globals** - Cannot access `Noodl.Variables.X`, `Noodl.Objects.Y`, `Noodl.Arrays.Z`
|
||||
3. **Boolean-focused outputs** - Primarily `isTrue`/`isFalse`, though `result` exists as `*` type
|
||||
4. **Workaround required** - Users must create connected input ports to pass in variable values
|
||||
5. **No reactive updates** - Doesn't automatically re-evaluate when referenced Variables/Objects change
|
||||
|
||||
### Desired State
|
||||
|
||||
Users should be able to write expressions like:
|
||||
```javascript
|
||||
Noodl.Variables.isLoggedIn ? `Welcome, ${Noodl.Variables.userName}!` : "Please log in"
|
||||
```
|
||||
|
||||
And have the expression automatically re-evaluate whenever `isLoggedIn` or `userName` changes.
|
||||
|
||||
---
|
||||
|
||||
## Files to Analyze First
|
||||
|
||||
Before making changes, thoroughly read and understand these files:
|
||||
|
||||
### Core Expression Implementation
|
||||
```
|
||||
@packages/noodl-runtime/src/nodes/std-library/expression.js
|
||||
```
|
||||
- Current expression compilation using `new Function()`
|
||||
- `functionPreamble` that injects Math helpers
|
||||
- `parsePorts()` for extracting variable references
|
||||
- Scheduling and caching mechanisms
|
||||
|
||||
### Noodl Global APIs
|
||||
```
|
||||
@packages/noodl-runtime/src/model.js
|
||||
@packages/noodl-viewer-react/src/noodl-js-api.js
|
||||
@packages/noodl-viewer-cloud/src/noodl-js-api.js
|
||||
```
|
||||
- How `Noodl.Variables`, `Noodl.Objects`, `Noodl.Arrays` are implemented
|
||||
- The Model class and its change event system
|
||||
- `Model.get('--ndl--global-variables')` pattern
|
||||
|
||||
### Type Definitions (for autocomplete later)
|
||||
```
|
||||
@packages/noodl-viewer-react/static/viewer/global.d.ts.keep
|
||||
@packages/noodl-viewer-cloud/static/viewer/global.d.ts.keep
|
||||
```
|
||||
- TypeScript definitions for the Noodl namespace
|
||||
- Documentation of the API surface
|
||||
|
||||
### JavaScript/Function Node (reference)
|
||||
```
|
||||
@packages/noodl-runtime/src/nodes/std-library/javascriptfunction.js
|
||||
```
|
||||
- How full JavaScript nodes access Noodl context
|
||||
- Pattern for providing richer execution context
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Create Expression Evaluator Module
|
||||
|
||||
Create a new shared module that handles expression compilation, dependency tracking, and evaluation.
|
||||
|
||||
**Create file:** `packages/noodl-runtime/src/expression-evaluator.js`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Expression Evaluator
|
||||
*
|
||||
* Compiles JavaScript expressions with access to Noodl globals
|
||||
* and tracks dependencies for reactive updates.
|
||||
*
|
||||
* Features:
|
||||
* - Full Noodl.Variables, Noodl.Objects, Noodl.Arrays access
|
||||
* - Math helpers (min, max, cos, sin, etc.)
|
||||
* - Dependency detection and change subscription
|
||||
* - Expression versioning for future compatibility
|
||||
* - Caching of compiled functions
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const Model = require('./model');
|
||||
|
||||
// Expression system version - increment when context changes
|
||||
const EXPRESSION_VERSION = 1;
|
||||
|
||||
// Cache for compiled functions
|
||||
const compiledFunctionsCache = new Map();
|
||||
|
||||
// Math helpers to inject
|
||||
const mathHelpers = {
|
||||
min: Math.min,
|
||||
max: Math.max,
|
||||
cos: Math.cos,
|
||||
sin: Math.sin,
|
||||
tan: Math.tan,
|
||||
sqrt: Math.sqrt,
|
||||
pi: Math.PI,
|
||||
round: Math.round,
|
||||
floor: Math.floor,
|
||||
ceil: Math.ceil,
|
||||
abs: Math.abs,
|
||||
random: Math.random,
|
||||
pow: Math.pow,
|
||||
log: Math.log,
|
||||
exp: Math.exp
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect dependencies in an expression string
|
||||
* Returns { variables: string[], objects: string[], arrays: string[] }
|
||||
*/
|
||||
function detectDependencies(expression) {
|
||||
const dependencies = {
|
||||
variables: [],
|
||||
objects: [],
|
||||
arrays: []
|
||||
};
|
||||
|
||||
// Remove strings to avoid false matches
|
||||
const exprWithoutStrings = expression
|
||||
.replace(/"([^"\\]|\\.)*"/g, '""')
|
||||
.replace(/'([^'\\]|\\.)*'/g, "''")
|
||||
.replace(/`([^`\\]|\\.)*`/g, '``');
|
||||
|
||||
// Match Noodl.Variables.X or Noodl.Variables["X"]
|
||||
const variableMatches = exprWithoutStrings.matchAll(
|
||||
/Noodl\.Variables\.([a-zA-Z_$][a-zA-Z0-9_$]*)|Noodl\.Variables\[["']([^"']+)["']\]/g
|
||||
);
|
||||
for (const match of variableMatches) {
|
||||
const varName = match[1] || match[2];
|
||||
if (varName && !dependencies.variables.includes(varName)) {
|
||||
dependencies.variables.push(varName);
|
||||
}
|
||||
}
|
||||
|
||||
// Match Noodl.Objects.X or Noodl.Objects["X"]
|
||||
const objectMatches = exprWithoutStrings.matchAll(
|
||||
/Noodl\.Objects\.([a-zA-Z_$][a-zA-Z0-9_$]*)|Noodl\.Objects\[["']([^"']+)["']\]/g
|
||||
);
|
||||
for (const match of objectMatches) {
|
||||
const objId = match[1] || match[2];
|
||||
if (objId && !dependencies.objects.includes(objId)) {
|
||||
dependencies.objects.push(objId);
|
||||
}
|
||||
}
|
||||
|
||||
// Match Noodl.Arrays.X or Noodl.Arrays["X"]
|
||||
const arrayMatches = exprWithoutStrings.matchAll(
|
||||
/Noodl\.Arrays\.([a-zA-Z_$][a-zA-Z0-9_$]*)|Noodl\.Arrays\[["']([^"']+)["']\]/g
|
||||
);
|
||||
for (const match of arrayMatches) {
|
||||
const arrId = match[1] || match[2];
|
||||
if (arrId && !dependencies.arrays.includes(arrId)) {
|
||||
dependencies.arrays.push(arrId);
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Noodl context object for expression evaluation
|
||||
*/
|
||||
function createNoodlContext(modelScope) {
|
||||
const scope = modelScope || Model;
|
||||
|
||||
return {
|
||||
Variables: scope.get('--ndl--global-variables')?.data || {},
|
||||
Objects: new Proxy({}, {
|
||||
get(target, prop) {
|
||||
const obj = scope.get(prop);
|
||||
return obj ? obj.data : undefined;
|
||||
}
|
||||
}),
|
||||
Arrays: new Proxy({}, {
|
||||
get(target, prop) {
|
||||
const arr = scope.get(prop);
|
||||
return arr ? arr.data : undefined;
|
||||
}
|
||||
}),
|
||||
Object: scope
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile an expression string into a callable function
|
||||
*/
|
||||
function compileExpression(expression) {
|
||||
const cacheKey = `v${EXPRESSION_VERSION}:${expression}`;
|
||||
|
||||
if (compiledFunctionsCache.has(cacheKey)) {
|
||||
return compiledFunctionsCache.get(cacheKey);
|
||||
}
|
||||
|
||||
// Build parameter list for the function
|
||||
const paramNames = ['Noodl', ...Object.keys(mathHelpers)];
|
||||
|
||||
// Wrap expression in return statement
|
||||
const functionBody = `
|
||||
"use strict";
|
||||
try {
|
||||
return (${expression});
|
||||
} catch (e) {
|
||||
console.error('Expression evaluation error:', e.message);
|
||||
return undefined;
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const fn = new Function(...paramNames, functionBody);
|
||||
compiledFunctionsCache.set(cacheKey, fn);
|
||||
return fn;
|
||||
} catch (e) {
|
||||
console.error('Expression compilation error:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a compiled expression with the current context
|
||||
*/
|
||||
function evaluateExpression(compiledFn, modelScope) {
|
||||
if (!compiledFn) return undefined;
|
||||
|
||||
const noodlContext = createNoodlContext(modelScope);
|
||||
const mathValues = Object.values(mathHelpers);
|
||||
|
||||
try {
|
||||
return compiledFn(noodlContext, ...mathValues);
|
||||
} catch (e) {
|
||||
console.error('Expression evaluation error:', e.message);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in expression dependencies
|
||||
* Returns an unsubscribe function
|
||||
*/
|
||||
function subscribeToChanges(dependencies, callback, modelScope) {
|
||||
const scope = modelScope || Model;
|
||||
const listeners = [];
|
||||
|
||||
// Subscribe to variable changes
|
||||
if (dependencies.variables.length > 0) {
|
||||
const variablesModel = scope.get('--ndl--global-variables');
|
||||
if (variablesModel) {
|
||||
const handler = (args) => {
|
||||
// Check if any of our dependencies changed
|
||||
if (dependencies.variables.some(v => args.name === v || !args.name)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
variablesModel.on('change', handler);
|
||||
listeners.push(() => variablesModel.off('change', handler));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to object changes
|
||||
for (const objId of dependencies.objects) {
|
||||
const objModel = scope.get(objId);
|
||||
if (objModel) {
|
||||
const handler = () => callback();
|
||||
objModel.on('change', handler);
|
||||
listeners.push(() => objModel.off('change', handler));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to array changes
|
||||
for (const arrId of dependencies.arrays) {
|
||||
const arrModel = scope.get(arrId);
|
||||
if (arrModel) {
|
||||
const handler = () => callback();
|
||||
arrModel.on('change', handler);
|
||||
listeners.push(() => arrModel.off('change', handler));
|
||||
}
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
listeners.forEach(unsub => unsub());
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate expression syntax without executing
|
||||
*/
|
||||
function validateExpression(expression) {
|
||||
try {
|
||||
new Function(`return (${expression})`);
|
||||
return { valid: true, error: null };
|
||||
} catch (e) {
|
||||
return { valid: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current expression system version
|
||||
*/
|
||||
function getExpressionVersion() {
|
||||
return EXPRESSION_VERSION;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectDependencies,
|
||||
compileExpression,
|
||||
evaluateExpression,
|
||||
subscribeToChanges,
|
||||
validateExpression,
|
||||
createNoodlContext,
|
||||
getExpressionVersion,
|
||||
EXPRESSION_VERSION
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Upgrade Expression Node
|
||||
|
||||
Modify the existing Expression node to use the new evaluator and support reactive updates.
|
||||
|
||||
**Modify file:** `packages/noodl-runtime/src/nodes/std-library/expression.js`
|
||||
|
||||
Key changes:
|
||||
1. Use `expression-evaluator.js` for compilation
|
||||
2. Add Noodl globals to the function preamble
|
||||
3. Implement dependency detection
|
||||
4. Subscribe to changes for automatic re-evaluation
|
||||
5. Add new typed outputs (`asString`, `asNumber`)
|
||||
6. Clean up subscriptions on node deletion
|
||||
|
||||
```javascript
|
||||
// Key additions to the expression node:
|
||||
|
||||
const ExpressionEvaluator = require('../../expression-evaluator');
|
||||
|
||||
// In initialize():
|
||||
internal.unsubscribe = null;
|
||||
internal.dependencies = { variables: [], objects: [], arrays: [] };
|
||||
|
||||
// In the expression input setter:
|
||||
// After compiling the expression:
|
||||
internal.dependencies = ExpressionEvaluator.detectDependencies(value);
|
||||
|
||||
// Set up reactive subscription
|
||||
if (internal.unsubscribe) {
|
||||
internal.unsubscribe();
|
||||
}
|
||||
|
||||
if (internal.dependencies.variables.length > 0 ||
|
||||
internal.dependencies.objects.length > 0 ||
|
||||
internal.dependencies.arrays.length > 0) {
|
||||
internal.unsubscribe = ExpressionEvaluator.subscribeToChanges(
|
||||
internal.dependencies,
|
||||
() => this._scheduleEvaluateExpression(),
|
||||
this.context?.modelScope
|
||||
);
|
||||
}
|
||||
|
||||
// Add cleanup in _onNodeDeleted or add a delete listener
|
||||
```
|
||||
|
||||
### Step 3: Update Function Preamble
|
||||
|
||||
Update the preamble to include Noodl globals:
|
||||
|
||||
```javascript
|
||||
var functionPreamble = [
|
||||
// Math helpers (existing)
|
||||
'var min = Math.min,',
|
||||
' max = Math.max,',
|
||||
' cos = Math.cos,',
|
||||
' sin = Math.sin,',
|
||||
' tan = Math.tan,',
|
||||
' sqrt = Math.sqrt,',
|
||||
' pi = Math.PI,',
|
||||
' round = Math.round,',
|
||||
' floor = Math.floor,',
|
||||
' ceil = Math.ceil,',
|
||||
' abs = Math.abs,',
|
||||
' pow = Math.pow,',
|
||||
' log = Math.log,',
|
||||
' exp = Math.exp,',
|
||||
' random = Math.random;',
|
||||
// Noodl context shortcuts (new)
|
||||
'var Variables = Noodl.Variables,',
|
||||
' Objects = Noodl.Objects,',
|
||||
' Arrays = Noodl.Arrays;'
|
||||
].join('\n');
|
||||
```
|
||||
|
||||
### Step 4: Add New Outputs
|
||||
|
||||
Add typed output alternatives for better downstream compatibility:
|
||||
|
||||
```javascript
|
||||
outputs: {
|
||||
// Existing outputs (keep for backward compatibility)
|
||||
result: { /* ... */ },
|
||||
isTrue: { /* ... */ },
|
||||
isFalse: { /* ... */ },
|
||||
isTrueEv: { /* ... */ },
|
||||
isFalseEv: { /* ... */ },
|
||||
|
||||
// New typed outputs
|
||||
asString: {
|
||||
group: 'Typed Results',
|
||||
type: 'string',
|
||||
displayName: 'As String',
|
||||
getter: function() {
|
||||
const val = this._internal.cachedValue;
|
||||
return val !== undefined && val !== null ? String(val) : '';
|
||||
}
|
||||
},
|
||||
asNumber: {
|
||||
group: 'Typed Results',
|
||||
type: 'number',
|
||||
displayName: 'As Number',
|
||||
getter: function() {
|
||||
const val = this._internal.cachedValue;
|
||||
return typeof val === 'number' ? val : Number(val) || 0;
|
||||
}
|
||||
},
|
||||
asBoolean: {
|
||||
group: 'Typed Results',
|
||||
type: 'boolean',
|
||||
displayName: 'As Boolean',
|
||||
getter: function() {
|
||||
return !!this._internal.cachedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Add Expression Validation in Editor
|
||||
|
||||
Enhance the editor-side validation to provide better error messages:
|
||||
|
||||
**Modify file:** `packages/noodl-runtime/src/nodes/std-library/expression.js` (setup function)
|
||||
|
||||
```javascript
|
||||
// In the setup function, enhance evalCompileWarnings:
|
||||
function evalCompileWarnings(editorConnection, node) {
|
||||
const expression = node.parameters.expression;
|
||||
if (!expression) {
|
||||
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = ExpressionEvaluator.validateExpression(expression);
|
||||
|
||||
if (!validation.valid) {
|
||||
editorConnection.sendWarning(node.component.name, node.id, 'expression-compile-error', {
|
||||
message: `Syntax error: ${validation.error}`
|
||||
});
|
||||
} else {
|
||||
editorConnection.clearWarning(node.component.name, node.id, 'expression-compile-error');
|
||||
|
||||
// Also show detected dependencies as info (optional)
|
||||
const deps = ExpressionEvaluator.detectDependencies(expression);
|
||||
if (deps.variables.length > 0 || deps.objects.length > 0 || deps.arrays.length > 0) {
|
||||
// Could show this as info, not warning
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Add Tests
|
||||
|
||||
**Create file:** `packages/noodl-runtime/test/expression-evaluator.test.js`
|
||||
|
||||
```javascript
|
||||
const ExpressionEvaluator = require('../src/expression-evaluator');
|
||||
|
||||
describe('Expression Evaluator', () => {
|
||||
describe('detectDependencies', () => {
|
||||
test('detects Noodl.Variables references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies(
|
||||
'Noodl.Variables.isLoggedIn ? Noodl.Variables.userName : "guest"'
|
||||
);
|
||||
expect(deps.variables).toContain('isLoggedIn');
|
||||
expect(deps.variables).toContain('userName');
|
||||
});
|
||||
|
||||
test('detects bracket notation', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies(
|
||||
'Noodl.Variables["my variable"]'
|
||||
);
|
||||
expect(deps.variables).toContain('my variable');
|
||||
});
|
||||
|
||||
test('ignores references inside strings', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies(
|
||||
'"Noodl.Variables.notReal"'
|
||||
);
|
||||
expect(deps.variables).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects Noodl.Objects references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies(
|
||||
'Noodl.Objects.CurrentUser.name'
|
||||
);
|
||||
expect(deps.objects).toContain('CurrentUser');
|
||||
});
|
||||
|
||||
test('detects Noodl.Arrays references', () => {
|
||||
const deps = ExpressionEvaluator.detectDependencies(
|
||||
'Noodl.Arrays.items.length'
|
||||
);
|
||||
expect(deps.arrays).toContain('items');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compileExpression', () => {
|
||||
test('compiles valid expression', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('1 + 1');
|
||||
expect(fn).not.toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for invalid expression', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('1 +');
|
||||
expect(fn).toBeNull();
|
||||
});
|
||||
|
||||
test('caches compiled functions', () => {
|
||||
const fn1 = ExpressionEvaluator.compileExpression('2 + 2');
|
||||
const fn2 = ExpressionEvaluator.compileExpression('2 + 2');
|
||||
expect(fn1).toBe(fn2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExpression', () => {
|
||||
test('validates correct syntax', () => {
|
||||
const result = ExpressionEvaluator.validateExpression('a > b ? 1 : 0');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
test('catches syntax errors', () => {
|
||||
const result = ExpressionEvaluator.validateExpression('a >');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateExpression', () => {
|
||||
test('evaluates math expressions', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('min(10, 5) + max(1, 2)');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
|
||||
test('handles pi constant', () => {
|
||||
const fn = ExpressionEvaluator.compileExpression('round(pi * 100) / 100');
|
||||
const result = ExpressionEvaluator.evaluateExpression(fn);
|
||||
expect(result).toBe(3.14);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Step 7: Update TypeScript Definitions
|
||||
|
||||
**Modify file:** `packages/noodl-editor/src/editor/src/utils/CodeEditor/model.ts`
|
||||
|
||||
Add the enhanced context for Expression nodes in the Monaco editor:
|
||||
|
||||
```typescript
|
||||
// In registerOrUpdate_Expression function, add more complete typings
|
||||
function registerOrUpdate_Expression(): TypescriptModule {
|
||||
return {
|
||||
uri: 'expression-context.d.ts',
|
||||
source: `
|
||||
declare const Noodl: {
|
||||
Variables: Record<string, any>;
|
||||
Objects: Record<string, any>;
|
||||
Arrays: Record<string, any>;
|
||||
};
|
||||
declare const Variables: Record<string, any>;
|
||||
declare const Objects: Record<string, any>;
|
||||
declare const Arrays: Record<string, any>;
|
||||
|
||||
declare const min: typeof Math.min;
|
||||
declare const max: typeof Math.max;
|
||||
declare const cos: typeof Math.cos;
|
||||
declare const sin: typeof Math.sin;
|
||||
declare const tan: typeof Math.tan;
|
||||
declare const sqrt: typeof Math.sqrt;
|
||||
declare const pi: number;
|
||||
declare const round: typeof Math.round;
|
||||
declare const floor: typeof Math.floor;
|
||||
declare const ceil: typeof Math.ceil;
|
||||
declare const abs: typeof Math.abs;
|
||||
declare const pow: typeof Math.pow;
|
||||
declare const log: typeof Math.log;
|
||||
declare const exp: typeof Math.exp;
|
||||
declare const random: typeof Math.random;
|
||||
`,
|
||||
libs: []
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [ ] Expression node can evaluate `Noodl.Variables.X` syntax
|
||||
- [ ] Expression node can evaluate `Noodl.Objects.X.property` syntax
|
||||
- [ ] Expression node can evaluate `Noodl.Arrays.X` syntax
|
||||
- [ ] Shorthand aliases work (`Variables.X`, `Objects.X`, `Arrays.X`)
|
||||
- [ ] Expression auto-re-evaluates when referenced Variable changes
|
||||
- [ ] Expression auto-re-evaluates when referenced Object property changes
|
||||
- [ ] Expression auto-re-evaluates when referenced Array changes
|
||||
- [ ] New typed outputs (`asString`, `asNumber`, `asBoolean`) work correctly
|
||||
- [ ] Backward compatibility - existing expressions continue to work
|
||||
- [ ] Math helpers continue to work (min, max, cos, sin, etc.)
|
||||
- [ ] Syntax errors show clear warning messages in editor
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] Compiled functions are cached for performance
|
||||
- [ ] Memory cleanup - subscriptions are removed when node is deleted
|
||||
- [ ] Expression version is tracked for future migration support
|
||||
- [ ] No performance regression for expressions without Noodl globals
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **Basic Math Expression**
|
||||
- Create Expression node with `min(10, 5) + max(1, 2)`
|
||||
- Verify result output is 7
|
||||
|
||||
2. **Variable Reference**
|
||||
- Set `Noodl.Variables.testVar = 42` in a Function node
|
||||
- Create Expression node with `Noodl.Variables.testVar * 2`
|
||||
- Verify result is 84
|
||||
|
||||
3. **Reactive Update**
|
||||
- Create Expression with `Noodl.Variables.counter`
|
||||
- Connect a button to increment `Noodl.Variables.counter`
|
||||
- Verify Expression result updates automatically on button click
|
||||
|
||||
4. **Object Property Access**
|
||||
- Create an Object with ID "TestObject" and property "name"
|
||||
- Create Expression with `Noodl.Objects.TestObject.name`
|
||||
- Verify result shows the name value
|
||||
|
||||
5. **Ternary with Variables**
|
||||
- Set `Noodl.Variables.isAdmin = true`
|
||||
- Create Expression: `Noodl.Variables.isAdmin ? "Admin" : "User"`
|
||||
- Verify result is "Admin"
|
||||
- Toggle isAdmin to false, verify result changes to "User"
|
||||
|
||||
6. **Template Literals**
|
||||
- Set `Noodl.Variables.name = "Alice"`
|
||||
- Create Expression: `` `Hello, ${Noodl.Variables.name}!` ``
|
||||
- Verify result is "Hello, Alice!"
|
||||
|
||||
7. **Syntax Error Handling**
|
||||
- Create Expression with invalid syntax `1 +`
|
||||
- Verify warning appears in editor
|
||||
- Verify node doesn't crash
|
||||
|
||||
8. **Typed Outputs**
|
||||
- Create Expression: `"42"`
|
||||
- Connect `asNumber` output to a Number display
|
||||
- Verify it shows 42 as number
|
||||
|
||||
### Automated Testing
|
||||
|
||||
- [ ] Run `npm test` in packages/noodl-runtime
|
||||
- [ ] All expression-evaluator tests pass
|
||||
- [ ] Existing expression.test.js tests pass
|
||||
- [ ] No TypeScript errors in editor package
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues are discovered:
|
||||
|
||||
1. The expression-evaluator.js module is additive - can be removed without breaking existing code
|
||||
2. Expression node changes are backward compatible - old expressions work
|
||||
3. New outputs are additive - removing them won't break existing connections
|
||||
4. Keep original functionPreamble as fallback option
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementer
|
||||
|
||||
### Important Patterns to Preserve
|
||||
|
||||
1. **Input port generation** - The expression node dynamically creates input ports for referenced variables. This behavior should be preserved for explicit inputs while also supporting implicit Noodl.Variables access.
|
||||
|
||||
2. **Scheduling** - Use `scheduleAfterInputsHaveUpdated` pattern for batching evaluations.
|
||||
|
||||
3. **Caching** - The existing `cachedValue` pattern prevents unnecessary output updates.
|
||||
|
||||
### Edge Cases to Handle
|
||||
|
||||
1. **Circular dependencies** - What if Variable A's expression references Variable B and vice versa?
|
||||
2. **Missing variables** - Handle gracefully when referenced variable doesn't exist
|
||||
3. **Type coercion** - Be consistent with JavaScript's type coercion rules
|
||||
4. **Async expressions** - Current system is sync-only, keep it that way
|
||||
|
||||
### Questions to Resolve During Implementation
|
||||
|
||||
1. Should the shorthand `Variables.X` work without `Noodl.` prefix?
|
||||
- **Recommendation:** Yes, add to preamble for convenience
|
||||
|
||||
2. Should we detect unused input ports and warn?
|
||||
- **Recommendation:** Not in this phase
|
||||
|
||||
3. How to handle expressions that error at runtime?
|
||||
- **Recommendation:** Return undefined, log error, don't crash
|
||||
@@ -0,0 +1,960 @@
|
||||
# TASK: Inline Expression Properties in Property Panel
|
||||
|
||||
## Overview
|
||||
|
||||
Add the ability to toggle any node property between "fixed value" and "expression mode" directly in the property panel - similar to n8n's approach. When in expression mode, users can write JavaScript expressions that evaluate at runtime with full access to Noodl globals.
|
||||
|
||||
**Estimated effort:** 3-4 weeks
|
||||
**Priority:** High - Major UX modernization
|
||||
**Dependencies:** Phase 1 (Enhanced Expression Node) must be complete
|
||||
|
||||
---
|
||||
|
||||
## Background & Motivation
|
||||
|
||||
### The Problem Today
|
||||
|
||||
To make any property dynamic in Noodl, users must:
|
||||
1. Create a separate Expression, Variable, or Function node
|
||||
2. Configure that node with the logic
|
||||
3. Draw a connection cable to the target property
|
||||
4. Repeat for every dynamic value
|
||||
|
||||
**Result:** Canvas cluttered with helper nodes, hard to understand data flow.
|
||||
|
||||
### The Solution
|
||||
|
||||
Every property input gains a toggle between:
|
||||
- **Fixed Mode** (default): Traditional static value editing
|
||||
- **Expression Mode**: JavaScript expression evaluated at runtime
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Margin Left │
|
||||
│ ┌────────┬────────────────────────────────────────────┬───┐ │
|
||||
│ │ Fixed │ 16 │ ⚡ │ │
|
||||
│ └────────┴────────────────────────────────────────────┴───┘ │
|
||||
│ │
|
||||
│ After clicking ⚡ toggle: │
|
||||
│ │
|
||||
│ ┌────────┬────────────────────────────────────────────┬───┐ │
|
||||
│ │ fx │ Noodl.Variables.isMobile ? 8 : 16 │ ⚡ │ │
|
||||
│ └────────┴────────────────────────────────────────────┴───┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Analyze First
|
||||
|
||||
### Phase 1 Foundation (must be complete)
|
||||
```
|
||||
@packages/noodl-runtime/src/expression-evaluator.js
|
||||
```
|
||||
- Expression compilation and evaluation
|
||||
- Dependency detection
|
||||
- Change subscription
|
||||
|
||||
### Property Panel Architecture
|
||||
```
|
||||
@packages/noodl-core-ui/src/components/property-panel/PropertyPanelInput/PropertyPanelInput.tsx
|
||||
@packages/noodl-core-ui/src/components/property-panel/README.md
|
||||
@packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts
|
||||
@packages/noodl-editor/src/editor/src/views/panels/propertyeditor/index.tsx
|
||||
```
|
||||
- Property panel component structure
|
||||
- How different property types are rendered
|
||||
- Property value flow from model to UI and back
|
||||
|
||||
### Type-Specific Editors
|
||||
```
|
||||
@packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts
|
||||
@packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BooleanType.ts
|
||||
@packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ColorType.ts
|
||||
@packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/VariableType.ts
|
||||
```
|
||||
- Pattern for different input types
|
||||
- How values are stored and retrieved
|
||||
|
||||
### Node Model & Parameters
|
||||
```
|
||||
@packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts
|
||||
@packages/noodl-runtime/src/node.js
|
||||
```
|
||||
- How parameters are stored
|
||||
- Parameter update events
|
||||
- Visual state parameter patterns (`paramName_stateName`)
|
||||
|
||||
### Port/Connection System
|
||||
```
|
||||
@packages/noodl-editor/src/editor/src/models/nodelibrary/nodelibrary.ts
|
||||
```
|
||||
- Port type definitions
|
||||
- Connection state detection (`isConnected`)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Extend Parameter Storage Model
|
||||
|
||||
Parameters need to support both simple values and expression metadata.
|
||||
|
||||
**Modify:** Node model parameter handling
|
||||
|
||||
```typescript
|
||||
// New parameter value types
|
||||
interface FixedParameter {
|
||||
value: any;
|
||||
}
|
||||
|
||||
interface ExpressionParameter {
|
||||
mode: 'expression';
|
||||
expression: string;
|
||||
fallback?: any; // Value to use if expression errors
|
||||
version?: number; // Expression system version for migration
|
||||
}
|
||||
|
||||
type ParameterValue = any | ExpressionParameter;
|
||||
|
||||
// Helper to check if parameter is expression
|
||||
function isExpressionParameter(param: any): param is ExpressionParameter {
|
||||
return param && typeof param === 'object' && param.mode === 'expression';
|
||||
}
|
||||
|
||||
// Helper to get display value
|
||||
function getParameterDisplayValue(param: ParameterValue): any {
|
||||
if (isExpressionParameter(param)) {
|
||||
return param.expression;
|
||||
}
|
||||
return param;
|
||||
}
|
||||
```
|
||||
|
||||
**Ensure backward compatibility:**
|
||||
- Simple values (strings, numbers, etc.) continue to work as-is
|
||||
- Expression parameters are objects with `mode: 'expression'`
|
||||
- Serialization/deserialization handles both formats
|
||||
|
||||
### Step 2: Create Expression Toggle Component
|
||||
|
||||
**Create file:** `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.tsx`
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { IconButton, IconButtonVariant } from '../../inputs/IconButton';
|
||||
import { IconName, IconSize } from '../../common/Icon';
|
||||
import { Tooltip } from '../../popups/Tooltip';
|
||||
import css from './ExpressionToggle.module.scss';
|
||||
|
||||
export interface ExpressionToggleProps {
|
||||
mode: 'fixed' | 'expression';
|
||||
isConnected?: boolean; // Port has cable connection
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExpressionToggle({
|
||||
mode,
|
||||
isConnected,
|
||||
onToggle,
|
||||
disabled
|
||||
}: ExpressionToggleProps) {
|
||||
// If connected via cable, show connection indicator instead
|
||||
if (isConnected) {
|
||||
return (
|
||||
<Tooltip content="Connected via cable">
|
||||
<div className={css.connectionIndicator}>
|
||||
<Icon name={IconName.Connection} size={IconSize.Tiny} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipText = mode === 'expression'
|
||||
? 'Switch to fixed value'
|
||||
: 'Switch to expression';
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipText}>
|
||||
<IconButton
|
||||
icon={mode === 'expression' ? IconName.Function : IconName.Lightning}
|
||||
size={IconSize.Tiny}
|
||||
variant={mode === 'expression'
|
||||
? IconButtonVariant.Active
|
||||
: IconButtonVariant.OpaqueOnHover}
|
||||
onClick={onToggle}
|
||||
isDisabled={disabled}
|
||||
UNSAFE_className={mode === 'expression' ? css.expressionActive : undefined}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Create file:** `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.module.scss`
|
||||
|
||||
```scss
|
||||
.connectionIndicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.expressionActive {
|
||||
background-color: var(--theme-color-expression-bg, #6366f1);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-expression-bg-hover, #4f46e5);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create Expression Input Component
|
||||
|
||||
**Create file:** `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.tsx`
|
||||
|
||||
```tsx
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { TextInput, TextInputVariant } from '../../inputs/TextInput';
|
||||
import { IconButton } from '../../inputs/IconButton';
|
||||
import { IconName, IconSize } from '../../common/Icon';
|
||||
import { Tooltip } from '../../popups/Tooltip';
|
||||
import css from './ExpressionInput.module.scss';
|
||||
|
||||
export interface ExpressionInputProps {
|
||||
expression: string;
|
||||
onChange: (expression: string) => void;
|
||||
onOpenBuilder?: () => void;
|
||||
expectedType?: string; // 'string', 'number', 'boolean', 'color'
|
||||
hasError?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function ExpressionInput({
|
||||
expression,
|
||||
onChange,
|
||||
onOpenBuilder,
|
||||
expectedType,
|
||||
hasError,
|
||||
errorMessage
|
||||
}: ExpressionInputProps) {
|
||||
const [localValue, setLocalValue] = useState(expression);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (localValue !== expression) {
|
||||
onChange(localValue);
|
||||
}
|
||||
}, [localValue, expression, onChange]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
onChange(localValue);
|
||||
}
|
||||
}, [localValue, onChange]);
|
||||
|
||||
return (
|
||||
<div className={css.container}>
|
||||
<span className={css.badge}>fx</span>
|
||||
<TextInput
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
variant={TextInputVariant.Transparent}
|
||||
placeholder="Enter expression..."
|
||||
UNSAFE_style={{ fontFamily: 'monospace', fontSize: '12px' }}
|
||||
UNSAFE_className={hasError ? css.hasError : undefined}
|
||||
/>
|
||||
{onOpenBuilder && (
|
||||
<Tooltip content="Open expression builder (Cmd+Shift+E)">
|
||||
<IconButton
|
||||
icon={IconName.Expand}
|
||||
size={IconSize.Tiny}
|
||||
onClick={onOpenBuilder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasError && errorMessage && (
|
||||
<Tooltip content={errorMessage}>
|
||||
<div className={css.errorIndicator}>!</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Create file:** `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.module.scss`
|
||||
|
||||
```scss
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background-color: var(--theme-color-expression-input-bg, rgba(99, 102, 241, 0.1));
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--theme-color-expression-border, rgba(99, 102, 241, 0.3));
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-expression-badge, #6366f1);
|
||||
padding: 2px 4px;
|
||||
background-color: var(--theme-color-expression-badge-bg, rgba(99, 102, 241, 0.2));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hasError {
|
||||
border-color: var(--theme-color-error, #ef4444);
|
||||
}
|
||||
|
||||
.errorIndicator {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--theme-color-error, #ef4444);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Integrate with PropertyPanelInput
|
||||
|
||||
**Modify:** `packages/noodl-core-ui/src/components/property-panel/PropertyPanelInput/PropertyPanelInput.tsx`
|
||||
|
||||
```tsx
|
||||
// Add to imports
|
||||
import { ExpressionToggle } from '../ExpressionToggle';
|
||||
import { ExpressionInput } from '../ExpressionInput';
|
||||
|
||||
// Extend props interface
|
||||
export interface PropertyPanelInputProps extends Omit<PropertyPanelBaseInputProps, 'type'> {
|
||||
label: string;
|
||||
inputType: PropertyPanelInputType;
|
||||
properties: TSFixme;
|
||||
|
||||
// Expression support (new)
|
||||
supportsExpression?: boolean; // Default true for most types
|
||||
expressionMode?: 'fixed' | 'expression';
|
||||
expression?: string;
|
||||
isConnected?: boolean;
|
||||
onExpressionModeChange?: (mode: 'fixed' | 'expression') => void;
|
||||
onExpressionChange?: (expression: string) => void;
|
||||
}
|
||||
|
||||
export function PropertyPanelInput({
|
||||
label,
|
||||
value,
|
||||
inputType = PropertyPanelInputType.Text,
|
||||
properties,
|
||||
isChanged,
|
||||
isConnected,
|
||||
onChange,
|
||||
// Expression props
|
||||
supportsExpression = true,
|
||||
expressionMode = 'fixed',
|
||||
expression,
|
||||
onExpressionModeChange,
|
||||
onExpressionChange
|
||||
}: PropertyPanelInputProps) {
|
||||
|
||||
// Determine if we should show expression UI
|
||||
const showExpressionToggle = supportsExpression && !isConnected;
|
||||
const isExpressionMode = expressionMode === 'expression';
|
||||
|
||||
// Handle mode toggle
|
||||
const handleToggleMode = () => {
|
||||
if (onExpressionModeChange) {
|
||||
onExpressionModeChange(isExpressionMode ? 'fixed' : 'expression');
|
||||
}
|
||||
};
|
||||
|
||||
// Render expression input or standard input
|
||||
const renderInput = () => {
|
||||
if (isExpressionMode && onExpressionChange) {
|
||||
return (
|
||||
<ExpressionInput
|
||||
expression={expression || ''}
|
||||
onChange={onExpressionChange}
|
||||
expectedType={inputType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Standard input rendering (existing code)
|
||||
const Input = useMemo(() => {
|
||||
switch (inputType) {
|
||||
case PropertyPanelInputType.Text:
|
||||
return PropertyPanelTextInput;
|
||||
// ... rest of existing switch
|
||||
}
|
||||
}, [inputType]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
properties={properties}
|
||||
isChanged={isChanged}
|
||||
isConnected={isConnected}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css.container}>
|
||||
<label className={css.label}>{label}</label>
|
||||
<div className={css.inputRow}>
|
||||
{renderInput()}
|
||||
{showExpressionToggle && (
|
||||
<ExpressionToggle
|
||||
mode={expressionMode}
|
||||
isConnected={isConnected}
|
||||
onToggle={handleToggleMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Wire Up to Property Editor
|
||||
|
||||
**Modify:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts`
|
||||
|
||||
This is where the connection between the model and property panel happens. Add expression support:
|
||||
|
||||
```typescript
|
||||
// In the render or value handling logic:
|
||||
|
||||
// Check if current parameter value is an expression
|
||||
const paramValue = parent.model.parameters[port.name];
|
||||
const isExpressionMode = isExpressionParameter(paramValue);
|
||||
|
||||
// When mode changes:
|
||||
onExpressionModeChange(mode) {
|
||||
if (mode === 'expression') {
|
||||
// Convert current value to expression parameter
|
||||
const currentValue = parent.model.parameters[port.name];
|
||||
parent.model.setParameter(port.name, {
|
||||
mode: 'expression',
|
||||
expression: String(currentValue || ''),
|
||||
fallback: currentValue,
|
||||
version: ExpressionEvaluator.EXPRESSION_VERSION
|
||||
});
|
||||
} else {
|
||||
// Convert back to fixed value
|
||||
const param = parent.model.parameters[port.name];
|
||||
const fixedValue = isExpressionParameter(param) ? param.fallback : param;
|
||||
parent.model.setParameter(port.name, fixedValue);
|
||||
}
|
||||
}
|
||||
|
||||
// When expression changes:
|
||||
onExpressionChange(expression) {
|
||||
const param = parent.model.parameters[port.name];
|
||||
if (isExpressionParameter(param)) {
|
||||
parent.model.setParameter(port.name, {
|
||||
...param,
|
||||
expression
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Runtime Expression Evaluation
|
||||
|
||||
**Modify:** `packages/noodl-runtime/src/node.js`
|
||||
|
||||
Add expression evaluation to the parameter update flow:
|
||||
|
||||
```javascript
|
||||
// In Node.prototype._onNodeModelParameterUpdated or similar:
|
||||
|
||||
Node.prototype._evaluateExpressionParameter = function(paramName, paramValue) {
|
||||
const ExpressionEvaluator = require('./expression-evaluator');
|
||||
|
||||
if (!paramValue || paramValue.mode !== 'expression') {
|
||||
return paramValue;
|
||||
}
|
||||
|
||||
// Compile and evaluate
|
||||
const compiled = ExpressionEvaluator.compileExpression(paramValue.expression);
|
||||
if (!compiled) {
|
||||
return paramValue.fallback;
|
||||
}
|
||||
|
||||
const result = ExpressionEvaluator.evaluateExpression(compiled, this.context?.modelScope);
|
||||
|
||||
// Set up reactive subscription if not already
|
||||
if (!this._expressionSubscriptions) {
|
||||
this._expressionSubscriptions = {};
|
||||
}
|
||||
|
||||
if (!this._expressionSubscriptions[paramName]) {
|
||||
const deps = ExpressionEvaluator.detectDependencies(paramValue.expression);
|
||||
if (deps.variables.length > 0 || deps.objects.length > 0 || deps.arrays.length > 0) {
|
||||
this._expressionSubscriptions[paramName] = ExpressionEvaluator.subscribeToChanges(
|
||||
deps,
|
||||
() => {
|
||||
// Re-evaluate and update
|
||||
const newResult = ExpressionEvaluator.evaluateExpression(compiled, this.context?.modelScope);
|
||||
this.queueInput(paramName, newResult);
|
||||
},
|
||||
this.context?.modelScope
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result !== undefined ? result : paramValue.fallback;
|
||||
};
|
||||
|
||||
// Clean up subscriptions on delete
|
||||
Node.prototype._onNodeDeleted = function() {
|
||||
// ... existing cleanup ...
|
||||
|
||||
// Clean up expression subscriptions
|
||||
if (this._expressionSubscriptions) {
|
||||
for (const unsub of Object.values(this._expressionSubscriptions)) {
|
||||
if (typeof unsub === 'function') unsub();
|
||||
}
|
||||
this._expressionSubscriptions = null;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Step 7: Expression Builder Modal (Optional Enhancement)
|
||||
|
||||
**Create file:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionBuilder/ExpressionBuilder.tsx`
|
||||
|
||||
A full-featured modal for complex expression editing:
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Modal } from '@noodl-core-ui/components/layout/Modal';
|
||||
import { MonacoEditor } from '@noodl-core-ui/components/inputs/MonacoEditor';
|
||||
import { TreeView } from '@noodl-core-ui/components/tree/TreeView';
|
||||
import css from './ExpressionBuilder.module.scss';
|
||||
|
||||
interface ExpressionBuilderProps {
|
||||
isOpen: boolean;
|
||||
expression: string;
|
||||
expectedType?: string;
|
||||
onApply: (expression: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ExpressionBuilder({
|
||||
isOpen,
|
||||
expression: initialExpression,
|
||||
expectedType,
|
||||
onApply,
|
||||
onCancel
|
||||
}: ExpressionBuilderProps) {
|
||||
const [expression, setExpression] = useState(initialExpression);
|
||||
const [preview, setPreview] = useState<{ result: any; error?: string }>({ result: null });
|
||||
|
||||
// Build available completions tree
|
||||
const completionsTree = useMemo(() => {
|
||||
// This would be populated from actual project data
|
||||
return [
|
||||
{
|
||||
label: 'Noodl',
|
||||
children: [
|
||||
{
|
||||
label: 'Variables',
|
||||
children: [] // Populated from Noodl.Variables
|
||||
},
|
||||
{
|
||||
label: 'Objects',
|
||||
children: [] // Populated from known Object IDs
|
||||
},
|
||||
{
|
||||
label: 'Arrays',
|
||||
children: [] // Populated from known Array IDs
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Math',
|
||||
children: [
|
||||
{ label: 'min(a, b)', insertText: 'min()' },
|
||||
{ label: 'max(a, b)', insertText: 'max()' },
|
||||
{ label: 'round(n)', insertText: 'round()' },
|
||||
{ label: 'floor(n)', insertText: 'floor()' },
|
||||
{ label: 'ceil(n)', insertText: 'ceil()' },
|
||||
{ label: 'abs(n)', insertText: 'abs()' },
|
||||
{ label: 'sqrt(n)', insertText: 'sqrt()' },
|
||||
{ label: 'pow(base, exp)', insertText: 'pow()' },
|
||||
{ label: 'pi', insertText: 'pi' },
|
||||
{ label: 'random()', insertText: 'random()' }
|
||||
]
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
// Live preview
|
||||
useEffect(() => {
|
||||
const ExpressionEvaluator = require('@noodl/runtime/src/expression-evaluator');
|
||||
const validation = ExpressionEvaluator.validateExpression(expression);
|
||||
|
||||
if (!validation.valid) {
|
||||
setPreview({ result: null, error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const compiled = ExpressionEvaluator.compileExpression(expression);
|
||||
if (compiled) {
|
||||
const result = ExpressionEvaluator.evaluateExpression(compiled);
|
||||
setPreview({ result, error: undefined });
|
||||
}
|
||||
}, [expression]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onCancel}
|
||||
title="Expression Builder"
|
||||
size="large"
|
||||
>
|
||||
<div className={css.container}>
|
||||
<div className={css.editor}>
|
||||
<MonacoEditor
|
||||
value={expression}
|
||||
onChange={setExpression}
|
||||
language="javascript"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'off',
|
||||
fontSize: 14,
|
||||
wordWrap: 'on'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={css.sidebar}>
|
||||
<div className={css.completions}>
|
||||
<h4>Available</h4>
|
||||
<TreeView
|
||||
items={completionsTree}
|
||||
onItemClick={(item) => {
|
||||
// Insert at cursor
|
||||
if (item.insertText) {
|
||||
setExpression(prev => prev + item.insertText);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={css.preview}>
|
||||
<h4>Preview</h4>
|
||||
{preview.error ? (
|
||||
<div className={css.error}>{preview.error}</div>
|
||||
) : (
|
||||
<div className={css.result}>
|
||||
<div className={css.resultLabel}>Result:</div>
|
||||
<div className={css.resultValue}>
|
||||
{JSON.stringify(preview.result)}
|
||||
</div>
|
||||
<div className={css.resultType}>
|
||||
Type: {typeof preview.result}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css.actions}>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
<button onClick={() => onApply(expression)}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: Add Keyboard Shortcuts
|
||||
|
||||
**Modify:** `packages/noodl-editor/src/editor/src/constants/Keybindings.ts`
|
||||
|
||||
```typescript
|
||||
export namespace Keybindings {
|
||||
// ... existing keybindings ...
|
||||
|
||||
// Expression shortcuts (new)
|
||||
export const TOGGLE_EXPRESSION_MODE = new Keybinding(KeyMod.CtrlCmd, KeyCode.KEY_E);
|
||||
export const OPEN_EXPRESSION_BUILDER = new Keybinding(KeyMod.CtrlCmd, KeyMod.Shift, KeyCode.KEY_E);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 9: Handle Property Types
|
||||
|
||||
Different property types need type-appropriate expression handling:
|
||||
|
||||
| Property Type | Expression Returns | Coercion |
|
||||
|--------------|-------------------|----------|
|
||||
| `string` | Any → String | `String(result)` |
|
||||
| `number` | Number | `Number(result) \|\| fallback` |
|
||||
| `boolean` | Truthy/Falsy | `!!result` |
|
||||
| `color` | Hex/RGB string | Validate format |
|
||||
| `enum` | Enum value string | Validate against options |
|
||||
| `component` | Component name | Validate exists |
|
||||
|
||||
**Create file:** `packages/noodl-runtime/src/expression-type-coercion.js`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Coerce expression result to expected property type
|
||||
*/
|
||||
function coerceToType(value, expectedType, fallback, enumOptions) {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
switch (expectedType) {
|
||||
case 'string':
|
||||
return String(value);
|
||||
|
||||
case 'number':
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? fallback : num;
|
||||
|
||||
case 'boolean':
|
||||
return !!value;
|
||||
|
||||
case 'color':
|
||||
const str = String(value);
|
||||
// Basic validation for hex or rgb
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(str) || /^rgba?\(/.test(str)) {
|
||||
return str;
|
||||
}
|
||||
return fallback;
|
||||
|
||||
case 'enum':
|
||||
const enumVal = String(value);
|
||||
if (enumOptions && enumOptions.some(opt =>
|
||||
opt === enumVal || opt.value === enumVal
|
||||
)) {
|
||||
return enumVal;
|
||||
}
|
||||
return fallback;
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { coerceToType };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [ ] Expression toggle button appears on supported property types
|
||||
- [ ] Toggle switches between fixed and expression modes
|
||||
- [ ] Expression mode shows `fx` badge and code-style input
|
||||
- [ ] Expression evaluates correctly at runtime
|
||||
- [ ] Expression re-evaluates when dependencies change
|
||||
- [ ] Connected ports (via cables) disable expression mode
|
||||
- [ ] Type coercion works for each property type
|
||||
- [ ] Invalid expressions show error state
|
||||
- [ ] Copy/paste expressions works
|
||||
- [ ] Expression builder modal opens (Cmd+Shift+E)
|
||||
- [ ] Undo/redo works for expression changes
|
||||
|
||||
### Property Types Supported
|
||||
|
||||
- [ ] String (`PropertyPanelTextInput`)
|
||||
- [ ] Number (`PropertyPanelNumberInput`)
|
||||
- [ ] Number with units (`PropertyPanelLengthUnitInput`)
|
||||
- [ ] Boolean (`PropertyPanelCheckbox`)
|
||||
- [ ] Select/Enum (`PropertyPanelSelectInput`)
|
||||
- [ ] Slider (`PropertyPanelSliderInput`)
|
||||
- [ ] Color (`ColorType` / color picker)
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] No performance regression in property panel rendering
|
||||
- [ ] Expressions compile once, evaluate efficiently
|
||||
- [ ] Memory cleanup when nodes are deleted
|
||||
- [ ] Backward compatibility with existing projects
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **Basic Toggle**
|
||||
- Select a Group node
|
||||
- Find the "Margin Left" property
|
||||
- Click expression toggle button
|
||||
- Verify UI changes to expression mode
|
||||
- Toggle back to fixed mode
|
||||
- Verify original value is preserved
|
||||
|
||||
2. **Expression Evaluation**
|
||||
- Set a Group's margin to expression mode
|
||||
- Enter: `Noodl.Variables.spacing || 16`
|
||||
- Set `Noodl.Variables.spacing = 32` in a Function node
|
||||
- Verify margin updates to 32
|
||||
|
||||
3. **Reactive Updates**
|
||||
- Create expression: `Noodl.Variables.isExpanded ? 200 : 50`
|
||||
- Add button that toggles `Noodl.Variables.isExpanded`
|
||||
- Click button, verify property updates
|
||||
|
||||
4. **Connected Port Behavior**
|
||||
- Connect an output to a property input
|
||||
- Verify expression toggle is disabled/hidden
|
||||
- Disconnect
|
||||
- Verify toggle is available again
|
||||
|
||||
5. **Type Coercion**
|
||||
- Number property with expression returning string "42"
|
||||
- Verify it coerces to number 42
|
||||
- Boolean property with expression returning "yes"
|
||||
- Verify it coerces to true
|
||||
|
||||
6. **Error Handling**
|
||||
- Enter invalid expression: `1 +`
|
||||
- Verify error indicator appears
|
||||
- Verify property uses fallback value
|
||||
- Fix expression
|
||||
- Verify error clears
|
||||
|
||||
7. **Undo/Redo**
|
||||
- Change property to expression mode
|
||||
- Undo (Cmd+Z)
|
||||
- Verify returns to fixed mode
|
||||
- Redo
|
||||
- Verify returns to expression mode
|
||||
|
||||
8. **Project Save/Load**
|
||||
- Create property with expression
|
||||
- Save project
|
||||
- Close and reopen project
|
||||
- Verify expression is preserved and working
|
||||
|
||||
### Property Type Coverage
|
||||
|
||||
- [ ] Text input with expression
|
||||
- [ ] Number input with expression
|
||||
- [ ] Number with units (px, %, etc.) with expression
|
||||
- [ ] Checkbox/boolean with expression
|
||||
- [ ] Dropdown/select with expression
|
||||
- [ ] Color picker with expression
|
||||
- [ ] Slider with expression
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- [ ] Expression referencing non-existent variable
|
||||
- [ ] Expression with runtime error (division by zero)
|
||||
- [ ] Very long expression
|
||||
- [ ] Expression with special characters
|
||||
- [ ] Expression in visual state parameter
|
||||
- [ ] Expression in variant parameter
|
||||
|
||||
---
|
||||
|
||||
## Migration Considerations
|
||||
|
||||
### Existing Projects
|
||||
|
||||
- Existing projects have simple parameter values
|
||||
- These continue to work as-is (backward compatible)
|
||||
- No automatic migration needed
|
||||
|
||||
### Future Expression Version Changes
|
||||
|
||||
If we need to change the expression context in the future:
|
||||
1. Increment `EXPRESSION_VERSION` in expression-evaluator.js
|
||||
2. Add migration logic to handle old version expressions
|
||||
3. Show warning for expressions with old version
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementer
|
||||
|
||||
### Important Patterns
|
||||
|
||||
1. **Model-View Separation**
|
||||
- Property panel is the view
|
||||
- NodeGraphNode.parameters is the model
|
||||
- Changes go through `setParameter()` for undo support
|
||||
|
||||
2. **Port Connection Priority**
|
||||
- Connected ports take precedence over expressions
|
||||
- Connected ports take precedence over fixed values
|
||||
- This is existing behavior, preserve it
|
||||
|
||||
3. **Visual States**
|
||||
- Visual state parameters use `paramName_stateName` pattern
|
||||
- Expression parameters in visual states need same pattern
|
||||
- Example: `marginLeft_hover` could be an expression
|
||||
|
||||
### Edge Cases to Handle
|
||||
|
||||
1. **Expression references port that's also connected**
|
||||
- Expression should still work
|
||||
- Connected value might be available via `this.inputs.X`
|
||||
|
||||
2. **Circular expressions**
|
||||
- Expression A references Variable that's set by Expression B
|
||||
- Shouldn't cause infinite loop (dependency tracking prevents)
|
||||
|
||||
3. **Expressions in cloud runtime**
|
||||
- Cloud uses different Noodl.js API
|
||||
- Ensure expression-evaluator works in both contexts
|
||||
|
||||
### Questions to Resolve
|
||||
|
||||
1. **Which property types should NOT support expressions?**
|
||||
- Recommendation: component picker, image picker
|
||||
- These need special UI that doesn't fit expression pattern
|
||||
|
||||
2. **Should expressions work in style properties?**
|
||||
- Recommendation: Yes, if using inputCss pattern
|
||||
- CSS values often need to be dynamic
|
||||
|
||||
3. **Mobile/responsive expressions?**
|
||||
- Recommendation: Expressions can reference `Noodl.Variables.screenWidth`
|
||||
- Combine with existing variants system
|
||||
|
||||
---
|
||||
|
||||
## Files Created/Modified Summary
|
||||
|
||||
### New Files
|
||||
- `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.tsx`
|
||||
- `packages/noodl-core-ui/src/components/property-panel/ExpressionToggle/ExpressionToggle.module.scss`
|
||||
- `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.tsx`
|
||||
- `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.module.scss`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionBuilder/ExpressionBuilder.tsx`
|
||||
- `packages/noodl-runtime/src/expression-type-coercion.js`
|
||||
|
||||
### Modified Files
|
||||
- `packages/noodl-core-ui/src/components/property-panel/PropertyPanelInput/PropertyPanelInput.tsx`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BooleanType.ts`
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/ColorType.ts`
|
||||
- `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
- `packages/noodl-runtime/src/node.js`
|
||||
- `packages/noodl-editor/src/editor/src/constants/Keybindings.ts`
|
||||
@@ -143,15 +143,67 @@ function startServer(app, projectGetSettings, projectGetInfo, projectGetComponen
|
||||
//by this point it must be a static file in either the viewer folder or the project
|
||||
//check if it's a viewer file
|
||||
const viewerFilePath = appPath + '/src/external/viewer/' + path;
|
||||
|
||||
// Debug: Log ALL font requests regardless of where they're served from
|
||||
const fontExtensions = ['.ttf', '.otf', '.woff', '.woff2'];
|
||||
const ext = (path.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
||||
const isFont = fontExtensions.includes(ext);
|
||||
|
||||
if (isFont) {
|
||||
console.log(`\n======= FONT REQUEST =======`);
|
||||
console.log(`[Font] Requested path: ${path}`);
|
||||
console.log(`[Font] Checking viewer path: ${viewerFilePath}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(viewerFilePath)) {
|
||||
if (isFont) console.log(`[Font] SERVED from viewer folder`);
|
||||
serveFile(viewerFilePath, request, response);
|
||||
} else {
|
||||
// Check if file exists in project directory
|
||||
projectGetInfo((info) => {
|
||||
const projectPath = info.projectDirectory + path;
|
||||
|
||||
if (isFont) {
|
||||
console.log(`[Font] Project dir: ${info.projectDirectory}`);
|
||||
console.log(`[Font] Checking project path: ${projectPath}`);
|
||||
console.log(`[Font] Exists at project path: ${fs.existsSync(projectPath)}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(projectPath)) {
|
||||
if (isFont) console.log(`[Font] SERVED from project folder`);
|
||||
serveFile(projectPath, request, response);
|
||||
} else {
|
||||
// For fonts, try common fallback locations
|
||||
// Legacy projects may store fonts without folder prefix, or in different locations
|
||||
const fontExtensions = ['.ttf', '.otf', '.woff', '.woff2'];
|
||||
const ext = (path.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
||||
|
||||
if (fontExtensions.includes(ext)) {
|
||||
console.log(`[Font Debug] Request: ${path}`);
|
||||
console.log(`[Font Debug] Project dir: ${info.projectDirectory}`);
|
||||
console.log(`[Font Debug] Primary path NOT found: ${projectPath}`);
|
||||
|
||||
const filename = path.split('/').pop();
|
||||
const fallbackPaths = [
|
||||
info.projectDirectory + '/fonts' + path, // /fonts/filename.ttf
|
||||
info.projectDirectory + '/fonts/' + filename, // /fonts/filename.ttf (when path has no subfolder)
|
||||
info.projectDirectory + '/' + filename, // /filename.ttf (root level)
|
||||
info.projectDirectory + '/assets/fonts/' + filename // /assets/fonts/filename.ttf
|
||||
];
|
||||
|
||||
console.log(`[Font Debug] Trying fallback paths:`);
|
||||
for (const fallbackPath of fallbackPaths) {
|
||||
const exists = fs.existsSync(fallbackPath);
|
||||
console.log(`[Font Debug] ${exists ? '✓' : '✗'} ${fallbackPath}`);
|
||||
if (exists) {
|
||||
console.log(`[Font Debug] SUCCESS - serving from fallback`);
|
||||
serveFile(fallbackPath, request, response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log(`[Font Debug] FAILED - no fallback found`);
|
||||
}
|
||||
|
||||
serve404(response);
|
||||
}
|
||||
});
|
||||
@@ -184,7 +236,7 @@ function startServer(app, projectGetSettings, projectGetInfo, projectGetComponen
|
||||
});
|
||||
});
|
||||
|
||||
server.on('listening', (e) => {
|
||||
server.on('listening', () => {
|
||||
console.log('webserver hustling bytes on port', port);
|
||||
process.env.NOODLPORT = port;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user