diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 09de11f555..5754803550 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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() { diff --git a/packages/app-tests/dataGridPagination.test.ts b/packages/app-tests/dataGridPagination.test.ts index 710324340d..ca54de39ea 100644 --- a/packages/app-tests/dataGridPagination.test.ts +++ b/packages/app-tests/dataGridPagination.test.ts @@ -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"); +}); diff --git a/packages/app-tests/useDataGridActions.test.ts b/packages/app-tests/useDataGridActions.test.ts index 56f91143f9..514372eb85 100644 --- a/packages/app-tests/useDataGridActions.test.ts +++ b/packages/app-tests/useDataGridActions.test.ts @@ -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(); + } +});