Merge branch 'feat/conversation-finder-strategies' of github.com:chatwoot/chatwoot into feat/conversation-finder-strategies

This commit is contained in:
Shivam Mishra
2025-10-30 16:01:03 +05:30
26 changed files with 916 additions and 361 deletions
+2 -2
View File
@@ -594,7 +594,7 @@ GEM
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.3)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
@@ -653,7 +653,7 @@ GEM
rack (>= 2.0.0)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (4.1.1)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
+4
View File
@@ -36,6 +36,10 @@
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
}
},
"formation": {
+1 -1
View File
@@ -23,7 +23,7 @@ class Messages::MessageBuilder
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content if @account.feature_enabled?(:quoted_email_reply)
process_email_content
@message.save!
@message
end
@@ -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
@@ -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"
/>
<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>
@@ -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>
@@ -14,6 +14,10 @@ defineProps({
type: Array,
default: () => [],
},
showBackdrop: {
type: Boolean,
default: true,
},
});
</script>
@@ -25,14 +29,24 @@ defineProps({
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
>
<slot name="empty-state-item" />
</div>
<div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-6">
<div
class="flex flex-col items-center justify-center gap-6"
:class="{
'mt-48': !showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
@@ -40,6 +54,7 @@ defineProps({
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
@@ -114,6 +114,7 @@ const handlePageChange = event => {
<slot name="action" />
</div>
</div>
<slot name="subHeader" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
@@ -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
@@ -6,16 +6,39 @@ import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCa
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
import { computed } from 'vue';
const props = defineProps({
variant: {
type: String,
default: 'approved',
validator: value => ['approved', 'pending'].includes(value),
},
hasActiveFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'clearFilters']);
const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const onClick = () => {
emit('click');
};
const onClearFilters = () => {
emit('clearFilters');
};
</script>
<template>
<FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
@@ -25,11 +48,16 @@ const onClick = () => {
class="mb-8"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
:title="
isPending
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']"
:show-backdrop="isApproved"
>
<template #empty-state-item>
<template v-if="isApproved" #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)"
@@ -45,11 +73,21 @@ const onClick = () => {
</div>
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<div class="flex flex-col items-center gap-3">
<Button
v-if="isApproved"
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
<Button
v-else-if="isPending && hasActiveFilters"
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
variant="link"
size="sm"
@click="onClearFilters"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -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>
@@ -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."
@@ -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": {
@@ -942,7 +942,9 @@
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
@@ -2,7 +2,7 @@
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -23,6 +23,7 @@ import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
@@ -90,6 +91,18 @@ const handleCreateClose = () => {
selectedResponse.value = null;
};
const updateURLWithFilters = (page, search) => {
const query = {
page: page || 1,
};
if (search) {
query.search = search;
}
router.replace({ query });
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'approved' };
@@ -99,6 +112,10 @@ const fetchResponses = (page = 1) => {
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
// Update URL with current filters
updateURLWithFilters(page, searchQuery.value);
store.dispatch('captainResponses/get', filterParams);
};
@@ -186,20 +203,28 @@ const onBulkDeleteSuccess = () => {
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchResponses();
fetchResponses(1);
};
const debouncedSearch = debounce(async () => {
fetchResponses();
fetchResponses(1);
}, 500);
const initializeFromURL = () => {
if (route.query.search) {
searchQuery.value = route.query.search;
}
const pageFromURL = parseInt(route.query.page, 10) || 1;
fetchResponses(pageFromURL);
};
const navigateToPendingFAQs = () => {
router.push({ name: 'captain_responses_pending' });
};
onMounted(() => {
store.dispatch('captainAssistants/get');
fetchResponses();
initializeFromURL();
store.dispatch('captainResponses/fetchPendingCount');
});
</script>
@@ -230,18 +255,10 @@ onMounted(() => {
/>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #controls>
<template #subHeader>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center py-1"
class="mb-2 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
@@ -314,12 +331,20 @@ onMounted(() => {
</div>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<Banner
v-if="pendingCount > 0"
color="blue"
class="mb-4"
class="mb-4 -mt-3"
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
@action="navigateToPendingFAQs"
>
@@ -119,6 +119,18 @@ const handleCreateClose = () => {
selectedResponse.value = null;
};
const updateURLWithFilters = (page, search) => {
const query = {
page: page || 1,
};
if (search) {
query.search = search;
}
router.replace({ query });
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'pending' };
@@ -128,6 +140,10 @@ const fetchResponses = (page = 1) => {
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
// Update URL with current filters
updateURLWithFilters(page, searchQuery.value);
store.dispatch('captainResponses/get', filterParams);
};
@@ -225,16 +241,34 @@ const onBulkDeleteSuccess = () => {
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchResponses();
fetchResponses(1);
};
const debouncedSearch = debounce(async () => {
fetchResponses();
fetchResponses(1);
}, 500);
const hasActiveFilters = computed(() => {
return Boolean(searchQuery.value || selectedAssistant.value !== 'all');
});
const clearFilters = () => {
searchQuery.value = '';
selectedAssistant.value = 'all';
fetchResponses(1);
};
const initializeFromURL = () => {
if (route.query.search) {
searchQuery.value = route.query.search;
}
const pageFromURL = parseInt(route.query.page, 10) || 1;
fetchResponses(pageFromURL);
};
onMounted(() => {
store.dispatch('captainAssistants/get');
fetchResponses();
initializeFromURL();
});
</script>
@@ -265,18 +299,10 @@ onMounted(() => {
/>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #controls>
<template #subHeader>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center py-1"
class="mb-2 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
@@ -358,6 +384,19 @@ onMounted(() => {
</div>
</template>
<template #emptyState>
<ResponsePageEmptyState
variant="pending"
:has-active-filters="hasActiveFilters"
@click="handleCreate"
@clear-filters="clearFilters"
/>
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
@@ -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 { 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>
+14
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
@@ -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
@@ -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
@@ -10,8 +10,12 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
Current.executed_by = @assistant
ActiveRecord::Base.transaction do
generate_and_process_response
if captain_v2_enabled?
generate_response_with_v2
else
ActiveRecord::Base.transaction do
generate_and_process_response
end
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
@@ -26,16 +30,20 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = if captain_v2_enabled?
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
else
Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
message_history: collect_previous_messages
)
end
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
message_history: collect_previous_messages
)
process_response
end
def generate_response_with_v2
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
message_history: collect_previous_messages
)
process_response
end
def process_response
return process_action('handoff') if handoff_requested?
create_messages
@@ -123,6 +131,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def captain_v2_enabled?
return account.feature_enabled?('captain_integration_v2')
account.feature_enabled?('captain_integration_v2')
end
end
@@ -199,19 +199,6 @@ describe Messages::MessageBuilder do
expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
end
it 'does not process custom email content when quoted_email_reply feature is disabled' do
account.disable_features('quoted_email_reply')
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'does not process custom email content for private messages' do
params = ActionController::Parameters.new({
content: 'Regular message content',
@@ -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
@@ -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
@@ -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