Merge pull request 'Added pagination to invites' (#16) from dev into main
This commit was merged in pull request #16.
This commit is contained in:
@@ -21,12 +21,12 @@ describe('invites API', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('getInvitesApi sends GET to /user/invites and returns the invites', async () => {
|
||||
const mockInvites = [{ id: 'invite-1', code: 'ABC123' }]
|
||||
it('getInvitesApi sends GET to /user/invites and returns the paginated result', async () => {
|
||||
const mockResponse = { data: [{ id: 'invite-1', code: 'ABC123' }], total: 1, count: 1 }
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockInvites,
|
||||
json: async () => mockResponse,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
@@ -36,7 +36,24 @@ describe('invites API', () => {
|
||||
`${API_BASE_URL}/user/invites`,
|
||||
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 () => {
|
||||
|
||||
+13
-3
@@ -1,9 +1,19 @@
|
||||
import { apiClient } from '@/api/client'
|
||||
import { extractErrorMessage } from '@/api/http'
|
||||
import type { Invite } from '@/types/invite'
|
||||
import type { Invite, PaginatedInvites } from '@/types/invite'
|
||||
|
||||
export async function getInvitesApi(): Promise<Invite[]> {
|
||||
const response = await apiClient.get('/user/invites')
|
||||
export interface GetInvitesParams {
|
||||
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) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to load invites'))
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem);
|
||||
padding-top: calc(1rem + var(--safe-top));
|
||||
|
||||
@@ -5,8 +5,12 @@ import type { Invite } from '@/types/invite'
|
||||
|
||||
type InviteStatus = 'active' | 'used' | 'expired'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const expanded = ref(false)
|
||||
const invites = ref<Invite[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isCreating = ref(false)
|
||||
const error = ref('')
|
||||
@@ -20,6 +24,8 @@ let sharedTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const activeCount = computed(
|
||||
() => 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(() => {
|
||||
clearTimeout(sharedTimeout)
|
||||
@@ -45,7 +51,9 @@ async function loadInvites() {
|
||||
|
||||
isLoading.value = true
|
||||
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
|
||||
} catch (err) {
|
||||
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() {
|
||||
error.value = ''
|
||||
if (isOffline()) {
|
||||
@@ -64,8 +80,8 @@ async function handleCreate() {
|
||||
isCreating.value = true
|
||||
try {
|
||||
const invite = await createInviteApi()
|
||||
invites.value = [invite, ...invites.value]
|
||||
hasLoaded = true
|
||||
page.value = 1
|
||||
await loadInvites()
|
||||
// Sharing is the whole point of an invite, so offer it immediately
|
||||
// instead of making the user hunt for the Share button afterwards.
|
||||
await shareInvite(invite)
|
||||
@@ -96,7 +112,12 @@ async function confirmDelete(id: string) {
|
||||
deletingId.value = id
|
||||
try {
|
||||
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) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
|
||||
} 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>
|
||||
|
||||
<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>
|
||||
|
||||
<button
|
||||
@@ -384,6 +427,12 @@ function inviteDetail(invite: Invite): string {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.invite-code {
|
||||
max-width: 80ch;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-status-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.65rem;
|
||||
@@ -472,4 +521,42 @@ function inviteDetail(invite: Invite): string {
|
||||
align-self: flex-start;
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import InvitesPanel from '../InvitesPanel.vue'
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
@@ -36,12 +40,15 @@ describe('InvitesPanel', () => {
|
||||
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)
|
||||
await wrapper.find('.invites-toggle').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getSpy).toHaveBeenCalledWith({ page: 1, count: 10 })
|
||||
expect(wrapper.text()).toContain('ABC123')
|
||||
expect(wrapper.text()).toContain('USEDCODE')
|
||||
expect(wrapper.text()).toContain('Active')
|
||||
@@ -60,7 +67,7 @@ describe('InvitesPanel', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
await wrapper.find('.invites-toggle').trigger('click')
|
||||
@@ -69,9 +76,60 @@ describe('InvitesPanel', () => {
|
||||
expect(wrapper.text()).toContain('No invites yet')
|
||||
})
|
||||
|
||||
it('generates a new invite and prepends it to the list', async () => {
|
||||
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([])
|
||||
it('does not show pagination controls when everything fits on one page', async () => {
|
||||
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' }
|
||||
vi.spyOn(invitesApi, 'getInvitesApi')
|
||||
.mockResolvedValueOnce(paginated([]))
|
||||
.mockResolvedValueOnce(paginated([newInvite]))
|
||||
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
|
||||
|
||||
const wrapper = mount(InvitesPanel)
|
||||
@@ -85,8 +143,10 @@ describe('InvitesPanel', () => {
|
||||
})
|
||||
|
||||
it('shares the newly created invite automatically', async () => {
|
||||
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce([])
|
||||
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
|
||||
vi.spyOn(invitesApi, 'getInvitesApi')
|
||||
.mockResolvedValueOnce(paginated([]))
|
||||
.mockResolvedValueOnce(paginated([newInvite]))
|
||||
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
|
||||
|
||||
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 () => {
|
||||
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)
|
||||
await wrapper.find('.invites-toggle').trigger('click')
|
||||
@@ -123,7 +183,7 @@ describe('InvitesPanel', () => {
|
||||
Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true })
|
||||
|
||||
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)
|
||||
await wrapper.find('.invites-toggle').trigger('click')
|
||||
@@ -140,7 +200,9 @@ describe('InvitesPanel', () => {
|
||||
|
||||
it('deletes an invite after confirming', async () => {
|
||||
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 wrapper = mount(InvitesPanel)
|
||||
@@ -158,13 +220,39 @@ describe('InvitesPanel', () => {
|
||||
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 () => {
|
||||
const usedInvite: Invite = {
|
||||
id: 'invite-1',
|
||||
code: 'USEDCODE',
|
||||
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)
|
||||
await wrapper.find('.invites-toggle').trigger('click')
|
||||
|
||||
@@ -4,3 +4,9 @@ export interface Invite {
|
||||
expires_at?: string
|
||||
consumed_at?: string
|
||||
}
|
||||
|
||||
export interface PaginatedInvites {
|
||||
data: Invite[]
|
||||
total: number
|
||||
count: number
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ onMounted(() => {
|
||||
|
||||
<InvitesPanel />
|
||||
|
||||
<h1>Pending changes</h1>
|
||||
<h1 class="section-heading">Pending changes</h1>
|
||||
|
||||
<section class="card info-card">
|
||||
<h4>Sync status</h4>
|
||||
@@ -56,11 +56,21 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 768px) {
|
||||
.page.about {
|
||||
max-width: 780px;
|
||||
}
|
||||
}
|
||||
|
||||
.about h1 {
|
||||
font-size: 1.35rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.about p {
|
||||
color: var(--c-text-soft);
|
||||
font-size: 0.9rem;
|
||||
|
||||
Reference in New Issue
Block a user