fix(grid): preserve current page on data refresh instead of resetting to page 1

* @
fix(grid): preserve current page on data refresh instead of resetting to page 1

When refreshing table data (toolbar button, Ctrl+R, or auto-refresh), the
offset was hardcoded to 0, causing pagination to always jump back to the
first page. This was frustrating for users viewing later pages.

Use the current page offset (currentPage - 1) * pageSize so the user
stays on the same page after refresh. In infinite-scroll mode, the
offset still evaluates to 0 because resetInfiniteScrollState() sets
currentPage = 1, so behavior is unchanged.
@

* @
fix(grid): auto-redirect to last page when refreshed page no longer exists

Extends the refresh pagination fix with a safety net: when data is deleted
while viewing a later page (e.g. page 5 with only 2 pages remaining), the
grid auto-navigates to the last available page instead of showing an empty
page.

Uses a transient isRefreshingData flag to distinguish refresh/rollback
from normal pagination navigation, avoiding false triggers.
@

* Revert "@"

This reverts commit ff0bf0bb31.

* @
fix(grid): auto-redirect to last page when refreshed page no longer exists

When data is deleted while viewing a later page, the grid now auto-navigates
to the last available page instead of showing an empty page.

Uses a transient isRefreshingData flag to distinguish refresh/rollback
from normal pagination, and a loading-transition watcher (true->false)
placed after displayedTotalRowCount declaration to avoid TDZ errors.
@

* @
test(grid): add pagination offset-preservation and auto-redirect tests

Cover the two new behaviors added to DataGrid.vue:

1. dataGridPagination.test.ts — auto-redirect page calculation:
   - page beyond last page after data deletion → triggers redirect
   - page still valid → no redirect
   - fewer rows than one page → redirects to page 1
   - total=0 / total=undefined → guard prevents redirect

2. useDataGridActions.test.ts — offset preserved on reload:
   - onReloadData(offset=400) passes offset=400 to build-table-select-sql
   - resultPageOffset stored as 400 (not reset to 0)
@
This commit is contained in:
Abeautifulsnow
2026-07-08 13:28:36 +08:00
committed by GitHub
parent 2929b1c035
commit 0901f176b0
3 changed files with 126 additions and 2 deletions
+28 -2
View File
@@ -3364,6 +3364,10 @@ const isInfiniteScrollPaginating = ref(false);
let lastInfiniteScrollPage = 0;
let infiniteScrollCheckScheduled = false;
let infiniteScrollAllLoaded = 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.
const isRefreshingData = ref(false);
watch(pageSize, (value) => {
customPageSizeInput.value = String(value);
});
@@ -3454,6 +3458,26 @@ const canGoNextPage = computed(() => {
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql));
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const canCalculateTotalRowCount = computed(() => !isResultsContext.value && !!props.connectionId && (!!props.tableMeta || !!props.countSql));
// When a refresh/rollback completes and the current page exceeds the last
// available page (e.g. data was deleted while viewing), auto-navigate to the
// last available page instead of showing an empty page.
watch(
() => props.loading,
(loading, prevLoading) => {
// Only act when loading completes (transitions from true to false)
// and the completion was triggered by a refresh/rollback.
if (!loading && prevLoading && isRefreshingData.value) {
isRefreshingData.value = false;
const total = displayedTotalRowCount.value;
if (!total || total <= 0) return;
const lastPageNum = Math.max(1, Math.ceil(total / pageSize.value));
if (currentPage.value <= lastPageNum) return;
currentPage.value = lastPageNum;
resetGridVerticalScroll(true);
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
}
},
);
const showQueryEditReadyBadge = computed(() => isResultsContext.value && hasData.value && !!props.editable && (!!props.tableMeta || !!props.customSaveHandler));
const queryEditReadyTargetLabel = computed(() => props.tableMeta?.tableName ?? props.customSaveHandler?.targetLabel ?? "");
const showKeylessEditWarning = computed(() => !!props.editable && !!props.tableMeta && canUseKeylessRowPredicate(props.databaseType, props.tableMeta.primaryKeys ?? []));
@@ -4034,7 +4058,8 @@ async function onToolbarRefresh() {
resetInfiniteScrollState();
}
preserveTransposeOnNextResult.value = showTranspose.value;
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, 0);
isRefreshingData.value = true;
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value);
}
function stopAutoRefreshTimer() {
@@ -4078,7 +4103,8 @@ function onToolbarRollback() {
if (infiniteScrollEnabled.value) {
resetInfiniteScrollState();
}
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, 0);
isRefreshingData.value = true;
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value);
}
function addRow() {
@@ -43,3 +43,51 @@ test("unknown total falls back to full-page heuristic", () => {
assert.equal(canGoNextDataGridPage({ rowCount: 1, pageSize: 1 }), true);
assert.equal(canGoNextDataGridPage({ rowCount: 0, pageSize: 1 }), false);
});
// --- 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))
// redirect when currentPage > lastPageNum
test("auto-redirect: current page beyond last page after data deletion — should redirect", () => {
// user on page 5, data shrinks to 200 rows, pageSize=100 → last page=2
const total = 200;
const pageSize = 100;
const currentPage = 5;
const lastPageNum = Math.max(1, Math.ceil(total / pageSize));
assert.equal(lastPageNum, 2);
assert.equal(currentPage > lastPageNum, true, "redirect should be triggered");
assert.equal((lastPageNum - 1) * pageSize, 100, "paginate offset for last page should be 100");
});
test("auto-redirect: current page still valid after partial deletion — no redirect", () => {
// user on page 5, data still has 500 rows → last page stays 5
const total = 500;
const pageSize = 100;
const currentPage = 5;
const lastPageNum = Math.max(1, Math.ceil(total / pageSize));
assert.equal(lastPageNum, 5);
assert.equal(currentPage > lastPageNum, false, "no redirect should be triggered");
});
test("auto-redirect: fewer rows than one page — redirects to page 1", () => {
// user on page 3, data shrinks to 30 rows, pageSize=100 → last page=1
const total = 30;
const pageSize = 100;
const currentPage = 3;
const lastPageNum = Math.max(1, Math.ceil(total / pageSize));
assert.equal(lastPageNum, 1, "ceil(30/100)=1, max(1,1)=1");
assert.equal(currentPage > lastPageNum, true, "redirect should be triggered");
assert.equal((lastPageNum - 1) * pageSize, 0, "paginate offset for page 1 should be 0");
});
test("auto-redirect: total is zero — guard prevents redirect attempt", () => {
// When total=0, the '!total || total <= 0' guard fires and skips the redirect
const total = 0;
assert.equal(!total || total <= 0, true, "guard should prevent redirect when total is 0");
});
test("auto-redirect: total is undefined — guard prevents redirect attempt", () => {
const total = undefined;
assert.equal(!total || (total as any) <= 0, true, "guard should prevent redirect when total is unknown");
});
@@ -120,3 +120,53 @@ test("data reload executes before slow metadata refresh completes", async () =>
restoreStorage();
}
});
test("data reload preserves current page offset instead of resetting to page 1", async () => {
// Verifies the fix: onReloadData called with offset=400 (page 5, pageSize=100)
// should pass offset=400 to build-table-select-sql and store it in resultPageOffset,
// rather than defaulting to offset=0.
const restoreStorage = installMemoryStorage();
const originalFetch = globalThis.fetch;
const { useDataGridActions } = await import("../../apps/desktop/src/composables/useDataGridActions.ts");
let buildSqlOptions: any;
let executePagination: any;
globalThis.fetch = (async (input, init) => {
const url = new URL(String(input), "http://localhost");
if (url.pathname === "/api/connection/check-health") return Response.json(null);
if (url.pathname === "/api/schema/columns") return Response.json([]);
if (url.pathname === "/api/schema/indexes") return Response.json([]);
if (url.pathname === "/api/query/build-table-select-sql") {
buildSqlOptions = JSON.parse(String(init?.body ?? "{}"))?.options;
return Response.json(`SELECT * FROM \`orders\` LIMIT 100 OFFSET ${buildSqlOptions?.offset ?? 0}`);
}
if (url.pathname === "/api/query/execute-multi") {
const body = JSON.parse(String(init?.body ?? "{}"));
executePagination = body?.page_offset;
return Response.json([{ columns: ["id"], rows: [[42]], affected_rows: 0, execution_time_ms: 1 }]);
}
return new Response(`unexpected ${url.pathname}`, { status: 500 });
}) as typeof fetch;
try {
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
connectionStore.addEphemeralConnection(conn("mysql-1"));
const tabId = queryStore.createTab("mysql-1", "app", "orders", "data");
queryStore.setTableMeta(tabId, { tableName: "orders", tableType: "TABLE", columns: [], primaryKeys: [] });
const tab = queryStore.tabs.find((item) => item.id === tabId);
assert.ok(tab);
const actions = useDataGridActions(computed(() => tab));
// Simulate refresh from page 5 with pageSize=100 → offset=400
await actions.onReloadData(undefined, undefined, undefined, undefined, 100, 400);
assert.equal(buildSqlOptions?.offset, 400, "build-table-select-sql must receive offset=400 to stay on page 5");
assert.equal(buildSqlOptions?.limit, 100, "limit must be 100");
assert.equal(tab.resultPageOffset, 400, "resultPageOffset must be 400 after reload to keep DataGrid on page 5");
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});