iachat/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactsIndex.vue
Sivin Varghese 7b2b3ac37d
feat(V5): Update settings pages UI (#13396)
# Pull Request Template

## Description

This PR updates settings page UI


## Type of change

- [x] New feature (non-breaking change which adds functionality)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-02-19 15:04:40 +05:30

517 lines
15 KiB
Vue

<script setup>
import { onMounted, computed, ref, reactive, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { debounce } from '@chatwoot/utils';
import { useUISettings } from 'dashboard/composables/useUISettings';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import ContactsListLayout from 'dashboard/components-next/Contacts/ContactsListLayout.vue';
import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkActionsAPI from 'dashboard/api/bulkActions';
const DEFAULT_SORT_FIELD = 'last_activity_at';
const DEBOUNCE_DELAY = 300;
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const contacts = useMapGetter('contacts/getContactsList');
const uiFlags = useMapGetter('contacts/getUIFlags');
const customViewsUiFlags = useMapGetter('customViews/getUIFlags');
const segments = useMapGetter('customViews/getContactCustomViews');
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
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('-');
const sortField = hasDescending ? sortString.slice(1) : sortString;
return {
sort: sortField || DEFAULT_SORT_FIELD,
order: hasDescending ? '-' : '',
};
};
const { contacts_sort_by: contactSortBy = '' } = uiSettings.value ?? {};
const { sort: initialSort, order: initialOrder } =
parseSortSettings(contactSortBy);
const sortState = reactive({
activeSort: initialSort,
activeOrdering: initialOrder,
});
const activeLabel = computed(() => route.params.label);
const activeSegmentId = computed(() => route.params.segmentId);
const isFetchingList = computed(
() => uiFlags.value.isFetching || customViewsUiFlags.value.isFetching
);
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);
const bulkDeleteDialogRef = ref(null);
const selectedCount = computed(() => selectedContactIds.value.length);
const bulkDeleteDialogTitle = computed(() =>
selectedCount.value > 1
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.TITLE')
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_TITLE')
);
const bulkDeleteDialogDescription = computed(() =>
selectedCount.value > 1
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.DESCRIPTION', {
count: selectedCount.value,
})
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_DESCRIPTION')
);
const bulkDeleteDialogConfirmLabel = computed(() =>
selectedCount.value > 1
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_MULTIPLE')
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_SINGLE')
);
const hasSelection = computed(() => selectedCount.value > 0);
const activeSegment = computed(() => {
if (!activeSegmentId.value) return undefined;
return segments.value.find(view => view.id === Number(activeSegmentId.value));
});
const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
);
const isActiveView = computed(() => route.name === 'contacts_dashboard_active');
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length > 0;
});
const showEmptyStateLayout = computed(() => {
return (
!searchQuery.value &&
!hasContacts.value &&
isContactIndexView.value &&
!hasAppliedFilters.value
);
});
const showEmptyText = computed(() => {
return (
(searchQuery.value ||
hasAppliedFilters.value ||
!isContactIndexView.value) &&
!hasContacts.value
);
});
const headerTitle = computed(() => {
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE');
if (activeSegmentId.value) return activeSegment.value?.name;
if (activeLabel.value) return `#${activeLabel.value}`;
return t('CONTACTS_LAYOUT.HEADER.TITLE');
});
const emptyStateMessage = computed(() => {
if (isActiveView.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE');
if (!searchQuery.value || hasAppliedFilters.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE');
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const visibleContactIds = computed(() =>
contacts.value.map(contact => contact.id)
);
const clearSelection = () => {
selectedContactIds.value = [];
};
const openBulkDeleteDialog = () => {
if (!selectedContactIds.value.length || isBulkActionLoading.value) return;
bulkDeleteDialogRef.value?.open?.();
};
const toggleSelectAll = shouldSelect => {
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
};
const toggleContactSelection = ({ id, value }) => {
const isAlreadySelected = selectedContactIds.value.includes(id);
const shouldSelect = value ?? !isAlreadySelected;
if (shouldSelect && !isAlreadySelected) {
selectedContactIds.value = [...selectedContactIds.value, id];
} else if (!shouldSelect && isAlreadySelected) {
selectedContactIds.value = selectedContactIds.value.filter(
contactId => contactId !== id
);
}
};
const updatePageParam = (page, search = '') => {
const query = {
...route.query,
page: page.toString(),
...(search ? { search } : {}),
};
if (!search) {
delete query.search;
}
router.replace({ query });
};
const buildSortAttr = () =>
`${sortState.activeOrdering}${sortState.activeSort}`;
const getCommonFetchParams = (page = 1) => ({
page,
sortAttr: buildSortAttr(),
label: activeLabel.value,
});
const fetchContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/get', getCommonFetchParams(page));
updatePageParam(page);
};
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
clearSelection();
await store.dispatch('contacts/filter', {
...getCommonFetchParams(page),
queryPayload: payload,
});
updatePageParam(page);
};
const fetchActiveContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
sortAttr: buildSortAttr(),
});
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1, append = false) => {
if (!append) {
clearSelection();
searchPageNumber.value = 1;
}
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
if (!value) {
updatePageParam(page);
await fetchContacts(page);
return;
}
updatePageParam(page, value);
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);
if (isFetchingList.value) return;
if (searchQuery.value) {
await searchContacts(searchQuery.value, page);
return;
}
// Reset the search value when we change the view
searchValue.value = '';
// If we're on the active route, fetch active contacts
if (isActiveView.value) {
await fetchActiveContacts(page);
return;
}
// If there are applied filters or active segment with query
if (
(hasAppliedFilters.value || activeSegment.value?.query) &&
!activeLabel.value
) {
const queryPayload =
activeSegment.value?.query || filterQueryGenerator(appliedFilters.value);
await fetchSavedOrAppliedFilteredContact(queryPayload, page);
return;
}
// Default case: fetch regular contacts + label
await fetchContacts(page);
};
const assignLabels = async labels => {
if (!labels.length || !selectedContactIds.value.length) {
return;
}
isBulkActionLoading.value = true;
try {
await BulkActionsAPI.create({
type: 'Contact',
ids: selectedContactIds.value,
labels: { add: labels },
});
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_SUCCESS'));
clearSelection();
await fetchContactsBasedOnContext(pageNumber.value);
} catch (error) {
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_FAILED'));
} finally {
isBulkActionLoading.value = false;
}
};
const deleteContacts = async () => {
if (!selectedContactIds.value.length) {
return;
}
isBulkActionLoading.value = true;
try {
await BulkActionsAPI.create({
type: 'Contact',
ids: selectedContactIds.value,
action_name: 'delete',
});
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_SUCCESS'));
clearSelection();
await fetchContactsBasedOnContext(pageNumber.value);
bulkDeleteDialogRef.value?.close?.();
} catch (error) {
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_FAILED'));
} finally {
isBulkActionLoading.value = false;
}
};
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
await updateUISettings({
contacts_sort_by: buildSortAttr(),
});
if (searchQuery.value) {
await searchContacts(searchValue.value);
return;
}
if (isActiveView.value) {
await fetchActiveContacts();
return;
}
await (activeSegmentId.value || hasAppliedFilters.value
? fetchSavedOrAppliedFilteredContact(
activeSegmentId.value
? activeSegment.value?.query
: filterQueryGenerator(appliedFilters.value)
)
: fetchContacts());
};
const createContact = async contact => {
await store.dispatch('contacts/create', contact);
};
watch(
contacts,
newContacts => {
const idsOnPage = newContacts.map(contact => contact.id);
selectedContactIds.value = selectedContactIds.value.filter(id =>
idsOnPage.includes(id)
);
},
{ deep: true }
);
watch(hasSelection, value => {
if (!value) {
bulkDeleteDialogRef.value?.close?.();
}
});
watch(
() => uiSettings.value?.contacts_sort_by,
newSortBy => {
if (newSortBy) {
const { sort, order } = parseSortSettings(newSortBy);
sortState.activeSort = sort;
sortState.activeOrdering = order;
}
},
{ immediate: true }
);
watch(
[activeLabel, activeSegment, isActiveView],
() => {
fetchContactsBasedOnContext(pageNumber.value);
},
{ deep: true }
);
watch(searchQuery, value => {
if (isFetchingList.value) return;
searchValue.value = value || '';
// Reset the view if there is search query when we click on the sidebar group
if (value === undefined) {
if (
isActiveView.value ||
activeLabel.value ||
activeSegment.value ||
hasAppliedFilters.value
)
return;
fetchContacts();
}
});
onMounted(async () => {
if (!activeSegmentId.value) {
if (searchQuery.value) {
await searchContacts(searchQuery.value, pageNumber.value);
return;
}
if (isActiveView.value) {
await fetchActiveContacts(pageNumber.value);
return;
}
await fetchContacts(pageNumber.value);
} else if (activeSegment.value && activeSegmentId.value) {
await fetchSavedOrAppliedFilteredContact(
activeSegment.value.query,
pageNumber.value
);
}
});
</script>
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
>
<ContactsListLayout
:search-value="searchValue"
:header-title="headerTitle"
:current-page="currentPage"
:total-items="totalItems"
: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 && !(isSearchView && hasContacts)"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<template v-else>
<ContactsBulkActionBar
v-if="hasSelection"
:visible-contact-ids="visibleContactIds"
:selected-contact-ids="selectedContactIds"
:is-loading="isBulkActionLoading"
@toggle-all="toggleSelectAll"
@clear-selection="clearSelection"
@assign-labels="assignLabels"
@delete-selected="openBulkDeleteDialog"
/>
<ContactEmptyState
v-if="showEmptyStateLayout"
class="pt-14"
:title="t('CONTACTS_LAYOUT.EMPTY_STATE.TITLE')"
:subtitle="t('CONTACTS_LAYOUT.EMPTY_STATE.SUBTITLE')"
:button-label="t('CONTACTS_LAYOUT.EMPTY_STATE.BUTTON_LABEL')"
@create="createContact"
/>
<div
v-else-if="showEmptyText"
class="flex items-center justify-center py-10"
>
<span class="text-base text-n-slate-11">
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col gap-4 pt-4 pb-6">
<ContactsList
:contacts="contacts"
:selected-contact-ids="selectedContactIds"
@toggle-contact="toggleContactSelection"
/>
<Dialog
v-if="selectedCount"
ref="bulkDeleteDialogRef"
type="alert"
:title="bulkDeleteDialogTitle"
:description="bulkDeleteDialogDescription"
:confirm-button-label="bulkDeleteDialogConfirmLabel"
:is-loading="isBulkActionLoading"
@confirm="deleteContacts"
/>
</div>
</template>
</ContactsListLayout>
</div>
</template>