diff --git a/backend/app/Contracts/DatabaseDriverInterface.php b/backend/app/Contracts/DatabaseDriverInterface.php index 3b3c4f5..74445f3 100644 --- a/backend/app/Contracts/DatabaseDriverInterface.php +++ b/backend/app/Contracts/DatabaseDriverInterface.php @@ -48,4 +48,34 @@ interface DatabaseDriverInterface * Get database metadata (charset, collation, size, etc.) */ public function getDatabaseMetadata(string $database): array; + + /** + * Get table metadata. + */ + public function getTableMetadata(string $database, string $table): array; + + /** + * Get metadata for all tables in a database. + */ + public function getTablesMetadata(string $database): array; + + /** + * Truncate a table. + */ + public function truncateTable(string $table): bool; + + /** + * Drop a table. + */ + public function dropTable(string $table): bool; + + /** + * Optimize a table. + */ + public function optimizeTable(string $table): bool; + + /** + * Perform batch update on a table. + */ + public function batchUpdate(string $table, array $changes): bool; } diff --git a/backend/app/Http/Controllers/Api/SchemaController.php b/backend/app/Http/Controllers/Api/SchemaController.php index f477cd3..58aaa3b 100644 --- a/backend/app/Http/Controllers/Api/SchemaController.php +++ b/backend/app/Http/Controllers/Api/SchemaController.php @@ -248,4 +248,19 @@ class SchemaController extends Controller return Response::json(['error' => $e->getMessage()], 400); } } + + public function batchUpdate(Request $request, $table) + { + $request->validate([ + 'changes' => 'required|array', + ]); + + try { + $this->initializeDriver($request); + $this->databaseService->batchUpdate($table, $request->changes); + return Response::json(['message' => 'Batch update successful']); + } catch (\Exception $e) { + return Response::json(['error' => $e->getMessage()], 400); + } + } } diff --git a/backend/app/Services/Database/MySqlDriver.php b/backend/app/Services/Database/MySqlDriver.php index 00627b4..181ef1d 100644 --- a/backend/app/Services/Database/MySqlDriver.php +++ b/backend/app/Services/Database/MySqlDriver.php @@ -302,4 +302,41 @@ class MySqlDriver implements DatabaseDriverInterface, SchemaDiscoveryInterface return $this->query($sql, [$database]); } + + public function batchUpdate(string $table, array $changes): bool + { + $connection = $this->getConnection(); + + // Find primary key + $schema = $this->getTableSchema($table); + $primaryKey = 'id'; // default + foreach ($schema as $col) { + if (($col->Key ?? '') === 'PRI') { + $primaryKey = $col->Field; + break; + } + } + + $connection->beginTransaction(); + try { + foreach ($changes as $change) { + if (!isset($change[$primaryKey])) { + continue; + } + + $id = $change[$primaryKey]; + $updateData = $change; + unset($updateData[$primaryKey]); + + if (empty($updateData)) continue; + + $connection->table($table)->where($primaryKey, $id)->update($updateData); + } + $connection->commit(); + return true; + } catch (\Exception $e) { + $connection->rollBack(); + throw $e; + } + } } diff --git a/backend/app/Services/DatabaseService.php b/backend/app/Services/DatabaseService.php index ae534f1..0fe8edc 100644 --- a/backend/app/Services/DatabaseService.php +++ b/backend/app/Services/DatabaseService.php @@ -156,4 +156,12 @@ class DatabaseService { return $this->getDriver()->getTablesMetadata($database); } + + /** + * Perform batch update on a table. + */ + public function batchUpdate(string $table, array $changes): bool + { + return $this->getDriver()->batchUpdate($table, $changes); + } } diff --git a/backend/routes/api.php b/backend/routes/api.php index d9039e1..200e428 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -20,6 +20,7 @@ Route::prefix('schema')->group(function () { Route::post('/bulk-action', [SchemaController::class, 'bulkAction']); Route::get('/{table}', [SchemaController::class, 'schema']); Route::get('/{table}/data', [SchemaController::class, 'data']); + Route::post('/{table}/batch-update', [SchemaController::class, 'batchUpdate']); Route::post('/execute', [SchemaController::class, 'execute']); Route::post('/export', [SchemaController::class, 'export']); Route::post('/import', [SchemaController::class, 'import']); diff --git a/frontend/src/components/MainContent.tsx b/frontend/src/components/MainContent.tsx index 86d8076..5259962 100644 --- a/frontend/src/components/MainContent.tsx +++ b/frontend/src/components/MainContent.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Box, Paper, @@ -8,17 +8,21 @@ import { Alert, AlertTitle, Tabs, - Tab + Tab, + Button } from '@mui/material'; import { TableChart, Terminal, CloudDownload, CloudUpload, - Info + Info, + Save, + Undo } from '@mui/icons-material'; -import { DataGrid, type GridPaginationModel } from '@mui/x-data-grid'; +import { DataGrid, type GridPaginationModel, type GridRowModel } from '@mui/x-data-grid'; import { useAppStore } from '../store/useAppStore'; +import { SchemaService } from '../services/api'; import TransferContent from './TransferContent'; import DatabaseTablesGrid from './DatabaseTablesGrid'; import TechnicalOverview from './TechnicalOverview'; @@ -42,11 +46,180 @@ const MainContent: React.FC = () => { severity: 'error' }); - const { columns, loading: loadingSchema } = useTableSchema(activeTable, activeDatabase); - const { rows, rowCount, loading: loadingData } = useTableData(activeTable, activeDatabase, paginationModel); + const [pendingChanges, setPendingChanges] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + const [cellFocus, setCellFocus] = useState<{ id: string | number, field: string } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState<{ id: string | number, field: string } | null>(null); + const [dragCurrent, setDragCurrent] = useState<{ id: string | number, field: string } | null>(null); + + const { columns, loading: loadingSchema, primaryKey } = useTableSchema(activeTable, activeDatabase); + const { rows, rowCount, loading: loadingData, refetch } = useTableData(activeTable, activeDatabase, paginationModel); + + const handleRowUpdate = (newRow: GridRowModel, oldRow: GridRowModel) => { + const hasChanged = Object.keys(newRow).some(key => newRow[key] !== oldRow[key]); + if (hasChanged) { + const rowId = newRow[primaryKey]; + setPendingChanges(prev => ({ + ...prev, + [rowId]: { + ...(prev[rowId] || {}), + ...newRow + } + })); + } + return newRow; + }; + + const handleBatchSave = async () => { + if (!activeTable) return; + setIsSaving(true); + try { + const changes = Object.values(pendingChanges); + await SchemaService.batchUpdate(activeTable, changes); + setNotification({ + open: true, + title: 'Batch Update Success', + message: `Successfully updated ${changes.length} records.`, + severity: 'success' + }); + setPendingChanges({}); + refetch(); // Refresh data from server to be sure + } catch (error: any) { + setNotification({ + open: true, + title: 'Update Failed', + message: error.response?.data?.error || error.message, + severity: 'error' + }); + } finally { + setIsSaving(false); + } + }; + + const handlePaste = useCallback((event: React.ClipboardEvent) => { + if (!cellFocus || !activeTable) return; + + const pasteData = event.clipboardData.getData('text'); + const rows_data = pasteData.split(/\r?\n/).filter(line => line.length > 0).map(line => line.split('\t')); + + if (rows_data.length === 0) return; + + const updatedChanges = { ...pendingChanges }; + const focusedRowIndex = rows.findIndex(r => r[primaryKey] === cellFocus.id); + const focusedColIndex = columns.findIndex(c => c.field === cellFocus.field); + + if (focusedRowIndex === -1 || focusedColIndex === -1) return; + + rows_data.forEach((rowData, rIdx) => { + const targetRowIndex = focusedRowIndex + rIdx; + if (targetRowIndex >= rows.length) return; + + const targetRow = rows[targetRowIndex]; + const targetRowId = targetRow[primaryKey]; + + const newRowData = { ...(updatedChanges[targetRowId] || {}), [primaryKey]: targetRowId }; + + rowData.forEach((cellData, cIdx) => { + const targetColIndex = focusedColIndex + cIdx; + if (targetColIndex >= columns.length) return; + + const targetCol = columns[targetColIndex]; + if (targetCol.editable !== false && targetCol.field !== primaryKey) { + newRowData[targetCol.field] = cellData; + } + }); + + updatedChanges[targetRowId] = newRowData; + }); + + setPendingChanges(updatedChanges); + event.preventDefault(); + }, [cellFocus, rows, columns, primaryKey, pendingChanges, activeTable]); + + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + // Excel Ctrl+D support (Fill Down) + if (event.ctrlKey && event.key === 'd') { + if (!cellFocus || !activeTable) return; + + const focusedRowIndex = rows.findIndex(r => r[primaryKey] === cellFocus.id); + if (focusedRowIndex === -1 || focusedRowIndex + 1 >= rows.length) return; + + const valueToCopy = (pendingChanges[cellFocus.id]?.[cellFocus.field]) ?? rows[focusedRowIndex][cellFocus.field]; + const nextRow = rows[focusedRowIndex + 1]; + const nextRowId = nextRow[primaryKey]; + + setPendingChanges(prev => ({ + ...prev, + [nextRowId]: { + ...(prev[nextRowId] || {}), + [primaryKey]: nextRowId, + [cellFocus.field]: valueToCopy + } + })); + + event.preventDefault(); + } + }, [cellFocus, rows, pendingChanges, primaryKey, activeTable]); + + const handleCellMouseDown = useCallback((params: any, event: React.MouseEvent) => { + // Only start drag if clicking near the bottom-right corner (simulating fill handle) + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Check if click is in the bottom-right corner (approx 10x10px) + if (x > rect.width - 12 && y > rect.height - 12) { + setIsDragging(true); + setDragStart({ id: params.id, field: params.field }); + setDragCurrent({ id: params.id, field: params.field }); + event.stopPropagation(); + event.preventDefault(); + } + }, []); + + const handleCellMouseEnter = useCallback((params: any) => { + if (isDragging) { + setDragCurrent({ id: params.id, field: params.field }); + } + }, [isDragging]); + + const handleMouseUp = useCallback(() => { + if (isDragging && dragStart && dragCurrent && dragStart.field === dragCurrent.field) { + const startIndex = rows.findIndex(r => r[primaryKey] === dragStart.id); + const endIndex = rows.findIndex(r => r[primaryKey] === dragCurrent.id); + + if (startIndex !== -1 && endIndex !== -1) { + const start = Math.min(startIndex, endIndex); + const end = Math.max(startIndex, endIndex); + + const valueToFill = (pendingChanges[dragStart.id]?.[dragStart.field]) ?? rows[startIndex][dragStart.field]; + const updatedChanges = { ...pendingChanges }; + + for (let i = start; i <= end; i++) { + const targetRowId = rows[i][primaryKey]; + updatedChanges[targetRowId] = { + ...(updatedChanges[targetRowId] || {}), + [primaryKey]: targetRowId, + [dragStart.field]: valueToFill + }; + } + setPendingChanges(updatedChanges); + } + } + setIsDragging(false); + setDragStart(null); + setDragCurrent(null); + }, [isDragging, dragStart, dragCurrent, rows, primaryKey, pendingChanges]); + + useEffect(() => { + window.addEventListener('mouseup', handleMouseUp); + return () => window.removeEventListener('mouseup', handleMouseUp); + }, [handleMouseUp]); useEffect(() => { setPaginationModel({ page: 0, pageSize: 100 }); + setPendingChanges({}); }, [activeTable]); const handleCloseNotification = () => setNotification({ ...notification, open: false }); @@ -116,6 +289,33 @@ const MainContent: React.FC = () => { {rowCount} rows found + + {Object.keys(pendingChanges).length > 0 && ( + + + + + )} { ) : ( ({ + ...row, + ...(pendingChanges[row[primaryKey]] || {}) + }))} + getRowId={(row) => row[primaryKey]} columns={columns} rowCount={rowCount} loading={loadingData} @@ -143,14 +347,43 @@ const MainContent: React.FC = () => { paginationModel={paginationModel} onPaginationModelChange={setPaginationModel} pageSizeOptions={[25, 50, 100]} + processRowUpdate={handleRowUpdate} + onProcessRowUpdateError={(error) => { + console.error('Row update error:', error); + }} + onCellFocusIn={(params) => setCellFocus({ id: params.id, field: params.field })} + onCellMouseDown={handleCellMouseDown} + onCellMouseEnter={handleCellMouseEnter} + slotProps={{ + root: { + onPaste: handlePaste, + onKeyDown: handleKeyDown, + } + }} + density="compact" sx={{ border: 'none', + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 800, + fontSize: '0.75rem', + textTransform: 'uppercase', + letterSpacing: '0.5px' + }, + '& .MuiDataGrid-cell': { + fontSize: '0.8rem', + px: 1, + borderRight: '1px solid', + borderColor: 'divider', + }, + '& .MuiDataGrid-row': { + minHeight: '32px !important', + maxHeight: '32px !important', + }, '& .MuiDataGrid-row:nth-of-type(even)': { bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)', }, - '& .MuiDataGrid-cell': { - borderRight: '1px solid', - borderColor: 'divider', + '& .MuiDataGrid-cell--editable': { + bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0, 255, 0, 0.02)' : 'rgba(0, 255, 0, 0.01)', }, '& .MuiDataGrid-columnHeader': { borderRight: '1px solid', @@ -161,6 +394,37 @@ const MainContent: React.FC = () => { borderColor: 'divider', }, '& .MuiDataGrid-row:hover': { bgcolor: 'rgba(0, 97, 255, 0.04)' }, + '& .MuiDataGrid-cell:focus-within': { + outline: '2px solid #0061ff', + outlineOffset: '-2px', + position: 'relative', + '&::after': { + content: '""', + position: 'absolute', + bottom: -1, + right: -1, + width: 8, + height: 8, + bgcolor: '#0061ff', + border: '1px solid white', + cursor: 'crosshair', + zIndex: 10, + } + }, + '& .MuiDataGrid-cell--dragging': { + bgcolor: 'rgba(0, 97, 255, 0.08) !important', + } + }} + getCellClassName={(params) => { + if (isDragging && dragStart && dragCurrent && params.field === dragStart.field) { + const startIndex = rows.findIndex(r => r[primaryKey] === dragStart.id); + const endIndex = rows.findIndex(r => r[primaryKey] === dragCurrent.id); + const currentIndex = rows.findIndex(r => r[primaryKey] === params.id); + if (currentIndex >= Math.min(startIndex, endIndex) && currentIndex <= Math.max(startIndex, endIndex)) { + return 'MuiDataGrid-cell--dragging'; + } + } + return ''; }} /> )} diff --git a/frontend/src/hooks/useTableSchema.ts b/frontend/src/hooks/useTableSchema.ts index 7a82c71..b2f8cdd 100644 --- a/frontend/src/hooks/useTableSchema.ts +++ b/frontend/src/hooks/useTableSchema.ts @@ -14,6 +14,7 @@ const mapSqlTypeToMuiType = (sqlType: string): 'string' | 'number' | 'date' | 'd export const useTableSchema = (activeTable: string | null, activeDatabase: string | null) => { const [columns, setColumns] = useState([]); const [loading, setLoading] = useState(false); + const [primaryKey, setPrimaryKey] = useState('id'); useEffect(() => { const fetchSchema = async () => { @@ -22,7 +23,9 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin setLoading(true); try { const schemaRes = await SchemaService.getTableSchema(activeTable, activeDatabase); + let pk = 'id'; const cols: GridColDef[] = schemaRes.data.map((col: any) => { + if (col.Key === 'PRI') pk = col.Field; const type = mapSqlTypeToMuiType(col.Type); return { field: col.Field, @@ -30,6 +33,7 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin type: type, width: 200, minWidth: 100, + editable: col.Key !== 'PRI', // Enable editing if not primary key valueGetter: (value: any) => { if ((type === 'date' || type === 'dateTime') && value && typeof value === 'string') { return new Date(value); @@ -39,6 +43,7 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin }; }); setColumns(cols); + setPrimaryKey(pk); } catch (error) { console.error('Failed to fetch table schema', error); } finally { @@ -49,5 +54,5 @@ export const useTableSchema = (activeTable: string | null, activeDatabase: strin fetchSchema(); }, [activeTable, activeDatabase]); - return { columns, loading }; + return { columns, loading, primaryKey }; }; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 7d30c40..77f8d67 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -34,6 +34,7 @@ export const SchemaService = { dropTable: (table: string, database?: string) => api.post(`/schema/drop/${table}`, { database }), optimizeTable: (table: string, database?: string) => api.post(`/schema/optimize/${table}`, { database }), bulkAction: (data: { tables: string[], action: string, database: string }) => api.post('/schema/bulk-action', data), + batchUpdate: (table: string, changes: any[]) => api.post(`/schema/${table}/batch-update`, { changes }), executeQuery: (query: string) => api.post('/schema/execute', { query }), exportDatabase: (database?: string, table?: string) => api.post('/schema/export', { database, table }, { responseType: 'blob' }), importDatabase: (formData: FormData) => api.post('/schema/import', formData, {