Skip to content

Commit 8ef0d7f

Browse files
AchoArnoldCopilot
andcommitted
feat(web): server-side pagination for the contacts page
- store total now reflects the server count; loadContacts accepts skip and limit and remembers the current window so mutation refreshes stay in place - contacts page uses VDataTableServer with items-length bound to the server total; page/size changes fetch the correct skip/limit and a debounced query resets to page 1 without duplicate requests - delete bumps the load generation so an in-flight load cannot resurrect a deleted contact, without leaving loading stuck or hiding delete errors - remove the dead filteredContacts computed and add defensive null coalescing for emails/phone_numbers in the table display - regenerate API models with the new contacts total field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e16b2c6 commit 8ef0d7f

3 files changed

Lines changed: 111 additions & 40 deletions

File tree

web/app/pages/contacts/index.vue

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ const deleteDialog = ref(false)
6969
const importDialog = ref(false)
7070
const saving = ref(false)
7171
72+
// Server-driven pagination state for VDataTableServer.
73+
const page = ref(1)
74+
const itemsPerPage = ref(10)
75+
// initialLoadComplete gates the table's initial @update:options emit so the
76+
// first fetch is driven by onMounted rather than firing twice on mount.
77+
const initialLoadComplete = ref(false)
78+
7279
const editingId = ref<string | null>(null)
7380
const pendingDelete = ref<EntitiesContact | null>(null)
7481
@@ -143,9 +150,10 @@ function openEdit(contact: EntitiesContact) {
143150
editingId.value = contact.id
144151
form.value = {
145152
name: contact.name ?? '',
146-
phoneNumbers:
147-
contact.phone_numbers.length > 0 ? [...contact.phone_numbers] : [''],
148-
emails: contact.emails.length > 0 ? [...contact.emails] : [''],
153+
phoneNumbers: contact.phone_numbers?.length
154+
? [...contact.phone_numbers]
155+
: [''],
156+
emails: contact.emails?.length ? [...contact.emails] : [''],
149157
properties: Object.entries(contact.properties ?? {}).map(
150158
([key, value]) => ({ key, value }),
151159
),
@@ -300,24 +308,50 @@ async function submitImport() {
300308
}
301309
}
302310
311+
function fetchContacts() {
312+
const skip = (page.value - 1) * itemsPerPage.value
313+
return contactsStore
314+
.loadContacts({ force: true, skip, limit: itemsPerPage.value })
315+
.catch(() => {
316+
// The store already surfaced the failure via a notification.
317+
})
318+
}
319+
320+
function onUpdateOptions(options: { page: number; itemsPerPage: number }) {
321+
page.value = options.page
322+
itemsPerPage.value = options.itemsPerPage
323+
324+
// Ignore the initial emit fired while the table mounts; onMounted owns the
325+
// first fetch so the request is not duplicated.
326+
if (!initialLoadComplete.value) {
327+
return
328+
}
329+
fetchContacts()
330+
}
331+
303332
watch(
304333
() => contactsStore.search,
305334
() => {
306335
if (searchTimer) {
307336
clearTimeout(searchTimer)
308337
}
309338
searchTimer = setTimeout(() => {
310-
contactsStore.loadContacts(true).catch(() => {
311-
// The store already surfaced the failure via a notification.
312-
})
339+
// A new query always resets to the first page. If we are already on
340+
// page 1 the page ref does not change, so no @update:options fires and
341+
// we fetch directly; otherwise resetting the page drives the fetch via
342+
// onUpdateOptions. Either way exactly one request is made.
343+
if (page.value !== 1) {
344+
page.value = 1
345+
} else {
346+
fetchContacts()
347+
}
313348
}, 350)
314349
},
315350
)
316351
317352
onMounted(() => {
318-
contactsStore.loadContacts(true).catch(() => {
319-
// The store already surfaced the failure via a notification.
320-
})
353+
initialLoadComplete.value = true
354+
fetchContacts()
321355
})
322356
323357
onBeforeUnmount(() => {
@@ -386,15 +420,18 @@ onBeforeUnmount(() => {
386420
/>
387421

388422
<VCard variant="outlined">
389-
<VDataTable
423+
<VDataTableServer
424+
v-model:page="page"
425+
v-model:items-per-page="itemsPerPage"
390426
:headers="headers"
391427
:items="contactsStore.contacts"
428+
:items-length="contactsStore.total"
392429
:loading="contactsStore.loading"
393-
:items-per-page="10"
394430
:items-per-page-options="itemsPerPageOptions"
395431
item-value="id"
396432
hover
397433
loading-text="Loading contacts…"
434+
@update:options="onUpdateOptions"
398435
>
399436
<template #[`item.name`]="{ item }">
400437
<div class="d-flex align-center py-2">
@@ -416,11 +453,11 @@ onBeforeUnmount(() => {
416453

417454
<template #[`item.phone_numbers`]="{ item }">
418455
<div
419-
v-if="item.phone_numbers.length"
456+
v-if="item.phone_numbers?.length"
420457
class="d-flex flex-column ga-1 py-2"
421458
>
422459
<VChip
423-
v-for="phone in item.phone_numbers"
460+
v-for="phone in item.phone_numbers ?? []"
424461
:key="phone"
425462
size="small"
426463
variant="tonal"
@@ -435,11 +472,11 @@ onBeforeUnmount(() => {
435472

436473
<template #[`item.emails`]="{ item }">
437474
<div
438-
v-if="item.emails.length"
475+
v-if="item.emails?.length"
439476
class="d-flex flex-column ga-1 py-2"
440477
>
441478
<span
442-
v-for="email in item.emails"
479+
v-for="email in item.emails ?? []"
443480
:key="email"
444481
class="d-flex align-center text-body-2"
445482
>
@@ -518,7 +555,7 @@ onBeforeUnmount(() => {
518555
</VBtn>
519556
</div>
520557
</template>
521-
</VDataTable>
558+
</VDataTableServer>
522559
</VCard>
523560
</VCol>
524561
</VRow>

web/app/stores/contacts.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { defineStore } from 'pinia'
2-
import { computed, ref } from 'vue'
2+
import { ref } from 'vue'
33
import type { EntitiesContact } from '~~/shared/types/api'
44
import { getApiErrorMessage } from '~/utils/api-error'
55

@@ -10,50 +10,71 @@ export interface ContactInput {
1010
properties?: Record<string, string>
1111
}
1212

13+
export interface LoadContactsOptions {
14+
force?: boolean
15+
skip?: number
16+
limit?: number
17+
}
18+
19+
// DEFAULT_LIMIT mirrors the contacts page's initial items-per-page. It is only
20+
// used when a caller (e.g. a mutation refresh) does not specify its own limit.
21+
const DEFAULT_LIMIT = 10
22+
1323
export const useContactsStore = defineStore('contacts', () => {
1424
const contacts = ref<EntitiesContact[]>([])
25+
const total = ref(0)
1526
const loading = ref(false)
1627
const search = ref('')
1728
const { apiFetch } = useApi()
1829
const notificationsStore = useNotificationsStore()
1930
let loadContactsGeneration = 0
2031

21-
const total = computed(() => contacts.value.length)
32+
// The pagination window last requested by the page. Mutation-triggered
33+
// refreshes reuse it so the user stays on the page they were viewing.
34+
let currentSkip = 0
35+
let currentLimit = DEFAULT_LIMIT
2236

23-
const filteredContacts = computed<EntitiesContact[]>(() => {
24-
const term = search.value.trim().toLowerCase()
25-
if (!term) return contacts.value
37+
function normalizeOptions(
38+
options: LoadContactsOptions | boolean,
39+
): LoadContactsOptions {
40+
if (typeof options === 'boolean') {
41+
return { force: options }
42+
}
43+
return options
44+
}
2645

27-
return contacts.value.filter((contact) => {
28-
const name = contact.name.toLowerCase()
29-
const emails = contact.emails.join(' ').toLowerCase()
30-
const phoneNumbers = contact.phone_numbers.join(' ').toLowerCase()
46+
async function loadContacts(
47+
options: LoadContactsOptions | boolean = {},
48+
): Promise<void> {
49+
const { force = false, skip, limit } = normalizeOptions(options)
3150

32-
return (
33-
name.includes(term) ||
34-
emails.includes(term) ||
35-
phoneNumbers.includes(term)
36-
)
37-
})
38-
})
51+
if (skip !== undefined) {
52+
currentSkip = skip
53+
}
54+
if (limit !== undefined) {
55+
currentLimit = limit
56+
}
3957

40-
async function loadContacts(force = false): Promise<void> {
4158
if (contacts.value.length > 0 && !force) return
4259

4360
const generation = ++loadContactsGeneration
4461
loading.value = true
4562
try {
4663
const term = search.value.trim()
47-
const params: Record<string, string | number> = { limit: 100 }
64+
const params: Record<string, string | number> = {
65+
skip: currentSkip,
66+
limit: currentLimit,
67+
}
4868
if (term) {
4969
params.query = term
5070
}
51-
const response = await apiFetch<{ data: EntitiesContact[] }>(
52-
'/v1/contacts',
53-
{ params },
54-
)
71+
const response = await apiFetch<{
72+
data: EntitiesContact[]
73+
total?: number
74+
}>('/v1/contacts', { params })
5575
if (generation === loadContactsGeneration) {
5676
contacts.value = response.data ?? []
77+
total.value = response.total ?? contacts.value.length
5778
}
5879
} catch (error: unknown) {
5980
if (generation !== loadContactsGeneration) {
@@ -135,7 +156,12 @@ export const useContactsStore = defineStore('contacts', () => {
135156
loading.value = true
136157
try {
137158
await apiFetch(`/v1/contacts/${id}`, { method: 'DELETE' })
159+
// Invalidate any in-flight load so a stale response cannot resurrect the
160+
// just-deleted contact. Bumping the generation makes loadContacts skip
161+
// its assignment (and its finally toggling loading) for the older request.
162+
loadContactsGeneration++
138163
contacts.value = contacts.value.filter((contact) => contact.id !== id)
164+
total.value = Math.max(0, total.value - 1)
139165
notificationsStore.addNotification({
140166
message: 'Contact deleted',
141167
type: 'success',
@@ -183,16 +209,18 @@ export const useContactsStore = defineStore('contacts', () => {
183209
function resetState() {
184210
loadContactsGeneration++
185211
contacts.value = []
212+
total.value = 0
186213
loading.value = false
187214
search.value = ''
215+
currentSkip = 0
216+
currentLimit = DEFAULT_LIMIT
188217
}
189218

190219
return {
191220
contacts,
221+
total,
192222
loading,
193223
search,
194-
total,
195-
filteredContacts,
196224
loadContacts,
197225
saveContacts,
198226
updateContact,

web/shared/types/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,12 @@ export interface ResponsesContactsResponse {
679679
message: string;
680680
/** @example "success" */
681681
status: string;
682+
/**
683+
* Total is the number of contacts matching the request filter for the
684+
* user, independent of the pagination skip/limit applied to Data.
685+
* @example 57
686+
*/
687+
total: number;
682688
}
683689

684690
export interface ResponsesDiscordResponse {

0 commit comments

Comments
 (0)