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()
|
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
@@ -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'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user