feat: implement interactive data grid with batch editing, fill handle, and clipboard support in MainContent component
This commit is contained in:
@@ -51,8 +51,11 @@ const MainContent: React.FC = () => {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [cellFocus, setCellFocus] = useState<{ id: string | number, field: string } | null>(null);
|
const [cellFocus, setCellFocus] = useState<{ id: string | number, field: string } | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragType, setDragType] = useState<'fill' | 'select' | null>(null);
|
||||||
const [dragStart, setDragStart] = useState<{ id: string | number, field: string } | null>(null);
|
const [dragStart, setDragStart] = useState<{ id: string | number, field: string } | null>(null);
|
||||||
const [dragCurrent, setDragCurrent] = useState<{ id: string | number, field: string } | null>(null);
|
const [dragCurrent, setDragCurrent] = useState<{ id: string | number, field: string } | null>(null);
|
||||||
|
const [selectionStart, setSelectionStart] = useState<{ id: string | number, field: string } | null>(null);
|
||||||
|
const [selectionEnd, setSelectionEnd] = useState<{ id: string | number, field: string } | null>(null);
|
||||||
|
|
||||||
const { columns, loading: loadingSchema, primaryKey } = useTableSchema(activeTable, activeDatabase);
|
const { columns, loading: loadingSchema, primaryKey } = useTableSchema(activeTable, activeDatabase);
|
||||||
const { rows, rowCount, loading: loadingData, refetch } = useTableData(activeTable, activeDatabase, paginationModel);
|
const { rows, rowCount, loading: loadingData, refetch } = useTableData(activeTable, activeDatabase, paginationModel);
|
||||||
@@ -161,57 +164,128 @@ const MainContent: React.FC = () => {
|
|||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}, [cellFocus, rows, pendingChanges, primaryKey, activeTable]);
|
|
||||||
|
// Delete key to clear selection or focused cell
|
||||||
|
if (event.key === 'Delete') {
|
||||||
|
const updatedChanges = { ...pendingChanges };
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (selectionStart && selectionEnd) {
|
||||||
|
const startRowIdx = rows.findIndex(r => r[primaryKey] === selectionStart.id);
|
||||||
|
const endRowIdx = rows.findIndex(r => r[primaryKey] === selectionEnd.id);
|
||||||
|
const startColIdx = columns.findIndex(c => c.field === selectionStart.field);
|
||||||
|
const endColIdx = columns.findIndex(c => c.field === selectionEnd.field);
|
||||||
|
|
||||||
|
if (startRowIdx !== -1 && endRowIdx !== -1 && startColIdx !== -1 && endColIdx !== -1) {
|
||||||
|
const rStart = Math.min(startRowIdx, endRowIdx);
|
||||||
|
const rEnd = Math.max(startRowIdx, endRowIdx);
|
||||||
|
const cStart = Math.min(startColIdx, endColIdx);
|
||||||
|
const cEnd = Math.max(startColIdx, endColIdx);
|
||||||
|
|
||||||
|
for (let r = rStart; r <= rEnd; r++) {
|
||||||
|
const rowId = rows[r][primaryKey];
|
||||||
|
for (let c = cStart; c <= cEnd; c++) {
|
||||||
|
const field = columns[c].field;
|
||||||
|
if (columns[c].editable !== false && field !== primaryKey) {
|
||||||
|
updatedChanges[rowId] = {
|
||||||
|
...(updatedChanges[rowId] || {}),
|
||||||
|
[primaryKey]: rowId,
|
||||||
|
[field]: ''
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (cellFocus) {
|
||||||
|
const col = columns.find(c => c.field === cellFocus.field);
|
||||||
|
if (col && col.editable !== false && col.field !== primaryKey) {
|
||||||
|
updatedChanges[cellFocus.id] = {
|
||||||
|
...(updatedChanges[cellFocus.id] || {}),
|
||||||
|
[primaryKey]: cellFocus.id,
|
||||||
|
[cellFocus.field]: ''
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setPendingChanges(updatedChanges);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [cellFocus, selectionStart, selectionEnd, rows, columns, pendingChanges, primaryKey, activeTable]);
|
||||||
|
|
||||||
const handleCellMouseDown = useCallback((params: any, event: React.MouseEvent) => {
|
const handleCellMouseDown = useCallback((params: any, event: React.MouseEvent) => {
|
||||||
// Only start drag if clicking near the bottom-right corner (simulating fill handle)
|
const cellElement = (event.target as HTMLElement).closest('.MuiDataGrid-cell');
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
if (!cellElement) return;
|
||||||
|
|
||||||
|
const rect = cellElement.getBoundingClientRect();
|
||||||
const x = event.clientX - rect.left;
|
const x = event.clientX - rect.left;
|
||||||
const y = event.clientY - rect.top;
|
const y = event.clientY - rect.top;
|
||||||
|
|
||||||
// Check if click is in the bottom-right corner (approx 10x10px)
|
// Check if click is in the bottom-right corner (approx 12x12px fill handle)
|
||||||
if (x > rect.width - 12 && y > rect.height - 12) {
|
if (x > rect.width - 15 && y > rect.height - 15) {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
setDragType('fill');
|
||||||
setDragStart({ id: params.id, field: params.field });
|
setDragStart({ id: params.id, field: params.field });
|
||||||
setDragCurrent({ id: params.id, field: params.field });
|
setDragCurrent({ id: params.id, field: params.field });
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
} else {
|
||||||
|
// Start range selection
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragType('select');
|
||||||
|
setSelectionStart({ id: params.id, field: params.field });
|
||||||
|
setSelectionEnd({ id: params.id, field: params.field });
|
||||||
|
// Reset dragCurrent to avoid visual confusion
|
||||||
|
setDragCurrent(null);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCellMouseEnter = useCallback((params: any) => {
|
const handleCellMouseEnter = useCallback((params: any) => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
setDragCurrent({ id: params.id, field: params.field });
|
if (dragType === 'fill') {
|
||||||
}
|
setDragCurrent({ id: params.id, field: params.field });
|
||||||
}, [isDragging]);
|
} else if (dragType === 'select') {
|
||||||
|
setSelectionEnd({ id: params.id, field: params.field });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, [isDragging, dragType]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
if (dragType === 'fill' && dragStart && dragCurrent) {
|
||||||
|
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];
|
||||||
|
const targetCol = columns.find(c => c.field === dragStart.field);
|
||||||
|
if (targetCol && targetCol.editable !== false && targetCol.field !== primaryKey) {
|
||||||
|
updatedChanges[targetRowId] = {
|
||||||
|
...(updatedChanges[targetRowId] || {}),
|
||||||
|
[primaryKey]: targetRowId,
|
||||||
|
[dragStart.field]: valueToFill
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPendingChanges(updatedChanges);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Selection stays until another click or clear
|
||||||
|
}
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setDragType(null);
|
||||||
setDragStart(null);
|
setDragStart(null);
|
||||||
setDragCurrent(null);
|
setDragCurrent(null);
|
||||||
}, [isDragging, dragStart, dragCurrent, rows, primaryKey, pendingChanges]);
|
}, [isDragging, dragType, dragStart, dragCurrent, rows, columns, primaryKey, pendingChanges]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
@@ -225,6 +299,11 @@ const MainContent: React.FC = () => {
|
|||||||
const unsubs = [
|
const unsubs = [
|
||||||
api.subscribeEvent('cellFocusIn', (params) => {
|
api.subscribeEvent('cellFocusIn', (params) => {
|
||||||
setCellFocus({ id: params.id, field: params.field });
|
setCellFocus({ id: params.id, field: params.field });
|
||||||
|
// Single click should reset selection unless dragging
|
||||||
|
if (!isDragging) {
|
||||||
|
setSelectionStart(null);
|
||||||
|
setSelectionEnd(null);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
api.subscribeEvent('cellMouseDown', handleCellMouseDown),
|
api.subscribeEvent('cellMouseDown', handleCellMouseDown),
|
||||||
api.subscribeEvent('cellMouseOver', handleCellMouseEnter)
|
api.subscribeEvent('cellMouseOver', handleCellMouseEnter)
|
||||||
@@ -236,6 +315,8 @@ const MainContent: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPaginationModel({ page: 0, pageSize: 100 });
|
setPaginationModel({ page: 0, pageSize: 100 });
|
||||||
setPendingChanges({});
|
setPendingChanges({});
|
||||||
|
setSelectionStart(null);
|
||||||
|
setSelectionEnd(null);
|
||||||
}, [activeTable]);
|
}, [activeTable]);
|
||||||
|
|
||||||
const handleCloseNotification = () => setNotification({ ...notification, open: false });
|
const handleCloseNotification = () => setNotification({ ...notification, open: false });
|
||||||
@@ -426,19 +507,50 @@ const MainContent: React.FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'& .MuiDataGrid-cell--dragging': {
|
'& .MuiDataGrid-cell--dragging': {
|
||||||
|
bgcolor: 'rgba(0, 97, 255, 0.15) !important',
|
||||||
|
border: '1px dashed #0061ff !important',
|
||||||
|
},
|
||||||
|
'& .MuiDataGrid-cell--selected': {
|
||||||
bgcolor: 'rgba(0, 97, 255, 0.08) !important',
|
bgcolor: 'rgba(0, 97, 255, 0.08) !important',
|
||||||
|
border: '1px double rgba(0, 97, 255, 0.3) !important',
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
getCellClassName={(params) => {
|
getCellClassName={(params) => {
|
||||||
if (isDragging && dragStart && dragCurrent && params.field === dragStart.field) {
|
const classNames: string[] = [];
|
||||||
|
|
||||||
|
// Filling preview
|
||||||
|
if (isDragging && dragType === 'fill' && dragStart && dragCurrent && params.field === dragStart.field) {
|
||||||
const startIndex = rows.findIndex(r => r[primaryKey] === dragStart.id);
|
const startIndex = rows.findIndex(r => r[primaryKey] === dragStart.id);
|
||||||
const endIndex = rows.findIndex(r => r[primaryKey] === dragCurrent.id);
|
const endIndex = rows.findIndex(r => r[primaryKey] === dragCurrent.id);
|
||||||
const currentIndex = rows.findIndex(r => r[primaryKey] === params.id);
|
const currentIndex = rows.findIndex(r => r[primaryKey] === params.id);
|
||||||
if (currentIndex >= Math.min(startIndex, endIndex) && currentIndex <= Math.max(startIndex, endIndex)) {
|
if (currentIndex >= Math.min(startIndex, endIndex) && currentIndex <= Math.max(startIndex, endIndex)) {
|
||||||
return 'MuiDataGrid-cell--dragging';
|
classNames.push('MuiDataGrid-cell--dragging');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
|
||||||
|
// Selection range
|
||||||
|
if (selectionStart && selectionEnd) {
|
||||||
|
const startRowIdx = rows.findIndex(r => r[primaryKey] === selectionStart.id);
|
||||||
|
const endRowIdx = rows.findIndex(r => r[primaryKey] === selectionEnd.id);
|
||||||
|
const startColIdx = columns.findIndex(c => c.field === selectionStart.field);
|
||||||
|
const endColIdx = columns.findIndex(c => c.field === selectionEnd.field);
|
||||||
|
|
||||||
|
const currentRowIdx = rows.findIndex(r => r[primaryKey] === params.id);
|
||||||
|
const currentColIdx = columns.findIndex(c => c.field === params.field);
|
||||||
|
|
||||||
|
if (currentRowIdx !== -1 && endRowIdx !== -1 && startColIdx !== -1 && endColIdx !== -1) {
|
||||||
|
if (
|
||||||
|
currentRowIdx >= Math.min(startRowIdx, endRowIdx) &&
|
||||||
|
currentRowIdx <= Math.max(startRowIdx, endRowIdx) &&
|
||||||
|
currentColIdx >= Math.min(startColIdx, endColIdx) &&
|
||||||
|
currentColIdx <= Math.max(startColIdx, endColIdx)
|
||||||
|
) {
|
||||||
|
classNames.push('MuiDataGrid-cell--selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return classNames.join(' ');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user