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:
@@ -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
|
||||||
|
|||||||
@@ -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,11 +97,31 @@ 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">
|
||||||
|
<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="flex items-center justify-start flex-1 gap-4">
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
@mouseenter="handleAvatarHover(true)"
|
||||||
|
@mouseleave="handleAvatarHover(false)"
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
:name="name"
|
:name="name"
|
||||||
:src="thumbnail"
|
:src="thumbnail"
|
||||||
@@ -100,7 +129,21 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
:status="availabilityStatus"
|
:status="availabilityStatus"
|
||||||
hide-offline-status
|
hide-offline-status
|
||||||
rounded-full
|
rounded-full
|
||||||
|
>
|
||||||
|
<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 class="flex flex-col gap-0.5 flex-1">
|
<div class="flex flex-col gap-0.5 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-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">
|
<span class="text-base font-medium truncate text-n-slate-12">
|
||||||
@@ -119,7 +162,9 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
|
<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">
|
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||||
<span class="text-sm text-n-slate-11">
|
<span class="text-sm text-n-slate-11">
|
||||||
{{ email }}
|
{{ email }}
|
||||||
@@ -195,4 +240,5 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</CardLayout>
|
</CardLayout>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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,14 +69,27 @@ 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">
|
||||||
|
<div v-for="contact in contacts" :key="contact.id" class="relative">
|
||||||
<ContactsCard
|
<ContactsCard
|
||||||
v-for="contact in contacts"
|
|
||||||
: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"
|
||||||
@@ -74,9 +98,14 @@ const toggleExpanded = id => {
|
|||||||
: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)"
|
||||||
|
:is-selected="isSelected(contact.id)"
|
||||||
@toggle="toggleExpanded(contact.id)"
|
@toggle="toggleExpanded(contact.id)"
|
||||||
@update-contact="updateContact"
|
@update-contact="updateContact"
|
||||||
@show-contact="onClickViewDetails"
|
@show-contact="onClickViewDetails"
|
||||||
|
@select="value => handleSelect(contact.id, value)"
|
||||||
|
@avatar-hover="value => handleAvatarHover(contact.id, value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
<div class="h-4 w-px bg-n-strong" />
|
<div class="h-4 w-px bg-n-strong" />
|
||||||
|
<slot name="secondary-actions" />
|
||||||
|
</div>
|
||||||
<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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
>
|
|
||||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
|
||||||
<input
|
|
||||||
v-model="query"
|
v-model="query"
|
||||||
type="search"
|
type="search"
|
||||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||||
class="reset-base !outline-0 !text-sm label--search_input"
|
icon-left="i-lucide-search"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</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,61 +131,6 @@ export default {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.labels-list {
|
|
||||||
@apply flex flex-col max-h-[15rem] min-h-[auto];
|
|
||||||
|
|
||||||
.labels-list__header {
|
|
||||||
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.triangle {
|
||||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||||
|
|
||||||
@@ -164,45 +138,4 @@ export default {
|
|||||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
@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>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
14
app/jobs/contacts/bulk_action_job.rb
Normal file
14
app/jobs/contacts/bulk_action_job.rb
Normal 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
|
||||||
32
app/services/contacts/bulk_action_service.rb
Normal file
32
app/services/contacts/bulk_action_service.rb
Normal 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
|
||||||
19
app/services/contacts/bulk_assign_labels_service.rb
Normal file
19
app/services/contacts/bulk_assign_labels_service.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
22
spec/jobs/contacts/bulk_action_job_spec.rb
Normal file
22
spec/jobs/contacts/bulk_action_job_spec.rb
Normal 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
|
||||||
48
spec/services/contacts/bulk_assign_labels_service_spec.rb
Normal file
48
spec/services/contacts/bulk_assign_labels_service_spec.rb
Normal 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
|
||||||
Reference in New Issue
Block a user