Merge pull request 'Always display invites status and new API information' (#18) from dev into main
This commit was merged in pull request #18.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { getInvitesApi, createInviteApi, deleteInviteApi } from '../invites'
|
||||
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '../invites'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { API_BASE_URL } from '@/api/http'
|
||||
|
||||
@@ -65,6 +65,33 @@ describe('invites API', () => {
|
||||
await expect(getInvitesApi()).rejects.toThrow('Failed')
|
||||
})
|
||||
|
||||
it('getInviteStatusApi sends GET to /user/invites/status and returns the counts', async () => {
|
||||
const mockCounts = { active: 12, expired: 2, used: 8 }
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockCounts,
|
||||
} as unknown as Response)
|
||||
global.fetch = fetchMock
|
||||
|
||||
const result = await getInviteStatusApi()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`${API_BASE_URL}/user/invites/status`,
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
)
|
||||
expect(result).toEqual(mockCounts)
|
||||
})
|
||||
|
||||
it('getInviteStatusApi throws on failure', async () => {
|
||||
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ message: 'Failed to load invite counts' }),
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(getInviteStatusApi()).rejects.toThrow('Failed to load invite counts')
|
||||
})
|
||||
|
||||
it('createInviteApi sends POST to /user/invites and returns the created invite', async () => {
|
||||
const mockInvite = { id: 'invite-1', code: 'ABC123' }
|
||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||
|
||||
+9
-1
@@ -1,6 +1,6 @@
|
||||
import { apiClient } from '@/api/client'
|
||||
import { extractErrorMessage } from '@/api/http'
|
||||
import type { Invite, PaginatedInvites } from '@/types/invite'
|
||||
import type { Invite, InviteStatusCounts, PaginatedInvites } from '@/types/invite'
|
||||
|
||||
export interface GetInvitesParams {
|
||||
page?: number
|
||||
@@ -20,6 +20,14 @@ export async function getInvitesApi(params: GetInvitesParams = {}): Promise<Pagi
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function getInviteStatusApi(): Promise<InviteStatusCounts> {
|
||||
const response = await apiClient.get('/user/invites/status')
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to load invite counts'))
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function createInviteApi(): Promise<Invite> {
|
||||
const response = await apiClient.post('/user/invites')
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
|
||||
import type { VersionInfo } from '@/types/version'
|
||||
|
||||
export async function getVersionApi(): Promise<VersionInfo> {
|
||||
const response = await fetch(`${API_BASE_URL}/version`)
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to load API version'))
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { getInvitesApi, createInviteApi, deleteInviteApi } from '@/api/invites'
|
||||
import type { Invite } from '@/types/invite'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '@/api/invites'
|
||||
import type { Invite, InviteStatusCounts } from '@/types/invite'
|
||||
|
||||
type InviteStatus = 'active' | 'used' | 'expired'
|
||||
|
||||
@@ -17,16 +17,24 @@ const error = ref('')
|
||||
const pendingDeleteId = ref<string | null>(null)
|
||||
const deletingId = ref<string | null>(null)
|
||||
const sharedId = ref<string | null>(null)
|
||||
// Counts across ALL invites (not just the current page)
|
||||
const statusCounts = ref<InviteStatusCounts | null>(null)
|
||||
|
||||
let hasLoaded = false
|
||||
let sharedTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const activeCount = computed(
|
||||
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length,
|
||||
const totalInvites = computed(() =>
|
||||
statusCounts.value
|
||||
? statusCounts.value.active + statusCounts.value.expired + statusCounts.value.used
|
||||
: null,
|
||||
)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||||
const showPagination = computed(() => totalPages.value > 1)
|
||||
|
||||
onMounted(() => {
|
||||
void loadStatusCounts()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(sharedTimeout)
|
||||
})
|
||||
@@ -62,6 +70,16 @@ async function loadInvites() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStatusCounts() {
|
||||
if (isOffline()) return
|
||||
try {
|
||||
statusCounts.value = await getInviteStatusApi()
|
||||
} catch {
|
||||
// Non-critical: the header badges just stay hidden until the next
|
||||
// successful fetch instead of blocking the rest of the panel.
|
||||
}
|
||||
}
|
||||
|
||||
async function goToPage(target: number) {
|
||||
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
|
||||
return
|
||||
@@ -82,6 +100,7 @@ async function handleCreate() {
|
||||
const invite = await createInviteApi()
|
||||
page.value = 1
|
||||
await loadInvites()
|
||||
await loadStatusCounts()
|
||||
// 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)
|
||||
@@ -118,6 +137,7 @@ async function confirmDelete(id: string) {
|
||||
page.value -= 1
|
||||
}
|
||||
await loadInvites()
|
||||
await loadStatusCounts()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
|
||||
} finally {
|
||||
@@ -222,7 +242,12 @@ function inviteDetail(invite: Invite): string {
|
||||
>
|
||||
<span class="invites-toggle-label">
|
||||
<h4>Invites</h4>
|
||||
<span v-if="activeCount > 0" class="invites-count-badge">{{ activeCount }} active</span>
|
||||
<span v-if="statusCounts" class="invites-stats">
|
||||
<span class="invites-count-badge">{{ statusCounts.active }} active</span>
|
||||
<span class="invites-count-badge badge-expired">{{ statusCounts.expired }} expired</span>
|
||||
<span class="invites-count-badge badge-used">{{ statusCounts.used }} used</span>
|
||||
<span class="invites-total-label">{{ totalInvites }} total</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
@@ -345,6 +370,7 @@ function inviteDetail(invite: Invite): string {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.invites-toggle-label h4 {
|
||||
@@ -352,6 +378,13 @@ function inviteDetail(invite: Invite): string {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invites-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.invites-count-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
@@ -361,6 +394,21 @@ function inviteDetail(invite: Invite): string {
|
||||
color: var(--c-accent-strong);
|
||||
}
|
||||
|
||||
.invites-count-badge.badge-expired {
|
||||
background-color: var(--c-danger-bg);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.invites-count-badge.badge-used {
|
||||
background-color: var(--c-bg-elevated);
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.invites-total-label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--c-text-soft);
|
||||
transition: transform 0.15s ease-in-out;
|
||||
|
||||
@@ -18,6 +18,13 @@ describe('InvitesPanel', () => {
|
||||
})
|
||||
// jsdom has no Web Share API; tests that want it define it explicitly.
|
||||
Reflect.deleteProperty(navigator, 'share')
|
||||
// Every mount fetches status counts once on mount; give it a harmless
|
||||
// default so tests that don't care about counts don't hit real fetch.
|
||||
vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
|
||||
active: 0,
|
||||
expired: 0,
|
||||
used: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders collapsed by default without loading invites', () => {
|
||||
@@ -30,6 +37,115 @@ describe('InvitesPanel', () => {
|
||||
expect(getSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches invite status counts on mount and shows them in the header while collapsed', async () => {
|
||||
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValueOnce({
|
||||
active: 12,
|
||||
expired: 2,
|
||||
used: 8,
|
||||
})
|
||||
|
||||
const wrapper = mount(InvitesPanel)
|
||||
await flushPromises()
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('#invites-panel').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('12 active')
|
||||
expect(wrapper.text()).toContain('2 expired')
|
||||
expect(wrapper.text()).toContain('8 used')
|
||||
expect(wrapper.text()).toContain('22 total')
|
||||
})
|
||||
|
||||
it('does not fetch status counts on mount when offline', () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
|
||||
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi')
|
||||
|
||||
mount(InvitesPanel)
|
||||
|
||||
expect(statusSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides status badges without an error banner when the status endpoint fails', async () => {
|
||||
vi.spyOn(invitesApi, 'getInviteStatusApi').mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const wrapper = mount(InvitesPanel)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.invites-stats').exists()).toBe(false)
|
||||
expect(wrapper.find('.banner-error').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refetch status counts when paginating', async () => {
|
||||
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
|
||||
active: 11,
|
||||
expired: 0,
|
||||
used: 0,
|
||||
})
|
||||
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
|
||||
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
|
||||
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()
|
||||
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const [, nextBtn] = wrapper.findAll('.page-btn')
|
||||
await nextBtn?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refetches status counts after creating an invite', async () => {
|
||||
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
|
||||
active: 1,
|
||||
expired: 0,
|
||||
used: 0,
|
||||
})
|
||||
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)
|
||||
await wrapper.find('.invites-toggle').trigger('click')
|
||||
await flushPromises()
|
||||
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.find('.generate-btn').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(statusSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refetches status counts after deleting an invite', async () => {
|
||||
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
|
||||
active: 0,
|
||||
expired: 0,
|
||||
used: 0,
|
||||
})
|
||||
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
|
||||
vi.spyOn(invitesApi, 'getInvitesApi')
|
||||
.mockResolvedValueOnce(paginated([mockInvite]))
|
||||
.mockResolvedValueOnce(paginated([]))
|
||||
vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
|
||||
|
||||
const wrapper = mount(InvitesPanel)
|
||||
await wrapper.find('.invites-toggle').trigger('click')
|
||||
await flushPromises()
|
||||
expect(statusSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
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(statusSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('loads and displays invites on expand', async () => {
|
||||
const mockInvites: Invite[] = [
|
||||
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
|
||||
|
||||
@@ -10,3 +10,9 @@ export interface PaginatedInvites {
|
||||
total: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface InviteStatusCounts {
|
||||
active: number
|
||||
expired: number
|
||||
used: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface VersionInfo {
|
||||
version: string
|
||||
commit: string
|
||||
buildTime: string
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getVersionApi } from '@/api/version'
|
||||
import type { VersionInfo } from '@/types/version'
|
||||
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
|
||||
import InvitesPanel from '@/components/InvitesPanel.vue'
|
||||
|
||||
@@ -9,10 +11,27 @@ const listsStore = useListsStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const showChangePassword = ref(false)
|
||||
const versionInfo = ref<VersionInfo | null>(null)
|
||||
const versionError = ref('')
|
||||
const isLoadingVersion = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
listsStore.loadLists()
|
||||
loadVersion()
|
||||
})
|
||||
|
||||
async function loadVersion() {
|
||||
isLoadingVersion.value = true
|
||||
versionError.value = ''
|
||||
try {
|
||||
versionInfo.value = await getVersionApi()
|
||||
} catch (err) {
|
||||
versionError.value = err instanceof Error ? err.message : 'Failed to load API information'
|
||||
} finally {
|
||||
isLoadingVersion.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,6 +70,27 @@ onMounted(() => {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h1 class="section-heading">API information</h1>
|
||||
|
||||
<section class="card info-card">
|
||||
<p v-if="isLoadingVersion" class="loading-text">Loading…</p>
|
||||
<template v-else-if="versionInfo">
|
||||
<p class="row">
|
||||
<span>Version</span>
|
||||
<strong class="mono-num">{{ versionInfo.version }}</strong>
|
||||
</p>
|
||||
<p class="row">
|
||||
<span>Commit</span>
|
||||
<strong class="mono-num">{{ versionInfo.commit }}</strong>
|
||||
</p>
|
||||
<p class="row">
|
||||
<span>Build time</span>
|
||||
<strong>{{ versionInfo.buildTime }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
<p v-else class="banner banner-error">{{ versionError }}</p>
|
||||
</section>
|
||||
|
||||
<ChangePasswordModal v-if="showChangePassword" @close="showChangePassword = false" />
|
||||
</main>
|
||||
</template>
|
||||
@@ -107,4 +147,10 @@ onMounted(() => {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--c-text-soft);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user