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()} {allSelectedAreDeleted ? ( - + ) : ( - + )} - {selectedRowKeysLength > 0 && {translate('data_grid.toolbar.selected_count', { count: selectedRowKeysLength })}} + {deleteTargetRowCount > 0 && {translate('data_grid.toolbar.selected_count', { count: deleteTargetRowCount })}} {renderToolbarDivider()}