From 8b6e609376550820c5c1317c7d27a5663bcf8677 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 16:18:20 +0000 Subject: [PATCH 1/4] fix(frontend): keep DataTable page size fixed after deletes Inferring page size from the current page length made last-page/next controls jump past real pages when the final page was short after bundle deletes. Use an explicit offset and clamp out-of-range pages. Co-authored-by: Martin DONADIEU --- src/components/DataTable.vue | 23 ++++++++++++++----- src/components/tables/AppTable.vue | 3 +++ src/components/tables/BundleTable.vue | 10 +++++++- src/components/tables/ChannelHistoryTable.vue | 1 + src/components/tables/ChannelTable.vue | 2 +- src/components/tables/DeviceTable.vue | 2 +- src/components/tables/HistoryTable.vue | 1 + src/pages/apps.vue | 1 + 8 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/components/DataTable.vue b/src/components/DataTable.vue index 260c115269..ae111ee4c3 100644 --- a/src/components/DataTable.vue +++ b/src/components/DataTable.vue @@ -36,6 +36,8 @@ interface Props { addButtonTestId?: string search?: string total: number + /** Fixed page size used for last-page / next calculations. Prefer this over inferring from the current page length. */ + offset?: number currentPage: number columns: TableColumn[] elementList: { [key: string]: any }[] @@ -71,12 +73,19 @@ const pendingAdd = ref(false) // const sorts = ref({}) // get columns from elementList -const offset = computed(() => { - if (!props.elementList) - return 0 +// Page size must stay fixed across pages. Inferring it from the current page's +// row count breaks last-page / next controls when the final page is short +// (common after deletes). +const pageSize = computed(() => { + if (props.offset && props.offset > 0) + return props.offset + if (!props.elementList || props.elementList.length === 0) + return 1 return props.elementList.length }) +const totalPages = computed(() => Math.max(1, Math.ceil(props.total / pageSize.value))) + const selectedRows = ref(props.elementList.map(_ => false)) const previousSelectedRow = ref(null) @@ -335,13 +344,15 @@ function tooltipIdFor(rowIndex: number, actionIndex: number): string { } const displayElemRange = computed(() => { - const begin = (props.currentPage - 1) * props.elementList.length + if (props.elementList.length === 0) + return '0-0' + const begin = (props.currentPage - 1) * pageSize.value const end = begin + props.elementList.length return `${begin}-${end}` }) function canNext() { - return props.currentPage < Math.ceil(props.total / offset.value) + return props.currentPage < totalPages.value } function canPrev() { return props.currentPage > 1 @@ -356,7 +367,7 @@ async function next() { async function fastForward() { if (canNext()) { emit('fastForward') - emit('update:currentPage', Math.ceil(props.total / offset.value)) + emit('update:currentPage', totalPages.value) } } async function prev() { diff --git a/src/components/tables/AppTable.vue b/src/components/tables/AppTable.vue index 383651e69e..1ad646a966 100644 --- a/src/components/tables/AppTable.vue +++ b/src/components/tables/AppTable.vue @@ -15,6 +15,8 @@ const props = defineProps<{ apps: (Database['public']['Tables']['apps']['Row'])[] deleteButton: boolean total?: number + /** Fixed page size for server-side pagination controls */ + offset?: number currentPage?: number search?: string serverSidePagination?: boolean @@ -308,6 +310,7 @@ const filteredApps = computed(() => { v-model:search="internalSearch" :show-add="!isMobile" :total="props.total ?? filteredApps.length" + :offset="props.offset" :element-list="filteredApps" :search-placeholder="t('search-by-name-or-app-id')" :is-loading="props.isLoading ?? false" diff --git a/src/components/tables/BundleTable.vue b/src/components/tables/BundleTable.vue index 2264043816..8904eefeeb 100644 --- a/src/components/tables/BundleTable.vue +++ b/src/components/tables/BundleTable.vue @@ -247,10 +247,17 @@ async function getData() { const { data: dataVersions, count } = await req if (!dataVersions) return + total.value = count ?? 0 + const maxPage = Math.max(1, Math.ceil(total.value / offset)) + if (currentPage.value > maxPage) { + currentPage.value = maxPage + // Re-fetch the clamped page so pagination stays aligned with the new total + // (e.g. after deletes shrink the last page away). + return getData() + } const enhancedVersions = await enhanceVersionElems(dataVersions) await fetchChannelsForVersions(enhancedVersions) elements.value = enhancedVersions as any - total.value = count ?? 0 } catch (error) { console.error(error) @@ -612,6 +619,7 @@ watch(props, async () => { { :search="search" :search-placeholder="t('search-by-name')" :total="total" + :offset="pageSize" :current-page="page" :columns="columns" :element-list="historyEntries" diff --git a/src/components/tables/ChannelTable.vue b/src/components/tables/ChannelTable.vue index 64f52b40bd..a193010bc7 100644 --- a/src/components/tables/ChannelTable.vue +++ b/src/components/tables/ChannelTable.vue @@ -380,7 +380,7 @@ watch(props, async () => {
props.channelId, () => props.bundleId, () => props.appId], () => { :search="search" :search-placeholder="t('search-by-name')" :total="total" + :offset="pageSize" :current-page="page" :columns="columns" :element-list="deployHistory" diff --git a/src/pages/apps.vue b/src/pages/apps.vue index 67383c962c..2e212ba3db 100644 --- a/src/pages/apps.vue +++ b/src/pages/apps.vue @@ -214,6 +214,7 @@ displayStore.defaultBack = '/apps' :search="searchQuery" :apps="apps" :total="totalApps" + :offset="pageSize" :delete-button="!organizationStore.currentOrganizationFailed" :server-side-pagination="true" :is-loading="isTableLoading" From 50eb6d5810e813b9021dd502bfd5ecc793ef8f2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 16:31:33 +0000 Subject: [PATCH 2/4] fix(frontend): await clamped page refetch in BundleTable Keep loading state true until the out-of-range page refetch finishes. Co-authored-by: Martin DONADIEU --- src/components/tables/BundleTable.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/tables/BundleTable.vue b/src/components/tables/BundleTable.vue index 8904eefeeb..2b91eda6ce 100644 --- a/src/components/tables/BundleTable.vue +++ b/src/components/tables/BundleTable.vue @@ -251,9 +251,10 @@ async function getData() { const maxPage = Math.max(1, Math.ceil(total.value / offset)) if (currentPage.value > maxPage) { currentPage.value = maxPage - // Re-fetch the clamped page so pagination stays aligned with the new total - // (e.g. after deletes shrink the last page away). - return getData() + // Await the clamped-page refetch so isLoading stays true until fresh rows + // arrive (e.g. after deletes shrink the last page away). + await getData() + return } const enhancedVersions = await enhanceVersionElems(dataVersions) await fetchChannelsForVersions(enhancedVersions) From ead0e10a61675c5cd6b933a74a869a17e035a656 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 16:38:35 +0000 Subject: [PATCH 3/4] fix(frontend): clamp ChannelTable page after deletes Keep the table on a valid page when the last item on a page is deleted, matching BundleTable pagination behavior. Co-authored-by: Martin DONADIEU --- src/components/tables/ChannelTable.vue | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/tables/ChannelTable.vue b/src/components/tables/ChannelTable.vue index a193010bc7..f44cb5e244 100644 --- a/src/components/tables/ChannelTable.vue +++ b/src/components/tables/ChannelTable.vue @@ -133,10 +133,17 @@ async function getData() { const { data: dataVersions, count } = await req if (!dataVersions) return + total.value = count ?? 0 + const maxPage = Math.max(1, Math.ceil(total.value / offset)) + if (currentPage.value > maxPage) { + currentPage.value = maxPage + // Await the clamped-page refetch so isLoading stays true until fresh rows + // arrive (e.g. after deletes shrink the last page away). + await getData() + return + } elements.value.length = 0 elements.value.push(...dataVersions as any) - // console.log('count', count) - total.value = count ?? 0 if (count === 0) { showAddModal() } @@ -166,16 +173,13 @@ async function getData() { isLoading.value = false } async function refreshData(keepCurrentPage = false) { - // console.log('refreshData') try { - const page = currentPage.value if (!keepCurrentPage) currentPage.value = 1 elements.value.length = 0 + // getData clamps currentPage when deletes leave it past the last page await getData() - if (keepCurrentPage) - currentPage.value = page } catch (error) { console.error(error) From c1c6998ee9db5879d097480686178e4c7796940b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 16:47:31 +0000 Subject: [PATCH 4/4] fix(frontend): share out-of-range page refetch helper Extract clamp-and-refetch into one helper so BundleTable and ChannelTable do not duplicate pagination recovery after deletes. Co-authored-by: Martin DONADIEU --- src/components/tables/BundleTable.vue | 9 ++------- src/components/tables/ChannelTable.vue | 9 ++------- src/services/tablePagination.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 src/services/tablePagination.ts diff --git a/src/components/tables/BundleTable.vue b/src/components/tables/BundleTable.vue index 2b91eda6ce..9c7f8c4c04 100644 --- a/src/components/tables/BundleTable.vue +++ b/src/components/tables/BundleTable.vue @@ -18,6 +18,7 @@ import { formatBytes } from '~/services/conversion' import { formatDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' import { useSupabase } from '~/services/supabase' +import { refetchIfPageOutOfRange } from '~/services/tablePagination' import { useDialogV2Store } from '~/stores/dialogv2' const props = defineProps<{ @@ -248,14 +249,8 @@ async function getData() { if (!dataVersions) return total.value = count ?? 0 - const maxPage = Math.max(1, Math.ceil(total.value / offset)) - if (currentPage.value > maxPage) { - currentPage.value = maxPage - // Await the clamped-page refetch so isLoading stays true until fresh rows - // arrive (e.g. after deletes shrink the last page away). - await getData() + if (await refetchIfPageOutOfRange(currentPage, total.value, offset, getData)) return - } const enhancedVersions = await enhanceVersionElems(dataVersions) await fetchChannelsForVersions(enhancedVersions) elements.value = enhancedVersions as any diff --git a/src/components/tables/ChannelTable.vue b/src/components/tables/ChannelTable.vue index f44cb5e244..48c41deda9 100644 --- a/src/components/tables/ChannelTable.vue +++ b/src/components/tables/ChannelTable.vue @@ -14,6 +14,7 @@ import IconTrash from '~icons/heroicons/trash' import { formatDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' import { useSupabase } from '~/services/supabase' +import { refetchIfPageOutOfRange } from '~/services/tablePagination' import { useDialogV2Store } from '~/stores/dialogv2' import { useMainStore } from '~/stores/main' import { useOrganizationStore } from '~/stores/organization' @@ -134,14 +135,8 @@ async function getData() { if (!dataVersions) return total.value = count ?? 0 - const maxPage = Math.max(1, Math.ceil(total.value / offset)) - if (currentPage.value > maxPage) { - currentPage.value = maxPage - // Await the clamped-page refetch so isLoading stays true until fresh rows - // arrive (e.g. after deletes shrink the last page away). - await getData() + if (await refetchIfPageOutOfRange(currentPage, total.value, offset, getData)) return - } elements.value.length = 0 elements.value.push(...dataVersions as any) if (count === 0) { diff --git a/src/services/tablePagination.ts b/src/services/tablePagination.ts new file mode 100644 index 0000000000..2ff9a9d4bb --- /dev/null +++ b/src/services/tablePagination.ts @@ -0,0 +1,17 @@ +/** + * If the active page is past the last page for the new total, clamp it and refetch. + * Returns true when a refetch was started. + */ +export async function refetchIfPageOutOfRange( + currentPage: { value: number }, + total: number, + pageSize: number, + refetch: () => Promise, +): Promise { + const maxPage = Math.max(1, Math.ceil(total / pageSize)) + if (currentPage.value <= maxPage) + return false + currentPage.value = maxPage + await refetch() + return true +}