Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion chat2db-community-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"build:desktop": "npm run build:web:desktop",
"build:prod": "npm run build:web:prod",
"build:web": "umi build",
"prebuild:web:community": "yarn test:chat-answer-update && yarn test:tree-title-highlight && yarn test:tree-data-update && yarn test:tree-node-lookup && yarn test:data-source-authorization && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:console-tab-name && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:editor-close && yarn test:invoice-routing && yarn test:result-set-ui && yarn test:result-status",
"prebuild:web:community": "yarn test:chat-answer-update && yarn test:tree-title-highlight && yarn test:tree-data-update && yarn test:tree-node-lookup && yarn test:data-source-authorization && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:console-tab-name && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:editor-close && yarn test:invoice-routing && yarn test:result-set-ui && yarn test:result-status && yarn test:pagination",
"postbuild:web:community": "node ./scripts/verify-production-bundles.cjs",
"build:web:2java": "yarn run build:web:prod && rm -rf ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/* && cp -r dist/index.html ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/",
"build:web:desktop": "cross-env UMI_ENV=desktop cross-env APP_NAME=chat2db-pro cross-env APP_VERSION=${npm_config_app_version} cross-env PRINT_LOGS=${npm_config_print_logs} cross-env APP_PORT=${npm_config_app_port} umi build",
Expand Down Expand Up @@ -70,6 +70,7 @@
"test:local-file-tab-refresh": "tsx src/store/workspace/utils/localFileWorkspaceTab.test.ts",
"test:local-file-encoding": "tsx src/utils/localFileEncoding.test.ts && yarn test:local-file-tab-refresh && tsx src/store/workspace/utils/workspaceTabPersistence.test.ts",
"test:main-page-navigation": "tsx src/utils/mainPageNavigation.test.ts && tsx src/pages/main/navigationItems.test.ts",
"test:pagination": "tsx src/components/Pagination/paginationState.test.ts",
"test:mcp-lifecycle": "tsx src/blocks/Setting/McpSetting/mcpLifecycle.test.ts",
"test:sql-completion-context": "tsx src/components/SQLEditor/core/sqlCompletionContext.test.ts",
"test:sql-in-clipboard": "tsx src/utils/sqlInClipboard.test.ts && tsx src/components/SQLEditor/helper/sqlInsertValueDefaults.test.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useState, ForwardedRef, forwardRef, useImperativeHandle, useRef, useMemo } from 'react';
import { memo, useState, ForwardedRef, forwardRef, useImperativeHandle, useRef, useMemo, useLayoutEffect } from 'react';
import Pagination from '@/components/Pagination';
import i18n from '@/i18n';
import { IconButton } from '@chat2db/ui';
Expand All @@ -8,12 +8,21 @@ import { IChartItem, IManageResultData, IResultConfig } from '@/typings';
import { useUpdateEffect } from 'ahooks';
import { isEqualMemo, keyboardKey } from '@/utils';
import sqlService from '@/service/sql';
import _ from 'lodash';
import EditorChartModal, { EditChartModalRef } from '@/blocks/BI/ChartCardBox/EditorChartModal';
import DingChartModal, { DingChartModalRef } from '@/blocks/BI/ChartCardBox/DingChartModal';
import ChartNoAxesCombined from '@/components/LucideIcons/ChartNoAxesCombined';
import { useZoerStore } from '@/store/zoer';
import { Columns3Cog } from 'lucide-react';
import {
getPaginationQueryKey,
isCurrentPaginationCountRequest,
isExpectedPaginationResponse,
type IPaginationCountRequest,
type IPaginationRequest,
resolvePaginationTotal,
updatePaginationPage,
updatePaginationPageSize,
} from '@/components/Pagination/paginationState';

export enum ToolbarOperationType {
ADD_BLANK_ROW = 'addBlankRow',
Expand Down Expand Up @@ -56,59 +65,93 @@ const ResultSetToolbar = forwardRef((props: IProps, ref: ForwardedRef<ResultSetT
total: resultData.fuzzyTotal,
hasNextPage: resultData.hasNextPage,
});
const paginationQueryKey = getPaginationQueryKey(resultData);
const paginationQueryKeyRef = useRef(paginationQueryKey);
const exactTotalQueryKeyRef = useRef<string>();
const pendingPaginationRequestRef = useRef<IPaginationRequest>();
const resultDataRef = useRef(resultData);
const resultGenerationRef = useRef(0);
const countRequestSequenceRef = useRef(0);
const zoerBoundInfo = useZoerStore((s) => s.zoerBoundInfo);

const showCreateChart = useMemo(() => !zoerBoundInfo, [zoerBoundInfo]);

useUpdateEffect(() => {
// Use the latest fuzzy total when the displayed total is not numeric (for example, "1000+").
let total = paginationConfig.total;
const numericTotal = _.toNumber(total);
if (_.isNaN(numericTotal)) {
total = resultData.fuzzyTotal;
useLayoutEffect(() => {
const resultDataChanged = resultDataRef.current !== resultData;
const queryChanged = paginationQueryKeyRef.current !== paginationQueryKey;
if (!resultDataChanged && !queryChanged) {
return;
}

resultDataRef.current = resultData;
paginationQueryKeyRef.current = paginationQueryKey;
resultGenerationRef.current += 1;
if (queryChanged) {
exactTotalQueryKeyRef.current = undefined;
pendingPaginationRequestRef.current = undefined;
}

setPaginationConfig({
// Keep an exact total only for the response to an explicit page/page-size request.
const preserveExactTotal =
exactTotalQueryKeyRef.current === paginationQueryKey &&
isExpectedPaginationResponse(pendingPaginationRequestRef.current, paginationQueryKey, resultData);
pendingPaginationRequestRef.current = undefined;

setPaginationConfig((config) => ({
pageNo: resultData.pageNo,
pageSize: resultData.pageSize,
total: resultData.fuzzyTotal,
total: resolvePaginationTotal(config.total, resultData.fuzzyTotal, preserveExactTotal),
hasNextPage: resultData.hasNextPage,
});
}));
}, [paginationQueryKey, resultData]);

useUpdateEffect(() => {
setChartDetail(null);
}, [resultData]);

const onPageNoChange = (pageNo: number) => {
setPaginationConfig({
...paginationConfig,
pageNo,
});
pendingPaginationRequestRef.current = { queryKey: paginationQueryKey, pageNo, pageSize: paginationConfig.pageSize };
setPaginationConfig((config) => updatePaginationPage(config, pageNo));
setTimeout(() => {
handleToolbarOperation(ToolbarOperationType.EXECUTE_SQL);
}, 0);
};

const onPageSizeChange = (pageSize: number) => {
setPaginationConfig({
...paginationConfig,
pageNo: 1,
pageSize,
});
pendingPaginationRequestRef.current = { queryKey: paginationQueryKey, pageNo: 1, pageSize };
setPaginationConfig((config) => updatePaginationPageSize(config, pageSize));
setTimeout(() => {
handleToolbarOperation(ToolbarOperationType.EXECUTE_SQL);
}, 0);
};

const onClickTotalBtn = (): Promise<number> => {
const onClickTotalBtn = (): Promise<number | undefined> => {
if (!resultData.executeSqlParams) return Promise.reject('executeSqlParams is not exist');
const request: IPaginationCountRequest = {
queryKey: paginationQueryKey,
resultGeneration: resultGenerationRef.current,
sequence: ++countRequestSequenceRef.current,
};
return sqlService.getDMLCount(resultData.executeSqlParams).then((res) => {
const config = { ...paginationConfig, total: res };
setPaginationConfig(config);
if (!isCurrentPaginationCountRequest(request, {
queryKey: paginationQueryKeyRef.current,
resultGeneration: resultGenerationRef.current,
sequence: countRequestSequenceRef.current,
})) {
return undefined;
}
exactTotalQueryKeyRef.current = request.queryKey;
setPaginationConfig((config) => ({ ...config, total: res }));
return res;
});
};

const handleRefresh = () => {
exactTotalQueryKeyRef.current = undefined;
pendingPaginationRequestRef.current = undefined;
resultGenerationRef.current += 1;
countRequestSequenceRef.current += 1;
setPaginationConfig((config) => ({ ...config, total: resultData.fuzzyTotal }));
handleToolbarOperation(ToolbarOperationType.EXECUTE_SQL);
};

Expand Down
24 changes: 3 additions & 21 deletions chat2db-community-client/src/components/Pagination/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } f
import { RESULT_PAGE_SIZE_OPTIONS } from '@/constants/pagination';
import { useGlobalStore } from '@/store/global';
import { settingSelectors } from '@/store/global/selectors';
import { isPaginationNavigationDisabled, type PaginationNavigationType } from './paginationState';

interface IProps {
onPageSizeChange?: (pageSize: number) => void;
Expand All @@ -19,7 +20,7 @@ interface IProps {
paginationConfig: IResultConfig;
}

type IIconType = 'pre' | 'next' | 'first' | 'last';
type IIconType = PaginationNavigationType;

export default function Pagination(props: IProps) {
const { onPageNoChange, onPageSizeChange, paginationConfig } = props;
Expand Down Expand Up @@ -104,26 +105,7 @@ export default function Pagination(props: IProps) {
};

const handleIsDisabled = (type: IIconType) => {
if (!paginationConfig) {
return false;
}
if (type === 'first') {
return paginationConfig?.pageNo === 1;
}
if (type === 'pre') {
return paginationConfig?.pageNo === 1;
}

const isNumber = _.isNumber(paginationConfig.total);
const totalShow = paginationConfig.pageNo * paginationConfig.pageSize;
if (type === 'next' || type === 'last') {
if (isNumber) {
return totalShow > (paginationConfig.total as number);
}
return !paginationConfig?.hasNextPage;
}

return true;
return isPaginationNavigationDisabled(paginationConfig, type);
};

const isPresetDefaultPageSize = RESULT_PAGE_SIZE_OPTIONS.some((pageSize) => pageSize === defaultPageSize);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isCurrentPaginationCountRequest,
getPaginationQueryKey,
isExpectedPaginationResponse,
isPaginationNavigationDisabled,
resolvePaginationTotal,
updatePaginationPage,
updatePaginationPageSize,
} from './paginationState';

test('exact totals disable next and last on a full final page', () => {
const paginationConfig = {
pageNo: 1,
pageSize: 100,
total: 100,
// An exact total is authoritative even if this stale flag says more data is available.
hasNextPage: true,
};

assert.equal(isPaginationNavigationDisabled(paginationConfig, 'next'), true);
assert.equal(isPaginationNavigationDisabled(paginationConfig, 'last'), true);
});

test('exact totals keep navigation enabled until the final record is visible', () => {
assert.equal(
isPaginationNavigationDisabled(
{ pageNo: 1, pageSize: 100, total: 101, hasNextPage: false },
'next',
),
false,
);
assert.equal(
isPaginationNavigationDisabled(
{ pageNo: 2, pageSize: 100, total: 101, hasNextPage: true },
'next',
),
true,
);
});

test('fuzzy totals continue to use the server hasNextPage signal', () => {
assert.equal(
isPaginationNavigationDisabled(
{ pageNo: 1, pageSize: 100, total: '100+', hasNextPage: true },
'next',
),
false,
);
assert.equal(
isPaginationNavigationDisabled(
{ pageNo: 1, pageSize: 100, total: '100+', hasNextPage: false },
'next',
),
true,
);
});

test('a numeric final-page total from the server is authoritative even when encoded as a string', () => {
assert.equal(
isPaginationNavigationDisabled(
{ pageNo: 1, pageSize: 100, total: '100', hasNextPage: true },
'next',
),
true,
);
});

test('a known numeric total survives later fuzzy paging responses', () => {
assert.equal(resolvePaginationTotal(100, '100+'), 100);
assert.equal(resolvePaginationTotal('100', '100+'), 100);
assert.equal(resolvePaginationTotal('100+', '200+'), '200+');
});

test('a changed query replaces the previous exact total with its fuzzy total', () => {
assert.equal(resolvePaginationTotal(100, '20+', false), '20+');
});

test('the pagination query key ignores page changes but isolates SQL and connection changes', () => {
const baseResult = {
originalSql: 'SELECT * FROM users',
executeSqlParams: {
dataSourceId: 1,
databaseName: 'app',
schemaName: 'public',
pageNo: 1,
pageSize: 100,
},
};

assert.equal(
getPaginationQueryKey(baseResult),
getPaginationQueryKey({
...baseResult,
executeSqlParams: { ...baseResult.executeSqlParams, pageNo: 2, pageSize: 50 },
}),
);
assert.notEqual(
getPaginationQueryKey(baseResult),
getPaginationQueryKey({ ...baseResult, originalSql: 'SELECT * FROM users WHERE active = 1' }),
);
assert.notEqual(
getPaginationQueryKey(baseResult),
getPaginationQueryKey({
...baseResult,
executeSqlParams: { ...baseResult.executeSqlParams, dataSourceId: 2 },
}),
);
});

test('only an expected page response can retain an exact total', () => {
const request = { queryKey: 'query-a', pageNo: 2, pageSize: 100 };

assert.equal(isExpectedPaginationResponse(request, 'query-a', { pageNo: 2, pageSize: 100 }), true);
assert.equal(isExpectedPaginationResponse(undefined, 'query-a', { pageNo: 2, pageSize: 100 }), false);
assert.equal(isExpectedPaginationResponse(request, 'query-a', { pageNo: 1, pageSize: 100 }), false);
assert.equal(isExpectedPaginationResponse(request, 'query-b', { pageNo: 2, pageSize: 100 }), false);
});

test('only the latest count response for the current result can update the total', () => {
const currentRequest = { queryKey: 'query-a', resultGeneration: 4, sequence: 2 };

assert.equal(isCurrentPaginationCountRequest(currentRequest, currentRequest), true);
assert.equal(
isCurrentPaginationCountRequest({ ...currentRequest, sequence: 1 }, currentRequest),
false,
);
assert.equal(
isCurrentPaginationCountRequest({ ...currentRequest, resultGeneration: 3 }, currentRequest),
false,
);
assert.equal(
isCurrentPaginationCountRequest({ ...currentRequest, queryKey: 'query-b' }, currentRequest), false);
});

test('page changes retain an exact total resolved immediately before a last-page navigation', () => {
const countResolvedConfig = { pageNo: 1, pageSize: 100, total: 250, hasNextPage: true };

assert.deepEqual(updatePaginationPage(countResolvedConfig, 3), {
...countResolvedConfig,
pageNo: 3,
});
assert.deepEqual(updatePaginationPageSize(countResolvedConfig, 50), {
...countResolvedConfig,
pageNo: 1,
pageSize: 50,
});
});
Loading
Loading