From 7cddba2b08443cf11efb3d49aec3f85b7cb8c499 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 27 Jan 2026 18:55:19 -0800 Subject: [PATCH] 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 --- .../api/v1/accounts/contacts_controller.rb | 21 ++++++++- .../Contacts/ContactsListLayout.vue | 20 ++++++++- .../Contacts/ContactsLoadMore.vue | 28 ++++++++++++ .../dashboard/i18n/locale/en/contact.json | 3 +- .../contacts/pages/ContactsIndex.vue | 38 ++++++++++++++-- .../store/modules/contacts/actions.js | 11 +++-- .../dashboard/store/modules/contacts/index.js | 1 + .../store/modules/contacts/mutations.js | 17 ++++++- .../dashboard/store/mutation-types.js | 1 + .../v1/accounts/contacts/search.json.jbuilder | 1 + .../v1/accounts/contacts_controller_spec.rb | 44 +++++++++++++++++++ 11 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 app/javascript/dashboard/components-next/Contacts/ContactsLoadMore.vue diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 86e21d9fd..14d4f2c89 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -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? diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue index 648cd84d6..c1b5ea712 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue @@ -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; +});