fix(mongo): stabilize estimated-total pagination
This commit is contained in:
1 parent
96038d2b8a
commit
8f3fd0ebfb
23 files changed
+462
-124
No files matched your search
@@ -19,7 +19,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||||
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
|
||||||
import { clampDocumentPage, documentPageRequestLimit, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
|
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 {
|
import {
|
||||||
arrayObjectAncestorPathForDocumentField,
|
arrayObjectAncestorPathForDocumentField,
|
||||||
buildDocumentFilterCondition,
|
buildDocumentFilterCondition,
|
||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
type DocumentFieldPathNode,
|
type DocumentFieldPathNode,
|
||||||
type DocumentFilterMode,
|
type DocumentFilterMode,
|
||||||
type DocumentFilterRule,
|
type DocumentFilterRule,
|
||||||
|
type DocumentStoreKind,
|
||||||
type ElasticsearchBoolClause,
|
type ElasticsearchBoolClause,
|
||||||
type ElasticsearchQueryType,
|
type ElasticsearchQueryType,
|
||||||
} from "@/lib/app/documentStoreProvider";
|
} from "@/lib/app/documentStoreProvider";
|
||||||
@@ -144,6 +145,9 @@ let elasticsearchExactTotal: number | undefined;
|
|||||||
let elasticsearchPaginationLowerBound: number | undefined;
|
let elasticsearchPaginationLowerBound: number | undefined;
|
||||||
let elasticsearchCountExecutionId = "";
|
let elasticsearchCountExecutionId = "";
|
||||||
let elasticsearchCountGeneration = 0;
|
let elasticsearchCountGeneration = 0;
|
||||||
|
type LoadedDocumentQueryTotalCountRequest = DocumentQueryTotalCountRequest & { storeKind: DocumentStoreKind };
|
||||||
|
let loadedDocumentQueryTotalCountRequest: LoadedDocumentQueryTotalCountRequest | undefined;
|
||||||
|
let documentRequestGeneration = 0;
|
||||||
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
|
const documentStoreProvider = computed(() => documentStoreProviderFor(props.databaseType));
|
||||||
const documentColumnLayoutScopeKey = computed(() =>
|
const documentColumnLayoutScopeKey = computed(() =>
|
||||||
documentDataGridColumnLayoutScopeKey({
|
documentDataGridColumnLayoutScopeKey({
|
||||||
@@ -772,6 +776,13 @@ function elasticsearchCountFilterKey(filter: string | undefined): string {
|
|||||||
return JSON.stringify([props.connectionId, props.database, props.collection, filter ?? ""]);
|
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() {
|
function cancelElasticsearchCount() {
|
||||||
elasticsearchCountGeneration++;
|
elasticsearchCountGeneration++;
|
||||||
const executionId = elasticsearchCountExecutionId;
|
const executionId = elasticsearchCountExecutionId;
|
||||||
@@ -790,13 +801,12 @@ function resetElasticsearchTotals(options: { preservePaginationTotal?: boolean }
|
|||||||
totalIsExact.value = nextTotals.totalIsExact;
|
totalIsExact.value = nextTotals.totalIsExact;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampPageToPaginationTotal(): boolean {
|
function clampPageToPaginationTotal(): number | undefined {
|
||||||
const cap = paginationTotal.value;
|
const cap = paginationTotal.value;
|
||||||
if (cap === undefined) return false;
|
if (cap === undefined) return undefined;
|
||||||
const nextPage = clampDocumentPage(page.value, pageSize.value, cap);
|
const nextPage = clampDocumentPage(page.value, pageSize.value, cap);
|
||||||
if (page.value === nextPage) return false;
|
if (page.value === nextPage) return undefined;
|
||||||
page.value = nextPage;
|
return nextPage;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startElasticsearchExactCount(filter: string | undefined) {
|
function startElasticsearchExactCount(filter: string | undefined) {
|
||||||
@@ -817,7 +827,8 @@ function startElasticsearchExactCount(filter: string | undefined) {
|
|||||||
total.value = totals.total;
|
total.value = totals.total;
|
||||||
totalIsExact.value = totals.totalIsExact;
|
totalIsExact.value = totals.totalIsExact;
|
||||||
paginationTotal.value = totals.paginationTotal;
|
paginationTotal.value = totals.paginationTotal;
|
||||||
if (clampPageToPaginationTotal()) void load();
|
const clampedPage = clampPageToPaginationTotal();
|
||||||
|
if (clampedPage !== undefined) void load({ page: clampedPage });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// The lower-bound result remains truthful when a background count fails.
|
// The lower-bound result remains truthful when a background count fails.
|
||||||
@@ -862,27 +873,35 @@ function applyElasticsearchSearchTotal(searchTotal: number, isExact: boolean, fi
|
|||||||
startElasticsearchExactCount(filter);
|
startElasticsearchExactCount(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load(options: { page?: number } = {}) {
|
||||||
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
||||||
|
const requestGeneration = ++documentRequestGeneration;
|
||||||
const executionId = uuid();
|
const executionId = uuid();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
documentLoadExecutionId.value = executionId;
|
documentLoadExecutionId.value = executionId;
|
||||||
documentLoadCancelling.value = false;
|
documentLoadCancelling.value = false;
|
||||||
startDocumentLoadingTimer();
|
startDocumentLoadingTimer();
|
||||||
error.value = "";
|
error.value = "";
|
||||||
|
const requestPage = options.page ?? page.value;
|
||||||
const previousSelectedIdx = selectedIdx.value;
|
const previousSelectedIdx = selectedIdx.value;
|
||||||
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
|
const previousSelectedId = previousSelectedIdx === null ? null : documentIdentity(documents.value[previousSelectedIdx]);
|
||||||
try {
|
try {
|
||||||
|
const connectionId = props.connectionId;
|
||||||
|
const database = props.database;
|
||||||
|
const collection = props.collection;
|
||||||
|
const storeKind = documentStoreProvider.value.kind;
|
||||||
const filter = currentDocumentFilter();
|
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();
|
resetElasticsearchTotals();
|
||||||
}
|
}
|
||||||
const sort = currentDocumentSortJson(sortInput.value);
|
const sort = currentDocumentSortJson(sortInput.value);
|
||||||
const skip = page.value * pageSize.value;
|
const skip = requestPage * pageSize.value;
|
||||||
const result = await api.documentFindDocuments(props.connectionId, props.database, props.collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId);
|
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, executionId);
|
||||||
if (documentLoadExecutionId.value !== executionId) return;
|
if (documentLoadExecutionId.value !== executionId) return;
|
||||||
|
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
|
||||||
const nextDocuments =
|
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) => {
|
? result.raw_documents.map((raw, index) => {
|
||||||
try {
|
try {
|
||||||
return asRecord(parseJsonPreservingLargeNumbers(raw));
|
return asRecord(parseJsonPreservingLargeNumbers(raw));
|
||||||
@@ -893,9 +912,12 @@ async function load() {
|
|||||||
: result.documents.map(asRecord);
|
: result.documents.map(asRecord);
|
||||||
const hasTypePreservingCopyDocuments = result.extended_documents?.length === nextDocuments.length;
|
const hasTypePreservingCopyDocuments = result.extended_documents?.length === nextDocuments.length;
|
||||||
const nextCopyDocuments = hasTypePreservingCopyDocuments ? result.extended_documents!.map(asRecord) : nextDocuments;
|
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;
|
documents.value = nextDocuments;
|
||||||
copyDocuments.value = nextCopyDocuments;
|
copyDocuments.value = nextCopyDocuments;
|
||||||
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
|
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
|
||||||
|
loadedDocumentQueryTotalCountRequest = countRequest;
|
||||||
if (nextDocuments.length > 0) {
|
if (nextDocuments.length > 0) {
|
||||||
const keySet = new Set<string>();
|
const keySet = new Set<string>();
|
||||||
keySet.add("_id");
|
keySet.add("_id");
|
||||||
@@ -906,11 +928,15 @@ async function load() {
|
|||||||
}
|
}
|
||||||
lastGridColumns.value = [...keySet];
|
lastGridColumns.value = [...keySet];
|
||||||
}
|
}
|
||||||
if (documentStoreProvider.value.kind === "elasticsearch") {
|
if (storeKind === "elasticsearch") {
|
||||||
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
|
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
|
||||||
} else {
|
} else {
|
||||||
cancelElasticsearchCount();
|
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;
|
total.value = totals.total;
|
||||||
totalIsExact.value = totals.totalIsExact;
|
totalIsExact.value = totals.totalIsExact;
|
||||||
paginationTotal.value = totals.paginationTotal;
|
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() {
|
async function refreshDocuments() {
|
||||||
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
|
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
|
||||||
await load();
|
await load();
|
||||||
@@ -959,8 +1013,8 @@ function paginate(offset: number, limit: number) {
|
|||||||
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
|
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
|
||||||
pageSize.value = normalizedLimit;
|
pageSize.value = normalizedLimit;
|
||||||
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
|
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
|
||||||
page.value = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
|
const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
|
||||||
void load();
|
void load({ page: nextPage });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
|
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
|
||||||
@@ -1475,6 +1529,8 @@ onMounted(async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
|
||||||
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
if (documentLoadExecutionId.value) void api.cancelQuery(documentLoadExecutionId.value);
|
||||||
|
documentRequestGeneration++;
|
||||||
|
loadedDocumentQueryTotalCountRequest = undefined;
|
||||||
cancelElasticsearchCount();
|
cancelElasticsearchCount();
|
||||||
stopDocumentLoadingTimer();
|
stopDocumentLoadingTimer();
|
||||||
endTableSearchSplitResize();
|
endTableSearchSplitResize();
|
||||||
@@ -1616,7 +1672,9 @@ defineExpose({ focusSearch });
|
|||||||
:page-limit="pageSize"
|
:page-limit="pageSize"
|
||||||
:total-row-count="total"
|
:total-row-count="total"
|
||||||
:total-row-count-is-exact="totalIsExact"
|
:total-row-count-is-exact="totalIsExact"
|
||||||
|
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
|
||||||
:pagination-total-row-count="pageTotal"
|
:pagination-total-row-count="pageTotal"
|
||||||
|
:count-total-rows="countExactDocumentTotal"
|
||||||
@sort="onSort"
|
@sort="onSort"
|
||||||
@reload="refreshDocuments"
|
@reload="refreshDocuments"
|
||||||
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
|
@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 { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor";
|
||||||
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
|
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||||
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
|
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 { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
|
||||||
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
|
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";
|
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 { 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 { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, extractorUnavailableForDatabase, type DataGridCopyExtractorId } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||||
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
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 { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
|
||||||
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
|
import { useDataGridColumnLayout, useDataGridColumnLayoutState } from "@/composables/useDataGridColumnLayout";
|
||||||
import { useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
|
import { useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime";
|
||||||
@@ -245,6 +245,7 @@ const connectionStore = useConnectionStore();
|
|||||||
const queryStore = useQueryStore();
|
const queryStore = useQueryStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
|
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
|
||||||
|
const rowNumberWidth = ref(DATA_GRID_ROW_NUM_WIDTH);
|
||||||
const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMultiRowTranspose);
|
const multiRowTranspose = computed(() => settingsStore.editorSettings.dataGridMultiRowTranspose);
|
||||||
const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns);
|
const hideNullColumns = computed(() => settingsStore.editorSettings.dataGridHideNullColumns);
|
||||||
const { isDark, themePalette } = useTheme();
|
const { isDark, themePalette } = useTheme();
|
||||||
@@ -300,9 +301,12 @@ interface DataGridProps {
|
|||||||
countSql?: string;
|
countSql?: string;
|
||||||
totalRowCount?: number;
|
totalRowCount?: number;
|
||||||
totalRowCountIsExact?: boolean;
|
totalRowCountIsExact?: boolean;
|
||||||
|
inexactTotalRowCountMode?: DataGridInexactTotalRowCountMode;
|
||||||
paginationTotalRowCount?: number;
|
paginationTotalRowCount?: number;
|
||||||
paginationEnabled?: boolean;
|
paginationEnabled?: boolean;
|
||||||
totalRowCountLoading?: boolean;
|
totalRowCountLoading?: boolean;
|
||||||
|
/** Document stores (e.g. MongoDB) count exactly on demand without SQL tableMeta/countSql. */
|
||||||
|
countTotalRows?: () => Promise<number | undefined>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
exportSql?: string;
|
exportSql?: string;
|
||||||
@@ -322,6 +326,7 @@ const props = withDefaults(defineProps<DataGridProps>(), {
|
|||||||
// Vue casts absent Boolean props to false unless a default is explicit.
|
// Vue casts absent Boolean props to false unless a default is explicit.
|
||||||
// Regular grids have exact totals; document stores opt into lower-bound totals.
|
// Regular grids have exact totals; document stores opt into lower-bound totals.
|
||||||
totalRowCountIsExact: true,
|
totalRowCountIsExact: true,
|
||||||
|
inexactTotalRowCountMode: "at-least",
|
||||||
paginationEnabled: true,
|
paginationEnabled: true,
|
||||||
// Omitted row-action limits must keep normal table-data editing.
|
// Omitted row-action limits must keep normal table-data editing.
|
||||||
allowInsertRows: undefined,
|
allowInsertRows: undefined,
|
||||||
@@ -1898,6 +1903,7 @@ const { initColumnWidths, onResizeStart, autoFitColumn, renderedColumnWidths, to
|
|||||||
columnStructureSignature,
|
columnStructureSignature,
|
||||||
measureHeaderText: measureColumnHeaderText,
|
measureHeaderText: measureColumnHeaderText,
|
||||||
headerMeasurementKey: columnHeaderMeasurementKey,
|
headerMeasurementKey: columnHeaderMeasurementKey,
|
||||||
|
rowNumberWidth,
|
||||||
});
|
});
|
||||||
const gridStyle = computed(() => ({
|
const gridStyle = computed(() => ({
|
||||||
...columnVars.value,
|
...columnVars.value,
|
||||||
@@ -1932,7 +1938,7 @@ const {
|
|||||||
renderedColumnWidths,
|
renderedColumnWidths,
|
||||||
scrollLeft: gridHorizontalScrollLeft,
|
scrollLeft: gridHorizontalScrollLeft,
|
||||||
viewportWidth: gridViewportWidth,
|
viewportWidth: gridViewportWidth,
|
||||||
rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH,
|
rowNumberWidth,
|
||||||
headerRef,
|
headerRef,
|
||||||
orderedColumnIndexes: orderedDisplayableColumnIndexes,
|
orderedColumnIndexes: orderedDisplayableColumnIndexes,
|
||||||
hiddenColumnIndexes,
|
hiddenColumnIndexes,
|
||||||
@@ -2453,9 +2459,10 @@ const inferredBackendTotalRowCount = computed(() => {
|
|||||||
if (affected <= props.result.rows.length) return undefined;
|
if (affected <= props.result.rows.length) return undefined;
|
||||||
return affected;
|
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 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
|
// A backend can expose an exact display total while deliberately restricting
|
||||||
// offset pagination to a smaller safe range.
|
// offset pagination to a smaller safe range.
|
||||||
const paginationTotalRowCount = computed(() =>
|
const paginationTotalRowCount = computed(() =>
|
||||||
@@ -2506,9 +2513,36 @@ const canFetchNextInfiniteScrollSegment = computed(() =>
|
|||||||
allRowsLoaded: allRowsLoaded.value,
|
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 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
|
// 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
|
// available page (e.g. data was deleted while viewing), auto-navigate to the
|
||||||
// last available page instead of showing an empty page.
|
// last available page instead of showing an empty page.
|
||||||
@@ -2582,7 +2616,7 @@ function currentOrderBy(): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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;
|
manualTotalRowCount.value = undefined;
|
||||||
// Reset infinite-scroll allLoaded when query context changes
|
// Reset infinite-scroll allLoaded when query context changes
|
||||||
@@ -2684,42 +2718,73 @@ function applyCustomPageSize() {
|
|||||||
changePageSize(normalizeResultPageSize(customPageSizeInput.value, pageSize.value));
|
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() {
|
async function lastPage() {
|
||||||
if (infiniteScrollEnabled.value) return;
|
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) {
|
if (allRowsLoaded.value) {
|
||||||
const total = props.result.rows.length;
|
const total = props.result.rows.length;
|
||||||
if (total <= 0) return;
|
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;
|
if (lastPageNum <= currentPage.value) return;
|
||||||
currentPage.value = lastPageNum;
|
currentPage.value = lastPageNum;
|
||||||
resetGridVerticalScroll(true);
|
resetGridVerticalScroll(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!props.connectionId) return;
|
// Navicat-style: always re-COUNT when jumping to the last page.
|
||||||
const countTarget = await buildCurrentCountTarget();
|
if (props.countTotalRows) {
|
||||||
const sql = countTarget?.sql;
|
if (!(await beginManualTotalRowCount())) return;
|
||||||
if (!sql) return;
|
try {
|
||||||
try {
|
const total = await props.countTotalRows();
|
||||||
const result = await api.executeQuery(props.connectionId, props.executionDatabase ?? props.database ?? "", sql, countTarget.schema, undefined, dataGridCountQueryOptions(connectionStore.getConfig(props.connectionId)));
|
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return;
|
||||||
const total = Number(result.rows?.[0]?.[0] ?? 0);
|
manualTotalRowCount.value = total;
|
||||||
if (total <= 0) return;
|
jumpToCountedLastPage(total);
|
||||||
const lastPageNum = Math.ceil(total / pageSize.value);
|
// Keep the busy overlay until the parent query loading flag can take over.
|
||||||
if (lastPageNum <= currentPage.value) return;
|
await nextTick();
|
||||||
currentPage.value = lastPageNum;
|
} catch (e: any) {
|
||||||
resetGridVerticalScroll(true);
|
toast(t("grid.calculateTotalRowsFailed", { message: e?.message || String(e) }), 5000);
|
||||||
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
|
} finally {
|
||||||
} catch {
|
manualTotalRowCountLoading.value = false;
|
||||||
// COUNT query failed — ignore silently
|
}
|
||||||
|
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() {
|
async function calculateTotalRowCount() {
|
||||||
if (!props.connectionId || manualTotalRowCountLoading.value) return;
|
if (!(await beginManualTotalRowCount())) return;
|
||||||
manualTotalRowCountLoading.value = true;
|
|
||||||
try {
|
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();
|
const countTarget = await buildCurrentCountTarget();
|
||||||
if (!countTarget?.sql) return;
|
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)));
|
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));
|
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 {
|
function rowNumberText(item: RowItem | undefined): string {
|
||||||
if (!item) return "";
|
if (!item) return "";
|
||||||
if (item.isDraft) 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 {
|
function draftCellPlaceholder(item: RowItem | undefined, columnIndex: number): string | null {
|
||||||
@@ -4546,12 +4624,12 @@ function dataGridCellFromClientPoint(clientX: number, clientY: number): { rowInd
|
|||||||
const scroller = dataGridSelectionScroller();
|
const scroller = dataGridSelectionScroller();
|
||||||
if (!scroller) return null;
|
if (!scroller) return null;
|
||||||
const rect = scroller.getBoundingClientRect();
|
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));
|
const clampedY = Math.min(rect.bottom - 1, Math.max(rect.top + 1, clientY));
|
||||||
|
|
||||||
if (useCanvasGridRows.value) {
|
if (useCanvasGridRows.value) {
|
||||||
const rowIndex = Math.floor((scroller.scrollTop + clampedY - rect.top) / CANVAS_DATA_GRID_ROW_HEIGHT);
|
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;
|
if (rowIndex < 0 || rowIndex >= displayRowCount.value || visibleColIdx < 0) return null;
|
||||||
const item = displayItemAt(rowIndex);
|
const item = displayItemAt(rowIndex);
|
||||||
return item ? { rowIndex: item.displayIndex, colIndex: visibleColIdx } : null;
|
return item ? { rowIndex: item.displayIndex, colIndex: visibleColIdx } : null;
|
||||||
@@ -4578,7 +4656,7 @@ function dataGridRowFromClientPoint(_clientX: number, clientY: number): number |
|
|||||||
return item?.displayIndex ?? null;
|
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 row = target instanceof Element ? target.closest<HTMLElement>("[data-row-index]") : null;
|
||||||
const rowIndex = Number(row?.dataset.rowIndex);
|
const rowIndex = Number(row?.dataset.rowIndex);
|
||||||
return Number.isInteger(rowIndex) ? rowIndex : null;
|
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 y = event.clientY - rect.top;
|
||||||
const rowIndex = Math.floor((scroller.scrollTop + y) / CANVAS_DATA_GRID_ROW_HEIGHT);
|
const rowIndex = Math.floor((scroller.scrollTop + y) / CANVAS_DATA_GRID_ROW_HEIGHT);
|
||||||
if (rowIndex < 0 || rowIndex >= displayRowCount.value) return null;
|
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 影响,需要特殊处理
|
// 冻结列区域不受 scrollLeft 影响,需要特殊处理
|
||||||
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0;
|
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 0;
|
||||||
let contentX: number;
|
let contentX: number;
|
||||||
if (frozenWidth > 0 && x - DATA_GRID_ROW_NUM_WIDTH < frozenWidth) {
|
if (frozenWidth > 0 && x - rowNumberWidth.value < frozenWidth) {
|
||||||
contentX = x - DATA_GRID_ROW_NUM_WIDTH;
|
contentX = x - rowNumberWidth.value;
|
||||||
} else {
|
} else {
|
||||||
contentX = scroller.scrollLeft + x - DATA_GRID_ROW_NUM_WIDTH;
|
contentX = scroller.scrollLeft + x - rowNumberWidth.value;
|
||||||
}
|
}
|
||||||
const visibleColIdx = canvasColumnAt(contentX);
|
const visibleColIdx = canvasColumnAt(contentX);
|
||||||
if (visibleColIdx < 0) return null;
|
if (visibleColIdx < 0) return null;
|
||||||
@@ -4908,7 +4986,7 @@ function canvasCellViewportRect(rowIndex: number, visibleColIdx: number) {
|
|||||||
if (colWidth === undefined) return null;
|
if (colWidth === undefined) return null;
|
||||||
// 冻结列不受 scrollLeft 影响
|
// 冻结列不受 scrollLeft 影响
|
||||||
const isFrozen = visibleColIdx < frozenColumnCount.value;
|
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 {
|
return {
|
||||||
left,
|
left,
|
||||||
top: rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - canvasScrollTop.value,
|
top: rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - canvasScrollTop.value,
|
||||||
@@ -4931,7 +5009,7 @@ function canvasEditingCellIsVisible() {
|
|||||||
if (!rect) return false;
|
if (!rect) return false;
|
||||||
const viewportWidth = canvasEffectiveViewportWidth();
|
const viewportWidth = canvasEffectiveViewportWidth();
|
||||||
const viewportHeight = canvasEffectiveViewportHeight();
|
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;
|
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;
|
return rect.top + rect.height > 0 && rect.top < viewportHeight && clippedRight - clippedLeft > 0;
|
||||||
}
|
}
|
||||||
@@ -4974,7 +5052,7 @@ const canvasEditingCellStyle = computed(() => {
|
|||||||
const cell = canvasEditingCell.value;
|
const cell = canvasEditingCell.value;
|
||||||
if (!cell) return {};
|
if (!cell) return {};
|
||||||
const viewportWidth = canvasEffectiveViewportWidth();
|
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;
|
const clippedRight = viewportWidth > 0 ? Math.min(viewportWidth, cell.rect.left + cell.rect.width) : cell.rect.left + cell.rect.width;
|
||||||
return {
|
return {
|
||||||
left: `${clippedLeft}px`,
|
left: `${clippedLeft}px`,
|
||||||
@@ -4996,7 +5074,7 @@ const canvasDetailButtonCell = computed(() => {
|
|||||||
if (!rect) return null;
|
if (!rect) return null;
|
||||||
const viewportWidth = canvasEffectiveViewportWidth();
|
const viewportWidth = canvasEffectiveViewportWidth();
|
||||||
const viewportHeight = canvasEffectiveViewportHeight();
|
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 visibleRight = viewportWidth > 0 ? Math.min(viewportWidth, rect.left + rect.width) : rect.left + rect.width;
|
||||||
const canQuickDownload = canQuickDownloadCellValue(target.rowIndex, target.col);
|
const canQuickDownload = canQuickDownloadCellValue(target.rowIndex, target.col);
|
||||||
const minWidth = canQuickDownload ? 46 : 24;
|
const minWidth = canQuickDownload ? 46 : 24;
|
||||||
@@ -5010,7 +5088,7 @@ const canvasDetailButtonStyle = computed(() => {
|
|||||||
const actionWidth = cell.canQuickDownload ? 44 : 22;
|
const actionWidth = cell.canQuickDownload ? 44 : 22;
|
||||||
const edgeGap = 6;
|
const edgeGap = 6;
|
||||||
return {
|
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`,
|
top: `${cell.rect.top + cell.rect.height / 2}px`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -5045,7 +5123,7 @@ function drawCanvasGrid() {
|
|||||||
columnPreviewOffsets: columnHeaderPreviewOffsets.value,
|
columnPreviewOffsets: columnHeaderPreviewOffsets.value,
|
||||||
columnPreviewSourceVisibleIndex: columnHeaderPreviewSourceVisibleIndex.value,
|
columnPreviewSourceVisibleIndex: columnHeaderPreviewSourceVisibleIndex.value,
|
||||||
visibleColumnIndexes: visibleColumnIndexes.value,
|
visibleColumnIndexes: visibleColumnIndexes.value,
|
||||||
rowNumberWidth: DATA_GRID_ROW_NUM_WIDTH,
|
rowNumberWidth: rowNumberWidth.value,
|
||||||
hoverCell: canvasHoverCell.value,
|
hoverCell: canvasHoverCell.value,
|
||||||
isScrolling: isScrolling.value,
|
isScrolling: isScrolling.value,
|
||||||
editingCell: editingCell.value,
|
editingCell: editingCell.value,
|
||||||
@@ -5058,8 +5136,7 @@ function drawCanvasGrid() {
|
|||||||
cellIsSelected,
|
cellIsSelected,
|
||||||
cellCanHover: canEditCellItem,
|
cellCanHover: canEditCellItem,
|
||||||
infiniteScrollEnabled: infiniteScrollEnabled.value,
|
infiniteScrollEnabled: infiniteScrollEnabled.value,
|
||||||
pageSize: pageSize.value,
|
pageOffset: rowNumberPageOffset(),
|
||||||
currentPage: currentPage.value,
|
|
||||||
frozenColumnCount: frozenColumnCount.value,
|
frozenColumnCount: frozenColumnCount.value,
|
||||||
columnAligns: columnAligns.value,
|
columnAligns: columnAligns.value,
|
||||||
rightAlignedActionCell: canvasRightAlignedActionCell.value,
|
rightAlignedActionCell: canvasRightAlignedActionCell.value,
|
||||||
@@ -5924,11 +6001,11 @@ function scrollGridColumnIntoView(visibleColIdx: number) {
|
|||||||
const colLeft = columnContentOffsetLeft(visibleColIdx);
|
const colLeft = columnContentOffsetLeft(visibleColIdx);
|
||||||
const colRight = colLeft + (renderedColumnWidths.value[visibleColIdx] ?? 0);
|
const colRight = colLeft + (renderedColumnWidths.value[visibleColIdx] ?? 0);
|
||||||
const frozenWidth = frozenColumnCount.value > 0 ? (renderedColumnOffsets.value[frozenColumnCount.value] ?? 0) : 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;
|
const viewportRight = scroller.scrollLeft + scroller.clientWidth;
|
||||||
|
|
||||||
if (colLeft < viewportLeft) {
|
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) {
|
} else if (colRight > viewportRight) {
|
||||||
scroller.scrollLeft = Math.max(0, colRight - scroller.clientWidth);
|
scroller.scrollLeft = Math.max(0, colRight - scroller.clientWidth);
|
||||||
}
|
}
|
||||||
@@ -7470,45 +7547,40 @@ function stopLoadingElapsedTimer() {
|
|||||||
|
|
||||||
function startLoadingElapsedTimer() {
|
function startLoadingElapsedTimer() {
|
||||||
stopLoadingElapsedTimer();
|
stopLoadingElapsedTimer();
|
||||||
if (!dataGridIsActive || !props.loading) return;
|
if (!dataGridIsActive || !gridSurfaceBusy.value) return;
|
||||||
_loadingStart = Date.now();
|
_loadingStart = Date.now();
|
||||||
loadingElapsed.value = 0;
|
loadingElapsed.value = 0;
|
||||||
const updateOnNextFrame = () => {
|
const updateOnNextFrame = () => {
|
||||||
if (!dataGridIsActive || !props.loading) return;
|
if (!dataGridIsActive || !gridSurfaceBusy.value) return;
|
||||||
loadingElapsed.value = Date.now() - _loadingStart;
|
loadingElapsed.value = Date.now() - _loadingStart;
|
||||||
_loadingFrame = window.requestAnimationFrame(updateOnNextFrame);
|
_loadingFrame = window.requestAnimationFrame(updateOnNextFrame);
|
||||||
};
|
};
|
||||||
_loadingFrame = window.requestAnimationFrame(updateOnNextFrame);
|
_loadingFrame = window.requestAnimationFrame(updateOnNextFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(gridSurfaceBusy, (isLoading) => {
|
||||||
() => props.loading,
|
stopLoadingElapsedTimer();
|
||||||
(isLoading) => {
|
if (isDebugLoggingEnabled()) {
|
||||||
stopLoadingElapsedTimer();
|
logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
|
||||||
if (isDebugLoggingEnabled()) {
|
traceId: dataGridTraceId,
|
||||||
logDataGridTiming(isLoading ? "[DBX][DataGrid:loading:start]" : "[DBX][DataGrid:loading:stop]", {
|
cacheKey: props.cacheKey,
|
||||||
traceId: dataGridTraceId,
|
elapsedSinceSetup: dataGridElapsed(),
|
||||||
cacheKey: props.cacheKey,
|
});
|
||||||
elapsedSinceSetup: dataGridElapsed(),
|
}
|
||||||
});
|
if (isLoading) {
|
||||||
}
|
startLoadingElapsedTimer();
|
||||||
if (isLoading) {
|
} else if (isDebugLoggingEnabled()) {
|
||||||
startLoadingElapsedTimer();
|
nextTick(() => {
|
||||||
} else {
|
requestAnimationFrame(() => {
|
||||||
if (isDebugLoggingEnabled()) {
|
logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", {
|
||||||
nextTick(() => {
|
traceId: dataGridTraceId,
|
||||||
requestAnimationFrame(() => {
|
cacheKey: props.cacheKey,
|
||||||
logDataGridTiming("[DBX][DataGrid:loading:stop:first-frame]", {
|
elapsedSinceSetup: dataGridElapsed(),
|
||||||
traceId: dataGridTraceId,
|
|
||||||
cacheKey: props.cacheKey,
|
|
||||||
elapsedSinceSetup: dataGridElapsed(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}
|
});
|
||||||
},
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
startLoadingElapsedTimer();
|
startLoadingElapsedTimer();
|
||||||
@@ -8949,7 +9021,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
</template>
|
</template>
|
||||||
</RecycleScroller>
|
</RecycleScroller>
|
||||||
<!-- Infinite scroll loading indicator for 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" />
|
<Loader2 class="w-3 h-3 animate-spin mr-1" />
|
||||||
{{ t("grid.loadingMore") }}
|
{{ t("grid.loadingMore") }}
|
||||||
</div>
|
</div>
|
||||||
@@ -8959,7 +9031,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
<div v-if="hasGridVerticalOverflow" ref="gridVerticalScrollbarTrackRef" class="data-grid-vertical-scrollbar" @pointerdown="startGridVerticalScrollbarDrag">
|
<div v-if="hasGridVerticalOverflow" ref="gridVerticalScrollbarTrackRef" class="data-grid-vertical-scrollbar" @pointerdown="startGridVerticalScrollbarDrag">
|
||||||
<div ref="gridVerticalScrollbarThumbRef" class="data-grid-vertical-scrollbar__thumb" />
|
<div ref="gridVerticalScrollbarThumbRef" class="data-grid-vertical-scrollbar__thumb" />
|
||||||
</div>
|
</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">
|
<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" />
|
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||||
<span>{{ formatElapsedSeconds(loadingElapsed) }}s</span>
|
<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">
|
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||||
<span v-if="hasData" class="shrink-0">
|
<span v-if="hasData" class="shrink-0">
|
||||||
{{ t(showTruncationWarning ? "grid.loadedRows" : "grid.totalRows", { count: result.rows.length }) }}
|
{{ 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-if="typeof displayedTotalRowCount === 'number' && displayedTotalRowCount >= 0" class="text-muted-foreground/70">{{ t(totalRowCountLabelKey, { count: displayedTotalRowCount }) }}</span>
|
||||||
<span v-else-if="totalRowCountBusy" class="text-muted-foreground/70">
|
<span v-if="totalRowCountBusy" class="text-muted-foreground/70">
|
||||||
{{ t("grid.totalRowCountLoading") }}
|
{{ t("grid.totalRowCountLoading") }}
|
||||||
</span>
|
</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") }}
|
{{ t("grid.calculateTotalRowsInline") }}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -9385,7 +9457,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
:pagination-enabled="paginationEnabled"
|
:pagination-enabled="paginationEnabled"
|
||||||
:selection-summary="selectionSummary"
|
:selection-summary="selectionSummary"
|
||||||
:selection-summary-sum-text="selectionSummarySumText"
|
:selection-summary-sum-text="selectionSummarySumText"
|
||||||
:loading="loading"
|
:loading="gridSurfaceBusy"
|
||||||
:infinite-scroll-enabled="infiniteScrollEnabled"
|
:infinite-scroll-enabled="infiniteScrollEnabled"
|
||||||
:infinite-scroll-all-loaded="infiniteScrollAllLoaded"
|
:infinite-scroll-all-loaded="infiniteScrollAllLoaded"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
|||||||
@@ -86,11 +86,11 @@ const emit = defineEmits<{
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</LightDropdown>
|
</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="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="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('previousPage')"><ChevronLeft class="h-3 w-3" /></Button>
|
||||||
<span class="shrink-0 tabular-nums">{{ currentPage }}</span>
|
<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="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="!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 || !canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
|
||||||
</template>
|
</template>
|
||||||
<DataGridExportMenu :items="exportMenuItems" :label="t('grid.export')" :on-select="(value) => emit('selectExport', value)" />
|
<DataGridExportMenu :items="exportMenuItems" :label="t('grid.export')" :on-select="(value) => emit('selectExport', value)" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -212,6 +212,10 @@ describe("DataGridPagination", () => {
|
|||||||
expect(previousPage).toHaveBeenCalledOnce();
|
expect(previousPage).toHaveBeenCalledOnce();
|
||||||
expect(nextPage).toHaveBeenCalledOnce();
|
expect(nextPage).toHaveBeenCalledOnce();
|
||||||
expect(lastPage).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", () => {
|
it("hides pagination controls when the data source does not support paging", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { dataGridTotalRowCountLabelKey } from "@/lib/dataGrid/dataGridPagination";
|
||||||
import DataGrid from "../DataGrid.vue";
|
import DataGrid from "../DataGrid.vue";
|
||||||
|
|
||||||
type VuePropDefinition = { default?: unknown };
|
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", () => {
|
it("treats totals as exact unless a caller explicitly marks them as a lower bound", () => {
|
||||||
const component = DataGrid as unknown as VueComponentWithProps;
|
const component = DataGrid as unknown as VueComponentWithProps;
|
||||||
expect(component.props?.totalRowCountIsExact?.default).toBe(true);
|
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 { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { DATA_GRID_COL_AUTO_FIT_MAX_WIDTH, DATA_GRID_COL_MIN_WIDTH } from "@/lib/dataGrid/dataGridColumnWidth";
|
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 { 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 }) {
|
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);
|
const compact = ref(options.compactColumnHeaderActions ?? true);
|
||||||
@@ -301,3 +301,19 @@ describe("useDataGridColumnResize", () => {
|
|||||||
expect(comf.columnWidths.value[0]).toBeGreaterThanOrEqual(std.columnWidths.value[0]);
|
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[]>;
|
renderedColumnWidths: MaybeRefOrGetter<readonly number[]>;
|
||||||
scrollLeft: MaybeRefOrGetter<number>;
|
scrollLeft: MaybeRefOrGetter<number>;
|
||||||
viewportWidth: MaybeRefOrGetter<number>;
|
viewportWidth: MaybeRefOrGetter<number>;
|
||||||
rowNumberWidth: number;
|
rowNumberWidth: MaybeRefOrGetter<number>;
|
||||||
bufferPx?: number;
|
bufferPx?: number;
|
||||||
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
|
headerRef?: MaybeRefOrGetter<HTMLElement | null | undefined>;
|
||||||
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
|
orderedColumnIndexes?: MaybeRefOrGetter<readonly number[]>;
|
||||||
@@ -427,7 +427,7 @@ export function useDataGridColumnLayout(options: {
|
|||||||
columnCount: toValue(options.visibleColumnIndexes).length,
|
columnCount: toValue(options.visibleColumnIndexes).length,
|
||||||
scrollLeft: toValue(options.scrollLeft),
|
scrollLeft: toValue(options.scrollLeft),
|
||||||
viewportWidth: toValue(options.viewportWidth),
|
viewportWidth: toValue(options.viewportWidth),
|
||||||
rowNumberWidth: options.rowNumberWidth,
|
rowNumberWidth: toValue(options.rowNumberWidth),
|
||||||
bufferPx: options.bufferPx ?? 900,
|
bufferPx: options.bufferPx ?? 900,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -478,7 +478,7 @@ export function useDataGridColumnLayout(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function columnContentOffsetLeft(visibleColIdx: number): number {
|
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);
|
const columnHeaderDragState = ref<ColumnHeaderDragState | null>(null);
|
||||||
|
|||||||
@@ -5,8 +5,27 @@ import type { ColumnWidthDensity } from "@/stores/settingsStore";
|
|||||||
|
|
||||||
type CellValue = string | number | boolean | null;
|
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;
|
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 {
|
export function resizeDataGridColumnWidth(startWidth: number, deltaX: number): number {
|
||||||
return Math.max(DATA_GRID_COL_MIN_WIDTH, startWidth + deltaX);
|
return Math.max(DATA_GRID_COL_MIN_WIDTH, startWidth + deltaX);
|
||||||
}
|
}
|
||||||
@@ -21,6 +40,7 @@ export interface UseDataGridColumnResizeOptions {
|
|||||||
columnStructureSignature: ComputedRef<string>;
|
columnStructureSignature: ComputedRef<string>;
|
||||||
measureHeaderText?: (text: string) => number | undefined;
|
measureHeaderText?: (text: string) => number | undefined;
|
||||||
headerMeasurementKey?: Ref<unknown>;
|
headerMeasurementKey?: Ref<unknown>;
|
||||||
|
rowNumberWidth?: Ref<number> | ComputedRef<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) {
|
export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions) {
|
||||||
@@ -143,14 +163,16 @@ export function useDataGridColumnResize(options: UseDataGridColumnResizeOptions)
|
|||||||
|
|
||||||
const renderedColumnWidths = computed(() => columnWidths.value.slice());
|
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 columnVars = computed(() => {
|
||||||
const vars: Record<string, string> = {};
|
const vars: Record<string, string> = {};
|
||||||
renderedColumnWidths.value.forEach((w, i) => {
|
renderedColumnWidths.value.forEach((w, i) => {
|
||||||
vars[`--col-w-${i}`] = `${w}px`;
|
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`;
|
vars["--total-w"] = `${totalWidth.value}px`;
|
||||||
return vars;
|
return vars;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1018,6 +1018,7 @@ export default {
|
|||||||
loadedRows: "Loaded {count} rows",
|
loadedRows: "Loaded {count} rows",
|
||||||
totalRowCount: "({count} total)",
|
totalRowCount: "({count} total)",
|
||||||
totalRowCountAtLeast: "(at least {count} total)",
|
totalRowCountAtLeast: "(at least {count} total)",
|
||||||
|
totalRowCountEstimated: "(~{count})",
|
||||||
totalRowCountLoading: "(counting...)",
|
totalRowCountLoading: "(counting...)",
|
||||||
loadingMore: "Loading more data...",
|
loadingMore: "Loading more data...",
|
||||||
allLoaded: "all loaded",
|
allLoaded: "all loaded",
|
||||||
|
|||||||
@@ -966,6 +966,7 @@ export default withEnglishFallback({
|
|||||||
loadedRows: "{count} filas cargadas",
|
loadedRows: "{count} filas cargadas",
|
||||||
totalRowCount: "({count} en total)",
|
totalRowCount: "({count} en total)",
|
||||||
totalRowCountAtLeast: "(al menos {count} en total)",
|
totalRowCountAtLeast: "(al menos {count} en total)",
|
||||||
|
totalRowCountEstimated: "(aprox. {count})",
|
||||||
totalRowCountLoading: "(contando...)",
|
totalRowCountLoading: "(contando...)",
|
||||||
loadingMore: "Cargando más datos...",
|
loadingMore: "Cargando más datos...",
|
||||||
allLoaded: "todo cargado",
|
allLoaded: "todo cargado",
|
||||||
|
|||||||
@@ -964,6 +964,7 @@ export default withEnglishFallback({
|
|||||||
loadedRows: "{count} righe caricate",
|
loadedRows: "{count} righe caricate",
|
||||||
totalRowCount: "({count} in totale)",
|
totalRowCount: "({count} in totale)",
|
||||||
totalRowCountAtLeast: "(almeno {count} in totale)",
|
totalRowCountAtLeast: "(almeno {count} in totale)",
|
||||||
|
totalRowCountEstimated: "(circa {count})",
|
||||||
totalRowCountLoading: "(conteggio...)",
|
totalRowCountLoading: "(conteggio...)",
|
||||||
loadingMore: "Caricamento altri dati...",
|
loadingMore: "Caricamento altri dati...",
|
||||||
allLoaded: "tutto caricato",
|
allLoaded: "tutto caricato",
|
||||||
|
|||||||
@@ -965,6 +965,7 @@ export default withEnglishFallback({
|
|||||||
loadedRows: "{count}件読み込み済み",
|
loadedRows: "{count}件読み込み済み",
|
||||||
totalRowCount: "(全{count}件)",
|
totalRowCount: "(全{count}件)",
|
||||||
totalRowCountAtLeast: "(少なくとも{count}件)",
|
totalRowCountAtLeast: "(少なくとも{count}件)",
|
||||||
|
totalRowCountEstimated: "(約 {count}件)",
|
||||||
totalRowCountLoading: "(カウント中...)",
|
totalRowCountLoading: "(カウント中...)",
|
||||||
calculateTotalRows: "総行数をカウント",
|
calculateTotalRows: "総行数をカウント",
|
||||||
calculateTotalRowsInline: "(総行数をカウント)",
|
calculateTotalRowsInline: "(総行数をカウント)",
|
||||||
|
|||||||
@@ -1006,6 +1006,7 @@ export default withEnglishFallback({
|
|||||||
loadedRows: "{count}행 로드됨",
|
loadedRows: "{count}행 로드됨",
|
||||||
totalRowCount: "(전체 {count})",
|
totalRowCount: "(전체 {count})",
|
||||||
totalRowCountAtLeast: "(최소 {count})",
|
totalRowCountAtLeast: "(최소 {count})",
|
||||||
|
totalRowCountEstimated: "(약 {count})",
|
||||||
totalRowCountLoading: "(집계 중...)",
|
totalRowCountLoading: "(집계 중...)",
|
||||||
loadingMore: "데이터를 더 불러오는 중...",
|
loadingMore: "데이터를 더 불러오는 중...",
|
||||||
allLoaded: "모두 로드됨",
|
allLoaded: "모두 로드됨",
|
||||||
|
|||||||
@@ -966,6 +966,7 @@ export default withEnglishFallback({
|
|||||||
loadedRows: "{count} linhas carregadas",
|
loadedRows: "{count} linhas carregadas",
|
||||||
totalRowCount: "({count} no total)",
|
totalRowCount: "({count} no total)",
|
||||||
totalRowCountAtLeast: "(pelo menos {count} no total)",
|
totalRowCountAtLeast: "(pelo menos {count} no total)",
|
||||||
|
totalRowCountEstimated: "(aprox. {count})",
|
||||||
totalRowCountLoading: "(contando...)",
|
totalRowCountLoading: "(contando...)",
|
||||||
loadingMore: "Carregando mais dados...",
|
loadingMore: "Carregando mais dados...",
|
||||||
allLoaded: "tudo carregado",
|
allLoaded: "tudo carregado",
|
||||||
|
|||||||
@@ -1019,6 +1019,7 @@ export default withEnglishFallback({
|
|||||||
loadedRows: "已加载 {count} 行",
|
loadedRows: "已加载 {count} 行",
|
||||||
totalRowCount: "(总计 {count} 行)",
|
totalRowCount: "(总计 {count} 行)",
|
||||||
totalRowCountAtLeast: "(至少 {count} 行)",
|
totalRowCountAtLeast: "(至少 {count} 行)",
|
||||||
|
totalRowCountEstimated: "(约 {count} 行)",
|
||||||
totalRowCountLoading: "(统计中...)",
|
totalRowCountLoading: "(统计中...)",
|
||||||
loadingMore: "加载更多数据...",
|
loadingMore: "加载更多数据...",
|
||||||
allLoaded: "已全部加载",
|
allLoaded: "已全部加载",
|
||||||
|
|||||||
@@ -965,6 +965,7 @@ export default withEnglishFallback({
|
|||||||
loadedRows: "已載入 {count} 筆",
|
loadedRows: "已載入 {count} 筆",
|
||||||
totalRowCount: "(總計 {count} 筆)",
|
totalRowCount: "(總計 {count} 筆)",
|
||||||
totalRowCountAtLeast: "(至少 {count} 筆)",
|
totalRowCountAtLeast: "(至少 {count} 筆)",
|
||||||
|
totalRowCountEstimated: "(約 {count} 筆)",
|
||||||
totalRowCountLoading: "(統計中...)",
|
totalRowCountLoading: "(統計中...)",
|
||||||
loadingMore: "載入更多資料...",
|
loadingMore: "載入更多資料...",
|
||||||
allLoaded: "已全部載入",
|
allLoaded: "已全部載入",
|
||||||
|
|||||||
+1
-2
@@ -63,8 +63,7 @@ function createBaseOptions(overrides: Partial<DrawCanvasDataGridOptions> = {}):
|
|||||||
cellIsSelected: () => false,
|
cellIsSelected: () => false,
|
||||||
cellCanHover: () => true,
|
cellCanHover: () => true,
|
||||||
infiniteScrollEnabled: false,
|
infiniteScrollEnabled: false,
|
||||||
pageSize: 100,
|
pageOffset: 0,
|
||||||
currentPage: 1,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("document query totals", () => {
|
||||||
it("uses exact totals as the pagination bound", () => {
|
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: 999_999, pageSize: 10, rowCount: 10 })).toBe(true);
|
||||||
expect(canGoNextDocumentPage({ page: 1_000_000, pageSize: 10, rowCount: 3 })).toBe(false);
|
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;
|
cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean;
|
||||||
cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean;
|
cellCanHover: (row: CanvasDataGridRow, actualColIdx: number) => boolean;
|
||||||
infiniteScrollEnabled: boolean;
|
infiniteScrollEnabled: boolean;
|
||||||
pageSize: number;
|
pageOffset: number;
|
||||||
currentPage: number;
|
|
||||||
frozenColumnCount?: number;
|
frozenColumnCount?: number;
|
||||||
columnAligns?: readonly ("left" | "right")[];
|
columnAligns?: readonly ("left" | "right")[];
|
||||||
rightAlignedActionCell?: CanvasRightAlignedActionCell | null;
|
rightAlignedActionCell?: CanvasRightAlignedActionCell | null;
|
||||||
@@ -267,8 +266,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
cellIsSelected,
|
cellIsSelected,
|
||||||
cellCanHover,
|
cellCanHover,
|
||||||
infiniteScrollEnabled,
|
infiniteScrollEnabled,
|
||||||
pageSize,
|
pageOffset,
|
||||||
currentPage,
|
|
||||||
frozenColumnCount = 0,
|
frozenColumnCount = 0,
|
||||||
columnAligns,
|
columnAligns,
|
||||||
rightAlignedActionCell,
|
rightAlignedActionCell,
|
||||||
@@ -362,13 +360,18 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
|
|||||||
ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont;
|
ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont;
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
const textY = alignCanvasPixel(y + rowTextOffsetY, dpr);
|
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) {
|
if (item.isDraft) {
|
||||||
ctx.fillText("*", rowNumberTextX, textY);
|
ctx.fillText("*", rowNumberTextX, textY);
|
||||||
} else if (infiniteScrollEnabled) {
|
} else if (infiniteScrollEnabled) {
|
||||||
ctx.fillText(String(item.displayIndex + 1), rowNumberTextX, textY);
|
ctx.fillText(String(item.displayIndex + 1), rowNumberTextX, textY);
|
||||||
} else {
|
} 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.font = normalFont;
|
||||||
|
|
||||||
ctx.strokeStyle = theme.border;
|
ctx.strokeStyle = theme.border;
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export interface CanFetchNextDataGridSegmentOptions {
|
|||||||
allRowsLoaded?: boolean;
|
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 {
|
export function resolveDataGridPaginationTotal(options: { paginationTotalRowCount?: number; serverKnownTotalRowCount?: number; totalRowCountIsExact: boolean }): number | undefined {
|
||||||
if (options.paginationTotalRowCount !== undefined) return options.paginationTotalRowCount;
|
if (options.paginationTotalRowCount !== undefined) return options.paginationTotalRowCount;
|
||||||
return options.totalRowCountIsExact ? options.serverKnownTotalRowCount : undefined;
|
return options.totalRowCountIsExact ? options.serverKnownTotalRowCount : undefined;
|
||||||
|
|||||||
@@ -4,7 +4,46 @@ export interface DocumentQueryTotals {
|
|||||||
paginationTotal: number | undefined;
|
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 {
|
return {
|
||||||
total,
|
total,
|
||||||
totalIsExact,
|
totalIsExact,
|
||||||
|
|||||||
+38
@@ -1,4 +1,5 @@
|
|||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { test } from "vitest";
|
import { test } from "vitest";
|
||||||
import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts";
|
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;
|
const total = undefined;
|
||||||
assert.equal(!total || (total as any) <= 0, true, "guard should prevent redirect when total is unknown");
|
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,/);
|
||||||
|
});
|
||||||
+19
@@ -63,6 +63,25 @@ test("document save uses shared identity plan and write helpers", () => {
|
|||||||
assert.doesNotMatch(source, /async function replaceDocumentStoreDocument/);
|
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", () => {
|
test("document query inputs apply on Enter and reserve Shift+Enter for newlines", () => {
|
||||||
const source = documentBrowserSource();
|
const source = documentBrowserSource();
|
||||||
assert.equal(source.match(/@keydown\.enter\.exact\.prevent="applyFilter"/g)?.length, 2);
|
assert.equal(source.match(/@keydown\.enter\.exact\.prevent="applyFilter"/g)?.length, 2);
|
||||||
|
|||||||
Reference in new issue
Block a user