Finished prototype local backends and expression editor

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

View File

@@ -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);
}
}

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,9 @@ import { isExpressionParameter, createExpressionParameter } from '@noodl-models/
import { NodeLibrary } from '@noodl-models/nodelibrary';
import { 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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,6 +100,65 @@ class BackendManager {
return this.exportSchema(id, format);
});
// 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)
*/