mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 00:33:28 +08:00
✨ feat(data-grid): 支持只读查询结果拖选复制
- 允许聚合与只读结果拖选单元格并复制 TSV - 增加选区工具栏操作和 Ctrl/Cmd+C 快捷键 - 隔离后台结果页与编辑操作,避免快捷键抢占 Fixes #673
This commit is contained in:
@@ -1126,7 +1126,7 @@ describe('DataGrid layout', () => {
|
||||
expect(dataGridSource).toContain('if (activeSelection.size === 0) {');
|
||||
expect(dataGridSource).toContain('closeCellEditMode();');
|
||||
expect(dataGridSource).toContain('resetCellSelection();');
|
||||
expect(dataGridSource).toContain("tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable");
|
||||
expect(dataGridSource).toContain('activeElement?.closest(nativeShortcutGuard) || eventTarget?.closest(nativeShortcutGuard)');
|
||||
expect(paginationSource).toContain("padding: 0");
|
||||
expect(paginationSource).toContain("justifyContent: 'flex-start'");
|
||||
});
|
||||
@@ -2464,6 +2464,23 @@ describe('DataGrid layout', () => {
|
||||
expect(markup.match(/data-grid-query-copy-action="true"/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps range selection and Ctrl/Cmd+C available for read-only aggregate results', () => {
|
||||
const batchActionsSource = readFileSync(new URL('./useDataGridBatchActions.ts', import.meta.url), 'utf8');
|
||||
const v2ActionsSource = readFileSync(new URL('./useDataGridV2Actions.ts', import.meta.url), 'utf8');
|
||||
const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(batchActionsSource).toContain('if (!isActive || !isTableSurfaceActive) return;');
|
||||
expect(batchActionsSource).not.toContain('if (!canModifyData || !isTableSurfaceActive) return;');
|
||||
expect(batchActionsSource).toContain('canSelectGridCellForClipboard({');
|
||||
expect(batchActionsSource).toContain('if (canModifyData && !cellEditModeRef.current)');
|
||||
expect(batchActionsSource).toContain('markCellSelectionDeleteEligible(canModifyData);');
|
||||
expect(v2ActionsSource).toContain('if (!isActive || !isTableSurfaceActive || (!cellEditMode && selectedCells.size === 0)) return;');
|
||||
expect(v2ActionsSource).toContain("String(event.key || '').toLowerCase() === 'c'");
|
||||
expect(v2ActionsSource).toContain('if (document.getSelection?.()?.toString()) return;');
|
||||
expect(toolbarSource).toContain('!canModifyData && selectedCellsSize > 0');
|
||||
expect(toolbarSource).toContain('data-grid-copy-selection-action="true"');
|
||||
});
|
||||
|
||||
it('keeps export and import chrome behind translateDataGrid while preserving raw details', () => {
|
||||
const source = readDataGridSource();
|
||||
const exportDialogSource = readFileSync(new URL('./DataExportDialog.tsx', import.meta.url), 'utf8');
|
||||
|
||||
@@ -1773,6 +1773,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
effectiveEditLocator,
|
||||
isCellValueEqualForDiff,
|
||||
isDraggingRef,
|
||||
isActive,
|
||||
isTableSurfaceActive,
|
||||
isWritableResultColumn,
|
||||
makeCellKey,
|
||||
@@ -3440,6 +3441,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
hasChanges,
|
||||
hasExplicitSort,
|
||||
hasFilteredExportSql,
|
||||
isActive,
|
||||
isTableSurfaceActive,
|
||||
isQueryResultExport,
|
||||
mergedDisplayData,
|
||||
modal,
|
||||
|
||||
@@ -434,6 +434,19 @@ const DataGridToolbarFrame: React.FC<DataGridToolbarFrameProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!canModifyData && selectedCellsSize > 0 && (
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
<Button
|
||||
data-grid-copy-selection-action="true"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={onCopySelectedCellsToClipboard}
|
||||
>
|
||||
{translate('data_grid.toolbar.copy_selection', { count: selectedCellsSize })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{renderToolbarDivider()}
|
||||
<Tooltip title={translate('data_grid.toolbar.ai_insight_tooltip')}>
|
||||
|
||||
@@ -637,6 +637,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
readOnly: true,
|
||||
}]}
|
||||
activeResultKey="result-1"
|
||||
isActive
|
||||
loading={false}
|
||||
executionError=""
|
||||
sqlLogCount={1}
|
||||
@@ -3327,6 +3328,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
sortInfo,
|
||||
}]}
|
||||
activeResultKey="result-1"
|
||||
isActive
|
||||
loading={false}
|
||||
executionError=""
|
||||
sqlLogCount={0}
|
||||
@@ -3359,6 +3361,75 @@ describe('QueryEditor external SQL save', () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('activates shortcuts only for the visible result grid in the active query editor', async () => {
|
||||
const resultSets = [
|
||||
{
|
||||
key: 'result-1',
|
||||
sql: 'select 1 as value',
|
||||
rows: [{ value: 1 }],
|
||||
columns: ['value'],
|
||||
pkColumns: [],
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
key: 'result-2',
|
||||
sql: 'select 2 as value',
|
||||
rows: [{ value: 2 }],
|
||||
columns: ['value'],
|
||||
pkColumns: [],
|
||||
readOnly: true,
|
||||
},
|
||||
];
|
||||
const renderPanel = (activeResultKey: string, isActive: boolean) => (
|
||||
<QueryEditorResultsPanel
|
||||
resultSets={resultSets}
|
||||
activeResultKey={activeResultKey}
|
||||
isActive={isActive}
|
||||
loading={false}
|
||||
executionError=""
|
||||
sqlLogCount={0}
|
||||
darkMode={false}
|
||||
isV2Ui
|
||||
currentDb="main"
|
||||
currentConnectionId="conn-1"
|
||||
toggleShortcutLabel=""
|
||||
onActiveResultKeyChange={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
onCloseResult={vi.fn()}
|
||||
onCloseOtherResultTabs={vi.fn()}
|
||||
onCloseResultTabsToLeft={vi.fn()}
|
||||
onCloseResultTabsToRight={vi.fn()}
|
||||
onCloseAllResultTabs={vi.fn()}
|
||||
onReloadResult={vi.fn()}
|
||||
onResultPageChange={vi.fn()}
|
||||
onResultSort={vi.fn()}
|
||||
onDiagnoseExecutionError={vi.fn()}
|
||||
/>
|
||||
);
|
||||
let renderer!: ReactTestRenderer;
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(renderPanel('result-1', true));
|
||||
});
|
||||
expect(dataGridState.latestProps?.data).toEqual([{ value: 1 }]);
|
||||
expect(dataGridState.latestProps?.isActive).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
renderer.update(renderPanel('result-1', false));
|
||||
});
|
||||
expect(dataGridState.latestProps?.isActive).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
renderer.update(renderPanel('result-2', true));
|
||||
});
|
||||
expect(dataGridState.latestProps?.data).toEqual([{ value: 2 }]);
|
||||
expect(dataGridState.latestProps?.isActive).toBe(true);
|
||||
expect(readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8'))
|
||||
.toContain('isActive={isActive && resolvedActiveResultKey === rs.key}');
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('sorts complete query results locally and restores execution order when cleared', async () => {
|
||||
const query = "select 3 as id, 'Zulu' as name union all select 1, 'Alpha' union all select 2, 'Alpha';";
|
||||
backendApp.DBQueryMulti.mockResolvedValueOnce({
|
||||
@@ -3476,6 +3547,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
<QueryEditorResultsPanel
|
||||
resultSets={[]}
|
||||
activeResultKey=""
|
||||
isActive
|
||||
loading={false}
|
||||
executionError=""
|
||||
sqlLogCount={sqlLogCount}
|
||||
@@ -3534,6 +3606,7 @@ describe('QueryEditor external SQL save', () => {
|
||||
<QueryEditorResultsPanel
|
||||
resultSets={resultSets}
|
||||
activeResultKey="stale-result"
|
||||
isActive
|
||||
loading={false}
|
||||
executionError=""
|
||||
sqlLogCount={0}
|
||||
|
||||
@@ -8936,6 +8936,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
<QueryEditorResultsPanel
|
||||
resultSets={resultSets}
|
||||
activeResultKey={activeResultKey}
|
||||
isActive={isActive}
|
||||
loading={loading}
|
||||
executionError={executionError}
|
||||
sqlLogCount={sqlLogCount}
|
||||
|
||||
@@ -71,6 +71,7 @@ export const resolveEffectiveActiveResultKey = (
|
||||
interface QueryEditorResultsPanelProps {
|
||||
resultSets: QueryEditorResultSet[];
|
||||
activeResultKey: string;
|
||||
isActive: boolean;
|
||||
loading: boolean;
|
||||
executionError: string;
|
||||
sqlLogCount: number;
|
||||
@@ -131,6 +132,7 @@ export const shouldActivateResultTabDetachPointer = (event: {
|
||||
const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
resultSets,
|
||||
activeResultKey,
|
||||
isActive,
|
||||
loading,
|
||||
executionError,
|
||||
sqlLogCount,
|
||||
@@ -532,6 +534,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
<DataGrid
|
||||
data={rs.rows}
|
||||
columnNames={visibleColumns}
|
||||
isActive={isActive && resolvedActiveResultKey === rs.key}
|
||||
loading={loading || rs.page?.loading === true}
|
||||
tableName={resultTableName}
|
||||
columnPinScope={resultTableName ? undefined : buildQueryResultColumnPinScope({
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
|
||||
import { buildSelectedCellClipboardText, canSelectGridCellForClipboard } from './dataGridSelectionCopy';
|
||||
|
||||
describe('dataGridSelectionCopy helpers', () => {
|
||||
it('allows displayed read-only cells while keeping editable expressions out of batch selection', () => {
|
||||
expect(canSelectGridCellForClipboard({
|
||||
canModifyData: false,
|
||||
isDisplayedColumn: true,
|
||||
isWritableColumn: false,
|
||||
})).toBe(true);
|
||||
expect(canSelectGridCellForClipboard({
|
||||
canModifyData: true,
|
||||
isDisplayedColumn: true,
|
||||
isWritableColumn: false,
|
||||
})).toBe(false);
|
||||
expect(canSelectGridCellForClipboard({
|
||||
canModifyData: false,
|
||||
isDisplayedColumn: false,
|
||||
isWritableColumn: false,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('builds clipboard text in visible row and column order', () => {
|
||||
const text = buildSelectedCellClipboardText({
|
||||
selectedCells: [
|
||||
|
||||
@@ -3,6 +3,16 @@ export interface SelectedGridCell {
|
||||
colName: string;
|
||||
}
|
||||
|
||||
export const canSelectGridCellForClipboard = ({
|
||||
canModifyData,
|
||||
isDisplayedColumn,
|
||||
isWritableColumn,
|
||||
}: {
|
||||
canModifyData: boolean;
|
||||
isDisplayedColumn: boolean;
|
||||
isWritableColumn: boolean;
|
||||
}): boolean => isDisplayedColumn && (!canModifyData || isWritableColumn);
|
||||
|
||||
const normalizeClipboardCellValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return 'NULL';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
import { message } from 'antd';
|
||||
import type { Item } from './DataGridCore';
|
||||
import { canSelectGridCellForClipboard } from './dataGridSelectionCopy';
|
||||
|
||||
type DataGridBatchActionsContext = Record<string, any> & {
|
||||
CELL_SELECTION_DRAG_THRESHOLD_PX: number;
|
||||
@@ -75,6 +76,7 @@ export const useDataGridBatchActions = (ctx: DataGridBatchActionsContext) => {
|
||||
displayColumnNames,
|
||||
displayDataRef,
|
||||
effectiveEditLocator,
|
||||
isActive,
|
||||
isCellValueEqualForDiff,
|
||||
isDraggingRef,
|
||||
isTableSurfaceActive,
|
||||
@@ -102,6 +104,7 @@ export const useDataGridBatchActions = (ctx: DataGridBatchActionsContext) => {
|
||||
} = ctx;
|
||||
|
||||
const handleBatchFillCells = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
const cellsToFill = currentSelectionRef.current;
|
||||
if (cellsToFill.size === 0) {
|
||||
void message.info(translateDataGrid('data_grid.message.select_cells_to_fill'));
|
||||
@@ -131,6 +134,7 @@ const handleBatchFillCells = useCallback(() => {
|
||||
const parts = splitCellKey(cellKey);
|
||||
if (!parts) return;
|
||||
const { rowKey, colName } = parts;
|
||||
if (!isWritableResultColumn(colName, effectiveEditLocator)) return;
|
||||
|
||||
const existing = modifiedRows[rowKey];
|
||||
const baseRow = baseRowMap.get(rowKey);
|
||||
@@ -200,12 +204,13 @@ const handleBatchFillCells = useCallback(() => {
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection, closeBatchEditModal, markCellSelectionDeleteEligible, translateDataGrid]);
|
||||
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection, closeBatchEditModal, markCellSelectionDeleteEligible, translateDataGrid, canModifyData, effectiveEditLocator, isWritableResultColumn, splitCellKey]);
|
||||
|
||||
// 事件委托:在容器级别处理单元格拖选;未开启模式时,拖拽超过阈值会自动进入单元格编辑模式。
|
||||
// 事件委托:在容器级别处理单元格拖选。可编辑结果会自动进入编辑模式,
|
||||
// 只读/聚合查询结果仅保留选区与复制能力,不触发任何数据修改入口。
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!canModifyData || !isTableSurfaceActive) return;
|
||||
if (!isActive || !isTableSurfaceActive) return;
|
||||
if (!container) return;
|
||||
const EDGE_THRESHOLD_PX = 28;
|
||||
const MIN_SCROLL_STEP = 8;
|
||||
@@ -221,7 +226,11 @@ const handleBatchFillCells = useCallback(() => {
|
||||
const cell = target.closest('[data-row-key][data-col-name]') as HTMLElement;
|
||||
if (!cell || !container.contains(cell)) return null;
|
||||
const colName = cell.getAttribute('data-col-name');
|
||||
if (!colName || !isWritableResultColumn(colName, effectiveEditLocator)) return null;
|
||||
if (!colName || !canSelectGridCellForClipboard({
|
||||
canModifyData,
|
||||
isDisplayedColumn: columnIndexMap.has(colName),
|
||||
isWritableColumn: isWritableResultColumn(colName, effectiveEditLocator),
|
||||
})) return null;
|
||||
return cell;
|
||||
};
|
||||
|
||||
@@ -263,7 +272,13 @@ const handleBatchFillCells = useCallback(() => {
|
||||
const row = currentData[i];
|
||||
const rKey = String(row?.[GONAVI_ROW_KEY]);
|
||||
for (let j = minColIndex; j <= maxColIndex; j++) {
|
||||
newSelectedCells.add(makeCellKey(rKey, displayColumnNames[j]));
|
||||
const colName = displayColumnNames[j];
|
||||
if (!canSelectGridCellForClipboard({
|
||||
canModifyData,
|
||||
isDisplayedColumn: true,
|
||||
isWritableColumn: isWritableResultColumn(colName, effectiveEditLocator),
|
||||
})) continue;
|
||||
newSelectedCells.add(makeCellKey(rKey, colName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,11 +375,12 @@ const handleBatchFillCells = useCallback(() => {
|
||||
};
|
||||
|
||||
const beginCellSelection = (cellInfo: { rowKey: string; colName: string }, x: number, y: number) => {
|
||||
if (!cellEditModeRef.current) {
|
||||
if (canModifyData && !cellEditModeRef.current) {
|
||||
cellEditModeRef.current = true;
|
||||
setCellEditMode(true);
|
||||
}
|
||||
suppressCellSelectionClickRef.current = true;
|
||||
document.getSelection?.()?.removeAllRanges();
|
||||
pendingCellSelectionStartRef.current = null;
|
||||
isDraggingRef.current = true;
|
||||
cellSelectionPointerRef.current = { x, y };
|
||||
@@ -446,7 +462,7 @@ const handleBatchFillCells = useCallback(() => {
|
||||
|
||||
if (currentSelectionRef.current.size > 0) {
|
||||
setSelectedCells(new Set(currentSelectionRef.current));
|
||||
markCellSelectionDeleteEligible(true);
|
||||
markCellSelectionDeleteEligible(canModifyData);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -493,7 +509,7 @@ const handleBatchFillCells = useCallback(() => {
|
||||
cellSelectionPointerRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
}, [canModifyData, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, markCellSelectionDeleteEligible, updateCellSelection]);
|
||||
}, [canModifyData, isActive, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, isWritableResultColumn, markCellSelectionDeleteEligible, updateCellSelection]);
|
||||
|
||||
const handleCopySelectedColumnsFromRow = useCallback(() => {
|
||||
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
|
||||
|
||||
@@ -73,6 +73,8 @@ export const useDataGridV2Actions = (ctx: DataGridV2ActionsContext) => {
|
||||
hasChanges,
|
||||
hasExplicitSort,
|
||||
hasFilteredExportSql,
|
||||
isActive,
|
||||
isTableSurfaceActive,
|
||||
isQueryResultExport,
|
||||
mergedDisplayData,
|
||||
modal,
|
||||
@@ -298,22 +300,27 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
|
||||
}, [selectedCells, mergedDisplayData, displayColumnNames, copyToClipboard, translateDataGrid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cellEditMode) return;
|
||||
if (!isActive || !isTableSurfaceActive || (!cellEditMode && selectedCells.size === 0)) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
const tagName = String(activeElement?.tagName || '').toLowerCase();
|
||||
if (tagName === 'input' || tagName === 'textarea' || activeElement?.isContentEditable) {
|
||||
const eventTarget = event.target instanceof HTMLElement ? event.target : null;
|
||||
const nativeShortcutGuard = 'input, textarea, select, [contenteditable="true"], .ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover, [data-gonavi-close-shortcut-guard]';
|
||||
if (activeElement?.closest(nativeShortcutGuard) || eventTarget?.closest(nativeShortcutGuard)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
|
||||
event.preventDefault();
|
||||
if (activeSelection.size === 0) {
|
||||
closeCellEditMode();
|
||||
if (cellEditMode) {
|
||||
event.preventDefault();
|
||||
closeCellEditMode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
resetCellSelection();
|
||||
return;
|
||||
}
|
||||
@@ -321,6 +328,8 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
|
||||
const isCopy = (event.ctrlKey || event.metaKey) && !event.altKey && String(event.key || '').toLowerCase() === 'c';
|
||||
if (!isCopy) return;
|
||||
|
||||
if (document.getSelection?.()?.toString()) return;
|
||||
|
||||
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
|
||||
if (activeSelection.size === 0) return;
|
||||
|
||||
@@ -330,10 +339,10 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode]);
|
||||
}, [cellEditMode, selectedCells, handleCopySelectedCellsToClipboard, resetCellSelection, closeCellEditMode, isActive, isTableSurfaceActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cellEditMode) return;
|
||||
if (!isActive || !isTableSurfaceActive || (!cellEditMode && selectedCells.size === 0)) return;
|
||||
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
const root = rootRef.current;
|
||||
@@ -343,12 +352,16 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
|
||||
&& target.closest('.ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover')) {
|
||||
return;
|
||||
}
|
||||
closeCellEditMode();
|
||||
if (cellEditMode) {
|
||||
closeCellEditMode();
|
||||
} else {
|
||||
resetCellSelection();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
return () => document.removeEventListener('mousedown', onPointerDown);
|
||||
}, [cellEditMode, closeCellEditMode]);
|
||||
}, [cellEditMode, closeCellEditMode, isActive, isTableSurfaceActive, resetCellSelection, selectedCells.size]);
|
||||
|
||||
const getTargets = useCallback((clickedRecord: any) => {
|
||||
const selKeys = selectedRowKeysRef.current;
|
||||
|
||||
Reference in New Issue
Block a user