feat: Add infinite scroll to contacts search page (#13376)

## Summary
- Add `has_more` to contacts search API response to enable infinite
scroll without expensive count queries
- Set `count` to the number of items in the current page instead of
total count
- Implement "Load more" button for contacts search results
- Keep existing contacts visible while loading additional pages

## Changes

### Backend
- Add `fetch_contacts_with_has_more` method that fetches N+1 records to
determine if more pages exist
- Return `has_more` in search endpoint meta response
- Set `count` to current page size instead of total count

### Frontend
- Add `APPEND_CONTACTS` mutation for appending contacts without clearing
existing ones
- Update search action to support `append` parameter
- Add `ContactsLoadMore` component with loading state
- Update `ContactsListLayout` to support infinite scroll mode
- Update `ContactsIndex` to use infinite scroll for search view
This commit is contained in:
Pranav
2026-01-27 18:55:19 -08:00
committed by GitHub
parent 04b2901e1f
commit 7cddba2b08
11 changed files with 173 additions and 12 deletions

View File

@@ -36,6 +36,9 @@ const meta = useMapGetter('contacts/getMeta');
const searchQuery = computed(() => route.query?.search);
const searchValue = ref(searchQuery.value || '');
const pageNumber = computed(() => Number(route.query?.page) || 1);
// For infinite scroll in search, track page internally
const searchPageNumber = ref(1);
const isLoadingMore = ref(false);
const parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-');
@@ -62,6 +65,8 @@ const isFetchingList = computed(
);
const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count);
const hasMore = computed(() => meta.value?.hasMore ?? false);
const isSearchView = computed(() => !!searchQuery.value);
const selectedContactIds = ref([]);
const isBulkActionLoading = ref(false);
@@ -212,8 +217,11 @@ const fetchActiveContacts = async (page = 1) => {
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1) => {
clearSelection();
const searchContacts = debounce(async (value, page = 1, append = false) => {
if (!append) {
clearSelection();
searchPageNumber.value = 1;
}
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
@@ -227,9 +235,27 @@ const searchContacts = debounce(async (value, page = 1) => {
await store.dispatch('contacts/search', {
...getCommonFetchParams(page),
search: encodeURIComponent(value),
append,
});
searchPageNumber.value = page;
}, DEBOUNCE_DELAY);
const loadMoreSearchResults = async () => {
if (!hasMore.value || isLoadingMore.value) return;
isLoadingMore.value = true;
const nextPage = searchPageNumber.value + 1;
await store.dispatch('contacts/search', {
...getCommonFetchParams(nextPage),
search: encodeURIComponent(searchValue.value),
append: true,
});
searchPageNumber.value = nextPage;
isLoadingMore.value = false;
};
const fetchContactsBasedOnContext = async page => {
clearSelection();
updatePageParam(page, searchValue.value);
@@ -416,21 +442,25 @@ onMounted(async () => {
:header-title="headerTitle"
:current-page="currentPage"
:total-items="totalItems"
:show-pagination-footer="!isFetchingList && hasContacts"
:show-pagination-footer="!isFetchingList && hasContacts && !isSearchView"
:active-sort="sortState.activeSort"
:active-ordering="sortState.activeOrdering"
:active-segment="activeSegment"
:segments-id="activeSegmentId"
:is-fetching-list="isFetchingList"
:has-applied-filters="hasAppliedFilters"
:use-infinite-scroll="isSearchView"
:has-more="hasMore"
:is-loading-more="isLoadingMore"
@update:current-page="fetchContactsBasedOnContext"
@search="searchContacts"
@update:sort="handleSort"
@apply-filter="fetchSavedOrAppliedFilteredContact"
@clear-filters="fetchContacts"
@load-more="loadMoreSearchResults"
>
<div
v-if="isFetchingList"
v-if="isFetchingList && !(isSearchView && hasContacts)"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />