🐛 fix(data-grid): 刷新总数后跳转尾页

- 为表数据视图增加独立尾页刷新回调
- 按当前筛选条件执行精确 COUNT 后计算最新页码与偏移
- 丢弃过期计数请求并在计数失败时停止旧尾页查询
- 覆盖数据增减、并发翻页和兼容回退测试

Refs #706
This commit is contained in:
Syngnat
2026-07-28 11:31:37 +08:00
parent 33ddee28ff
commit 19bb9d633b
7 changed files with 384 additions and 14 deletions

View File

@@ -311,7 +311,7 @@ const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, columnPinScope, objectType = 'table', exportScope = 'table', dbName, ddlDbName, ddlTableName, connectionId, pkColumns = [], editLocator, readOnly = false,
resultSql,
resultExportAllSql,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
onReload, onSort, onPageChange, onLastPage, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
onApplyQuickWhereCondition,
scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions, showRowNumberColumn, isActive = true, enableSqlLogEvent = false,
initialViewMode,
@@ -5499,6 +5499,7 @@ const DataGrid: React.FC<DataGridProps> = ({
onCancelTotalCount,
onOpenErTable: openTableByName,
onPageChange,
onLastPage,
onReload,
onRequestTotalCount,
onSort,

View File

@@ -1347,6 +1347,7 @@ interface DataGridProps {
onReload?: () => void;
onSort?: (field: string, order: string) => void;
onPageChange?: (page: number, size: number) => void;
onLastPage?: (pageSize: number) => void;
pagination?: {
current: number,
pageSize: number,

View File

@@ -2,7 +2,10 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import DataGridPaginationBar, { resolveDataGridPaginationBoundaryTarget } from './DataGridPaginationBar';
import DataGridPaginationBar, {
createDataGridLastPageAction,
resolveDataGridPaginationBoundaryTarget,
} from './DataGridPaginationBar';
describe('DataGridPaginationBar boundary navigation', () => {
it('resolves the first and last page when the total page count is known', () => {
@@ -102,4 +105,58 @@ describe('DataGridPaginationBar boundary navigation', () => {
expect(markup).not.toContain('data-grid-pagination-total-count="true"');
});
it('keeps the last-page action available for a fresh tail lookup at the cached boundary', () => {
const onPageChange = vi.fn();
const onLastPage = vi.fn();
const action = createDataGridLastPageAction({
current: 10,
pageSize: 10,
totalPages: 10,
totalKnown: true,
onPageChange,
onLastPage,
});
const markup = renderToStaticMarkup(
<DataGridPaginationBar
isV2Ui
pagination={{ current: 10, pageSize: 10, total: 100, totalKnown: true }}
paginationV2SummaryText="100 rows"
paginationSummaryText="100 rows"
paginationControlTotal={100}
paginationTotalPages={10}
paginationPageText="Page 10 / 10"
paginationPageSizeOptions={['10']}
showKnownPageCount
onPageChange={onPageChange}
onLastPage={onLastPage}
onPageSizeChange={vi.fn()}
onV2PageStep={vi.fn()}
/>,
);
const lastPageButton = markup.match(/<button[^>]*data-grid-pagination-last="true"[^>]*>/)?.[0];
expect(lastPageButton).toBeDefined();
expect(lastPageButton).not.toContain('disabled');
expect(action).toEqual(expect.any(Function));
action?.();
expect(onLastPage).toHaveBeenCalledWith(10);
expect(onPageChange).not.toHaveBeenCalled();
});
it('falls back to cached last-page navigation when no fresh callback is available', () => {
const onPageChange = vi.fn();
const action = createDataGridLastPageAction({
current: 3,
pageSize: 10,
totalPages: 10,
totalKnown: true,
onPageChange,
});
expect(action).toEqual(expect.any(Function));
action?.();
expect(onPageChange).toHaveBeenCalledWith(10, 10);
});
});

View File

@@ -36,6 +36,7 @@ export interface DataGridPaginationBarProps {
manualTotalCountAvailable?: boolean;
totalCountLoading?: boolean;
onPageChange?: (page: number, size: number) => void;
onLastPage?: (pageSize: number) => void;
onPageSizeChange: (value: string) => void;
onV2PageStep: (direction: 'previous' | 'next') => void;
onToggleTotalCount?: () => void;
@@ -65,6 +66,36 @@ export const resolveDataGridPaginationBoundaryTarget = ({
return current < lastPage ? lastPage : null;
};
export const createDataGridLastPageAction = ({
current,
pageSize,
totalPages,
totalKnown,
onPageChange,
onLastPage,
}: {
current: number;
pageSize: number;
totalPages: number;
totalKnown: boolean;
onPageChange?: (page: number, size: number) => void;
onLastPage?: (pageSize: number) => void;
}): (() => void) | null => {
if (onLastPage) {
return () => onLastPage(pageSize);
}
const target = resolveDataGridPaginationBoundaryTarget({
boundary: 'last',
current,
totalPages,
totalKnown,
canNavigate: Boolean(onPageChange),
});
if (!onPageChange || target === null) return null;
return () => onPageChange(target, pageSize);
};
const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
isV2Ui,
pagination,
@@ -78,6 +109,7 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
manualTotalCountAvailable = false,
totalCountLoading = false,
onPageChange,
onLastPage,
onPageSizeChange,
onV2PageStep,
onToggleTotalCount,
@@ -160,12 +192,13 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
totalKnown: showKnownPageCount,
canNavigate: Boolean(onPageChange),
});
const lastPageTarget = resolveDataGridPaginationBoundaryTarget({
boundary: 'last',
const lastPageAction = createDataGridLastPageAction({
current: pagination.current,
pageSize: pagination.pageSize,
totalPages: paginationTotalPages,
totalKnown: showKnownPageCount,
canNavigate: Boolean(onPageChange),
onPageChange,
onLastPage,
});
const navigateToBoundary = (target: number | null) => {
if (!onPageChange || target === null) return;
@@ -198,8 +231,8 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
icon={<VerticalLeftOutlined />}
iconPosition="end"
aria-label={lastPageLabel}
disabled={lastPageTarget === null}
onClick={() => navigateToBoundary(lastPageTarget)}
disabled={lastPageAction === null}
onClick={() => lastPageAction?.()}
>
{lastPageLabel}
</Button>

View File

@@ -228,6 +228,7 @@ const DataGridShell: React.FC<DataGridShellProps> = (props) => {
onCancelTotalCount,
onOpenErTable,
onPageChange,
onLastPage,
onReload,
onRequestTotalCount,
onSort,
@@ -490,6 +491,7 @@ const renderDataTableView = () => (
manualTotalCountAvailable={prefersManualTotalCount && !!onRequestTotalCount}
totalCountLoading={pagination?.totalCountLoading}
onPageChange={onPageChange}
onLastPage={onLastPage}
onPageSizeChange={handlePageSizeChange}
onV2PageStep={handleV2PageStep}
onToggleTotalCount={onRequestTotalCount ? handleToggleTotalCount : undefined}

View File

@@ -673,6 +673,203 @@ describe('DataViewer safe editing locator', () => {
renderer!.unmount();
});
it.each([
{ label: 'shrinks', nextTotal: 51, expectedPage: 6, expectedOffset: 50 },
{ label: 'grows', nextTotal: 151, expectedPage: 16, expectedOffset: 150 },
])('recounts and navigates to the current last page when table data $label', async ({
nextTotal,
expectedPage,
expectedOffset,
}) => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let databaseTotal = 101;
backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => {
const normalizedSql = String(sql || '');
if (/count\s*\(/i.test(normalizedSql)) {
return {
success: true,
fields: ['total'],
data: [{ total: databaseTotal }],
};
}
const limit = Number(normalizedSql.match(/\bLIMIT\s+(\d+)/i)?.[1] || 101);
const offset = Number(normalizedSql.match(/\bOFFSET\s+(\d+)/i)?.[1] || 0);
const rowCount = Math.max(0, Math.min(limit, databaseTotal - offset));
return {
success: true,
fields: ['ID', 'NAME'],
data: createRows(rowCount),
};
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataViewer tab={createTab({
id: `tab-last-page-${nextTotal}`,
dbName: 'main',
tableName: 'users',
title: 'users',
})} />);
});
await flushPromises();
expect(dataGridState.latestProps?.pagination).toMatchObject({ total: 101, totalKnown: true });
await act(async () => {
await dataGridState.latestProps.onPageChange(11, 10);
});
await flushPromises();
expect(dataGridState.latestProps?.pagination).toMatchObject({ current: 11, pageSize: 10, total: 101 });
databaseTotal = nextTotal;
const callsBeforeLastPage = backendApp.DBQuery.mock.calls.length;
expect(dataGridState.latestProps?.onLastPage).toEqual(expect.any(Function));
await act(async () => {
await dataGridState.latestProps.onLastPage(10);
});
await flushPromises();
const lastPageSql = backendApp.DBQuery.mock.calls
.slice(callsBeforeLastPage)
.map((call: any[]) => String(call[2] || ''));
expect(lastPageSql[0]).toMatch(/count\s*\(/i);
expect(lastPageSql.some((sql: string) => new RegExp(`\\bOFFSET\\s+${expectedOffset}\\b`, 'i').test(sql))).toBe(true);
expect(dataGridState.latestProps?.pagination).toMatchObject({
current: expectedPage,
pageSize: 10,
total: nextTotal,
totalKnown: true,
});
expect(dataGridState.latestProps?.data).toHaveLength(1);
renderer!.unmount();
});
it('ignores a stale last-page count after a newer page navigation starts', async () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let deferNextCount = false;
let resolveDeferredCount: ((value: any) => void) | undefined;
backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => {
const normalizedSql = String(sql || '');
if (/count\s*\(/i.test(normalizedSql)) {
if (deferNextCount) {
deferNextCount = false;
return new Promise((resolve) => {
resolveDeferredCount = resolve;
});
}
return { success: true, fields: ['total'], data: [{ total: 101 }] };
}
const limit = Number(normalizedSql.match(/\bLIMIT\s+(\d+)/i)?.[1] || 101);
const offset = Number(normalizedSql.match(/\bOFFSET\s+(\d+)/i)?.[1] || 0);
return {
success: true,
fields: ['ID', 'NAME'],
data: createRows(Math.max(0, Math.min(limit, 101 - offset))),
};
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataViewer tab={createTab({
id: 'tab-last-page-race',
dbName: 'main',
tableName: 'users',
title: 'users',
})} />);
});
await flushPromises();
deferNextCount = true;
let staleLastPageRequest!: Promise<void>;
act(() => {
staleLastPageRequest = dataGridState.latestProps.onLastPage(10);
});
await flushPromises();
expect(resolveDeferredCount).toEqual(expect.any(Function));
await act(async () => {
await dataGridState.latestProps.onPageChange(2, 10);
});
await flushPromises();
await act(async () => {
resolveDeferredCount?.({ success: true, fields: ['total'], data: [{ total: 151 }] });
await staleLastPageRequest;
});
await flushPromises();
expect(dataGridState.latestProps?.pagination).toMatchObject({
current: 2,
pageSize: 10,
total: 101,
totalKnown: true,
});
expect(backendApp.DBQuery.mock.calls
.map((call: any[]) => String(call[2] || ''))
.some((sql: string) => /\bOFFSET\s+150\b/i.test(sql))).toBe(false);
renderer!.unmount();
});
it('reports a fresh last-page count failure without querying the cached tail', async () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let failNextCount = false;
backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => {
const normalizedSql = String(sql || '');
if (/count\s*\(/i.test(normalizedSql)) {
if (failNextCount) {
failNextCount = false;
return { success: false, message: '', data: [] };
}
return { success: true, fields: ['total'], data: [{ total: 101 }] };
}
return { success: true, fields: ['ID', 'NAME'], data: createRows(101) };
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataViewer tab={createTab({
id: 'tab-last-page-count-failure',
dbName: 'main',
tableName: 'users',
title: 'users',
})} />);
});
await flushPromises();
failNextCount = true;
const callsBeforeLastPage = backendApp.DBQuery.mock.calls.length;
await act(async () => {
await dataGridState.latestProps.onLastPage(10);
});
await flushPromises();
const lastPageSql = backendApp.DBQuery.mock.calls
.slice(callsBeforeLastPage)
.map((call: any[]) => String(call[2] || ''));
expect(lastPageSql).toHaveLength(1);
expect(lastPageSql[0]).toMatch(/count\s*\(/i);
expect(messageApi.error).toHaveBeenCalledWith('统计总数失败');
renderer!.unmount();
});
it('shows an actionable message for DuckDB timeout interruption errors', async () => {
storeState.languagePreference = 'en-US';
storeState.connections[0].config.type = 'duckdb';

View File

@@ -42,6 +42,11 @@ type ViewerPaginationState = {
totalCountCancelled: boolean;
};
type DataViewerFetchOptions = {
refreshTotal?: boolean;
navigateToLastPage?: boolean;
};
type DataViewerTranslator = (key: string, params?: I18nParams) => string;
const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
@@ -583,8 +588,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true }));
}, []);
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize, options?: { refreshTotal?: boolean }) => {
const refreshTotal = options?.refreshTotal === true;
const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize, options?: DataViewerFetchOptions) => {
const navigateToLastPage = options?.navigateToLastPage === true;
const refreshTotal = options?.refreshTotal === true || navigateToLastPage;
const seq = ++fetchSeqRef.current;
setLoading(true);
const conn = connections.find(c => c.id === tab.connectionId);
@@ -726,12 +732,81 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const countSql = isMongoDB
? buildMongoCountCommand(tableName, mongoFilter || {})
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
let refreshedTotal: number | null = null;
if (navigateToLastPage) {
countSeqRef.current++;
manualCountSeqRef.current++;
duckdbApproxSeqRef.current++;
oracleApproxSeqRef.current++;
countKeyRef.current = '';
autoCountKeyRef.current = '';
manualCountKeyRef.current = '';
duckdbApproxKeyRef.current = '';
oracleApproxKeyRef.current = '';
setPagination(prev => ({
...prev,
totalCountLoading: false,
totalCountCancelled: false,
}));
latestConfigRef.current = config;
latestDbTypeRef.current = dbTypeLower;
latestDbNameRef.current = dbName;
latestCountSqlRef.current = countSql;
latestCountKeyRef.current = countKey;
const countStart = Date.now();
const countConfig = buildRpcConnectionConfig(config, { timeout: 120 });
try {
const resCount = await DBQuery(countConfig as any, dbName, countSql);
addSqlLog({
id: `log-${Date.now()}-last-page-count`,
timestamp: Date.now(),
sql: countSql,
status: resCount?.success ? 'success' : 'error',
duration: Date.now() - countStart,
message: resCount?.success ? '' : String(resCount?.message || tr('data_viewer.message.total_count_failed')),
dbName,
});
if (fetchSeqRef.current !== seq) return;
if (!resCount?.success) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(String(resCount?.message || tr('data_viewer.message.total_count_failed')));
setLoading(false);
return;
}
if (!Array.isArray(resCount.data) || resCount.data.length === 0) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(tr('data_viewer.message.total_count_failed'));
setLoading(false);
return;
}
refreshedTotal = parseTotalFromCountRow(resCount.data[0]);
if (refreshedTotal === null) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(tr('data_viewer.message.total_count_parse_failed'));
setLoading(false);
return;
}
} catch (e: any) {
if (fetchSeqRef.current !== seq) return;
setPagination(prev => ({ ...prev, totalCountLoading: false }));
message.error(tr('data_viewer.message.total_count_failed_detail', { detail: String(e?.message || e) }));
setLoading(false);
return;
}
}
const orderBySQL = isMongoDB
? ''
: buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery));
const totalRows = Number(pagination.total);
const totalRows = refreshedTotal ?? Number(pagination.total);
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
const totalKnown = !refreshTotal && pagination.totalKnown && hasFiniteTotal;
const totalKnown = refreshedTotal !== null || (!refreshTotal && pagination.totalKnown && hasFiniteTotal);
const approximateTotalRows = Number(pagination.approximateTotal);
const hasApproximateTotalPages =
!refreshTotal &&
@@ -740,9 +815,10 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
pagination.totalApprox &&
Number.isFinite(approximateTotalRows) &&
approximateTotalRows > 0;
const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : (refreshTotal ? 0 : totalRows);
const effectiveTotalRows = refreshedTotal ?? (hasApproximateTotalPages ? approximateTotalRows : (refreshTotal ? 0 : totalRows));
const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0;
const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page);
const requestedPage = navigateToLastPage ? Math.max(1, totalPages) : page;
const currentPage = totalPages > 0 ? Math.min(Math.max(1, requestedPage), totalPages) : Math.max(1, requestedPage);
const offset = (currentPage - 1) * size;
const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse';
const reverseOrderSQL = isClickHouse ? reverseOrderBySQL(orderBySQL) : '';
@@ -905,7 +981,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = `row-${offset + i}`;
});
setData(resultData);
const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`;
const derivedTotalKnown = !hasMore;
const derivedTotal = derivedTotalKnown ? offset + resultData.length : currentPage * size + 1;
const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length;
@@ -1193,6 +1268,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]);
}, []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleLastPage = useCallback((pageSize: number) => (
fetchData(1, pageSize, { navigateToLastPage: true })
), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => {
skipNextAutoFetchRef.current = false;
@@ -1278,6 +1356,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
onReload={handleReload}
onSort={handleSort}
onPageChange={handlePageChange}
onLastPage={handleLastPage}
pagination={pagination}
onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined}
onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}