feat: implement database schema discovery and management services with frontend integration
This commit is contained in:
@@ -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<Record<string, any>>({});
|
||||
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 = () => {
|
||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>{rowCount} rows found</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{Object.keys(pendingChanges).length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="small"
|
||||
startIcon={<Save />}
|
||||
onClick={handleBatchSave}
|
||||
disabled={isSaving}
|
||||
sx={{ borderRadius: 2, fontWeight: 700 }}
|
||||
>
|
||||
{isSaving ? <CircularProgress size={20} color="inherit" /> : `Save ${Object.keys(pendingChanges).length} Changes`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
startIcon={<Undo />}
|
||||
onClick={() => setPendingChanges({})}
|
||||
disabled={isSaving}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Paper sx={{
|
||||
@@ -135,7 +335,11 @@ const MainContent: React.FC = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
rows={rows.map(row => ({
|
||||
...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 '';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<GridColDef[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [primaryKey, setPrimaryKey] = useState<string>('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 };
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user