mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(grid): load all remaining rows with confirmation guard
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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: "変更を元に戻す",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user