diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx
index df5f59d7..2d8b16a2 100644
--- a/frontend/src/components/DataGrid.ddl.test.tsx
+++ b/frontend/src/components/DataGrid.ddl.test.tsx
@@ -6,10 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DataGrid, {
attachDataGridVirtualEditRenderVersion,
buildDataGridCommitChangeSet,
+ collectDataGridCellSelectionRowKeys,
GONAVI_ROW_KEY,
hasDataGridVirtualEditRenderVersionChanged,
} from './DataGrid';
import { resetDataGridDdlViewSharedStateForTests } from './useDataGridDdlView';
+import DataGridPageFind from './DataGridPageFind';
import DataGridToolbarFrame from './DataGridToolbarFrame';
import { V2CellContextMenuView, V2ColumnHeaderContextMenuView, V2TableGroupContextMenuView } from './V2TableContextMenu';
import { setCurrentLanguage, t } from '../i18n';
@@ -512,6 +514,21 @@ const commitColumnGuard = (columnName: string) => (
columnName !== GONAVI_ROW_KEY && columnName !== ORACLE_ROWID_LOCATOR_COLUMN
);
+describe('DataGrid cell selection row keys', () => {
+ it('deduplicates every record covered by a rectangular cell selection', () => {
+ const cellKeys = Array.from({ length: 6 }, (_, rowIndex) => (
+ ['id', 'user_id', 'app_key'].map((columnName) => `row-${rowIndex + 1}\u0001${columnName}`)
+ )).flat();
+
+ expect(collectDataGridCellSelectionRowKeys([
+ ...cellKeys,
+ 'row-3\u0001id',
+ 'malformed-cell-key',
+ '\u0001empty-row-key',
+ ])).toEqual(['row-1', 'row-2', 'row-3', 'row-4', 'row-5', 'row-6']);
+ });
+});
+
describe('DataGrid commit change set', () => {
it('uses unique locator values instead of falling back to the whole row', () => {
const result = buildDataGridCommitChangeSet({
@@ -1136,6 +1153,250 @@ describe('DataGrid DDL interactions', () => {
renderer!.unmount();
});
+ it('deletes every record represented by a cell-only column selection', async () => {
+ messageApi.info.mockResolvedValue(undefined);
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ await waitForEffects();
+
+ await act(async () => {
+ renderer!.root.findByType(DataGridToolbarFrame).props.onToggleCellEditMode();
+ });
+ await waitForEffects();
+
+ const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name');
+ expect(nameColumn?.editable).toBe(true);
+ const headerProps = nameColumn.onHeaderCell(nameColumn);
+ await act(async () => {
+ headerProps.onClickCapture({
+ target: { closest: vi.fn(() => null) },
+ currentTarget: { querySelector: vi.fn(() => null) },
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ });
+ });
+
+ let toolbar = renderer!.root.findByType(DataGridToolbarFrame);
+ expect(toolbar.props.selectedRowKeysLength).toBe(0);
+ expect(toolbar.props.selectedCellsSize).toBe(2);
+ expect(toolbar.props.deleteTargetRowCount).toBe(2);
+ expect(findButton(renderer!, t('data_grid.toolbar.delete_selected')).props.disabled).toBeFalsy();
+
+ await act(async () => {
+ toolbar.props.onRefresh();
+ });
+ await waitForEffects();
+
+ toolbar = renderer!.root.findByType(DataGridToolbarFrame);
+ expect(toolbar.props.selectedCellsSize).toBe(0);
+ expect(toolbar.props.deleteTargetRowCount).toBe(0);
+ expect(findButton(renderer!, t('data_grid.toolbar.delete_selected')).props.disabled).toBe(true);
+
+ await act(async () => {
+ headerProps.onClickCapture({
+ target: { closest: vi.fn(() => null) },
+ currentTarget: { querySelector: vi.fn(() => null) },
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ });
+ });
+
+ await act(async () => {
+ findButton(renderer!, t('data_grid.toolbar.delete_selected')).props.onClick();
+ });
+ await waitForEffects();
+
+ toolbar = renderer!.root.findByType(DataGridToolbarFrame);
+ expect(toolbar.props.pendingChangeCount).toBe(2);
+ expect(toolbar.props.selectedCellsSize).toBe(0);
+ expect(toolbar.props.deleteTargetRowCount).toBe(0);
+ expect(findButton(renderer!, t('data_grid.toolbar.delete_selected')).props.disabled).toBe(true);
+ expect(
+ testRenderState.latestTableProps.dataSource.map((row: Record) => (
+ testRenderState.latestTableProps.rowClassName(row)
+ )),
+ ).toEqual(['row-deleted', 'row-deleted']);
+ renderer!.unmount();
+ });
+
+ it('does not treat page-find highlighting as a deletable cell selection', async () => {
+ messageApi.info.mockResolvedValue(undefined);
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ await waitForEffects();
+
+ await act(async () => {
+ renderer!.root.findByType(DataGridToolbarFrame).props.onToggleCellEditMode();
+ });
+ await act(async () => {
+ renderer!.root.findByType(DataGridPageFind).props.onPageFindTextChange('Ada');
+ });
+ await waitForEffects();
+
+ const pageFind = renderer!.root.findByType(DataGridPageFind);
+ expect(pageFind.props.matchCount).toBeGreaterThan(0);
+ await act(async () => {
+ pageFind.props.onNavigateNext();
+ });
+ await waitForEffects();
+
+ const toolbar = renderer!.root.findByType(DataGridToolbarFrame);
+ expect(toolbar.props.cellEditMode).toBe(true);
+ expect(toolbar.props.selectedCellsSize).toBe(1);
+ expect(toolbar.props.deleteTargetRowCount).toBe(0);
+ expect(findButton(renderer!, t('data_grid.toolbar.delete_selected')).props.disabled).toBe(true);
+ renderer!.unmount();
+ });
+
+ it('keeps checkbox-selected rows as the delete target when a cell selection also exists', async () => {
+ messageApi.info.mockResolvedValue(undefined);
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ await waitForEffects();
+
+ await act(async () => {
+ renderer!.root.findByType(DataGridToolbarFrame).props.onToggleCellEditMode();
+ });
+ const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name');
+ const headerProps = nameColumn.onHeaderCell(nameColumn);
+ await act(async () => {
+ headerProps.onClickCapture({
+ target: { closest: vi.fn(() => null) },
+ currentTarget: { querySelector: vi.fn(() => null) },
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ });
+ testRenderState.latestTableProps.rowSelection.onChange(['row-1']);
+ });
+ await waitForEffects();
+
+ let toolbar = renderer!.root.findByType(DataGridToolbarFrame);
+ expect(toolbar.props.selectedCellsSize).toBe(2);
+ expect(toolbar.props.selectedRowKeysLength).toBe(1);
+ expect(toolbar.props.deleteTargetRowCount).toBe(1);
+
+ await act(async () => {
+ toolbar.props.onDeleteSelected();
+ });
+ await waitForEffects();
+
+ toolbar = renderer!.root.findByType(DataGridToolbarFrame);
+ expect(toolbar.props.pendingChangeCount).toBe(1);
+ expect(toolbar.props.selectedCellsSize).toBe(0);
+ expect(
+ testRenderState.latestTableProps.dataSource.map((row: Record) => (
+ testRenderState.latestTableProps.rowClassName(row)
+ )),
+ ).toEqual(['row-deleted', '']);
+ renderer!.unmount();
+ });
+
+ it('removes a newly added record selected only through its cells without leaving a pending delete', async () => {
+ messageApi.info.mockResolvedValue(undefined);
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ await waitForEffects();
+
+ await act(async () => {
+ renderer!.root.findByType(DataGridToolbarFrame).props.onAddRow();
+ });
+ await waitForEffects();
+ expect(testRenderState.latestTableProps.dataSource).toHaveLength(1);
+ expect(renderer!.root.findByType(DataGridToolbarFrame).props.pendingChangeCount).toBe(1);
+
+ await act(async () => {
+ renderer!.root.findByType(DataGridToolbarFrame).props.onToggleCellEditMode();
+ });
+ await waitForEffects();
+
+ const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name');
+ expect(nameColumn?.editable).toBe(true);
+ const headerProps = nameColumn.onHeaderCell(nameColumn);
+ await act(async () => {
+ headerProps.onClickCapture({
+ target: { closest: vi.fn(() => null) },
+ currentTarget: { querySelector: vi.fn(() => null) },
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ });
+ });
+
+ let toolbar = renderer!.root.findByType(DataGridToolbarFrame);
+ expect(toolbar.props.selectedRowKeysLength).toBe(0);
+ expect(toolbar.props.selectedCellsSize).toBe(1);
+ expect(toolbar.props.deleteTargetRowCount).toBe(1);
+
+ await act(async () => {
+ findButton(renderer!, t('data_grid.toolbar.delete_selected')).props.onClick();
+ });
+ await waitForEffects();
+
+ toolbar = renderer!.root.findByType(DataGridToolbarFrame);
+ expect(testRenderState.latestTableProps.dataSource).toHaveLength(0);
+ expect(toolbar.props.pendingChangeCount).toBe(0);
+ expect(toolbar.props.selectedCellsSize).toBe(0);
+ expect(toolbar.props.deleteTargetRowCount).toBe(0);
+ expect(findButton(renderer!, t('data_grid.toolbar.delete_selected')).props.disabled).toBe(true);
+ renderer!.unmount();
+ });
+
it('allows sorter arrow clicks through while cell edit mode is active', async () => {
messageApi.info.mockResolvedValue(undefined);
const onSort = vi.fn();
diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx
index 9b06793a..fd85f322 100644
--- a/frontend/src/components/DataGrid.layout.test.tsx
+++ b/frontend/src/components/DataGrid.layout.test.tsx
@@ -1299,7 +1299,7 @@ describe('DataGrid layout', () => {
expect(toolbarFrameSource).toContain(`translate('${key}`);
});
[
- /translate\('data_grid\.toolbar\.selected_count', \{ count: selectedRowKeysLength \}\)/,
+ /translate\('data_grid\.toolbar\.selected_count', \{ count: deleteTargetRowCount \}\)/,
/translate\('data_grid\.toolbar\.copy_selection', \{ count: selectedCellsSize \}\)/,
/translate\('data_grid\.toolbar\.copy_selection_columns', \{ count: selectedCellsSize \}\)/,
/translate\('data_grid\.toolbar\.batch_fill', \{ count: selectedCellsSize \}\)/,
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx
index 05336174..d3d176cb 100644
--- a/frontend/src/components/DataGrid.tsx
+++ b/frontend/src/components/DataGrid.tsx
@@ -190,6 +190,7 @@ import {
useDataGridI18nLanguage,
makeCellKey,
splitCellKey,
+ collectDataGridCellSelectionRowKeys,
resolveContextMenuFieldName,
trimSimpleCache,
looksLikeDateTimeText,
@@ -289,6 +290,7 @@ export {
resolveNextGridFilterOperatorForColumnChange,
buildGridFieldSelectOptions,
buildDataGridCommitChangeSet,
+ collectDataGridCellSelectionRowKeys,
} from './DataGridCore';
// Native scroll events can outlive a pointer gesture on macOS. Wait for a brief
@@ -875,6 +877,8 @@ const DataGrid: React.FC = ({
// 批量编辑模式状态
const [cellEditMode, setCellEditMode] = useState(false);
const [selectedCells, setSelectedCells] = useState>(new Set());
+ const [cellSelectionDeleteEligible, setCellSelectionDeleteEligible] = useState(false);
+ const cellSelectionSourceDataRef = useRef- (null);
const [copiedCellPatch, setCopiedCellPatch] = useState<{ sourceRowKey: string; values: Record } | null>(null);
const [copiedRowsForPaste, setCopiedRowsForPaste] = useState>>([]);
@@ -1664,10 +1668,16 @@ const DataGrid: React.FC = ({
});
}, []);
+ const markCellSelectionDeleteEligible = useCallback((eligible: boolean) => {
+ cellSelectionSourceDataRef.current = eligible ? data : null;
+ setCellSelectionDeleteEligible(eligible);
+ }, [data]);
+
const resetCellSelection = useCallback((clearState: boolean = true) => {
if (clearState) {
setSelectedCells(new Set());
}
+ markCellSelectionDeleteEligible(false);
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
pendingCellSelectionStartRef.current = null;
@@ -1686,7 +1696,7 @@ const DataGrid: React.FC = ({
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
- }, [updateCellSelection]);
+ }, [markCellSelectionDeleteEligible, updateCellSelection]);
const closeCellEditMode = useCallback(() => {
setCellEditMode(false);
@@ -1713,8 +1723,17 @@ const DataGrid: React.FC = ({
});
currentSelectionRef.current = nextSelection;
setSelectedCells(nextSelection);
+ markCellSelectionDeleteEligible(true);
updateCellSelection(nextSelection);
- }, [canModifyData, effectiveEditLocator, makeCellKey, resetCellSelection, rowKeyStr, updateCellSelection]);
+ }, [canModifyData, effectiveEditLocator, makeCellKey, markCellSelectionDeleteEligible, resetCellSelection, rowKeyStr, updateCellSelection]);
+
+ const previousSelectionSourceDataRef = useRef(data);
+ useEffect(() => {
+ if (previousSelectionSourceDataRef.current === data) return;
+ previousSelectionSourceDataRef.current = data;
+ setSelectedRowKeys([]);
+ resetCellSelection();
+ }, [data, resetCellSelection]);
useEffect(() => {
closeCellEditModeRef.current = closeCellEditMode;
@@ -1766,6 +1785,7 @@ const DataGrid: React.FC = ({
setCopiedCellPatch,
setModifiedRows,
setSelectedCells,
+ markCellSelectionDeleteEligible,
splitCellKey,
suppressCellSelectionClickRef,
translateDataGrid,
@@ -1812,10 +1832,25 @@ const DataGrid: React.FC = ({
setAutoCommitRemainingSeconds(null);
}, []);
+ const selectedCellRowKeys = useMemo(
+ () => cellEditMode
+ && cellSelectionDeleteEligible
+ && cellSelectionSourceDataRef.current === data
+ ? collectDataGridCellSelectionRowKeys(selectedCells)
+ : [],
+ [cellEditMode, cellSelectionDeleteEligible, data, selectedCells],
+ );
+ const deleteTargetRowKeys = useMemo(
+ () => selectedRowKeys.length > 0
+ ? selectedRowKeys.map(key => rowKeyStr(key))
+ : selectedCellRowKeys,
+ [rowKeyStr, selectedCellRowKeys, selectedRowKeys],
+ );
+ const deleteTargetRowCount = deleteTargetRowKeys.length;
const allSelectedAreDeleted = useMemo(() => {
- if (selectedRowKeys.length === 0) return false;
- return selectedRowKeys.every(key => deletedRowKeys.has(rowKeyStr(key)));
- }, [selectedRowKeys, deletedRowKeys, rowKeyStr]);
+ if (deleteTargetRowKeys.length === 0) return false;
+ return deleteTargetRowKeys.every(key => deletedRowKeys.has(key));
+ }, [deleteTargetRowKeys, deletedRowKeys]);
const addedRowKeySet = useMemo(() => {
const next = new Set();
@@ -2136,6 +2171,7 @@ const DataGrid: React.FC = ({
setDeletedRowKeys(new Set());
setModifiedColumns({});
setSelectedRowKeys([]);
+ resetCellSelection();
setCopiedCellPatch(null);
setCopiedRowsForPaste([]);
closeRowEditor();
@@ -2159,6 +2195,7 @@ const DataGrid: React.FC = ({
currentConnConfig,
dataSourceContextKey,
isV2Ui,
+ resetCellSelection,
resetDdlViewState,
resolvedDdlTableName,
viewMode,
@@ -3055,8 +3092,7 @@ const DataGrid: React.FC = ({
const handleDeleteSelected = () => {
const addedKeysToRemove: string[] = [];
const baseKeysToDelete: string[] = [];
- for (const key of selectedRowKeys) {
- const keyStr = rowKeyStr(key);
+ for (const keyStr of deleteTargetRowKeys) {
if (addedRowKeySet.has(keyStr)) {
addedKeysToRemove.push(keyStr);
} else if (!deletedRowKeys.has(keyStr)) {
@@ -3079,15 +3115,17 @@ const DataGrid: React.FC = ({
});
}
setSelectedRowKeys([]);
+ if (cellEditMode) resetCellSelection();
};
const handleUndoDeleteSelected = () => {
setDeletedRowKeys(prev => {
const newDeleted = new Set(prev);
- selectedRowKeys.forEach(key => newDeleted.delete(rowKeyStr(key)));
+ deleteTargetRowKeys.forEach(key => newDeleted.delete(key));
return newDeleted;
});
setSelectedRowKeys([]);
+ if (cellEditMode) resetCellSelection();
};
const handlePreviewChanges = useCallback(async () => {
@@ -3914,6 +3952,7 @@ const DataGrid: React.FC = ({
const focusPageFindMatch = useCallback((match: DataGridFindMatch) => {
if (!match) return;
const nextSelection = new Set([makeCellKey(match.rowKey, match.columnName)]);
+ markCellSelectionDeleteEligible(false);
setSelectedCells(nextSelection);
currentSelectionRef.current = nextSelection;
selectionStartRef.current = {
@@ -4003,7 +4042,7 @@ const DataGrid: React.FC = ({
applyVisibleFocus();
});
});
- }, [applyVirtualHorizontalOffset, enableVirtual, mergedDisplayData, pickVerticalScrollTarget, readVirtualHorizontalOffset, rowKeyStr, updateCellSelection, updateFocusedCell]);
+ }, [applyVirtualHorizontalOffset, enableVirtual, markCellSelectionDeleteEligible, mergedDisplayData, pickVerticalScrollTarget, readVirtualHorizontalOffset, rowKeyStr, updateCellSelection, updateFocusedCell]);
const handleNavigatePageFind = useCallback((direction: DataGridFindNavigationDirection) => {
const nextIndex = resolveDataGridFindNavigationIndex(activePageFindMatchIndex, pageFindMatches.length, direction);
@@ -4952,6 +4991,7 @@ const DataGrid: React.FC = ({
dataContextValue,
dataEditAutoCommitDelayMs,
dataEditCommitMode,
+ deleteTargetRowCount,
dataPanelDirtyRef,
dataPanelIsJson,
dataPanelOpen,
diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx
index 347d462c..cce0e9df 100644
--- a/frontend/src/components/DataGridCore.tsx
+++ b/frontend/src/components/DataGridCore.tsx
@@ -241,6 +241,15 @@ const splitCellKey = (cellKey: string): { rowKey: string; colName: string } | nu
colName: cellKey.slice(sepIndex + CELL_KEY_SEP.length),
};
};
+const collectDataGridCellSelectionRowKeys = (cellKeys: Iterable): string[] => {
+ const rowKeys = new Set();
+ for (const cellKey of cellKeys) {
+ const parsed = splitCellKey(cellKey);
+ if (!parsed || !parsed.rowKey) continue;
+ rowKeys.add(parsed.rowKey);
+ }
+ return Array.from(rowKeys);
+};
export const resolveContextMenuFieldName = (dataIndex: string, title?: string): string => {
const name = String(dataIndex || title || '').trim();
return name;
@@ -1661,6 +1670,7 @@ export {
useDataGridI18nLanguage,
makeCellKey,
splitCellKey,
+ collectDataGridCellSelectionRowKeys,
trimSimpleCache,
looksLikeDateTimeText,
normalizeDateTimeString,
diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx
index 1332acbc..28d4f790 100644
--- a/frontend/src/components/DataGridShell.tsx
+++ b/frontend/src/components/DataGridShell.tsx
@@ -111,6 +111,7 @@ const DataGridShell: React.FC = (props) => {
dataContextValue,
dataEditAutoCommitDelayMs,
dataEditCommitMode,
+ deleteTargetRowCount,
dataPanelDirtyRef,
dataPanelIsJson,
dataPanelOpen,
@@ -508,6 +509,7 @@ const renderDataTableView = () => (
const handleRefreshGrid = useCallback(() => {
setSelectedRowKeys([]);
+ resetCellSelection();
const normalizedTableName = String(tableName || '').trim();
const normalizedDbName = String(dbName || '').trim();
if (connectionId && normalizedTableName) {
@@ -518,7 +520,7 @@ const renderDataTableView = () => (
setMetadataReloadVersion((value: number) => value + 1);
}
if (onReload) onReload();
- }, [connectionId, dbName, onReload, tableName]);
+ }, [connectionId, dbName, onReload, resetCellSelection, tableName]);
const handleResetPendingChanges = useCallback(() => {
clearAutoCommitTimer();
@@ -588,6 +590,7 @@ const renderDataTableView = () => (
onToggleFilter={onToggleFilter}
canModifyData={canModifyData}
selectedRowKeysLength={selectedRowKeys.length}
+ deleteTargetRowCount={deleteTargetRowCount}
allSelectedAreDeleted={allSelectedAreDeleted}
cellEditMode={cellEditMode}
selectedCellsSize={selectedCells.size}
diff --git a/frontend/src/components/DataGridToolbarFrame.tsx b/frontend/src/components/DataGridToolbarFrame.tsx
index 01ffec34..b9330504 100644
--- a/frontend/src/components/DataGridToolbarFrame.tsx
+++ b/frontend/src/components/DataGridToolbarFrame.tsx
@@ -60,6 +60,7 @@ export interface DataGridToolbarFrameProps {
onToggleFilter?: () => void;
canModifyData: boolean;
selectedRowKeysLength: number;
+ deleteTargetRowCount: number;
allSelectedAreDeleted: boolean;
cellEditMode: boolean;
selectedCellsSize: number;
@@ -161,6 +162,7 @@ const DataGridToolbarFrame: React.FC = ({
onToggleFilter,
canModifyData,
selectedRowKeysLength,
+ deleteTargetRowCount,
allSelectedAreDeleted,
cellEditMode,
selectedCellsSize,
@@ -317,11 +319,11 @@ const DataGridToolbarFrame: React.FC = ({
{renderToolbarDivider()}
} onClick={onAddRow}>{translate('data_grid.toolbar.add_row')}
{allSelectedAreDeleted ? (
- } disabled={selectedRowKeysLength === 0} onClick={onUndoDeleteSelected}>{translate('data_grid.toolbar.undo_delete')}
+ } disabled={deleteTargetRowCount === 0} onClick={onUndoDeleteSelected}>{translate('data_grid.toolbar.undo_delete')}
) : (
- } danger disabled={selectedRowKeysLength === 0} onClick={onDeleteSelected}>{translate('data_grid.toolbar.delete_selected')}
+ } danger disabled={deleteTargetRowCount === 0} onClick={onDeleteSelected}>{translate('data_grid.toolbar.delete_selected')}
)}
- {selectedRowKeysLength > 0 && {translate('data_grid.toolbar.selected_count', { count: selectedRowKeysLength })}}
+ {deleteTargetRowCount > 0 && {translate('data_grid.toolbar.selected_count', { count: deleteTargetRowCount })}}
{renderToolbarDivider()}