feat: Bulk actions for contacts (#12763)

Introduces APIs and UI for bulk actions in contacts table. The initial
action available will be assign labels

Fixes: #8536 #12253 

## Screens

<img width="1350" height="747" alt="Screenshot 2025-10-29 at 4 05 08 PM"
src="https://github.com/user-attachments/assets/0792dff5-0371-4b2e-bdfb-cd32db773402"
/>
<img width="1345" height="717" alt="Screenshot 2025-10-29 at 4 05 19 PM"
src="https://github.com/user-attachments/assets/ae510404-c6de-4c15-a720-f6d10cdac25b"
/>

---------

Co-authored-by: Muhsin <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Sojan Jose
2025-10-30 02:58:28 -07:00
committed by GitHub
parent ce400a36d7
commit 159c810117
15 changed files with 731 additions and 295 deletions

View File

@@ -0,0 +1,142 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
const props = defineProps({
visibleContactIds: {
type: Array,
default: () => [],
},
selectedContactIds: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['clearSelection', 'assignLabels', 'toggleAll']);
const { t } = useI18n();
const selectedCount = computed(() => props.selectedContactIds.length);
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
const showLabelSelector = ref(false);
const selectAllLabel = computed(() => {
if (!totalVisibleContacts.value) {
return '';
}
return t('CONTACTS_BULK_ACTIONS.SELECT_ALL', {
count: totalVisibleContacts.value,
});
});
const selectedCountLabel = computed(() =>
t('CONTACTS_BULK_ACTIONS.SELECTED_COUNT', {
count: selectedCount.value,
})
);
const allItems = computed(() =>
props.visibleContactIds.map(id => ({
id,
}))
);
const selectionModel = computed({
get: () => new Set(props.selectedContactIds),
set: newSet => {
if (!props.visibleContactIds.length) {
emit('toggleAll', false);
return;
}
const shouldSelectAll =
newSet.size === props.visibleContactIds.length && newSet.size > 0;
emit('toggleAll', shouldSelectAll);
},
});
const emitClearSelection = () => {
showLabelSelector.value = false;
emit('clearSelection');
};
const toggleLabelSelector = () => {
if (!selectedCount.value || props.isLoading) return;
showLabelSelector.value = !showLabelSelector.value;
};
const closeLabelSelector = () => {
showLabelSelector.value = false;
};
const handleAssignLabels = labels => {
emit('assignLabels', labels);
closeLabelSelector();
};
</script>
<template>
<BulkSelectBar
v-model="selectionModel"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
class="!px-1.5"
@click="emitClearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<div
v-on-click-outside="closeLabelSelector"
class="relative flex items-center"
>
<Button
sm
faded
slate
icon="i-lucide-tags"
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="toggleLabelSelector"
/>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<LabelActions
v-if="showLabelSelector"
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
@assign="handleAssignLabels"
/>
</transition>
</div>
</div>
</template>
</BulkSelectBar>
</template>

View File

@@ -3,14 +3,17 @@ 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 ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.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 BulkActionsAPI from 'dashboard/api/bulkActions';
const DEFAULT_SORT_FIELD = 'last_activity_at';
const DEBOUNCE_DELAY = 300;
@@ -58,6 +61,10 @@ const isFetchingList = computed(
);
const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count);
const selectedContactIds = ref([]);
const isBulkActionLoading = ref(false);
const hasSelection = computed(() => selectedContactIds.value.length > 0);
const activeSegment = computed(() => {
if (!activeSegmentId.value) return undefined;
return segments.value.find(view => view.id === Number(activeSegmentId.value));
@@ -105,6 +112,31 @@ const emptyStateMessage = computed(() => {
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const visibleContactIds = computed(() =>
contacts.value.map(contact => contact.id)
);
const clearSelection = () => {
selectedContactIds.value = [];
};
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,
@@ -129,6 +161,7 @@ const getCommonFetchParams = (page = 1) => ({
});
const fetchContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/get', getCommonFetchParams(page));
updatePageParam(page);
@@ -136,6 +169,7 @@ const fetchContacts = async (page = 1) => {
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
clearSelection();
await store.dispatch('contacts/filter', {
...getCommonFetchParams(page),
queryPayload: payload,
@@ -144,6 +178,7 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
};
const fetchActiveContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
@@ -153,6 +188,7 @@ const fetchActiveContacts = async (page = 1) => {
};
const searchContacts = debounce(async (value, page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
@@ -170,6 +206,7 @@ const searchContacts = debounce(async (value, page = 1) => {
}, DEBOUNCE_DELAY);
const fetchContactsBasedOnContext = async page => {
clearSelection();
updatePageParam(page, searchValue.value);
if (isFetchingList.value) return;
if (searchQuery.value) {
@@ -197,6 +234,28 @@ const fetchContactsBasedOnContext = async page => {
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 handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
@@ -227,6 +286,17 @@ 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(
() => uiSettings.value?.contacts_sort_by,
newSortBy => {
@@ -331,7 +401,23 @@ onMounted(async () => {
</span>
</div>
<ContactsList v-else :contacts="contacts" />
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
<div v-if="hasSelection">
<ContactsBulkActionBar
:visible-contact-ids="visibleContactIds"
:selected-contact-ids="selectedContactIds"
:is-loading="isBulkActionLoading"
@toggle-all="toggleSelectAll"
@clear-selection="clearSelection"
@assign-labels="assignLabels"
/>
</div>
<ContactsList
:contacts="contacts"
:selected-contact-ids="selectedContactIds"
@toggle-contact="toggleContactSelection"
/>
</div>
</template>
</ContactsListLayout>
</div>