feat(grid): load all remaining rows with confirmation guard

This commit is contained in:
zipg
2026-09-24 12:53:00 +08:00
committed by GitHub
parent 0b3e79331b
commit 35d624764f
25 changed files with 336 additions and 27 deletions
+1 -1
View File
@@ -4336,7 +4336,7 @@ onUnmounted(() => {
@format-error="toast(t('toolbar.formatSqlFailed'))"
@save-sql="(tabId: string) => void openSaveSqlDialog(tabId)"
@reload="(tabId: string, sql: any, searchText: any, whereInput: any, orderBy: any, limit: any, offset: any, intent: any) => onReloadData(tabId, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string) => onPaginate(tabId, offset, limit, whereInput, orderBy)"
@paginate="(tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => onPaginate(tabId, offset, limit, whereInput, orderBy, appendResult)"
@sort="(tabId: string, column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => onSort(tabId, column, columnIndex, direction, whereInput, mode)"
@execute-sql="(tabId: string, sql: string) => onExecuteSql(tabId, sql)"
@click-table="(_tabId: string, target: SqlObjectNavigationTarget) => onClickTable(target)"
@@ -2957,6 +2957,7 @@ defineExpose({ focusSearch });
:total-row-count-is-exact="totalIsExact"
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
:pagination-total-row-count="pageTotal"
:load-all-rows-enabled="false"
:count-total-rows="countExactDocumentTotal"
:full-export-result="documentStoreProvider.kind === 'mongodb' || documentStoreProvider.kind === 'dynamodb' || documentStoreProvider.kind === 'elasticsearch' ? exportAllDocumentStoreDocuments : undefined"
@sort="onSort"
+139 -10
View File
@@ -208,6 +208,7 @@ import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid
import {
canFetchNextDataGridSegment,
canGoNextDataGridPage,
dataGridLoadAllSegment,
dataGridTotalRowCountLabelKey,
dataGridTruncationHintKey,
ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS,
@@ -493,6 +494,7 @@ interface DataGridProps {
inexactTotalRowCountMode?: DataGridInexactTotalRowCountMode;
paginationTotalRowCount?: number;
paginationEnabled?: boolean;
loadAllRowsEnabled?: boolean;
totalRowCountLoading?: boolean;
pageJumpProgress?: QueryPageJumpProgress;
/** Document stores (e.g. MongoDB) count exactly on demand without SQL tableMeta/countSql. */
@@ -556,6 +558,7 @@ const props = withDefaults(defineProps<DataGridProps>(), {
totalRowCountIsExact: true,
inexactTotalRowCountMode: "at-least",
paginationEnabled: true,
loadAllRowsEnabled: true,
// Omitted row-action limits must keep normal table-data editing.
allowInsertRows: undefined,
allowDeleteRows: undefined,
@@ -588,7 +591,7 @@ function logDataGridTiming(message: string, payload?: Record<string, unknown>) {
const emit = defineEmits<{
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
"update:whereInput": [value: string];
"update:orderByInput": [value: string];
@@ -3009,6 +3012,8 @@ let infiniteScrollCheckScheduled = false;
let infiniteScrollAllLoaded = false;
let infiniteScrollRequestedOffset: number | undefined;
let infiniteScrollRequestedLimit: number | undefined;
let infiniteScrollLoadAllPending = false;
const loadAllRowsActive = ref(false);
// Tracks whether the current loading cycle was triggered by a refresh/rollback
// (as opposed to a normal paginate). Used to decide whether to auto-redirect
// when the current page no longer exists after data was deleted.
@@ -3046,11 +3051,14 @@ watch(
},
{ immediate: true },
);
// Clear infinite-scroll loading when the parent finishes loading new data
// Complete an append only after both the loading state and appended result have
// propagated. Large results can update those props in separate render cycles.
watch(
() => props.loading,
(loading, prevLoading) => {
if (prevLoading && !loading && infiniteScrollLoading.value) {
() => [props.loading, props.result.rows.length, props.result.appended_from_row_count] as const,
([loading]) => {
if (!loading && infiniteScrollLoading.value) {
const shouldSelectLastRow = infiniteScrollLoadAllPending;
infiniteScrollLoadAllPending = false;
infiniteScrollLoading.value = false;
isInfiniteScrollPaginating.value = false;
const requestedOffset = infiniteScrollRequestedOffset;
@@ -3062,19 +3070,31 @@ watch(
// optimistic page marker so a later scroll can retry the same segment.
currentPage.value = Math.max(1, currentPage.value - 1);
lastInfiniteScrollPage = Math.max(0, currentPage.value - 1);
loadAllRowsActive.value = false;
return;
}
const appendedRows = props.result.rows.length - requestedOffset;
if (props.result.rows.length >= infiniteScrollMaxRows.value || appendedRows < (requestedLimit ?? pageSize.value)) {
infiniteScrollAllLoaded = true;
}
if (shouldSelectLastRow) selectAndRevealLastLoadedRow();
}
},
{ flush: "post" },
);
const manualTotalRowCount = ref<number | undefined>(undefined);
const manualTotalRowCountLoading = ref(false);
const esDeepPageJumpConfirmOpen = ref(false);
const pendingEsDeepPageJump = ref<{ targetPage: number; requestCount: number; updateCurrentPage: boolean }>();
// One "load all" click fetches the whole remaining segment in a single request;
// with the result-row cap disabled that segment is effectively unbounded, so a
// large shot needs an explicit confirmation the way ES deep page jumps do.
const LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD = 100_000;
const loadAllRowsConfirmOpen = ref(false);
const pendingLoadAllRows = ref<{ remaining: number }>();
watch(loadAllRowsConfirmOpen, (open) => {
if (!open) pendingLoadAllRows.value = undefined;
});
watch(esDeepPageJumpConfirmOpen, (open) => {
if (!open) {
pendingEsDeepPageJump.value = undefined;
@@ -3318,8 +3338,6 @@ watch(
(values, previousValues) => {
if (!didDataGridInfiniteScrollContextChange(values, previousValues)) return;
manualTotalRowCount.value = undefined;
// Reset infinite-scroll allLoaded when query context changes
infiniteScrollAllLoaded = false;
},
);
@@ -3402,6 +3420,79 @@ function infiniteScrollNextPage() {
// row identities, which would invalidate pending edits while the user scrolls.
emit("paginate", nextOffset, nextLimit, currentWhereInput(), currentOrderBy());
}
function selectAndRevealLastLoadedRow() {
nextTick(() => {
let rowIndex = displayRowRefs.value.length - 1;
while (rowIndex >= 0 && !("sourceIndex" in displayRowRefs.value[rowIndex])) rowIndex--;
if (rowIndex < 0) return;
selectRow(rowIndex);
if (showTranspose.value) {
transposeRowIndex.value = rowIndex;
nextTick(() => scrollTransposeRecordIntoView(rowIndex, "nearest"));
return;
}
const rowHeight = useCanvasGridRows.value ? CANVAS_DATA_GRID_ROW_HEIGHT : DOM_DATA_GRID_ROW_HEIGHT;
const expectedRowBottom = (rowIndex + 1) * rowHeight;
let remainingFrames = 12;
const revealWhenReady = () => {
const scroller = gridScrollerElement();
if (scroller) {
if (useCanvasGridRows.value) scrollCanvasRowIntoView(rowIndex, "end");
else scrollDomRowIntoView(rowIndex, "end");
const expectedScrollTop = Math.max(0, expectedRowBottom - scroller.clientHeight);
if (scroller.scrollTop >= expectedScrollTop - 1) return;
}
remainingFrames--;
if (remainingFrames > 0) requestAnimationFrame(revealWhenReady);
};
requestAnimationFrame(revealWhenReady);
});
}
function loadAllRowsAndGoToLast() {
if (!props.loadAllRowsEnabled || gridSurfaceBusy.value || infiniteScrollLoading.value || props.result.rows.length === 0) return;
// search_after cursor paging fetches page by page; a single giant append
// against those cursors is untested, so ES/Easysearch grids keep the
// reveal-only shortcut instead of loading everything.
if (isResultsContext.value && (resolvedDatabaseType.value === "elasticsearch" || resolvedDatabaseType.value === "easysearch")) return;
const segment = dataGridLoadAllSegment(props.result.rows.length, infiniteScrollMaxRows.value, !infiniteScrollAllLoaded && canFetchNextInfiniteScrollSegment.value);
if (!segment) {
loadAllRowsActive.value = true;
infiniteScrollAllLoaded = true;
selectAndRevealLastLoadedRow();
return;
}
const knownTotal = displayedTotalRowCount.value;
const remaining = typeof knownTotal === "number" && Number.isFinite(knownTotal) && knownTotal >= props.result.rows.length ? knownTotal - props.result.rows.length : segment.limit;
if (remaining > LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD) {
pendingLoadAllRows.value = { remaining };
loadAllRowsConfirmOpen.value = true;
return;
}
startLoadAllRows(segment);
}
function startLoadAllRows(segment: { offset: number; limit: number }) {
loadAllRowsActive.value = true;
infiniteScrollLoadAllPending = true;
infiniteScrollLoading.value = true;
isInfiniteScrollPaginating.value = true;
infiniteScrollRequestedOffset = segment.offset;
infiniteScrollRequestedLimit = segment.limit;
currentPage.value++;
emit("paginate", segment.offset, segment.limit, currentWhereInput(), currentOrderBy(), true);
}
function confirmLoadAllRows() {
const pending = pendingLoadAllRows.value;
if (!pending) return;
pendingLoadAllRows.value = undefined;
loadAllRowsConfirmOpen.value = false;
const segment = dataGridLoadAllSegment(props.result.rows.length, infiniteScrollMaxRows.value, !infiniteScrollAllLoaded && canFetchNextInfiniteScrollSegment.value);
if (segment) startLoadAllRows(segment);
}
function checkInfiniteScroll(scroller: HTMLElement) {
if (!infiniteScrollEnabled.value || infiniteScrollLoading.value || props.loading) return;
if (infiniteScrollAllLoaded) return;
@@ -3423,6 +3514,7 @@ function changePageSize(size: number) {
currentPage.value = 1;
lastInfiniteScrollPage = 0;
infiniteScrollAllLoaded = false;
loadAllRowsActive.value = false;
infiniteScrollPositions = new WeakMap();
resetGridVerticalScroll(true);
emit("paginate", 0, normalizedSize, currentWhereInput(), currentOrderBy());
@@ -4199,13 +4291,15 @@ function resetInfiniteScrollState() {
infiniteScrollRequestedLimit = undefined;
isInfiniteScrollPaginating.value = false;
infiniteScrollLoading.value = false;
infiniteScrollLoadAllPending = false;
loadAllRowsActive.value = false;
infiniteScrollPositions = new WeakMap();
resetGridVerticalScroll(true);
}
function prepareFullReload() {
const viewportAnchor = captureViewportAnchorForRefresh();
if (infiniteScrollEnabled.value) {
if (infiniteScrollEnabled.value || loadAllRowsActive.value) {
resetInfiniteScrollState();
}
const selection = captureCurrentSelectionForRefresh();
@@ -6114,7 +6208,7 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" |
toast(t("grid.largeValueLocalSortUnavailable"), 5000);
return;
}
if (mode === "database" && infiniteScrollEnabled.value) {
if (mode === "database" && (infiniteScrollEnabled.value || loadAllRowsActive.value)) {
resetInfiniteScrollState();
} else {
currentPage.value = 1;
@@ -9983,8 +10077,21 @@ watch(
infiniteScrollLoading.value = false;
isInfiniteScrollPaginating.value = false;
}
// The append completion above already reset `infiniteScrollLoading`, so the
// post-flush loading watcher cannot observe this append; a "load all" run
// must still reveal its last row from here.
if (infiniteScrollLoadAllPending) {
infiniteScrollLoadAllPending = false;
selectAndRevealLastLoadedRow();
}
return;
}
// A non-append result replaces the whole data set, so a running "load all" is over.
loadAllRowsActive.value = false;
// The replacement also invalidates the all-loaded marker: a filter change or
// page jump swaps in a fresh first page whose remaining segments must be
// re-derived instead of being silently skipped.
infiniteScrollAllLoaded = false;
if (getResetScrollAfterResult()) {
clearResetScrollAfterResult();
resetGridVerticalScroll();
@@ -13872,9 +13979,12 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:selection-summary="selectionSummary"
:selection-summary-sum-text="selectionSummarySumText"
:selection-summary-average-text="selectionSummaryAverageText"
:loading="gridPaginationBusy"
:loading="gridPaginationBusy || infiniteScrollLoading"
:infinite-scroll-enabled="infiniteScrollEnabled"
:infinite-scroll-all-loaded="infiniteScrollAllLoaded"
:load-all-rows-active="loadAllRowsActive"
:load-all-rows-enabled="loadAllRowsEnabled"
:can-load-all-rows="result.rows.length > 0"
:page-size="pageSize"
:default-page-size="defaultPageSize"
:page-size-menu-items="pageSizeMenuItems"
@@ -13891,6 +14001,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@next-page="nextPage"
@jump-page="jumpPage"
@last-page="lastPage"
@load-all-rows="loadAllRowsAndGoToLast"
@select-export="selectExportMenuItem"
/>
</div>
@@ -13999,6 +14110,24 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
</DialogContent>
</Dialog>
<Dialog v-model:open="loadAllRowsConfirmOpen">
<DialogContent class="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-5 w-5" />
{{ t("grid.loadAllRowsConfirmTitle") }}
</DialogTitle>
</DialogHeader>
<p class="py-3 text-sm leading-6 text-muted-foreground">
{{ t("grid.loadAllRowsConfirmMessage", { count: pendingLoadAllRows?.remaining ?? 0 }) }}
</p>
<DialogFooter>
<Button variant="outline" @click="loadAllRowsConfirmOpen = false">{{ t("dangerDialog.cancel") }}</Button>
<Button @click="confirmLoadAllRows">{{ t("grid.loadAllRowsContinue") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="generateIncrementDialogOpen">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Check, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2 } from "@lucide/vue";
import { Check, ChevronLeft, ChevronRight, ChevronsDown, ChevronsLeft, ChevronsRight, Loader2 } from "@lucide/vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@@ -20,6 +20,9 @@ const props = withDefaults(
loading: boolean;
infiniteScrollEnabled: boolean;
infiniteScrollAllLoaded: boolean;
loadAllRowsActive: boolean;
loadAllRowsEnabled: boolean;
canLoadAllRows: boolean;
pageSize: number;
defaultPageSize: number;
pageSizeMenuItems: LightDropdownItem[];
@@ -29,7 +32,7 @@ const props = withDefaults(
canGoNextPage: boolean;
canJumpLastPage: boolean;
}>(),
{ paginationEnabled: true },
{ paginationEnabled: true, loadAllRowsActive: false, loadAllRowsEnabled: true, canLoadAllRows: false },
);
const customPageSizeInput = defineModel<string>("customPageSizeInput", { default: "" });
@@ -49,6 +52,7 @@ const emit = defineEmits<{
nextPage: [];
jumpPage: [page: number];
lastPage: [];
loadAllRows: [];
selectExport: [value: string];
applyCustomPageSizeAndSetDefault: [];
}>();
@@ -96,10 +100,10 @@ function handlePageInputKeydown(event: KeyboardEvent) {
</div>
</div>
<Loader2 class="w-3 h-3 text-muted-foreground" :class="loading ? 'animate-spin' : 'invisible'" aria-hidden="true" />
<template v-if="paginationEnabled && infiniteScrollEnabled">
<template v-if="paginationEnabled && (infiniteScrollEnabled || loadAllRowsActive)">
<span v-if="infiniteScrollAllLoaded" class="text-xs text-muted-foreground shrink-0">{{ t("grid.allLoaded") }}</span>
</template>
<template v-if="paginationEnabled && !infiniteScrollEnabled">
<template v-if="paginationEnabled && !infiniteScrollEnabled && !loadAllRowsActive">
<LightDropdown
:model-value="String(pageSize)"
:items="pageSizeMenuItems"
@@ -152,6 +156,14 @@ function handlePageInputKeydown(event: KeyboardEvent) {
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canGoNextPage" @click="emit('nextPage')"><ChevronRight class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canJumpLastPage" @click="emit('lastPage')"><ChevronsRight class="h-3 w-3" /></Button>
</template>
<Tooltip v-if="paginationEnabled && loadAllRowsEnabled">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5 shrink-0" :disabled="loading || !canLoadAllRows" :aria-label="t('grid.loadAllAndGoToLastRow')" @click="emit('loadAllRows')">
<ChevronsDown class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.loadAllAndGoToLastRow") }}</TooltipContent>
</Tooltip>
<DataGridExportMenu :items="exportMenuItems" :label="t('grid.export')" :on-select="(value) => emit('selectExport', value)" />
</div>
</template>
@@ -30,6 +30,7 @@ vi.mock("@lucide/vue", async () => {
ChevronsLeft: icon,
ChevronsRight: icon,
Download: icon,
ChevronsDown: icon,
Filter: icon,
Focus: icon,
FileDiff: icon,
@@ -288,7 +289,7 @@ describe("DataGridPagination", () => {
onNextPage: nextPage,
onLastPage: lastPage,
});
const navigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
const navigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0" && node.props["aria-label"] !== "grid.loadAllAndGoToLastRow");
expect(navigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
navigation.forEach((node) => dispatch(node, "click"));
@@ -298,7 +299,7 @@ describe("DataGridPagination", () => {
expect(lastPage).not.toHaveBeenCalled();
await mounted.setProps({ currentPage: 2, canGoNextPage: true, canJumpLastPage: true });
const enabledNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0");
const enabledNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0" && node.props["aria-label"] !== "grid.loadAllAndGoToLastRow");
expect(enabledNavigation.map((node) => node.props.disabled)).toEqual([false, false, false, false]);
enabledNavigation.forEach((node) => dispatch(node, "click"));
expect(firstPage).toHaveBeenCalledOnce();
@@ -307,7 +308,7 @@ describe("DataGridPagination", () => {
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");
const busyNavigation = findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0" && node.props["aria-label"] !== "grid.loadAllAndGoToLastRow");
expect(busyNavigation.map((node) => node.props.disabled)).toEqual([true, true, true, true]);
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.jumpToPage").props.disabled).toBe(true);
});
@@ -375,6 +376,36 @@ describe("DataGridPagination", () => {
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0")).toHaveLength(0);
});
it("loads all paginated rows and moves to the last row", async () => {
const loadAllRows = vi.fn();
const mounted = mountComponent(DataGridPagination, {
selectionSummary: null,
selectionSummarySumText: "",
loading: false,
infiniteScrollEnabled: false,
infiniteScrollAllLoaded: false,
canLoadAllRows: true,
pageSize: 100,
customPageSizeInput: "",
pageSizeMenuItems: [],
exportMenuItems: [],
currentPage: 1,
canGoNextPage: true,
canJumpLastPage: true,
onLoadAllRows: loadAllRows,
});
const loadAllButton = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.loadAllAndGoToLastRow");
expect(loadAllButton.props.disabled).toBe(false);
dispatch(loadAllButton, "click");
expect(loadAllRows).toHaveBeenCalledOnce();
await mounted.setProps({ loading: true });
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.loadAllAndGoToLastRow").props.disabled).toBe(true);
await mounted.setProps({ loading: false, canLoadAllRows: false });
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.loadAllAndGoToLastRow").props.disabled).toBe(true);
});
});
describe("DataGridColumnHeader", () => {
@@ -2301,7 +2301,7 @@ defineExpose({
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
@@ -2727,7 +2727,7 @@ defineExpose({
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
@@ -91,7 +91,7 @@ export interface ContentAreaSurfaceEmits {
editorStateFlushed: [tabId: string];
formatError: [tabId: string];
reload: [tabId: string, sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string];
paginate: [tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean];
sort: [tabId: string, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
executeSql: [tabId: string, sql: string];
clickTable: [tabId: string, target: SqlObjectNavigationTarget];
@@ -478,10 +478,10 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
await queryStore.executeCurrentTab();
}
async function onPaginate(tabId: string | undefined, offset: number, limit: number, whereInput?: string, orderBy?: string) {
async function onPaginate(tabId: string | undefined, offset: number, limit: number, whereInput?: string, orderBy?: string, appendRequested = false) {
const tab = resolveActionTab(tabId);
if (!tab) return;
const appendResult = settingsStore.editorSettings.infiniteScroll && offset > 0 && offset === tab.result?.rows.length;
const appendResult = (appendRequested || settingsStore.editorSettings.infiniteScroll) && offset > 0 && offset === tab.result?.rows.length;
const appendOptions = appendResult
? {
appendResult: {
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import az from "../locales/az";
import en from "../locales/en";
import es from "../locales/es";
import it_ from "../locales/it";
import ja from "../locales/ja";
import ko from "../locales/ko";
import ptBR from "../locales/pt-BR";
import tr from "../locales/tr";
import zhCN from "../locales/zh-CN";
import zhTW from "../locales/zh-TW";
const locales: Array<[string, Record<string, unknown>]> = [
["az", az],
["en", en],
["es", es],
["it", it_],
["ja", ja],
["ko", ko],
["pt-BR", ptBR],
["tr", tr],
["zh-CN", zhCN],
["zh-TW", zhTW],
];
// The grid toolbar renders in whatever locale the user runs, so a missing key
// would leak a raw key next to the "load all" button.
const GRID_LOAD_ALL_KEYS = ["loadAllAndGoToLastRow", "allLoaded", "loadAllRowsConfirmTitle", "loadAllRowsConfirmMessage", "loadAllRowsContinue"] as const;
describe("grid load-all locale parity", () => {
it.each(locales)("%s exposes the load-all copy", (_name, locale) => {
const grid = (locale as { grid: Record<string, unknown> }).grid;
for (const key of GRID_LOAD_ALL_KEYS) {
expect(grid[key], `${_name}: grid.${key}`).toBeTypeOf("string");
expect(grid[key] as string, `${_name}: grid.${key}`).not.toHaveLength(0);
}
});
});
+4
View File
@@ -1763,6 +1763,10 @@ export default withEnglishFallback({
esDeepPageJumpConfirmMessage:
"Elasticsearch search_after birbaşa səhifəyə keçə bilmir. DBX çatışmayan kursorları ardıcıllıqla əldə etməlidir və geriyə keçid 1-ci səhifədən yenidən başlayır. {page} nömrəli səhifəyə keçid zamanı {requests} sorğu göndəriləcəyi gözlənilir. Bu, uzun çəkə və klasterin yükünü artıra bilər. Davam edilsin?",
esDeepPageJumpContinue: "Davam et",
loadAllAndGoToLastRow: "Hamısını yüklə və sonuncu sətrə keç",
loadAllRowsConfirmTitle: "Bütün sətirlərin yüklənməsini təsdiqlə",
loadAllRowsConfirmMessage: "Hamısını yükləmək bir sorğu ilə ən çox {count} sətir əlavə gətirə bilər. Çox böyük nəticələr yaddaşı çox istifadə edir və uzun çəkə bilər. Davam edilsin?",
loadAllRowsContinue: "Hamısını yüklə",
allLoaded: "hamısı yükləndi",
calculateTotalRows: "Sətirlərin ümumi sayını hesabla",
calculateTotalRowsInline: "(sətirlərin ümumi sayını hesabla)",
+4
View File
@@ -1992,6 +1992,10 @@ export default {
"Elasticsearch search_after cannot jump directly to a page. DBX must fetch missing cursors in order, and backward navigation restarts from page 1. Jumping to page {page} is expected to send {requests} queries. This may take a long time and increase cluster load. Continue?",
esDeepPageJumpContinue: "Continue",
allLoaded: "all loaded",
loadAllAndGoToLastRow: "Load all and go to the last row",
loadAllRowsConfirmTitle: "Confirm loading all rows",
loadAllRowsConfirmMessage: "Loading everything fetches up to {count} more rows in one request. Very large results consume significant memory and can take a long time. Continue?",
loadAllRowsContinue: "Load all rows",
calculateTotalRows: "Count total rows",
calculateTotalRowsInline: "(count total rows)",
calculateTotalRowsFailed: "Count failed: {message}",
+4
View File
@@ -1925,6 +1925,10 @@ export default withEnglishFallback({
"search_after de Elasticsearch no puede saltar directamente a una página. DBX debe obtener en orden los cursores que faltan y, al retroceder, comienza de nuevo desde la página 1. Saltar a la página {page} enviará aproximadamente {requests} consultas. Puede tardar y aumentar la carga del clúster. ¿Continuar?",
esDeepPageJumpContinue: "Continuar",
allLoaded: "todo cargado",
loadAllAndGoToLastRow: "Cargar todo e ir a la última fila",
loadAllRowsConfirmTitle: "Confirmar carga de todas las filas",
loadAllRowsConfirmMessage: "Cargar todo puede traer hasta {count} filas más en una sola solicitud. Los resultados muy grandes consumen mucha memoria y pueden tardar. ¿Continuar?",
loadAllRowsContinue: "Cargar todas las filas",
calculateTotalRows: "Contar filas totales",
calculateTotalRowsInline: "(contar filas totales)",
calculateTotalRowsFailed: "Error al contar: {message}",
+4
View File
@@ -1922,6 +1922,10 @@ export default withEnglishFallback({
"search_after di Elasticsearch non può passare direttamente a una pagina. DBX deve recuperare in ordine i cursori mancanti e, tornando indietro, riparte dalla pagina 1. Il passaggio alla pagina {page} invierà circa {requests} query. Potrebbe richiedere tempo e aumentare il carico del cluster. Continuare?",
esDeepPageJumpContinue: "Continua",
allLoaded: "tutto caricato",
loadAllAndGoToLastRow: "Carica tutto e vai all'ultima riga",
loadAllRowsConfirmTitle: "Conferma caricamento di tutte le righe",
loadAllRowsConfirmMessage: "Il caricamento completo può recuperare fino a {count} righe aggiuntive in un'unica richiesta. Risultati molto grandi consumano molta memoria e possono richiedere tempo. Continuare?",
loadAllRowsContinue: "Carica tutte le righe",
calculateTotalRows: "Conta righe totali",
calculateTotalRowsInline: "(conta righe totali)",
calculateTotalRowsFailed: "Conteggio non riuscito: {message}",
+4
View File
@@ -2509,6 +2509,10 @@ export default withEnglishFallback({
"Elasticsearch の search_after では指定ページへ直接移動できません。DBX は不足しているカーソルを順番に取得し、前のページへ戻る場合は 1 ページ目からやり直します。ページ {page} への移動では約 {requests} 回のクエリが送信される見込みです。時間がかかり、クラスター負荷が増える可能性があります。続行しますか?",
esDeepPageJumpContinue: "移動を続ける",
allLoaded: "すべて読み込み済み",
loadAllAndGoToLastRow: "すべて読み込み、最終行へ移動",
loadAllRowsConfirmTitle: "全行ロードの確認",
loadAllRowsConfirmMessage: "すべて読み込むと、1 回のリクエストで最大 {count} 行を追加取得します。非常に大きな結果セットは多くのメモリを消費し、時間がかかることがあります。続行しますか?",
loadAllRowsContinue: "ロードを続行",
previewSqlEmpty: "プレビューする保留中のSQL変更はありません",
renderMode: "レンダーモード",
undoChange: "変更を元に戻す",
+4
View File
@@ -1872,6 +1872,10 @@ export default withEnglishFallback({
"Elasticsearch의 search_after는 지정한 페이지로 바로 이동할 수 없습니다. DBX는 누락된 커서를 순서대로 가져와야 하며 이전 페이지로 이동할 때는 1페이지부터 다시 시작합니다. {page}페이지로 이동하면 약 {requests}개의 쿼리가 전송됩니다. 시간이 오래 걸리고 클러스터 부하가 증가할 수 있습니다. 계속하시겠습니까?",
esDeepPageJumpContinue: "계속 이동",
allLoaded: "모두 로드됨",
loadAllAndGoToLastRow: "모두 불러오고 마지막 행으로 이동",
loadAllRowsConfirmTitle: "전체 로드 확인",
loadAllRowsConfirmMessage: "모두 로드하면 한 번의 요청으로 최대 {count}개의 행을 더 가져옵니다. 매우 큰 결과 집합은 많은 메모리를 사용하고 시간이 오래 걸릴 수 있습니다. 계속하시겠습니까?",
loadAllRowsContinue: "계속 로드",
calculateTotalRows: "전체 행 수 집계",
calculateTotalRowsInline: "(전체 행 수 집계)",
calculateTotalRowsFailed: "집계 실패: {message}",
+4
View File
@@ -1924,6 +1924,10 @@ export default withEnglishFallback({
"O search_after do Elasticsearch não pode ir diretamente para uma página. O DBX precisa buscar os cursores ausentes em ordem e, ao voltar, reinicia pela página 1. Ir para a página {page} deve enviar cerca de {requests} consultas. Isso pode demorar e aumentar a carga do cluster. Continuar?",
esDeepPageJumpContinue: "Continuar",
allLoaded: "tudo carregado",
loadAllAndGoToLastRow: "Carregar tudo e ir para a última linha",
loadAllRowsConfirmTitle: "Confirmar carregamento de todas as linhas",
loadAllRowsConfirmMessage: "Carregar tudo pode buscar até {count} linhas adicionais em uma única solicitação. Resultados muito grandes consomem muita memória e podem demorar. Continuar?",
loadAllRowsContinue: "Carregar todas as linhas",
calculateTotalRows: "Contar total de linhas",
calculateTotalRowsInline: "(contar total de linhas)",
calculateTotalRowsFailed: "Falha ao contar: {message}",
+4
View File
@@ -1764,6 +1764,10 @@ export default withEnglishFallback({
esDeepPageJumpConfirmMessage:
"Elasticsearch search_after doğrudan bir sayfaya atlayamaz. DBX eksik imleçleri sırayla getirmelidir ve geri gitme 1. sayfadan yeniden başlar. {page}. sayfaya atlamak için {requests} sorgu gönderilmesi bekleniyor. Bu uzun sürebilir ve küme yükünü artırabilir. Devam edilsin mi?",
esDeepPageJumpContinue: "Devam et",
loadAllAndGoToLastRow: "Tümünü yükle ve son satıra git",
loadAllRowsConfirmTitle: "Tüm satırları yükleme onayı",
loadAllRowsConfirmMessage: "Tümünü yüklemek tek istekte en fazla {count} satır daha çekebilir. Çok büyük sonuç kümeleri belleği yoğun kullanır ve uzun sürebilir. Devam edilsin mi?",
loadAllRowsContinue: "Tümünü yükle",
allLoaded: "tümü yüklendi",
calculateTotalRows: "Toplam satırları say",
calculateTotalRowsInline: "(toplam satırları say)",
+4
View File
@@ -1905,6 +1905,10 @@ export default withEnglishFallback({
esDeepPageJumpConfirmMessage: "Elasticsearch 的 search_after 不能直接定位到指定页面。DBX 必须按顺序获取缺失的游标,跳回较小页码时还会从第 1 页重新开始。跳转到第 {page} 页预计会发送 {requests} 次查询,可能耗时较长并增加集群负载。是否继续?",
esDeepPageJumpContinue: "继续跳转",
allLoaded: "已全部加载",
loadAllAndGoToLastRow: "加载全部并转到末行",
loadAllRowsConfirmTitle: "确认加载全部行",
loadAllRowsConfirmMessage: "加载全部将通过一次请求最多再获取 {count} 行。超大结果集会占用大量内存并可能耗时较长。是否继续?",
loadAllRowsContinue: "继续加载",
calculateTotalRows: "统计总行数",
calculateTotalRowsInline: "(统计总行数)",
calculateTotalRowsFailed: "统计失败:{message}",
+4
View File
@@ -1913,6 +1913,10 @@ export default withEnglishFallback({
esDeepPageJumpConfirmMessage: "Elasticsearch 的 search_after 無法直接定位到指定頁面。DBX 必須依序取得缺少的游標,跳回較小頁碼時還會從第 1 頁重新開始。跳轉到第 {page} 頁預計會傳送 {requests} 次查詢,可能耗時較長並增加叢集負載。是否繼續?",
esDeepPageJumpContinue: "繼續跳轉",
allLoaded: "已全部載入",
loadAllAndGoToLastRow: "載入全部並前往最後一列",
loadAllRowsConfirmTitle: "確認載入全部資料列",
loadAllRowsConfirmMessage: "載入全部將透過一次請求最多再取得 {count} 筆。超大型結果集會佔用大量記憶體且可能耗時較長。是否繼續?",
loadAllRowsContinue: "繼續載入",
calculateTotalRows: "統計總筆數",
calculateTotalRowsInline: "(統計總筆數)",
calculateTotalRowsFailed: "統計失敗:{message}",
@@ -31,6 +31,11 @@ export interface CanFetchNextDataGridSegmentOptions {
allRowsLoaded?: boolean;
}
export interface DataGridLoadAllSegment {
offset: number;
limit: number;
}
export type DataGridInexactTotalRowCountMode = "at-least" | "estimated";
export const ELASTICSEARCH_PAGE_JUMP_WARNING_REQUESTS = 100;
@@ -109,3 +114,10 @@ export function canFetchNextDataGridSegment(options: CanFetchNextDataGridSegment
if (options.allRowsLoaded === true) return false;
return options.loadedRowCount >= Math.max(1, options.pageSize);
}
export function dataGridLoadAllSegment(loadedRowCount: number, maxRows: number, canFetchMore: boolean): DataGridLoadAllSegment | null {
const offset = Number.isFinite(loadedRowCount) ? Math.max(0, Math.trunc(loadedRowCount)) : 0;
const boundedMaxRows = Number.isFinite(maxRows) ? Math.max(0, Math.trunc(maxRows)) : 0;
if (!canFetchMore || offset >= boundedMaxRows) return null;
return { offset, limit: boundedMaxRows - offset };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+36
View File
@@ -1,4 +1,5 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
import { dataGridScrollPosition, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll } from "../../apps/desktop/src/lib/dataGrid/dataGridInfiniteScroll.ts";
@@ -38,3 +39,38 @@ test("append marker does not preserve state when an existing row was replaced",
assert.equal(isDataGridPrefixAppend(previous, { rows: [first, [...second], [3, "Grace"]], appended_from_row_count: 2 }), false);
assert.equal(isDataGridPrefixAppend(previous, { rows: [first, second, [3, "Grace"]] }), false);
});
test("load-all selects the last loaded row only after a valid append completes", () => {
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
const contentAreaSource = readFileSync("apps/desktop/src/components/layout/ContentArea.vue", "utf8");
const querySurfacesSource = readFileSync("apps/desktop/src/components/layout/querySurfaces.ts", "utf8");
const appSource = readFileSync("apps/desktop/src/App.vue", "utf8");
const documentBrowserSource = readFileSync("apps/desktop/src/components/document/DocumentBrowser.vue", "utf8");
// loadAllRowsAndGoToLast hands the actual fetch to startLoadAllRows after the
// ES guard and the large-shot confirmation, so extract both bodies.
const loadAllFn = source.match(/function loadAllRowsAndGoToLast\(\) \{[\s\S]*?\nfunction confirmLoadAllRows\(\) \{[\s\S]*?\n\}/)?.[0] ?? "";
assert.match(loadAllFn, /gridSurfaceBusy\.value \|\| infiniteScrollLoading\.value/);
assert.match(loadAllFn, /dataGridLoadAllSegment/);
assert.match(loadAllFn, /if \(!segment\) \{[\s\S]*?selectAndRevealLastLoadedRow\(\);[\s\S]*?return;/);
assert.match(loadAllFn, /resolvedDatabaseType\.value === "elasticsearch" \|\| resolvedDatabaseType\.value === "easysearch"/);
assert.match(loadAllFn, /LOAD_ALL_ROWS_CONFIRM_ROW_THRESHOLD/);
assert.match(loadAllFn, /infiniteScrollLoadAllPending = true/);
assert.match(loadAllFn, /emit\("paginate", segment\.offset, segment\.limit, currentWhereInput\(\), currentOrderBy\(\), true\)/);
assert.match(source, /\(\) => \[props\.loading, props\.result\.rows\.length, props\.result\.appended_from_row_count\] as const[\s\S]*?\{ flush: "post" \}/);
assert.match(source, /props\.result\.appended_from_row_count !== requestedOffset[\s\S]*?return;[\s\S]*?if \(shouldSelectLastRow\) selectAndRevealLastLoadedRow\(\)/);
assert.match(source, /function selectAndRevealLastLoadedRow\(\)[\s\S]*?selectRow\(rowIndex\)[\s\S]*?remainingFrames = 12[\s\S]*?scrollCanvasRowIntoView\(rowIndex, "end"\)[\s\S]*?scrollDomRowIntoView\(rowIndex, "end"\)[\s\S]*?requestAnimationFrame\(revealWhenReady\)/);
assert.match(source, /\(\) => \[props\.countSql \?\? ""[\s\S]*?manualTotalRowCount\.value = undefined/);
assert.match(source, /if \(infiniteScrollLoadAllPending\) \{[\s\S]*?infiniteScrollLoadAllPending = false;[\s\S]*?selectAndRevealLastLoadedRow\(\)/);
assert.match(source, /dataGridInfiniteScrollAppendCompletion\(previousResult, result,[\s\S]*?loadAllRowsActive\.value = false/);
assert.match(querySurfacesSource, /paginate: \[tabId: string, offset: number, limit: number, whereInput\?: string, orderBy\?: string, appendResult\?: boolean\]/);
// App.vue's inline handler must forward appendResult, or "load all" silently
// degrades into replacing the result with the fetched segment.
assert.match(appSource, /@paginate="\(tabId: string, offset: number, limit: number, whereInput\?: string, orderBy\?: string, appendResult\?: boolean\) => onPaginate\(tabId, offset, limit, whereInput, orderBy, appendResult\)"/);
assert.equal(contentAreaSource.match(/emit\('paginate', activeTab\.id, offset, limit, whereInput, orderBy, appendResult\)/g)?.length, 2);
assert.match(documentBrowserSource, /:load-all-rows-enabled="false"/);
// A non-append result replacement must clear the all-loaded marker (stale all-loaded
// used to lock scrolling and degrade the load-all button to locate-only after WHERE edits).
const nonAppendIdx = source.indexOf("// A non-append result replaces the whole data set");
assert.ok(nonAppendIdx > 0, "non-append replacement branch must exist");
assert.match(source.slice(nonAppendIdx, nonAppendIdx + 420), /infiniteScrollAllLoaded = false;/);
});
+11 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridLoadAllSegment, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal } from "../../apps/desktop/src/lib/dataGrid/dataGridPagination.ts";
test("estimated display totals do not become pagination bounds", () => {
assert.equal(
@@ -187,6 +187,16 @@ test("infinite scroll preserves authoritative has-more and complete-local-result
assert.equal(canFetchNextDataGridSegment({ loadedRowCount: 1_000, pageSize: 1_000, allRowsLoaded: true }), false);
});
test("load-all requests every remaining row up to the configured cap", () => {
assert.deepEqual(dataGridLoadAllSegment(100, 5_000, true), { offset: 100, limit: 4_900 });
assert.deepEqual(dataGridLoadAllSegment(2_500, 5_000, true), { offset: 2_500, limit: 2_500 });
});
test("load-all does not request past the cap or after the result is complete", () => {
assert.equal(dataGridLoadAllSegment(5_000, 5_000, true), null);
assert.equal(dataGridLoadAllSegment(100, 5_000, false), null);
});
// --- auto-redirect page calculation after refresh ---
// These tests document the math used in DataGrid.vue's loading watcher:
// lastPageNum = Math.max(1, Math.ceil(total / pageSize))
+3 -3
View File
@@ -172,7 +172,7 @@ test("data reload preserves current page offset instead of resetting to page 1",
}
});
test("infinite pagination fetches and appends only the next table segment", async () => {
test("explicit load-all pagination fetches and appends only the missing table segment", async () => {
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
const { useDataGridActions } = await import("../../apps/desktop/src/composables/useDataGridActions.ts");
@@ -198,7 +198,7 @@ test("infinite pagination fetches and appends only the next table segment", asyn
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({ infiniteScroll: true, queryResultMaxRowsEnabled: true, queryResultMaxRows: 5000 });
settingsStore.updateEditorSettings({ infiniteScroll: false, queryResultMaxRowsEnabled: true, queryResultMaxRows: 5000 });
connectionStore.addEphemeralConnection(conn("mysql-1"));
const tabId = queryStore.createTab("mysql-1", "app", "orders", "data");
queryStore.setTableMeta(tabId, { tableName: "orders", tableType: "TABLE", columns: [], primaryKeys: [] });
@@ -210,7 +210,7 @@ test("infinite pagination fetches and appends only the next table segment", asyn
tab.resultPageOffset = 0;
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(tabId, 1, 100);
await actions.onPaginate(tabId, 1, 100, undefined, undefined, true);
assert.equal(buildSqlOptions?.offset, 1);
assert.equal(buildSqlOptions?.limit, 100);