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 class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create def create
if type_matches? case normalized_type
::BulkActionsJob.perform_later( when 'Conversation'
account: @current_account, enqueue_conversation_job
user: current_user, head :ok
params: permitted_params when 'Contact'
) enqueue_contact_job
head :ok head :ok
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
@@ -16,11 +14,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private private
def type_matches? def normalized_type
['Conversation'].include?(params[:type]) params[:type].to_s.camelize
end 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: []]) params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
end 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 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 Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.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'; import countries from 'shared/constants/countries';
const props = defineProps({ const props = defineProps({
@@ -20,9 +21,17 @@ const props = defineProps({
availabilityStatus: { type: String, default: null }, availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { 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(); const { t } = useI18n();
@@ -88,111 +97,148 @@ const onClickExpand = () => {
}; };
const onClickViewDetails = () => emit('showContact', props.id); const onClickViewDetails = () => emit('showContact', props.id);
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script> </script>
<template> <template>
<CardLayout :key="id" layout="row"> <div class="relative">
<div class="flex items-center justify-start flex-1 gap-4"> <CardLayout
<Avatar :key="id"
:name="name" layout="row"
:src="thumbnail" :class="{
:size="48" 'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
:status="availabilityStatus" }"
hide-offline-status >
rounded-full <div class="flex items-center justify-start flex-1 gap-4">
/> <div
<div class="flex flex-col gap-0.5 flex-1"> class="relative"
<div class="flex flex-wrap items-center gap-x-4 gap-y-1"> @mouseenter="handleAvatarHover(true)"
<span class="text-base font-medium truncate text-n-slate-12"> @mouseleave="handleAvatarHover(false)"
{{ name }} >
</span> <Avatar
<span class="inline-flex items-center gap-1"> :name="name"
<span :src="thumbnail"
v-if="additionalAttributes?.companyName" :size="48"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5" :status="availabilityStatus"
/> hide-offline-status
<span rounded-full
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" /> <template v-if="selectable" #overlay="{ size }">
{{ formattedLocation }} <label
</span> class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" /> :style="{ width: `${size}px`, height: `${size}px` }"
<Button @click.stop
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')" >
variant="link" <Checkbox
size="xs" :model-value="isSelected"
@click="onClickViewDetails" @change="event => toggleSelect(event.target.checked)"
/> />
</label>
</template>
</Avatar>
</div> </div>
</div> <div class="flex flex-col gap-0.5 flex-1">
</div> <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
<Button {{ name }}
icon="i-lucide-chevron-down" </span>
variant="ghost" <span class="inline-flex items-center gap-1">
color="slate" <span
size="xs" v-if="additionalAttributes?.companyName"
:class="{ 'rotate-180': isExpanded }" class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
@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> <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> </div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div> </div>
</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> </template>

View File

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

View File

@@ -61,23 +61,26 @@ const bulkCheckboxState = computed({
> >
<div <div
v-if="hasSelected" 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-3">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5 min-w-0">
<Checkbox <Checkbox
v-model="bulkCheckboxState" v-model="bulkCheckboxState"
:indeterminate="isIndeterminate" :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 }} {{ selectAllLabel }}
</span> </span>
</div> </div>
<span class="text-sm text-n-slate-10 tabular-nums"> <span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }} {{ selectedCountLabel }}
</span> </span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div> </div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount"> <slot name="actions" :selected-count="selectedCount">
<Button <Button

View File

@@ -1,100 +1,129 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { ref, computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
export default { import NextButton from 'dashboard/components-next/button/Button.vue';
components: { import Input from 'dashboard/components-next/input/Input.vue';
NextButton,
}, const emit = defineEmits(['close', 'assign']);
emits: ['update', 'close', 'assign'],
data() { const { t } = useI18n();
return {
query: '', const labels = useMapGetter('labels/getLabels');
selectedLabels: [],
}; const query = ref('');
}, const selectedLabels = ref([]);
computed: {
...mapGetters({ labels: 'labels/getLabels' }), const filteredLabels = computed(() => {
filteredLabels() { if (!query.value) return labels.value;
return this.labels.filter(label => return labels.value.filter(label =>
label.title.toLowerCase().includes(this.query.toLowerCase()) label.title.toLowerCase().includes(query.value.toLowerCase())
); );
}, });
},
methods: { const hasLabels = computed(() => labels.value.length > 0);
isLabelSelected(label) { const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
return this.selectedLabels.includes(label);
}, const isLabelSelected = label => {
assignLabels(key) { return selectedLabels.value.includes(label);
this.$emit('update', key); };
},
onClose() { const onClose = () => {
this.$emit('close'); emit('close');
}, };
},
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
}; };
</script> </script>
<template> <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"> <div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24"> <svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" /> <path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg> </svg>
</div> </div>
<div class="flex items-center justify-between header"> <div class="flex items-center justify-between p-2.5">
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span> <span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" /> <NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div> </div>
<div class="labels-list"> <div class="flex flex-col max-h-60 min-h-0">
<header class="labels-list__header"> <header class="py-2 px-2.5">
<div <Input
class="flex items-center justify-between h-8 gap-2 label-list-search" v-model="query"
> type="search"
<fluent-icon icon="search" class="search-icon" size="16" /> :placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
<input icon-left="i-lucide-search"
v-model="query" size="sm"
type="search" class="w-full"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" :aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm label--search_input" />
/>
</div>
</header> </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 <li
v-for="label in filteredLabels" v-for="label in filteredLabels"
:key="label.id" :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 <label
class="item" 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"
:class="{ 'label-selected': isLabelSelected(label.title) }"
> >
<input <input
v-model="selectedLabels" v-model="selectedLabels"
type="checkbox" type="checkbox"
:value="label.title" :value="label.title"
class="label-checkbox" class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/> />
<span <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 }} {{ label.title }}
</span> </span>
<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 }" :style="{ backgroundColor: label.color }"
/> />
</label> </label>
</li> </li>
</ul> </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 <NextButton
sm sm
type="submit" type="submit"
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')" class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length" :disabled="!selectedLabels.length"
@click="$emit('assign', selectedLabels)" @click="handleAssign"
/> />
</footer> </footer>
</div> </div>
@@ -102,107 +131,11 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.labels-list { .triangle {
@apply flex flex-col max-h-[15rem] min-h-[auto]; @apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
.labels-list__header { svg path {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5; @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> </style>

View File

@@ -24,7 +24,7 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "Assign 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_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.", "ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again." "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 🌙" "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": { "COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": { "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 { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator'; import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import ContactsListLayout from 'dashboard/components-next/Contacts/ContactsListLayout.vue'; 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 ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.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 DEFAULT_SORT_FIELD = 'last_activity_at';
const DEBOUNCE_DELAY = 300; const DEBOUNCE_DELAY = 300;
@@ -58,6 +61,10 @@ 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 selectedContactIds = ref([]);
const isBulkActionLoading = ref(false);
const hasSelection = computed(() => selectedContactIds.value.length > 0);
const activeSegment = computed(() => { const activeSegment = computed(() => {
if (!activeSegmentId.value) return undefined; if (!activeSegmentId.value) return undefined;
return segments.value.find(view => view.id === Number(activeSegmentId.value)); 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'); 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 updatePageParam = (page, search = '') => {
const query = { const query = {
...route.query, ...route.query,
@@ -129,6 +161,7 @@ const getCommonFetchParams = (page = 1) => ({
}); });
const fetchContacts = async (page = 1) => { const fetchContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters'); await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/get', getCommonFetchParams(page)); await store.dispatch('contacts/get', getCommonFetchParams(page));
updatePageParam(page); updatePageParam(page);
@@ -136,6 +169,7 @@ const fetchContacts = async (page = 1) => {
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => { const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
if (!activeSegmentId.value && !hasAppliedFilters.value) return; if (!activeSegmentId.value && !hasAppliedFilters.value) return;
clearSelection();
await store.dispatch('contacts/filter', { await store.dispatch('contacts/filter', {
...getCommonFetchParams(page), ...getCommonFetchParams(page),
queryPayload: payload, queryPayload: payload,
@@ -144,6 +178,7 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
}; };
const fetchActiveContacts = async (page = 1) => { const fetchActiveContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters'); await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', { await store.dispatch('contacts/active', {
page, page,
@@ -153,6 +188,7 @@ const fetchActiveContacts = async (page = 1) => {
}; };
const searchContacts = debounce(async (value, page = 1) => { const searchContacts = debounce(async (value, page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters'); await store.dispatch('contacts/clearContactFilters');
searchValue.value = value; searchValue.value = value;
@@ -170,6 +206,7 @@ const searchContacts = debounce(async (value, page = 1) => {
}, DEBOUNCE_DELAY); }, DEBOUNCE_DELAY);
const fetchContactsBasedOnContext = async page => { const fetchContactsBasedOnContext = async page => {
clearSelection();
updatePageParam(page, searchValue.value); updatePageParam(page, searchValue.value);
if (isFetchingList.value) return; if (isFetchingList.value) return;
if (searchQuery.value) { if (searchQuery.value) {
@@ -197,6 +234,28 @@ const fetchContactsBasedOnContext = async page => {
await fetchContacts(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 }) => { const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order }); Object.assign(sortState, { activeSort: sort, activeOrdering: order });
@@ -227,6 +286,17 @@ const createContact = async contact => {
await store.dispatch('contacts/create', 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( watch(
() => uiSettings.value?.contacts_sort_by, () => uiSettings.value?.contacts_sort_by,
newSortBy => { newSortBy => {
@@ -331,7 +401,23 @@ onMounted(async () => {
</span> </span>
</div> </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> </template>
</ContactsListLayout> </ContactsListLayout>
</div> </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.first.label_list).to contain_exactly('support', 'priority_customer')
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer') expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
end 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
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