mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-03-07 17:43:28 +01:00
Finished prototype local backends and expression editor
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
flex: 1;
|
||||
min-width: 0; // Allow flex item to shrink below content size
|
||||
overflow: hidden; // Prevent content overflow
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
@@ -62,3 +64,29 @@
|
||||
color: var(--theme-color-error, #ef4444);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.ExpandButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.5));
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-1, rgba(255, 255, 255, 0.1));
|
||||
color: var(--theme-color-primary, #6366f1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: var(--theme-color-bg-1, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface ExpressionInputProps extends UnsafeStyleProps {
|
||||
|
||||
/** Debounce delay in milliseconds */
|
||||
debounceMs?: number;
|
||||
|
||||
/** Callback when expand button is clicked - opens expression in full editor */
|
||||
onExpand?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +59,7 @@ export function ExpressionInput({
|
||||
placeholder = 'Enter expression...',
|
||||
testId,
|
||||
debounceMs = 300,
|
||||
onExpand,
|
||||
UNSAFE_className,
|
||||
UNSAFE_style
|
||||
}: ExpressionInputProps) {
|
||||
@@ -143,6 +147,13 @@ export function ExpressionInput({
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onExpand && (
|
||||
<Tooltip content="Edit in code editor">
|
||||
<button type="button" className={css['ExpandButton']} onClick={onExpand} aria-label="Edit in code editor">
|
||||
<Icon icon={IconName.Code} size={IconSize.Tiny} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,3 +26,42 @@
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.FxButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.5));
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-1, rgba(255, 255, 255, 0.1));
|
||||
color: var(--theme-color-primary, #6366f1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: var(--theme-color-bg-1, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,23 +62,39 @@ export function ExpressionToggle({
|
||||
|
||||
const tooltipContent = isExpressionMode ? 'Switch to fixed value' : 'Switch to expression';
|
||||
|
||||
const icon = isExpressionMode ? IconName.Code : IconName.MagicWand;
|
||||
|
||||
const variant = isExpressionMode ? IconButtonVariant.Default : IconButtonVariant.OpaqueOnHover;
|
||||
// When in expression mode, show TextInBox icon (switch to fixed value)
|
||||
// When in fixed mode, show "fx" text button (switch to expression)
|
||||
if (isExpressionMode) {
|
||||
return (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<div className={css['Root']} style={UNSAFE_style}>
|
||||
<IconButton
|
||||
icon={IconName.TextInBox}
|
||||
size={IconSize.Tiny}
|
||||
variant={IconButtonVariant.Default}
|
||||
onClick={onToggle}
|
||||
isDisabled={isDisabled}
|
||||
testId={testId}
|
||||
UNSAFE_className={css['ExpressionActive']}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed mode - show "fx" text button
|
||||
return (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<div className={css['Root']} style={UNSAFE_style}>
|
||||
<IconButton
|
||||
icon={icon}
|
||||
size={IconSize.Tiny}
|
||||
variant={variant}
|
||||
onClick={onToggle}
|
||||
isDisabled={isDisabled}
|
||||
testId={testId}
|
||||
UNSAFE_className={isExpressionMode ? css['ExpressionActive'] : UNSAFE_className}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${css['FxButton']} ${isDisabled ? css['is-disabled'] : ''} ${UNSAFE_className || ''}`}
|
||||
style={UNSAFE_style}
|
||||
onClick={isDisabled ? undefined : onToggle}
|
||||
disabled={isDisabled}
|
||||
data-test={testId}
|
||||
>
|
||||
fx
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,4 +24,6 @@
|
||||
|
||||
.InputContainer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; // Allow flex item to shrink below content size
|
||||
overflow: hidden; // Prevent content overflow
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface PropertyPanelInputProps extends Omit<PropertyPanelBaseInputProp
|
||||
onExpressionChange?: (expression: string) => void;
|
||||
/** Whether the expression has an error */
|
||||
expressionError?: string;
|
||||
/** Callback when expand button is clicked (opens expression in full editor) */
|
||||
onExpressionExpand?: () => void;
|
||||
}
|
||||
|
||||
export function PropertyPanelInput({
|
||||
@@ -90,7 +92,8 @@ export function PropertyPanelInput({
|
||||
expression = '',
|
||||
onExpressionModeChange,
|
||||
onExpressionChange,
|
||||
expressionError
|
||||
expressionError,
|
||||
onExpressionExpand
|
||||
}: PropertyPanelInputProps) {
|
||||
const Input = useMemo(() => {
|
||||
switch (inputType) {
|
||||
@@ -136,6 +139,7 @@ export function PropertyPanelInput({
|
||||
onChange={onExpressionChange}
|
||||
hasError={!!expressionError}
|
||||
errorMessage={expressionError}
|
||||
onExpand={onExpressionExpand}
|
||||
UNSAFE_style={{ flex: 1 }}
|
||||
/>
|
||||
);
|
||||
@@ -165,7 +169,7 @@ export function PropertyPanelInput({
|
||||
<div className={css['Root']}>
|
||||
<div className={classNames(css['Label'], isChanged && css['is-changed'])}>{label}</div>
|
||||
<div className={css['InputContainer']}>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', minWidth: 0 }}>
|
||||
{renderInput()}
|
||||
{showExpressionToggle && (
|
||||
<ExpressionToggle mode={expressionMode} isConnected={isConnected} onToggle={handleToggleMode} />
|
||||
|
||||
@@ -63,3 +63,28 @@
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.SchemaPanelOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 40px;
|
||||
|
||||
> div {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow: auto;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
@@ -16,6 +17,8 @@ import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-c
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import { DataBrowser } from '../../databrowser';
|
||||
import { SchemaPanel } from '../../schemamanager';
|
||||
import { LocalBackendInfo } from '../hooks/useLocalBackends';
|
||||
import css from './LocalBackendCard.module.scss';
|
||||
|
||||
@@ -44,6 +47,8 @@ function getStatusDisplay(running: boolean): { icon: IconName; color: string; te
|
||||
|
||||
export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport }: LocalBackendCardProps) {
|
||||
const [isOperating, setIsOperating] = useState(false);
|
||||
const [showSchemaPanel, setShowSchemaPanel] = useState(false);
|
||||
const [showDataBrowser, setShowDataBrowser] = useState(false);
|
||||
const statusDisplay = getStatusDisplay(backend.running);
|
||||
|
||||
// Format date
|
||||
@@ -127,6 +132,22 @@ export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport
|
||||
onClick={handleToggle}
|
||||
isDisabled={isOperating}
|
||||
/>
|
||||
{backend.running && (
|
||||
<>
|
||||
<PrimaryButton
|
||||
label="Data"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={() => setShowDataBrowser(true)}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Schema"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={() => setShowSchemaPanel(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{onExport && backend.running && (
|
||||
<PrimaryButton
|
||||
label="Export"
|
||||
@@ -144,6 +165,29 @@ export function LocalBackendCard({ backend, onStart, onStop, onDelete, onExport
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{/* Schema Panel (rendered via portal for full-screen overlay) */}
|
||||
{showSchemaPanel &&
|
||||
createPortal(
|
||||
<div className={css.SchemaPanelOverlay}>
|
||||
<SchemaPanel
|
||||
backendId={backend.id}
|
||||
backendName={backend.name}
|
||||
isRunning={backend.running}
|
||||
onClose={() => setShowSchemaPanel(false)}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Data Browser (rendered via portal for full-screen overlay) */}
|
||||
{showDataBrowser &&
|
||||
createPortal(
|
||||
<div className={css.SchemaPanelOverlay}>
|
||||
<DataBrowser backendId={backend.id} backendName={backend.name} onClose={() => setShowDataBrowser(false)} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* CellEditor styles
|
||||
*/
|
||||
|
||||
.CellEditor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Input,
|
||||
.DateInput {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--theme-color-primary);
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--theme-color-primary-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.JsonEditor {
|
||||
width: 250px;
|
||||
min-height: 100px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--theme-color-primary);
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
box-shadow: 0 0 0 2px var(--theme-color-primary-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.JsonActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.SaveButton,
|
||||
.CancelButton {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.SaveButton {
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-fg-on-primary);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.CancelButton {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
.Error {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--theme-color-danger);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
border-radius: 4px;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* CellEditor
|
||||
*
|
||||
* Inline cell editor component with type-aware input controls.
|
||||
* Handles String, Number, Boolean, Date, Object, and Array types.
|
||||
*
|
||||
* @module panels/databrowser/CellEditor
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import css from './CellEditor.module.scss';
|
||||
|
||||
export interface CellEditorProps {
|
||||
/** Current value */
|
||||
value: unknown;
|
||||
/** Column type */
|
||||
type: string;
|
||||
/** Called when value saved */
|
||||
onSave: (value: unknown) => void;
|
||||
/** Called when editing cancelled */
|
||||
onCancel: () => void;
|
||||
/** Error message to display */
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CellEditor component - type-aware inline editor
|
||||
*/
|
||||
export function CellEditor({ value, type, onSave, onCancel, error }: CellEditorProps) {
|
||||
const [editValue, setEditValue] = useState<string>(() => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
});
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
|
||||
// Focus input on mount with delay to prevent immediate blur
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
if (inputRef.current instanceof HTMLInputElement) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
setIsFocused(true);
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && type !== 'Object' && type !== 'Array') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[type, onCancel]
|
||||
);
|
||||
|
||||
// Handle save with type conversion
|
||||
const handleSave = useCallback(() => {
|
||||
let finalValue: unknown = editValue;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'Number':
|
||||
if (editValue.trim() === '') {
|
||||
finalValue = null;
|
||||
} else {
|
||||
finalValue = parseFloat(editValue);
|
||||
if (isNaN(finalValue as number)) {
|
||||
setJsonError('Invalid number');
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Boolean':
|
||||
// Boolean is handled by checkbox, just use editValue
|
||||
finalValue = editValue === 'true';
|
||||
break;
|
||||
|
||||
case 'Date':
|
||||
if (editValue.trim() === '') {
|
||||
finalValue = null;
|
||||
} else {
|
||||
finalValue = new Date(editValue).toISOString();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Object':
|
||||
case 'Array':
|
||||
if (editValue.trim() === '') {
|
||||
finalValue = type === 'Array' ? [] : {};
|
||||
} else {
|
||||
finalValue = JSON.parse(editValue);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// String - use as-is
|
||||
finalValue = editValue;
|
||||
}
|
||||
|
||||
setJsonError(null);
|
||||
onSave(finalValue);
|
||||
} catch (err) {
|
||||
setJsonError('Invalid JSON');
|
||||
}
|
||||
}, [editValue, type, onSave]);
|
||||
|
||||
// Boolean - render checkbox
|
||||
if (type === 'Boolean') {
|
||||
return (
|
||||
<div className={css.CellEditor}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value === true || editValue === 'true'}
|
||||
onChange={(e) => {
|
||||
setEditValue(e.target.checked ? 'true' : 'false');
|
||||
onSave(e.target.checked);
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Escape' && onCancel()}
|
||||
/>
|
||||
{error && <div className={css.Error}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Date - render datetime input
|
||||
if (type === 'Date') {
|
||||
const dateValue = value ? new Date(value as string).toISOString().slice(0, 16) : '';
|
||||
return (
|
||||
<div className={css.CellEditor}>
|
||||
<input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
type="datetime-local"
|
||||
className={css.DateInput}
|
||||
value={editValue || dateValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => isFocused && handleSave()}
|
||||
/>
|
||||
{(error || jsonError) && <div className={css.Error}>{error || jsonError}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Object/Array - render textarea
|
||||
if (type === 'Object' || type === 'Array') {
|
||||
return (
|
||||
<div className={css.CellEditor}>
|
||||
<textarea
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
className={css.JsonEditor}
|
||||
value={editValue}
|
||||
onChange={(e) => {
|
||||
setEditValue(e.target.value);
|
||||
setJsonError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className={css.JsonActions}>
|
||||
<button className={css.SaveButton} onClick={handleSave}>
|
||||
Save
|
||||
</button>
|
||||
<button className={css.CancelButton} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{(error || jsonError) && <div className={css.Error}>{error || jsonError}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Number - render number input
|
||||
if (type === 'Number') {
|
||||
return (
|
||||
<div className={css.CellEditor}>
|
||||
<input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
type="number"
|
||||
className={css.Input}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => isFocused && handleSave()}
|
||||
step="any"
|
||||
/>
|
||||
{(error || jsonError) && <div className={css.Error}>{error || jsonError}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: String - render text input
|
||||
return (
|
||||
<div className={css.CellEditor}>
|
||||
<input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
type="text"
|
||||
className={css.Input}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => isFocused && handleSave()}
|
||||
/>
|
||||
{(error || jsonError) && <div className={css.Error}>{error || jsonError}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* DataBrowser styles
|
||||
* Uses theme tokens per UI-STYLING-GUIDE.md
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 80vh;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
.HeaderIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: var(--theme-color-primary);
|
||||
border-radius: 6px;
|
||||
color: var(--theme-color-fg-on-primary);
|
||||
}
|
||||
|
||||
.Toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.TableSelect {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
min-width: 150px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
}
|
||||
|
||||
.BulkActions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--theme-color-notice-bg);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.Error {
|
||||
padding: 12px 16px;
|
||||
background-color: var(--theme-color-danger-bg);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.Content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.Loading,
|
||||
.EmptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.Pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
.PageControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.PageButton {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* DataBrowser
|
||||
*
|
||||
* Main data browser panel for viewing and editing records in local backend tables.
|
||||
* Provides a spreadsheet-like interface with inline editing, search, and pagination.
|
||||
*
|
||||
* @module panels/databrowser/DataBrowser
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import css from './DataBrowser.module.scss';
|
||||
import { DataGrid } from './DataGrid';
|
||||
import { NewRecordModal } from './NewRecordModal';
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
/** Column definition from schema */
|
||||
export interface ColumnDef {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
targetClass?: string;
|
||||
}
|
||||
|
||||
/** Table schema */
|
||||
export interface TableSchema {
|
||||
name: string;
|
||||
columns: ColumnDef[];
|
||||
}
|
||||
|
||||
export interface DataBrowserProps {
|
||||
/** Backend ID to browse */
|
||||
backendId: string;
|
||||
/** Backend display name */
|
||||
backendName: string;
|
||||
/** Initial table to show (optional) */
|
||||
initialTable?: string;
|
||||
/** Close callback */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* DataBrowser component - main data browsing UI
|
||||
*/
|
||||
export function DataBrowser({ backendId, backendName, initialTable, onClose }: DataBrowserProps) {
|
||||
// State
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(initialTable || null);
|
||||
const [schema, setSchema] = useState<TableSchema | null>(null);
|
||||
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedRecords, setSelectedRecords] = useState<Set<string>>(new Set());
|
||||
const [showNewRecord, setShowNewRecord] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// System columns shown for all tables
|
||||
const systemColumns: ColumnDef[] = useMemo(
|
||||
() => [
|
||||
{ name: 'id', type: 'String', required: true },
|
||||
{ name: 'createdAt', type: 'Date', required: true },
|
||||
{ name: 'updatedAt', type: 'Date', required: true }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
// All columns = system + user columns (deduplicated by name)
|
||||
const allColumns = useMemo(() => {
|
||||
if (!schema) return systemColumns;
|
||||
// Deduplicate by column name, system columns first
|
||||
const seen = new Set(systemColumns.map((c) => c.name));
|
||||
const userColumns = (schema.columns || []).filter((c: ColumnDef) => !seen.has(c.name));
|
||||
return [...systemColumns, ...userColumns];
|
||||
}, [schema, systemColumns]);
|
||||
|
||||
// Load table list
|
||||
const loadTables = useCallback(async () => {
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('backend:getSchema', backendId);
|
||||
const tableNames = result.tables.map((t: { name: string }) => t.name);
|
||||
setTables(tableNames);
|
||||
|
||||
// Auto-select first table if none selected
|
||||
if (!selectedTable && tableNames.length > 0) {
|
||||
setSelectedTable(tableNames[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load tables:', err);
|
||||
setError('Failed to load tables');
|
||||
}
|
||||
}, [backendId, selectedTable]);
|
||||
|
||||
// Load data for selected table
|
||||
const loadData = useCallback(async () => {
|
||||
if (!selectedTable) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Load schema for this table
|
||||
const tableSchema = await ipcRenderer.invoke('backend:getTableSchema', backendId, selectedTable);
|
||||
setSchema(tableSchema);
|
||||
|
||||
// Build query
|
||||
const queryOptions: {
|
||||
collection: string;
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string[];
|
||||
count: boolean;
|
||||
where?: Record<string, unknown>;
|
||||
} = {
|
||||
collection: selectedTable,
|
||||
limit: PAGE_SIZE,
|
||||
skip: page * PAGE_SIZE,
|
||||
sort: ['-createdAt'],
|
||||
count: true
|
||||
};
|
||||
|
||||
// Apply search (simple contains search across string fields)
|
||||
if (searchQuery.trim()) {
|
||||
const stringColumns = tableSchema?.columns?.filter((c: ColumnDef) => c.type === 'String') || [];
|
||||
const searchConditions = stringColumns.map((col: ColumnDef) => ({
|
||||
[col.name]: { contains: searchQuery.trim() }
|
||||
}));
|
||||
|
||||
// Also search id
|
||||
searchConditions.push({ id: { contains: searchQuery.trim() } });
|
||||
|
||||
if (searchConditions.length > 0) {
|
||||
queryOptions.where = { $or: searchConditions };
|
||||
}
|
||||
}
|
||||
|
||||
// Load records
|
||||
const result = await ipcRenderer.invoke('backend:queryRecords', backendId, queryOptions);
|
||||
setRecords(result.results || []);
|
||||
setTotalCount(result.count || 0);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
setError('Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [backendId, selectedTable, page, searchQuery]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, [loadTables]);
|
||||
|
||||
// Load data when table, page, or search changes
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// Reset page when search or table changes
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
setSelectedRecords(new Set());
|
||||
}, [selectedTable, searchQuery]);
|
||||
|
||||
// Save cell (inline edit)
|
||||
const handleSaveCell = useCallback(
|
||||
async (recordId: string, field: string, value: unknown) => {
|
||||
if (!selectedTable) return;
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke('backend:saveRecord', backendId, selectedTable, recordId, {
|
||||
[field]: value
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setRecords((prev) =>
|
||||
prev.map((r) => (r.id === recordId ? { ...r, [field]: value, updatedAt: new Date().toISOString() } : r))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to save cell:', err);
|
||||
throw err; // Re-throw so CellEditor can show error
|
||||
}
|
||||
},
|
||||
[backendId, selectedTable]
|
||||
);
|
||||
|
||||
// Delete single record
|
||||
const handleDeleteRecord = useCallback(
|
||||
async (recordId: string) => {
|
||||
if (!selectedTable) return;
|
||||
if (!window.confirm('Delete this record?')) return;
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke('backend:deleteRecord', backendId, selectedTable, recordId);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete record:', err);
|
||||
setError('Failed to delete record');
|
||||
}
|
||||
},
|
||||
[backendId, selectedTable, loadData]
|
||||
);
|
||||
|
||||
// Bulk delete
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
if (selectedRecords.size === 0 || !selectedTable) return;
|
||||
if (!window.confirm(`Delete ${selectedRecords.size} records?`)) return;
|
||||
|
||||
try {
|
||||
for (const recordId of selectedRecords) {
|
||||
await ipcRenderer.invoke('backend:deleteRecord', backendId, selectedTable, recordId);
|
||||
}
|
||||
setSelectedRecords(new Set());
|
||||
loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to bulk delete:', err);
|
||||
setError('Failed to delete some records');
|
||||
}
|
||||
}, [backendId, selectedTable, selectedRecords, loadData]);
|
||||
|
||||
// Export to CSV
|
||||
const handleExport = useCallback(() => {
|
||||
if (records.length === 0 || !schema) return;
|
||||
|
||||
// Build CSV
|
||||
const headers = allColumns.map((c) => c.name).join(',');
|
||||
const rows = records.map((record) =>
|
||||
allColumns
|
||||
.map((col) => {
|
||||
const value = record[col.name];
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return `"${JSON.stringify(value).replace(/"/g, '""')}"`;
|
||||
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return String(value);
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
|
||||
const csv = [headers, ...rows].join('\n');
|
||||
|
||||
// Download
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${selectedTable}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [records, allColumns, selectedTable, schema]);
|
||||
|
||||
// Toggle record selection
|
||||
const handleSelectRecord = useCallback((recordId: string, selected: boolean) => {
|
||||
setSelectedRecords((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(recordId);
|
||||
} else {
|
||||
next.delete(recordId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Select/deselect all
|
||||
const handleSelectAll = useCallback(
|
||||
(selected: boolean) => {
|
||||
if (selected) {
|
||||
setSelectedRecords(new Set(records.map((r) => r.id as string)));
|
||||
} else {
|
||||
setSelectedRecords(new Set());
|
||||
}
|
||||
},
|
||||
[records]
|
||||
);
|
||||
|
||||
// Pagination
|
||||
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||
const startRecord = page * PAGE_SIZE + 1;
|
||||
const endRecord = Math.min((page + 1) * PAGE_SIZE, totalCount);
|
||||
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
{/* Header */}
|
||||
<div className={css.Header}>
|
||||
<HStack hasSpacing>
|
||||
<div className={css.HeaderIcon}>
|
||||
<Icon icon={IconName.CloudData} size={IconSize.Small} />
|
||||
</div>
|
||||
<VStack>
|
||||
<Text textType={TextType.DefaultContrast}>Data Browser</Text>
|
||||
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
|
||||
{backendName}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<IconButton icon={IconName.Close} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className={css.Toolbar}>
|
||||
<HStack hasSpacing>
|
||||
{/* Table selector */}
|
||||
<select
|
||||
className={css.TableSelect}
|
||||
value={selectedTable || ''}
|
||||
onChange={(e) => setSelectedTable(e.target.value || null)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select table...
|
||||
</option>
|
||||
{tables.map((table) => (
|
||||
<option key={table} value={table}>
|
||||
{table}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
className={css.SearchInput}
|
||||
placeholder="Search records..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Refresh */}
|
||||
<IconButton icon={IconName.Refresh} onClick={loadData} />
|
||||
</HStack>
|
||||
|
||||
<HStack hasSpacing>
|
||||
{selectedTable && (
|
||||
<>
|
||||
<PrimaryButton
|
||||
label="+ New Record"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={() => setShowNewRecord(true)}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Export CSV"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={handleExport}
|
||||
isDisabled={records.length === 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedRecords.size > 0 && (
|
||||
<div className={css.BulkActions}>
|
||||
<Text textType={TextType.Default}>{selectedRecords.size} records selected</Text>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="Delete Selected"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Danger}
|
||||
onClick={handleBulkDelete}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Clear Selection"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={() => setSelectedRecords(new Set())}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className={css.Error}>
|
||||
<Text textType={TextType.Default}>{error}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div className={css.Content}>
|
||||
{loading ? (
|
||||
<div className={css.Loading}>
|
||||
<Text textType={TextType.Shy}>Loading...</Text>
|
||||
</div>
|
||||
) : !selectedTable ? (
|
||||
<div className={css.EmptyState}>
|
||||
<Text textType={TextType.Shy}>Select a table to browse data</Text>
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className={css.EmptyState}>
|
||||
<Text textType={TextType.Shy}>No records found</Text>
|
||||
{!searchQuery && (
|
||||
<PrimaryButton
|
||||
label="Create First Record"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={() => setShowNewRecord(true)}
|
||||
UNSAFE_style={{ marginTop: '12px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
columns={allColumns}
|
||||
records={records}
|
||||
selectedRecords={selectedRecords}
|
||||
onSelectRecord={handleSelectRecord}
|
||||
onSelectAll={handleSelectAll}
|
||||
onSaveCell={handleSaveCell}
|
||||
onDeleteRecord={handleDeleteRecord}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{selectedTable && totalCount > 0 && (
|
||||
<div className={css.Pagination}>
|
||||
<Text textType={TextType.Shy} style={{ fontSize: '12px' }}>
|
||||
Showing {startRecord.toLocaleString()}-{endRecord.toLocaleString()} of {totalCount.toLocaleString()} records
|
||||
</Text>
|
||||
<div className={css.PageControls}>
|
||||
<button className={css.PageButton} onClick={() => setPage(0)} disabled={page === 0}>
|
||||
First
|
||||
</button>
|
||||
<button className={css.PageButton} onClick={() => setPage(page - 1)} disabled={page === 0}>
|
||||
Previous
|
||||
</button>
|
||||
<Text textType={TextType.Shy} style={{ fontSize: '12px', margin: '0 8px' }}>
|
||||
Page {page + 1} of {totalPages}
|
||||
</Text>
|
||||
<button className={css.PageButton} onClick={() => setPage(page + 1)} disabled={page >= totalPages - 1}>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
className={css.PageButton}
|
||||
onClick={() => setPage(totalPages - 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Record Modal */}
|
||||
{showNewRecord && schema && selectedTable && (
|
||||
<NewRecordModal
|
||||
backendId={backendId}
|
||||
tableName={selectedTable}
|
||||
columns={schema.columns}
|
||||
onClose={() => setShowNewRecord(false)}
|
||||
onSuccess={() => {
|
||||
setShowNewRecord(false);
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* DataGrid styles
|
||||
*/
|
||||
|
||||
.GridContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.Grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.Grid thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
.Grid th,
|
||||
.Grid td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.HeaderCell {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-default);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.HeaderContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.HeaderName {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.TypeBadge {
|
||||
font-size: 9px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.TypeString {
|
||||
background-color: var(--theme-color-primary);
|
||||
color: var(--theme-color-fg-on-primary);
|
||||
}
|
||||
|
||||
.TypeNumber {
|
||||
background-color: var(--theme-color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.TypeBoolean {
|
||||
background-color: var(--theme-color-notice);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.TypeDate {
|
||||
background-color: #8b5cf6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.TypeObject {
|
||||
background-color: #ec4899;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.TypeArray {
|
||||
background-color: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.TypePointer {
|
||||
background-color: var(--theme-color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.CheckboxCol {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.ActionsCol {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
.Cell {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
.CellValue {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
.EditableCell {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
.ReadOnlyCell {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.SelectedRow {
|
||||
background-color: var(--theme-color-primary-muted);
|
||||
}
|
||||
|
||||
.SelectedRow:hover {
|
||||
background-color: var(--theme-color-primary-muted);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* DataGrid
|
||||
*
|
||||
* Spreadsheet-style grid for displaying and editing records.
|
||||
* Supports inline editing, selection, and type-aware cell rendering.
|
||||
*
|
||||
* @module panels/databrowser/DataGrid
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
|
||||
import { CellEditor } from './CellEditor';
|
||||
import { ColumnDef } from './DataBrowser';
|
||||
import css from './DataGrid.module.scss';
|
||||
|
||||
export interface DataGridProps {
|
||||
/** Column definitions */
|
||||
columns: ColumnDef[];
|
||||
/** Records to display */
|
||||
records: Record<string, unknown>[];
|
||||
/** Set of selected record IDs */
|
||||
selectedRecords: Set<string>;
|
||||
/** Called when record selection changes */
|
||||
onSelectRecord: (recordId: string, selected: boolean) => void;
|
||||
/** Called when select all toggled */
|
||||
onSelectAll: (selected: boolean) => void;
|
||||
/** Called when cell value saved */
|
||||
onSaveCell: (recordId: string, field: string, value: unknown) => Promise<void>;
|
||||
/** Called when delete requested */
|
||||
onDeleteRecord: (recordId: string) => void;
|
||||
}
|
||||
|
||||
/** System fields that are read-only */
|
||||
const READ_ONLY_FIELDS = new Set(['id', 'createdAt', 'updatedAt']);
|
||||
|
||||
/**
|
||||
* Format a cell value for display
|
||||
*/
|
||||
function formatCellValue(value: unknown, type: string): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
switch (type) {
|
||||
case 'Boolean':
|
||||
return value ? '✓' : '';
|
||||
case 'Date':
|
||||
try {
|
||||
return new Date(value as string).toLocaleString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
case 'Object':
|
||||
case 'Array':
|
||||
return JSON.stringify(value);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSS class for type badge
|
||||
*/
|
||||
function getTypeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'String':
|
||||
return css.TypeString;
|
||||
case 'Number':
|
||||
return css.TypeNumber;
|
||||
case 'Boolean':
|
||||
return css.TypeBoolean;
|
||||
case 'Date':
|
||||
return css.TypeDate;
|
||||
case 'Object':
|
||||
return css.TypeObject;
|
||||
case 'Array':
|
||||
return css.TypeArray;
|
||||
case 'Pointer':
|
||||
case 'Relation':
|
||||
return css.TypePointer;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DataGrid component
|
||||
*/
|
||||
export function DataGrid({
|
||||
columns,
|
||||
records,
|
||||
selectedRecords,
|
||||
onSelectRecord,
|
||||
onSelectAll,
|
||||
onSaveCell,
|
||||
onDeleteRecord
|
||||
}: DataGridProps) {
|
||||
const [editingCell, setEditingCell] = useState<{ recordId: string; field: string } | null>(null);
|
||||
const [savingError, setSavingError] = useState<string | null>(null);
|
||||
|
||||
// Check if all records are selected
|
||||
const allSelected = records.length > 0 && selectedRecords.size === records.length;
|
||||
|
||||
// Handle cell click - start editing if editable
|
||||
const handleCellClick = useCallback((recordId: string, field: string) => {
|
||||
if (READ_ONLY_FIELDS.has(field)) return;
|
||||
setEditingCell({ recordId, field });
|
||||
setSavingError(null);
|
||||
}, []);
|
||||
|
||||
// Handle save from CellEditor
|
||||
const handleSave = useCallback(
|
||||
async (recordId: string, field: string, value: unknown) => {
|
||||
try {
|
||||
await onSaveCell(recordId, field, value);
|
||||
setEditingCell(null);
|
||||
setSavingError(null);
|
||||
} catch (err) {
|
||||
setSavingError('Failed to save');
|
||||
// Don't close editor on error
|
||||
}
|
||||
},
|
||||
[onSaveCell]
|
||||
);
|
||||
|
||||
// Handle cancel editing
|
||||
const handleCancel = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
setSavingError(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={css.GridContainer}>
|
||||
<table className={css.Grid}>
|
||||
<thead>
|
||||
<tr>
|
||||
{/* Checkbox column */}
|
||||
<th className={css.CheckboxCol}>
|
||||
<input type="checkbox" checked={allSelected} onChange={(e) => onSelectAll(e.target.checked)} />
|
||||
</th>
|
||||
{/* Data columns */}
|
||||
{columns.map((col) => (
|
||||
<th key={col.name} className={css.HeaderCell}>
|
||||
<div className={css.HeaderContent}>
|
||||
<span className={css.HeaderName}>{col.name}</span>
|
||||
<span className={`${css.TypeBadge} ${getTypeBadgeClass(col.type)}`}>{col.type}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{/* Actions column */}
|
||||
<th className={css.ActionsCol}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const recordId = record.id as string;
|
||||
const isSelected = selectedRecords.has(recordId);
|
||||
|
||||
return (
|
||||
<tr key={recordId} className={isSelected ? css.SelectedRow : ''}>
|
||||
{/* Checkbox */}
|
||||
<td className={css.CheckboxCol}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => onSelectRecord(recordId, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
{/* Data cells */}
|
||||
{columns.map((col) => {
|
||||
const value = record[col.name];
|
||||
const isEditing = editingCell?.recordId === recordId && editingCell?.field === col.name;
|
||||
const isReadOnly = READ_ONLY_FIELDS.has(col.name);
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.name}
|
||||
className={`${css.Cell} ${isReadOnly ? css.ReadOnlyCell : css.EditableCell}`}
|
||||
onClick={() => !isEditing && handleCellClick(recordId, col.name)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<CellEditor
|
||||
value={value}
|
||||
type={col.type}
|
||||
onSave={(newValue) => handleSave(recordId, col.name, newValue)}
|
||||
onCancel={handleCancel}
|
||||
error={savingError}
|
||||
/>
|
||||
) : (
|
||||
<div className={css.CellValue} title={String(value ?? '')}>
|
||||
{formatCellValue(value, col.type)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
{/* Actions */}
|
||||
<td className={css.ActionsCol}>
|
||||
<IconButton icon={IconName.Trash} size={IconSize.Small} onClick={() => onDeleteRecord(recordId)} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* NewRecordModal styles
|
||||
*/
|
||||
|
||||
.Overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.Modal {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.Header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.Content {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.Field {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.Label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.Required {
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.TypeHint {
|
||||
font-size: 10px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
background-color: var(--theme-color-bg-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.Input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.JsonInput {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.Error {
|
||||
padding: 12px;
|
||||
background-color: var(--theme-color-danger-bg);
|
||||
border-radius: 4px;
|
||||
margin-top: 16px;
|
||||
color: var(--theme-color-danger);
|
||||
}
|
||||
|
||||
.Footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* NewRecordModal
|
||||
*
|
||||
* Modal dialog for creating a new record with type-aware form fields.
|
||||
*
|
||||
* @module panels/databrowser/NewRecordModal
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import { ColumnDef } from './DataBrowser';
|
||||
import css from './NewRecordModal.module.scss';
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
export interface NewRecordModalProps {
|
||||
/** Backend ID */
|
||||
backendId: string;
|
||||
/** Table name */
|
||||
tableName: string;
|
||||
/** Column definitions */
|
||||
columns: ColumnDef[];
|
||||
/** Called when modal closed */
|
||||
onClose: () => void;
|
||||
/** Called when record created */
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default value for a column type
|
||||
*/
|
||||
function getDefaultValue(type: string): unknown {
|
||||
switch (type) {
|
||||
case 'Boolean':
|
||||
return false;
|
||||
case 'Number':
|
||||
return 0;
|
||||
case 'Object':
|
||||
return {};
|
||||
case 'Array':
|
||||
return [];
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NewRecordModal component
|
||||
*/
|
||||
export function NewRecordModal({ backendId, tableName, columns, onClose, onSuccess }: NewRecordModalProps) {
|
||||
// Initialize form state with default values
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(() => {
|
||||
const initial: Record<string, unknown> = {};
|
||||
columns.forEach((col) => {
|
||||
initial[col.name] = col.default !== undefined ? col.default : getDefaultValue(col.type);
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Handle field change
|
||||
const handleChange = useCallback((field: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Handle form submit
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
for (const col of columns) {
|
||||
if (col.required) {
|
||||
const value = formData[col.name];
|
||||
if (value === null || value === undefined || value === '') {
|
||||
setError(`${col.name} is required`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke('backend:createRecord', backendId, tableName, formData);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to create record:', err);
|
||||
setError('Failed to create record');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[backendId, tableName, formData, columns, onSuccess]
|
||||
);
|
||||
|
||||
// Render form field based on type
|
||||
const renderField = (col: ColumnDef) => {
|
||||
const value = formData[col.name];
|
||||
|
||||
switch (col.type) {
|
||||
case 'Boolean':
|
||||
return (
|
||||
<input type="checkbox" checked={value === true} onChange={(e) => handleChange(col.name, e.target.checked)} />
|
||||
);
|
||||
|
||||
case 'Number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className={css.Input}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => handleChange(col.name, e.target.value ? parseFloat(e.target.value) : null)}
|
||||
step="any"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'Date':
|
||||
return (
|
||||
<input
|
||||
type="datetime-local"
|
||||
className={css.Input}
|
||||
value={value ? new Date(value as string).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleChange(col.name, e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'Object':
|
||||
case 'Array':
|
||||
return (
|
||||
<textarea
|
||||
className={css.JsonInput}
|
||||
value={typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value ?? '')}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
handleChange(col.name, JSON.parse(e.target.value));
|
||||
} catch {
|
||||
// Keep as string while editing, parse on blur
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder={col.type === 'Array' ? '[]' : '{}'}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
// String
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className={css.Input}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => handleChange(col.name, e.target.value)}
|
||||
placeholder={`Enter ${col.name}...`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css.Overlay} onClick={onClose}>
|
||||
<div className={css.Modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={css.Header}>
|
||||
<Text textType={TextType.DefaultContrast}>New Record in {tableName}</Text>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={css.Content}>
|
||||
{columns.length === 0 ? (
|
||||
<Text textType={TextType.Shy}>No columns defined for this table</Text>
|
||||
) : (
|
||||
columns.map((col) => (
|
||||
<div key={col.name} className={css.Field}>
|
||||
<label className={css.Label}>
|
||||
{col.name}
|
||||
{col.required && <span className={css.Required}>*</span>}
|
||||
<span className={css.TypeHint}>{col.type}</span>
|
||||
</label>
|
||||
{renderField(col)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={css.Error}>
|
||||
<Text textType={TextType.Default}>{error}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={css.Footer}>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={saving ? 'Creating...' : 'Create Record'}
|
||||
size={PrimaryButtonSize.Small}
|
||||
isDisabled={saving || columns.length === 0}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Data Browser Panel
|
||||
*
|
||||
* Export all data browser components.
|
||||
*
|
||||
* @module panels/databrowser
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
export { DataBrowser, type DataBrowserProps, type ColumnDef, type TableSchema } from './DataBrowser';
|
||||
export { DataGrid, type DataGridProps } from './DataGrid';
|
||||
export { CellEditor, type CellEditorProps } from './CellEditor';
|
||||
export { NewRecordModal, type NewRecordModalProps } from './NewRecordModal';
|
||||
@@ -5,11 +5,9 @@ import { isExpressionParameter, createExpressionParameter } from '@noodl-models/
|
||||
import { NodeLibrary } from '@noodl-models/nodelibrary';
|
||||
import { ParameterValueResolver } from '@noodl-utils/ParameterValueResolver';
|
||||
|
||||
import {
|
||||
PropertyPanelInput,
|
||||
PropertyPanelInputType
|
||||
} from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
|
||||
import { PropertyPanelInputType } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
|
||||
|
||||
import { PropertyPanelInputWithExpressionModal } from '../components/PropertyPanelInputWithExpressionModal';
|
||||
import { TypeView } from '../TypeView';
|
||||
import { getEditType } from '../utils';
|
||||
|
||||
@@ -154,10 +152,12 @@ export class BasicType extends TypeView {
|
||||
}
|
||||
|
||||
this.isDefault = false;
|
||||
// Re-render to update UI and sync modal with inline input
|
||||
setTimeout(() => this.renderReact(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
this.root.render(React.createElement(PropertyPanelInput, props));
|
||||
this.root.render(React.createElement(PropertyPanelInputWithExpressionModal, props));
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
.Overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.Modal {
|
||||
background-color: var(--theme-color-bg-2, #1a1a1a);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 700px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default, rgba(255, 255, 255, 0.1));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.Title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-fg-highlight, #ffffff);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Body {
|
||||
padding: 16px 20px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.HelpText {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--theme-color-bg-3, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default-shy, rgba(255, 255, 255, 0.6));
|
||||
|
||||
code {
|
||||
background-color: var(--theme-color-bg-1, rgba(99, 102, 241, 0.2));
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-primary, #6366f1);
|
||||
}
|
||||
}
|
||||
|
||||
.EditorWrapper {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--theme-color-border-default, rgba(255, 255, 255, 0.1));
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* ExpressionEditorModal
|
||||
*
|
||||
* A modal dialog for editing expressions in a full-featured code editor.
|
||||
* Uses the new CodeMirror-based JavaScriptEditor component.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { JavaScriptEditor } from '@noodl-core-ui/components/code-editor';
|
||||
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import css from './ExpressionEditorModal.module.scss';
|
||||
|
||||
export interface ExpressionEditorModalProps {
|
||||
/** Whether the modal is open */
|
||||
isOpen: boolean;
|
||||
/** The property name being edited */
|
||||
propertyName: string;
|
||||
/** The initial expression value */
|
||||
expression: string;
|
||||
/** Called when expression is applied */
|
||||
onApply: (expression: string) => void;
|
||||
/** Called when modal is closed/cancelled */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for editing expressions in a larger code editor
|
||||
*/
|
||||
export function ExpressionEditorModal({
|
||||
isOpen,
|
||||
propertyName,
|
||||
expression,
|
||||
onApply,
|
||||
onClose
|
||||
}: ExpressionEditorModalProps) {
|
||||
const [localExpression, setLocalExpression] = useState(expression);
|
||||
|
||||
// Reset local expression when modal opens with new value
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalExpression(expression);
|
||||
}
|
||||
}, [isOpen, expression]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[isOpen, onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
onApply(localExpression);
|
||||
onClose();
|
||||
}, [localExpression, onApply, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Handle Ctrl+Enter to apply
|
||||
const handleSave = useCallback(
|
||||
(code: string) => {
|
||||
onApply(code);
|
||||
onClose();
|
||||
},
|
||||
[onApply, onClose]
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Render into portal to escape any z-index issues
|
||||
return ReactDOM.createPortal(
|
||||
<div className={css['Overlay']} onClick={handleCancel}>
|
||||
<div className={css['Modal']} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className={css['Header']}>
|
||||
<span className={css['Title']}>Edit Expression: {propertyName}</span>
|
||||
</div>
|
||||
|
||||
{/* Body with editor */}
|
||||
<div className={css['Body']}>
|
||||
<div className={css['HelpText']}>
|
||||
<Text>
|
||||
Available: <code>Noodl.Variables.x</code>, <code>Noodl.Objects.id.prop</code>,{' '}
|
||||
<code>Noodl.Arrays.id</code>, and Math functions like <code>min()</code>, <code>max()</code>,{' '}
|
||||
<code>round()</code>
|
||||
</Text>
|
||||
</div>
|
||||
<div className={css['EditorWrapper']}>
|
||||
<JavaScriptEditor
|
||||
value={localExpression}
|
||||
onChange={setLocalExpression}
|
||||
onSave={handleSave}
|
||||
validationType="expression"
|
||||
height={300}
|
||||
width="100%"
|
||||
placeholder="// Enter your expression here, e.g. Noodl.Variables.count * 2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with buttons */}
|
||||
<div className={css['Footer']}>
|
||||
<PrimaryButton label="Cancel" variant={PrimaryButtonVariant.Ghost} onClick={handleCancel} />
|
||||
<PrimaryButton label="Apply" variant={PrimaryButtonVariant.Cta} onClick={handleApply} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ExpressionEditorModal } from './ExpressionEditorModal';
|
||||
export type { ExpressionEditorModalProps } from './ExpressionEditorModal';
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* PropertyPanelInputWithExpressionModal
|
||||
*
|
||||
* Wraps PropertyPanelInput with ExpressionEditorModal state management.
|
||||
* Used by BasicType.ts to provide expression modal support.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
import {
|
||||
PropertyPanelInput,
|
||||
PropertyPanelInputProps
|
||||
} from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
|
||||
|
||||
import { ExpressionEditorModal } from '../ExpressionEditorModal';
|
||||
|
||||
export interface PropertyPanelInputWithExpressionModalProps extends PropertyPanelInputProps {
|
||||
/** Property name for the modal title */
|
||||
propertyName?: string;
|
||||
}
|
||||
|
||||
export function PropertyPanelInputWithExpressionModal({
|
||||
propertyName,
|
||||
label,
|
||||
expression = '',
|
||||
expressionMode,
|
||||
onExpressionChange,
|
||||
...props
|
||||
}: PropertyPanelInputWithExpressionModalProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
setIsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleModalApply = useCallback(
|
||||
(newExpression: string) => {
|
||||
if (onExpressionChange) {
|
||||
onExpressionChange(newExpression);
|
||||
}
|
||||
},
|
||||
[onExpressionChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PropertyPanelInput
|
||||
label={label}
|
||||
expression={expression}
|
||||
expressionMode={expressionMode}
|
||||
onExpressionChange={onExpressionChange}
|
||||
onExpressionExpand={handleExpand}
|
||||
{...props}
|
||||
/>
|
||||
<ExpressionEditorModal
|
||||
isOpen={isModalOpen}
|
||||
propertyName={propertyName || label}
|
||||
expression={expression}
|
||||
onApply={handleModalApply}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* AddColumnForm styles
|
||||
*/
|
||||
|
||||
.Root {
|
||||
padding: 8px 12px;
|
||||
background: var(--theme-color-bg-2);
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.Row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1.2fr 0.8fr 1.5fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.FieldSmall {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.Actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.Input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 3px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.Select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 3px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.Error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 0 0;
|
||||
color: var(--theme-color-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Inline column rename styles
|
||||
.RenameWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.RenameInput {
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
background: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-primary);
|
||||
border-radius: 2px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.RenameError {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
padding: 4px 6px;
|
||||
background: var(--theme-color-danger);
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 10px;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* AddColumnForm
|
||||
*
|
||||
* Inline form for adding a new column to an existing table.
|
||||
* Also handles column renaming via inline edit.
|
||||
*
|
||||
* @module schemamanager/AddColumnForm
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import css from './AddColumnForm.module.scss';
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
/** Supported column types */
|
||||
const COLUMN_TYPES = ['String', 'Number', 'Boolean', 'Date', 'Object', 'Array'] as const;
|
||||
type ColumnType = (typeof COLUMN_TYPES)[number];
|
||||
|
||||
export interface AddColumnFormProps {
|
||||
/** Backend ID */
|
||||
backendId: string;
|
||||
/** Table name */
|
||||
tableName: string;
|
||||
/** Existing column names (for validation) */
|
||||
existingColumns: string[];
|
||||
/** Called when column is added */
|
||||
onSuccess: () => void;
|
||||
/** Called when form is cancelled */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate column name
|
||||
*/
|
||||
function validateColumnName(name: string, existingNames: string[]): string | null {
|
||||
if (!name.trim()) {
|
||||
return 'Column name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return 'Must start with letter, alphanumeric and underscore only';
|
||||
}
|
||||
if (['objectId', 'createdAt', 'updatedAt', 'ACL'].includes(name)) {
|
||||
return 'Reserved column name';
|
||||
}
|
||||
if (existingNames.includes(name)) {
|
||||
return 'Column already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AddColumnForm component - inline form for adding columns
|
||||
*/
|
||||
export function AddColumnForm({ backendId, tableName, existingColumns, onSuccess, onCancel }: AddColumnFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [type, setType] = useState<ColumnType>('String');
|
||||
const [required, setRequired] = useState(false);
|
||||
const [defaultValue, setDefaultValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setError(null);
|
||||
|
||||
const nameError = validateColumnName(name, existingColumns);
|
||||
if (nameError) {
|
||||
setError(nameError);
|
||||
return;
|
||||
}
|
||||
|
||||
const column = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
required,
|
||||
defaultValue: defaultValue.trim() || undefined
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke('backend:addColumn', backendId, tableName, column);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to add column';
|
||||
setError(message);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [backendId, tableName, name, type, required, defaultValue, existingColumns, onSuccess]);
|
||||
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<div className={css.Row}>
|
||||
<div className={css.Field}>
|
||||
<input
|
||||
type="text"
|
||||
className={css.Input}
|
||||
placeholder="column_name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className={css.Field}>
|
||||
<select className={css.Select} value={type} onChange={(e) => setType(e.target.value as ColumnType)}>
|
||||
{COLUMN_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={css.FieldSmall}>
|
||||
<input type="checkbox" checked={required} onChange={(e) => setRequired(e.target.checked)} />
|
||||
</div>
|
||||
<div className={css.Field}>
|
||||
<input
|
||||
type="text"
|
||||
className={css.Input}
|
||||
placeholder="default"
|
||||
value={defaultValue}
|
||||
onChange={(e) => setDefaultValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.Actions}>
|
||||
<PrimaryButton
|
||||
label={saving ? '...' : 'Add'}
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Cta}
|
||||
onClick={handleSubmit}
|
||||
isDisabled={saving || !name.trim()}
|
||||
/>
|
||||
<IconButton icon={IconName.Close} onClick={onCancel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={css.Error}>
|
||||
<Icon icon={IconName.WarningTriangle} size={IconSize.Tiny} />
|
||||
<Text textType={TextType.Default}>{error}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Props for inline column rename */
|
||||
export interface ColumnRenameProps {
|
||||
/** Backend ID */
|
||||
backendId: string;
|
||||
/** Table name */
|
||||
tableName: string;
|
||||
/** Current column name */
|
||||
columnName: string;
|
||||
/** Existing column names (for validation) */
|
||||
existingColumns: string[];
|
||||
/** Called when rename succeeds */
|
||||
onSuccess: () => void;
|
||||
/** Called when cancelled */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ColumnRenameInput - inline input for renaming a column
|
||||
*/
|
||||
export function ColumnRenameInput({
|
||||
backendId,
|
||||
tableName,
|
||||
columnName,
|
||||
existingColumns,
|
||||
onSuccess,
|
||||
onCancel
|
||||
}: ColumnRenameProps) {
|
||||
const [newName, setNewName] = useState(columnName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setError(null);
|
||||
|
||||
// No change
|
||||
if (newName.trim() === columnName) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate (exclude current name from existing list)
|
||||
const otherColumns = existingColumns.filter((c) => c !== columnName);
|
||||
const nameError = validateColumnName(newName, otherColumns);
|
||||
if (nameError) {
|
||||
setError(nameError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke('backend:renameColumn', backendId, tableName, columnName, newName.trim());
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to rename column';
|
||||
setError(message);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [backendId, tableName, columnName, newName, existingColumns, onSuccess, onCancel]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[handleSubmit, onCancel]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={css.RenameWrapper}>
|
||||
<input
|
||||
type="text"
|
||||
className={css.RenameInput}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSubmit}
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
/>
|
||||
{error && <span className={css.RenameError}>{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* CreateTableModal styles
|
||||
*/
|
||||
|
||||
.Overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.Modal {
|
||||
background: var(--theme-color-bg-2);
|
||||
border-radius: 8px;
|
||||
width: 640px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.Content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.Section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.Input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
margin-top: 8px;
|
||||
background: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 13px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ColumnsTable {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ColumnRow {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1.2fr 0.8fr 1.5fr 40px;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--theme-color-bg-3);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
align-items: center;
|
||||
|
||||
&:first-child {
|
||||
background: var(--theme-color-bg-2);
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&[data-system='true'] {
|
||||
background: var(--theme-color-bg-2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.ColName,
|
||||
.ColType,
|
||||
.ColRequired,
|
||||
.ColDefault,
|
||||
.ColActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ColRequired {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ColActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ColumnInput {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--theme-color-bg-2);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 3px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.TypeSelect {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--theme-color-bg-2);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 3px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.Error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--theme-color-danger-transparent);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-danger);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.Footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* CreateTableModal
|
||||
*
|
||||
* Modal for creating a new database table with columns.
|
||||
*
|
||||
* @module schemamanager/CreateTableModal
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { HStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import css from './CreateTableModal.module.scss';
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
/** Supported column types */
|
||||
const COLUMN_TYPES = ['String', 'Number', 'Boolean', 'Date', 'Object', 'Array'] as const;
|
||||
type ColumnType = (typeof COLUMN_TYPES)[number];
|
||||
|
||||
/** SQLite reserved words to prevent as table names */
|
||||
const SQLITE_RESERVED = [
|
||||
'ABORT',
|
||||
'ACTION',
|
||||
'ADD',
|
||||
'ALL',
|
||||
'ALTER',
|
||||
'AND',
|
||||
'AS',
|
||||
'ASC',
|
||||
'AUTOINCREMENT',
|
||||
'BETWEEN',
|
||||
'BY',
|
||||
'CASCADE',
|
||||
'CASE',
|
||||
'CHECK',
|
||||
'COLLATE',
|
||||
'COLUMN',
|
||||
'COMMIT',
|
||||
'CONFLICT',
|
||||
'CONSTRAINT',
|
||||
'CREATE',
|
||||
'CROSS',
|
||||
'DATABASE',
|
||||
'DEFAULT',
|
||||
'DELETE',
|
||||
'DESC',
|
||||
'DISTINCT',
|
||||
'DROP',
|
||||
'ELSE',
|
||||
'END',
|
||||
'ESCAPE',
|
||||
'EXCEPT',
|
||||
'EXISTS',
|
||||
'FOREIGN',
|
||||
'FROM',
|
||||
'GROUP',
|
||||
'HAVING',
|
||||
'IN',
|
||||
'INDEX',
|
||||
'INNER',
|
||||
'INSERT',
|
||||
'INTERSECT',
|
||||
'INTO',
|
||||
'IS',
|
||||
'ISNULL',
|
||||
'JOIN',
|
||||
'KEY',
|
||||
'LEFT',
|
||||
'LIKE',
|
||||
'LIMIT',
|
||||
'NATURAL',
|
||||
'NOT',
|
||||
'NOTNULL',
|
||||
'NULL',
|
||||
'ON',
|
||||
'OR',
|
||||
'ORDER',
|
||||
'OUTER',
|
||||
'PRIMARY',
|
||||
'REFERENCES',
|
||||
'REPLACE',
|
||||
'RIGHT',
|
||||
'ROLLBACK',
|
||||
'SELECT',
|
||||
'SET',
|
||||
'TABLE',
|
||||
'THEN',
|
||||
'TO',
|
||||
'TRANSACTION',
|
||||
'UNION',
|
||||
'UNIQUE',
|
||||
'UPDATE',
|
||||
'USING',
|
||||
'VALUES',
|
||||
'WHEN',
|
||||
'WHERE'
|
||||
];
|
||||
|
||||
/** Column definition in the form */
|
||||
interface ColumnDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ColumnType;
|
||||
required: boolean;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
export interface CreateTableModalProps {
|
||||
/** Backend ID */
|
||||
backendId: string;
|
||||
/** Callback when modal should close */
|
||||
onClose: () => void;
|
||||
/** Callback when table is created */
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate table name
|
||||
*/
|
||||
function validateTableName(name: string): string | null {
|
||||
if (!name.trim()) {
|
||||
return 'Table name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return 'Must start with letter, only alphanumeric and underscore';
|
||||
}
|
||||
if (SQLITE_RESERVED.includes(name.toUpperCase())) {
|
||||
return `"${name}" is a reserved word`;
|
||||
}
|
||||
if (name.length > 64) {
|
||||
return 'Name too long (max 64 characters)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate column name
|
||||
*/
|
||||
function validateColumnName(name: string, existingNames: string[]): string | null {
|
||||
if (!name.trim()) {
|
||||
return 'Column name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return 'Invalid column name';
|
||||
}
|
||||
if (['objectId', 'createdAt', 'updatedAt', 'ACL'].includes(name)) {
|
||||
return 'Reserved column name';
|
||||
}
|
||||
if (existingNames.includes(name)) {
|
||||
return 'Duplicate column name';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID
|
||||
*/
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateTableModal component
|
||||
*/
|
||||
export function CreateTableModal({ backendId, onClose, onSuccess }: CreateTableModalProps) {
|
||||
// State
|
||||
const [tableName, setTableName] = useState('');
|
||||
const [columns, setColumns] = useState<ColumnDef[]>([
|
||||
{ id: generateId(), name: '', type: 'String', required: false, defaultValue: '' }
|
||||
]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Add a new column
|
||||
const handleAddColumn = useCallback(() => {
|
||||
setColumns((prev) => [...prev, { id: generateId(), name: '', type: 'String', required: false, defaultValue: '' }]);
|
||||
}, []);
|
||||
|
||||
// Remove a column
|
||||
const handleRemoveColumn = useCallback((id: string) => {
|
||||
setColumns((prev) => prev.filter((c) => c.id !== id));
|
||||
}, []);
|
||||
|
||||
// Update a column field
|
||||
const handleUpdateColumn = useCallback((id: string, field: keyof ColumnDef, value: string | boolean) => {
|
||||
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
|
||||
}, []);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setError(null);
|
||||
|
||||
// Validate table name
|
||||
const tableNameError = validateTableName(tableName);
|
||||
if (tableNameError) {
|
||||
setError(tableNameError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out empty columns and validate remaining
|
||||
const validColumns = columns.filter((c) => c.name.trim());
|
||||
const columnNames: string[] = [];
|
||||
|
||||
for (const col of validColumns) {
|
||||
const colError = validateColumnName(col.name, columnNames);
|
||||
if (colError) {
|
||||
setError(`Column "${col.name}": ${colError}`);
|
||||
return;
|
||||
}
|
||||
columnNames.push(col.name);
|
||||
}
|
||||
|
||||
// Build schema
|
||||
const tableSchema = {
|
||||
name: tableName.trim(),
|
||||
columns: validColumns.map((col) => ({
|
||||
name: col.name.trim(),
|
||||
type: col.type,
|
||||
required: col.required,
|
||||
defaultValue: col.defaultValue.trim() || undefined
|
||||
}))
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
await ipcRenderer.invoke('backend:createTable', backendId, tableSchema);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create table';
|
||||
setError(message);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [backendId, tableName, columns, onSuccess]);
|
||||
|
||||
// Handle Enter key
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && e.metaKey) {
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={css.Overlay} onClick={onClose}>
|
||||
<div className={css.Modal} onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown}>
|
||||
{/* Header */}
|
||||
<div className={css.Header}>
|
||||
<Text textType={TextType.Proud}>Create New Table</Text>
|
||||
<IconButton icon={IconName.Close} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={css.Content}>
|
||||
{/* Table Name */}
|
||||
<div className={css.Section}>
|
||||
<Text textType={TextType.DefaultContrast}>Table Name</Text>
|
||||
<input
|
||||
type="text"
|
||||
className={css.Input}
|
||||
placeholder="e.g., Products, Users, Orders"
|
||||
value={tableName}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Columns */}
|
||||
<div className={css.Section}>
|
||||
<HStack UNSAFE_style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text textType={TextType.DefaultContrast}>Fields</Text>
|
||||
<PrimaryButton
|
||||
label="+ Add Field"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Ghost}
|
||||
onClick={handleAddColumn}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<div className={css.ColumnsTable}>
|
||||
{/* Header */}
|
||||
<div className={css.ColumnRow}>
|
||||
<div className={css.ColName}>Name</div>
|
||||
<div className={css.ColType}>Type</div>
|
||||
<div className={css.ColRequired}>Required</div>
|
||||
<div className={css.ColDefault}>Default</div>
|
||||
<div className={css.ColActions}></div>
|
||||
</div>
|
||||
|
||||
{/* System columns (info only) */}
|
||||
<div className={css.ColumnRow} data-system="true">
|
||||
<div className={css.ColName}>
|
||||
<Text textType={TextType.Shy}>objectId</Text>
|
||||
</div>
|
||||
<div className={css.ColType}>
|
||||
<Text textType={TextType.Shy}>String</Text>
|
||||
</div>
|
||||
<div className={css.ColRequired}>
|
||||
<Text textType={TextType.Shy}>✓</Text>
|
||||
</div>
|
||||
<div className={css.ColDefault}>
|
||||
<Text textType={TextType.Shy}>auto</Text>
|
||||
</div>
|
||||
<div className={css.ColActions}></div>
|
||||
</div>
|
||||
<div className={css.ColumnRow} data-system="true">
|
||||
<div className={css.ColName}>
|
||||
<Text textType={TextType.Shy}>createdAt</Text>
|
||||
</div>
|
||||
<div className={css.ColType}>
|
||||
<Text textType={TextType.Shy}>Date</Text>
|
||||
</div>
|
||||
<div className={css.ColRequired}>
|
||||
<Text textType={TextType.Shy}>✓</Text>
|
||||
</div>
|
||||
<div className={css.ColDefault}>
|
||||
<Text textType={TextType.Shy}>auto</Text>
|
||||
</div>
|
||||
<div className={css.ColActions}></div>
|
||||
</div>
|
||||
<div className={css.ColumnRow} data-system="true">
|
||||
<div className={css.ColName}>
|
||||
<Text textType={TextType.Shy}>updatedAt</Text>
|
||||
</div>
|
||||
<div className={css.ColType}>
|
||||
<Text textType={TextType.Shy}>Date</Text>
|
||||
</div>
|
||||
<div className={css.ColRequired}>
|
||||
<Text textType={TextType.Shy}>✓</Text>
|
||||
</div>
|
||||
<div className={css.ColDefault}>
|
||||
<Text textType={TextType.Shy}>auto</Text>
|
||||
</div>
|
||||
<div className={css.ColActions}></div>
|
||||
</div>
|
||||
|
||||
{/* User columns */}
|
||||
{columns.map((col) => (
|
||||
<div key={col.id} className={css.ColumnRow}>
|
||||
<div className={css.ColName}>
|
||||
<input
|
||||
type="text"
|
||||
className={css.ColumnInput}
|
||||
placeholder="field_name"
|
||||
value={col.name}
|
||||
onChange={(e) => handleUpdateColumn(col.id, 'name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.ColType}>
|
||||
<select
|
||||
className={css.TypeSelect}
|
||||
value={col.type}
|
||||
onChange={(e) => handleUpdateColumn(col.id, 'type', e.target.value)}
|
||||
>
|
||||
{COLUMN_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={css.ColRequired}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.required}
|
||||
onChange={(e) => handleUpdateColumn(col.id, 'required', e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.ColDefault}>
|
||||
<input
|
||||
type="text"
|
||||
className={css.ColumnInput}
|
||||
placeholder={col.type === 'Boolean' ? 'true/false' : ''}
|
||||
value={col.defaultValue}
|
||||
onChange={(e) => handleUpdateColumn(col.id, 'defaultValue', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.ColActions}>
|
||||
<IconButton
|
||||
icon={IconName.Trash}
|
||||
onClick={() => handleRemoveColumn(col.id)}
|
||||
UNSAFE_style={{ opacity: columns.length === 1 ? 0.3 : 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className={css.Error}>
|
||||
<Icon icon={IconName.WarningTriangle} size={IconSize.Tiny} />
|
||||
<Text textType={TextType.Default}>{error}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={css.Footer}>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={saving ? 'Creating...' : 'Create Table'}
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Cta}
|
||||
onClick={handleSubmit}
|
||||
isDisabled={saving || !tableName.trim()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* SchemaPanel styles
|
||||
* Uses theme tokens from UI-STYLING-GUIDE.md
|
||||
*/
|
||||
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
|
||||
.TableList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.Loading,
|
||||
.Error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.EmptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* SchemaPanel
|
||||
*
|
||||
* Main panel for viewing and managing database schemas in local backends.
|
||||
* Shows a list of tables with columns, record counts, and management options.
|
||||
*
|
||||
* @module schemamanager/SchemaPanel
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import { CreateTableModal } from './CreateTableModal';
|
||||
import css from './SchemaPanel.module.scss';
|
||||
import { TableRow, TableInfo } from './TableRow';
|
||||
|
||||
export interface SchemaPanelProps {
|
||||
/** Backend ID */
|
||||
backendId: string;
|
||||
/** Backend name for display */
|
||||
backendName: string;
|
||||
/** Whether backend is running */
|
||||
isRunning: boolean;
|
||||
/** Called when panel should close */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SchemaData {
|
||||
tables: TableInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke IPC handler with error handling
|
||||
*/
|
||||
async function invokeIPC<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { ipcRenderer } = (window as any).require('electron');
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* SchemaPanel - View and manage database schemas
|
||||
*/
|
||||
export function SchemaPanel({ backendId, backendName, isRunning, onClose }: SchemaPanelProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [schema, setSchema] = useState<SchemaData | null>(null);
|
||||
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
||||
const [recordCounts, setRecordCounts] = useState<Record<string, number>>({});
|
||||
const [showCreateTable, setShowCreateTable] = useState(false);
|
||||
|
||||
// Load schema from backend
|
||||
const loadSchema = useCallback(async () => {
|
||||
if (!isRunning) {
|
||||
setError('Backend must be running to view schema');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const schemaData = await invokeIPC<SchemaData>('backend:getSchema', backendId);
|
||||
setSchema(schemaData);
|
||||
|
||||
// Load record counts asynchronously
|
||||
loadRecordCounts(schemaData.tables);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load schema';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [backendId, isRunning]);
|
||||
|
||||
// Load record counts for all tables
|
||||
const loadRecordCounts = useCallback(
|
||||
async (tables: TableInfo[]) => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const count = await invokeIPC<number>('backend:getRecordCount', backendId, table.name);
|
||||
counts[table.name] = count;
|
||||
} catch {
|
||||
counts[table.name] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
setRecordCounts(counts);
|
||||
},
|
||||
[backendId]
|
||||
);
|
||||
|
||||
// Load schema on mount and when backend changes
|
||||
useEffect(() => {
|
||||
loadSchema();
|
||||
}, [loadSchema]);
|
||||
|
||||
// Handle table expand/collapse
|
||||
const handleToggleExpand = useCallback((tableName: string) => {
|
||||
setExpandedTable((prev) => (prev === tableName ? null : tableName));
|
||||
}, []);
|
||||
|
||||
// Handle edit table - expands table to show columns
|
||||
const handleEditTable = useCallback((tableName: string) => {
|
||||
// Expand the table to show columns - full editing (add/remove columns) will be added in a future task
|
||||
setExpandedTable((prev) => (prev === tableName ? tableName : tableName));
|
||||
}, []);
|
||||
|
||||
// Render loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<div className={css.Header}>
|
||||
<Text textType={TextType.Proud}>Schema: {backendName}</Text>
|
||||
<PrimaryButton
|
||||
label="Close"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.Loading}>
|
||||
<Text textType={TextType.Shy}>Loading schema...</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<div className={css.Header}>
|
||||
<Text textType={TextType.Proud}>Schema: {backendName}</Text>
|
||||
<PrimaryButton
|
||||
label="Close"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.Error}>
|
||||
<Text textType={TextType.Shy}>{error}</Text>
|
||||
<PrimaryButton
|
||||
label="Retry"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={loadSchema}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tables = schema?.tables || [];
|
||||
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
{/* Header */}
|
||||
<div className={css.Header}>
|
||||
<VStack>
|
||||
<Text textType={TextType.Proud}>Schema: {backendName}</Text>
|
||||
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
|
||||
{tables.length} {tables.length === 1 ? 'table' : 'tables'}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack hasSpacing>
|
||||
<PrimaryButton
|
||||
label="+ New Table"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Cta}
|
||||
onClick={() => setShowCreateTable(true)}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Refresh"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={loadSchema}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Close"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{/* Table List */}
|
||||
<div className={css.TableList}>
|
||||
{tables.length === 0 ? (
|
||||
<div className={css.EmptyState}>
|
||||
<Text textType={TextType.DefaultContrast}>No tables yet</Text>
|
||||
<Text textType={TextType.Shy} style={{ marginTop: '8px' }}>
|
||||
Create your first table to start storing data.
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
label="Create First Table"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Cta}
|
||||
onClick={() => setShowCreateTable(true)}
|
||||
UNSAFE_style={{ marginTop: '16px' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
tables.map((table) => (
|
||||
<TableRow
|
||||
key={table.name}
|
||||
table={table}
|
||||
recordCount={recordCounts[table.name]}
|
||||
expanded={expandedTable === table.name}
|
||||
onToggleExpand={() => handleToggleExpand(table.name)}
|
||||
onEdit={() => handleEditTable(table.name)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Table Modal */}
|
||||
{showCreateTable && (
|
||||
<CreateTableModal
|
||||
backendId={backendId}
|
||||
onClose={() => setShowCreateTable(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateTable(false);
|
||||
loadSchema();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* TableRow styles
|
||||
* Uses theme tokens from UI-STYLING-GUIDE.md
|
||||
*/
|
||||
|
||||
.Root {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
}
|
||||
|
||||
&[data-expanded='true'] {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
}
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ExpandIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.TableIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: var(--theme-color-primary);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.TableName {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.Stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Columns {
|
||||
padding: 0 12px 12px 12px;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.ColumnTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
.SystemColumn {
|
||||
td {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.TypeBadge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.TargetClass {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 10px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.NoColumns {
|
||||
text-align: center;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-style: italic;
|
||||
padding: 16px !important;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* TableRow
|
||||
*
|
||||
* Expandable row showing table name, column count, and record count.
|
||||
* When expanded, shows all columns with their types.
|
||||
* Supports adding columns, renaming columns, and deleting tables.
|
||||
*
|
||||
* @module schemamanager/TableRow
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { HStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import css from './TableRow.module.scss';
|
||||
|
||||
/** Column definition from schema */
|
||||
export interface ColumnDefinition {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
targetClass?: string;
|
||||
}
|
||||
|
||||
/** Table info from schema API */
|
||||
export interface TableInfo {
|
||||
name: string;
|
||||
columns: ColumnDefinition[];
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface TableRowProps {
|
||||
/** Table information */
|
||||
table: TableInfo;
|
||||
/** Record count (undefined while loading) */
|
||||
recordCount?: number;
|
||||
/** Whether row is expanded */
|
||||
expanded: boolean;
|
||||
/** Called when expand/collapse is toggled */
|
||||
onToggleExpand: () => void;
|
||||
/** Called when edit is requested */
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
/** Color mapping for data types */
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
String: 'var(--theme-color-primary)',
|
||||
Number: 'var(--theme-color-success)',
|
||||
Boolean: 'var(--theme-color-notice)',
|
||||
Date: '#8b5cf6',
|
||||
Object: '#ec4899',
|
||||
Array: '#6366f1',
|
||||
Pointer: 'var(--theme-color-danger)',
|
||||
Relation: 'var(--theme-color-danger)',
|
||||
GeoPoint: '#14b8a6',
|
||||
File: '#f97316'
|
||||
};
|
||||
|
||||
/**
|
||||
* TypeBadge - Small colored badge showing column type
|
||||
*/
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const color = TYPE_COLORS[type] || 'var(--theme-color-fg-default-shy)';
|
||||
|
||||
return (
|
||||
<span className={css.TypeBadge} style={{ backgroundColor: color }}>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TableRow - Expandable table display row
|
||||
*/
|
||||
export function TableRow({ table, recordCount, expanded, onToggleExpand, onEdit }: TableRowProps) {
|
||||
const columnCount = table.columns?.length || 0;
|
||||
|
||||
return (
|
||||
<div className={css.Root} data-expanded={expanded}>
|
||||
{/* Header row - always visible */}
|
||||
<div className={css.Header} onClick={onToggleExpand}>
|
||||
<HStack hasSpacing>
|
||||
<div className={css.ExpandIcon}>
|
||||
<Icon
|
||||
icon={expanded ? IconName.CaretDown : IconName.CaretRight}
|
||||
size={IconSize.Tiny}
|
||||
UNSAFE_style={{ color: 'var(--theme-color-fg-default-shy)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.TableIcon}>
|
||||
<Text textType={TextType.Proud}>T</Text>
|
||||
</div>
|
||||
<div className={css.TableName}>
|
||||
<Text textType={TextType.DefaultContrast}>{table.name}</Text>
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
<HStack hasSpacing>
|
||||
<div className={css.Stats}>
|
||||
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
|
||||
{columnCount} {columnCount === 1 ? 'field' : 'fields'}
|
||||
</Text>
|
||||
{recordCount !== undefined && (
|
||||
<Text textType={TextType.Shy} style={{ fontSize: '11px', marginLeft: '8px' }}>
|
||||
• {recordCount.toLocaleString()} {recordCount === 1 ? 'record' : 'records'}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<PrimaryButton
|
||||
label="Edit"
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Ghost}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{/* Expanded content - columns list */}
|
||||
{expanded && (
|
||||
<div className={css.Columns}>
|
||||
<table className={css.ColumnTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field Name</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Default</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* System columns - always present */}
|
||||
<tr className={css.SystemColumn}>
|
||||
<td>id</td>
|
||||
<td>
|
||||
<TypeBadge type="String" />
|
||||
</td>
|
||||
<td>✓</td>
|
||||
<td>UUID (auto)</td>
|
||||
</tr>
|
||||
<tr className={css.SystemColumn}>
|
||||
<td>createdAt</td>
|
||||
<td>
|
||||
<TypeBadge type="Date" />
|
||||
</td>
|
||||
<td>✓</td>
|
||||
<td>auto</td>
|
||||
</tr>
|
||||
<tr className={css.SystemColumn}>
|
||||
<td>updatedAt</td>
|
||||
<td>
|
||||
<TypeBadge type="Date" />
|
||||
</td>
|
||||
<td>✓</td>
|
||||
<td>auto</td>
|
||||
</tr>
|
||||
{/* User-defined columns */}
|
||||
{table.columns.map((col) => (
|
||||
<tr key={col.name}>
|
||||
<td>{col.name}</td>
|
||||
<td>
|
||||
<TypeBadge type={col.type} />
|
||||
{col.targetClass && <span className={css.TargetClass}> → {col.targetClass}</span>}
|
||||
</td>
|
||||
<td>{col.required ? '✓' : ''}</td>
|
||||
<td>{col.default !== undefined ? String(col.default) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{table.columns.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className={css.NoColumns}>
|
||||
No custom fields defined yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Schema Manager Panel
|
||||
*
|
||||
* UI components for viewing and managing database schemas in local backends.
|
||||
*
|
||||
* @module schemamanager
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
export { SchemaPanel } from './SchemaPanel';
|
||||
export type { SchemaPanelProps } from './SchemaPanel';
|
||||
|
||||
export { TableRow } from './TableRow';
|
||||
export type { TableRowProps, TableInfo, ColumnDefinition } from './TableRow';
|
||||
|
||||
export { CreateTableModal } from './CreateTableModal';
|
||||
export type { CreateTableModalProps } from './CreateTableModal';
|
||||
|
||||
export { AddColumnForm, ColumnRenameInput } from './AddColumnForm';
|
||||
export type { AddColumnFormProps, ColumnRenameProps } from './AddColumnForm';
|
||||
@@ -100,6 +100,65 @@ class BackendManager {
|
||||
return this.exportSchema(id, format);
|
||||
});
|
||||
|
||||
// Get full schema
|
||||
ipcMain.handle('backend:getSchema', async (_, id) => {
|
||||
return this.getSchema(id);
|
||||
});
|
||||
|
||||
// Get single table schema
|
||||
ipcMain.handle('backend:getTableSchema', async (_, id, tableName) => {
|
||||
return this.getTableSchema(id, tableName);
|
||||
});
|
||||
|
||||
// Get record count for a table
|
||||
ipcMain.handle('backend:getRecordCount', async (_, id, tableName) => {
|
||||
return this.getRecordCount(id, tableName);
|
||||
});
|
||||
|
||||
// Create a new table
|
||||
ipcMain.handle('backend:createTable', async (_, id, tableSchema) => {
|
||||
return this.createTable(id, tableSchema);
|
||||
});
|
||||
|
||||
// Add column to existing table
|
||||
ipcMain.handle('backend:addColumn', async (_, id, tableName, column) => {
|
||||
return this.addColumn(id, tableName, column);
|
||||
});
|
||||
|
||||
// Rename column in existing table
|
||||
ipcMain.handle('backend:renameColumn', async (_, id, tableName, oldName, newName) => {
|
||||
return this.renameColumn(id, tableName, oldName, newName);
|
||||
});
|
||||
|
||||
// Delete a table
|
||||
ipcMain.handle('backend:deleteTable', async (_, id, tableName) => {
|
||||
return this.deleteTable(id, tableName);
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// DATA OPERATIONS (for Data Browser)
|
||||
// ==========================================================================
|
||||
|
||||
// Query records with pagination, search, filters
|
||||
ipcMain.handle('backend:queryRecords', async (_, id, options) => {
|
||||
return this.queryRecords(id, options);
|
||||
});
|
||||
|
||||
// Create a new record
|
||||
ipcMain.handle('backend:createRecord', async (_, id, collection, data) => {
|
||||
return this.createRecord(id, collection, data);
|
||||
});
|
||||
|
||||
// Update an existing record
|
||||
ipcMain.handle('backend:saveRecord', async (_, id, collection, objectId, data) => {
|
||||
return this.saveRecord(id, collection, objectId, data);
|
||||
});
|
||||
|
||||
// Delete a record
|
||||
ipcMain.handle('backend:deleteRecord', async (_, id, collection, objectId) => {
|
||||
return this.deleteRecord(id, collection, objectId);
|
||||
});
|
||||
|
||||
// Workflow management
|
||||
ipcMain.handle('backend:update-workflow', async (_, args) => {
|
||||
return this.updateWorkflow(args.backendId, args.name, args.workflow);
|
||||
@@ -310,16 +369,200 @@ class BackendManager {
|
||||
throw new Error('Adapter or schema manager not available');
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case 'postgres':
|
||||
return adapter.schemaManager.generatePostgresSQL();
|
||||
case 'supabase':
|
||||
return adapter.schemaManager.generateSupabaseSQL();
|
||||
case 'json':
|
||||
default:
|
||||
const schema = await adapter.schemaManager.exportSchema();
|
||||
return JSON.stringify(schema, null, 2);
|
||||
if (format === 'postgres') {
|
||||
return adapter.schemaManager.generatePostgresSQL();
|
||||
}
|
||||
if (format === 'supabase') {
|
||||
return adapter.schemaManager.generateSupabaseSQL();
|
||||
}
|
||||
// Default: json
|
||||
const exportedSchema = await adapter.schemaManager.exportSchema();
|
||||
return JSON.stringify(exportedSchema, null, 2);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// SCHEMA MANAGEMENT
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get full schema for a backend
|
||||
* @param {string} id - Backend ID
|
||||
* @returns {Promise<Object>} Schema with tables array
|
||||
*/
|
||||
async getSchema(id) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to get schema');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter || !adapter.schemaManager) {
|
||||
throw new Error('Adapter or schema manager not available');
|
||||
}
|
||||
|
||||
const tables = adapter.schemaManager.listTables();
|
||||
const schemas = adapter.schemaManager.exportSchemas();
|
||||
|
||||
// Build response with table info
|
||||
return {
|
||||
tables: tables.map((tableName) => {
|
||||
const schema = schemas.find((s) => s.name === tableName);
|
||||
return {
|
||||
name: tableName,
|
||||
columns: schema?.columns || [],
|
||||
createdAt: schema?.createdAt || null
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schema for a single table
|
||||
* @param {string} id - Backend ID
|
||||
* @param {string} tableName - Table name
|
||||
* @returns {Promise<Object|null>} Table schema
|
||||
*/
|
||||
async getTableSchema(id, tableName) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to get table schema');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter || !adapter.schemaManager) {
|
||||
throw new Error('Adapter or schema manager not available');
|
||||
}
|
||||
|
||||
const schema = adapter.schemaManager.getTableSchema(tableName);
|
||||
if (!schema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: tableName,
|
||||
columns: schema.columns || []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get record count for a table
|
||||
* @param {string} id - Backend ID
|
||||
* @param {string} tableName - Table name
|
||||
* @returns {Promise<number>} Record count
|
||||
*/
|
||||
async getRecordCount(id, tableName) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to get record count');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter) {
|
||||
throw new Error('Adapter not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
adapter.count({
|
||||
collection: tableName,
|
||||
success: (count) => resolve(count),
|
||||
error: (err) => reject(new Error(err))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new table
|
||||
* @param {string} id - Backend ID
|
||||
* @param {Object} tableSchema - Table schema { name, columns }
|
||||
* @returns {Promise<Object>} Result with success status
|
||||
*/
|
||||
async createTable(id, tableSchema) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to create table');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter || !adapter.schemaManager) {
|
||||
throw new Error('Adapter or schema manager not available');
|
||||
}
|
||||
|
||||
const created = adapter.schemaManager.createTable(tableSchema);
|
||||
safeLog(`Created table: ${tableSchema.name} (created: ${created})`);
|
||||
|
||||
return { success: true, created, tableName: tableSchema.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a column to an existing table
|
||||
* @param {string} id - Backend ID
|
||||
* @param {string} tableName - Table name
|
||||
* @param {Object} column - Column definition { name, type, required, default }
|
||||
* @returns {Promise<Object>} Result with success status
|
||||
*/
|
||||
async addColumn(id, tableName, column) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to add column');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter || !adapter.schemaManager) {
|
||||
throw new Error('Adapter or schema manager not available');
|
||||
}
|
||||
|
||||
adapter.schemaManager.addColumn(tableName, column);
|
||||
safeLog(`Added column: ${column.name} to table ${tableName}`);
|
||||
|
||||
return { success: true, tableName, columnName: column.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a column in an existing table
|
||||
* @param {string} id - Backend ID
|
||||
* @param {string} tableName - Table name
|
||||
* @param {string} oldName - Current column name
|
||||
* @param {string} newName - New column name
|
||||
* @returns {Promise<Object>} Result with success status
|
||||
*/
|
||||
async renameColumn(id, tableName, oldName, newName) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to rename column');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter || !adapter.schemaManager) {
|
||||
throw new Error('Adapter or schema manager not available');
|
||||
}
|
||||
|
||||
adapter.schemaManager.renameColumn(tableName, oldName, newName);
|
||||
safeLog(`Renamed column: ${oldName} -> ${newName} in table ${tableName}`);
|
||||
|
||||
return { success: true, tableName, oldName, newName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a table and all its data
|
||||
* @param {string} id - Backend ID
|
||||
* @param {string} tableName - Table name
|
||||
* @returns {Promise<Object>} Result with success status
|
||||
*/
|
||||
async deleteTable(id, tableName) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to delete table');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter || !adapter.schemaManager) {
|
||||
throw new Error('Adapter or schema manager not available');
|
||||
}
|
||||
|
||||
const deleted = adapter.schemaManager.deleteTable(tableName);
|
||||
safeLog(`Deleted table: ${tableName} (deleted: ${deleted})`);
|
||||
|
||||
return { success: true, deleted, tableName };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,6 +586,147 @@ class BackendManager {
|
||||
return port;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DATA OPERATIONS (for Data Browser)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Query records with pagination, search, and filters
|
||||
* @param {string} id - Backend ID
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} options.collection - Table/collection name
|
||||
* @param {number} [options.limit=50] - Max records to return
|
||||
* @param {number} [options.skip=0] - Records to skip (for pagination)
|
||||
* @param {Object} [options.where] - Filter conditions
|
||||
* @param {Array} [options.sort] - Sort order (e.g., ['-createdAt'])
|
||||
* @param {boolean} [options.count] - Include total count
|
||||
* @returns {Promise<{results: Object[], count?: number}>}
|
||||
*/
|
||||
async queryRecords(id, options) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to query records');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter) {
|
||||
throw new Error('Adapter not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
adapter.query({
|
||||
collection: options.collection,
|
||||
limit: options.limit || 50,
|
||||
skip: options.skip || 0,
|
||||
where: options.where,
|
||||
sort: options.sort,
|
||||
count: options.count,
|
||||
success: (results, count) => {
|
||||
resolve({
|
||||
results,
|
||||
count: options.count ? count : undefined
|
||||
});
|
||||
},
|
||||
error: (err) => reject(new Error(err))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
* @param {string} id - Backend ID
|
||||
* @param {string} collection - Table/collection name
|
||||
* @param {Object} data - Record data
|
||||
* @returns {Promise<Object>} Created record with objectId
|
||||
*/
|
||||
async createRecord(id, collection, data) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to create records');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter) {
|
||||
throw new Error('Adapter not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
adapter.create({
|
||||
collection,
|
||||
data,
|
||||
success: (record) => {
|
||||
safeLog(`Created record in ${collection}:`, record.objectId);
|
||||
resolve(record);
|
||||
},
|
||||
error: (err) => reject(new Error(err))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
* @param {string} id - Backend ID
|
||||
* @param {string} collection - Table/collection name
|
||||
* @param {string} objectId - Record ID to update
|
||||
* @param {Object} data - Fields to update
|
||||
* @returns {Promise<Object>} Updated record
|
||||
*/
|
||||
async saveRecord(id, collection, objectId, data) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to save records');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter) {
|
||||
throw new Error('Adapter not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
adapter.save({
|
||||
collection,
|
||||
objectId,
|
||||
data,
|
||||
success: (record) => {
|
||||
safeLog(`Updated record in ${collection}:`, objectId);
|
||||
resolve(record);
|
||||
},
|
||||
error: (err) => reject(new Error(err))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record
|
||||
* @param {string} id - Backend ID
|
||||
* @param {string} collection - Table/collection name
|
||||
* @param {string} objectId - Record ID to delete
|
||||
* @returns {Promise<{success: boolean}>}
|
||||
*/
|
||||
async deleteRecord(id, collection, objectId) {
|
||||
const server = this.runningBackends.get(id);
|
||||
if (!server) {
|
||||
throw new Error('Backend must be running to delete records');
|
||||
}
|
||||
|
||||
const adapter = server.getAdapter();
|
||||
if (!adapter) {
|
||||
throw new Error('Adapter not available');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
adapter.delete({
|
||||
collection,
|
||||
objectId,
|
||||
success: () => {
|
||||
safeLog(`Deleted record from ${collection}:`, objectId);
|
||||
resolve({ success: true });
|
||||
},
|
||||
error: (err) => reject(new Error(err))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running backends (for cleanup on app exit)
|
||||
*/
|
||||
|
||||
@@ -13,17 +13,17 @@ const QueryBuilder = require('./QueryBuilder');
|
||||
const SchemaManager = require('./SchemaManager');
|
||||
|
||||
/**
|
||||
* Generate a unique object ID (similar to Parse objectId)
|
||||
* Generate a UUID v4
|
||||
*
|
||||
* @returns {string} 10-character alphanumeric ID
|
||||
* @returns {string} UUID string (e.g., "123e4567-e89b-12d3-a456-426614174000")
|
||||
*/
|
||||
function generateObjectId() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let id = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
id += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return id;
|
||||
function generateUUID() {
|
||||
// RFC 4122 version 4 UUID
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,17 +132,25 @@ class LocalSQLAdapter {
|
||||
const self = this;
|
||||
return {
|
||||
ensureSchemaTable: () => {},
|
||||
createTable: ({ name }) => {
|
||||
createTable: ({ name, columns }) => {
|
||||
if (!self._mockData[name]) {
|
||||
self._mockData[name] = {};
|
||||
self._mockSchema[name] = {};
|
||||
self._mockSchema[name] = { name, columns: columns || [] };
|
||||
}
|
||||
return true;
|
||||
},
|
||||
addColumn: (table, col) => {
|
||||
if (!self._mockSchema[table]) self._mockSchema[table] = {};
|
||||
self._mockSchema[table][col.name] = col;
|
||||
if (!self._mockSchema[table]) self._mockSchema[table] = { name: table, columns: [] };
|
||||
if (!self._mockSchema[table].columns) self._mockSchema[table].columns = [];
|
||||
// Check if column already exists
|
||||
const exists = self._mockSchema[table].columns.some((c) => c.name === col.name);
|
||||
if (!exists) {
|
||||
self._mockSchema[table].columns.push(col);
|
||||
}
|
||||
},
|
||||
getTableSchema: (table) => self._mockSchema[table] || {},
|
||||
getTableSchema: (table) => self._mockSchema[table] || null,
|
||||
listTables: () => Object.keys(self._mockData).filter((name) => !name.startsWith('_')),
|
||||
exportSchemas: () => Object.values(self._mockSchema).filter((s) => s && !s.name?.startsWith('_')),
|
||||
addRelation: () => {},
|
||||
removeRelation: () => {}
|
||||
};
|
||||
@@ -153,8 +161,9 @@ class LocalSQLAdapter {
|
||||
* @private
|
||||
*/
|
||||
_mockExec(sql, params, mode) {
|
||||
// Parse simple SQL patterns for mock execution
|
||||
const selectMatch = sql.match(/SELECT \* FROM "?(\w+)"?\s*(?:WHERE "?objectId"?\s*=\s*\?)?/i);
|
||||
// Parse SQL patterns for mock execution
|
||||
// Match SELECT with optional WHERE, ORDER BY, LIMIT, OFFSET
|
||||
const selectMatch = sql.match(/SELECT\s+\*\s+FROM\s+"?(\w+)"?/i);
|
||||
const insertMatch = sql.match(/INSERT INTO "?(\w+)"?/i);
|
||||
const updateMatch = sql.match(/UPDATE "?(\w+)"?\s+SET/i);
|
||||
const deleteMatch = sql.match(/DELETE FROM "?(\w+)"?/i);
|
||||
@@ -163,48 +172,111 @@ class LocalSQLAdapter {
|
||||
const table = selectMatch[1];
|
||||
if (!this._mockData[table]) this._mockData[table] = {};
|
||||
|
||||
if (params.length > 0) {
|
||||
// Single record fetch
|
||||
const record = this._mockData[table][params[0]];
|
||||
let records = Object.values(this._mockData[table]);
|
||||
|
||||
// Check for WHERE id = ? or WHERE objectId = ?
|
||||
const idMatch = sql.match(/WHERE\s+"?(?:id|objectId)"?\s*=\s*\?/i);
|
||||
if (idMatch && params.length > 0) {
|
||||
const recordId = params[0];
|
||||
const record = this._mockData[table][recordId];
|
||||
return mode === 'get' ? record || null : record ? [record] : [];
|
||||
}
|
||||
// Return all records
|
||||
const records = Object.values(this._mockData[table]);
|
||||
|
||||
// Handle ORDER BY
|
||||
const orderMatch = sql.match(/ORDER BY\s+"?(\w+)"?\s+(ASC|DESC)?/i);
|
||||
if (orderMatch) {
|
||||
const orderCol = orderMatch[1];
|
||||
const orderDir = (orderMatch[2] || 'ASC').toUpperCase();
|
||||
records = records.sort((a, b) => {
|
||||
const aVal = a[orderCol];
|
||||
const bVal = b[orderCol];
|
||||
if (aVal < bVal) return orderDir === 'ASC' ? -1 : 1;
|
||||
if (aVal > bVal) return orderDir === 'ASC' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle LIMIT and OFFSET
|
||||
// Find position of LIMIT in params (it comes after WHERE params if any)
|
||||
let paramIndex = 0;
|
||||
if (sql.includes('LIMIT')) {
|
||||
// Find LIMIT param position - it's after WHERE params
|
||||
const limitIdx = sql.indexOf('LIMIT');
|
||||
const whereClause = sql.substring(0, limitIdx);
|
||||
const whereParams = (whereClause.match(/\?/g) || []).length;
|
||||
paramIndex = whereParams;
|
||||
|
||||
const limit = params[paramIndex];
|
||||
const skip = sql.includes('OFFSET') ? params[paramIndex + 1] || 0 : 0;
|
||||
records = records.slice(skip, skip + limit);
|
||||
}
|
||||
|
||||
return mode === 'get' ? records[0] || null : records;
|
||||
}
|
||||
|
||||
if (insertMatch) {
|
||||
const table = insertMatch[1];
|
||||
if (!this._mockData[table]) this._mockData[table] = {};
|
||||
// Find objectId in params (simple extraction)
|
||||
|
||||
// Parse column names from SQL: INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...)
|
||||
const columnsMatch = sql.match(/\(([^)]+)\)\s*VALUES/i);
|
||||
if (columnsMatch) {
|
||||
const columns = columnsMatch[1].split(',').map((c) => c.trim().replace(/"/g, ''));
|
||||
const record = {};
|
||||
columns.forEach((col, idx) => {
|
||||
record[col] = params[idx];
|
||||
});
|
||||
// Ensure id exists
|
||||
if (!record.id && params[0]) {
|
||||
record.id = params[0];
|
||||
}
|
||||
const recordId = record.id;
|
||||
this._mockData[table][recordId] = record;
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
// Fallback: simple record creation
|
||||
const now = new Date().toISOString();
|
||||
const record = { objectId: params[0], createdAt: now, updatedAt: now };
|
||||
const record = { id: params[0], createdAt: now, updatedAt: now };
|
||||
this._mockData[table][params[0]] = record;
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (updateMatch) {
|
||||
const table = updateMatch[1];
|
||||
// Last param is typically the objectId in WHERE clause
|
||||
const objectId = params[params.length - 1];
|
||||
if (this._mockData[table] && this._mockData[table][objectId]) {
|
||||
this._mockData[table][objectId].updatedAt = new Date().toISOString();
|
||||
// Last param is typically the id in WHERE clause
|
||||
const recordId = params[params.length - 1];
|
||||
if (this._mockData[table] && this._mockData[table][recordId]) {
|
||||
// Parse SET clauses to update actual fields
|
||||
const setMatch = sql.match(/SET\s+(.+?)\s+WHERE/i);
|
||||
if (setMatch) {
|
||||
const setParts = setMatch[1].split(',');
|
||||
let paramIdx = 0;
|
||||
setParts.forEach((part) => {
|
||||
const colMatch = part.match(/"?(\w+)"?\s*=/);
|
||||
if (colMatch) {
|
||||
this._mockData[table][recordId][colMatch[1]] = params[paramIdx];
|
||||
paramIdx++;
|
||||
}
|
||||
});
|
||||
}
|
||||
this._mockData[table][recordId].updatedAt = new Date().toISOString();
|
||||
}
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
if (deleteMatch) {
|
||||
const table = deleteMatch[1];
|
||||
const objectId = params[0];
|
||||
const recordId = params[0];
|
||||
if (this._mockData[table]) {
|
||||
delete this._mockData[table][objectId];
|
||||
delete this._mockData[table][recordId];
|
||||
}
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
// Count query
|
||||
if (sql.includes('COUNT(*)')) {
|
||||
const countMatch = sql.match(/FROM "?(\w+)"?/i);
|
||||
const countMatch = sql.match(/FROM\s+"?(\w+)"?/i);
|
||||
if (countMatch) {
|
||||
const table = countMatch[1];
|
||||
const count = Object.keys(this._mockData[table] || {}).length;
|
||||
@@ -356,8 +428,9 @@ class LocalSQLAdapter {
|
||||
try {
|
||||
this._ensureTable(options.collection);
|
||||
|
||||
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`;
|
||||
const row = this.db.prepare(sql).get(options.objectId);
|
||||
const sql = `SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`;
|
||||
const recordId = options.id || options.objectId;
|
||||
const row = this.db.prepare(sql).get(recordId);
|
||||
|
||||
if (!row) {
|
||||
options.error('Object not found');
|
||||
@@ -370,7 +443,7 @@ class LocalSQLAdapter {
|
||||
|
||||
this.events.emit('fetch', {
|
||||
type: 'fetch',
|
||||
objectId: options.objectId,
|
||||
id: recordId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
@@ -392,22 +465,22 @@ class LocalSQLAdapter {
|
||||
// Auto-add columns for new fields
|
||||
if (this.options.autoCreateTables && this.schemaManager) {
|
||||
for (const [key, value] of Object.entries(options.data)) {
|
||||
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
|
||||
if (key !== 'id' && key !== 'createdAt' && key !== 'updatedAt') {
|
||||
const type = this._inferType(value);
|
||||
this.schemaManager.addColumn(options.collection, { name: key, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const objectId = generateObjectId();
|
||||
const { sql, params } = QueryBuilder.buildInsert(options, objectId);
|
||||
const recordId = generateUUID();
|
||||
const { sql, params } = QueryBuilder.buildInsert(options, recordId);
|
||||
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the created record to get all fields
|
||||
const createdRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(objectId);
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
|
||||
.get(recordId);
|
||||
|
||||
const record = this._rowToRecord(createdRow, options.collection);
|
||||
|
||||
@@ -415,7 +488,7 @@ class LocalSQLAdapter {
|
||||
|
||||
this.events.emit('create', {
|
||||
type: 'create',
|
||||
objectId,
|
||||
id: recordId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
@@ -437,20 +510,21 @@ class LocalSQLAdapter {
|
||||
// Auto-add columns for new fields
|
||||
if (this.options.autoCreateTables && this.schemaManager) {
|
||||
for (const [key, value] of Object.entries(options.data)) {
|
||||
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
|
||||
if (key !== 'id' && key !== 'createdAt' && key !== 'updatedAt') {
|
||||
const type = this._inferType(value);
|
||||
this.schemaManager.addColumn(options.collection, { name: key, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recordId = options.id || options.objectId;
|
||||
const { sql, params } = QueryBuilder.buildUpdate(options);
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the updated record
|
||||
const updatedRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(options.objectId);
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
|
||||
.get(recordId);
|
||||
|
||||
const record = this._rowToRecord(updatedRow, options.collection);
|
||||
|
||||
@@ -458,7 +532,7 @@ class LocalSQLAdapter {
|
||||
|
||||
this.events.emit('save', {
|
||||
type: 'save',
|
||||
objectId: options.objectId,
|
||||
id: recordId,
|
||||
object: record,
|
||||
collection: options.collection
|
||||
});
|
||||
@@ -482,9 +556,10 @@ class LocalSQLAdapter {
|
||||
|
||||
options.success();
|
||||
|
||||
const recordId = options.id || options.objectId;
|
||||
this.events.emit('delete', {
|
||||
type: 'delete',
|
||||
objectId: options.objectId,
|
||||
id: recordId,
|
||||
collection: options.collection
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -573,9 +648,10 @@ class LocalSQLAdapter {
|
||||
this.db.prepare(sql).run(...params);
|
||||
|
||||
// Fetch the updated record
|
||||
const recordId = options.id || options.objectId;
|
||||
const updatedRow = this.db
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "objectId" = ?`)
|
||||
.get(options.objectId);
|
||||
.prepare(`SELECT * FROM ${QueryBuilder.escapeTable(options.collection)} WHERE "id" = ?`)
|
||||
.get(recordId);
|
||||
|
||||
const record = this._rowToRecord(updatedRow, options.collection);
|
||||
options.success(record);
|
||||
|
||||
@@ -275,6 +275,12 @@ function translateOperator(col, op, value, params, schema) {
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'contains':
|
||||
case '$contains':
|
||||
// Contains search - convert to LIKE with wildcards
|
||||
params.push(`%${convertedValue}%`);
|
||||
return `${col} LIKE ?`;
|
||||
|
||||
// Geo queries - not fully supported in SQLite without extensions
|
||||
case '$nearSphere':
|
||||
case '$within':
|
||||
@@ -333,8 +339,8 @@ function buildSelect(options, schema) {
|
||||
let selectClause = '*';
|
||||
if (options.select) {
|
||||
const selectArray = Array.isArray(options.select) ? options.select : options.select.split(',');
|
||||
// Always include objectId
|
||||
const fields = new Set(['objectId', ...selectArray.map((s) => s.trim())]);
|
||||
// Always include id
|
||||
const fields = new Set(['id', ...selectArray.map((s) => s.trim())]);
|
||||
selectClause = Array.from(fields)
|
||||
.map((f) => escapeColumn(f))
|
||||
.join(', ');
|
||||
@@ -406,13 +412,13 @@ function buildCount(options, schema) {
|
||||
* @param {string} objectId
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildInsert(options, objectId) {
|
||||
function buildInsert(options, id) {
|
||||
const params = [];
|
||||
const table = escapeTable(options.collection);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const data = {
|
||||
objectId,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...options.data
|
||||
@@ -441,7 +447,7 @@ function buildInsert(options, objectId) {
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @param {string} options.id - Record ID
|
||||
* @param {Object} options.data
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
@@ -455,7 +461,7 @@ function buildUpdate(options) {
|
||||
data.updatedAt = new Date().toISOString();
|
||||
|
||||
// Remove protected fields
|
||||
delete data.objectId;
|
||||
delete data.id;
|
||||
delete data.createdAt;
|
||||
delete data._createdAt;
|
||||
delete data._updatedAt;
|
||||
@@ -467,9 +473,11 @@ function buildUpdate(options) {
|
||||
params.push(serializeValue(value));
|
||||
}
|
||||
|
||||
params.push(options.objectId);
|
||||
// Use id or objectId for backwards compatibility
|
||||
const recordId = options.id || options.objectId;
|
||||
params.push(recordId);
|
||||
|
||||
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`;
|
||||
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "id" = ?`;
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
@@ -479,13 +487,15 @@ function buildUpdate(options) {
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @param {string} options.id - Record ID
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
function buildDelete(options) {
|
||||
const table = escapeTable(options.collection);
|
||||
const sql = `DELETE FROM ${table} WHERE "objectId" = ?`;
|
||||
return { sql, params: [options.objectId] };
|
||||
// Use id or objectId for backwards compatibility
|
||||
const recordId = options.id || options.objectId;
|
||||
const sql = `DELETE FROM ${table} WHERE "id" = ?`;
|
||||
return { sql, params: [recordId] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,7 +503,7 @@ function buildDelete(options) {
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.collection
|
||||
* @param {string} options.objectId
|
||||
* @param {string} options.id - Record ID
|
||||
* @param {Object<string, number>} options.properties
|
||||
* @returns {{ sql: string, params: Array }}
|
||||
*/
|
||||
@@ -513,9 +523,11 @@ function buildIncrement(options) {
|
||||
setClause.push('"updatedAt" = ?');
|
||||
params.push(new Date().toISOString());
|
||||
|
||||
params.push(options.objectId);
|
||||
// Use id or objectId for backwards compatibility
|
||||
const recordId = options.id || options.objectId;
|
||||
params.push(recordId);
|
||||
|
||||
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "objectId" = ?`;
|
||||
const sql = `UPDATE ${table} SET ${setClause.join(', ')} WHERE "id" = ?`;
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
@@ -168,6 +168,89 @@ class SchemaManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a table and all its data
|
||||
*
|
||||
* @param {string} tableName - Table name
|
||||
* @returns {boolean} Whether table was deleted
|
||||
*/
|
||||
deleteTable(tableName) {
|
||||
// Check if table exists
|
||||
const exists = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(tableName);
|
||||
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Drop the table
|
||||
this.db.exec(`DROP TABLE IF EXISTS ${escapeTable(tableName)}`);
|
||||
|
||||
// Remove from schema tracking
|
||||
this.ensureSchemaTable();
|
||||
this.db.prepare('DELETE FROM "_Schema" WHERE "name" = ?').run(tableName);
|
||||
|
||||
// Clear cache
|
||||
this._schemaCache.delete(tableName);
|
||||
|
||||
// Drop any junction tables for relations
|
||||
const junctionTables = this.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?")
|
||||
.all(`_Join_%_${tableName}`);
|
||||
|
||||
for (const jt of junctionTables) {
|
||||
this.db.exec(`DROP TABLE IF EXISTS ${escapeTable(jt.name)}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a column in a table (SQLite 3.25.0+)
|
||||
*
|
||||
* @param {string} tableName - Table name
|
||||
* @param {string} oldName - Current column name
|
||||
* @param {string} newName - New column name
|
||||
* @returns {boolean} Whether column was renamed
|
||||
*/
|
||||
renameColumn(tableName, oldName, newName) {
|
||||
// Validate new name
|
||||
if (!newName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(newName)) {
|
||||
throw new Error('Invalid column name');
|
||||
}
|
||||
|
||||
// Can't rename system columns
|
||||
const systemCols = ['objectId', 'createdAt', 'updatedAt', 'ACL'];
|
||||
if (systemCols.includes(oldName)) {
|
||||
throw new Error('Cannot rename system columns');
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.exec(
|
||||
`ALTER TABLE ${escapeTable(tableName)} RENAME COLUMN ${escapeColumn(oldName)} TO ${escapeColumn(newName)}`
|
||||
);
|
||||
|
||||
// Update schema tracking
|
||||
const schema = this.getTableSchema(tableName);
|
||||
if (schema && schema.columns) {
|
||||
const col = schema.columns.find((c) => c.name === oldName);
|
||||
if (col) {
|
||||
col.name = newName;
|
||||
this.db
|
||||
.prepare(`UPDATE "_Schema" SET "schema" = ?, "updatedAt" = CURRENT_TIMESTAMP WHERE "name" = ?`)
|
||||
.run(JSON.stringify(schema), tableName);
|
||||
this._schemaCache.set(tableName, schema);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.message.includes('no such column')) {
|
||||
throw new Error(`Column "${oldName}" does not exist`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get schema for a table
|
||||
*
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const OutputProperty = require('./outputproperty');
|
||||
const { evaluateExpression } = require('./expression-evaluator');
|
||||
const {
|
||||
evaluateExpression,
|
||||
compileExpression,
|
||||
detectDependencies,
|
||||
subscribeToChanges
|
||||
} = require('./expression-evaluator');
|
||||
const { coerceToType } = require('./expression-type-coercion');
|
||||
|
||||
/**
|
||||
@@ -45,6 +50,9 @@ function Node(context, id) {
|
||||
|
||||
this._valuesFromConnections = {};
|
||||
this.updateOnDirtyFlagging = true;
|
||||
|
||||
// Expression subscriptions: { [portName]: { unsub: unsubscribeFn, expression: string } }
|
||||
this._expressionSubscriptions = {};
|
||||
}
|
||||
|
||||
Node.prototype.getInputValue = function (name) {
|
||||
@@ -101,7 +109,8 @@ Node.prototype.registerInputIfNeeded = function () {
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluate an expression parameter and return the coerced result
|
||||
* Evaluate an expression parameter and return the coerced result.
|
||||
* Also sets up reactive subscriptions so the node updates when dependencies change.
|
||||
*
|
||||
* @param {*} paramValue - The parameter value (might be an ExpressionParameter)
|
||||
* @param {string} portName - The input port name
|
||||
@@ -110,6 +119,14 @@ Node.prototype.registerInputIfNeeded = function () {
|
||||
Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
|
||||
// Check if this is an expression parameter
|
||||
if (!isExpressionParameter(paramValue)) {
|
||||
// Clean up any existing subscription for this port since it's no longer an expression
|
||||
if (this._expressionSubscriptions[portName]) {
|
||||
const sub = this._expressionSubscriptions[portName];
|
||||
if (sub && sub.unsub) {
|
||||
sub.unsub();
|
||||
}
|
||||
delete this._expressionSubscriptions[portName];
|
||||
}
|
||||
return paramValue; // Simple value, return as-is
|
||||
}
|
||||
|
||||
@@ -119,14 +136,63 @@ Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Evaluate the expression with access to context
|
||||
const result = evaluateExpression(paramValue.expression, this.context);
|
||||
// Compile and evaluate the expression
|
||||
// Note: We pass undefined for modelScope - evaluateExpression will use the global Model
|
||||
const compiled = compileExpression(paramValue.expression);
|
||||
if (!compiled) {
|
||||
console.warn(`Expression compilation failed for ${this.name}.${portName}: ${paramValue.expression}`);
|
||||
return paramValue.fallback;
|
||||
}
|
||||
const result = evaluateExpression(compiled, undefined);
|
||||
|
||||
// Coerce to expected type
|
||||
const coercedValue = coerceToType(result, input.type, paramValue.fallback);
|
||||
|
||||
// Set up reactive subscription
|
||||
// Track both the unsubscribe function and the expression string
|
||||
// If expression changes, we need to re-subscribe with new dependencies
|
||||
const currentSub = this._expressionSubscriptions[portName];
|
||||
const expressionChanged = currentSub && currentSub.expression !== paramValue.expression;
|
||||
|
||||
// Unsubscribe if expression changed
|
||||
if (expressionChanged && currentSub.unsub) {
|
||||
currentSub.unsub();
|
||||
delete this._expressionSubscriptions[portName];
|
||||
}
|
||||
|
||||
// Subscribe if not subscribed or expression changed
|
||||
if (!this._expressionSubscriptions[portName]) {
|
||||
const dependencies = detectDependencies(paramValue.expression);
|
||||
const hasDependencies =
|
||||
dependencies.variables.length > 0 || dependencies.objects.length > 0 || dependencies.arrays.length > 0;
|
||||
|
||||
if (hasDependencies) {
|
||||
// Subscribe to changes - when a dependency changes, re-queue the input
|
||||
// Note: We store the expression string to detect changes later
|
||||
const unsub = subscribeToChanges(
|
||||
dependencies,
|
||||
function () {
|
||||
// Don't re-evaluate if node is deleted
|
||||
if (this._deleted) return;
|
||||
|
||||
// Re-queue the expression parameter - it will be re-evaluated
|
||||
// Use the stored input value which has the current expression
|
||||
const currentValue = this._inputValues[portName];
|
||||
if (isExpressionParameter(currentValue)) {
|
||||
this.queueInput(portName, currentValue);
|
||||
}
|
||||
}.bind(this)
|
||||
);
|
||||
|
||||
this._expressionSubscriptions[portName] = {
|
||||
unsub: unsub,
|
||||
expression: paramValue.expression
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any previous expression errors
|
||||
if (this.context.editorConnection) {
|
||||
if (this.context && this.context.editorConnection) {
|
||||
this.context.editorConnection.clearWarning(
|
||||
this.nodeScope.componentOwner.name,
|
||||
this.id,
|
||||
@@ -140,7 +206,7 @@ Node.prototype._evaluateExpressionParameter = function (paramValue, portName) {
|
||||
console.warn(`Expression evaluation failed for ${this.name}.${portName}:`, error);
|
||||
|
||||
// Show warning in editor
|
||||
if (this.context.editorConnection) {
|
||||
if (this.context && this.context.editorConnection) {
|
||||
this.context.editorConnection.sendWarning(
|
||||
this.nodeScope.componentOwner.name,
|
||||
this.id,
|
||||
@@ -605,6 +671,15 @@ Node.prototype._onNodeDeleted = function () {
|
||||
|
||||
this._deleted = true;
|
||||
|
||||
// Clean up expression subscriptions
|
||||
for (const portName in this._expressionSubscriptions) {
|
||||
const sub = this._expressionSubscriptions[portName];
|
||||
if (sub && sub.unsub) {
|
||||
sub.unsub();
|
||||
}
|
||||
}
|
||||
this._expressionSubscriptions = {};
|
||||
|
||||
for (const deleteListener of this._deleteListeners) {
|
||||
deleteListener.call(this);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user