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',
|
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||||
search: "%#{params[:q].strip}%"
|
search: "%#{params[:q].strip}%"
|
||||||
)
|
)
|
||||||
@contacts = fetch_contacts(contacts)
|
@contacts = fetch_contacts_with_has_more(contacts)
|
||||||
@contacts_count = @contacts.total_count
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def import
|
def import
|
||||||
@@ -142,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
.per(RESULTS_PER_PAGE)
|
.per(RESULTS_PER_PAGE)
|
||||||
end
|
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
|
def build_contact_inbox
|
||||||
return if params[:inbox_id].blank?
|
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 ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
|
||||||
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
||||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
|
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
searchValue: { type: String, default: '' },
|
searchValue: { type: String, default: '' },
|
||||||
@@ -19,6 +20,9 @@ const props = defineProps({
|
|||||||
segmentsId: { type: [String, Number], default: 0 },
|
segmentsId: { type: [String, Number], default: 0 },
|
||||||
hasAppliedFilters: { type: Boolean, default: false },
|
hasAppliedFilters: { type: Boolean, default: false },
|
||||||
isFetchingList: { 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([
|
const emit = defineEmits([
|
||||||
@@ -27,6 +31,7 @@ const emit = defineEmits([
|
|||||||
'search',
|
'search',
|
||||||
'applyFilter',
|
'applyFilter',
|
||||||
'clearFilters',
|
'clearFilters',
|
||||||
|
'loadMore',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -61,6 +66,14 @@ const updateCurrentPage = page => {
|
|||||||
const openFilter = () => {
|
const openFilter = () => {
|
||||||
contactListHeaderWrapper.value?.onToggleFilters();
|
contactListHeaderWrapper.value?.onToggleFilters();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showLoadMore = computed(() => {
|
||||||
|
return props.useInfiniteScroll && props.hasMore;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPagination = computed(() => {
|
||||||
|
return !props.useInfiniteScroll && props.showPaginationFooter;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -94,9 +107,14 @@ const openFilter = () => {
|
|||||||
@open-filter="openFilter"
|
@open-filter="openFilter"
|
||||||
/>
|
/>
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
|
<ContactsLoadMore
|
||||||
|
v-if="showLoadMore"
|
||||||
|
:is-loading="isLoadingMore"
|
||||||
|
@load-more="emit('loadMore')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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
|
<PaginationFooter
|
||||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
:current-page="currentPage"
|
: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 🔍",
|
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
|
||||||
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
|
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
|
||||||
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
||||||
}
|
},
|
||||||
|
"LOAD_MORE": "Load more"
|
||||||
},
|
},
|
||||||
"CONTACTS_BULK_ACTIONS": {
|
"CONTACTS_BULK_ACTIONS": {
|
||||||
"ASSIGN_LABELS": "Assign Labels",
|
"ASSIGN_LABELS": "Assign Labels",
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ const meta = useMapGetter('contacts/getMeta');
|
|||||||
const searchQuery = computed(() => route.query?.search);
|
const searchQuery = computed(() => route.query?.search);
|
||||||
const searchValue = ref(searchQuery.value || '');
|
const searchValue = ref(searchQuery.value || '');
|
||||||
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
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 parseSortSettings = (sortString = '') => {
|
||||||
const hasDescending = sortString.startsWith('-');
|
const hasDescending = sortString.startsWith('-');
|
||||||
@@ -62,6 +65,8 @@ const isFetchingList = computed(
|
|||||||
);
|
);
|
||||||
const currentPage = computed(() => Number(meta.value?.currentPage));
|
const currentPage = computed(() => Number(meta.value?.currentPage));
|
||||||
const totalItems = computed(() => meta.value?.count);
|
const totalItems = computed(() => meta.value?.count);
|
||||||
|
const hasMore = computed(() => meta.value?.hasMore ?? false);
|
||||||
|
const isSearchView = computed(() => !!searchQuery.value);
|
||||||
|
|
||||||
const selectedContactIds = ref([]);
|
const selectedContactIds = ref([]);
|
||||||
const isBulkActionLoading = ref(false);
|
const isBulkActionLoading = ref(false);
|
||||||
@@ -212,8 +217,11 @@ const fetchActiveContacts = async (page = 1) => {
|
|||||||
updatePageParam(page);
|
updatePageParam(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchContacts = debounce(async (value, page = 1) => {
|
const searchContacts = debounce(async (value, page = 1, append = false) => {
|
||||||
clearSelection();
|
if (!append) {
|
||||||
|
clearSelection();
|
||||||
|
searchPageNumber.value = 1;
|
||||||
|
}
|
||||||
await store.dispatch('contacts/clearContactFilters');
|
await store.dispatch('contacts/clearContactFilters');
|
||||||
searchValue.value = value;
|
searchValue.value = value;
|
||||||
|
|
||||||
@@ -227,9 +235,27 @@ const searchContacts = debounce(async (value, page = 1) => {
|
|||||||
await store.dispatch('contacts/search', {
|
await store.dispatch('contacts/search', {
|
||||||
...getCommonFetchParams(page),
|
...getCommonFetchParams(page),
|
||||||
search: encodeURIComponent(value),
|
search: encodeURIComponent(value),
|
||||||
|
append,
|
||||||
});
|
});
|
||||||
|
searchPageNumber.value = page;
|
||||||
}, DEBOUNCE_DELAY);
|
}, 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 => {
|
const fetchContactsBasedOnContext = async page => {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
updatePageParam(page, searchValue.value);
|
updatePageParam(page, searchValue.value);
|
||||||
@@ -416,21 +442,25 @@ onMounted(async () => {
|
|||||||
:header-title="headerTitle"
|
:header-title="headerTitle"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
:show-pagination-footer="!isFetchingList && hasContacts"
|
:show-pagination-footer="!isFetchingList && hasContacts && !isSearchView"
|
||||||
:active-sort="sortState.activeSort"
|
:active-sort="sortState.activeSort"
|
||||||
:active-ordering="sortState.activeOrdering"
|
:active-ordering="sortState.activeOrdering"
|
||||||
:active-segment="activeSegment"
|
:active-segment="activeSegment"
|
||||||
:segments-id="activeSegmentId"
|
:segments-id="activeSegmentId"
|
||||||
:is-fetching-list="isFetchingList"
|
:is-fetching-list="isFetchingList"
|
||||||
:has-applied-filters="hasAppliedFilters"
|
:has-applied-filters="hasAppliedFilters"
|
||||||
|
:use-infinite-scroll="isSearchView"
|
||||||
|
:has-more="hasMore"
|
||||||
|
:is-loading-more="isLoadingMore"
|
||||||
@update:current-page="fetchContactsBasedOnContext"
|
@update:current-page="fetchContactsBasedOnContext"
|
||||||
@search="searchContacts"
|
@search="searchContacts"
|
||||||
@update:sort="handleSort"
|
@update:sort="handleSort"
|
||||||
@apply-filter="fetchSavedOrAppliedFilteredContact"
|
@apply-filter="fetchSavedOrAppliedFilteredContact"
|
||||||
@clear-filters="fetchContacts"
|
@clear-filters="fetchContacts"
|
||||||
|
@load-more="loadMoreSearchResults"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isFetchingList"
|
v-if="isFetchingList && !(isSearchView && hasContacts)"
|
||||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
|||||||
@@ -45,14 +45,19 @@ export const handleContactOperationErrors = error => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
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 });
|
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
data: { payload, meta },
|
data: { payload, meta },
|
||||||
} = await ContactAPI.search(search, page, sortAttr, label);
|
} = await ContactAPI.search(search, page, sortAttr, label);
|
||||||
commit(types.CLEAR_CONTACTS);
|
if (!append) {
|
||||||
commit(types.SET_CONTACTS, payload);
|
commit(types.CLEAR_CONTACTS);
|
||||||
|
}
|
||||||
|
commit(append ? types.APPEND_CONTACTS : types.SET_CONTACTS, payload);
|
||||||
commit(types.SET_CONTACT_META, meta);
|
commit(types.SET_CONTACT_META, meta);
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
|
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const state = {
|
|||||||
meta: {
|
meta: {
|
||||||
count: 0,
|
count: 0,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
|
hasMore: false,
|
||||||
},
|
},
|
||||||
records: {},
|
records: {},
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
|
|||||||
@@ -15,9 +15,24 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
[types.SET_CONTACT_META]: ($state, data) => {
|
[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.count = count;
|
||||||
$state.meta.currentPage = currentPage;
|
$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) => {
|
[types.SET_CONTACTS]: ($state, data) => {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export default {
|
|||||||
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
|
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
|
||||||
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
|
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
|
||||||
SET_CONTACTS: 'SET_CONTACTS',
|
SET_CONTACTS: 'SET_CONTACTS',
|
||||||
|
APPEND_CONTACTS: 'APPEND_CONTACTS',
|
||||||
CLEAR_CONTACTS: 'CLEAR_CONTACTS',
|
CLEAR_CONTACTS: 'CLEAR_CONTACTS',
|
||||||
EDIT_CONTACT: 'EDIT_CONTACT',
|
EDIT_CONTACT: 'EDIT_CONTACT',
|
||||||
DELETE_CONTACT: 'DELETE_CONTACT',
|
DELETE_CONTACT: 'DELETE_CONTACT',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
json.meta do
|
json.meta do
|
||||||
json.count @contacts_count
|
json.count @contacts_count
|
||||||
json.current_page @current_page
|
json.current_page @current_page
|
||||||
|
json.has_more @has_more
|
||||||
end
|
end
|
||||||
|
|
||||||
json.payload do
|
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).to include(contact_special.identifier)
|
||||||
expect(response.body).not_to include(contact_normal.identifier)
|
expect(response.body).not_to include(contact_normal.identifier)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user