fix(mongo): stabilize estimated-total pagination
This commit is contained in:
parent
96038d2b8a
commit
8f3fd0ebfb
|
|
@ -19,7 +19,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
||||
import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
|
||||
import { canGoNextDocumentPage, resolveDocumentQueryTotals } from "@/lib/document/documentQueryTotals";
|
||||
import { canGoNextDocumentPage, isSameDocumentQueryTotalCountRequest, resolveDocumentQueryTotals, type DocumentQueryTotalCountRequest } from "@/lib/document/documentQueryTotals";
|
||||
import {
|
||||
arrayObjectAncestorPathForDocumentField,
|
||||
buildDocumentFilterCondition,
|
||||
|
|
@ -45,6 +45,7 @@ import {
|
|||
type DocumentFieldPathNode,
|
||||
type DocumentFilterMode,
|
||||
type DocumentFilterRule,
|
||||
type DocumentStoreKind,
|
||||
type ElasticsearchBoolClause,
|
||||
type ElasticsearchQueryType,
|
||||
} from "@/lib/app/documentStoreProvider";
|
||||
|
|
@ -144,6 +145,9 @@ let elasticsearchExactTotal: number | undefined;
|
|||
let elasticsearchPaginationLowerBound: number | undefined;
|
||||
let elasticsearchCountExecutionId = "";
|
||||
let elasticsearchCountGeneration = 0;
|
||||
type LoadedDocumentQueryTotalCountRequest = DocumentQueryTotalCountRequest & { storeKind: DocumentStoreKind };
|
||||
let loadedDocumentQueryTotalCountRequest: LoadedDocumentQueryTotalCountRequest | undefined;
|
||||
let documentRequestGeneration = 0;
|
||||
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
|
||||
const documentColumnLayoutScopeKey = computed(() =>
|
||||
documentDataGridColumnLayoutScopeKey({
|
||||
|
|
@ -772,6 +776,13 @@ function elasticsearchCountFilterKey(filter: string | undefined): string {
|
|||
return JSON.stringify([props.connectionId, props.database, props.collection, filter ?? ""]);
|
||||
}
|
||||
|
||||
function isCurrentDocumentQueryTotalCountRequest(request: LoadedDocumentQueryTotalCountRequest): boolean {
|
||||
if (request.generation !== documentRequestGeneration || request.connectionId !== props.connectionId || request.database !== props.database || request.collection !== props.collection || request.storeKind !== documentStoreProvider.value.kind) {
|
||||
return false;
|
||||
}
|
||||
return loadedDocumentQueryTotalCountRequest !== undefined && isSameDocumentQueryTotalCountRequest(request, loadedDocumentQueryTotalCountRequest) && request.storeKind === loadedDocumentQueryTotalCountRequest.storeKind;
|
||||
}
|
||||
|
||||
function cancelElasticsearchCount() {
|
||||
elasticsearchCountGeneration++;
|
||||
const executionId = elasticsearchCountExecutionId;
|
||||
|
|
@ -790,13 +801,12 @@ function resetElasticsearchTotals(options: { preservePaginationTotal?: boolean }
|
|||
totalIsExact.value = nextTotals.totalIsExact;
|
||||
}
|
||||
|
||||
function clampPageToPaginationTotal(): boolean {
|
||||
function clampPageToPaginationTotal(): number | undefined {
|
||||
const cap = paginationTotal.value;
|
||||
if (cap === undefined) return false;
|
||||
if (cap === undefined) return undefined;
|
||||
const nextPage = clampDocumentPage(page.value, pageSize.value, cap);
|
||||
if (page.value === nextPage) return false;
|
||||
page.value = nextPage;
|
||||
return true;
|
||||
if (page.value === nextPage) return undefined;
|
||||
return nextPage;
|
||||
}
|
||||
|
||||
function startElasticsearchExactCount(filter: string | undefined) {
|
||||
|
|
@ -817,7 +827,8 @@ function startElasticsearchExactCount(filter: string | undefined) {
|
|||
total.value = totals.total;
|
||||
totalIsExact.value = totals.totalIsExact;
|
||||
paginationTotal.value = totals.paginationTotal;
|
||||
if (clampPageToPaginationTotal()) void load();
|
||||
const clampedPage = clampPageToPaginationTotal();
|
||||
if (clampedPage !== undefined) void load({ page: clampedPage });
|
||||
})
|
||||
.catch(() => {
|
||||
// The lower-bound result remains truthful when a background count fails.
|
||||
|
|
@ -862,27 +873,35 @@ function applyElasticsearchSearchTotal(searchTotal: number, isExact: boolean, fi
|
|||
startElasticsearchExactCount(filter);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
async function load(options: { page?: number } = {}) {
|
||||
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
||||
const requestGeneration = ++documentRequestGeneration;
|
||||
const executionId = uuid();
|
||||
loading.value = true;
|
||||
documentLoadExecutionId.value = executionId;
|
||||
documentLoadCancelling.value = false;
|
||||
startDocumentLoadingTimer();
|
||||
error.value = "";
|
||||
const requestPage = options.page ?? page.value;
|
||||
const previousSelectedIdx = selectedIdx.value;
|
||||
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
|
||||
try {
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
const collection = props.collection;
|
||||
const storeKind = documentStoreProvider.value.kind;
|
||||
const filter = currentDocumentFilter();
|
||||
if (documentStoreProvider.value.kind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
|
||||
const countRequest: LoadedDocumentQueryTotalCountRequest = { connectionId, database, collection, filter, generation: requestGeneration, storeKind };
|
||||
if (storeKind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
|
||||
resetElasticsearchTotals();
|
||||
}
|
||||
const sort = currentDocumentSortJson(sortInput.value);
|
||||
const skip = page.value * pageSize.value;
|
||||
const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId);
|
||||
const skip = requestPage * pageSize.value;
|
||||
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId);
|
||||
if (documentLoadExecutionId.value !== executionId) return;
|
||||
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
|
||||
const nextDocuments =
|
||||
documentStoreProvider.value.kind === "elasticsearch" && result.raw_documents?.length === result.documents.length
|
||||
storeKind === "elasticsearch" && result.raw_documents?.length === result.documents.length
|
||||
? result.raw_documents.map((raw, index) => {
|
||||
try {
|
||||
return asRecord(parseJsonPreservingLargeNumbers(raw));
|
||||
|
|
@ -893,9 +912,12 @@ async function load() {
|
|||
: result.documents.map(asRecord);
|
||||
const hasTypePreservingCopyDocuments = result.extended_documents?.length === nextDocuments.length;
|
||||
const nextCopyDocuments = hasTypePreservingCopyDocuments ? result.extended_documents!.map(asRecord) : nextDocuments;
|
||||
// Commit page + rows together so stale rows never briefly show last-page indexes.
|
||||
if (options.page !== undefined) page.value = options.page;
|
||||
documents.value = nextDocuments;
|
||||
copyDocuments.value = nextCopyDocuments;
|
||||
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
|
||||
loadedDocumentQueryTotalCountRequest = countRequest;
|
||||
if (nextDocuments.length > 0) {
|
||||
const keySet = new Set<string>();
|
||||
keySet.add("_id");
|
||||
|
|
@ -906,11 +928,15 @@ async function load() {
|
|||
}
|
||||
lastGridColumns.value = [...keySet];
|
||||
}
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
||||
if (storeKind === "elasticsearch") {
|
||||
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
|
||||
} else {
|
||||
cancelElasticsearchCount();
|
||||
const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false);
|
||||
const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false, {
|
||||
page: requestPage,
|
||||
pageSize: pageSize.value,
|
||||
rowCount: nextDocuments.length,
|
||||
});
|
||||
total.value = totals.total;
|
||||
totalIsExact.value = totals.totalIsExact;
|
||||
paginationTotal.value = totals.paginationTotal;
|
||||
|
|
@ -928,6 +954,34 @@ async function load() {
|
|||
}
|
||||
}
|
||||
|
||||
async function countExactDocumentTotal(): Promise<number | undefined> {
|
||||
const request = loadedDocumentQueryTotalCountRequest;
|
||||
if (!request || !isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
|
||||
if (request.storeKind === "elasticsearch") {
|
||||
const exactCount = await api.elasticsearchCountDocuments(request.connectionId, request.collection, request.filter);
|
||||
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
|
||||
if (!Number.isFinite(exactCount) || exactCount < 0) {
|
||||
throw new Error("invalid count");
|
||||
}
|
||||
elasticsearchExactTotal = exactCount;
|
||||
const totals = resolveElasticsearchDocumentTotals(elasticsearchPaginationLowerBound ?? exactCount, false, exactCount);
|
||||
total.value = totals.total;
|
||||
totalIsExact.value = totals.totalIsExact;
|
||||
paginationTotal.value = totals.paginationTotal;
|
||||
return exactCount;
|
||||
}
|
||||
const exactCount = await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
|
||||
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
|
||||
if (!Number.isFinite(exactCount) || exactCount < 0) {
|
||||
throw new Error("invalid count");
|
||||
}
|
||||
const totals = resolveDocumentQueryTotals(exactCount, true);
|
||||
total.value = totals.total;
|
||||
totalIsExact.value = totals.totalIsExact;
|
||||
paginationTotal.value = totals.paginationTotal;
|
||||
return exactCount;
|
||||
}
|
||||
|
||||
async function refreshDocuments() {
|
||||
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
|
||||
await load();
|
||||
|
|
@ -959,8 +1013,8 @@ function paginate(offset: number, limit: number) {
|
|||
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
|
||||
pageSize.value = normalizedLimit;
|
||||
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
|
||||
page.value = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
|
||||
void load();
|
||||
const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
|
||||
void load({ page: nextPage });
|
||||
}
|
||||
|
||||
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
|
||||
|
|
@ -1475,6 +1529,8 @@ onMounted(async () => {
|
|||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
||||
documentRequestGeneration++;
|
||||
loadedDocumentQueryTotalCountRequest = undefined;
|
||||
cancelElasticsearchCount();
|
||||
stopDocumentLoadingTimer();
|
||||
endTableSearchSplitResize();
|
||||
|
|
@ -1616,7 +1672,9 @@ defineExpose({ focusSearch });
|
|||
:page-limit="pageSize"
|
||||
:total-row-count="total"
|
||||
:total-row-count-is-exact="totalIsExact"
|
||||
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
|
||||
:pagination-total-row-count="pageTotal"
|
||||
:count-total-rows="countExactDocumentTotal"
|
||||
@sort="onSort"
|
||||
@reload="refreshDocuments"
|
||||
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOpti
|
|||
import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
|
||||
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
|
||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "@/lib/dataGrid/dataGridPagination";
|
||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
|
||||
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
|
||||
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
||||
import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid } from "@/lib/dataGrid/canvasDataGridRenderer";
|
||||
|
|
@ -184,7 +184,7 @@ import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFro
|
|||
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
|
||||
import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, extractorUnavailableForDatabase, type DataGridCopyExtractorId } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
|
||||
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
|
||||
import { useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
|
||||
|
|
@ -245,6 +245,7 @@ const connectionStore = useConnectionStore();
|
|||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
|
||||
const rowNumberWidth = ref(DATA_GRID_ROW_NUM_WIDTH);
|
||||
const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMultiRowTranspose);
|
||||
const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns);
|
||||
const { isDark, themePalette } = useTheme();
|
||||
|
|
@ -300,9 +301,12 @@ interface DataGridProps {
|
|||
countSql?: string;
|
||||
totalRowCount?: number;
|
||||
totalRowCountIsExact?: boolean;
|
||||
inexactTotalRowCountMode?: DataGridInexactTotalRowCountMode;
|
||||
paginationTotalRowCount?: number;
|
||||
paginationEnabled?: boolean;
|
||||
totalRowCountLoading?: boolean;
|
||||
/** Document stores (e.g. MongoDB) count exactly on demand without SQL tableMeta/countSql. */
|
||||
countTotalRows?: () => Promise<number | undefined>;
|
||||
loading?: boolean;
|
||||
cacheKey?: string;
|
||||
exportSql?: string;
|
||||
|
|
@ -322,6 +326,7 @@ const props = withDefaults(defineProps<DataGridProps>(), {
|
|||
// Vue casts absent Boolean props to false unless a default is explicit.
|
||||
// Regular grids have exact totals; document stores opt into lower-bound totals.
|
||||
totalRowCountIsExact: true,
|
||||
inexactTotalRowCountMode: "at-least",
|
||||
paginationEnabled: true,
|
||||
// Omitted row-action limits must keep normal table-data editing.
|
||||
allowInsertRows: undefined,
|
||||
|
|
@ -1898,6 +1903,7 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to
|
|||
columnStructureSignature,
|
||||
measureHeaderText: measureColumnHeaderText,
|
||||
headerMeasurementKey: columnHeaderMeasurementKey,
|
||||
rowNumberWidth,
|
||||
});
|
||||
const gridStyle = computed(() => ({
|
||||
...columnVars.value,
|
||||
|
|
@ -1932,7 +1938,7 @@ const {
|
|||
renderedColumnWidths,
|
||||
scrollLeft: gridHorizontalScrollLeft,
|
||||
viewportWidth: gridViewportWidth,
|
||||
rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH,
|
||||
rowNumberWidth,
|
||||
headerRef,
|
||||
orderedColumnIndexes: orderedDisplayableColumnIndexes,
|
||||
hiddenColumnIndexes,
|
||||
|
|
@ -2453,9 +2459,10 @@ const inferredBackendTotalRowCount = computed(() => {
|
|||
if (affected <= props.result.rows.length) return undefined;
|
||||
return affected;
|
||||
});
|
||||
const serverKnownTotalRowCount = computed(() => props.totalRowCount ?? manualTotalRowCount.value);
|
||||
const serverKnownTotalRowCount = computed(() => (typeof manualTotalRowCount.value === "number" ? manualTotalRowCount.value : props.totalRowCount));
|
||||
const displayedTotalRowCount = computed(() => serverKnownTotalRowCount.value ?? inferredBackendTotalRowCount.value);
|
||||
const totalRowCountIsExact = computed(() => props.totalRowCountIsExact !== false);
|
||||
const totalRowCountIsExact = computed(() => typeof manualTotalRowCount.value === "number" || props.totalRowCountIsExact !== false);
|
||||
const totalRowCountLabelKey = computed(() => dataGridTotalRowCountLabelKey(totalRowCountIsExact.value, props.inexactTotalRowCountMode));
|
||||
// A backend can expose an exact display total while deliberately restricting
|
||||
// offset pagination to a smaller safe range.
|
||||
const paginationTotalRowCount = computed(() =>
|
||||
|
|
@ -2506,9 +2513,36 @@ const canFetchNextInfiniteScrollSegment = computed(() =>
|
|||
allRowsLoaded: allRowsLoaded.value,
|
||||
}),
|
||||
);
|
||||
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql));
|
||||
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql || !!props.countTotalRows));
|
||||
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
|
||||
const canCalculateTotalRowCount = computed(() => !!props.connectionId && (!!props.tableMeta || !!props.countSql));
|
||||
/** Full-grid busy state: query loading or on-demand COUNT (e.g. jump to last page). */
|
||||
const gridSurfaceBusy = computed(() => props.loading === true || totalRowCountBusy.value);
|
||||
const canCalculateTotalRowCount = computed(() => !!props.countTotalRows || (!!props.connectionId && (!!props.tableMeta || !!props.countSql)));
|
||||
const showExactTotalCountAction = computed(() => canCalculateTotalRowCount.value && (totalRowCountIsExact.value === false || typeof displayedTotalRowCount.value !== "number"));
|
||||
watch(
|
||||
[
|
||||
() =>
|
||||
resolveDataGridMaxRowNumber({
|
||||
infiniteScroll: infiniteScrollEnabled.value,
|
||||
allRowsLoaded: allRowsLoaded.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
rowCount: props.result.rows.length,
|
||||
}),
|
||||
tableFontSize,
|
||||
tableFontFamily,
|
||||
],
|
||||
([maxRowNumber, fontSize, fontFamily]) => {
|
||||
rowNumberWidth.value = dataGridRowNumberColumnWidth(maxRowNumber, fontSize, (text) => {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
if (columnHeaderMeasureContext === undefined) columnHeaderMeasureContext = document.createElement("canvas").getContext("2d");
|
||||
if (!columnHeaderMeasureContext) return undefined;
|
||||
columnHeaderMeasureContext.font = `400 ${fontSize}px ${fontFamily}`;
|
||||
return Math.ceil(columnHeaderMeasureContext.measureText(text).width);
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
// When a refresh/rollback completes and the current page exceeds the last
|
||||
// available page (e.g. data was deleted while viewing), auto-navigate to the
|
||||
// last available page instead of showing an empty page.
|
||||
|
|
@ -2582,7 +2616,7 @@ function currentOrderBy(): string | undefined {
|
|||
}
|
||||
|
||||
watch(
|
||||
() => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? "", props.result],
|
||||
() => [props.countSql ?? "", props.tableMeta?.schema ?? "", props.tableMeta?.tableName ?? "", currentWhereInput() ?? "", props.database ?? "", props.connectionId ?? ""],
|
||||
() => {
|
||||
manualTotalRowCount.value = undefined;
|
||||
// Reset infinite-scroll allLoaded when query context changes
|
||||
|
|
@ -2684,42 +2718,73 @@ function applyCustomPageSize() {
|
|||
changePageSize(normalizeResultPageSize(customPageSizeInput.value, pageSize.value));
|
||||
}
|
||||
|
||||
function jumpToCountedLastPage(total: number) {
|
||||
if (total <= 0) return;
|
||||
const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value));
|
||||
if (lastPageNum <= currentPage.value) return;
|
||||
// Do not bump currentPage before the new page loads — otherwise stale rows
|
||||
// briefly render with last-page indexes (e.g. 12001-13000) and flash a fake full page.
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
}
|
||||
|
||||
async function beginManualTotalRowCount(): Promise<boolean> {
|
||||
if (manualTotalRowCountLoading.value) return false;
|
||||
manualTotalRowCountLoading.value = true;
|
||||
// Flush busy UI (overlay / spinner) before the slow COUNT starts.
|
||||
await nextTick();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function lastPage() {
|
||||
if (infiniteScrollEnabled.value) return;
|
||||
if (hasKnownPaginationTotalRowCount.value) {
|
||||
const total = paginationTotalRowCount.value ?? 0;
|
||||
if (total <= 0) return;
|
||||
const lastPageNum = Math.ceil(total / pageSize.value);
|
||||
if (lastPageNum <= currentPage.value) return;
|
||||
currentPage.value = lastPageNum;
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
return;
|
||||
}
|
||||
if (allRowsLoaded.value) {
|
||||
const total = props.result.rows.length;
|
||||
if (total <= 0) return;
|
||||
const lastPageNum = Math.ceil(total / pageSize.value);
|
||||
const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value));
|
||||
if (lastPageNum <= currentPage.value) return;
|
||||
currentPage.value = lastPageNum;
|
||||
resetGridVerticalScroll(true);
|
||||
return;
|
||||
}
|
||||
if (!props.connectionId) return;
|
||||
const countTarget = await buildCurrentCountTarget();
|
||||
const sql = countTarget?.sql;
|
||||
if (!sql) return;
|
||||
try {
|
||||
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId)));
|
||||
const total = Number(result.rows?.[0]?.[0] ?? 0);
|
||||
if (total <= 0) return;
|
||||
const lastPageNum = Math.ceil(total / pageSize.value);
|
||||
if (lastPageNum <= currentPage.value) return;
|
||||
currentPage.value = lastPageNum;
|
||||
resetGridVerticalScroll(true);
|
||||
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
|
||||
} catch {
|
||||
// COUNT query failed — ignore silently
|
||||
// Navicat-style: always re-COUNT when jumping to the last page.
|
||||
if (props.countTotalRows) {
|
||||
if (!(await beginManualTotalRowCount())) return;
|
||||
try {
|
||||
const total = await props.countTotalRows();
|
||||
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return;
|
||||
manualTotalRowCount.value = total;
|
||||
jumpToCountedLastPage(total);
|
||||
// Keep the busy overlay until the parent query loading flag can take over.
|
||||
await nextTick();
|
||||
} catch (e: any) {
|
||||
toast(t("grid.calculateTotalRowsFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
manualTotalRowCountLoading.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (props.connectionId && (props.countSql || props.tableMeta)) {
|
||||
if (!(await beginManualTotalRowCount())) return;
|
||||
try {
|
||||
const countTarget = await buildCurrentCountTarget();
|
||||
const sql = countTarget?.sql;
|
||||
if (!sql) return;
|
||||
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId)));
|
||||
const total = Number(result.rows?.[0]?.[0] ?? 0);
|
||||
if (!Number.isFinite(total) || total < 0) return;
|
||||
manualTotalRowCount.value = total;
|
||||
jumpToCountedLastPage(total);
|
||||
await nextTick();
|
||||
} catch {
|
||||
// COUNT query failed — ignore silently
|
||||
} finally {
|
||||
manualTotalRowCountLoading.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hasKnownPaginationTotalRowCount.value) {
|
||||
jumpToCountedLastPage(paginationTotalRowCount.value ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2741,9 +2806,16 @@ async function buildCurrentCountTarget(): Promise<{ sql: string; schema?: string
|
|||
}
|
||||
|
||||
async function calculateTotalRowCount() {
|
||||
if (!props.connectionId || manualTotalRowCountLoading.value) return;
|
||||
manualTotalRowCountLoading.value = true;
|
||||
if (!(await beginManualTotalRowCount())) return;
|
||||
try {
|
||||
if (props.countTotalRows) {
|
||||
const total = await props.countTotalRows();
|
||||
if (typeof total === "number" && Number.isFinite(total) && total >= 0) {
|
||||
manualTotalRowCount.value = total;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!props.connectionId) return;
|
||||
const countTarget = await buildCurrentCountTarget();
|
||||
if (!countTarget?.sql) return;
|
||||
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", countTarget.sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId)));
|
||||
|
|
@ -4385,10 +4457,16 @@ function formatCellCached(value: CellValue, columnIndex?: number): string {
|
|||
return rememberPrimitiveCellFormat(key, formatCell(value, columnIndex));
|
||||
}
|
||||
|
||||
function rowNumberPageOffset(): number {
|
||||
if (infiniteScrollEnabled.value) return 0;
|
||||
if (typeof props.pageOffset === "number" && props.pageOffset >= 0) return props.pageOffset;
|
||||
return (currentPage.value - 1) * pageSize.value;
|
||||
}
|
||||
|
||||
function rowNumberText(item: RowItem | undefined): string {
|
||||
if (!item) return "";
|
||||
if (item.isDraft) return "*";
|
||||
return String(infiniteScrollEnabled.value ? item.displayIndex + 1 : item.displayIndex + 1 + (currentPage.value - 1) * pageSize.value);
|
||||
return String(item.displayIndex + 1 + rowNumberPageOffset());
|
||||
}
|
||||
|
||||
function draftCellPlaceholder(item: RowItem | undefined, columnIndex: number): string | null {
|
||||
|
|
@ -4546,12 +4624,12 @@ function dataGridCellFromClientPoint(clientX: number, clientY: number): { rowInd
|
|||
const scroller = dataGridSelectionScroller();
|
||||
if (!scroller) return null;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const clampedX = Math.min(rect.right - 1, Math.max(rect.left + DATA_GRID_ROW_NUM_WIDTH + 1, clientX));
|
||||
const clampedX = Math.min(rect.right - 1, Math.max(rect.left + rowNumberWidth.value + 1, clientX));
|
||||
const clampedY = Math.min(rect.bottom - 1, Math.max(rect.top + 1, clientY));
|
||||
|
||||
if (useCanvasGridRows.value) {
|
||||
const rowIndex = Math.floor((scroller.scrollTop + clampedY - rect.top) / CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||
const visibleColIdx = canvasColumnAt(scroller.scrollLeft + clampedX - rect.left - DATA_GRID_ROW_NUM_WIDTH);
|
||||
const visibleColIdx = canvasColumnAt(scroller.scrollLeft + clampedX - rect.left - rowNumberWidth.value);
|
||||
if (rowIndex < 0 || rowIndex >= displayRowCount.value || visibleColIdx < 0) return null;
|
||||
const item = displayItemAt(rowIndex);
|
||||
return item ? { rowIndex: item.displayIndex, colIndex: visibleColIdx } : null;
|
||||
|
|
@ -4578,7 +4656,7 @@ function dataGridRowFromClientPoint(_clientX: number, clientY: number): number |
|
|||
return item?.displayIndex ?? null;
|
||||
}
|
||||
|
||||
const target = document.elementFromPoint(rect.left + Math.min(DATA_GRID_ROW_NUM_WIDTH / 2, rect.width / 2), clampedY);
|
||||
const target = document.elementFromPoint(rect.left + Math.min(rowNumberWidth.value / 2, rect.width / 2), clampedY);
|
||||
const row = target instanceof Element ? target.closest<HTMLElement>("[data-row-index]") : null;
|
||||
const rowIndex = Number(row?.dataset.rowIndex);
|
||||
return Number.isInteger(rowIndex) ? rowIndex : null;
|
||||
|
|
@ -4681,14 +4759,14 @@ function canvasHitTest(event: MouseEvent): { rowIndex: number; visibleColIdx: nu
|
|||
const y = event.clientY - rect.top;
|
||||
const rowIndex = Math.floor((scroller.scrollTop + y) / CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||
if (rowIndex < 0 || rowIndex >= displayRowCount.value) return null;
|
||||
if (x < DATA_GRID_ROW_NUM_WIDTH) return { rowIndex, visibleColIdx: -1, rowNumber: true };
|
||||
if (x < rowNumberWidth.value) return { rowIndex, visibleColIdx: -1, rowNumber: true };
|
||||
// 冻结列区域不受 scrollLeft 影响,需要特殊处理
|
||||
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0;
|
||||
let contentX: number;
|
||||
if (frozenWidth > 0 && x - DATA_GRID_ROW_NUM_WIDTH < frozenWidth) {
|
||||
contentX = x - DATA_GRID_ROW_NUM_WIDTH;
|
||||
if (frozenWidth > 0 && x - rowNumberWidth.value < frozenWidth) {
|
||||
contentX = x - rowNumberWidth.value;
|
||||
} else {
|
||||
contentX = scroller.scrollLeft + x - DATA_GRID_ROW_NUM_WIDTH;
|
||||
contentX = scroller.scrollLeft + x - rowNumberWidth.value;
|
||||
}
|
||||
const visibleColIdx = canvasColumnAt(contentX);
|
||||
if (visibleColIdx < 0) return null;
|
||||
|
|
@ -4908,7 +4986,7 @@ function canvasCellViewportRect(rowIndex: number, visibleColIdx: number) {
|
|||
if (colWidth === undefined) return null;
|
||||
// 冻结列不受 scrollLeft 影响
|
||||
const isFrozen = visibleColIdx < frozenColumnCount.value;
|
||||
const left = DATA_GRID_ROW_NUM_WIDTH + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - (isFrozen ? 0 : gridHorizontalScrollLeft.value);
|
||||
const left = rowNumberWidth.value + (renderedColumnOffsets.value[visibleColIdx] ?? 0) - (isFrozen ? 0 : gridHorizontalScrollLeft.value);
|
||||
return {
|
||||
left,
|
||||
top: rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - canvasScrollTop.value,
|
||||
|
|
@ -4931,7 +5009,7 @@ function canvasEditingCellIsVisible() {
|
|||
if (!rect) return false;
|
||||
const viewportWidth = canvasEffectiveViewportWidth();
|
||||
const viewportHeight = canvasEffectiveViewportHeight();
|
||||
const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left);
|
||||
const clippedLeft = Math.max(rowNumberWidth.value, rect.left);
|
||||
const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width;
|
||||
return rect.top + rect.height > 0 && rect.top < viewportHeight && clippedRight - clippedLeft > 0;
|
||||
}
|
||||
|
|
@ -4974,7 +5052,7 @@ const canvasEditingCellStyle = computed(() => {
|
|||
const cell = canvasEditingCell.value;
|
||||
if (!cell) return {};
|
||||
const viewportWidth = canvasEffectiveViewportWidth();
|
||||
const clippedLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, cell.rect.left);
|
||||
const clippedLeft = Math.max(rowNumberWidth.value, cell.rect.left);
|
||||
const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, cell.rect.left + cell.rect.width) : cell.rect.left + cell.rect.width;
|
||||
return {
|
||||
left: `${clippedLeft}px`,
|
||||
|
|
@ -4996,7 +5074,7 @@ const canvasDetailButtonCell = computed(() => {
|
|||
if (!rect) return null;
|
||||
const viewportWidth = canvasEffectiveViewportWidth();
|
||||
const viewportHeight = canvasEffectiveViewportHeight();
|
||||
const visibleLeft = Math.max(DATA_GRID_ROW_NUM_WIDTH, rect.left);
|
||||
const visibleLeft = Math.max(rowNumberWidth.value, rect.left);
|
||||
const visibleRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width;
|
||||
const canQuickDownload = canQuickDownloadCellValue(target.rowIndex, target.col);
|
||||
const minWidth = canQuickDownload ? 46 : 24;
|
||||
|
|
@ -5010,7 +5088,7 @@ const canvasDetailButtonStyle = computed(() => {
|
|||
const actionWidth = cell.canQuickDownload ? 44 : 22;
|
||||
const edgeGap = 6;
|
||||
return {
|
||||
left: `${Math.max(DATA_GRID_ROW_NUM_WIDTH, cell.rect.left + cell.rect.width - actionWidth - edgeGap)}px`,
|
||||
left: `${Math.max(rowNumberWidth.value, cell.rect.left + cell.rect.width - actionWidth - edgeGap)}px`,
|
||||
top: `${cell.rect.top + cell.rect.height / 2}px`,
|
||||
};
|
||||
});
|
||||
|
|
@ -5045,7 +5123,7 @@ function drawCanvasGrid() {
|
|||
columnPreviewOffsets: columnHeaderPreviewOffsets.value,
|
||||
columnPreviewSourceVisibleIndex: columnHeaderPreviewSourceVisibleIndex.value,
|
||||
visibleColumnIndexes: visibleColumnIndexes.value,
|
||||
rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH,
|
||||
rowNumberWidth: rowNumberWidth.value,
|
||||
hoverCell: canvasHoverCell.value,
|
||||
isScrolling: isScrolling.value,
|
||||
editingCell: editingCell.value,
|
||||
|
|
@ -5058,8 +5136,7 @@ function drawCanvasGrid() {
|
|||
cellIsSelected,
|
||||
cellCanHover: canEditCellItem,
|
||||
infiniteScrollEnabled: infiniteScrollEnabled.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
pageOffset: rowNumberPageOffset(),
|
||||
frozenColumnCount: frozenColumnCount.value,
|
||||
columnAligns: columnAligns.value,
|
||||
rightAlignedActionCell: canvasRightAlignedActionCell.value,
|
||||
|
|
@ -5924,11 +6001,11 @@ function scrollGridColumnIntoView(visibleColIdx: number) {
|
|||
const colLeft = columnContentOffsetLeft(visibleColIdx);
|
||||
const colRight = colLeft + (renderedColumnWidths.value[visibleColIdx] ?? 0);
|
||||
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0;
|
||||
const viewportLeft = scroller.scrollLeft + DATA_GRID_ROW_NUM_WIDTH + frozenWidth;
|
||||
const viewportLeft = scroller.scrollLeft + rowNumberWidth.value + frozenWidth;
|
||||
const viewportRight = scroller.scrollLeft + scroller.clientWidth;
|
||||
|
||||
if (colLeft < viewportLeft) {
|
||||
scroller.scrollLeft = Math.max(0, colLeft - DATA_GRID_ROW_NUM_WIDTH - frozenWidth);
|
||||
scroller.scrollLeft = Math.max(0, colLeft - rowNumberWidth.value - frozenWidth);
|
||||
} else if (colRight > viewportRight) {
|
||||
scroller.scrollLeft = Math.max(0, colRight - scroller.clientWidth);
|
||||
}
|
||||
|
|
@ -7470,45 +7547,40 @@ function stopLoadingElapsedTimer() {
|
|||
|
||||
function startLoadingElapsedTimer() {
|
||||
stopLoadingElapsedTimer();
|
||||
if (!dataGridIsActive || !props.loading) return;
|
||||
if (!dataGridIsActive || !gridSurfaceBusy.value) return;
|
||||
_loadingStart = Date.now();
|
||||
loadingElapsed.value = 0;
|
||||
const updateOnNextFrame = () => {
|
||||
if (!dataGridIsActive || !props.loading) return;
|
||||
if (!dataGridIsActive || !gridSurfaceBusy.value) return;
|
||||
loadingElapsed.value = Date.now() - _loadingStart;
|
||||
_loadingFrame = window.requestAnimationFrame(updateOnNextFrame);
|
||||
};
|
||||
_loadingFrame = window.requestAnimationFrame(updateOnNextFrame);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(isLoading) => {
|
||||
stopLoadingElapsedTimer();
|
||||
if (isDebugLoggingEnabled()) {
|
||||
logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
});
|
||||
}
|
||||
if (isLoading) {
|
||||
startLoadingElapsedTimer();
|
||||
} else {
|
||||
if (isDebugLoggingEnabled()) {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
});
|
||||
});
|
||||
watch(gridSurfaceBusy, (isLoading) => {
|
||||
stopLoadingElapsedTimer();
|
||||
if (isDebugLoggingEnabled()) {
|
||||
logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
});
|
||||
}
|
||||
if (isLoading) {
|
||||
startLoadingElapsedTimer();
|
||||
} else if (isDebugLoggingEnabled()) {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", {
|
||||
traceId: dataGridTraceId,
|
||||
cacheKey: props.cacheKey,
|
||||
elapsedSinceSetup: dataGridElapsed(),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
startLoadingElapsedTimer();
|
||||
|
|
@ -8949,7 +9021,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
</template>
|
||||
</RecycleScroller>
|
||||
<!-- Infinite scroll loading indicator for RecycleScroller -->
|
||||
<div v-if="infiniteScrollEnabled && infiniteScrollLoading && !loading" class="flex items-center justify-center py-2 text-xs text-muted-foreground">
|
||||
<div v-if="infiniteScrollEnabled && infiniteScrollLoading && !gridSurfaceBusy" class="flex items-center justify-center py-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="w-3 h-3 animate-spin mr-1" />
|
||||
{{ t("grid.loadingMore") }}
|
||||
</div>
|
||||
|
|
@ -8959,7 +9031,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
<div v-if="hasGridVerticalOverflow" ref="gridVerticalScrollbarTrackRef" class="data-grid-vertical-scrollbar" @pointerdown="startGridVerticalScrollbarDrag">
|
||||
<div ref="gridVerticalScrollbarThumbRef" class="data-grid-vertical-scrollbar__thumb" />
|
||||
</div>
|
||||
<div v-if="loading" class="absolute inset-0 z-20 bg-background/50 flex items-center justify-center">
|
||||
<div v-if="gridSurfaceBusy" class="absolute inset-0 z-20 bg-background/50 flex items-center justify-center">
|
||||
<div class="flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>{{ formatElapsedSeconds(loadingElapsed) }}s</span>
|
||||
|
|
@ -9349,11 +9421,11 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<span v-if="hasData" class="shrink-0">
|
||||
{{ t(showTruncationWarning ? "grid.loadedRows" : "grid.totalRows", { count: result.rows.length }) }}
|
||||
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountIsExact === false ? "grid.totalRowCountAtLeast" : "grid.totalRowCount", { count: displayedTotalRowCount }) }}</span>
|
||||
<span v-else-if="totalRowCountBusy" class="text-muted-foreground/70">
|
||||
<span v-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountLabelKey, { count: displayedTotalRowCount }) }}</span>
|
||||
<span v-if="totalRowCountBusy" class="text-muted-foreground/70">
|
||||
{{ t("grid.totalRowCountLoading") }}
|
||||
</span>
|
||||
<button v-else-if="canCalculateTotalRowCount" type="button" class="text-muted-foreground/70 hover:text-foreground hover:underline underline-offset-2 disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
|
||||
<button v-else-if="showExactTotalCountAction" type="button" class="text-muted-foreground/70 hover:text-foreground hover:underline underline-offset-2 disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
|
||||
{{ t("grid.calculateTotalRowsInline") }}
|
||||
</button>
|
||||
</span>
|
||||
|
|
@ -9385,7 +9457,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
:pagination-enabled="paginationEnabled"
|
||||
:selection-summary="selectionSummary"
|
||||
:selection-summary-sum-text="selectionSummarySumText"
|
||||
:loading="loading"
|
||||
:loading="gridSurfaceBusy"
|
||||
:infinite-scroll-enabled="infiniteScrollEnabled"
|
||||
:infinite-scroll-all-loaded="infiniteScrollAllLoaded"
|
||||
:page-size="pageSize"
|
||||
|
|
|
|||
|
|
@ -86,11 +86,11 @@ const emit = defineEmits<{
|
|||
</Tooltip>
|
||||
</div>
|
||||
</LightDropdown>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('firstPage')"><ChevronsLeft class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || currentPage <= 1" @click="emit('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
|
||||
<span class="shrink-0 tabular-nums">{{ currentPage }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="!canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="!canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
|
||||
</template>
|
||||
<DataGridExportMenu :items="exportMenuItems" :label="t('grid.export')" :on-select="(value) => emit('selectExport', value)" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -212,6 +212,10 @@ describe("DataGridPagination", () => {
|
|||
expect(previousPage).toHaveBeenCalledOnce();
|
||||
expect(nextPage).toHaveBeenCalledOnce();
|
||||
expect(lastPage).toHaveBeenCalledOnce();
|
||||
|
||||
await mounted.setProps({ loading: true });
|
||||
const busyNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
|
||||
expect(busyNavigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
|
||||
});
|
||||
|
||||
it("hides pagination controls when the data source does not support paging", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { dataGridTotalRowCountLabelKey } from "@/lib/dataGrid/dataGridPagination";
|
||||
import DataGrid from "../DataGrid.vue";
|
||||
|
||||
type VuePropDefinition = { default?: unknown };
|
||||
|
|
@ -8,5 +9,12 @@ describe("DataGrid total row count exactness", () => {
|
|||
it("treats totals as exact unless a caller explicitly marks them as a lower bound", () => {
|
||||
const component = DataGrid as unknown as VueComponentWithProps;
|
||||
expect(component.props?.totalRowCountIsExact?.default).toBe(true);
|
||||
expect(component.props?.inexactTotalRowCountMode?.default).toBe("at-least");
|
||||
});
|
||||
|
||||
it("keeps lower-bound and estimated total labels distinct", () => {
|
||||
expect(dataGridTotalRowCountLabelKey(true, "estimated")).toBe("grid.totalRowCount");
|
||||
expect(dataGridTotalRowCountLabelKey(false, "at-least")).toBe("grid.totalRowCountAtLeast");
|
||||
expect(dataGridTotalRowCountLabelKey(false, "estimated")).toBe("grid.totalRowCountEstimated");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { computed, nextTick, ref } from "vue";
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
|
||||
import { clearDataGridColumnWidthStates, createDataGridColumnMeasurementSignature, createDataGridColumnStructureSignature, DATA_GRID_COLUMN_WIDTH_STATE_LIMIT, dataGridColumnWidthStateCount, loadDataGridColumnWidthState, saveDataGridColumnWidthState } from "@/lib/dataGrid/dataGridColumnWidthState";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, resizeDataGridColumnWidth, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resizeDataGridColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
|
||||
function createResizeState(options: { columns: string[]; rows: Array<Array<string | number | boolean | null>>; columnIndexes?: number[]; columnTypes?: string[]; cacheKey?: string; density?: "compact" | "standard" | "comfortable"; compactColumnHeaderActions?: boolean; headerTextWidth?: number }) {
|
||||
const compact = ref(options.compactColumnHeaderActions ?? true);
|
||||
|
|
@ -301,3 +301,19 @@ describe("useDataGridColumnResize", () => {
|
|||
expect(comf.columnWidths.value[0]).toBeGreaterThanOrEqual(std.columnWidths.value[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dataGridRowNumberColumnWidth", () => {
|
||||
it("keeps the default width for small page indexes", () => {
|
||||
expect(dataGridRowNumberColumnWidth(999)).toBe(DATA_GRID_ROW_NUM_WIDTH);
|
||||
expect(dataGridRowNumberColumnWidth(9999)).toBe(DATA_GRID_ROW_NUM_WIDTH);
|
||||
});
|
||||
|
||||
it("widens the gutter for multi-million row numbers", () => {
|
||||
expect(dataGridRowNumberColumnWidth(4_215_101)).toBeGreaterThan(DATA_GRID_ROW_NUM_WIDTH);
|
||||
expect(dataGridRowNumberColumnWidth(4_215_101)).toBe(dataGridRowNumberColumnWidth(9_999_999));
|
||||
});
|
||||
|
||||
it("prefers measured text width when provided", () => {
|
||||
expect(dataGridRowNumberColumnWidth(99, 12, () => 40)).toBe(56);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -405,7 +405,7 @@ export function useDataGridColumnLayout(options: {
|
|||
renderedColumnWidths: MaybeRefOrGetter<readonly number[]>;
|
||||
scrollLeft: MaybeRefOrGetter<number>;
|
||||
viewportWidth: MaybeRefOrGetter<number>;
|
||||
rowNumberWidth: number;
|
||||
rowNumberWidth: MaybeRefOrGetter<number>;
|
||||
bufferPx?: number;
|
||||
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
|
||||
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
|
||||
|
|
@ -427,7 +427,7 @@ export function useDataGridColumnLayout(options: {
|
|||
columnCount: toValue(options.visibleColumnIndexes).length,
|
||||
scrollLeft: toValue(options.scrollLeft),
|
||||
viewportWidth: toValue(options.viewportWidth),
|
||||
rowNumberWidth: options.rowNumberWidth,
|
||||
rowNumberWidth: toValue(options.rowNumberWidth),
|
||||
bufferPx: options.bufferPx ?? 900,
|
||||
}),
|
||||
);
|
||||
|
|
@ -478,7 +478,7 @@ export function useDataGridColumnLayout(options: {
|
|||
}
|
||||
|
||||
function columnContentOffsetLeft(visibleColIdx: number): number {
|
||||
return options.rowNumberWidth + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
|
||||
return toValue(options.rowNumberWidth) + (renderedColumnOffsets.value[visibleColIdx] ?? 0);
|
||||
}
|
||||
|
||||
const columnHeaderDragState = ref<ColumnHeaderDragState | null>(null);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,27 @@ import type { ColumnWidthDensity } from "@/stores/settingsStore";
|
|||
|
||||
type CellValue = string | number | boolean | null;
|
||||
|
||||
/** Minimum row-number gutter; fits ~4 digits with px-2 padding. */
|
||||
export const DATA_GRID_ROW_NUM_WIDTH = 48;
|
||||
|
||||
/** Largest absolute row number that may appear in the gutter for the current page/window. */
|
||||
export function resolveDataGridMaxRowNumber(options: { infiniteScroll: boolean; allRowsLoaded: boolean; currentPage: number; pageSize: number; rowCount: number }): number {
|
||||
if (options.infiniteScroll || options.allRowsLoaded) {
|
||||
return Math.max(1, options.rowCount);
|
||||
}
|
||||
const pageSize = Math.max(1, options.pageSize);
|
||||
return Math.max(1, Math.max(0, options.currentPage - 1) * pageSize + Math.max(options.rowCount, 1));
|
||||
}
|
||||
|
||||
/** Grow the sticky # column so multi-million row indexes are not clipped or spilled into data cells. */
|
||||
export function dataGridRowNumberColumnWidth(maxRowNumber: number, fontSize = 12, measureTextWidth?: (text: string) => number | undefined): number {
|
||||
const text = String(Math.max(1, Math.floor(Math.max(0, maxRowNumber))));
|
||||
const measured = measureTextWidth?.(text);
|
||||
const contentWidth = typeof measured === "number" && Number.isFinite(measured) && measured > 0 ? measured : text.length * Math.max(8, Math.ceil(fontSize * 0.65));
|
||||
// Keep ~4 digits in the default 48px gutter; measured text gets the same 16px padding as px-2.
|
||||
return Math.max(DATA_GRID_ROW_NUM_WIDTH, Math.ceil(contentWidth) + 16);
|
||||
}
|
||||
|
||||
export function resizeDataGridColumnWidth(startWidth: number, deltaX: number): number {
|
||||
return Math.max(DATA_GRID_COL_MIN_WIDTH, startWidth + deltaX);
|
||||
}
|
||||
|
|
@ -21,6 +40,7 @@ export interface UseDataGridColumnResizeOptions {
|
|||
columnStructureSignature: ComputedRef<string>;
|
||||
measureHeaderText?: (text: string) => number | undefined;
|
||||
headerMeasurementKey?: Ref<unknown>;
|
||||
rowNumberWidth?: Ref<number> | ComputedRef<number>;
|
||||
}
|
||||
|
||||
export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) {
|
||||
|
|
@ -143,14 +163,16 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
|||
|
||||
const renderedColumnWidths = computed(() => columnWidths.value.slice());
|
||||
|
||||
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + DATA_GRID_ROW_NUM_WIDTH);
|
||||
const resolvedRowNumberWidth = computed(() => options.rowNumberWidth?.value ?? DATA_GRID_ROW_NUM_WIDTH);
|
||||
|
||||
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0) + resolvedRowNumberWidth.value);
|
||||
|
||||
const columnVars = computed(() => {
|
||||
const vars: Record<string, string> = {};
|
||||
renderedColumnWidths.value.forEach((w, i) => {
|
||||
vars[`--col-w-${i}`] = `${w}px`;
|
||||
});
|
||||
vars["--row-num-w"] = `${DATA_GRID_ROW_NUM_WIDTH}px`;
|
||||
vars["--row-num-w"] = `${resolvedRowNumberWidth.value}px`;
|
||||
vars["--total-w"] = `${totalWidth.value}px`;
|
||||
return vars;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1018,6 +1018,7 @@ export default {
|
|||
loadedRows: "Loaded {count} rows",
|
||||
totalRowCount: "({count} total)",
|
||||
totalRowCountAtLeast: "(at least {count} total)",
|
||||
totalRowCountEstimated: "(~{count})",
|
||||
totalRowCountLoading: "(counting...)",
|
||||
loadingMore: "Loading more data...",
|
||||
allLoaded: "all loaded",
|
||||
|
|
|
|||
|
|
@ -966,6 +966,7 @@ export default withEnglishFallback({
|
|||
loadedRows: "{count} filas cargadas",
|
||||
totalRowCount: "({count} en total)",
|
||||
totalRowCountAtLeast: "(al menos {count} en total)",
|
||||
totalRowCountEstimated: "(aprox. {count})",
|
||||
totalRowCountLoading: "(contando...)",
|
||||
loadingMore: "Cargando más datos...",
|
||||
allLoaded: "todo cargado",
|
||||
|
|
|
|||
|
|
@ -964,6 +964,7 @@ export default withEnglishFallback({
|
|||
loadedRows: "{count} righe caricate",
|
||||
totalRowCount: "({count} in totale)",
|
||||
totalRowCountAtLeast: "(almeno {count} in totale)",
|
||||
totalRowCountEstimated: "(circa {count})",
|
||||
totalRowCountLoading: "(conteggio...)",
|
||||
loadingMore: "Caricamento altri dati...",
|
||||
allLoaded: "tutto caricato",
|
||||
|
|
|
|||
|
|
@ -965,6 +965,7 @@ export default withEnglishFallback({
|
|||
loadedRows: "{count}件読み込み済み",
|
||||
totalRowCount: "(全{count}件)",
|
||||
totalRowCountAtLeast: "(少なくとも{count}件)",
|
||||
totalRowCountEstimated: "(約 {count}件)",
|
||||
totalRowCountLoading: "(カウント中...)",
|
||||
calculateTotalRows: "総行数をカウント",
|
||||
calculateTotalRowsInline: "(総行数をカウント)",
|
||||
|
|
|
|||
|
|
@ -1006,6 +1006,7 @@ export default withEnglishFallback({
|
|||
loadedRows: "{count}행 로드됨",
|
||||
totalRowCount: "(전체 {count})",
|
||||
totalRowCountAtLeast: "(최소 {count})",
|
||||
totalRowCountEstimated: "(약 {count})",
|
||||
totalRowCountLoading: "(집계 중...)",
|
||||
loadingMore: "데이터를 더 불러오는 중...",
|
||||
allLoaded: "모두 로드됨",
|
||||
|
|
|
|||
|
|
@ -966,6 +966,7 @@ export default withEnglishFallback({
|
|||
loadedRows: "{count} linhas carregadas",
|
||||
totalRowCount: "({count} no total)",
|
||||
totalRowCountAtLeast: "(pelo menos {count} no total)",
|
||||
totalRowCountEstimated: "(aprox. {count})",
|
||||
totalRowCountLoading: "(contando...)",
|
||||
loadingMore: "Carregando mais dados...",
|
||||
allLoaded: "tudo carregado",
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,7 @@ export default withEnglishFallback({
|
|||
loadedRows: "已加载 {count} 行",
|
||||
totalRowCount: "(总计 {count} 行)",
|
||||
totalRowCountAtLeast: "(至少 {count} 行)",
|
||||
totalRowCountEstimated: "(约 {count} 行)",
|
||||
totalRowCountLoading: "(统计中...)",
|
||||
loadingMore: "加载更多数据...",
|
||||
allLoaded: "已全部加载",
|
||||
|
|
|
|||
|
|
@ -965,6 +965,7 @@ export default withEnglishFallback({
|
|||
loadedRows: "已載入 {count} 筆",
|
||||
totalRowCount: "(總計 {count} 筆)",
|
||||
totalRowCountAtLeast: "(至少 {count} 筆)",
|
||||
totalRowCountEstimated: "(約 {count} 筆)",
|
||||
totalRowCountLoading: "(統計中...)",
|
||||
loadingMore: "載入更多資料...",
|
||||
allLoaded: "已全部載入",
|
||||
|
|
|
|||
|
|
@ -63,8 +63,7 @@ function createBaseOptions(overrides: Partial<DrawCanvasDataGridOptions> = {}):
|
|||
cellIsSelected: () => false,
|
||||
cellCanHover: () => true,
|
||||
infiniteScrollEnabled: false,
|
||||
pageSize: 100,
|
||||
currentPage: 1,
|
||||
pageOffset: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { canGoNextDocumentPage, resolveDocumentQueryTotals } from "@/lib/document/documentQueryTotals";
|
||||
import { canGoNextDocumentPage, exactTotalFromIncompleteDocumentPage, isSameDocumentQueryTotalCountRequest, resolveDocumentQueryTotals, type DocumentQueryTotalCountRequest } from "@/lib/document/documentQueryTotals";
|
||||
|
||||
describe("document query totals", () => {
|
||||
it("uses exact totals as the pagination bound", () => {
|
||||
|
|
@ -21,4 +21,49 @@ describe("document query totals", () => {
|
|||
expect(canGoNextDocumentPage({ page: 999_999, pageSize: 10, rowCount: 10 })).toBe(true);
|
||||
expect(canGoNextDocumentPage({ page: 1_000_000, pageSize: 10, rowCount: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a short page as an exact total even when the backend estimate is inexact", () => {
|
||||
expect(exactTotalFromIncompleteDocumentPage({ page: 0, pageSize: 500, rowCount: 0 })).toBe(0);
|
||||
expect(exactTotalFromIncompleteDocumentPage({ page: 0, pageSize: 500, rowCount: 1 })).toBe(1);
|
||||
expect(resolveDocumentQueryTotals(1, false, { page: 0, pageSize: 500, rowCount: 1 })).toEqual({
|
||||
total: 1,
|
||||
totalIsExact: true,
|
||||
paginationTotal: 1,
|
||||
});
|
||||
expect(resolveDocumentQueryTotals(50, false, { page: 1, pageSize: 500, rowCount: 12 })).toEqual({
|
||||
total: 512,
|
||||
totalIsExact: true,
|
||||
paginationTotal: 512,
|
||||
});
|
||||
expect(resolveDocumentQueryTotals(658_320, false, { page: 0, pageSize: 500, rowCount: 500 })).toEqual({
|
||||
total: 658_320,
|
||||
totalIsExact: false,
|
||||
paginationTotal: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer an exact total from an empty page after the first page", () => {
|
||||
expect(exactTotalFromIncompleteDocumentPage({ page: 1, pageSize: 500, rowCount: 0 })).toBeUndefined();
|
||||
expect(resolveDocumentQueryTotals(658_320, false, { page: 1, pageSize: 500, rowCount: 0 })).toEqual({
|
||||
total: 658_320,
|
||||
totalIsExact: false,
|
||||
paginationTotal: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("matches exact-count requests across identity, filter, and generation", () => {
|
||||
const request: DocumentQueryTotalCountRequest = {
|
||||
connectionId: "connection-a",
|
||||
database: "database-a",
|
||||
collection: "collection-a",
|
||||
filter: '{"active":true}',
|
||||
generation: 3,
|
||||
};
|
||||
expect(isSameDocumentQueryTotalCountRequest(request, { ...request })).toBe(true);
|
||||
expect(isSameDocumentQueryTotalCountRequest(request, { ...request, connectionId: "connection-b" })).toBe(false);
|
||||
expect(isSameDocumentQueryTotalCountRequest(request, { ...request, database: "database-b" })).toBe(false);
|
||||
expect(isSameDocumentQueryTotalCountRequest(request, { ...request, collection: "collection-b" })).toBe(false);
|
||||
expect(isSameDocumentQueryTotalCountRequest(request, { ...request, filter: undefined })).toBe(false);
|
||||
expect(isSameDocumentQueryTotalCountRequest(request, { ...request, generation: 4 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -70,8 +70,7 @@ export interface DrawCanvasDataGridOptions {
|
|||
cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean;
|
||||
cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean;
|
||||
infiniteScrollEnabled: boolean;
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
pageOffset: number;
|
||||
frozenColumnCount?: number;
|
||||
columnAligns?: readonly ("left" | "right")[];
|
||||
rightAlignedActionCell?: CanvasRightAlignedActionCell | null;
|
||||
|
|
@ -267,8 +266,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||
cellIsSelected,
|
||||
cellCanHover,
|
||||
infiniteScrollEnabled,
|
||||
pageSize,
|
||||
currentPage,
|
||||
pageOffset,
|
||||
frozenColumnCount = 0,
|
||||
columnAligns,
|
||||
rightAlignedActionCell,
|
||||
|
|
@ -362,13 +360,18 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||
ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont;
|
||||
ctx.textAlign = "center";
|
||||
const textY = alignCanvasPixel(y + rowTextOffsetY, dpr);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||
ctx.clip();
|
||||
if (item.isDraft) {
|
||||
ctx.fillText("*", rowNumberTextX, textY);
|
||||
} else if (infiniteScrollEnabled) {
|
||||
ctx.fillText(String(item.displayIndex + 1), rowNumberTextX, textY);
|
||||
} else {
|
||||
ctx.fillText(String(item.displayIndex + 1 + pageSize * (currentPage - 1)), rowNumberTextX, textY);
|
||||
ctx.fillText(String(item.displayIndex + 1 + pageOffset), rowNumberTextX, textY);
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.font = normalFont;
|
||||
|
||||
ctx.strokeStyle = theme.border;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ export interface CanFetchNextDataGridSegmentOptions {
|
|||
allRowsLoaded?: boolean;
|
||||
}
|
||||
|
||||
export type DataGridInexactTotalRowCountMode = "at-least" | "estimated";
|
||||
|
||||
export function dataGridTotalRowCountLabelKey(totalRowCountIsExact: boolean, inexactMode: DataGridInexactTotalRowCountMode): "grid.totalRowCount" | "grid.totalRowCountAtLeast" | "grid.totalRowCountEstimated" {
|
||||
if (totalRowCountIsExact) return "grid.totalRowCount";
|
||||
return inexactMode === "estimated" ? "grid.totalRowCountEstimated" : "grid.totalRowCountAtLeast";
|
||||
}
|
||||
|
||||
export function resolveDataGridPaginationTotal(options: { paginationTotalRowCount?: number; serverKnownTotalRowCount?: number; totalRowCountIsExact: boolean }): number | undefined {
|
||||
if (options.paginationTotalRowCount !== undefined) return options.paginationTotalRowCount;
|
||||
return options.totalRowCountIsExact ? options.serverKnownTotalRowCount : undefined;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,46 @@ export interface DocumentQueryTotals {
|
|||
paginationTotal: number | undefined;
|
||||
}
|
||||
|
||||
export function resolveDocumentQueryTotals(total: number, totalIsExact: boolean): DocumentQueryTotals {
|
||||
export interface DocumentQueryTotalsPageOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
export interface DocumentQueryTotalCountRequest {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
filter: string | undefined;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export function isSameDocumentQueryTotalCountRequest(left: DocumentQueryTotalCountRequest, right: DocumentQueryTotalCountRequest): boolean {
|
||||
return left.connectionId === right.connectionId && left.database === right.database && left.collection === right.collection && left.filter === right.filter && left.generation === right.generation;
|
||||
}
|
||||
|
||||
/** When the current page is short, the true total is exactly offset + rowCount. */
|
||||
export function exactTotalFromIncompleteDocumentPage(options: DocumentQueryTotalsPageOptions): number | undefined {
|
||||
const page = options.page ?? 0;
|
||||
const pageSize = options.pageSize;
|
||||
const rowCount = options.rowCount;
|
||||
if (!Number.isInteger(page) || page < 0 || typeof pageSize !== "number" || !Number.isInteger(pageSize) || pageSize <= 0 || typeof rowCount !== "number" || !Number.isInteger(rowCount) || rowCount < 0 || rowCount >= pageSize || (page > 0 && rowCount === 0)) {
|
||||
return undefined;
|
||||
}
|
||||
return page * pageSize + rowCount;
|
||||
}
|
||||
|
||||
export function resolveDocumentQueryTotals(total: number, totalIsExact: boolean, pageOptions?: DocumentQueryTotalsPageOptions): DocumentQueryTotals {
|
||||
if (!totalIsExact) {
|
||||
const exactFromPage = exactTotalFromIncompleteDocumentPage(pageOptions ?? {});
|
||||
if (typeof exactFromPage === "number") {
|
||||
return {
|
||||
total: exactFromPage,
|
||||
totalIsExact: true,
|
||||
paginationTotal: exactFromPage,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
total,
|
||||
totalIsExact,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts";
|
||||
|
||||
|
|
@ -162,3 +163,40 @@ test("auto-redirect: total is undefined — guard prevents redirect attempt", ()
|
|||
const total = undefined;
|
||||
assert.equal(!total || (total as any) <= 0, true, "guard should prevent redirect when total is unknown");
|
||||
});
|
||||
|
||||
test("last-page COUNT shows grid busy overlay before executeQuery", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /const gridSurfaceBusy = computed\(\(\) => props\.loading === true \|\| totalRowCountBusy\.value\)/);
|
||||
assert.match(source, /v-if="gridSurfaceBusy"/);
|
||||
assert.match(source, /async function beginManualTotalRowCount/);
|
||||
assert.match(source, /await nextTick\(\);/);
|
||||
const lastPageFn = source.match(/async function lastPage\(\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
assert.match(lastPageFn, /beginManualTotalRowCount\(\)/);
|
||||
assert.match(lastPageFn, /buildCurrentCountTarget\(\)/);
|
||||
assert.ok(lastPageFn.indexOf("beginManualTotalRowCount") < lastPageFn.indexOf("buildCurrentCountTarget"), "busy UI must start before COUNT SQL is built");
|
||||
});
|
||||
|
||||
test("last page always re-counts when a count path is available", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const lastPageFn = source.match(/async function lastPage\(\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
const knownTotalIdx = lastPageFn.indexOf("hasKnownPaginationTotalRowCount");
|
||||
const countCallbackIdx = lastPageFn.indexOf("props.countTotalRows");
|
||||
const countSqlIdx = lastPageFn.indexOf("buildCurrentCountTarget");
|
||||
assert.ok(countCallbackIdx >= 0 && countSqlIdx >= 0, "last page must keep count paths");
|
||||
assert.ok(knownTotalIdx < 0 || knownTotalIdx > countSqlIdx, "known totals are only a fallback after re-COUNT");
|
||||
});
|
||||
|
||||
test("jumping to last page does not rewrite indexes before the new page loads", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
const jumpFn = source.match(/function jumpToCountedLastPage\(total: number\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
assert.match(jumpFn, /emit\("paginate"/);
|
||||
assert.doesNotMatch(jumpFn, /currentPage\.value\s*=/);
|
||||
assert.match(source, /function rowNumberPageOffset/);
|
||||
});
|
||||
|
||||
test("row number gutter width tracks the largest visible row index", () => {
|
||||
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
|
||||
assert.match(source, /dataGridRowNumberColumnWidth/);
|
||||
assert.match(source, /resolveDataGridMaxRowNumber/);
|
||||
assert.match(source, /rowNumberWidth,/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,6 +63,25 @@ test("document save uses shared identity plan and write helpers", () => {
|
|||
assert.doesNotMatch(source, /async function replaceDocumentStoreDocument/);
|
||||
});
|
||||
|
||||
test("document table wires on-demand exact total counting for estimated mongo totals", () => {
|
||||
const source = documentBrowserSource();
|
||||
assert.match(source, /:count-total-rows="countExactDocumentTotal"/);
|
||||
assert.match(source, /:inexact-total-row-count-mode="documentStoreProvider\.kind === 'mongodb' \? 'estimated' : 'at-least'"/);
|
||||
assert.match(source, /async function countExactDocumentTotal/);
|
||||
assert.match(source, /const request = loadedDocumentQueryTotalCountRequest/);
|
||||
assert.match(source, /if \(!request \|\| !isCurrentDocumentQueryTotalCountRequest\(request\)\) return undefined;/);
|
||||
assert.match(source, /api\.mongoCountDocuments\(request\.connectionId, request\.database, request\.collection, request\.filter, "accurate"\)/);
|
||||
assert.equal(source.match(/if \(!isCurrentDocumentQueryTotalCountRequest\(request\)\) return undefined;/g)?.length, 2);
|
||||
assert.match(source, /"accurate"/);
|
||||
});
|
||||
|
||||
test("document pagination commits page index with fetched rows", () => {
|
||||
const source = documentBrowserSource();
|
||||
assert.match(source, /async function load\(options: \{ page\?: number \} = \{\}\)/);
|
||||
assert.match(source, /void load\(\{ page: nextPage \}\)/);
|
||||
assert.match(source, /if \(options\.page !== undefined\) page\.value = options\.page;/);
|
||||
});
|
||||
|
||||
test("document query inputs apply on Enter and reserve Shift+Enter for newlines", () => {
|
||||
const source = documentBrowserSource();
|
||||
assert.equal(source.match(/@keydown\.enter\.exact\.prevent="applyFilter"/g)?.length, 2);
|
||||
|
|
|
|||
Loading…
Reference in New Issue