Skip to content

Commit 2959c76

Browse files
Shukriclaude
authored andcommitted
feat(dashboard): replace load-more with page-based pagination
- Issues store: swap offset/hasMore/loadingMore for page/perPage/totalPages getter; fetch now takes page param, live events only prepend on page 1 - IssuesView: paginated footer with prev/next arrows, numbered page buttons, smart ellipsis window (always shows first, last, current ±1 neighbours), row-range label ("1–25 of 312"), per-page indicator; page synced to URL query so browser back/forward and sharing work; any filter change resets to page 1; smooth scroll-to-top on page change Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent aee6f99 commit 2959c76

14 files changed

Lines changed: 134 additions & 64 deletions

dashboard/src/stores/issues.js

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
import axios from "axios";
22
import { defineStore } from "pinia";
33

4+
const PER_PAGE = 25;
5+
46
export const useIssuesStore = defineStore("issues", {
57
state: () => ({
6-
issues: [],
7-
total: 0,
8-
offset: 0,
9-
hasMore: false,
10-
loading: true,
11-
loadingMore: false,
12-
stats: null,
13-
live: [],
8+
issues: [],
9+
total: 0,
10+
page: 1,
11+
perPage: PER_PAGE,
12+
loading: true,
13+
stats: null,
14+
live: [],
1415
}),
1516

17+
getters: {
18+
totalPages: (state) => Math.max(1, Math.ceil(state.total / state.perPage)),
19+
},
20+
1621
actions: {
17-
async fetch(projectId, status = "unresolved", { search = "", environment = "", release = "", reset = true } = {}) {
18-
if (reset) {
19-
this.loading = true;
20-
this.offset = 0;
21-
this.issues = [];
22-
} else {
23-
this.loadingMore = true;
24-
}
22+
async fetch(projectId, status = "unresolved", { search = "", environment = "", release = "", page = 1 } = {}) {
23+
this.loading = true;
24+
this.page = page;
2525

2626
try {
2727
const { data } = await axios.get("/api/issues", {
@@ -31,31 +31,18 @@ export const useIssuesStore = defineStore("issues", {
3131
search: search || undefined,
3232
environment: environment || undefined,
3333
release: release || undefined,
34-
limit: 50,
35-
offset: reset ? 0 : this.offset,
34+
limit: this.perPage,
35+
offset: (page - 1) * this.perPage,
3636
},
3737
});
3838

39-
if (reset) {
40-
this.issues = data.data;
41-
} else {
42-
this.issues.push(...data.data);
43-
}
44-
45-
this.total = data.total ?? data.data.length;
46-
this.offset = (reset ? 0 : this.offset) + data.data.length;
47-
this.hasMore = this.issues.length < this.total;
39+
this.issues = data.data;
40+
this.total = data.total ?? data.data.length;
4841
} finally {
49-
this.loading = false;
50-
this.loadingMore = false;
42+
this.loading = false;
5143
}
5244
},
5345

54-
async fetchMore(projectId, status, search, environment, release = "") {
55-
if (!this.hasMore || this.loadingMore) return;
56-
await this.fetch(projectId, status, { search, environment, release, reset: false });
57-
},
58-
5946
async fetchStats() {
6047
try {
6148
const { data } = await axios.get("/api/stats");
@@ -93,7 +80,7 @@ export const useIssuesStore = defineStore("issues", {
9380
this.live.unshift(event);
9481
if (this.live.length > 20) this.live.pop();
9582

96-
if (event.is_new && !this.issues.some((i) => i.id === event.issue_id)) {
83+
if (event.is_new && this.page === 1 && !this.issues.some((i) => i.id === event.issue_id)) {
9784
this.issues.unshift({
9885
id: event.issue_id,
9986
title: event.title,
@@ -102,6 +89,8 @@ export const useIssuesStore = defineStore("issues", {
10289
event_count: 1,
10390
last_seen: new Date().toISOString(),
10491
});
92+
// Trim to page size so the row count stays consistent
93+
if (this.issues.length > this.perPage) this.issues.pop();
10594
this.total += 1;
10695
}
10796
},

dashboard/src/views/IssuesView.vue

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -286,19 +286,68 @@
286286
</div>
287287
</div>
288288

289-
<!-- Footer -->
290-
<div class="px-4 py-3 bg-[#0d0d16] border-t border-white/5 flex items-center justify-between">
291-
<span class="text-xs text-gray-600">
292-
Showing <span class="text-gray-400">{{ store.issues.length }}</span> of <span class="text-gray-400">{{ store.total }}</span>
289+
<!-- Pagination footer -->
290+
<div class="px-4 py-3 bg-[#0d0d16] border-t border-white/5 flex items-center justify-between gap-4">
291+
292+
<!-- Row count -->
293+
<span class="text-xs text-gray-600 shrink-0">
294+
<span class="text-gray-400">{{ pageStart }}–{{ pageEnd }}</span>
295+
of
296+
<span class="text-gray-400">{{ store.total }}</span>
293297
</span>
294-
<button
295-
v-if="store.hasMore"
296-
@click="loadMore"
297-
:disabled="store.loadingMore"
298-
class="text-xs text-violet-400 hover:text-violet-300 disabled:opacity-50 transition-colors font-medium"
299-
>
300-
{{ store.loadingMore ? 'Loading…' : 'Load more' }}
301-
</button>
298+
299+
<!-- Page buttons -->
300+
<div class="flex items-center gap-1">
301+
<!-- Prev -->
302+
<button
303+
@click="goToPage(store.page - 1)"
304+
:disabled="store.page <= 1"
305+
class="w-7 h-7 flex items-center justify-center rounded-md text-gray-400 hover:text-white hover:bg-white/8
306+
disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
307+
aria-label="Previous page"
308+
>
309+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
310+
<polyline points="10 4 6 8 10 12"/>
311+
</svg>
312+
</button>
313+
314+
<!-- Page numbers -->
315+
<span
316+
v-for="(p, i) in pageButtons"
317+
:key="i"
318+
>
319+
<span v-if="p === '...'" class="w-7 h-7 flex items-center justify-center text-xs text-gray-600">…</span>
320+
<button
321+
v-else
322+
@click="goToPage(p)"
323+
:class="p === store.page
324+
? 'bg-violet-600 text-white shadow'
325+
: 'text-gray-400 hover:text-white hover:bg-white/8'"
326+
class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-medium transition-colors tabular-nums"
327+
>
328+
{{ p }}
329+
</button>
330+
</span>
331+
332+
<!-- Next -->
333+
<button
334+
@click="goToPage(store.page + 1)"
335+
:disabled="store.page >= store.totalPages"
336+
class="w-7 h-7 flex items-center justify-center rounded-md text-gray-400 hover:text-white hover:bg-white/8
337+
disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
338+
aria-label="Next page"
339+
>
340+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
341+
<polyline points="6 4 10 8 6 12"/>
342+
</svg>
343+
</button>
344+
</div>
345+
346+
<!-- Per-page label -->
347+
<span class="text-xs text-gray-600 shrink-0 hidden sm:block">
348+
{{ store.perPage }} / page
349+
</span>
350+
302351
</div>
303352
</div>
304353

@@ -330,6 +379,7 @@ const tab = ref(route.query.tab ?? 'unresolved')
330379
const search = ref(route.query.search ?? '')
331380
const environment = ref(route.query.env ?? '')
332381
const release = ref(route.query.release ?? '')
382+
const page = ref(Number(route.query.page ?? 1))
333383
const releases = ref([])
334384
const openMenu = ref(null)
335385
const menuConfirm = ref(null) // { id, type } — pending confirmation for ellipsis action
@@ -343,10 +393,11 @@ function activeSearch() { return tab.value === 'vitals' ? 'Performance vitals'
343393
344394
function syncQuery() {
345395
router.replace({ query: {
346-
...(tab.value !== 'unresolved' ? { tab: tab.value } : {}),
347-
...(search.value ? { search: search.value } : {}),
396+
...(tab.value !== 'unresolved' ? { tab: tab.value } : {}),
397+
...(search.value ? { search: search.value } : {}),
348398
...(environment.value ? { env: environment.value } : {}),
349399
...(release.value ? { release: release.value } : {}),
400+
...(page.value > 1 ? { page: page.value } : {}),
350401
}})
351402
}
352403
@@ -355,6 +406,7 @@ function fetchIssues() {
355406
search: activeSearch(),
356407
environment: environment.value,
357408
release: release.value,
409+
page: page.value,
358410
})
359411
}
360412
@@ -407,12 +459,14 @@ async function action(type, id) {
407459
// ── Tab / filter changes ──────────────────────────────────────────────────────
408460
function setTab(t) {
409461
tab.value = t
462+
page.value = 1
410463
selected.value = new Set()
411464
syncQuery()
412465
fetchIssues()
413466
}
414467
415468
function refetch() {
469+
page.value = 1
416470
selected.value = new Set()
417471
syncQuery()
418472
fetchIssues()
@@ -423,10 +477,37 @@ function onSearch() {
423477
searchTimer = setTimeout(refetch, 300)
424478
}
425479
426-
function loadMore() {
427-
store.fetchMore(route.params.id, activeStatus(), activeSearch(), environment.value, release.value)
480+
function goToPage(p) {
481+
if (p < 1 || p > store.totalPages || p === store.page) return
482+
page.value = p
483+
selected.value = new Set()
484+
syncQuery()
485+
fetchIssues()
486+
window.scrollTo({ top: 0, behavior: 'smooth' })
428487
}
429488
489+
// ── Pagination helpers ────────────────────────────────────────────────────────
490+
const pageStart = computed(() => store.total === 0 ? 0 : (store.page - 1) * store.perPage + 1)
491+
const pageEnd = computed(() => Math.min(store.page * store.perPage, store.total))
492+
493+
// Builds the page button list with ellipsis, e.g. [1, '...', 4, 5, 6, '...', 12]
494+
const pageButtons = computed(() => {
495+
const total = store.totalPages
496+
const cur = store.page
497+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
498+
499+
const pages = new Set([1, total, cur - 1, cur, cur + 1].filter(p => p >= 1 && p <= total))
500+
const sorted = [...pages].sort((a, b) => a - b)
501+
const result = []
502+
let prev = 0
503+
for (const p of sorted) {
504+
if (p - prev > 1) result.push('...')
505+
result.push(p)
506+
prev = p
507+
}
508+
return result
509+
})
510+
430511
// ── Bulk selection ────────────────────────────────────────────────────────────
431512
const allSelected = computed(() => store.issues.length > 0 && store.issues.every(i => selected.value.has(i.id)))
432513
const someSelected = computed(() => !allSelected.value && store.issues.some(i => selected.value.has(i.id)))
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/dist/assets/IssuesView-BaHDZ07-.css

Lines changed: 0 additions & 1 deletion
This file was deleted.

web/dist/assets/IssuesView-BxA0bQLe.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)