Added pagination to invites #16

Merged
robin merged 3 commits from dev into main 2026-09-01 11:14:52 +02:00
7 changed files with 241 additions and 22 deletions
+21 -4
View File
@@ -21,12 +21,12 @@ describe('invites API', () => {
localStorage.clear() localStorage.clear()
}) })
it('getInvitesApi sends GET to /user/invites and returns the invites', async () => { it('getInvitesApi sends GET to /user/invites and returns the paginated result', async () => {
const mockInvites = [{ id: 'invite-1', code: 'ABC123' }] const mockResponse = { data: [{ id: 'invite-1', code: 'ABC123' }], total: 1, count: 1 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true, ok: true,
status: 200, status: 200,
json: async () => mockInvites, json: async () => mockResponse,
} as unknown as Response) } as unknown as Response)
global.fetch = fetchMock global.fetch = fetchMock
@@ -36,7 +36,24 @@ describe('invites API', () => {
`${API_BASE_URL}/user/invites`, `${API_BASE_URL}/user/invites`,
expect.objectContaining({ method: 'GET' }), expect.objectContaining({ method: 'GET' }),
) )
expect(result).toEqual(mockInvites) expect(result).toEqual(mockResponse)
})
it('getInvitesApi sends page and count as query params when given', async () => {
const mockResponse = { data: [], total: 0, count: 10 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
} as unknown as Response)
global.fetch = fetchMock
await getInvitesApi({ page: 2, count: 10 })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites?page=2&count=10`,
expect.objectContaining({ method: 'GET' }),
)
}) })
it('getInvitesApi throws on failure', async () => { it('getInvitesApi throws on failure', async () => {
+13 -3
View File
@@ -1,9 +1,19 @@
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http' import { extractErrorMessage } from '@/api/http'
import type { Invite } from '@/types/invite' import type { Invite, PaginatedInvites } from '@/types/invite'
export async function getInvitesApi(): Promise<Invite[]> { export interface GetInvitesParams {
const response = await apiClient.get('/user/invites') page?: number
count?: number
}
export async function getInvitesApi(params: GetInvitesParams = {}): Promise<PaginatedInvites> {
const query = new URLSearchParams()
if (params.page !== undefined) query.set('page', String(params.page))
if (params.count !== undefined) query.set('count', String(params.count))
const queryString = query.toString()
const response = await apiClient.get(`/user/invites${queryString ? `?${queryString}` : ''}`)
if (!response.ok) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load invites')) throw new Error(await extractErrorMessage(response, 'Failed to load invites'))
} }
+1
View File
@@ -130,6 +130,7 @@
} }
.page { .page {
width: 100%;
min-height: 100vh; min-height: 100vh;
padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem); padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem);
padding-top: calc(1rem + var(--safe-top)); padding-top: calc(1rem + var(--safe-top));
+91 -4
View File
@@ -5,8 +5,12 @@ import type { Invite } from '@/types/invite'
type InviteStatus = 'active' | 'used' | 'expired' type InviteStatus = 'active' | 'used' | 'expired'
const PAGE_SIZE = 10
const expanded = ref(false) const expanded = ref(false)
const invites = ref<Invite[]>([]) const invites = ref<Invite[]>([])
const page = ref(1)
const total = ref(0)
const isLoading = ref(false) const isLoading = ref(false)
const isCreating = ref(false) const isCreating = ref(false)
const error = ref('') const error = ref('')
@@ -20,6 +24,8 @@ let sharedTimeout: ReturnType<typeof setTimeout> | undefined
const activeCount = computed( const activeCount = computed(
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length, () => invites.value.filter((invite) => inviteStatus(invite) === 'active').length,
) )
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const showPagination = computed(() => totalPages.value > 1)
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearTimeout(sharedTimeout) clearTimeout(sharedTimeout)
@@ -45,7 +51,9 @@ async function loadInvites() {
isLoading.value = true isLoading.value = true
try { try {
invites.value = await getInvitesApi() const response = await getInvitesApi({ page: page.value, count: PAGE_SIZE })
invites.value = response.data
total.value = response.total
hasLoaded = true hasLoaded = true
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load invites' error.value = err instanceof Error ? err.message : 'Failed to load invites'
@@ -54,6 +62,14 @@ async function loadInvites() {
} }
} }
async function goToPage(target: number) {
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
return
}
page.value = target
await loadInvites()
}
async function handleCreate() { async function handleCreate() {
error.value = '' error.value = ''
if (isOffline()) { if (isOffline()) {
@@ -64,8 +80,8 @@ async function handleCreate() {
isCreating.value = true isCreating.value = true
try { try {
const invite = await createInviteApi() const invite = await createInviteApi()
invites.value = [invite, ...invites.value] page.value = 1
hasLoaded = true await loadInvites()
// Sharing is the whole point of an invite, so offer it immediately // Sharing is the whole point of an invite, so offer it immediately
// instead of making the user hunt for the Share button afterwards. // instead of making the user hunt for the Share button afterwards.
await shareInvite(invite) await shareInvite(invite)
@@ -96,7 +112,12 @@ async function confirmDelete(id: string) {
deletingId.value = id deletingId.value = id
try { try {
await deleteInviteApi(id) await deleteInviteApi(id)
invites.value = invites.value.filter((invite) => invite.id !== id) // Deleted the only item on a page past the first: step back a page
// instead of reloading into a stranded, empty page.
if (invites.value.length === 1 && page.value > 1) {
page.value -= 1
}
await loadInvites()
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete invite' error.value = err instanceof Error ? err.message : 'Failed to delete invite'
} finally { } finally {
@@ -258,6 +279,28 @@ function inviteDetail(invite: Invite): string {
<p v-else class="invites-empty">No invites yet. Generate one to invite someone.</p> <p v-else class="invites-empty">No invites yet. Generate one to invite someone.</p>
<div v-if="showPagination" class="invites-pagination">
<button
type="button"
class="page-btn"
:disabled="page <= 1 || isLoading"
aria-label="Previous page"
@click="goToPage(page - 1)"
>
</button>
<span class="pagination-info">{{ total }} invites total</span>
<button
type="button"
class="page-btn"
:disabled="page >= totalPages || isLoading"
aria-label="Next page"
@click="goToPage(page + 1)"
>
</button>
</div>
<p v-if="error" class="banner banner-error">{{ error }}</p> <p v-if="error" class="banner banner-error">{{ error }}</p>
<button <button
@@ -384,6 +427,12 @@ function inviteDetail(invite: Invite): string {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@media (min-width: 768px) {
.invite-code {
max-width: 80ch;
}
}
.invite-status-pill { .invite-status-pill {
flex-shrink: 0; flex-shrink: 0;
font-size: 0.65rem; font-size: 0.65rem;
@@ -472,4 +521,42 @@ function inviteDetail(invite: Invite): string {
align-self: flex-start; align-self: flex-start;
padding: 0.55rem 1.1rem; padding: 0.55rem 1.1rem;
} }
.invites-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
}
.pagination-info {
font-size: 0.72rem;
color: var(--c-text-soft);
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
padding: 0;
background: none;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
color: var(--c-text);
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
}
.page-btn:hover:not(:disabled) {
border-color: var(--c-border-hover);
color: var(--c-heading);
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style> </style>
+98 -10
View File
@@ -2,7 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import InvitesPanel from '../InvitesPanel.vue' import InvitesPanel from '../InvitesPanel.vue'
import * as invitesApi from '@/api/invites' import * as invitesApi from '@/api/invites'
import type { Invite } from '@/types/invite' import type { Invite, PaginatedInvites } from '@/types/invite'
function paginated(data: Invite[], total = data.length): PaginatedInvites {
return { data, total, count: data.length }
}
describe('InvitesPanel', () => { describe('InvitesPanel', () => {
beforeEach(() => { beforeEach(() => {
@@ -36,12 +40,15 @@ describe('InvitesPanel', () => {
consumed_at: '2026-01-01T00:00:00.000Z', consumed_at: '2026-01-01T00:00:00.000Z',
}, },
] ]
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(mockInvites) const getSpy = vi
.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(mockInvites))
const wrapper = mount(InvitesPanel) const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click') await wrapper.find('.invites-toggle').trigger('click')
await flushPromises() await flushPromises()
expect(getSpy).toHaveBeenCalledWith({ page: 1, count: 10 })
expect(wrapper.text()).toContain('ABC123') expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('USEDCODE') expect(wrapper.text()).toContain('USEDCODE')
expect(wrapper.text()).toContain('Active') expect(wrapper.text()).toContain('Active')
@@ -60,7 +67,7 @@ describe('InvitesPanel', () => {
}) })
it('shows empty state when there are no invites', async () => { it('shows empty state when there are no invites', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([]) vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([]))
const wrapper = mount(InvitesPanel) const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click') await wrapper.find('.invites-toggle').trigger('click')
@@ -69,9 +76,60 @@ describe('InvitesPanel', () => {
expect(wrapper.text()).toContain('No invites yet') expect(wrapper.text()).toContain('No invites yet')
}) })
it('generates a new invite and prepends it to the list', async () => { it('does not show pagination controls when everything fits on one page', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([]) vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(
paginated([{ id: 'invite-1', code: 'ABC123' }], 1),
)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.find('.invites-pagination').exists()).toBe(false)
})
it('shows pagination controls and total count when there is more than one page', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated(page1, 15))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.find('.invites-pagination').text()).toContain('15 invites total')
const [prevBtn, nextBtn] = wrapper.findAll('.page-btn')
expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(true)
expect((nextBtn!.element as HTMLButtonElement).disabled).toBe(false)
})
it('navigates to the next page when the forward button is clicked', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
const getSpy = vi
.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(page1, 11))
.mockResolvedValueOnce(paginated(page2, 11))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
expect(getSpy).toHaveBeenLastCalledWith({ page: 2, count: 10 })
expect(wrapper.text()).toContain('CODE10')
const [prevBtn, nextBtnAfter] = wrapper.findAll('.page-btn')
expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(false)
expect((nextBtnAfter!.element as HTMLButtonElement).disabled).toBe(true)
})
it('generates a new invite, reloads page one, and prepends it to the list', async () => {
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' } const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([]))
.mockResolvedValueOnce(paginated([newInvite]))
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite) vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
const wrapper = mount(InvitesPanel) const wrapper = mount(InvitesPanel)
@@ -85,8 +143,10 @@ describe('InvitesPanel', () => {
}) })
it('shares the newly created invite automatically', async () => { it('shares the newly created invite automatically', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([])
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' } const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([]))
.mockResolvedValueOnce(paginated([newInvite]))
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite) vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
const wrapper = mount(InvitesPanel) const wrapper = mount(InvitesPanel)
@@ -103,7 +163,7 @@ describe('InvitesPanel', () => {
it('shares an existing invite link via the clipboard when Web Share is unavailable', async () => { it('shares an existing invite link via the clipboard when Web Share is unavailable', async () => {
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite]) vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([mockInvite]))
const wrapper = mount(InvitesPanel) const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click') await wrapper.find('.invites-toggle').trigger('click')
@@ -123,7 +183,7 @@ describe('InvitesPanel', () => {
Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true }) Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true })
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite]) vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([mockInvite]))
const wrapper = mount(InvitesPanel) const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click') await wrapper.find('.invites-toggle').trigger('click')
@@ -140,7 +200,9 @@ describe('InvitesPanel', () => {
it('deletes an invite after confirming', async () => { it('deletes an invite after confirming', async () => {
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' } const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([mockInvite]) vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([mockInvite]))
.mockResolvedValueOnce(paginated([]))
const deleteSpy = vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce() const deleteSpy = vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel) const wrapper = mount(InvitesPanel)
@@ -158,13 +220,39 @@ describe('InvitesPanel', () => {
expect(wrapper.text()).toContain('No invites yet') expect(wrapper.text()).toContain('No invites yet')
}) })
it('steps back a page when deleting the last item on a page past the first', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
const getSpy = vi
.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(page1, 11))
.mockResolvedValueOnce(paginated(page2, 11))
.mockResolvedValueOnce(paginated(page1, 10))
vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn-danger').trigger('click')
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
await flushPromises()
expect(getSpy).toHaveBeenLastCalledWith({ page: 1, count: 10 })
})
it('disables delete for already-used invites', async () => { it('disables delete for already-used invites', async () => {
const usedInvite: Invite = { const usedInvite: Invite = {
id: 'invite-1', id: 'invite-1',
code: 'USEDCODE', code: 'USEDCODE',
consumed_at: '2026-01-01T00:00:00.000Z', consumed_at: '2026-01-01T00:00:00.000Z',
} }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([usedInvite]) vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([usedInvite]))
const wrapper = mount(InvitesPanel) const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click') await wrapper.find('.invites-toggle').trigger('click')
+6
View File
@@ -4,3 +4,9 @@ export interface Invite {
expires_at?: string expires_at?: string
consumed_at?: string consumed_at?: string
} }
export interface PaginatedInvites {
data: Invite[]
total: number
count: number
}
+11 -1
View File
@@ -37,7 +37,7 @@ onMounted(() => {
<InvitesPanel /> <InvitesPanel />
<h1>Pending changes</h1> <h1 class="section-heading">Pending changes</h1>
<section class="card info-card"> <section class="card info-card">
<h4>Sync status</h4> <h4>Sync status</h4>
@@ -56,11 +56,21 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
@media (min-width: 768px) {
.page.about {
max-width: 780px;
}
}
.about h1 { .about h1 {
font-size: 1.35rem; font-size: 1.35rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.section-heading {
margin-top: 1.5rem;
}
.about p { .about p {
color: var(--c-text-soft); color: var(--c-text-soft);
font-size: 0.9rem; font-size: 0.9rem;