Finished prototype local backends and expression editor

This commit is contained in:
Richard Osborne
2026-01-16 12:00:31 +01:00
parent 94c870e5d7
commit 32a0a0885f
48 changed files with 8513 additions and 108 deletions

View File

@@ -123,6 +123,7 @@ packages/
- `dev-docs/reference/NODE-PATTERNS.md` - How to create/modify nodes - `dev-docs/reference/NODE-PATTERNS.md` - How to create/modify nodes
- `dev-docs/reference/LEARNINGS.md` - Accumulated knowledge - `dev-docs/reference/LEARNINGS.md` - Accumulated knowledge
- `dev-docs/reference/UI-STYLING-GUIDE.md` - Styling rules (NO hardcoded colors!) - `dev-docs/reference/UI-STYLING-GUIDE.md` - Styling rules (NO hardcoded colors!)
- `dev-docs/reference/PANEL-UI-STYLE-GUIDE.md` - **Panels & Modals (READ BEFORE making UI!)**
- `dev-docs/reference/LEARNINGS-NODE-CREATION.md` - Node creation gotchas - `dev-docs/reference/LEARNINGS-NODE-CREATION.md` - Node creation gotchas
--- ---
@@ -418,6 +419,20 @@ Before marking any task complete:
- [ ] **Discoveries added to LEARNINGS.md or COMMON-ISSUES.md** - [ ] **Discoveries added to LEARNINGS.md or COMMON-ISSUES.md**
- [ ] Task CHANGELOG.md updated with progress - [ ] Task CHANGELOG.md updated with progress
### Visual Components (Panels, Modals, Forms)
**MUST read `dev-docs/reference/PANEL-UI-STYLE-GUIDE.md` before building UI!**
- [ ] NO emojis in buttons, labels, or headers
- [ ] Using CSS variables for ALL colors (`var(--theme-color-*)`)
- [ ] Using `Text` component with proper `textType` for typography
- [ ] Using `PrimaryButton` with correct variant (Cta/Muted/Ghost/Danger)
- [ ] Panel structure: Header → Toolbar → Content → Footer
- [ ] Modal structure: Overlay → Modal → Header → Body → Footer
- [ ] Form inputs styled with proper tokens (bg-1, bg-3 borders)
- [ ] Empty states, loading states, and error states handled
- [ ] Dark theme first - ensure contrast with light text
### Git ### Git
- [ ] Meaningful commit messages (conventional commits format) - [ ] Meaningful commit messages (conventional commits format)

View File

@@ -10,6 +10,95 @@ These fundamental patterns apply across ALL Noodl development. Understanding the
--- ---
## 📊 Property Expression Runtime Context (Jan 16, 2026)
### The Context Trap: Why evaluateExpression() Needs undefined, Not this.context
**Context**: TASK-006 Expressions Overhaul - Property expressions in the properties panel weren't evaluating. Error: "scope.get is not a function".
**The Problem**: The `evaluateExpression()` function in `expression-evaluator.js` expects either `undefined` (to use global Model) or a Model scope object. Passing `this.context` (the runtime node context with editorConnection, styles, etc.) caused the error because it lacks a `.get()` method.
**The Broken Pattern**:
```javascript
// ❌ WRONG - this.context is NOT a Model scope
Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
const compiled = compileExpression(paramValue.expression);
const result = evaluateExpression(compiled, this.context); // ☠️ scope.get is not a function
};
```
**The Correct Pattern**:
```javascript
// ✅ RIGHT - Pass undefined to use global Model
Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
const compiled = compileExpression(paramValue.expression);
const result = evaluateExpression(compiled, undefined); // ✅ Uses global Model
};
```
**Why This Works**:
The expression-evaluator's `createNoodlContext()` function does:
```javascript
const scope = modelScope || Model; // Falls back to global Model
const variablesModel = scope.get('--ndl--global-variables');
```
When `undefined` is passed, it uses the global `Model` which has the `.get()` method. The runtime's `this.context` is a completely different object containing `editorConnection`, `styles`, etc.
**Reactive Expression Subscriptions**:
To make expressions update when Variables change, the expression-evaluator already has:
- `detectDependencies(expression)` - finds what Variables/Objects/Arrays are referenced
- `subscribeToChanges(dependencies, callback)` - subscribes to Model changes
Wire these up in `_evaluateExpressionParameter`:
```javascript
// Set up reactive subscription
if (!this._expressionSubscriptions[portName]) {
const dependencies = detectDependencies(paramValue.expression);
if (dependencies.variables.length > 0) {
this._expressionSubscriptions[portName] = subscribeToChanges(
dependencies,
function () {
if (this._deleted) return;
this.queueInput(portName, paramValue); // Re-evaluate
}.bind(this)
);
}
}
```
**Critical Rules**:
1. **NEVER** pass `this.context` to `evaluateExpression()` - it's not a Model scope
2. **ALWAYS** pass `undefined` to use the global Model for Variables/Objects/Arrays
3. **Clean up subscriptions** in `_onNodeDeleted()` to prevent memory leaks
4. **Track subscriptions by port name** to avoid duplicate listeners
**Applies To**:
- Property panel expression fields
- Any runtime expression evaluation
- Future expression support in other property types
**Time Saved**: This pattern prevents 1-2 hours debugging "scope.get is not a function" errors.
**Location**:
- Fixed in: `packages/noodl-runtime/src/node.js`
- Expression evaluator: `packages/noodl-runtime/src/expression-evaluator.js`
- Task: Phase 3 TASK-006 Expressions Overhaul
**Keywords**: expression, evaluateExpression, this.context, Model scope, scope.get, Variables, reactive, subscribeToChanges, detectDependencies
---
## 🔴 Editor/Runtime Window Separation (Jan 2026) ## 🔴 Editor/Runtime Window Separation (Jan 2026)
### The Invisible Boundary: Why Editor Methods Don't Exist in Runtime ### The Invisible Boundary: Why Editor Methods Don't Exist in Runtime

View File

@@ -0,0 +1,511 @@
# Panel & Modal UI Style Guide
This guide documents the visual patterns used in OpenNoodl's editor panels and modals. **Always follow these patterns when creating new UI components.**
---
## Core Principles
### 1. Professional, Not Playful
- **NO emojis** in UI labels, buttons, or headers
- **NO decorative icons** unless they serve a functional purpose
- Clean, minimal aesthetic that respects the user's intelligence
- Think "developer tool" not "consumer app"
### 2. Consistent Visual Language
- Use design tokens (CSS variables) for ALL colors
- Consistent spacing using the spacing system (4px base unit)
- Typography hierarchy using the Text component types
- All interactive elements must have hover/active states
### 3. Dark Theme First
- Design for dark backgrounds (`--theme-color-bg-2`, `--theme-color-bg-3`)
- Ensure sufficient contrast with light text
- Colored elements should be muted, not neon
---
## Panel Structure
### Standard Panel Layout
```tsx
<div className={css.Root}>
{/* Header with title and close button */}
<div className={css.Header}>
<HStack hasSpacing>
<Icon icon={IconName.Something} size={IconSize.Small} />
<VStack>
<Text textType={TextType.DefaultContrast}>Panel Title</Text>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
Subtitle or context
</Text>
</VStack>
</HStack>
<IconButton icon={IconName.Close} onClick={onClose} />
</div>
{/* Toolbar with actions */}
<div className={css.Toolbar}>{/* Filters, search, action buttons */}</div>
{/* Content area (scrollable) */}
<div className={css.Content}>{/* Main panel content */}</div>
{/* Footer (optional - pagination, status) */}
<div className={css.Footer}>{/* Page controls, counts */}</div>
</div>
```
### Panel CSS Pattern
```scss
.Root {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
}
.Header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--theme-color-bg-3);
flex-shrink: 0;
}
.Toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background-color: var(--theme-color-bg-3);
gap: 8px;
flex-shrink: 0;
}
.Content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.Footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-top: 1px solid var(--theme-color-bg-3);
background-color: var(--theme-color-bg-2);
flex-shrink: 0;
}
```
---
## Modal Structure
### Standard Modal Layout
```tsx
<div className={css.Overlay}>
<div className={css.Modal}>
{/* Header */}
<div className={css.Header}>
<Text textType={TextType.Proud}>Modal Title</Text>
<IconButton icon={IconName.Close} onClick={onClose} />
</div>
{/* Body */}
<div className={css.Body}>{/* Form fields, content */}</div>
{/* Footer with actions */}
<div className={css.Footer}>
<PrimaryButton label="Cancel" variant={PrimaryButtonVariant.Muted} onClick={onClose} />
<PrimaryButton label="Create" variant={PrimaryButtonVariant.Cta} onClick={onSubmit} />
</div>
</div>
</div>
```
### Modal CSS Pattern
```scss
.Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.Modal {
background-color: var(--theme-color-bg-2);
border-radius: 8px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.Header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--theme-color-bg-3);
}
.Body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.Footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--theme-color-bg-3);
}
```
---
## Form Elements
### Text Inputs
```scss
.Input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--theme-color-bg-3);
border-radius: 4px;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
font-size: 13px;
font-family: inherit;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
}
```
### Select Dropdowns
```scss
.Select {
padding: 8px 12px;
border: 1px solid var(--theme-color-bg-3);
border-radius: 4px;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
font-size: 13px;
min-width: 120px;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
```
### Form Groups
```scss
.FormGroup {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.Label {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 500;
color: var(--theme-color-fg-default);
}
.HelpText {
margin-top: 4px;
font-size: 11px;
color: var(--theme-color-fg-default-shy);
}
```
---
## Data Tables & Grids
### Table Pattern
```scss
.Grid {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th {
text-align: left;
padding: 10px 12px;
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--theme-color-fg-default-shy);
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-bg-3);
position: sticky;
top: 0;
z-index: 1;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
}
tr:hover td {
background-color: var(--theme-color-bg-3);
}
}
```
### Type Badges
For showing data types, use subtle colored badges:
```scss
.TypeBadge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
color: white;
}
// Use semantic colors, not hardcoded
.TypeString {
background-color: var(--theme-color-primary);
}
.TypeNumber {
background-color: var(--theme-color-success);
}
.TypeBoolean {
background-color: var(--theme-color-notice);
}
.TypeDate {
background-color: #8b5cf6;
} // Purple - no token available
.TypePointer {
background-color: var(--theme-color-danger);
}
```
---
## Expandable Rows
For tree-like or expandable content:
```scss
.ExpandableRow {
border: 1px solid var(--theme-color-bg-3);
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
background-color: var(--theme-color-bg-2);
&[data-expanded='true'] {
border-color: var(--theme-color-primary);
}
}
.RowHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: var(--theme-color-bg-3);
}
}
.RowContent {
padding: 0 16px 16px;
background-color: var(--theme-color-bg-1);
border-top: 1px solid var(--theme-color-bg-3);
}
.ExpandIcon {
transition: transform 0.2s;
color: var(--theme-color-fg-default-shy);
}
```
---
## Empty States
When there's no content to show:
```scss
.EmptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--theme-color-fg-default-shy);
.EmptyIcon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.EmptyText {
margin-bottom: 8px;
}
}
```
---
## Loading & Error States
### Loading
```scss
.Loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--theme-color-fg-default-shy);
}
```
### Error
```scss
.Error {
padding: 12px 16px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--theme-color-danger);
border-radius: 4px;
color: var(--theme-color-danger);
font-size: 13px;
}
```
---
## Button Patterns
### Use PrimaryButton Variants Correctly
| Variant | Use For |
| -------- | ------------------------------------------ |
| `Cta` | Primary action (Create, Save, Submit) |
| `Muted` | Secondary action (Cancel, Close, Refresh) |
| `Ghost` | Tertiary action (Edit, View, minor action) |
| `Danger` | Destructive action (Delete) |
### Button Sizing
- `Small` - In toolbars, table rows, compact spaces
- `Medium` - Modal footers, standalone actions
- `Large` - Rarely used, hero actions only
---
## Spacing System
Use consistent spacing based on 4px unit:
| Token | Value | Use For |
| ----- | ----- | ------------------------ |
| `xs` | 4px | Tight spacing, icon gaps |
| `sm` | 8px | Related elements |
| `md` | 12px | Standard padding |
| `lg` | 16px | Section padding |
| `xl` | 24px | Large gaps |
| `xxl` | 32px | Major sections |
---
## Typography
### Use Text Component Types
| Type | Use For |
| ----------------- | ------------------------------- |
| `Proud` | Panel titles, modal headers |
| `DefaultContrast` | Primary content, item names |
| `Default` | Body text, descriptions |
| `Shy` | Secondary text, hints, metadata |
### Font Sizes
- Headers: 14-16px
- Body: 13px
- Labels: 12px
- Small text: 11px
- Badges: 10px
---
## Don'ts
**Don't use emojis** in buttons or labels
**Don't use hardcoded colors** - always use CSS variables
**Don't use bright/neon colors** - keep it muted
**Don't use decorative icons** that don't convey meaning
**Don't use rounded corners > 8px** - keep it subtle
**Don't use shadows > 0.4 opacity** - stay subtle
**Don't use animation duration > 200ms** - keep it snappy
**Don't mix different styling approaches** - be consistent
---
## Reference Components
For working examples, see:
- `packages/noodl-editor/src/editor/src/views/panels/schemamanager/`
- `packages/noodl-editor/src/editor/src/views/panels/databrowser/`
- `packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/`
---
_Last Updated: January 2026_

View File

@@ -0,0 +1,219 @@
# Phase 2B: Inline Property Expressions - COMPLETE ✅
**Started:** 2026-01-10
**Completed:** 2026-01-16
**Status:** ✅ COMPLETE
---
## Summary
Inline property expressions are now fully functional! Users can set property values using JavaScript expressions that reference Noodl.Variables, Noodl.Objects, and Noodl.Arrays. Expressions update reactively when their dependencies change.
---
## ✅ What Was Implemented
### 1. ExpressionInput Component - COMPLETE ✅
**Files Created:**
- `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.tsx`
- `packages/noodl-core-ui/src/components/property-panel/ExpressionInput/ExpressionInput.module.scss`
**Features:**
- Monospace input field with "fx" badge
- Expression mode toggle
- Expand button to open full editor modal
- Error state display
---
### 2. ExpressionEditorModal - COMPLETE ✅
**Files Created:**
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionEditorModal/ExpressionEditorModal.tsx`
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionEditorModal/ExpressionEditorModal.module.scss`
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionEditorModal/index.ts`
**Features:**
- Full-screen modal for editing complex expressions
- Uses JavaScriptEditor (CodeMirror) for syntax highlighting
- Help documentation showing available globals (Noodl.Variables, etc.)
- Apply/Cancel buttons
---
### 3. PropertyPanelInput Integration - COMPLETE ✅
**Files Modified:**
- `packages/noodl-core-ui/src/components/property-panel/PropertyPanelInput/PropertyPanelInput.tsx`
**Features:**
- Added expression-related props (supportsExpression, expressionMode, expression, etc.)
- Conditional rendering: expression input vs fixed input
- Mode change handlers
---
### 4. PropertyPanelInputWithExpressionModal Wrapper - COMPLETE ✅
**Files Created:**
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/PropertyPanelInputWithExpressionModal.tsx`
**Features:**
- Combines PropertyPanelInput with ExpressionEditorModal
- Manages modal open/close state
- Handles expression expand functionality
---
### 5. BasicType Property Editor Wiring - COMPLETE ✅
**Files Modified:**
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts`
**Features:**
- Uses React for rendering via createRoot
- Supports expression mode toggle
- Integrates with ExpressionParameter model
- Uses ParameterValueResolver for safe value display
---
### 6. Runtime Reactive Subscriptions - COMPLETE ✅
**Files Modified:**
- `packages/noodl-runtime/src/node.js`
**Features:**
- `_evaluateExpressionParameter()` now sets up reactive subscriptions
- Uses `detectDependencies()` to find referenced Variables/Objects/Arrays
- Uses `subscribeToChanges()` to listen for dependency updates
- Re-queues input when dependencies change
- Cleanup in `_onNodeDeleted()`
---
## 🎯 How It Works
### User Experience
1. Select a node with text/number properties
2. Click the "fx" button to toggle expression mode
3. Enter an expression like `Noodl.Variables.userName`
4. The value updates automatically when the variable changes
5. Click the expand button for a full code editor modal
### Supported Expressions
```javascript
// Simple variable access
Noodl.Variables.userName;
// Math operations
Noodl.Variables.count * 2 + 1;
// Ternary conditionals
Noodl.Variables.isLoggedIn ? 'Logout' : 'Login';
// String concatenation
'Hello, ' + Noodl.Variables.userName + '!';
// Math helpers
min(Noodl.Variables.price, 100);
max(10, Noodl.Variables.quantity);
round(Noodl.Variables.total);
// Object/Array access
Noodl.Objects['user'].name;
Noodl.Arrays['items'].length;
```
---
## 📁 Files Changed
### Created
- `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/ExpressionEditorModal/ExpressionEditorModal.tsx`
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionEditorModal/ExpressionEditorModal.module.scss`
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/ExpressionEditorModal/index.ts`
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/PropertyPanelInputWithExpressionModal.tsx`
### Modified
- `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-runtime/src/node.js` (added reactive subscriptions)
---
## 🐛 Bug Fixes (2026-01-16)
### Modal Showing Stale Expression
**Problem:** When editing an expression in the inline input field and then opening the modal, the modal showed the old expression text instead of the current one.
**Root Cause:** `BasicType.ts` was not re-rendering after `onExpressionChange`, so the modal component never received the updated expression prop.
**Fix:** Added `setTimeout(() => this.renderReact(), 0)` at the end of `onExpressionChange` in `BasicType.ts` to ensure the React tree updates with the new expression value.
### Variable Changes Not Updating Node Values
**Problem:** When using an expression like `Noodl.Variables.label_text`, changing the variable value didn't update the node's property until the expression was manually saved again.
**Root Cause:** The subscription callback in `node.js` captured the old `paramValue` object in a closure. When the subscription fired, it re-queued the old expression, not the current one stored in `_inputValues`.
**Fix:** Updated subscription management in `_evaluateExpressionParameter()`:
- Subscriptions now track both the unsubscribe function AND the expression string
- When expression changes, old subscription is unsubscribed before creating a new one
- The callback now reads from `this._inputValues[portName]` (current value) instead of using a closure
**Files Modified:**
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/BasicType.ts`
- `packages/noodl-runtime/src/node.js`
---
## 🎓 Key Learnings
### Context Issue
The `evaluateExpression()` function in expression-evaluator.js expects either `undefined` or a Model scope as the second parameter. Passing `this.context` (the runtime context with editorConnection, styles, etc.) caused "scope.get is not a function" errors. **Solution:** Pass `undefined` to use the global Model.
### Reactive Updates
The expression-evaluator module already had `detectDependencies()` and `subscribeToChanges()` functions. We just needed to wire them into `_evaluateExpressionParameter()` in node.js to enable reactive updates.
### Value Display
Expression parameters are objects (`{ mode: 'expression', expression: '...', fallback: ... }`). When displaying the value in the properties panel, we need to extract the fallback value, not display the object. The `ParameterValueResolver.toString()` helper handles this.
---
## 🚀 Future Enhancements
1. **Canvas rendering** - Show expression indicator on node ports (TASK-006B)
2. **More property types** - Extend to color, enum, and other types
3. **Expression autocomplete** - IntelliSense for Noodl.Variables names
4. **Expression validation** - Real-time syntax checking
---
**Last Updated:** 2026-01-16

View File

@@ -0,0 +1,107 @@
# TASK-007H: Schema Manager UI - COMPLETE
## Summary
Implemented a Schema Manager UI panel for viewing and managing database schemas in local SQLite backends.
## Files Created
### Schema Manager Panel Components
- `packages/noodl-editor/src/editor/src/views/panels/schemamanager/SchemaPanel.tsx` - Main panel showing tables list
- `packages/noodl-editor/src/editor/src/views/panels/schemamanager/SchemaPanel.module.scss` - Panel styles
- `packages/noodl-editor/src/editor/src/views/panels/schemamanager/TableRow.tsx` - Expandable table row with columns
- `packages/noodl-editor/src/editor/src/views/panels/schemamanager/TableRow.module.scss` - Table row styles
- `packages/noodl-editor/src/editor/src/views/panels/schemamanager/index.ts` - Module exports
## Files Modified
### BackendManager.js
Added IPC handlers for schema operations:
- `backend:getSchema` - Get full schema for a backend
- `backend:getTableSchema` - Get single table schema
- `backend:getRecordCount` - Get record count for a table
- `backend:createTable` - Create a new table
- `backend:addColumn` - Add column to existing table
### LocalBackendCard.tsx
- Added "Schema" button (shows when backend is running)
- Added schema panel overlay state
- Integrated SchemaPanel component
### LocalBackendCard.module.scss
- Added `.SchemaPanelOverlay` styles for full-screen modal:
- Uses React Portal (`createPortal`) to render at `document.body`
- `z-index: 9999` ensures overlay above all UI elements
- Dark backdrop (`rgba(0, 0, 0, 0.85)`)
- Centered modal with max-width 900px
- Proper border-radius, shadow, and background styling
## Features Implemented
### SchemaPanel
- Shows list of all tables in the backend
- Displays table count in header
- Refresh button to reload schema
- Empty state with "Create First Table" prompt (placeholder)
- Close button to dismiss panel
### TableRow
- Collapsible rows with expand/collapse toggle
- Table icon with "T" badge
- Shows field count and record count
- Expanded view shows:
- System columns (objectId, createdAt, updatedAt)
- User-defined columns with type badges
- Required indicator
- Default value display
- Type badges with color-coded data types:
- String (primary)
- Number (success/green)
- Boolean (notice/yellow)
- Date (purple)
- Object (pink)
- Array (indigo)
- Pointer/Relation (red)
- GeoPoint (teal)
- File (orange)
## Usage
1. Create and start a local backend
2. Click "Schema" button on the backend card
3. View tables and expand rows to see columns
4. Click "Refresh" to reload schema
5. Click "Close" to dismiss panel
## Future Enhancements (Deferred)
The following were scoped but not implemented in this initial version:
- CreateTableModal - For creating new tables
- ColumnEditor - For editing column definitions
- SchemaEditor - Full table editor
- ExportDialog - Export schema to SQL formats (handlers exist in BackendManager)
These can be added incrementally as needed.
## Verification
The implementation follows project patterns:
- ✅ Uses theme tokens (no hardcoded colors)
- ✅ Uses IPC pattern from useLocalBackends
- ✅ Proper TypeScript types
- ✅ JSDoc documentation
- ✅ Module SCSS with CSS modules
- ✅ Follows existing component structure
---
Completed: 15/01/2026

View File

@@ -0,0 +1,205 @@
# TASK-007I: Data Browser Panel - COMPLETE
## Summary
Implemented a full-featured data browser panel for viewing and editing records in local backend SQLite tables, providing a spreadsheet-like interface with inline editing, search, pagination, and CRUD operations.
## Status: ✅ Complete
## Date: 2026-01-15
---
## What Was Implemented
### 1. Backend IPC Handlers (BackendManager.js)
Added four new IPC handlers for data operations:
- **`backend:queryRecords`** - Query records with pagination, sorting, search filters
- **`backend:createRecord`** - Create a new record in a table
- **`backend:saveRecord`** - Update an existing record (inline edit)
- **`backend:deleteRecord`** - Delete a single record
These handlers wrap the existing LocalSQLAdapter CRUD methods (query, create, save, delete) and expose them to the renderer process.
### 2. DataBrowser Component (`panels/databrowser/`)
Created a complete data browser panel with the following components:
#### DataBrowser.tsx (Main Component)
- Table selector dropdown
- Search input with cross-field text search
- Pagination with page navigation (50 records per page)
- Bulk selection and delete operations
- CSV export functionality
- Loading states and error handling
- New record modal trigger
#### DataGrid.tsx (Spreadsheet View)
- Column headers with type badges (String, Number, Boolean, Date, Object, Array)
- System columns (objectId, createdAt, updatedAt) displayed as read-only
- Inline editing - click any editable cell to edit
- Row selection with checkbox
- Select all/none toggle
- Delete action per row
- Scrollable with sticky header
#### CellEditor.tsx (Inline Editor)
- Type-aware input controls:
- **String**: Text input
- **Number**: Number input with step="any"
- **Boolean**: Checkbox with immediate save
- **Date**: datetime-local input
- **Object/Array**: Multi-line JSON editor with Save/Cancel buttons
- Auto-focus and select on mount
- Enter to save, Escape to cancel (for simple types)
- Validation for numbers and JSON parsing
- Error display for invalid input
#### NewRecordModal.tsx (Create Record Form)
- Form fields generated from column schema
- Type-aware input controls for each field type
- Required field validation
- Default values based on column defaults or type defaults
- Success callback to refresh data grid
### 3. Styling
All components use theme CSS variables per UI-STYLING-GUIDE.md:
- `--theme-color-bg-*` for backgrounds
- `--theme-color-fg-*` for text
- `--theme-color-border-*` for borders
- `--theme-color-primary*` for accents
- `--theme-color-success/danger/notice` for status colors
### 4. Integration
Added "Data" button to LocalBackendCard (visible when backend is running):
- Opens DataBrowser in a full-screen portal overlay
- Same pattern as Schema panel
---
## Files Created/Modified
### Created Files:
```
packages/noodl-editor/src/editor/src/views/panels/databrowser/
├── DataBrowser.tsx - Main data browser component
├── DataBrowser.module.scss - Styles for DataBrowser
├── DataGrid.tsx - Spreadsheet grid component
├── DataGrid.module.scss - Styles for DataGrid
├── CellEditor.tsx - Inline cell editor
├── CellEditor.module.scss - Styles for CellEditor
├── NewRecordModal.tsx - Create record modal
├── NewRecordModal.module.scss - Styles for modal
└── index.ts - Exports
```
### Modified Files:
```
packages/noodl-editor/src/main/src/local-backend/BackendManager.js
- Added queryRecords(), createRecord(), saveRecord(), deleteRecord() methods
- Added corresponding IPC handlers
packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/LocalBackendCard/LocalBackendCard.tsx
- Added DataBrowser import
- Added showDataBrowser state
- Added "Data" button
- Added DataBrowser portal rendering
```
---
## How To Use
1. Start a local backend from the Backend Services panel
2. Click the "Data" button on the running backend card
3. Select a table from the dropdown (auto-selects first table if available)
4. Browse records with pagination
5. Click any editable cell to edit inline
6. Use "+ New Record" to add records
7. Use checkboxes for bulk selection and delete
8. Use "Export CSV" to download current view
---
## Technical Notes
### Query API
The query API supports:
```javascript
{
collection: string, // Table name
limit: number, // Max records (default 50)
skip: number, // Pagination offset
where: object, // Filter conditions ($or, contains, etc.)
sort: string[], // Sort order (e.g., ['-createdAt'])
count: boolean // Return total count for pagination
}
```
### Search Implementation
Search uses `$or` with `contains` operator across all String columns and objectId. The LocalSQLAdapter converts this to SQLite LIKE queries.
### Type Handling
- System fields (objectId, createdAt, updatedAt) are always read-only
- Boolean cells save immediately on checkbox toggle
- Object/Array cells require explicit Save button due to JSON editing
- Dates are stored as ISO8601 strings
---
## Future Improvements (Not in scope)
- Column sorting by clicking headers
- Filter by specific field values
- Relation/Pointer field expansion
- Inline record duplication
- Undo for delete operations
- Import CSV/JSON
---
## Verification Checklist
- [x] IPC handlers added to BackendManager.js
- [x] DataBrowser component created
- [x] DataGrid with inline editing
- [x] CellEditor with type-aware controls
- [x] NewRecordModal for creating records
- [x] CSV export functionality
- [x] Pagination working
- [x] Search functionality
- [x] Bulk delete operations
- [x] Integration with LocalBackendCard
- [x] Theme-compliant styling
- [x] No hardcoded colors
- [x] JSDoc comments added
---
## Related Tasks
- **TASK-007A**: Core SQLite adapter ✅
- **TASK-007B**: Workflow integration ✅
- **TASK-007C**: Schema management ✅
- **TASK-007D**: Backend lifecycle ✅
- **TASK-007E**: Express HTTP API ✅
- **TASK-007F**: Node integration ✅
- **TASK-007G**: UI components ✅
- **TASK-007H**: Schema Panel UI ✅
- **TASK-007I**: Data Browser ✅ (this task)

View File

@@ -0,0 +1,90 @@
# TASK-007J: Schema/Database Management UX - COMPLETE
## Status: ✅ COMPLETE (with Known Issues)
## Summary
Built Schema Manager and Data Browser UI panels with basic CRUD functionality. Uses `id` (UUID v4) as the primary key field to be compatible with Noodl frontend objects/arrays.
## What Was Built
### Schema Manager Panel
- **SchemaPanel** - Main panel for viewing/managing database schemas
- **TableRow** - Expandable row showing table name, columns, record count
- **CreateTableModal** - Modal for creating new tables with initial columns
- **AddColumnForm** - Form for adding columns (type dropdown, required checkbox)
### Data Browser Panel
- **DataBrowser** - Main data browsing UI with table selector, search, pagination
- **DataGrid** - Spreadsheet-style grid with inline editing
- **CellEditor** - Inline cell editor with type-aware input handling
- **NewRecordModal** - Modal for creating new records
### Backend Changes
- Changed primary key from `objectId` to `id` with UUID v4
- QueryBuilder uses `id` field for all operations
- LocalSQLAdapter generates RFC 4122 UUIDs
## Files Created/Modified
### New UI Components
- `packages/noodl-editor/src/editor/src/views/panels/schemamanager/`
- `SchemaPanel.tsx`, `SchemaPanel.module.scss`
- `TableRow.tsx`, `TableRow.module.scss`
- `CreateTableModal.tsx`, `CreateTableModal.module.scss`
- `AddColumnForm.tsx`, `AddColumnForm.module.scss`
- `index.ts`
- `packages/noodl-editor/src/editor/src/views/panels/databrowser/`
- `DataBrowser.tsx`, `DataBrowser.module.scss`
- `DataGrid.tsx`, `DataGrid.module.scss`
- `CellEditor.tsx`, `CellEditor.module.scss`
- `NewRecordModal.tsx`, `NewRecordModal.module.scss`
- `index.ts`
### Modified Backend Files
- `packages/noodl-runtime/src/api/adapters/local-sql/QueryBuilder.js`
- `packages/noodl-runtime/src/api/adapters/local-sql/LocalSQLAdapter.js`
## Known Issues (Future Task)
The following bugs need to be addressed in a follow-up task:
### Data Browser Bugs
1. **Object/Array fields don't work** - Can't type into object/array field editors
2. **Cell editor focus issues** - Sometimes loses focus unexpectedly
3. **Search functionality limited** - Only searches String fields
### Schema Manager Bugs
1. **Can't edit existing tables** - Edit button only expands row, no add/remove columns
2. **Can't delete tables** - No delete table functionality
### SQLite/Backend Bugs
1. **Real SQLite database doesn't work** - Falls back to in-memory mock
2. **better-sqlite3 import issues** - Electron compatibility problems
3. **Schema persistence** - Schema changes don't persist properly
4. **Query filtering** - Complex WHERE clauses may not work correctly
### UI/UX Issues
1. **Boolean toggle needs improvement** - Checkbox isn't very intuitive
2. **Date picker needed** - Currently just text input for dates
3. **Pointer/Relation fields** - No UI for selecting related records
4. **File upload** - No file upload/browse functionality
## Next Steps
See **TASK-007K-DRAFT.md** for bug fixes and improvements.
## Completion Date
January 15, 2026

View File

@@ -0,0 +1,260 @@
# TASK-007J: Schema & Data Creation UX
## Status: 📋 Draft
## Summary
Add the missing creation UX to the Schema and Data Browser panels - create tables, add/edit/delete columns, and create data records with proper type handling.
---
## Problem
The current implementation (007H + 007I) provides excellent **viewing** and **inline editing** capabilities, but is missing the ability to **create new structures from scratch**:
- Schema Panel shows tables but no way to create a new table
- No way to add/edit/delete columns in existing tables
- Data Browser has "+ New Record" but useless without tables
- Field types need proper UI for creation (default values, constraints)
---
## Dependencies
**Backend (Already Exists ✅):**
- `backend:createTable` - IPC handler in BackendManager.js
- `backend:addColumn` - IPC handler in BackendManager.js
- LocalSQLAdapter has all CRUD methods for schema changes
**UI Components (Already Exists ✅):**
- SchemaPanel - just needs buttons and modals wired up
- DataBrowser - just needs better empty state
---
## Implementation Plan
### Part 1: Create Table Modal
**New Component: `CreateTableModal.tsx`**
Features:
- Table name input (validated: alphanumeric, no spaces, unique)
- Initial columns list with ability to add/remove
- For each column:
- Name input
- Type dropdown (String, Number, Boolean, Date, Object, Array)
- Required checkbox
- Default value input (type-aware)
- "Create Table" button calls `backend:createTable`
**Supported Field Types:**
| Type | SQLite | Default Input |
|------|--------|---------------|
| String | TEXT | Text input |
| Number | REAL | Number input |
| Boolean | INTEGER | Checkbox |
| Date | TEXT (ISO) | Date picker |
| Object | TEXT (JSON) | JSON editor |
| Array | TEXT (JSON) | JSON editor |
**Integration:**
- Add `showCreateTableModal` state to SchemaPanel
- Wire up "+ New Table" button (already in header)
- Call `loadSchema()` on success
### Part 2: Add Column to Existing Table
**New Component: `AddColumnForm.tsx`**
Features:
- Inline form that appears in expanded table view
- Column name input
- Type dropdown
- Required checkbox
- Default value (required for non-nullable columns if table has data)
- "Add Column" button calls `backend:addColumn`
**Integration:**
- Add to TableRow component's expanded view
- Add "+ Add Column" button to expanded section
- Handles the case where existing records need default values
### Part 3: Edit Column (Rename/Change Type)
**Note:** SQLite has limited ALTER TABLE support. Only RENAME COLUMN is safe.
**Features:**
- Edit icon next to each column in expanded view
- Opens inline editor for column name
- Type change is **not supported** (would need table recreation)
- Shows tooltip: "Type cannot be changed after creation"
### Part 4: Delete Column
**New IPC Handler Needed:** `backend:deleteColumn`
**Note:** SQLite doesn't support DROP COLUMN directly - needs table recreation.
**Implementation options:**
1. **Not supported** - show tooltip "SQLite doesn't support column deletion"
2. **Recreate table** - expensive but possible:
- Create new table without column
- Copy data
- Drop old table
- Rename new table
**Recommendation:** Option 1 for MVP, Option 2 as enhancement
### Part 5: Delete Table
**New IPC Handler Needed:** `backend:deleteTable` (or use existing drop)
**Features:**
- Delete/trash icon on each table row
- Confirmation dialog: "Delete table {name}? This will permanently delete all data."
- Calls `backend:deleteTable`
### Part 6: Improved Empty States
**Schema Panel (when no tables):**
```
┌─────────────────────────────────────┐
│ 🗄️ No tables yet │
│ │
│ Create your first table to start │
│ storing data in your backend. │
│ │
│ [+ Create Table] │
└─────────────────────────────────────┘
```
**Data Browser (when no tables):**
```
┌─────────────────────────────────────┐
│ 📊 No tables in database │
│ │
│ Go to Schema panel to create │
│ your first table, then return │
│ here to browse and edit data. │
│ │
│ [Open Schema Panel] │
└─────────────────────────────────────┘
```
---
## Files to Create/Modify
### New Files:
```
panels/schemamanager/
├── CreateTableModal.tsx - Modal for creating new tables
├── CreateTableModal.module.scss
├── AddColumnForm.tsx - Inline form for adding columns
├── AddColumnForm.module.scss
├── ColumnEditor.tsx - Inline column name editor
└── ColumnEditor.module.scss
```
### Modified Files:
```
main/src/local-backend/BackendManager.js
- Add deleteColumn() if implementing column deletion
- Add deleteTable() IPC handler
panels/schemamanager/SchemaPanel.tsx
- Add showCreateTableModal state
- Wire up modal
panels/schemamanager/TableRow.tsx
- Add AddColumnForm integration
- Add column edit/delete actions
- Add table delete action
panels/databrowser/DataBrowser.tsx
- Improve empty state with link to Schema panel
```
---
## Estimation
| Part | Complexity | Time Est |
| --------------------- | ---------- | ----------- |
| CreateTableModal | Medium | 1-2 hrs |
| AddColumnForm | Medium | 1 hr |
| ColumnEditor (rename) | Simple | 30 min |
| Delete table | Simple | 30 min |
| Empty states | Simple | 15 min |
| Testing & polish | - | 1 hr |
| **Total** | - | **4-5 hrs** |
---
## Acceptance Criteria
- [ ] Can create a new table with name and columns
- [ ] Can add columns to existing tables
- [ ] Can rename columns
- [ ] Can delete tables (with confirmation)
- [ ] Field types have proper UI controls
- [ ] Empty states guide users to create structures
- [ ] All actions refresh the panel after success
- [ ] Error states for invalid names/constraints
- [ ] Theme-compliant styling (no hardcoded colors)
---
## Technical Notes
### Table Name Validation
```javascript
const isValidTableName = (name) => {
// Must start with letter, alphanumeric + underscore only
// Cannot be SQLite reserved words
return /^[a-zA-Z][a-zA-Z0-9_]*$/.test(name) && !SQLITE_RESERVED_WORDS.includes(name.toUpperCase());
};
```
### Default Values for Types
```javascript
const getDefaultForType = (type) =>
({
String: '',
Number: 0,
Boolean: false,
Date: new Date().toISOString(),
Object: {},
Array: []
}[type]);
```
### SQLite Limitations
- No DROP COLUMN (before SQLite 3.35.0)
- No ALTER COLUMN TYPE
- RENAME COLUMN supported (SQLite 3.25.0+)
---
## Related Tasks
- **007H**: Schema Panel (viewing) ✅
- **007I**: Data Browser (viewing/editing) ✅
- **007J**: Schema & Data Creation UX ← This task

View File

@@ -0,0 +1,120 @@
# TASK-007K: Local Backend Bug Fixes & Polish
## Status: 📋 DRAFT
## Overview
This task addresses the known bugs and UX issues from TASK-007J. The Schema Manager and Data Browser are functional but have rough edges that need polish.
## Priority 1: Critical Bugs
### 1.1 Object/Array Field Editor
**Problem:** Can't type into Object/Array field text areas in CellEditor.
**Files:** `CellEditor.tsx`
**Fix:** Review the textarea handling, may need to prevent event bubbling or fix focus management.
### 1.2 Real SQLite Database
**Problem:** Falls back to in-memory mock because better-sqlite3 import fails in Electron renderer.
**Files:** `LocalSQLAdapter.js`, `BackendManager.js`
**Fix Options:**
- Move SQLite operations to main process via IPC
- Use sql.js (pure JS SQLite) in renderer
- Configure better-sqlite3 for Electron properly
### 1.3 Schema Persistence
**Problem:** Schema changes (new tables, columns) don't persist across restarts.
**Files:** `SchemaManager.js`, `LocalBackendServer.js`
**Fix:** Ensure schema table is properly created and migrations are stored.
## Priority 2: Missing Features
### 2.1 Edit Existing Tables
**Problem:** No UI for adding/removing columns from existing tables.
**Files:** `SchemaPanel.tsx`, `TableRow.tsx`
**Add:**
- "Add Column" button in expanded table row
- Delete column button per column
- Confirmation for destructive actions
### 2.2 Delete Tables
**Problem:** No way to delete a table.
**Files:** `SchemaPanel.tsx`, `TableRow.tsx`
**Add:**
- Delete button in table row
- Confirmation dialog
- Backend `backend:deleteTable` IPC handler
### 2.3 Better Boolean Toggle
**Problem:** Checkbox not intuitive for boolean fields.
**Files:** `CellEditor.tsx`
**Add:** Toggle switch component instead of checkbox.
### 2.4 Date Picker
**Problem:** Text input for dates is error-prone.
**Files:** `CellEditor.tsx`
**Add:** Date picker component (can use core-ui DateInput if available).
## Priority 3: UX Improvements
### 3.1 Pointer/Relation Field Editor
Add dropdown to select from related records.
### 3.2 File Field Editor
Add file picker/upload UI.
### 3.3 Search All Fields
Extend search to Number, Date fields (not just String).
### 3.4 Keyboard Navigation
- Arrow keys to navigate grid
- Enter to edit cell
- Escape to cancel
- Tab to move between cells
## Estimated Effort
| Priority | Items | Effort |
| --------- | ----- | ------------- |
| P1 | 3 | 4-6 hrs |
| P2 | 4 | 3-4 hrs |
| P3 | 4 | 4-6 hrs |
| **Total** | | **11-16 hrs** |
## Dependencies
- Core UI components (Toggle, DatePicker)
- May need main process changes for SQLite
## Notes
- Consider splitting this into multiple sub-tasks if scope is too large
- SQLite issue may require significant architecture change
- Focus on P1 bugs first for usable MVP

View File

@@ -72,7 +72,9 @@
## Recent Updates ## Recent Updates
| Date | Update | | Date | Update |
| ---------- | --------------------------------------------------------- | | ---------- | ----------------------------------------------------------- |
| 2026-01-15 | TASK-007J Schema Manager & Data Browser UX complete w/ bugs |
| 2026-01-15 | TASK-007K Draft created for bug fixes |
| 2026-01-15 | TASK-007 Integrated Backend core infrastructure complete | | 2026-01-15 | TASK-007 Integrated Backend core infrastructure complete |
| 2026-01-07 | Corrected PROGRESS.md to reflect actual completion status | | 2026-01-07 | Corrected PROGRESS.md to reflect actual completion status |
| 2025-12-30 | TASK-002 bug fixes and system table support | | 2025-12-30 | TASK-002 bug fixes and system table support |
@@ -103,7 +105,7 @@
### TASK-007: Integrated Local Backend ✅ ### TASK-007: Integrated Local Backend ✅
Zero-config local SQLite backend system - infrastructure complete. Zero-config local SQLite backend system - infrastructure complete with Schema Manager & Data Browser UI.
**Completed subtasks:** **Completed subtasks:**
@@ -111,6 +113,9 @@ Zero-config local SQLite backend system - infrastructure complete.
- TASK-007B: Local Backend Server (Express + IPC handlers) - TASK-007B: Local Backend Server (Express + IPC handlers)
- TASK-007C: WorkflowRunner (Cloud function execution) - TASK-007C: WorkflowRunner (Cloud function execution)
- TASK-007D: Launcher UI (BackendServicesPanel integration) - TASK-007D: Launcher UI (BackendServicesPanel integration)
- TASK-007H: Backend Manager IPC Handlers
- TASK-007I: Data Browser Panel (spreadsheet-style grid)
- TASK-007J: Schema Manager Panel (create tables, add columns)
**What's working:** **What's working:**
@@ -118,12 +123,16 @@ Zero-config local SQLite backend system - infrastructure complete.
- Start/stop backend servers via IPC - Start/stop backend servers via IPC
- REST API compatible with Parse Server - REST API compatible with Parse Server
- Auto-schema (tables/columns created on first write) - Auto-schema (tables/columns created on first write)
- Schema Manager: create tables, add columns, view schema
- Data Browser: view records, inline editing, create/delete records
- UUID `id` field (compatible with Noodl frontend objects)
**Future work (separate task):** **Known Issues (TASK-007K):**
- Schema viewer/editor panel for visual data management - Object/Array field editors don't work
- Data browser with grid view - Real SQLite falls back to in-memory mock (better-sqlite3 issues)
- Migration/export tools (optional) - Can't edit/delete existing tables
- Cell editor focus issues
**Implementation Files:** **Implementation Files:**
@@ -143,6 +152,20 @@ packages/noodl-editor/src/main/src/local-backend/
packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/ packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/
├── hooks/useLocalBackends.ts ├── hooks/useLocalBackends.ts
└── LocalBackendCard/LocalBackendCard.tsx └── LocalBackendCard/LocalBackendCard.tsx
packages/noodl-editor/src/editor/src/views/panels/schemamanager/
├── SchemaPanel.tsx, SchemaPanel.module.scss
├── TableRow.tsx, TableRow.module.scss
├── CreateTableModal.tsx, CreateTableModal.module.scss
├── AddColumnForm.tsx, AddColumnForm.module.scss
└── index.ts
packages/noodl-editor/src/editor/src/views/panels/databrowser/
├── DataBrowser.tsx, DataBrowser.module.scss
├── DataGrid.tsx, DataGrid.module.scss
├── CellEditor.tsx, CellEditor.module.scss
├── NewRecordModal.tsx, NewRecordModal.module.scss
└── index.ts
``` ```
--- ---

View File

@@ -7,6 +7,8 @@
border-radius: 4px; border-radius: 4px;
padding: 4px 8px; padding: 4px 8px;
flex: 1; flex: 1;
min-width: 0; // Allow flex item to shrink below content size
overflow: hidden; // Prevent content overflow
transition: all 0.15s ease; transition: all 0.15s ease;
&:focus-within { &:focus-within {
@@ -62,3 +64,29 @@
color: var(--theme-color-error, #ef4444); color: var(--theme-color-error, #ef4444);
cursor: help; cursor: help;
} }
.ExpandButton {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.5));
cursor: pointer;
border-radius: 2px;
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-1, rgba(255, 255, 255, 0.1));
color: var(--theme-color-primary, #6366f1);
}
&:focus {
outline: none;
background-color: var(--theme-color-bg-1, rgba(255, 255, 255, 0.1));
}
}

View File

@@ -30,6 +30,9 @@ export interface ExpressionInputProps extends UnsafeStyleProps {
/** Debounce delay in milliseconds */ /** Debounce delay in milliseconds */
debounceMs?: number; debounceMs?: number;
/** Callback when expand button is clicked - opens expression in full editor */
onExpand?: () => void;
} }
/** /**
@@ -56,6 +59,7 @@ export function ExpressionInput({
placeholder = 'Enter expression...', placeholder = 'Enter expression...',
testId, testId,
debounceMs = 300, debounceMs = 300,
onExpand,
UNSAFE_className, UNSAFE_className,
UNSAFE_style UNSAFE_style
}: ExpressionInputProps) { }: ExpressionInputProps) {
@@ -143,6 +147,13 @@ export function ExpressionInput({
</div> </div>
</Tooltip> </Tooltip>
)} )}
{onExpand && (
<Tooltip content="Edit in code editor">
<button type="button" className={css['ExpandButton']} onClick={onExpand} aria-label="Edit in code editor">
<Icon icon={IconName.Code} size={IconSize.Tiny} />
</button>
</Tooltip>
)}
</div> </div>
); );
} }

View File

@@ -26,3 +26,42 @@
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;
} }
.FxButton {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 10px;
font-weight: 600;
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.5));
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background-color: var(--theme-color-bg-1, rgba(255, 255, 255, 0.1));
color: var(--theme-color-primary, #6366f1);
}
&:focus {
outline: none;
background-color: var(--theme-color-bg-1, rgba(255, 255, 255, 0.1));
}
&.is-disabled {
opacity: 0.4;
cursor: not-allowed;
&:hover {
background: transparent;
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.5));
}
}
}

View File

@@ -62,23 +62,39 @@ export function ExpressionToggle({
const tooltipContent = isExpressionMode ? 'Switch to fixed value' : 'Switch to expression'; const tooltipContent = isExpressionMode ? 'Switch to fixed value' : 'Switch to expression';
const icon = isExpressionMode ? IconName.Code : IconName.MagicWand; // When in expression mode, show TextInBox icon (switch to fixed value)
// When in fixed mode, show "fx" text button (switch to expression)
const variant = isExpressionMode ? IconButtonVariant.Default : IconButtonVariant.OpaqueOnHover; if (isExpressionMode) {
return ( return (
<Tooltip content={tooltipContent}> <Tooltip content={tooltipContent}>
<div className={css['Root']} style={UNSAFE_style}> <div className={css['Root']} style={UNSAFE_style}>
<IconButton <IconButton
icon={icon} icon={IconName.TextInBox}
size={IconSize.Tiny} size={IconSize.Tiny}
variant={variant} variant={IconButtonVariant.Default}
onClick={onToggle} onClick={onToggle}
isDisabled={isDisabled} isDisabled={isDisabled}
testId={testId} testId={testId}
UNSAFE_className={isExpressionMode ? css['ExpressionActive'] : UNSAFE_className} UNSAFE_className={css['ExpressionActive']}
/> />
</div> </div>
</Tooltip> </Tooltip>
); );
} }
// Fixed mode - show "fx" text button
return (
<Tooltip content={tooltipContent}>
<button
type="button"
className={`${css['FxButton']} ${isDisabled ? css['is-disabled'] : ''} ${UNSAFE_className || ''}`}
style={UNSAFE_style}
onClick={isDisabled ? undefined : onToggle}
disabled={isDisabled}
data-test={testId}
>
fx
</button>
</Tooltip>
);
}

View File

@@ -24,4 +24,6 @@
.InputContainer { .InputContainer {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; // Allow flex item to shrink below content size
overflow: hidden; // Prevent content overflow
} }

View File

@@ -74,6 +74,8 @@ export interface PropertyPanelInputProps extends Omit<PropertyPanelBaseInputProp
onExpressionChange?: (expression: string) => void; onExpressionChange?: (expression: string) => void;
/** Whether the expression has an error */ /** Whether the expression has an error */
expressionError?: string; expressionError?: string;
/** Callback when expand button is clicked (opens expression in full editor) */
onExpressionExpand?: () => void;
} }
export function PropertyPanelInput({ export function PropertyPanelInput({
@@ -90,7 +92,8 @@ export function PropertyPanelInput({
expression = '', expression = '',
onExpressionModeChange, onExpressionModeChange,
onExpressionChange, onExpressionChange,
expressionError expressionError,
onExpressionExpand
}: PropertyPanelInputProps) { }: PropertyPanelInputProps) {
const Input = useMemo(() => { const Input = useMemo(() => {
switch (inputType) { switch (inputType) {
@@ -136,6 +139,7 @@ export function PropertyPanelInput({
onChange={onExpressionChange} onChange={onExpressionChange}
hasError={!!expressionError} hasError={!!expressionError}
errorMessage={expressionError} errorMessage={expressionError}
onExpand={onExpressionExpand}
UNSAFE_style={{ flex: 1 }} UNSAFE_style={{ flex: 1 }}
/> />
); );
@@ -165,7 +169,7 @@ export function PropertyPanelInput({
<div className={css['Root']}> <div className={css['Root']}>
<div className={classNames(css['Label'], isChanged && css['is-changed'])}>{label}</div> <div className={classNames(css['Label'], isChanged && css['is-changed'])}>{label}</div>
<div className={css['InputContainer']}> <div className={css['InputContainer']}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', width: '100%' }}> <div style={{ display: 'flex', gap: '4px', alignItems: 'center', minWidth: 0 }}>
{renderInput()} {renderInput()}
{showExpressionToggle && ( {showExpressionToggle && (
<ExpressionToggle mode={expressionMode} isConnected={isConnected} onToggle={handleToggleMode} /> <ExpressionToggle mode={expressionMode} isConnected={isConnected} onToggle={handleToggleMode} />

View File

@@ -63,3 +63,28 @@
padding-top: 8px; padding-top: 8px;
border-top: 1px solid var(--theme-color-border-default); border-top: 1px solid var(--theme-color-border-default);
} }
.SchemaPanelOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 40px;
> div {
width: 100%;
max-width: 900px;
max-height: calc(100vh - 80px);
overflow: auto;
background-color: var(--theme-color-bg-2);
border-radius: 8px;
border: 1px solid var(--theme-color-border-default);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
}

View File

@@ -9,6 +9,7 @@
*/ */
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon'; import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton'; import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
@@ -16,6 +17,8 @@ import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-c
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack'; import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text'; import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { DataBrowser } from '../../databrowser';
import { SchemaPanel } from '../../schemamanager';
import { LocalBackendInfo } from '../hooks/useLocalBackends'; import { LocalBackendInfo } from '../hooks/useLocalBackends';
import css from './LocalBackendCard.module.scss'; import css from './LocalBackendCard.module.scss';
@@ -44,6 +47,8 @@ function getStatusDisplay(running: boolean): { icon: IconName; color: string; te
export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport }: LocalBackendCardProps) { export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport }: LocalBackendCardProps) {
const [isOperating, setIsOperating] = useState(false); const [isOperating, setIsOperating] = useState(false);
const [showSchemaPanel, setShowSchemaPanel] = useState(false);
const [showDataBrowser, setShowDataBrowser] = useState(false);
const statusDisplay = getStatusDisplay(backend.running); const statusDisplay = getStatusDisplay(backend.running);
// Format date // Format date
@@ -127,6 +132,22 @@ export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport
onClick={handleToggle} onClick={handleToggle}
isDisabled={isOperating} isDisabled={isOperating}
/> />
{backend.running && (
<>
<PrimaryButton
label="Data"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={() => setShowDataBrowser(true)}
/>
<PrimaryButton
label="Schema"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={() => setShowSchemaPanel(true)}
/>
</>
)}
{onExport && backend.running && ( {onExport && backend.running && (
<PrimaryButton <PrimaryButton
label="Export" label="Export"
@@ -144,6 +165,29 @@ export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport
/> />
</HStack> </HStack>
</div> </div>
{/* Schema Panel (rendered via portal for full-screen overlay) */}
{showSchemaPanel &&
createPortal(
<div className={css.SchemaPanelOverlay}>
<SchemaPanel
backendId={backend.id}
backendName={backend.name}
isRunning={backend.running}
onClose={() => setShowSchemaPanel(false)}
/>
</div>,
document.body
)}
{/* Data Browser (rendered via portal for full-screen overlay) */}
{showDataBrowser &&
createPortal(
<div className={css.SchemaPanelOverlay}>
<DataBrowser backendId={backend.id} backendName={backend.name} onClose={() => setShowDataBrowser(false)} />
</div>,
document.body
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,91 @@
/**
* CellEditor styles
*/
.CellEditor {
position: relative;
}
.Input,
.DateInput {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--theme-color-primary);
border-radius: 4px;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
font-size: 12px;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
box-shadow: 0 0 0 2px var(--theme-color-primary-muted);
}
}
.JsonEditor {
width: 250px;
min-height: 100px;
padding: 8px;
border: 1px solid var(--theme-color-primary);
border-radius: 4px;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default);
font-family: monospace;
font-size: 11px;
resize: vertical;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
box-shadow: 0 0 0 2px var(--theme-color-primary-muted);
}
}
.JsonActions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.SaveButton,
.CancelButton {
padding: 4px 12px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.SaveButton {
background-color: var(--theme-color-primary);
color: var(--theme-color-fg-on-primary);
border: none;
&:hover {
opacity: 0.9;
}
}
.CancelButton {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
&:hover {
background-color: var(--theme-color-bg-2);
}
}
.Error {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
padding: 4px 8px;
background-color: var(--theme-color-danger);
color: white;
font-size: 10px;
border-radius: 4px;
z-index: 10;
white-space: nowrap;
}

View File

@@ -0,0 +1,222 @@
/**
* CellEditor
*
* Inline cell editor component with type-aware input controls.
* Handles String, Number, Boolean, Date, Object, and Array types.
*
* @module panels/databrowser/CellEditor
* @since 1.2.0
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import css from './CellEditor.module.scss';
export interface CellEditorProps {
/** Current value */
value: unknown;
/** Column type */
type: string;
/** Called when value saved */
onSave: (value: unknown) => void;
/** Called when editing cancelled */
onCancel: () => void;
/** Error message to display */
error?: string | null;
}
/**
* CellEditor component - type-aware inline editor
*/
export function CellEditor({ value, type, onSave, onCancel, error }: CellEditorProps) {
const [editValue, setEditValue] = useState<string>(() => {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
});
const [jsonError, setJsonError] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
// Focus input on mount with delay to prevent immediate blur
useEffect(() => {
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
if (inputRef.current instanceof HTMLInputElement) {
inputRef.current.select();
}
setIsFocused(true);
}
}, 50);
return () => clearTimeout(timer);
}, []);
// Handle keyboard events
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && type !== 'Object' && type !== 'Array') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
onCancel();
}
},
[type, onCancel]
);
// Handle save with type conversion
const handleSave = useCallback(() => {
let finalValue: unknown = editValue;
try {
switch (type) {
case 'Number':
if (editValue.trim() === '') {
finalValue = null;
} else {
finalValue = parseFloat(editValue);
if (isNaN(finalValue as number)) {
setJsonError('Invalid number');
return;
}
}
break;
case 'Boolean':
// Boolean is handled by checkbox, just use editValue
finalValue = editValue === 'true';
break;
case 'Date':
if (editValue.trim() === '') {
finalValue = null;
} else {
finalValue = new Date(editValue).toISOString();
}
break;
case 'Object':
case 'Array':
if (editValue.trim() === '') {
finalValue = type === 'Array' ? [] : {};
} else {
finalValue = JSON.parse(editValue);
}
break;
default:
// String - use as-is
finalValue = editValue;
}
setJsonError(null);
onSave(finalValue);
} catch (err) {
setJsonError('Invalid JSON');
}
}, [editValue, type, onSave]);
// Boolean - render checkbox
if (type === 'Boolean') {
return (
<div className={css.CellEditor}>
<input
type="checkbox"
checked={value === true || editValue === 'true'}
onChange={(e) => {
setEditValue(e.target.checked ? 'true' : 'false');
onSave(e.target.checked);
}}
onKeyDown={(e) => e.key === 'Escape' && onCancel()}
/>
{error && <div className={css.Error}>{error}</div>}
</div>
);
}
// Date - render datetime input
if (type === 'Date') {
const dateValue = value ? new Date(value as string).toISOString().slice(0, 16) : '';
return (
<div className={css.CellEditor}>
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type="datetime-local"
className={css.DateInput}
value={editValue || dateValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => isFocused && handleSave()}
/>
{(error || jsonError) && <div className={css.Error}>{error || jsonError}</div>}
</div>
);
}
// Object/Array - render textarea
if (type === 'Object' || type === 'Array') {
return (
<div className={css.CellEditor}>
<textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
className={css.JsonEditor}
value={editValue}
onChange={(e) => {
setEditValue(e.target.value);
setJsonError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') onCancel();
}}
rows={5}
spellCheck={false}
/>
<div className={css.JsonActions}>
<button className={css.SaveButton} onClick={handleSave}>
Save
</button>
<button className={css.CancelButton} onClick={onCancel}>
Cancel
</button>
</div>
{(error || jsonError) && <div className={css.Error}>{error || jsonError}</div>}
</div>
);
}
// Number - render number input
if (type === 'Number') {
return (
<div className={css.CellEditor}>
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type="number"
className={css.Input}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => isFocused && handleSave()}
step="any"
/>
{(error || jsonError) && <div className={css.Error}>{error || jsonError}</div>}
</div>
);
}
// Default: String - render text input
return (
<div className={css.CellEditor}>
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type="text"
className={css.Input}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => isFocused && handleSave()}
/>
{(error || jsonError) && <div className={css.Error}>{error || jsonError}</div>}
</div>
);
}

View File

@@ -0,0 +1,152 @@
/**
* DataBrowser styles
* Uses theme tokens per UI-STYLING-GUIDE.md
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
max-height: 80vh;
width: 100%;
max-width: 1200px;
background-color: var(--theme-color-bg-2);
border-radius: 8px;
overflow: hidden;
}
.Header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-3);
}
.HeaderIcon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background-color: var(--theme-color-primary);
border-radius: 6px;
color: var(--theme-color-fg-on-primary);
}
.Toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--theme-color-border-default);
gap: 12px;
}
.TableSelect {
padding: 6px 12px;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
font-size: 13px;
min-width: 150px;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
option {
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
}
}
.SearchInput {
padding: 6px 12px;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
font-size: 13px;
min-width: 200px;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
}
.BulkActions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background-color: var(--theme-color-notice-bg);
border-bottom: 1px solid var(--theme-color-border-default);
}
.Error {
padding: 12px 16px;
background-color: var(--theme-color-danger-bg);
border-bottom: 1px solid var(--theme-color-border-default);
color: var(--theme-color-danger);
}
.Content {
flex: 1;
overflow: auto;
min-height: 300px;
}
.Loading,
.EmptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
padding: 40px;
}
.Pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-top: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-3);
}
.PageControls {
display: flex;
align-items: center;
gap: 4px;
}
.PageButton {
padding: 4px 8px;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-fg-default);
font-size: 12px;
cursor: pointer;
&:hover:not(:disabled) {
background-color: var(--theme-color-bg-3);
border-color: var(--theme-color-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@@ -0,0 +1,475 @@
/**
* DataBrowser
*
* Main data browser panel for viewing and editing records in local backend tables.
* Provides a spreadsheet-like interface with inline editing, search, and pagination.
*
* @module panels/databrowser/DataBrowser
* @since 1.2.0
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import css from './DataBrowser.module.scss';
import { DataGrid } from './DataGrid';
import { NewRecordModal } from './NewRecordModal';
const { ipcRenderer } = window.require('electron');
/** Column definition from schema */
export interface ColumnDef {
name: string;
type: string;
required?: boolean;
default?: unknown;
targetClass?: string;
}
/** Table schema */
export interface TableSchema {
name: string;
columns: ColumnDef[];
}
export interface DataBrowserProps {
/** Backend ID to browse */
backendId: string;
/** Backend display name */
backendName: string;
/** Initial table to show (optional) */
initialTable?: string;
/** Close callback */
onClose: () => void;
}
const PAGE_SIZE = 50;
/**
* DataBrowser component - main data browsing UI
*/
export function DataBrowser({ backendId, backendName, initialTable, onClose }: DataBrowserProps) {
// State
const [tables, setTables] = useState<string[]>([]);
const [selectedTable, setSelectedTable] = useState<string | null>(initialTable || null);
const [schema, setSchema] = useState<TableSchema | null>(null);
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedRecords, setSelectedRecords] = useState<Set<string>>(new Set());
const [showNewRecord, setShowNewRecord] = useState(false);
const [error, setError] = useState<string | null>(null);
// System columns shown for all tables
const systemColumns: ColumnDef[] = useMemo(
() => [
{ name: 'id', type: 'String', required: true },
{ name: 'createdAt', type: 'Date', required: true },
{ name: 'updatedAt', type: 'Date', required: true }
],
[]
);
// All columns = system + user columns (deduplicated by name)
const allColumns = useMemo(() => {
if (!schema) return systemColumns;
// Deduplicate by column name, system columns first
const seen = new Set(systemColumns.map((c) => c.name));
const userColumns = (schema.columns || []).filter((c: ColumnDef) => !seen.has(c.name));
return [...systemColumns, ...userColumns];
}, [schema, systemColumns]);
// Load table list
const loadTables = useCallback(async () => {
try {
const result = await ipcRenderer.invoke('backend:getSchema', backendId);
const tableNames = result.tables.map((t: { name: string }) => t.name);
setTables(tableNames);
// Auto-select first table if none selected
if (!selectedTable && tableNames.length > 0) {
setSelectedTable(tableNames[0]);
}
} catch (err) {
console.error('Failed to load tables:', err);
setError('Failed to load tables');
}
}, [backendId, selectedTable]);
// Load data for selected table
const loadData = useCallback(async () => {
if (!selectedTable) return;
setLoading(true);
setError(null);
try {
// Load schema for this table
const tableSchema = await ipcRenderer.invoke('backend:getTableSchema', backendId, selectedTable);
setSchema(tableSchema);
// Build query
const queryOptions: {
collection: string;
limit: number;
skip: number;
sort: string[];
count: boolean;
where?: Record<string, unknown>;
} = {
collection: selectedTable,
limit: PAGE_SIZE,
skip: page * PAGE_SIZE,
sort: ['-createdAt'],
count: true
};
// Apply search (simple contains search across string fields)
if (searchQuery.trim()) {
const stringColumns = tableSchema?.columns?.filter((c: ColumnDef) => c.type === 'String') || [];
const searchConditions = stringColumns.map((col: ColumnDef) => ({
[col.name]: { contains: searchQuery.trim() }
}));
// Also search id
searchConditions.push({ id: { contains: searchQuery.trim() } });
if (searchConditions.length > 0) {
queryOptions.where = { $or: searchConditions };
}
}
// Load records
const result = await ipcRenderer.invoke('backend:queryRecords', backendId, queryOptions);
setRecords(result.results || []);
setTotalCount(result.count || 0);
} catch (err) {
console.error('Failed to load data:', err);
setError('Failed to load data');
} finally {
setLoading(false);
}
}, [backendId, selectedTable, page, searchQuery]);
// Initial load
useEffect(() => {
loadTables();
}, [loadTables]);
// Load data when table, page, or search changes
useEffect(() => {
loadData();
}, [loadData]);
// Reset page when search or table changes
useEffect(() => {
setPage(0);
setSelectedRecords(new Set());
}, [selectedTable, searchQuery]);
// Save cell (inline edit)
const handleSaveCell = useCallback(
async (recordId: string, field: string, value: unknown) => {
if (!selectedTable) return;
try {
await ipcRenderer.invoke('backend:saveRecord', backendId, selectedTable, recordId, {
[field]: value
});
// Update local state
setRecords((prev) =>
prev.map((r) => (r.id === recordId ? { ...r, [field]: value, updatedAt: new Date().toISOString() } : r))
);
} catch (err) {
console.error('Failed to save cell:', err);
throw err; // Re-throw so CellEditor can show error
}
},
[backendId, selectedTable]
);
// Delete single record
const handleDeleteRecord = useCallback(
async (recordId: string) => {
if (!selectedTable) return;
if (!window.confirm('Delete this record?')) return;
try {
await ipcRenderer.invoke('backend:deleteRecord', backendId, selectedTable, recordId);
loadData();
} catch (err) {
console.error('Failed to delete record:', err);
setError('Failed to delete record');
}
},
[backendId, selectedTable, loadData]
);
// Bulk delete
const handleBulkDelete = useCallback(async () => {
if (selectedRecords.size === 0 || !selectedTable) return;
if (!window.confirm(`Delete ${selectedRecords.size} records?`)) return;
try {
for (const recordId of selectedRecords) {
await ipcRenderer.invoke('backend:deleteRecord', backendId, selectedTable, recordId);
}
setSelectedRecords(new Set());
loadData();
} catch (err) {
console.error('Failed to bulk delete:', err);
setError('Failed to delete some records');
}
}, [backendId, selectedTable, selectedRecords, loadData]);
// Export to CSV
const handleExport = useCallback(() => {
if (records.length === 0 || !schema) return;
// Build CSV
const headers = allColumns.map((c) => c.name).join(',');
const rows = records.map((record) =>
allColumns
.map((col) => {
const value = record[col.name];
if (value === null || value === undefined) return '';
if (typeof value === 'object') return `"${JSON.stringify(value).replace(/"/g, '""')}"`;
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return String(value);
})
.join(',')
);
const csv = [headers, ...rows].join('\n');
// Download
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedTable}.csv`;
a.click();
URL.revokeObjectURL(url);
}, [records, allColumns, selectedTable, schema]);
// Toggle record selection
const handleSelectRecord = useCallback((recordId: string, selected: boolean) => {
setSelectedRecords((prev) => {
const next = new Set(prev);
if (selected) {
next.add(recordId);
} else {
next.delete(recordId);
}
return next;
});
}, []);
// Select/deselect all
const handleSelectAll = useCallback(
(selected: boolean) => {
if (selected) {
setSelectedRecords(new Set(records.map((r) => r.id as string)));
} else {
setSelectedRecords(new Set());
}
},
[records]
);
// Pagination
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const startRecord = page * PAGE_SIZE + 1;
const endRecord = Math.min((page + 1) * PAGE_SIZE, totalCount);
return (
<div className={css.Root}>
{/* Header */}
<div className={css.Header}>
<HStack hasSpacing>
<div className={css.HeaderIcon}>
<Icon icon={IconName.CloudData} size={IconSize.Small} />
</div>
<VStack>
<Text textType={TextType.DefaultContrast}>Data Browser</Text>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
{backendName}
</Text>
</VStack>
</HStack>
<IconButton icon={IconName.Close} onClick={onClose} />
</div>
{/* Toolbar */}
<div className={css.Toolbar}>
<HStack hasSpacing>
{/* Table selector */}
<select
className={css.TableSelect}
value={selectedTable || ''}
onChange={(e) => setSelectedTable(e.target.value || null)}
>
<option value="" disabled>
Select table...
</option>
{tables.map((table) => (
<option key={table} value={table}>
{table}
</option>
))}
</select>
{/* Search */}
<input
type="text"
className={css.SearchInput}
placeholder="Search records..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* Refresh */}
<IconButton icon={IconName.Refresh} onClick={loadData} />
</HStack>
<HStack hasSpacing>
{selectedTable && (
<>
<PrimaryButton
label="+ New Record"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={() => setShowNewRecord(true)}
/>
<PrimaryButton
label="Export CSV"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={handleExport}
isDisabled={records.length === 0}
/>
</>
)}
</HStack>
</div>
{/* Bulk actions bar */}
{selectedRecords.size > 0 && (
<div className={css.BulkActions}>
<Text textType={TextType.Default}>{selectedRecords.size} records selected</Text>
<HStack hasSpacing>
<PrimaryButton
label="Delete Selected"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Danger}
onClick={handleBulkDelete}
/>
<PrimaryButton
label="Clear Selection"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={() => setSelectedRecords(new Set())}
/>
</HStack>
</div>
)}
{/* Error message */}
{error && (
<div className={css.Error}>
<Text textType={TextType.Default}>{error}</Text>
</div>
)}
{/* Content area */}
<div className={css.Content}>
{loading ? (
<div className={css.Loading}>
<Text textType={TextType.Shy}>Loading...</Text>
</div>
) : !selectedTable ? (
<div className={css.EmptyState}>
<Text textType={TextType.Shy}>Select a table to browse data</Text>
</div>
) : records.length === 0 ? (
<div className={css.EmptyState}>
<Text textType={TextType.Shy}>No records found</Text>
{!searchQuery && (
<PrimaryButton
label="Create First Record"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={() => setShowNewRecord(true)}
UNSAFE_style={{ marginTop: '12px' }}
/>
)}
</div>
) : (
<DataGrid
columns={allColumns}
records={records}
selectedRecords={selectedRecords}
onSelectRecord={handleSelectRecord}
onSelectAll={handleSelectAll}
onSaveCell={handleSaveCell}
onDeleteRecord={handleDeleteRecord}
/>
)}
</div>
{/* Pagination */}
{selectedTable && totalCount > 0 && (
<div className={css.Pagination}>
<Text textType={TextType.Shy} style={{ fontSize: '12px' }}>
Showing {startRecord.toLocaleString()}-{endRecord.toLocaleString()} of {totalCount.toLocaleString()} records
</Text>
<div className={css.PageControls}>
<button className={css.PageButton} onClick={() => setPage(0)} disabled={page === 0}>
First
</button>
<button className={css.PageButton} onClick={() => setPage(page - 1)} disabled={page === 0}>
Previous
</button>
<Text textType={TextType.Shy} style={{ fontSize: '12px', margin: '0 8px' }}>
Page {page + 1} of {totalPages}
</Text>
<button className={css.PageButton} onClick={() => setPage(page + 1)} disabled={page >= totalPages - 1}>
Next
</button>
<button
className={css.PageButton}
onClick={() => setPage(totalPages - 1)}
disabled={page >= totalPages - 1}
>
Last
</button>
</div>
</div>
)}
{/* New Record Modal */}
{showNewRecord && schema && selectedTable && (
<NewRecordModal
backendId={backendId}
tableName={selectedTable}
columns={schema.columns}
onClose={() => setShowNewRecord(false)}
onSuccess={() => {
setShowNewRecord(false);
loadData();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
/**
* DataGrid styles
*/
.GridContainer {
width: 100%;
height: 100%;
overflow: auto;
}
.Grid {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.Grid thead {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--theme-color-bg-3);
}
.Grid th,
.Grid td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--theme-color-border-default);
}
.HeaderCell {
background-color: var(--theme-color-bg-3);
font-weight: 500;
color: var(--theme-color-fg-default);
white-space: nowrap;
}
.HeaderContent {
display: flex;
align-items: center;
gap: 8px;
}
.HeaderName {
font-weight: 500;
}
.TypeBadge {
font-size: 9px;
padding: 2px 6px;
border-radius: 4px;
background-color: var(--theme-color-bg-1);
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
font-weight: 600;
}
.TypeString {
background-color: var(--theme-color-primary);
color: var(--theme-color-fg-on-primary);
}
.TypeNumber {
background-color: var(--theme-color-success);
color: white;
}
.TypeBoolean {
background-color: var(--theme-color-notice);
color: white;
}
.TypeDate {
background-color: #8b5cf6;
color: white;
}
.TypeObject {
background-color: #ec4899;
color: white;
}
.TypeArray {
background-color: #6366f1;
color: white;
}
.TypePointer {
background-color: var(--theme-color-danger);
color: white;
}
.CheckboxCol {
width: 40px;
text-align: center;
padding: 8px;
input[type='checkbox'] {
cursor: pointer;
}
}
.ActionsCol {
width: 60px;
text-align: center;
padding: 8px;
color: var(--theme-color-fg-default);
}
.Cell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--theme-color-fg-default);
}
.CellValue {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--theme-color-fg-default);
}
.EditableCell {
cursor: pointer;
&:hover {
background-color: var(--theme-color-bg-3);
}
}
.ReadOnlyCell {
color: var(--theme-color-fg-default-shy);
font-family: monospace;
font-size: 11px;
}
.SelectedRow {
background-color: var(--theme-color-primary-muted);
}
.SelectedRow:hover {
background-color: var(--theme-color-primary-muted);
}

View File

@@ -0,0 +1,210 @@
/**
* DataGrid
*
* Spreadsheet-style grid for displaying and editing records.
* Supports inline editing, selection, and type-aware cell rendering.
*
* @module panels/databrowser/DataGrid
* @since 1.2.0
*/
import React, { useCallback, useState } from 'react';
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
import { CellEditor } from './CellEditor';
import { ColumnDef } from './DataBrowser';
import css from './DataGrid.module.scss';
export interface DataGridProps {
/** Column definitions */
columns: ColumnDef[];
/** Records to display */
records: Record<string, unknown>[];
/** Set of selected record IDs */
selectedRecords: Set<string>;
/** Called when record selection changes */
onSelectRecord: (recordId: string, selected: boolean) => void;
/** Called when select all toggled */
onSelectAll: (selected: boolean) => void;
/** Called when cell value saved */
onSaveCell: (recordId: string, field: string, value: unknown) => Promise<void>;
/** Called when delete requested */
onDeleteRecord: (recordId: string) => void;
}
/** System fields that are read-only */
const READ_ONLY_FIELDS = new Set(['id', 'createdAt', 'updatedAt']);
/**
* Format a cell value for display
*/
function formatCellValue(value: unknown, type: string): string {
if (value === null || value === undefined) return '';
switch (type) {
case 'Boolean':
return value ? '✓' : '';
case 'Date':
try {
return new Date(value as string).toLocaleString();
} catch {
return String(value);
}
case 'Object':
case 'Array':
return JSON.stringify(value);
default:
return String(value);
}
}
/**
* Get CSS class for type badge
*/
function getTypeBadgeClass(type: string): string {
switch (type) {
case 'String':
return css.TypeString;
case 'Number':
return css.TypeNumber;
case 'Boolean':
return css.TypeBoolean;
case 'Date':
return css.TypeDate;
case 'Object':
return css.TypeObject;
case 'Array':
return css.TypeArray;
case 'Pointer':
case 'Relation':
return css.TypePointer;
default:
return '';
}
}
/**
* DataGrid component
*/
export function DataGrid({
columns,
records,
selectedRecords,
onSelectRecord,
onSelectAll,
onSaveCell,
onDeleteRecord
}: DataGridProps) {
const [editingCell, setEditingCell] = useState<{ recordId: string; field: string } | null>(null);
const [savingError, setSavingError] = useState<string | null>(null);
// Check if all records are selected
const allSelected = records.length > 0 && selectedRecords.size === records.length;
// Handle cell click - start editing if editable
const handleCellClick = useCallback((recordId: string, field: string) => {
if (READ_ONLY_FIELDS.has(field)) return;
setEditingCell({ recordId, field });
setSavingError(null);
}, []);
// Handle save from CellEditor
const handleSave = useCallback(
async (recordId: string, field: string, value: unknown) => {
try {
await onSaveCell(recordId, field, value);
setEditingCell(null);
setSavingError(null);
} catch (err) {
setSavingError('Failed to save');
// Don't close editor on error
}
},
[onSaveCell]
);
// Handle cancel editing
const handleCancel = useCallback(() => {
setEditingCell(null);
setSavingError(null);
}, []);
return (
<div className={css.GridContainer}>
<table className={css.Grid}>
<thead>
<tr>
{/* Checkbox column */}
<th className={css.CheckboxCol}>
<input type="checkbox" checked={allSelected} onChange={(e) => onSelectAll(e.target.checked)} />
</th>
{/* Data columns */}
{columns.map((col) => (
<th key={col.name} className={css.HeaderCell}>
<div className={css.HeaderContent}>
<span className={css.HeaderName}>{col.name}</span>
<span className={`${css.TypeBadge} ${getTypeBadgeClass(col.type)}`}>{col.type}</span>
</div>
</th>
))}
{/* Actions column */}
<th className={css.ActionsCol}>Actions</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const recordId = record.id as string;
const isSelected = selectedRecords.has(recordId);
return (
<tr key={recordId} className={isSelected ? css.SelectedRow : ''}>
{/* Checkbox */}
<td className={css.CheckboxCol}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onSelectRecord(recordId, e.target.checked)}
/>
</td>
{/* Data cells */}
{columns.map((col) => {
const value = record[col.name];
const isEditing = editingCell?.recordId === recordId && editingCell?.field === col.name;
const isReadOnly = READ_ONLY_FIELDS.has(col.name);
return (
<td
key={col.name}
className={`${css.Cell} ${isReadOnly ? css.ReadOnlyCell : css.EditableCell}`}
onClick={() => !isEditing && handleCellClick(recordId, col.name)}
>
{isEditing ? (
<CellEditor
value={value}
type={col.type}
onSave={(newValue) => handleSave(recordId, col.name, newValue)}
onCancel={handleCancel}
error={savingError}
/>
) : (
<div className={css.CellValue} title={String(value ?? '')}>
{formatCellValue(value, col.type)}
</div>
)}
</td>
);
})}
{/* Actions */}
<td className={css.ActionsCol}>
<IconButton icon={IconName.Trash} size={IconSize.Small} onClick={() => onDeleteRecord(recordId)} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,121 @@
/**
* NewRecordModal styles
*/
.Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.Modal {
background-color: var(--theme-color-bg-2);
border-radius: 8px;
width: 100%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.Header {
padding: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-3);
border-radius: 8px 8px 0 0;
}
.Content {
padding: 16px;
overflow-y: auto;
flex: 1;
}
.Field {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
}
.Label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
color: var(--theme-color-fg-default);
font-weight: 500;
}
.Required {
color: var(--theme-color-danger);
}
.TypeHint {
font-size: 10px;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
padding: 2px 6px;
background-color: var(--theme-color-bg-1);
border-radius: 4px;
}
.Input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
font-size: 13px;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.JsonInput {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
font-size: 12px;
font-family: monospace;
resize: vertical;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.Error {
padding: 12px;
background-color: var(--theme-color-danger-bg);
border-radius: 4px;
margin-top: 16px;
color: var(--theme-color-danger);
}
.Footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px;
border-top: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-3);
border-radius: 0 0 8px 8px;
}

View File

@@ -0,0 +1,218 @@
/**
* NewRecordModal
*
* Modal dialog for creating a new record with type-aware form fields.
*
* @module panels/databrowser/NewRecordModal
* @since 1.2.0
*/
import React, { useCallback, useState } from 'react';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { ColumnDef } from './DataBrowser';
import css from './NewRecordModal.module.scss';
const { ipcRenderer } = window.require('electron');
export interface NewRecordModalProps {
/** Backend ID */
backendId: string;
/** Table name */
tableName: string;
/** Column definitions */
columns: ColumnDef[];
/** Called when modal closed */
onClose: () => void;
/** Called when record created */
onSuccess: () => void;
}
/**
* Get default value for a column type
*/
function getDefaultValue(type: string): unknown {
switch (type) {
case 'Boolean':
return false;
case 'Number':
return 0;
case 'Object':
return {};
case 'Array':
return [];
default:
return '';
}
}
/**
* NewRecordModal component
*/
export function NewRecordModal({ backendId, tableName, columns, onClose, onSuccess }: NewRecordModalProps) {
// Initialize form state with default values
const [formData, setFormData] = useState<Record<string, unknown>>(() => {
const initial: Record<string, unknown> = {};
columns.forEach((col) => {
initial[col.name] = col.default !== undefined ? col.default : getDefaultValue(col.type);
});
return initial;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Handle field change
const handleChange = useCallback((field: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
setError(null);
}, []);
// Handle form submit
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
// Validate required fields
for (const col of columns) {
if (col.required) {
const value = formData[col.name];
if (value === null || value === undefined || value === '') {
setError(`${col.name} is required`);
return;
}
}
}
setSaving(true);
setError(null);
try {
await ipcRenderer.invoke('backend:createRecord', backendId, tableName, formData);
onSuccess();
} catch (err) {
console.error('Failed to create record:', err);
setError('Failed to create record');
} finally {
setSaving(false);
}
},
[backendId, tableName, formData, columns, onSuccess]
);
// Render form field based on type
const renderField = (col: ColumnDef) => {
const value = formData[col.name];
switch (col.type) {
case 'Boolean':
return (
<input type="checkbox" checked={value === true} onChange={(e) => handleChange(col.name, e.target.checked)} />
);
case 'Number':
return (
<input
type="number"
className={css.Input}
value={String(value ?? '')}
onChange={(e) => handleChange(col.name, e.target.value ? parseFloat(e.target.value) : null)}
step="any"
/>
);
case 'Date':
return (
<input
type="datetime-local"
className={css.Input}
value={value ? new Date(value as string).toISOString().slice(0, 16) : ''}
onChange={(e) => handleChange(col.name, e.target.value ? new Date(e.target.value).toISOString() : null)}
/>
);
case 'Object':
case 'Array':
return (
<textarea
className={css.JsonInput}
value={typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value ?? '')}
onChange={(e) => {
try {
handleChange(col.name, JSON.parse(e.target.value));
} catch {
// Keep as string while editing, parse on blur
}
}}
rows={3}
spellCheck={false}
placeholder={col.type === 'Array' ? '[]' : '{}'}
/>
);
default:
// String
return (
<input
type="text"
className={css.Input}
value={String(value ?? '')}
onChange={(e) => handleChange(col.name, e.target.value)}
placeholder={`Enter ${col.name}...`}
/>
);
}
};
return (
<div className={css.Overlay} onClick={onClose}>
<div className={css.Modal} onClick={(e) => e.stopPropagation()}>
<div className={css.Header}>
<Text textType={TextType.DefaultContrast}>New Record in {tableName}</Text>
</div>
<form onSubmit={handleSubmit}>
<div className={css.Content}>
{columns.length === 0 ? (
<Text textType={TextType.Shy}>No columns defined for this table</Text>
) : (
columns.map((col) => (
<div key={col.name} className={css.Field}>
<label className={css.Label}>
{col.name}
{col.required && <span className={css.Required}>*</span>}
<span className={css.TypeHint}>{col.type}</span>
</label>
{renderField(col)}
</div>
))
)}
{error && (
<div className={css.Error}>
<Text textType={TextType.Default}>{error}</Text>
</div>
)}
</div>
<div className={css.Footer}>
<PrimaryButton
label="Cancel"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onClose}
/>
<PrimaryButton
label={saving ? 'Creating...' : 'Create Record'}
size={PrimaryButtonSize.Small}
isDisabled={saving || columns.length === 0}
onClick={handleSubmit}
/>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Data Browser Panel
*
* Export all data browser components.
*
* @module panels/databrowser
* @since 1.2.0
*/
export { DataBrowser, type DataBrowserProps, type ColumnDef, type TableSchema } from './DataBrowser';
export { DataGrid, type DataGridProps } from './DataGrid';
export { CellEditor, type CellEditorProps } from './CellEditor';
export { NewRecordModal, type NewRecordModalProps } from './NewRecordModal';

View File

@@ -5,11 +5,9 @@ import { isExpressionParameter, createExpressionParameter } from '@noodl-models/
import { NodeLibrary } from '@noodl-models/nodelibrary'; import { NodeLibrary } from '@noodl-models/nodelibrary';
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver'; import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
import { import { PropertyPanelInputType } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
PropertyPanelInput,
PropertyPanelInputType
} from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { PropertyPanelInputWithExpressionModal } from '../components/PropertyPanelInputWithExpressionModal';
import { TypeView } from '../TypeView'; import { TypeView } from '../TypeView';
import { getEditType } from '../utils'; import { getEditType } from '../utils';
@@ -154,10 +152,12 @@ export class BasicType extends TypeView {
} }
this.isDefault = false; this.isDefault = false;
// Re-render to update UI and sync modal with inline input
setTimeout(() => this.renderReact(), 0);
} }
}; };
this.root.render(React.createElement(PropertyPanelInput, props)); this.root.render(React.createElement(PropertyPanelInputWithExpressionModal, props));
} }
dispose() { dispose() {

View File

@@ -0,0 +1,80 @@
.Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.Modal {
background-color: var(--theme-color-bg-2, #1a1a1a);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 700px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.Header {
padding: 16px 20px;
border-bottom: 1px solid var(--theme-color-border-default, rgba(255, 255, 255, 0.1));
display: flex;
align-items: center;
justify-content: space-between;
}
.Title {
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-highlight, #ffffff);
margin: 0;
}
.Body {
padding: 16px 20px;
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.HelpText {
padding: 8px 12px;
background-color: var(--theme-color-bg-3, rgba(255, 255, 255, 0.05));
border-radius: 4px;
font-size: 12px;
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.6));
code {
background-color: var(--theme-color-bg-1, rgba(99, 102, 241, 0.2));
padding: 2px 4px;
border-radius: 2px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 11px;
color: var(--theme-color-primary, #6366f1);
}
}
.EditorWrapper {
flex: 1;
min-height: 300px;
border-radius: 4px;
overflow: hidden;
}
.Footer {
padding: 12px 20px;
border-top: 1px solid var(--theme-color-border-default, rgba(255, 255, 255, 0.1));
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -0,0 +1,126 @@
/**
* ExpressionEditorModal
*
* A modal dialog for editing expressions in a full-featured code editor.
* Uses the new CodeMirror-based JavaScriptEditor component.
*/
import React, { useState, useCallback, useEffect } from 'react';
import ReactDOM from 'react-dom';
import { JavaScriptEditor } from '@noodl-core-ui/components/code-editor';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Text } from '@noodl-core-ui/components/typography/Text';
import css from './ExpressionEditorModal.module.scss';
export interface ExpressionEditorModalProps {
/** Whether the modal is open */
isOpen: boolean;
/** The property name being edited */
propertyName: string;
/** The initial expression value */
expression: string;
/** Called when expression is applied */
onApply: (expression: string) => void;
/** Called when modal is closed/cancelled */
onClose: () => void;
}
/**
* Modal for editing expressions in a larger code editor
*/
export function ExpressionEditorModal({
isOpen,
propertyName,
expression,
onApply,
onClose
}: ExpressionEditorModalProps) {
const [localExpression, setLocalExpression] = useState(expression);
// Reset local expression when modal opens with new value
useEffect(() => {
if (isOpen) {
setLocalExpression(expression);
}
}, [isOpen, expression]);
// Handle keyboard shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === 'Escape') {
onClose();
}
},
[isOpen, onClose]
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const handleApply = useCallback(() => {
onApply(localExpression);
onClose();
}, [localExpression, onApply, onClose]);
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
// Handle Ctrl+Enter to apply
const handleSave = useCallback(
(code: string) => {
onApply(code);
onClose();
},
[onApply, onClose]
);
if (!isOpen) return null;
// Render into portal to escape any z-index issues
return ReactDOM.createPortal(
<div className={css['Overlay']} onClick={handleCancel}>
<div className={css['Modal']} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className={css['Header']}>
<span className={css['Title']}>Edit Expression: {propertyName}</span>
</div>
{/* Body with editor */}
<div className={css['Body']}>
<div className={css['HelpText']}>
<Text>
Available: <code>Noodl.Variables.x</code>, <code>Noodl.Objects.id.prop</code>,{' '}
<code>Noodl.Arrays.id</code>, and Math functions like <code>min()</code>, <code>max()</code>,{' '}
<code>round()</code>
</Text>
</div>
<div className={css['EditorWrapper']}>
<JavaScriptEditor
value={localExpression}
onChange={setLocalExpression}
onSave={handleSave}
validationType="expression"
height={300}
width="100%"
placeholder="// Enter your expression here, e.g. Noodl.Variables.count * 2"
/>
</div>
</div>
{/* Footer with buttons */}
<div className={css['Footer']}>
<PrimaryButton label="Cancel" variant={PrimaryButtonVariant.Ghost} onClick={handleCancel} />
<PrimaryButton label="Apply" variant={PrimaryButtonVariant.Cta} onClick={handleApply} />
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,2 @@
export { ExpressionEditorModal } from './ExpressionEditorModal';
export type { ExpressionEditorModalProps } from './ExpressionEditorModal';

View File

@@ -0,0 +1,68 @@
/**
* PropertyPanelInputWithExpressionModal
*
* Wraps PropertyPanelInput with ExpressionEditorModal state management.
* Used by BasicType.ts to provide expression modal support.
*/
import React, { useState, useCallback } from 'react';
import {
PropertyPanelInput,
PropertyPanelInputProps
} from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { ExpressionEditorModal } from '../ExpressionEditorModal';
export interface PropertyPanelInputWithExpressionModalProps extends PropertyPanelInputProps {
/** Property name for the modal title */
propertyName?: string;
}
export function PropertyPanelInputWithExpressionModal({
propertyName,
label,
expression = '',
expressionMode,
onExpressionChange,
...props
}: PropertyPanelInputWithExpressionModalProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleExpand = useCallback(() => {
setIsModalOpen(true);
}, []);
const handleModalClose = useCallback(() => {
setIsModalOpen(false);
}, []);
const handleModalApply = useCallback(
(newExpression: string) => {
if (onExpressionChange) {
onExpressionChange(newExpression);
}
},
[onExpressionChange]
);
return (
<>
<PropertyPanelInput
label={label}
expression={expression}
expressionMode={expressionMode}
onExpressionChange={onExpressionChange}
onExpressionExpand={handleExpand}
{...props}
/>
<ExpressionEditorModal
isOpen={isModalOpen}
propertyName={propertyName || label}
expression={expression}
onApply={handleModalApply}
onClose={handleModalClose}
/>
</>
);
}

View File

@@ -0,0 +1,113 @@
/**
* AddColumnForm styles
*/
.Root {
padding: 8px 12px;
background: var(--theme-color-bg-2);
border-top: 1px solid var(--theme-color-border-default);
}
.Row {
display: grid;
grid-template-columns: 2fr 1.2fr 0.8fr 1.5fr auto;
gap: 8px;
align-items: center;
}
.Field {
display: flex;
align-items: center;
}
.FieldSmall {
display: flex;
align-items: center;
justify-content: center;
}
.Actions {
display: flex;
align-items: center;
gap: 4px;
}
.Input {
width: 100%;
padding: 6px 8px;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
color: var(--theme-color-fg-default);
font-size: 12px;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.Select {
width: 100%;
padding: 6px 8px;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
color: var(--theme-color-fg-default);
font-size: 12px;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.Error {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 0 0;
color: var(--theme-color-danger);
font-size: 12px;
}
// Inline column rename styles
.RenameWrapper {
position: relative;
}
.RenameInput {
width: 100%;
padding: 4px 6px;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-primary);
border-radius: 2px;
color: var(--theme-color-fg-default);
font-size: 12px;
&:focus {
outline: none;
}
&:disabled {
opacity: 0.6;
}
}
.RenameError {
position: absolute;
top: 100%;
left: 0;
padding: 4px 6px;
background: var(--theme-color-danger);
color: var(--theme-color-fg-default);
font-size: 10px;
border-radius: 2px;
white-space: nowrap;
z-index: 10;
}

View File

@@ -0,0 +1,239 @@
/**
* AddColumnForm
*
* Inline form for adding a new column to an existing table.
* Also handles column renaming via inline edit.
*
* @module schemamanager/AddColumnForm
* @since 1.2.0
*/
import React, { useCallback, useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import css from './AddColumnForm.module.scss';
const { ipcRenderer } = window.require('electron');
/** Supported column types */
const COLUMN_TYPES = ['String', 'Number', 'Boolean', 'Date', 'Object', 'Array'] as const;
type ColumnType = (typeof COLUMN_TYPES)[number];
export interface AddColumnFormProps {
/** Backend ID */
backendId: string;
/** Table name */
tableName: string;
/** Existing column names (for validation) */
existingColumns: string[];
/** Called when column is added */
onSuccess: () => void;
/** Called when form is cancelled */
onCancel: () => void;
}
/**
* Validate column name
*/
function validateColumnName(name: string, existingNames: string[]): string | null {
if (!name.trim()) {
return 'Column name is required';
}
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
return 'Must start with letter, alphanumeric and underscore only';
}
if (['objectId', 'createdAt', 'updatedAt', 'ACL'].includes(name)) {
return 'Reserved column name';
}
if (existingNames.includes(name)) {
return 'Column already exists';
}
return null;
}
/**
* AddColumnForm component - inline form for adding columns
*/
export function AddColumnForm({ backendId, tableName, existingColumns, onSuccess, onCancel }: AddColumnFormProps) {
const [name, setName] = useState('');
const [type, setType] = useState<ColumnType>('String');
const [required, setRequired] = useState(false);
const [defaultValue, setDefaultValue] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(async () => {
setError(null);
const nameError = validateColumnName(name, existingColumns);
if (nameError) {
setError(nameError);
return;
}
const column = {
name: name.trim(),
type,
required,
defaultValue: defaultValue.trim() || undefined
};
setSaving(true);
try {
await ipcRenderer.invoke('backend:addColumn', backendId, tableName, column);
onSuccess();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add column';
setError(message);
setSaving(false);
}
}, [backendId, tableName, name, type, required, defaultValue, existingColumns, onSuccess]);
return (
<div className={css.Root}>
<div className={css.Row}>
<div className={css.Field}>
<input
type="text"
className={css.Input}
placeholder="column_name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<div className={css.Field}>
<select className={css.Select} value={type} onChange={(e) => setType(e.target.value as ColumnType)}>
{COLUMN_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div className={css.FieldSmall}>
<input type="checkbox" checked={required} onChange={(e) => setRequired(e.target.checked)} />
</div>
<div className={css.Field}>
<input
type="text"
className={css.Input}
placeholder="default"
value={defaultValue}
onChange={(e) => setDefaultValue(e.target.value)}
/>
</div>
<div className={css.Actions}>
<PrimaryButton
label={saving ? '...' : 'Add'}
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Cta}
onClick={handleSubmit}
isDisabled={saving || !name.trim()}
/>
<IconButton icon={IconName.Close} onClick={onCancel} />
</div>
</div>
{error && (
<div className={css.Error}>
<Icon icon={IconName.WarningTriangle} size={IconSize.Tiny} />
<Text textType={TextType.Default}>{error}</Text>
</div>
)}
</div>
);
}
/** Props for inline column rename */
export interface ColumnRenameProps {
/** Backend ID */
backendId: string;
/** Table name */
tableName: string;
/** Current column name */
columnName: string;
/** Existing column names (for validation) */
existingColumns: string[];
/** Called when rename succeeds */
onSuccess: () => void;
/** Called when cancelled */
onCancel: () => void;
}
/**
* ColumnRenameInput - inline input for renaming a column
*/
export function ColumnRenameInput({
backendId,
tableName,
columnName,
existingColumns,
onSuccess,
onCancel
}: ColumnRenameProps) {
const [newName, setNewName] = useState(columnName);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(async () => {
setError(null);
// No change
if (newName.trim() === columnName) {
onCancel();
return;
}
// Validate (exclude current name from existing list)
const otherColumns = existingColumns.filter((c) => c !== columnName);
const nameError = validateColumnName(newName, otherColumns);
if (nameError) {
setError(nameError);
return;
}
setSaving(true);
try {
await ipcRenderer.invoke('backend:renameColumn', backendId, tableName, columnName, newName.trim());
onSuccess();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to rename column';
setError(message);
setSaving(false);
}
}, [backendId, tableName, columnName, newName, existingColumns, onSuccess, onCancel]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit();
} else if (e.key === 'Escape') {
onCancel();
}
},
[handleSubmit, onCancel]
);
return (
<div className={css.RenameWrapper}>
<input
type="text"
className={css.RenameInput}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSubmit}
autoFocus
disabled={saving}
/>
{error && <span className={css.RenameError}>{error}</span>}
</div>
);
}

View File

@@ -0,0 +1,175 @@
/**
* CreateTableModal styles
*/
.Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.Modal {
background: var(--theme-color-bg-2);
border-radius: 8px;
width: 640px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.Header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--theme-color-border-default);
}
.Content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.Section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
.Input {
width: 100%;
padding: 8px 12px;
margin-top: 8px;
background: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
color: var(--theme-color-fg-default);
font-size: 13px;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.ColumnsTable {
margin-top: 12px;
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
overflow: hidden;
}
.ColumnRow {
display: grid;
grid-template-columns: 2fr 1.2fr 0.8fr 1.5fr 40px;
gap: 8px;
padding: 8px 12px;
background: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
align-items: center;
&:first-child {
background: var(--theme-color-bg-2);
font-size: 11px;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
letter-spacing: 0.5px;
}
&:last-child {
border-bottom: none;
}
&[data-system='true'] {
background: var(--theme-color-bg-2);
opacity: 0.7;
}
}
.ColName,
.ColType,
.ColRequired,
.ColDefault,
.ColActions {
display: flex;
align-items: center;
}
.ColRequired {
justify-content: center;
}
.ColActions {
justify-content: flex-end;
}
.ColumnInput {
width: 100%;
padding: 6px 8px;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
color: var(--theme-color-fg-default);
font-size: 12px;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.TypeSelect {
width: 100%;
padding: 6px 8px;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
color: var(--theme-color-fg-default);
font-size: 12px;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--theme-color-primary);
}
}
.Error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--theme-color-danger-transparent);
border-radius: 4px;
color: var(--theme-color-danger);
margin-top: 12px;
}
.Footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--theme-color-border-default);
}

View File

@@ -0,0 +1,426 @@
/**
* CreateTableModal
*
* Modal for creating a new database table with columns.
*
* @module schemamanager/CreateTableModal
* @since 1.2.0
*/
import React, { useCallback, useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import css from './CreateTableModal.module.scss';
const { ipcRenderer } = window.require('electron');
/** Supported column types */
const COLUMN_TYPES = ['String', 'Number', 'Boolean', 'Date', 'Object', 'Array'] as const;
type ColumnType = (typeof COLUMN_TYPES)[number];
/** SQLite reserved words to prevent as table names */
const SQLITE_RESERVED = [
'ABORT',
'ACTION',
'ADD',
'ALL',
'ALTER',
'AND',
'AS',
'ASC',
'AUTOINCREMENT',
'BETWEEN',
'BY',
'CASCADE',
'CASE',
'CHECK',
'COLLATE',
'COLUMN',
'COMMIT',
'CONFLICT',
'CONSTRAINT',
'CREATE',
'CROSS',
'DATABASE',
'DEFAULT',
'DELETE',
'DESC',
'DISTINCT',
'DROP',
'ELSE',
'END',
'ESCAPE',
'EXCEPT',
'EXISTS',
'FOREIGN',
'FROM',
'GROUP',
'HAVING',
'IN',
'INDEX',
'INNER',
'INSERT',
'INTERSECT',
'INTO',
'IS',
'ISNULL',
'JOIN',
'KEY',
'LEFT',
'LIKE',
'LIMIT',
'NATURAL',
'NOT',
'NOTNULL',
'NULL',
'ON',
'OR',
'ORDER',
'OUTER',
'PRIMARY',
'REFERENCES',
'REPLACE',
'RIGHT',
'ROLLBACK',
'SELECT',
'SET',
'TABLE',
'THEN',
'TO',
'TRANSACTION',
'UNION',
'UNIQUE',
'UPDATE',
'USING',
'VALUES',
'WHEN',
'WHERE'
];
/** Column definition in the form */
interface ColumnDef {
id: string;
name: string;
type: ColumnType;
required: boolean;
defaultValue: string;
}
export interface CreateTableModalProps {
/** Backend ID */
backendId: string;
/** Callback when modal should close */
onClose: () => void;
/** Callback when table is created */
onSuccess: () => void;
}
/**
* Validate table name
*/
function validateTableName(name: string): string | null {
if (!name.trim()) {
return 'Table name is required';
}
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
return 'Must start with letter, only alphanumeric and underscore';
}
if (SQLITE_RESERVED.includes(name.toUpperCase())) {
return `"${name}" is a reserved word`;
}
if (name.length > 64) {
return 'Name too long (max 64 characters)';
}
return null;
}
/**
* Validate column name
*/
function validateColumnName(name: string, existingNames: string[]): string | null {
if (!name.trim()) {
return 'Column name is required';
}
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
return 'Invalid column name';
}
if (['objectId', 'createdAt', 'updatedAt', 'ACL'].includes(name)) {
return 'Reserved column name';
}
if (existingNames.includes(name)) {
return 'Duplicate column name';
}
return null;
}
/**
* Generate unique ID
*/
function generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
/**
* CreateTableModal component
*/
export function CreateTableModal({ backendId, onClose, onSuccess }: CreateTableModalProps) {
// State
const [tableName, setTableName] = useState('');
const [columns, setColumns] = useState<ColumnDef[]>([
{ id: generateId(), name: '', type: 'String', required: false, defaultValue: '' }
]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Add a new column
const handleAddColumn = useCallback(() => {
setColumns((prev) => [...prev, { id: generateId(), name: '', type: 'String', required: false, defaultValue: '' }]);
}, []);
// Remove a column
const handleRemoveColumn = useCallback((id: string) => {
setColumns((prev) => prev.filter((c) => c.id !== id));
}, []);
// Update a column field
const handleUpdateColumn = useCallback((id: string, field: keyof ColumnDef, value: string | boolean) => {
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
}, []);
// Handle form submission
const handleSubmit = useCallback(async () => {
setError(null);
// Validate table name
const tableNameError = validateTableName(tableName);
if (tableNameError) {
setError(tableNameError);
return;
}
// Filter out empty columns and validate remaining
const validColumns = columns.filter((c) => c.name.trim());
const columnNames: string[] = [];
for (const col of validColumns) {
const colError = validateColumnName(col.name, columnNames);
if (colError) {
setError(`Column "${col.name}": ${colError}`);
return;
}
columnNames.push(col.name);
}
// Build schema
const tableSchema = {
name: tableName.trim(),
columns: validColumns.map((col) => ({
name: col.name.trim(),
type: col.type,
required: col.required,
defaultValue: col.defaultValue.trim() || undefined
}))
};
setSaving(true);
try {
await ipcRenderer.invoke('backend:createTable', backendId, tableSchema);
onSuccess();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create table';
setError(message);
setSaving(false);
}
}, [backendId, tableName, columns, onSuccess]);
// Handle Enter key
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.metaKey) {
handleSubmit();
}
},
[handleSubmit]
);
return (
<div className={css.Overlay} onClick={onClose}>
<div className={css.Modal} onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown}>
{/* Header */}
<div className={css.Header}>
<Text textType={TextType.Proud}>Create New Table</Text>
<IconButton icon={IconName.Close} onClick={onClose} />
</div>
{/* Content */}
<div className={css.Content}>
{/* Table Name */}
<div className={css.Section}>
<Text textType={TextType.DefaultContrast}>Table Name</Text>
<input
type="text"
className={css.Input}
placeholder="e.g., Products, Users, Orders"
value={tableName}
onChange={(e) => setTableName(e.target.value)}
autoFocus
/>
</div>
{/* Columns */}
<div className={css.Section}>
<HStack UNSAFE_style={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Text textType={TextType.DefaultContrast}>Fields</Text>
<PrimaryButton
label="+ Add Field"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Ghost}
onClick={handleAddColumn}
/>
</HStack>
<div className={css.ColumnsTable}>
{/* Header */}
<div className={css.ColumnRow}>
<div className={css.ColName}>Name</div>
<div className={css.ColType}>Type</div>
<div className={css.ColRequired}>Required</div>
<div className={css.ColDefault}>Default</div>
<div className={css.ColActions}></div>
</div>
{/* System columns (info only) */}
<div className={css.ColumnRow} data-system="true">
<div className={css.ColName}>
<Text textType={TextType.Shy}>objectId</Text>
</div>
<div className={css.ColType}>
<Text textType={TextType.Shy}>String</Text>
</div>
<div className={css.ColRequired}>
<Text textType={TextType.Shy}></Text>
</div>
<div className={css.ColDefault}>
<Text textType={TextType.Shy}>auto</Text>
</div>
<div className={css.ColActions}></div>
</div>
<div className={css.ColumnRow} data-system="true">
<div className={css.ColName}>
<Text textType={TextType.Shy}>createdAt</Text>
</div>
<div className={css.ColType}>
<Text textType={TextType.Shy}>Date</Text>
</div>
<div className={css.ColRequired}>
<Text textType={TextType.Shy}></Text>
</div>
<div className={css.ColDefault}>
<Text textType={TextType.Shy}>auto</Text>
</div>
<div className={css.ColActions}></div>
</div>
<div className={css.ColumnRow} data-system="true">
<div className={css.ColName}>
<Text textType={TextType.Shy}>updatedAt</Text>
</div>
<div className={css.ColType}>
<Text textType={TextType.Shy}>Date</Text>
</div>
<div className={css.ColRequired}>
<Text textType={TextType.Shy}></Text>
</div>
<div className={css.ColDefault}>
<Text textType={TextType.Shy}>auto</Text>
</div>
<div className={css.ColActions}></div>
</div>
{/* User columns */}
{columns.map((col) => (
<div key={col.id} className={css.ColumnRow}>
<div className={css.ColName}>
<input
type="text"
className={css.ColumnInput}
placeholder="field_name"
value={col.name}
onChange={(e) => handleUpdateColumn(col.id, 'name', e.target.value)}
/>
</div>
<div className={css.ColType}>
<select
className={css.TypeSelect}
value={col.type}
onChange={(e) => handleUpdateColumn(col.id, 'type', e.target.value)}
>
{COLUMN_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div className={css.ColRequired}>
<input
type="checkbox"
checked={col.required}
onChange={(e) => handleUpdateColumn(col.id, 'required', e.target.checked)}
/>
</div>
<div className={css.ColDefault}>
<input
type="text"
className={css.ColumnInput}
placeholder={col.type === 'Boolean' ? 'true/false' : ''}
value={col.defaultValue}
onChange={(e) => handleUpdateColumn(col.id, 'defaultValue', e.target.value)}
/>
</div>
<div className={css.ColActions}>
<IconButton
icon={IconName.Trash}
onClick={() => handleRemoveColumn(col.id)}
UNSAFE_style={{ opacity: columns.length === 1 ? 0.3 : 1 }}
/>
</div>
</div>
))}
</div>
</div>
{/* Error */}
{error && (
<div className={css.Error}>
<Icon icon={IconName.WarningTriangle} size={IconSize.Tiny} />
<Text textType={TextType.Default}>{error}</Text>
</div>
)}
</div>
{/* Footer */}
<div className={css.Footer}>
<PrimaryButton
label="Cancel"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onClose}
/>
<PrimaryButton
label={saving ? 'Creating...' : 'Create Table'}
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Cta}
onClick={handleSubmit}
isDisabled={saving || !tableName.trim()}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
/**
* SchemaPanel styles
* Uses theme tokens from UI-STYLING-GUIDE.md
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--theme-color-bg-2);
border-radius: 8px;
overflow: hidden;
}
.Header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-3);
}
.TableList {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.Loading,
.Error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
gap: 16px;
}
.EmptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
}

View File

@@ -0,0 +1,239 @@
/**
* SchemaPanel
*
* Main panel for viewing and managing database schemas in local backends.
* Shows a list of tables with columns, record counts, and management options.
*
* @module schemamanager/SchemaPanel
* @since 1.2.0
*/
import React, { useCallback, useEffect, useState } from 'react';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { CreateTableModal } from './CreateTableModal';
import css from './SchemaPanel.module.scss';
import { TableRow, TableInfo } from './TableRow';
export interface SchemaPanelProps {
/** Backend ID */
backendId: string;
/** Backend name for display */
backendName: string;
/** Whether backend is running */
isRunning: boolean;
/** Called when panel should close */
onClose: () => void;
}
interface SchemaData {
tables: TableInfo[];
}
/**
* Invoke IPC handler with error handling
*/
async function invokeIPC<T>(channel: string, ...args: unknown[]): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { ipcRenderer } = (window as any).require('electron');
return ipcRenderer.invoke(channel, ...args);
}
/**
* SchemaPanel - View and manage database schemas
*/
export function SchemaPanel({ backendId, backendName, isRunning, onClose }: SchemaPanelProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [schema, setSchema] = useState<SchemaData | null>(null);
const [expandedTable, setExpandedTable] = useState<string | null>(null);
const [recordCounts, setRecordCounts] = useState<Record<string, number>>({});
const [showCreateTable, setShowCreateTable] = useState(false);
// Load schema from backend
const loadSchema = useCallback(async () => {
if (!isRunning) {
setError('Backend must be running to view schema');
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const schemaData = await invokeIPC<SchemaData>('backend:getSchema', backendId);
setSchema(schemaData);
// Load record counts asynchronously
loadRecordCounts(schemaData.tables);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load schema';
setError(message);
} finally {
setLoading(false);
}
}, [backendId, isRunning]);
// Load record counts for all tables
const loadRecordCounts = useCallback(
async (tables: TableInfo[]) => {
const counts: Record<string, number> = {};
for (const table of tables) {
try {
const count = await invokeIPC<number>('backend:getRecordCount', backendId, table.name);
counts[table.name] = count;
} catch {
counts[table.name] = 0;
}
}
setRecordCounts(counts);
},
[backendId]
);
// Load schema on mount and when backend changes
useEffect(() => {
loadSchema();
}, [loadSchema]);
// Handle table expand/collapse
const handleToggleExpand = useCallback((tableName: string) => {
setExpandedTable((prev) => (prev === tableName ? null : tableName));
}, []);
// Handle edit table - expands table to show columns
const handleEditTable = useCallback((tableName: string) => {
// Expand the table to show columns - full editing (add/remove columns) will be added in a future task
setExpandedTable((prev) => (prev === tableName ? tableName : tableName));
}, []);
// Render loading state
if (loading) {
return (
<div className={css.Root}>
<div className={css.Header}>
<Text textType={TextType.Proud}>Schema: {backendName}</Text>
<PrimaryButton
label="Close"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onClose}
/>
</div>
<div className={css.Loading}>
<Text textType={TextType.Shy}>Loading schema...</Text>
</div>
</div>
);
}
// Render error state
if (error) {
return (
<div className={css.Root}>
<div className={css.Header}>
<Text textType={TextType.Proud}>Schema: {backendName}</Text>
<PrimaryButton
label="Close"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onClose}
/>
</div>
<div className={css.Error}>
<Text textType={TextType.Shy}>{error}</Text>
<PrimaryButton
label="Retry"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={loadSchema}
/>
</div>
</div>
);
}
const tables = schema?.tables || [];
return (
<div className={css.Root}>
{/* Header */}
<div className={css.Header}>
<VStack>
<Text textType={TextType.Proud}>Schema: {backendName}</Text>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
{tables.length} {tables.length === 1 ? 'table' : 'tables'}
</Text>
</VStack>
<HStack hasSpacing>
<PrimaryButton
label="+ New Table"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Cta}
onClick={() => setShowCreateTable(true)}
/>
<PrimaryButton
label="Refresh"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={loadSchema}
/>
<PrimaryButton
label="Close"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onClose}
/>
</HStack>
</div>
{/* Table List */}
<div className={css.TableList}>
{tables.length === 0 ? (
<div className={css.EmptyState}>
<Text textType={TextType.DefaultContrast}>No tables yet</Text>
<Text textType={TextType.Shy} style={{ marginTop: '8px' }}>
Create your first table to start storing data.
</Text>
<PrimaryButton
label="Create First Table"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Cta}
onClick={() => setShowCreateTable(true)}
UNSAFE_style={{ marginTop: '16px' }}
/>
</div>
) : (
tables.map((table) => (
<TableRow
key={table.name}
table={table}
recordCount={recordCounts[table.name]}
expanded={expandedTable === table.name}
onToggleExpand={() => handleToggleExpand(table.name)}
onEdit={() => handleEditTable(table.name)}
/>
))
)}
</div>
{/* Create Table Modal */}
{showCreateTable && (
<CreateTableModal
backendId={backendId}
onClose={() => setShowCreateTable(false)}
onSuccess={() => {
setShowCreateTable(false);
loadSchema();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,117 @@
/**
* TableRow styles
* Uses theme tokens from UI-STYLING-GUIDE.md
*/
.Root {
background-color: var(--theme-color-bg-3);
border-radius: 6px;
margin-bottom: 4px;
overflow: hidden;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-4);
}
&[data-expanded='true'] {
background-color: var(--theme-color-bg-4);
}
}
.Header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
cursor: pointer;
user-select: none;
}
.ExpandIcon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.TableIcon {
width: 24px;
height: 24px;
background-color: var(--theme-color-primary);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: 600;
}
.TableName {
margin-left: 4px;
}
.Stats {
display: flex;
align-items: center;
}
.Columns {
padding: 0 12px 12px 12px;
border-top: 1px solid var(--theme-color-border-default);
}
.ColumnTable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
text-align: left;
padding: 8px 12px;
color: var(--theme-color-fg-default-shy);
font-weight: 500;
border-bottom: 1px solid var(--theme-color-border-default);
}
td {
padding: 6px 12px;
color: var(--theme-color-fg-default);
}
tr:hover td {
background-color: var(--theme-color-bg-3);
}
}
.SystemColumn {
td {
color: var(--theme-color-fg-default-shy);
font-style: italic;
}
}
.TypeBadge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
color: white;
text-transform: capitalize;
}
.TargetClass {
color: var(--theme-color-fg-default-shy);
font-size: 10px;
margin-left: 4px;
}
.NoColumns {
text-align: center;
color: var(--theme-color-fg-default-shy);
font-style: italic;
padding: 16px !important;
}

View File

@@ -0,0 +1,189 @@
/**
* TableRow
*
* Expandable row showing table name, column count, and record count.
* When expanded, shows all columns with their types.
* Supports adding columns, renaming columns, and deleting tables.
*
* @module schemamanager/TableRow
* @since 1.2.0
*/
import React from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import css from './TableRow.module.scss';
/** Column definition from schema */
export interface ColumnDefinition {
name: string;
type: string;
required?: boolean;
default?: unknown;
targetClass?: string;
}
/** Table info from schema API */
export interface TableInfo {
name: string;
columns: ColumnDefinition[];
createdAt?: string | null;
}
export interface TableRowProps {
/** Table information */
table: TableInfo;
/** Record count (undefined while loading) */
recordCount?: number;
/** Whether row is expanded */
expanded: boolean;
/** Called when expand/collapse is toggled */
onToggleExpand: () => void;
/** Called when edit is requested */
onEdit: () => void;
}
/** Color mapping for data types */
const TYPE_COLORS: Record<string, string> = {
String: 'var(--theme-color-primary)',
Number: 'var(--theme-color-success)',
Boolean: 'var(--theme-color-notice)',
Date: '#8b5cf6',
Object: '#ec4899',
Array: '#6366f1',
Pointer: 'var(--theme-color-danger)',
Relation: 'var(--theme-color-danger)',
GeoPoint: '#14b8a6',
File: '#f97316'
};
/**
* TypeBadge - Small colored badge showing column type
*/
function TypeBadge({ type }: { type: string }) {
const color = TYPE_COLORS[type] || 'var(--theme-color-fg-default-shy)';
return (
<span className={css.TypeBadge} style={{ backgroundColor: color }}>
{type}
</span>
);
}
/**
* TableRow - Expandable table display row
*/
export function TableRow({ table, recordCount, expanded, onToggleExpand, onEdit }: TableRowProps) {
const columnCount = table.columns?.length || 0;
return (
<div className={css.Root} data-expanded={expanded}>
{/* Header row - always visible */}
<div className={css.Header} onClick={onToggleExpand}>
<HStack hasSpacing>
<div className={css.ExpandIcon}>
<Icon
icon={expanded ? IconName.CaretDown : IconName.CaretRight}
size={IconSize.Tiny}
UNSAFE_style={{ color: 'var(--theme-color-fg-default-shy)' }}
/>
</div>
<div className={css.TableIcon}>
<Text textType={TextType.Proud}>T</Text>
</div>
<div className={css.TableName}>
<Text textType={TextType.DefaultContrast}>{table.name}</Text>
</div>
</HStack>
<HStack hasSpacing>
<div className={css.Stats}>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
{columnCount} {columnCount === 1 ? 'field' : 'fields'}
</Text>
{recordCount !== undefined && (
<Text textType={TextType.Shy} style={{ fontSize: '11px', marginLeft: '8px' }}>
{recordCount.toLocaleString()} {recordCount === 1 ? 'record' : 'records'}
</Text>
)}
</div>
<PrimaryButton
label="Edit"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Ghost}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
/>
</HStack>
</div>
{/* Expanded content - columns list */}
{expanded && (
<div className={css.Columns}>
<table className={css.ColumnTable}>
<thead>
<tr>
<th>Field Name</th>
<th>Type</th>
<th>Required</th>
<th>Default</th>
</tr>
</thead>
<tbody>
{/* System columns - always present */}
<tr className={css.SystemColumn}>
<td>id</td>
<td>
<TypeBadge type="String" />
</td>
<td></td>
<td>UUID (auto)</td>
</tr>
<tr className={css.SystemColumn}>
<td>createdAt</td>
<td>
<TypeBadge type="Date" />
</td>
<td></td>
<td>auto</td>
</tr>
<tr className={css.SystemColumn}>
<td>updatedAt</td>
<td>
<TypeBadge type="Date" />
</td>
<td></td>
<td>auto</td>
</tr>
{/* User-defined columns */}
{table.columns.map((col) => (
<tr key={col.name}>
<td>{col.name}</td>
<td>
<TypeBadge type={col.type} />
{col.targetClass && <span className={css.TargetClass}> {col.targetClass}</span>}
</td>
<td>{col.required ? '✓' : ''}</td>
<td>{col.default !== undefined ? String(col.default) : '—'}</td>
</tr>
))}
{table.columns.length === 0 && (
<tr>
<td colSpan={4} className={css.NoColumns}>
No custom fields defined yet
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
/**
* Schema Manager Panel
*
* UI components for viewing and managing database schemas in local backends.
*
* @module schemamanager
* @since 1.2.0
*/
export { SchemaPanel } from './SchemaPanel';
export type { SchemaPanelProps } from './SchemaPanel';
export { TableRow } from './TableRow';
export type { TableRowProps, TableInfo, ColumnDefinition } from './TableRow';
export { CreateTableModal } from './CreateTableModal';
export type { CreateTableModalProps } from './CreateTableModal';
export { AddColumnForm, ColumnRenameInput } from './AddColumnForm';
export type { AddColumnFormProps, ColumnRenameProps } from './AddColumnForm';

View File

@@ -100,6 +100,65 @@ class BackendManager {
return this.exportSchema(id, format); return this.exportSchema(id, format);
}); });
// Get full schema
ipcMain.handle('backend:getSchema', async (_, id) => {
return this.getSchema(id);
});
// Get single table schema
ipcMain.handle('backend:getTableSchema', async (_, id, tableName) => {
return this.getTableSchema(id, tableName);
});
// Get record count for a table
ipcMain.handle('backend:getRecordCount', async (_, id, tableName) => {
return this.getRecordCount(id, tableName);
});
// Create a new table
ipcMain.handle('backend:createTable', async (_, id, tableSchema) => {
return this.createTable(id, tableSchema);
});
// Add column to existing table
ipcMain.handle('backend:addColumn', async (_, id, tableName, column) => {
return this.addColumn(id, tableName, column);
});
// Rename column in existing table
ipcMain.handle('backend:renameColumn', async (_, id, tableName, oldName, newName) => {
return this.renameColumn(id, tableName, oldName, newName);
});
// Delete a table
ipcMain.handle('backend:deleteTable', async (_, id, tableName) => {
return this.deleteTable(id, tableName);
});
// ==========================================================================
// DATA OPERATIONS (for Data Browser)
// ==========================================================================
// Query records with pagination, search, filters
ipcMain.handle('backend:queryRecords', async (_, id, options) => {
return this.queryRecords(id, options);
});
// Create a new record
ipcMain.handle('backend:createRecord', async (_, id, collection, data) => {
return this.createRecord(id, collection, data);
});
// Update an existing record
ipcMain.handle('backend:saveRecord', async (_, id, collection, objectId, data) => {
return this.saveRecord(id, collection, objectId, data);
});
// Delete a record
ipcMain.handle('backend:deleteRecord', async (_, id, collection, objectId) => {
return this.deleteRecord(id, collection, objectId);
});
// Workflow management // Workflow management
ipcMain.handle('backend:update-workflow', async (_, args) => { ipcMain.handle('backend:update-workflow', async (_, args) => {
return this.updateWorkflow(args.backendId, args.name, args.workflow); return this.updateWorkflow(args.backendId, args.name, args.workflow);
@@ -310,16 +369,200 @@ class BackendManager {
throw new Error('Adapter or schema manager not available'); throw new Error('Adapter or schema manager not available');
} }
switch (format) { if (format === 'postgres') {
case 'postgres':
return adapter.schemaManager.generatePostgresSQL(); return adapter.schemaManager.generatePostgresSQL();
case 'supabase':
return adapter.schemaManager.generateSupabaseSQL();
case 'json':
default:
const schema = await adapter.schemaManager.exportSchema();
return JSON.stringify(schema, null, 2);
} }
if (format === 'supabase') {
return adapter.schemaManager.generateSupabaseSQL();
}
// Default: json
const exportedSchema = await adapter.schemaManager.exportSchema();
return JSON.stringify(exportedSchema, null, 2);
}
// ==========================================================================
// SCHEMA MANAGEMENT
// ==========================================================================
/**
* Get full schema for a backend
* @param {string} id - Backend ID
* @returns {Promise<Object>} Schema with tables array
*/
async getSchema(id) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to get schema');
}
const adapter = server.getAdapter();
if (!adapter || !adapter.schemaManager) {
throw new Error('Adapter or schema manager not available');
}
const tables = adapter.schemaManager.listTables();
const schemas = adapter.schemaManager.exportSchemas();
// Build response with table info
return {
tables: tables.map((tableName) => {
const schema = schemas.find((s) => s.name === tableName);
return {
name: tableName,
columns: schema?.columns || [],
createdAt: schema?.createdAt || null
};
})
};
}
/**
* Get schema for a single table
* @param {string} id - Backend ID
* @param {string} tableName - Table name
* @returns {Promise<Object|null>} Table schema
*/
async getTableSchema(id, tableName) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to get table schema');
}
const adapter = server.getAdapter();
if (!adapter || !adapter.schemaManager) {
throw new Error('Adapter or schema manager not available');
}
const schema = adapter.schemaManager.getTableSchema(tableName);
if (!schema) {
return null;
}
return {
name: tableName,
columns: schema.columns || []
};
}
/**
* Get record count for a table
* @param {string} id - Backend ID
* @param {string} tableName - Table name
* @returns {Promise<number>} Record count
*/
async getRecordCount(id, tableName) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to get record count');
}
const adapter = server.getAdapter();
if (!adapter) {
throw new Error('Adapter not available');
}
return new Promise((resolve, reject) => {
adapter.count({
collection: tableName,
success: (count) => resolve(count),
error: (err) => reject(new Error(err))
});
});
}
/**
* Create a new table
* @param {string} id - Backend ID
* @param {Object} tableSchema - Table schema { name, columns }
* @returns {Promise<Object>} Result with success status
*/
async createTable(id, tableSchema) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to create table');
}
const adapter = server.getAdapter();
if (!adapter || !adapter.schemaManager) {
throw new Error('Adapter or schema manager not available');
}
const created = adapter.schemaManager.createTable(tableSchema);
safeLog(`Created table: ${tableSchema.name} (created: ${created})`);
return { success: true, created, tableName: tableSchema.name };
}
/**
* Add a column to an existing table
* @param {string} id - Backend ID
* @param {string} tableName - Table name
* @param {Object} column - Column definition { name, type, required, default }
* @returns {Promise<Object>} Result with success status
*/
async addColumn(id, tableName, column) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to add column');
}
const adapter = server.getAdapter();
if (!adapter || !adapter.schemaManager) {
throw new Error('Adapter or schema manager not available');
}
adapter.schemaManager.addColumn(tableName, column);
safeLog(`Added column: ${column.name} to table ${tableName}`);
return { success: true, tableName, columnName: column.name };
}
/**
* Rename a column in an existing table
* @param {string} id - Backend ID
* @param {string} tableName - Table name
* @param {string} oldName - Current column name
* @param {string} newName - New column name
* @returns {Promise<Object>} Result with success status
*/
async renameColumn(id, tableName, oldName, newName) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to rename column');
}
const adapter = server.getAdapter();
if (!adapter || !adapter.schemaManager) {
throw new Error('Adapter or schema manager not available');
}
adapter.schemaManager.renameColumn(tableName, oldName, newName);
safeLog(`Renamed column: ${oldName} -> ${newName} in table ${tableName}`);
return { success: true, tableName, oldName, newName };
}
/**
* Delete a table and all its data
* @param {string} id - Backend ID
* @param {string} tableName - Table name
* @returns {Promise<Object>} Result with success status
*/
async deleteTable(id, tableName) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to delete table');
}
const adapter = server.getAdapter();
if (!adapter || !adapter.schemaManager) {
throw new Error('Adapter or schema manager not available');
}
const deleted = adapter.schemaManager.deleteTable(tableName);
safeLog(`Deleted table: ${tableName} (deleted: ${deleted})`);
return { success: true, deleted, tableName };
} }
/** /**
@@ -343,6 +586,147 @@ class BackendManager {
return port; return port;
} }
// ==========================================================================
// DATA OPERATIONS (for Data Browser)
// ==========================================================================
/**
* Query records with pagination, search, and filters
* @param {string} id - Backend ID
* @param {Object} options - Query options
* @param {string} options.collection - Table/collection name
* @param {number} [options.limit=50] - Max records to return
* @param {number} [options.skip=0] - Records to skip (for pagination)
* @param {Object} [options.where] - Filter conditions
* @param {Array} [options.sort] - Sort order (e.g., ['-createdAt'])
* @param {boolean} [options.count] - Include total count
* @returns {Promise<{results: Object[], count?: number}>}
*/
async queryRecords(id, options) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to query records');
}
const adapter = server.getAdapter();
if (!adapter) {
throw new Error('Adapter not available');
}
return new Promise((resolve, reject) => {
adapter.query({
collection: options.collection,
limit: options.limit || 50,
skip: options.skip || 0,
where: options.where,
sort: options.sort,
count: options.count,
success: (results, count) => {
resolve({
results,
count: options.count ? count : undefined
});
},
error: (err) => reject(new Error(err))
});
});
}
/**
* Create a new record
* @param {string} id - Backend ID
* @param {string} collection - Table/collection name
* @param {Object} data - Record data
* @returns {Promise<Object>} Created record with objectId
*/
async createRecord(id, collection, data) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to create records');
}
const adapter = server.getAdapter();
if (!adapter) {
throw new Error('Adapter not available');
}
return new Promise((resolve, reject) => {
adapter.create({
collection,
data,
success: (record) => {
safeLog(`Created record in ${collection}:`, record.objectId);
resolve(record);
},
error: (err) => reject(new Error(err))
});
});
}
/**
* Update an existing record
* @param {string} id - Backend ID
* @param {string} collection - Table/collection name
* @param {string} objectId - Record ID to update
* @param {Object} data - Fields to update
* @returns {Promise<Object>} Updated record
*/
async saveRecord(id, collection, objectId, data) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to save records');
}
const adapter = server.getAdapter();
if (!adapter) {
throw new Error('Adapter not available');
}
return new Promise((resolve, reject) => {
adapter.save({
collection,
objectId,
data,
success: (record) => {
safeLog(`Updated record in ${collection}:`, objectId);
resolve(record);
},
error: (err) => reject(new Error(err))
});
});
}
/**
* Delete a record
* @param {string} id - Backend ID
* @param {string} collection - Table/collection name
* @param {string} objectId - Record ID to delete
* @returns {Promise<{success: boolean}>}
*/
async deleteRecord(id, collection, objectId) {
const server = this.runningBackends.get(id);
if (!server) {
throw new Error('Backend must be running to delete records');
}
const adapter = server.getAdapter();
if (!adapter) {
throw new Error('Adapter not available');
}
return new Promise((resolve, reject) => {
adapter.delete({
collection,
objectId,
success: () => {
safeLog(`Deleted record from ${collection}:`, objectId);
resolve({ success: true });
},
error: (err) => reject(new Error(err))
});
});
}
/** /**
* Stop all running backends (for cleanup on app exit) * Stop all running backends (for cleanup on app exit)
*/ */

View File

@@ -13,17 +13,17 @@ const QueryBuilder = require('./QueryBuilder');
const SchemaManager = require('./SchemaManager'); const SchemaManager = require('./SchemaManager');
/** /**
* Generate a unique object ID (similar to Parse objectId) * Generate a UUID v4
* *
* @returns {string} 10-character alphanumeric ID * @returns {string} UUID string (e.g., "123e4567-e89b-12d3-a456-426614174000")
*/ */
function generateObjectId() { function generateUUID() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; // RFC 4122 version 4 UUID
let id = ''; return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
for (let i = 0; i < 10; i++) { const r = (Math.random() * 16) | 0;
id += chars.charAt(Math.floor(Math.random() * chars.length)); const v = c === 'x' ? r : (r & 0x3) | 0x8;
} return v.toString(16);
return id; });
} }
/** /**
@@ -132,17 +132,25 @@ class LocalSQLAdapter {
const self = this; const self = this;
return { return {
ensureSchemaTable: () => {}, ensureSchemaTable: () => {},
createTable: ({ name }) => { createTable: ({ name, columns }) => {
if (!self._mockData[name]) { if (!self._mockData[name]) {
self._mockData[name] = {}; self._mockData[name] = {};
self._mockSchema[name] = {}; self._mockSchema[name] = { name, columns: columns || [] };
} }
return true;
}, },
addColumn: (table, col) => { addColumn: (table, col) => {
if (!self._mockSchema[table]) self._mockSchema[table] = {}; if (!self._mockSchema[table]) self._mockSchema[table] = { name: table, columns: [] };
self._mockSchema[table][col.name] = col; if (!self._mockSchema[table].columns) self._mockSchema[table].columns = [];
// Check if column already exists
const exists = self._mockSchema[table].columns.some((c) => c.name === col.name);
if (!exists) {
self._mockSchema[table].columns.push(col);
}
}, },
getTableSchema: (table) => self._mockSchema[table] || {}, getTableSchema: (table) => self._mockSchema[table] || null,
listTables: () => Object.keys(self._mockData).filter((name) => !name.startsWith('_')),
exportSchemas: () => Object.values(self._mockSchema).filter((s) => s && !s.name?.startsWith('_')),
addRelation: () => {}, addRelation: () => {},
removeRelation: () => {} removeRelation: () => {}
}; };
@@ -153,8 +161,9 @@ class LocalSQLAdapter {
* @private * @private
*/ */
_mockExec(sql, params, mode) { _mockExec(sql, params, mode) {
// Parse simple SQL patterns for mock execution // Parse SQL patterns for mock execution
const selectMatch = sql.match(/SELECT \* FROM "?(\w+)"?\s*(?:WHERE "?objectId"?\s*=\s*\?)?/i); // Match SELECT with optional WHERE, ORDER BY, LIMIT, OFFSET
const selectMatch = sql.match(/SELECT\s+\*\s+FROM\s+"?(\w+)"?/i);
const insertMatch = sql.match(/INSERT INTO "?(\w+)"?/i); const insertMatch = sql.match(/INSERT INTO "?(\w+)"?/i);
const updateMatch = sql.match(/UPDATE "?(\w+)"?\s+SET/i); const updateMatch = sql.match(/UPDATE "?(\w+)"?\s+SET/i);
const deleteMatch = sql.match(/DELETE FROM "?(\w+)"?/i); const deleteMatch = sql.match(/DELETE FROM "?(\w+)"?/i);
@@ -163,48 +172,111 @@ class LocalSQLAdapter {
const table = selectMatch[1]; const table = selectMatch[1];
if (!this._mockData[table]) this._mockData[table] = {}; if (!this._mockData[table]) this._mockData[table] = {};
if (params.length > 0) { let records = Object.values(this._mockData[table]);
// Single record fetch
const record = this._mockData[table][params[0]]; // Check for WHERE id = ? or WHERE objectId = ?
const idMatch = sql.match(/WHERE\s+"?(?:id|objectId)"?\s*=\s*\?/i);
if (idMatch && params.length > 0) {
const recordId = params[0];
const record = this._mockData[table][recordId];
return mode === 'get' ? record || null : record ? [record] : []; return mode === 'get' ? record || null : record ? [record] : [];
} }
// Return all records
const records = Object.values(this._mockData[table]); // Handle ORDER BY
const orderMatch = sql.match(/ORDER BY\s+"?(\w+)"?\s+(ASC|DESC)?/i);
if (orderMatch) {
const orderCol = orderMatch[1];
const orderDir = (orderMatch[2] || 'ASC').toUpperCase();
records = records.sort((a, b) => {
const aVal = a[orderCol];
const bVal = b[orderCol];
if (aVal < bVal) return orderDir === 'ASC' ? -1 : 1;
if (aVal > bVal) return orderDir === 'ASC' ? 1 : -1;
return 0;
});
}
// Handle LIMIT and OFFSET
// Find position of LIMIT in params (it comes after WHERE params if any)
let paramIndex = 0;
if (sql.includes('LIMIT')) {
// Find LIMIT param position - it's after WHERE params
const limitIdx = sql.indexOf('LIMIT');
const whereClause = sql.substring(0, limitIdx);
const whereParams = (whereClause.match(/\?/g) || []).length;
paramIndex = whereParams;
const limit = params[paramIndex];
const skip = sql.includes('OFFSET') ? params[paramIndex + 1] || 0 : 0;
records = records.slice(skip, skip + limit);
}
return mode === 'get' ? records[0] || null : records; return mode === 'get' ? records[0] || null : records;
} }
if (insertMatch) { if (insertMatch) {
const table = insertMatch[1]; const table = insertMatch[1];
if (!this._mockData[table]) this._mockData[table] = {}; if (!this._mockData[table]) this._mockData[table] = {};
// Find objectId in params (simple extraction)
// Parse column names from SQL: INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...)
const columnsMatch = sql.match(/\(([^)]+)\)\s*VALUES/i);
if (columnsMatch) {
const columns = columnsMatch[1].split(',').map((c) => c.trim().replace(/"/g, ''));
const record = {};
columns.forEach((col, idx) => {
record[col] = params[idx];
});
// Ensure id exists
if (!record.id && params[0]) {
record.id = params[0];
}
const recordId = record.id;
this._mockData[table][recordId] = record;
return { changes: 1 };
}
// Fallback: simple record creation
const now = new Date().toISOString(); const now = new Date().toISOString();
const record = { objectId: params[0], createdAt: now, updatedAt: now }; const record = { id: params[0], createdAt: now, updatedAt: now };
this._mockData[table][params[0]] = record; this._mockData[table][params[0]] = record;
return { changes: 1 }; return { changes: 1 };
} }
if (updateMatch) { if (updateMatch) {
const table = updateMatch[1]; const table = updateMatch[1];
// Last param is typically the objectId in WHERE clause // Last param is typically the id in WHERE clause
const objectId = params[params.length - 1]; const recordId = params[params.length - 1];
if (this._mockData[table] && this._mockData[table][objectId]) { if (this._mockData[table] && this._mockData[table][recordId]) {
this._mockData[table][objectId].updatedAt = new Date().toISOString(); // Parse SET clauses to update actual fields
const setMatch = sql.match(/SET\s+(.+?)\s+WHERE/i);
if (setMatch) {
const setParts = setMatch[1].split(',');
let paramIdx = 0;
setParts.forEach((part) => {
const colMatch = part.match(/"?(\w+)"?\s*=/);
if (colMatch) {
this._mockData[table][recordId][colMatch[1]] = params[paramIdx];
paramIdx++;
}
});
}
this._mockData[table][recordId].updatedAt = new Date().toISOString();
} }
return { changes: 1 }; return { changes: 1 };
} }
if (deleteMatch) { if (deleteMatch) {
const table = deleteMatch[1]; const table = deleteMatch[1];
const objectId = params[0]; const recordId = params[0];
if (this._mockData[table]) { if (this._mockData[table]) {
delete this._mockData[table][objectId]; delete this._mockData[table][recordId];
} }
return { changes: 1 }; return { changes: 1 };
} }
// Count query // Count query
if (sql.includes('COUNT(*)')) { if (sql.includes('COUNT(*)')) {
const countMatch = sql.match(/FROM "?(\w+)"?/i); const countMatch = sql.match(/FROM\s+"?(\w+)"?/i);
if (countMatch) { if (countMatch) {
const table = countMatch[1]; const table = countMatch[1];
const count = Object.keys(this._mockData[table] || {}).length; const count = Object.keys(this._mockData[table] || {}).length;
@@ -356,8 +428,9 @@ class LocalSQLAdapter {
try { try {
this._ensureTable(options.collection); this._ensureTable(options.collection);
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`; const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`;
const row = this.db.prepare(sql).get(options.objectId); const recordId = options.id || options.objectId;
const row = this.db.prepare(sql).get(recordId);
if (!row) { if (!row) {
options.error('Object not found'); options.error('Object not found');
@@ -370,7 +443,7 @@ class LocalSQLAdapter {
this.events.emit('fetch', { this.events.emit('fetch', {
type: 'fetch', type: 'fetch',
objectId: options.objectId, id: recordId,
object: record, object: record,
collection: options.collection collection: options.collection
}); });
@@ -392,22 +465,22 @@ class LocalSQLAdapter {
// Auto-add columns for new fields // Auto-add columns for new fields
if (this.options.autoCreateTables && this.schemaManager) { if (this.options.autoCreateTables && this.schemaManager) {
for (const [key, value] of Object.entries(options.data)) { for (const [key, value] of Object.entries(options.data)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') { if (key !== 'id' && key !== 'createdAt' && key !== 'updatedAt') {
const type = this._inferType(value); const type = this._inferType(value);
this.schemaManager.addColumn(options.collection, { name: key, type }); this.schemaManager.addColumn(options.collection, { name: key, type });
} }
} }
} }
const objectId = generateObjectId(); const recordId = generateUUID();
const { sql, params } = QueryBuilder.buildInsert(options, objectId); const { sql, params } = QueryBuilder.buildInsert(options, recordId);
this.db.prepare(sql).run(...params); this.db.prepare(sql).run(...params);
// Fetch the created record to get all fields // Fetch the created record to get all fields
const createdRow = this.db const createdRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`) .prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
.get(objectId); .get(recordId);
const record = this._rowToRecord(createdRow, options.collection); const record = this._rowToRecord(createdRow, options.collection);
@@ -415,7 +488,7 @@ class LocalSQLAdapter {
this.events.emit('create', { this.events.emit('create', {
type: 'create', type: 'create',
objectId, id: recordId,
object: record, object: record,
collection: options.collection collection: options.collection
}); });
@@ -437,20 +510,21 @@ class LocalSQLAdapter {
// Auto-add columns for new fields // Auto-add columns for new fields
if (this.options.autoCreateTables && this.schemaManager) { if (this.options.autoCreateTables && this.schemaManager) {
for (const [key, value] of Object.entries(options.data)) { for (const [key, value] of Object.entries(options.data)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') { if (key !== 'id' && key !== 'createdAt' && key !== 'updatedAt') {
const type = this._inferType(value); const type = this._inferType(value);
this.schemaManager.addColumn(options.collection, { name: key, type }); this.schemaManager.addColumn(options.collection, { name: key, type });
} }
} }
} }
const recordId = options.id || options.objectId;
const { sql, params } = QueryBuilder.buildUpdate(options); const { sql, params } = QueryBuilder.buildUpdate(options);
this.db.prepare(sql).run(...params); this.db.prepare(sql).run(...params);
// Fetch the updated record // Fetch the updated record
const updatedRow = this.db const updatedRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`) .prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
.get(options.objectId); .get(recordId);
const record = this._rowToRecord(updatedRow, options.collection); const record = this._rowToRecord(updatedRow, options.collection);
@@ -458,7 +532,7 @@ class LocalSQLAdapter {
this.events.emit('save', { this.events.emit('save', {
type: 'save', type: 'save',
objectId: options.objectId, id: recordId,
object: record, object: record,
collection: options.collection collection: options.collection
}); });
@@ -482,9 +556,10 @@ class LocalSQLAdapter {
options.success(); options.success();
const recordId = options.id || options.objectId;
this.events.emit('delete', { this.events.emit('delete', {
type: 'delete', type: 'delete',
objectId: options.objectId, id: recordId,
collection: options.collection collection: options.collection
}); });
} catch (e) { } catch (e) {
@@ -573,9 +648,10 @@ class LocalSQLAdapter {
this.db.prepare(sql).run(...params); this.db.prepare(sql).run(...params);
// Fetch the updated record // Fetch the updated record
const recordId = options.id || options.objectId;
const updatedRow = this.db const updatedRow = this.db
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`) .prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
.get(options.objectId); .get(recordId);
const record = this._rowToRecord(updatedRow, options.collection); const record = this._rowToRecord(updatedRow, options.collection);
options.success(record); options.success(record);

View File

@@ -275,6 +275,12 @@ function translateOperator(col, op, value, params, schema) {
} }
return null; return null;
case 'contains':
case '$contains':
// Contains search - convert to LIKE with wildcards
params.push(`%${convertedValue}%`);
return `${col} LIKE ?`;
// Geo queries - not fully supported in SQLite without extensions // Geo queries - not fully supported in SQLite without extensions
case '$nearSphere': case '$nearSphere':
case '$within': case '$within':
@@ -333,8 +339,8 @@ function buildSelect(options, schema) {
let selectClause = '*'; let selectClause = '*';
if (options.select) { if (options.select) {
const selectArray = Array.isArray(options.select) ? options.select : options.select.split(','); const selectArray = Array.isArray(options.select) ? options.select : options.select.split(',');
// Always include objectId // Always include id
const fields = new Set(['objectId', ...selectArray.map((s) => s.trim())]); const fields = new Set(['id', ...selectArray.map((s) => s.trim())]);
selectClause = Array.from(fields) selectClause = Array.from(fields)
.map((f) => escapeColumn(f)) .map((f) => escapeColumn(f))
.join(', '); .join(', ');
@@ -406,13 +412,13 @@ function buildCount(options, schema) {
* @param {string} objectId * @param {string} objectId
* @returns {{ sql: string, params: Array }} * @returns {{ sql: string, params: Array }}
*/ */
function buildInsert(options, objectId) { function buildInsert(options, id) {
const params = []; const params = [];
const table = escapeTable(options.collection); const table = escapeTable(options.collection);
const now = new Date().toISOString(); const now = new Date().toISOString();
const data = { const data = {
objectId, id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
...options.data ...options.data
@@ -441,7 +447,7 @@ function buildInsert(options, objectId) {
* *
* @param {Object} options * @param {Object} options
* @param {string} options.collection * @param {string} options.collection
* @param {string} options.objectId * @param {string} options.id - Record ID
* @param {Object} options.data * @param {Object} options.data
* @returns {{ sql: string, params: Array }} * @returns {{ sql: string, params: Array }}
*/ */
@@ -455,7 +461,7 @@ function buildUpdate(options) {
data.updatedAt = new Date().toISOString(); data.updatedAt = new Date().toISOString();
// Remove protected fields // Remove protected fields
delete data.objectId; delete data.id;
delete data.createdAt; delete data.createdAt;
delete data._createdAt; delete data._createdAt;
delete data._updatedAt; delete data._updatedAt;
@@ -467,9 +473,11 @@ function buildUpdate(options) {
params.push(serializeValue(value)); params.push(serializeValue(value));
} }
params.push(options.objectId); // Use id or objectId for backwards compatibility
const recordId = options.id || options.objectId;
params.push(recordId);
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`; const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "id" = ?`;
return { sql, params }; return { sql, params };
} }
@@ -479,13 +487,15 @@ function buildUpdate(options) {
* *
* @param {Object} options * @param {Object} options
* @param {string} options.collection * @param {string} options.collection
* @param {string} options.objectId * @param {string} options.id - Record ID
* @returns {{ sql: string, params: Array }} * @returns {{ sql: string, params: Array }}
*/ */
function buildDelete(options) { function buildDelete(options) {
const table = escapeTable(options.collection); const table = escapeTable(options.collection);
const sql = `DELETE FROM ${table} WHERE "objectId" = ?`; // Use id or objectId for backwards compatibility
return { sql, params: [options.objectId] }; const recordId = options.id || options.objectId;
const sql = `DELETE FROM ${table} WHERE "id" = ?`;
return { sql, params: [recordId] };
} }
/** /**
@@ -493,7 +503,7 @@ function buildDelete(options) {
* *
* @param {Object} options * @param {Object} options
* @param {string} options.collection * @param {string} options.collection
* @param {string} options.objectId * @param {string} options.id - Record ID
* @param {Object<string, number>} options.properties * @param {Object<string, number>} options.properties
* @returns {{ sql: string, params: Array }} * @returns {{ sql: string, params: Array }}
*/ */
@@ -513,9 +523,11 @@ function buildIncrement(options) {
setClause.push('"updatedAt" = ?'); setClause.push('"updatedAt" = ?');
params.push(new Date().toISOString()); params.push(new Date().toISOString());
params.push(options.objectId); // Use id or objectId for backwards compatibility
const recordId = options.id || options.objectId;
params.push(recordId);
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`; const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "id" = ?`;
return { sql, params }; return { sql, params };
} }

View File

@@ -168,6 +168,89 @@ class SchemaManager {
} }
} }
/**
* Delete a table and all its data
*
* @param {string} tableName - Table name
* @returns {boolean} Whether table was deleted
*/
deleteTable(tableName) {
// Check if table exists
const exists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(tableName);
if (!exists) {
return false;
}
// Drop the table
this.db.exec(`DROP TABLE IF EXISTS ${escapeTable(tableName)}`);
// Remove from schema tracking
this.ensureSchemaTable();
this.db.prepare('DELETE FROM "_Schema" WHERE "name" = ?').run(tableName);
// Clear cache
this._schemaCache.delete(tableName);
// Drop any junction tables for relations
const junctionTables = this.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?")
.all(`_Join_%_${tableName}`);
for (const jt of junctionTables) {
this.db.exec(`DROP TABLE IF EXISTS ${escapeTable(jt.name)}`);
}
return true;
}
/**
* Rename a column in a table (SQLite 3.25.0+)
*
* @param {string} tableName - Table name
* @param {string} oldName - Current column name
* @param {string} newName - New column name
* @returns {boolean} Whether column was renamed
*/
renameColumn(tableName, oldName, newName) {
// Validate new name
if (!newName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(newName)) {
throw new Error('Invalid column name');
}
// Can't rename system columns
const systemCols = ['objectId', 'createdAt', 'updatedAt', 'ACL'];
if (systemCols.includes(oldName)) {
throw new Error('Cannot rename system columns');
}
try {
this.db.exec(
`ALTER TABLE ${escapeTable(tableName)} RENAME COLUMN ${escapeColumn(oldName)} TO ${escapeColumn(newName)}`
);
// Update schema tracking
const schema = this.getTableSchema(tableName);
if (schema && schema.columns) {
const col = schema.columns.find((c) => c.name === oldName);
if (col) {
col.name = newName;
this.db
.prepare(`UPDATE "_Schema" SET "schema" = ?, "updatedAt" = CURRENT_TIMESTAMP WHERE "name" = ?`)
.run(JSON.stringify(schema), tableName);
this._schemaCache.set(tableName, schema);
}
}
return true;
} catch (e) {
if (e.message.includes('no such column')) {
throw new Error(`Column "${oldName}" does not exist`);
}
throw e;
}
}
/** /**
* Get schema for a table * Get schema for a table
* *

View File

@@ -1,5 +1,10 @@
const OutputProperty = require('./outputproperty'); const OutputProperty = require('./outputproperty');
const { evaluateExpression } = require('./expression-evaluator'); const {
evaluateExpression,
compileExpression,
detectDependencies,
subscribeToChanges
} = require('./expression-evaluator');
const { coerceToType } = require('./expression-type-coercion'); const { coerceToType } = require('./expression-type-coercion');
/** /**
@@ -45,6 +50,9 @@ function Node(context, id) {
this._valuesFromConnections = {}; this._valuesFromConnections = {};
this.updateOnDirtyFlagging = true; this.updateOnDirtyFlagging = true;
// Expression subscriptions: { [portName]: { unsub: unsubscribeFn, expression: string } }
this._expressionSubscriptions = {};
} }
Node.prototype.getInputValue = function (name) { Node.prototype.getInputValue = function (name) {
@@ -101,7 +109,8 @@ Node.prototype.registerInputIfNeeded = function () {
}; };
/** /**
* Evaluate an expression parameter and return the coerced result * Evaluate an expression parameter and return the coerced result.
* Also sets up reactive subscriptions so the node updates when dependencies change.
* *
* @param {*} paramValue - The parameter value (might be an ExpressionParameter) * @param {*} paramValue - The parameter value (might be an ExpressionParameter)
* @param {string} portName - The input port name * @param {string} portName - The input port name
@@ -110,6 +119,14 @@ Node.prototype.registerInputIfNeeded = function () {
Node.prototype._evaluateExpressionParameter = function (paramValue, portName) { Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
// Check if this is an expression parameter // Check if this is an expression parameter
if (!isExpressionParameter(paramValue)) { if (!isExpressionParameter(paramValue)) {
// Clean up any existing subscription for this port since it's no longer an expression
if (this._expressionSubscriptions[portName]) {
const sub = this._expressionSubscriptions[portName];
if (sub && sub.unsub) {
sub.unsub();
}
delete this._expressionSubscriptions[portName];
}
return paramValue; // Simple value, return as-is return paramValue; // Simple value, return as-is
} }
@@ -119,14 +136,63 @@ Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
} }
try { try {
// Evaluate the expression with access to context // Compile and evaluate the expression
const result = evaluateExpression(paramValue.expression, this.context); // Note: We pass undefined for modelScope - evaluateExpression will use the global Model
const compiled = compileExpression(paramValue.expression);
if (!compiled) {
console.warn(`Expression compilation failed for ${this.name}.${portName}: ${paramValue.expression}`);
return paramValue.fallback;
}
const result = evaluateExpression(compiled, undefined);
// Coerce to expected type // Coerce to expected type
const coercedValue = coerceToType(result, input.type, paramValue.fallback); const coercedValue = coerceToType(result, input.type, paramValue.fallback);
// Set up reactive subscription
// Track both the unsubscribe function and the expression string
// If expression changes, we need to re-subscribe with new dependencies
const currentSub = this._expressionSubscriptions[portName];
const expressionChanged = currentSub && currentSub.expression !== paramValue.expression;
// Unsubscribe if expression changed
if (expressionChanged && currentSub.unsub) {
currentSub.unsub();
delete this._expressionSubscriptions[portName];
}
// Subscribe if not subscribed or expression changed
if (!this._expressionSubscriptions[portName]) {
const dependencies = detectDependencies(paramValue.expression);
const hasDependencies =
dependencies.variables.length > 0 || dependencies.objects.length > 0 || dependencies.arrays.length > 0;
if (hasDependencies) {
// Subscribe to changes - when a dependency changes, re-queue the input
// Note: We store the expression string to detect changes later
const unsub = subscribeToChanges(
dependencies,
function () {
// Don't re-evaluate if node is deleted
if (this._deleted) return;
// Re-queue the expression parameter - it will be re-evaluated
// Use the stored input value which has the current expression
const currentValue = this._inputValues[portName];
if (isExpressionParameter(currentValue)) {
this.queueInput(portName, currentValue);
}
}.bind(this)
);
this._expressionSubscriptions[portName] = {
unsub: unsub,
expression: paramValue.expression
};
}
}
// Clear any previous expression errors // Clear any previous expression errors
if (this.context.editorConnection) { if (this.context && this.context.editorConnection) {
this.context.editorConnection.clearWarning( this.context.editorConnection.clearWarning(
this.nodeScope.componentOwner.name, this.nodeScope.componentOwner.name,
this.id, this.id,
@@ -140,7 +206,7 @@ Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
console.warn(`Expression evaluation failed for ${this.name}.${portName}:`, error); console.warn(`Expression evaluation failed for ${this.name}.${portName}:`, error);
// Show warning in editor // Show warning in editor
if (this.context.editorConnection) { if (this.context && this.context.editorConnection) {
this.context.editorConnection.sendWarning( this.context.editorConnection.sendWarning(
this.nodeScope.componentOwner.name, this.nodeScope.componentOwner.name,
this.id, this.id,
@@ -605,6 +671,15 @@ Node.prototype._onNodeDeleted = function () {
this._deleted = true; this._deleted = true;
// Clean up expression subscriptions
for (const portName in this._expressionSubscriptions) {
const sub = this._expressionSubscriptions[portName];
if (sub && sub.unsub) {
sub.unsub();
}
}
this._expressionSubscriptions = {};
for (const deleteListener of this._deleteListeners) { for (const deleteListener of this._deleteListeners) {
deleteListener.call(this); deleteListener.call(this);
} }