mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-08 01:53:30 +01:00
Finished prototype local backends and expression editor
This commit is contained in:
15
.clinerules
15
.clinerules
@@ -123,6 +123,7 @@ packages/
|
||||
- `dev-docs/reference/NODE-PATTERNS.md` - How to create/modify nodes
|
||||
- `dev-docs/reference/LEARNINGS.md` - Accumulated knowledge
|
||||
- `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
|
||||
|
||||
---
|
||||
@@ -418,6 +419,20 @@ Before marking any task complete:
|
||||
- [ ] **Discoveries added to LEARNINGS.md or COMMON-ISSUES.md**
|
||||
- [ ] 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
|
||||
|
||||
- [ ] Meaningful commit messages (conventional commits format)
|
||||
|
||||
@@ -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)
|
||||
|
||||
### The Invisible Boundary: Why Editor Methods Don't Exist in Runtime
|
||||
|
||||
511
dev-docs/reference/PANEL-UI-STYLE-GUIDE.md
Normal file
511
dev-docs/reference/PANEL-UI-STYLE-GUIDE.md
Normal 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_
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -71,13 +71,15 @@
|
||||
|
||||
## Recent Updates
|
||||
|
||||
| Date | Update |
|
||||
| ---------- | --------------------------------------------------------- |
|
||||
| 2026-01-15 | TASK-007 Integrated Backend core infrastructure complete |
|
||||
| 2026-01-07 | Corrected PROGRESS.md to reflect actual completion status |
|
||||
| 2025-12-30 | TASK-002 bug fixes and system table support |
|
||||
| 2025-12-29 | TASK-001 Backend Services Panel completed |
|
||||
| 2025-12-29 | TASK-002 Data Nodes completed |
|
||||
| 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-07 | Corrected PROGRESS.md to reflect actual completion status |
|
||||
| 2025-12-30 | TASK-002 bug fixes and system table support |
|
||||
| 2025-12-29 | TASK-001 Backend Services Panel completed |
|
||||
| 2025-12-29 | TASK-002 Data Nodes completed |
|
||||
|
||||
---
|
||||
|
||||
@@ -103,7 +105,7 @@
|
||||
|
||||
### 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:**
|
||||
|
||||
@@ -111,6 +113,9 @@ Zero-config local SQLite backend system - infrastructure complete.
|
||||
- TASK-007B: Local Backend Server (Express + IPC handlers)
|
||||
- TASK-007C: WorkflowRunner (Cloud function execution)
|
||||
- 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:**
|
||||
|
||||
@@ -118,12 +123,16 @@ Zero-config local SQLite backend system - infrastructure complete.
|
||||
- Start/stop backend servers via IPC
|
||||
- REST API compatible with Parse Server
|
||||
- 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
|
||||
- Data browser with grid view
|
||||
- Migration/export tools (optional)
|
||||
- Object/Array field editors don't work
|
||||
- Real SQLite falls back to in-memory mock (better-sqlite3 issues)
|
||||
- Can't edit/delete existing tables
|
||||
- Cell editor focus issues
|
||||
|
||||
**Implementation Files:**
|
||||
|
||||
@@ -143,6 +152,20 @@ packages/noodl-editor/src/main/src/local-backend/
|
||||
packages/noodl-editor/src/editor/src/views/panels/BackendServicesPanel/
|
||||
├── hooks/useLocalBackends.ts
|
||||
└── 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
flex: 1;
|
||||
min-width: 0; // Allow flex item to shrink below content size
|
||||
overflow: hidden; // Prevent content overflow
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
@@ -62,3 +64,29 @@
|
||||
color: var(--theme-color-error, #ef4444);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface ExpressionInputProps extends UnsafeStyleProps {
|
||||
|
||||
/** Debounce delay in milliseconds */
|
||||
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...',
|
||||
testId,
|
||||
debounceMs = 300,
|
||||
onExpand,
|
||||
UNSAFE_className,
|
||||
UNSAFE_style
|
||||
}: ExpressionInputProps) {
|
||||
@@ -143,6 +147,13 @@ export function ExpressionInput({
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,3 +26,42 @@
|
||||
opacity: 0.5;
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,23 +62,39 @@ export function ExpressionToggle({
|
||||
|
||||
const tooltipContent = isExpressionMode ? 'Switch to fixed value' : 'Switch to expression';
|
||||
|
||||
const icon = isExpressionMode ? IconName.Code : IconName.MagicWand;
|
||||
|
||||
const variant = isExpressionMode ? IconButtonVariant.Default : IconButtonVariant.OpaqueOnHover;
|
||||
// When in expression mode, show TextInBox icon (switch to fixed value)
|
||||
// When in fixed mode, show "fx" text button (switch to expression)
|
||||
if (isExpressionMode) {
|
||||
return (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<div className={css['Root']} style={UNSAFE_style}>
|
||||
<IconButton
|
||||
icon={IconName.TextInBox}
|
||||
size={IconSize.Tiny}
|
||||
variant={IconButtonVariant.Default}
|
||||
onClick={onToggle}
|
||||
isDisabled={isDisabled}
|
||||
testId={testId}
|
||||
UNSAFE_className={css['ExpressionActive']}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed mode - show "fx" text button
|
||||
return (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<div className={css['Root']} style={UNSAFE_style}>
|
||||
<IconButton
|
||||
icon={icon}
|
||||
size={IconSize.Tiny}
|
||||
variant={variant}
|
||||
onClick={onToggle}
|
||||
isDisabled={isDisabled}
|
||||
testId={testId}
|
||||
UNSAFE_className={isExpressionMode ? css['ExpressionActive'] : UNSAFE_className}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,4 +24,6 @@
|
||||
|
||||
.InputContainer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; // Allow flex item to shrink below content size
|
||||
overflow: hidden; // Prevent content overflow
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface PropertyPanelInputProps extends Omit<PropertyPanelBaseInputProp
|
||||
onExpressionChange?: (expression: string) => void;
|
||||
/** Whether the expression has an error */
|
||||
expressionError?: string;
|
||||
/** Callback when expand button is clicked (opens expression in full editor) */
|
||||
onExpressionExpand?: () => void;
|
||||
}
|
||||
|
||||
export function PropertyPanelInput({
|
||||
@@ -90,7 +92,8 @@ export function PropertyPanelInput({
|
||||
expression = '',
|
||||
onExpressionModeChange,
|
||||
onExpressionChange,
|
||||
expressionError
|
||||
expressionError,
|
||||
onExpressionExpand
|
||||
}: PropertyPanelInputProps) {
|
||||
const Input = useMemo(() => {
|
||||
switch (inputType) {
|
||||
@@ -136,6 +139,7 @@ export function PropertyPanelInput({
|
||||
onChange={onExpressionChange}
|
||||
hasError={!!expressionError}
|
||||
errorMessage={expressionError}
|
||||
onExpand={onExpressionExpand}
|
||||
UNSAFE_style={{ flex: 1 }}
|
||||
/>
|
||||
);
|
||||
@@ -165,7 +169,7 @@ export function PropertyPanelInput({
|
||||
<div className={css['Root']}>
|
||||
<div className={classNames(css['Label'], isChanged && css['is-changed'])}>{label}</div>
|
||||
<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()}
|
||||
{showExpressionToggle && (
|
||||
<ExpressionToggle mode={expressionMode} isConnected={isConnected} onToggle={handleToggleMode} />
|
||||
|
||||
@@ -63,3 +63,28 @@
|
||||
padding-top: 8px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
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 { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import { DataBrowser } from '../../databrowser';
|
||||
import { SchemaPanel } from '../../schemamanager';
|
||||
import { LocalBackendInfo } from '../hooks/useLocalBackends';
|
||||
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) {
|
||||
const [isOperating, setIsOperating] = useState(false);
|
||||
const [showSchemaPanel, setShowSchemaPanel] = useState(false);
|
||||
const [showDataBrowser, setShowDataBrowser] = useState(false);
|
||||
const statusDisplay = getStatusDisplay(backend.running);
|
||||
|
||||
// Format date
|
||||
@@ -127,6 +132,22 @@ export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport
|
||||
onClick={handleToggle}
|
||||
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 && (
|
||||
<PrimaryButton
|
||||
label="Export"
|
||||
@@ -144,6 +165,29 @@ export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport
|
||||
/>
|
||||
</HStack>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -5,11 +5,9 @@ import { isExpressionParameter, createExpressionParameter } from '@noodl-models/
|
||||
import { NodeLibrary } from '@noodl-models/nodelibrary';
|
||||
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
|
||||
|
||||
import {
|
||||
PropertyPanelInput,
|
||||
PropertyPanelInputType
|
||||
} from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
|
||||
import { PropertyPanelInputType } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
|
||||
|
||||
import { PropertyPanelInputWithExpressionModal } from '../components/PropertyPanelInputWithExpressionModal';
|
||||
import { TypeView } from '../TypeView';
|
||||
import { getEditType } from '../utils';
|
||||
|
||||
@@ -154,10 +152,12 @@ export class BasicType extends TypeView {
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExpressionEditorModal } from './ExpressionEditorModal';
|
||||
export type { ExpressionEditorModalProps } from './ExpressionEditorModal';
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -100,6 +100,65 @@ class BackendManager {
|
||||
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
|
||||
ipcMain.handle('backend:update-workflow', async (_, args) => {
|
||||
return this.updateWorkflow(args.backendId, args.name, args.workflow);
|
||||
@@ -310,16 +369,200 @@ class BackendManager {
|
||||
throw new Error('Adapter or schema manager not available');
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case 'postgres':
|
||||
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 === 'postgres') {
|
||||
return adapter.schemaManager.generatePostgresSQL();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 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)
|
||||
*/
|
||||
|
||||
@@ -13,17 +13,17 @@ const QueryBuilder = require('./QueryBuilder');
|
||||
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() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let id = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
id += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return id;
|
||||
function generateUUID() {
|
||||
// RFC 4122 version 4 UUID
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,17 +132,25 @@ class LocalSQLAdapter {
|
||||
const self = this;
|
||||
return {
|
||||
ensureSchemaTable: () => {},
|
||||
createTable: ({ name }) => {
|
||||
createTable: ({ name, columns }) => {
|
||||
if (!self._mockData[name]) {
|
||||
self._mockData[name] = {};
|
||||
self._mockSchema[name] = {};
|
||||
self._mockSchema[name] = { name, columns: columns || [] };
|
||||
}
|
||||
return true;
|
||||
},
|
||||
addColumn: (table, col) => {
|
||||
if (!self._mockSchema[table]) self._mockSchema[table] = {};
|
||||
self._mockSchema[table][col.name] = col;
|
||||
if (!self._mockSchema[table]) self._mockSchema[table] = { name: table, columns: [] };
|
||||
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: () => {},
|
||||
removeRelation: () => {}
|
||||
};
|
||||
@@ -153,8 +161,9 @@ class LocalSQLAdapter {
|
||||
* @private
|
||||
*/
|
||||
_mockExec(sql, params, mode) {
|
||||
// Parse simple SQL patterns for mock execution
|
||||
const selectMatch = sql.match(/SELECT \* FROM "?(\w+)"?\s*(?:WHERE "?objectId"?\s*=\s*\?)?/i);
|
||||
// Parse SQL patterns for mock execution
|
||||
// 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 updateMatch = sql.match(/UPDATE "?(\w+)"?\s+SET/i);
|
||||
const deleteMatch = sql.match(/DELETE FROM "?(\w+)"?/i);
|
||||
@@ -163,48 +172,111 @@ class LocalSQLAdapter {
|
||||
const table = selectMatch[1];
|
||||
if (!this._mockData[table]) this._mockData[table] = {};
|
||||
|
||||
if (params.length > 0) {
|
||||
// Single record fetch
|
||||
const record = this._mockData[table][params[0]];
|
||||
let records = Object.values(this._mockData[table]);
|
||||
|
||||
// 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 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;
|
||||
}
|
||||
|
||||
if (insertMatch) {
|
||||
const table = insertMatch[1];
|
||||
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 record = { objectId: params[0], createdAt: now, updatedAt: now };
|
||||
const record = { id: params[0], createdAt: now, updatedAt: now };
|
||||
this._mockData[table][params[0]] = record;
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (updateMatch) {
|
||||
const table = updateMatch[1];
|
||||
// Last param is typically the objectId in WHERE clause
|
||||
const objectId = params[params.length - 1];
|
||||
if (this._mockData[table] && this._mockData[table][objectId]) {
|
||||
this._mockData[table][objectId].updatedAt = new Date().toISOString();
|
||||
// Last param is typically the id in WHERE clause
|
||||
const recordId = params[params.length - 1];
|
||||
if (this._mockData[table] && this._mockData[table][recordId]) {
|
||||
// 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 };
|
||||
}
|
||||
|
||||
if (deleteMatch) {
|
||||
const table = deleteMatch[1];
|
||||
const objectId = params[0];
|
||||
const recordId = params[0];
|
||||
if (this._mockData[table]) {
|
||||
delete this._mockData[table][objectId];
|
||||
delete this._mockData[table][recordId];
|
||||
}
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
// Count query
|
||||
if (sql.includes('COUNT(*)')) {
|
||||
const countMatch = sql.match(/FROM "?(\w+)"?/i);
|
||||
const countMatch = sql.match(/FROM\s+"?(\w+)"?/i);
|
||||
if (countMatch) {
|
||||
const table = countMatch[1];
|
||||
const count = Object.keys(this._mockData[table] || {}).length;
|
||||
@@ -356,8 +428,9 @@ class LocalSQLAdapter {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`;
|
||||
const row = this.db.prepare(sql).get(options.objectId);
|
||||
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`;
|
||||
const recordId = options.id || options.objectId;
|
||||
const row = this.db.prepare(sql).get(recordId);
|
||||
|
||||
if (!row) {
|
||||
options.error('Object not found');
|
||||
@@ -370,7 +443,7 @@ class LocalSQLAdapter {
|
||||
|
||||
this.events.emit('fetch', {
|
||||
type: 'fetch',
|
||||
objectId: options.objectId,
|
||||
id: recordId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
@@ -392,22 +465,22 @@ class LocalSQLAdapter {
|
||||
// Auto-add columns for new fields
|
||||
if (this.options.autoCreateTables && this.schemaManager) {
|
||||
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);
|
||||
this.schemaManager.addColumn(options.collection, { name: key, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const objectId = generateObjectId();
|
||||
const { sql, params } = QueryBuilder.buildInsert(options, objectId);
|
||||
const recordId = generateUUID();
|
||||
const { sql, params } = QueryBuilder.buildInsert(options, recordId);
|
||||
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the created record to get all fields
|
||||
const createdRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(objectId);
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
|
||||
.get(recordId);
|
||||
|
||||
const record = this._rowToRecord(createdRow, options.collection);
|
||||
|
||||
@@ -415,7 +488,7 @@ class LocalSQLAdapter {
|
||||
|
||||
this.events.emit('create', {
|
||||
type: 'create',
|
||||
objectId,
|
||||
id: recordId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
@@ -437,20 +510,21 @@ class LocalSQLAdapter {
|
||||
// Auto-add columns for new fields
|
||||
if (this.options.autoCreateTables && this.schemaManager) {
|
||||
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);
|
||||
this.schemaManager.addColumn(options.collection, { name: key, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recordId = options.id || options.objectId;
|
||||
const { sql, params } = QueryBuilder.buildUpdate(options);
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the updated record
|
||||
const updatedRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(options.objectId);
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
|
||||
.get(recordId);
|
||||
|
||||
const record = this._rowToRecord(updatedRow, options.collection);
|
||||
|
||||
@@ -458,7 +532,7 @@ class LocalSQLAdapter {
|
||||
|
||||
this.events.emit('save', {
|
||||
type: 'save',
|
||||
objectId: options.objectId,
|
||||
id: recordId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
@@ -482,9 +556,10 @@ class LocalSQLAdapter {
|
||||
|
||||
options.success();
|
||||
|
||||
const recordId = options.id || options.objectId;
|
||||
this.events.emit('delete', {
|
||||
type: 'delete',
|
||||
objectId: options.objectId,
|
||||
id: recordId,
|
||||
collection: options.collection
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -573,9 +648,10 @@ class LocalSQLAdapter {
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the updated record
|
||||
const recordId = options.id || options.objectId;
|
||||
const updatedRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(options.objectId);
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
|
||||
.get(recordId);
|
||||
|
||||
const record = this._rowToRecord(updatedRow, options.collection);
|
||||
options.success(record);
|
||||
|
||||
@@ -275,6 +275,12 @@ function translateOperator(col, op, value, params, schema) {
|
||||
}
|
||||
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
|
||||
case '$nearSphere':
|
||||
case '$within':
|
||||
@@ -333,8 +339,8 @@ function buildSelect(options, schema) {
|
||||
let selectClause = '*';
|
||||
if (options.select) {
|
||||
const selectArray = Array.isArray(options.select) ? options.select : options.select.split(',');
|
||||
// Always include objectId
|
||||
const fields = new Set(['objectId', ...selectArray.map((s) => s.trim())]);
|
||||
// Always include id
|
||||
const fields = new Set(['id', ...selectArray.map((s) => s.trim())]);
|
||||
selectClause = Array.from(fields)
|
||||
.map((f) => escapeColumn(f))
|
||||
.join(', ');
|
||||
@@ -406,13 +412,13 @@ function buildCount(options, schema) {
|
||||
* @param {string} objectId
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildInsert(options, objectId) {
|
||||
function buildInsert(options, id) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const data = {
|
||||
objectId,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...options.data
|
||||
@@ -441,7 +447,7 @@ function buildInsert(options, objectId) {
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @param {string} options.id - Record ID
|
||||
* @param {Object} options.data
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
@@ -455,7 +461,7 @@ function buildUpdate(options) {
|
||||
data.updatedAt = new Date().toISOString();
|
||||
|
||||
// Remove protected fields
|
||||
delete data.objectId;
|
||||
delete data.id;
|
||||
delete data.createdAt;
|
||||
delete data._createdAt;
|
||||
delete data._updatedAt;
|
||||
@@ -467,9 +473,11 @@ function buildUpdate(options) {
|
||||
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 };
|
||||
}
|
||||
@@ -479,13 +487,15 @@ function buildUpdate(options) {
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @param {string} options.id - Record ID
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildDelete(options) {
|
||||
const table = escapeTable(options.collection);
|
||||
const sql = `DELETE FROM ${table} WHERE "objectId" = ?`;
|
||||
return { sql, params: [options.objectId] };
|
||||
// Use id or objectId for backwards compatibility
|
||||
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 {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @param {string} options.id - Record ID
|
||||
* @param {Object<string, number>} options.properties
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
@@ -513,9 +523,11 @@ function buildIncrement(options) {
|
||||
setClause.push('"updatedAt" = ?');
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const OutputProperty = require('./outputproperty');
|
||||
const { evaluateExpression } = require('./expression-evaluator');
|
||||
const {
|
||||
evaluateExpression,
|
||||
compileExpression,
|
||||
detectDependencies,
|
||||
subscribeToChanges
|
||||
} = require('./expression-evaluator');
|
||||
const { coerceToType } = require('./expression-type-coercion');
|
||||
|
||||
/**
|
||||
@@ -45,6 +50,9 @@ function Node(context, id) {
|
||||
|
||||
this._valuesFromConnections = {};
|
||||
this.updateOnDirtyFlagging = true;
|
||||
|
||||
// Expression subscriptions: { [portName]: { unsub: unsubscribeFn, expression: string } }
|
||||
this._expressionSubscriptions = {};
|
||||
}
|
||||
|
||||
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 {string} portName - The input port name
|
||||
@@ -110,6 +119,14 @@ Node.prototype.registerInputIfNeeded = function () {
|
||||
Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
|
||||
// Check if this is an expression parameter
|
||||
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
|
||||
}
|
||||
|
||||
@@ -119,14 +136,63 @@ Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Evaluate the expression with access to context
|
||||
const result = evaluateExpression(paramValue.expression, this.context);
|
||||
// Compile and evaluate the expression
|
||||
// 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
|
||||
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
|
||||
if (this.context.editorConnection) {
|
||||
if (this.context && this.context.editorConnection) {
|
||||
this.context.editorConnection.clearWarning(
|
||||
this.nodeScope.componentOwner.name,
|
||||
this.id,
|
||||
@@ -140,7 +206,7 @@ Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
|
||||
console.warn(`Expression evaluation failed for ${this.name}.${portName}:`, error);
|
||||
|
||||
// Show warning in editor
|
||||
if (this.context.editorConnection) {
|
||||
if (this.context && this.context.editorConnection) {
|
||||
this.context.editorConnection.sendWarning(
|
||||
this.nodeScope.componentOwner.name,
|
||||
this.id,
|
||||
@@ -605,6 +671,15 @@ Node.prototype._onNodeDeleted = function () {
|
||||
|
||||
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) {
|
||||
deleteListener.call(this);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user