# TASK-007I: Data Browser & Editor UI ## Overview Build a native Nodegx UI for browsing, searching, and editing records in local backend tables, providing a visual interface similar to Parse Dashboard's data browser. **Parent Task:** TASK-007 (Integrated Local Backend) **Phase:** I (Data Management) **Effort:** 16-20 hours (2-3 days) **Priority:** HIGH (Unblocks user productivity) **Dependencies:** TASK-007A (LocalSQL Adapter), TASK-007H (Schema Manager) --- ## Objectives 1. Create a data browser panel for viewing records in tables 2. Build inline editing capabilities for all field types 3. Implement search and filtering 4. Support pagination for large datasets 5. Enable record creation, update, and deletion 6. Handle all Nodegx field types (String, Number, Boolean, Date, Object, Array, Pointer, Relation) 7. Provide bulk operations (delete multiple, export) 8. Integrate with SchemaManager for type-aware editing --- ## Background ### Current State With TASK-007H, users can now manage database schemas, but they cannot: - View the actual data in tables - Add/edit/delete records - Search or filter data - Import/export data The `LocalSQLAdapter` already provides all backend CRUD operations: ```typescript class LocalSQLAdapter { async query(options: QueryOptions): Promise; async fetch(options: FetchOptions): Promise; async create(options: CreateOptions): Promise; async save(options: SaveOptions): Promise; async delete(options: DeleteOptions): Promise; } ``` **We need to build the UI layer on top of these existing operations.** ### Design Principles 1. **Spreadsheet-like Interface** - Users think of databases as tables 2. **Inline Editing** - Click to edit, no separate forms for simple edits 3. **Type-Aware Editors** - Each field type gets appropriate input (date picker, JSON editor, etc.) 4. **Performance** - Handle tables with 100K+ records via pagination 5. **Safety** - Confirm destructive operations --- ## User Flows ### Flow 1: Browse Data ``` User clicks "Browse Data" on Backend Services panel ↓ Table selector appears (list of all tables) ↓ User selects "products" table ↓ Data Browser opens showing: - Table name - Total record count - Paginated grid (50 records per page) - Search bar - Action buttons (New Record, Export, etc.) ↓ User scrolls through records User clicks page 2 ``` ### Flow 2: Edit Record ``` User clicks on a cell in the grid ↓ Cell becomes editable ↓ Type-specific editor appears: - String: Text input - Number: Number input - Boolean: Checkbox - Date: Date picker - Object/Array: JSON editor modal - Pointer: Table selector + record picker ↓ User edits value ↓ User presses Enter or clicks outside ↓ Value saves to database ↓ Cell shows updated value ``` ### Flow 3: Create Record ``` User clicks "New Record" button ↓ Modal opens with form fields for all columns ↓ User fills in: - name: "Widget Pro" - price: 99.99 - inStock: true ↓ User clicks "Create" ↓ Record saved to database ↓ Grid refreshes with new record ``` ### Flow 4: Search & Filter ``` User types "pro" in search bar ↓ Grid filters to show only records with "pro" in any field ↓ User clicks "Advanced Filters" ↓ Filter builder opens: - Field: "price" - Operator: "greater than" - Value: 50 ↓ Grid shows only records where price > 50 ``` ### Flow 5: Bulk Operations ``` User clicks checkboxes on multiple records ↓ Bulk action bar appears: "3 records selected" ↓ User clicks "Delete Selected" ↓ Confirmation dialog: "Delete 3 records?" ↓ User confirms ↓ Records deleted, grid refreshes ``` --- ## Implementation Steps ### Step 1: Data Browser Component (5 hours) **File:** `packages/noodl-editor/src/editor/src/views/panels/databrowser/DataBrowser.tsx` ```typescript import React, { useState, useEffect, useCallback } from 'react'; import { ipcRenderer } from 'electron'; import { DataGrid } from './DataGrid'; import { TableSelector } from './TableSelector'; import { SearchBar } from './SearchBar'; import { FilterBuilder } from './FilterBuilder'; import styles from './DataBrowser.module.css'; interface DataBrowserProps { backendId: string; initialTable?: string; onClose?: () => void; } export function DataBrowser({ backendId, initialTable, onClose }: DataBrowserProps) { const [tables, setTables] = useState([]); const [selectedTable, setSelectedTable] = useState(initialTable || null); const [schema, setSchema] = useState(null); const [records, setRecords] = useState([]); const [totalCount, setTotalCount] = useState(0); const [page, setPage] = useState(0); const [pageSize] = useState(50); const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [filters, setFilters] = useState(null); const [selectedRecords, setSelectedRecords] = useState>(new Set()); const [showNewRecord, setShowNewRecord] = useState(false); // Load table list on mount useEffect(() => { loadTables(); }, [backendId]); // Load data when table, page, search, or filters change useEffect(() => { if (selectedTable) { loadData(); } }, [selectedTable, page, searchQuery, filters]); async function loadTables() { try { const schema = await ipcRenderer.invoke('backend:getSchema', backendId); setTables(schema.tables.map((t: any) => t.name)); if (!selectedTable && schema.tables.length > 0) { setSelectedTable(schema.tables[0].name); } } catch (error) { console.error('Failed to load tables:', error); } } async function loadData() { if (!selectedTable) return; setLoading(true); try { // Load schema for this table const tableSchema = await ipcRenderer.invoke('backend:getTableSchema', backendId, selectedTable); setSchema(tableSchema); // Build query const query: any = { collection: selectedTable, limit: pageSize, skip: page * pageSize, sort: ['-createdAt'] // Newest first }; // Apply search if (searchQuery) { // Search across all string fields query.where = { $or: tableSchema.columns .filter((col: any) => col.type === 'String') .map((col: any) => ({ [col.name]: { contains: searchQuery } })) }; } // Apply filters if (filters) { query.where = { ...query.where, ...filters }; } // Load records const result = await ipcRenderer.invoke('backend:query', backendId, query); setRecords(result.results); // Load total count const countQuery = { ...query, count: true, limit: 0 }; const countResult = await ipcRenderer.invoke('backend:query', backendId, countQuery); setTotalCount(countResult.count || 0); } catch (error) { console.error('Failed to load data:', error); } finally { setLoading(false); } } async function handleSaveCell(recordId: string, field: string, value: any) { try { await ipcRenderer.invoke('backend:saveRecord', backendId, selectedTable, recordId, { [field]: value }); // Update local state setRecords(records.map(r => r.objectId === recordId ? { ...r, [field]: value } : r )); } catch (error) { console.error('Failed to save cell:', error); throw error; } } async function handleDeleteRecord(recordId: string) { if (!confirm('Delete this record?')) return; try { await ipcRenderer.invoke('backend:deleteRecord', backendId, selectedTable, recordId); loadData(); // Refresh } catch (error) { console.error('Failed to delete record:', error); } } async function handleBulkDelete() { if (selectedRecords.size === 0) return; if (!confirm(`Delete ${selectedRecords.size} records?`)) return; try { for (const recordId of selectedRecords) { await ipcRenderer.invoke('backend:deleteRecord', backendId, selectedTable, recordId); } setSelectedRecords(new Set()); loadData(); // Refresh } catch (error) { console.error('Failed to bulk delete:', error); } } function handleExport() { // Export to CSV const csv = recordsToCSV(records, schema!); downloadCSV(csv, `${selectedTable}.csv`); } const totalPages = Math.ceil(totalCount / pageSize); return (
{selectedRecords.size > 0 && (
{selectedRecords.size} records selected
)}
{loading ? (
Loading...
) : schema ? ( { const newSelected = new Set(selectedRecords); if (selected) { newSelected.add(id); } else { newSelected.delete(id); } setSelectedRecords(newSelected); }} onSaveCell={handleSaveCell} onDeleteRecord={handleDeleteRecord} /> ) : (
Select a table to browse data
)}
Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, totalCount)} of {totalCount.toLocaleString()} records
Page {page + 1} of {totalPages}
{showNewRecord && schema && ( setShowNewRecord(false)} onSuccess={() => { setShowNewRecord(false); loadData(); }} /> )}
); } function recordsToCSV(records: any[], schema: TableSchema): string { const headers = schema.columns.map(c => c.name).join(','); const rows = records.map(record => schema.columns.map(col => { const value = record[col.name]; if (value === null || value === undefined) return ''; if (typeof value === 'object') return JSON.stringify(value); if (typeof value === 'string' && value.includes(',')) return `"${value}"`; return value; }).join(',') ); return [headers, ...rows].join('\n'); } function downloadCSV(csv: string, filename: string) { const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } ``` --- ### Step 2: Data Grid Component (5 hours) **File:** `packages/noodl-editor/src/editor/src/views/panels/databrowser/DataGrid.tsx` ```typescript import React, { useState } from 'react'; import { CellEditor } from './CellEditor'; import styles from './DataGrid.module.css'; interface DataGridProps { schema: TableSchema; records: any[]; selectedRecords: Set; onSelectRecord: (id: string, selected: boolean) => void; onSaveCell: (recordId: string, field: string, value: any) => Promise; onDeleteRecord: (recordId: string) => void; } export function DataGrid({ schema, records, selectedRecords, onSelectRecord, onSaveCell, onDeleteRecord }: DataGridProps) { const [editingCell, setEditingCell] = useState<{ recordId: string; field: string } | null>(null); // Show system fields + user fields const displayColumns = [ { name: 'objectId', type: 'String', readOnly: true }, { name: 'createdAt', type: 'Date', readOnly: true }, { name: 'updatedAt', type: 'Date', readOnly: true }, ...schema.columns ]; async function handleCellSave(recordId: string, field: string, value: any) { try { await onSaveCell(recordId, field, value); setEditingCell(null); } catch (error) { console.error('Failed to save cell:', error); alert('Failed to save changes'); } } function handleCellClick(recordId: string, field: string, readOnly: boolean) { if (readOnly) return; setEditingCell({ recordId, field }); } function formatCellValue(value: any, type: string): string { if (value === null || value === undefined) return ''; switch (type) { case 'Boolean': return value ? '✓' : ''; case 'Date': return new Date(value).toLocaleString(); case 'Object': case 'Array': return JSON.stringify(value); default: return String(value); } } return (
{displayColumns.map(col => ( ))} {records.map(record => ( {displayColumns.map(col => { const isEditing = editingCell?.recordId === record.objectId && editingCell?.field === col.name; const value = record[col.name]; const readOnly = col.readOnly || col.name === 'objectId'; return ( ); })} ))}
0} onChange={(e) => { records.forEach(r => onSelectRecord(r.objectId, e.target.checked)); }} />
{col.name} {col.type}
Actions
onSelectRecord(record.objectId, e.target.checked)} /> handleCellClick(record.objectId, col.name, readOnly)} > {isEditing ? ( handleCellSave(record.objectId, col.name, newValue)} onCancel={() => setEditingCell(null)} /> ) : (
{formatCellValue(value, col.type)}
)}
{records.length === 0 && (
No records found
)}
); } ``` --- ### Step 3: Cell Editor Component (4 hours) **File:** `packages/noodl-editor/src/editor/src/views/panels/databrowser/CellEditor.tsx` ```typescript import React, { useState, useEffect, useRef } from 'react'; import { TextInput } from '@noodl-core-ui/components/inputs/TextInput'; import { Checkbox } from '@noodl-core-ui/components/inputs/Checkbox'; import { DatePicker } from '@noodl-core-ui/components/inputs/DatePicker'; import styles from './CellEditor.module.css'; interface CellEditorProps { value: any; type: string; onSave: (value: any) => void; onCancel: () => void; } export function CellEditor({ value, type, onSave, onCancel }: CellEditorProps) { const [editValue, setEditValue] = useState(value); const [showJsonEditor, setShowJsonEditor] = useState(false); const inputRef = useRef(null); useEffect(() => { if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, []); function handleKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } else if (e.key === 'Escape') { onCancel(); } } function handleSave() { let finalValue = editValue; // Type conversion switch (type) { case 'Number': finalValue = parseFloat(editValue); if (isNaN(finalValue)) { alert('Invalid number'); return; } break; case 'Boolean': finalValue = Boolean(editValue); break; case 'Date': if (typeof editValue === 'string') { finalValue = new Date(editValue).toISOString(); } break; case 'Object': case 'Array': try { finalValue = JSON.parse(editValue); } catch (err) { alert('Invalid JSON'); return; } break; } onSave(finalValue); } // Type-specific editors switch (type) { case 'Boolean': return (
{ setEditValue(checked); onSave(checked); }} />
); case 'Date': return (
setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleSave} />
); case 'Object': case 'Array': return (
{showJsonEditor ? ( { setShowJsonEditor(false); onCancel(); }} /> ) : ( )}
); case 'Number': return (
setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleSave} />
); case 'String': default: return (
setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleSave} />
); } } ``` **File:** `packages/noodl-editor/src/editor/src/views/panels/databrowser/JsonEditorModal.tsx` ```typescript import React, { useState } from 'react'; import { Modal } from '@noodl-core-ui/components/modal'; import { PrimaryButton, SecondaryButton } from '@noodl-core-ui/components/buttons'; import styles from './JsonEditorModal.module.css'; interface JsonEditorModalProps { value: any; onSave: (value: any) => void; onCancel: () => void; } export function JsonEditorModal({ value, onSave, onCancel }: JsonEditorModalProps) { const [text, setText] = useState(JSON.stringify(value, null, 2)); const [error, setError] = useState(null); function handleSave() { try { const parsed = JSON.parse(text); onSave(parsed); } catch (err: any) { setError(err.message); } } return (