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

@@ -1,13 +1,11 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create
if type_matches?
::BulkActionsJob.perform_later(
account: @current_account,
user: current_user,
params: permitted_params
)
case normalized_type
when 'Conversation'
enqueue_conversation_job
head :ok
when 'Contact'
enqueue_contact_job
head :ok
else
render json: { success: false }, status: :unprocessable_entity
@@ -16,11 +14,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private
def type_matches?
['Conversation'].include?(params[:type])
def normalized_type
params[:type].to_s.camelize
end
def permitted_params
def enqueue_conversation_job
::BulkActionsJob.perform_later(
account: @current_account,
user: current_user,
params: conversation_params
)
end
def enqueue_contact_job
Contacts::BulkActionJob.perform_later(
@current_account.id,
current_user.id,
contact_params
)
end
def conversation_params
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
end
def contact_params
params.require(:ids)
permitted = params.permit(:type, ids: [], labels: [add: []])
permitted[:ids] = permitted[:ids].map(&:to_i) if permitted[:ids].present?
permitted
end
end

View File

@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -20,9 +21,17 @@ const props = defineProps({
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
isSelected: { type: Boolean, default: false },
});
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
const emit = defineEmits([
'toggle',
'updateContact',
'showContact',
'select',
'avatarHover',
]);
const { t } = useI18n();
@@ -88,111 +97,148 @@ const onClickExpand = () => {
};
const onClickViewDetails = () => emit('showContact', props.id);
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script>
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
<div class="relative">
<CardLayout
:key="id"
layout="row"
:class="{
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
}"
>
<div class="flex items-center justify-start flex-1 gap-4">
<div
class="relative"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
>
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
<template v-if="selectable" #overlay="{ size }">
<label
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
</div>
</div>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
</div>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
>
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
</template>
</CardLayout>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
</template>
</CardLayout>
</div>
</template>

View File

@@ -10,7 +10,15 @@ import {
} from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
defineProps({ contacts: { type: Array, required: true } });
const props = defineProps({
contacts: { type: Array, required: true },
selectedContactIds: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['toggleContact']);
const { t } = useI18n();
const store = useStore();
@@ -20,6 +28,9 @@ const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null);
const hoveredAvatarId = ref(null);
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
const updateContact = async updatedData => {
try {
@@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
};
const isSelected = id => selectedIdsSet.value.has(id);
const shouldShowSelection = id => {
return hoveredAvatarId.value === id || isSelected(id);
};
const handleSelect = (id, value) => {
emit('toggleContact', { id, value });
};
const handleAvatarHover = (id, isHovered) => {
hoveredAvatarId.value = isHovered ? id : null;
};
</script>
<template>
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<ContactsCard
v-for="contact in contacts"
:id="contact.id"
:key="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
/>
<div class="flex flex-col gap-4">
<div v-for="contact in contacts" :key="contact.id" class="relative">
<ContactsCard
:id="contact.id"
:name="contact.name"
:email="contact.email"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
:selectable="shouldShowSelection(contact.id)"
:is-selected="isSelected(contact.id)"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
@select="value => handleSelect(contact.id, value)"
@avatar-hover="value => handleAvatarHover(contact.id, value)"
/>
</div>
</div>
</template>

View File

@@ -61,23 +61,26 @@ const bulkCheckboxState = computed({
>
<div
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
class="flex items-center justify-between gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1.5 min-w-0">
<Checkbox
v-model="bulkCheckboxState"
:indeterminate="isIndeterminate"
/>
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
<span
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
>
{{ selectAllLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button

View File

@@ -1,100 +1,129 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
export default {
components: {
NextButton,
},
emits: ['update', 'close', 'assign'],
data() {
return {
query: '',
selectedLabels: [],
};
},
computed: {
...mapGetters({ labels: 'labels/getLabels' }),
filteredLabels() {
return this.labels.filter(label =>
label.title.toLowerCase().includes(this.query.toLowerCase())
);
},
},
methods: {
isLabelSelected(label) {
return this.selectedLabels.includes(label);
},
assignLabels(key) {
this.$emit('update', key);
},
onClose() {
this.$emit('close');
},
},
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div v-on-clickaway="onClose" class="labels-container">
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="labels-list">
<header class="labels-list__header">
<div
class="flex items-center justify-between h-8 gap-2 label-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm label--search_input"
/>
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul class="labels-list__body">
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="label__list-item"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="item"
:class="{ 'label-selected': isLabelSelected(label.title) }"
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="label-checkbox"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden label-title whitespace-nowrap text-ellipsis"
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="label-pill"
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<footer class="labels-list__footer">
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="$emit('assign', selectedLabels)"
@click="handleAssign"
/>
</footer>
</div>
@@ -102,107 +131,11 @@ export default {
</template>
<style scoped lang="scss">
.labels-list {
@apply flex flex-col max-h-[15rem] min-h-[auto];
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
.labels-list__header {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5;
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
.labels-list__body {
@apply flex-1 overflow-y-auto py-2.5 mx-0;
}
.labels-list__footer {
@apply p-2;
button {
@apply w-full;
.button__content {
@apply text-center;
}
}
}
}
.label-list-search {
@apply bg-n-alpha-black2 py-0 px-2.5 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.label--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
.labels-container {
@apply absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply max-h-[15rem] overflow-y-auto;
.label__list-container {
@apply h-full;
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
}
.labels-placeholder {
@apply p-2;
}
.label__list-item {
@apply my-1 mx-0 py-0 px-2.5;
.item {
@apply items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
&.label-selected {
@apply bg-n-slate-2;
}
span {
@apply text-sm;
}
.label-checkbox {
@apply my-0 ltr:mr-2.5 rtl:ml-2.5;
}
.label-title {
@apply flex-grow w-full;
}
.label-pill {
@apply rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
}
}
}
.search-container {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5 sticky top-0 z-20;
}
</style>

View File

@@ -24,7 +24,7 @@
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found for",
"NO_LABELS_FOUND": "No labels found",
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again."

View File

@@ -572,6 +572,16 @@
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
}
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})"
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {

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>

View File

@@ -0,0 +1,14 @@
class Contacts::BulkActionJob < ApplicationJob
queue_as :medium
def perform(account_id, user_id, params)
account = Account.find(account_id)
user = User.find(user_id)
Contacts::BulkActionService.new(
account: account,
user: user,
params: params
).perform
end
end

View File

@@ -0,0 +1,32 @@
class Contacts::BulkActionService
def initialize(account:, user:, params:)
@account = account
@user = user
@params = params.deep_symbolize_keys
end
def perform
return assign_labels if labels_to_add.any?
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
{ success: false, error: 'unknown_operation' }
end
private
def assign_labels
Contacts::BulkAssignLabelsService.new(
account: @account,
contact_ids: ids,
labels: labels_to_add
).perform
end
def ids
Array(@params[:ids]).compact
end
def labels_to_add
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
end
end

View File

@@ -0,0 +1,19 @@
class Contacts::BulkAssignLabelsService
def initialize(account:, contact_ids:, labels:)
@account = account
@contact_ids = Array(contact_ids)
@labels = Array(labels).compact_blank
end
def perform
return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank?
contacts = @account.contacts.where(id: @contact_ids)
contacts.find_each do |contact|
contact.add_labels(@labels)
end
{ success: true, updated_contact_ids: contacts.pluck(:id) }
end
end

View File

@@ -195,6 +195,37 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer')
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
end
it 'enqueues contact bulk action job with permitted params' do
contact_one = create(:contact, account: account)
contact_two = create(:contact, account: account)
previous_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :test
expect do
post "/api/v1/accounts/#{account.id}/bulk_actions",
headers: agent.create_new_auth_token,
params: {
type: 'Contact',
ids: [contact_one.id, contact_two.id],
labels: { add: %w[vip support] },
extra: 'ignored'
}
end.to have_enqueued_job(Contacts::BulkActionJob).with(
account.id,
agent.id,
hash_including(
'ids' => [contact_one.id, contact_two.id],
'labels' => hash_including('add' => %w[vip support])
)
)
expect(response).to have_http_status(:success)
ensure
ActiveJob::Base.queue_adapter = previous_adapter
clear_enqueued_jobs
end
end
end

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Contacts::BulkActionJob, type: :job do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:params) { { 'ids' => [1], 'labels' => { 'add' => ['vip'] } } }
it 'invokes the bulk action service with account and user' do
service_instance = instance_double(Contacts::BulkActionService, perform: true)
allow(Contacts::BulkActionService).to receive(:new).and_return(service_instance)
described_class.perform_now(account.id, user.id, params)
expect(Contacts::BulkActionService).to have_received(:new).with(
account: account,
user: user,
params: params
)
expect(service_instance).to have_received(:perform)
end
end

View File

@@ -0,0 +1,48 @@
require 'rails_helper'
RSpec.describe Contacts::BulkAssignLabelsService do
subject(:service) do
described_class.new(
account: account,
contact_ids: [contact_one.id, contact_two.id, other_contact.id],
labels: labels
)
end
let(:account) { create(:account) }
let!(:contact_one) { create(:contact, account: account) }
let!(:contact_two) { create(:contact, account: account) }
let!(:other_contact) { create(:contact) }
let(:labels) { %w[vip support] }
it 'assigns labels to the contacts that belong to the account' do
service.perform
expect(contact_one.reload.label_list).to include(*labels)
expect(contact_two.reload.label_list).to include(*labels)
end
it 'does not assign labels to contacts outside the account' do
service.perform
expect(other_contact.reload.label_list).to be_empty
end
it 'returns ids of contacts that were updated' do
result = service.perform
expect(result[:success]).to be(true)
expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
end
it 'returns success with no updates when labels are blank' do
result = described_class.new(
account: account,
contact_ids: [contact_one.id],
labels: []
).perform
expect(result).to eq(success: true, updated_contact_ids: [])
expect(contact_one.reload.label_list).to be_empty
end
end