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:
@@ -28,8 +28,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts = fetch_contacts(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
@contacts = fetch_contacts_with_has_more(contacts)
|
||||
end
|
||||
|
||||
def import
|
||||
@@ -142,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
.per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def fetch_contacts_with_has_more(contacts)
|
||||
includes_hash = { avatar_attachment: [:blob] }
|
||||
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
|
||||
|
||||
# Calculate offset manually to fetch one extra record for has_more check
|
||||
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
|
||||
results = filtrate(contacts)
|
||||
.includes(includes_hash)
|
||||
.offset(offset)
|
||||
.limit(RESULTS_PER_PAGE + 1)
|
||||
.to_a
|
||||
|
||||
@has_more = results.size > RESULTS_PER_PAGE
|
||||
results = results.first(RESULTS_PER_PAGE) if @has_more
|
||||
@contacts_count = results.size
|
||||
results
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
return if params[:inbox_id].blank?
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
|
||||
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
|
||||
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
|
||||
|
||||
const props = defineProps({
|
||||
searchValue: { type: String, default: '' },
|
||||
@@ -19,6 +20,9 @@ const props = defineProps({
|
||||
segmentsId: { type: [String, Number], default: 0 },
|
||||
hasAppliedFilters: { type: Boolean, default: false },
|
||||
isFetchingList: { type: Boolean, default: false },
|
||||
useInfiniteScroll: { type: Boolean, default: false },
|
||||
hasMore: { type: Boolean, default: false },
|
||||
isLoadingMore: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -27,6 +31,7 @@ const emit = defineEmits([
|
||||
'search',
|
||||
'applyFilter',
|
||||
'clearFilters',
|
||||
'loadMore',
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
@@ -61,6 +66,14 @@ const updateCurrentPage = page => {
|
||||
const openFilter = () => {
|
||||
contactListHeaderWrapper.value?.onToggleFilters();
|
||||
};
|
||||
|
||||
const showLoadMore = computed(() => {
|
||||
return props.useInfiniteScroll && props.hasMore;
|
||||
});
|
||||
|
||||
const showPagination = computed(() => {
|
||||
return !props.useInfiniteScroll && props.showPaginationFooter;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -94,9 +107,14 @@ const openFilter = () => {
|
||||
@open-filter="openFilter"
|
||||
/>
|
||||
<slot name="default" />
|
||||
<ContactsLoadMore
|
||||
v-if="showLoadMore"
|
||||
:is-loading="isLoadingMore"
|
||||
@load-more="emit('loadMore')"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['loadMore']);
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center py-4">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
|
||||
:is-loading="isLoading"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@click="emit('loadMore')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -574,7 +574,8 @@
|
||||
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
|
||||
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
||||
}
|
||||
},
|
||||
"LOAD_MORE": "Load more"
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -45,14 +45,19 @@ export const handleContactOperationErrors = error => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
search: async ({ commit }, { search, page, sortAttr, label }) => {
|
||||
search: async (
|
||||
{ commit },
|
||||
{ search, page, sortAttr, label, append = false }
|
||||
) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await ContactAPI.search(search, page, sortAttr, label);
|
||||
commit(types.CLEAR_CONTACTS);
|
||||
commit(types.SET_CONTACTS, payload);
|
||||
if (!append) {
|
||||
commit(types.CLEAR_CONTACTS);
|
||||
}
|
||||
commit(append ? types.APPEND_CONTACTS : types.SET_CONTACTS, payload);
|
||||
commit(types.SET_CONTACT_META, meta);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const state = {
|
||||
meta: {
|
||||
count: 0,
|
||||
currentPage: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
records: {},
|
||||
uiFlags: {
|
||||
|
||||
@@ -15,9 +15,24 @@ export const mutations = {
|
||||
},
|
||||
|
||||
[types.SET_CONTACT_META]: ($state, data) => {
|
||||
const { count, current_page: currentPage } = data;
|
||||
const { count, current_page: currentPage, has_more: hasMore } = data;
|
||||
$state.meta.count = count;
|
||||
$state.meta.currentPage = currentPage;
|
||||
if (hasMore !== undefined) {
|
||||
$state.meta.hasMore = hasMore;
|
||||
}
|
||||
},
|
||||
|
||||
[types.APPEND_CONTACTS]: ($state, data) => {
|
||||
data.forEach(contact => {
|
||||
$state.records[contact.id] = {
|
||||
...($state.records[contact.id] || {}),
|
||||
...contact,
|
||||
};
|
||||
if (!$state.sortOrder.includes(contact.id)) {
|
||||
$state.sortOrder.push(contact.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
[types.SET_CONTACTS]: ($state, data) => {
|
||||
|
||||
@@ -139,6 +139,7 @@ export default {
|
||||
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
|
||||
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
|
||||
SET_CONTACTS: 'SET_CONTACTS',
|
||||
APPEND_CONTACTS: 'APPEND_CONTACTS',
|
||||
CLEAR_CONTACTS: 'CLEAR_CONTACTS',
|
||||
EDIT_CONTACT: 'EDIT_CONTACT',
|
||||
DELETE_CONTACT: 'DELETE_CONTACT',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
json.meta do
|
||||
json.count @contacts_count
|
||||
json.current_page @current_page
|
||||
json.has_more @has_more
|
||||
end
|
||||
|
||||
json.payload do
|
||||
|
||||
@@ -369,6 +369,50 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
expect(response.body).to include(contact_special.identifier)
|
||||
expect(response.body).not_to include(contact_normal.identifier)
|
||||
end
|
||||
|
||||
it 'returns has_more as false when results fit in one page' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/search",
|
||||
params: { q: contact2.email },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['meta']['has_more']).to be(false)
|
||||
expect(response_body['meta']['count']).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns has_more as true when there are more results' do
|
||||
# Create 16 contacts (more than RESULTS_PER_PAGE which is 15)
|
||||
create_list(:contact, 16, account: account, name: 'searchable_contact')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/contacts/search",
|
||||
params: { q: 'searchable_contact' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['meta']['has_more']).to be(true)
|
||||
expect(response_body['meta']['count']).to eq(15)
|
||||
expect(response_body['payload'].length).to eq(15)
|
||||
end
|
||||
|
||||
it 'returns has_more as false on the last page' do
|
||||
# Create 16 contacts
|
||||
create_list(:contact, 16, account: account, name: 'searchable_contact')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/contacts/search",
|
||||
params: { q: 'searchable_contact', page: 2 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['meta']['has_more']).to be(false)
|
||||
expect(response_body['meta']['count']).to eq(1)
|
||||
expect(response_body['payload'].length).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user