mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user