feat: Bulk delete for contacts (#12778)
Introduces a new bulk action `delete` for contacts ref: https://github.com/chatwoot/chatwoot/pull/12763 ## Screens <img width="1492" height="973" alt="Screenshot 2025-10-31 at 6 27 21 PM" src="https://github.com/user-attachments/assets/30dab1bb-2c2c-4168-9800-44e0eb5f8e3a" /> <img width="1492" height="985" alt="Screenshot 2025-10-31 at 6 27 32 PM" src="https://github.com/user-attachments/assets/5be610c4-b19e-4614-a164-103b22337382" />
This commit is contained in:
@@ -5,6 +5,7 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
||||
enqueue_conversation_job
|
||||
head :ok
|
||||
when 'Contact'
|
||||
check_authorization_for_contact_action
|
||||
enqueue_contact_job
|
||||
head :ok
|
||||
else
|
||||
@@ -34,14 +35,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
||||
)
|
||||
end
|
||||
|
||||
def delete_contact_action?
|
||||
params[:action_name] == 'delete'
|
||||
end
|
||||
|
||||
def check_authorization_for_contact_action
|
||||
authorize(Contact, :destroy?) if delete_contact_action?
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
||||
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
|
||||
# and then remove this method in favor of a common params method.
|
||||
base = params.permit(
|
||||
:snoozed_until,
|
||||
fields: [:status, :assignee_id, :team_id]
|
||||
)
|
||||
append_common_bulk_attributes(base)
|
||||
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
|
||||
# TODO: remove this method in favor of a common params method.
|
||||
# once legacy conversation payloads are migrated.
|
||||
append_common_bulk_attributes({})
|
||||
end
|
||||
|
||||
def append_common_bulk_attributes(base_params)
|
||||
# NOTE: Conversation payloads historically diverged per action. Going forward we
|
||||
# want all objects to share a common contract: `{ action_name, action_attributes }`
|
||||
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
|
||||
base_params.merge(common)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
|
||||
@click="updateContact"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -580,7 +580,18 @@
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
"CLEAR_SELECTION": "Clear selection",
|
||||
"SELECT_ALL": "Select all ({count})"
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"DELETE_CONTACTS": "Delete",
|
||||
"DELETE_SUCCESS": "Contacts deleted successfully.",
|
||||
"DELETE_FAILED": "Failed to delete contacts.",
|
||||
"DELETE_DIALOG": {
|
||||
"TITLE": "Delete selected contacts",
|
||||
"SINGULAR_TITLE": "Delete selected contact",
|
||||
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
|
||||
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
|
||||
"CONFIRM_MULTIPLE": "Delete contacts",
|
||||
"CONFIRM_SINGLE": "Delete contact"
|
||||
}
|
||||
},
|
||||
|
||||
"COMPOSE_NEW_CONVERSATION": {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visibleContactIds: {
|
||||
@@ -22,7 +23,12 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['clearSelection', 'assignLabels', 'toggleAll']);
|
||||
const emit = defineEmits([
|
||||
'clearSelection',
|
||||
'assignLabels',
|
||||
'toggleAll',
|
||||
'deleteSelected',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -139,6 +145,21 @@ const handleAssignLabels = labels => {
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
sm
|
||||
faded
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
@click="emit('deleteSelected')"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
|
||||
@@ -13,6 +13,7 @@ import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/Con
|
||||
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 Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import BulkActionsAPI from 'dashboard/api/bulkActions';
|
||||
|
||||
const DEFAULT_SORT_FIELD = 'last_activity_at';
|
||||
@@ -64,7 +65,26 @@ const totalItems = computed(() => meta.value?.count);
|
||||
|
||||
const selectedContactIds = ref([]);
|
||||
const isBulkActionLoading = ref(false);
|
||||
const hasSelection = computed(() => selectedContactIds.value.length > 0);
|
||||
const bulkDeleteDialogRef = ref(null);
|
||||
const selectedCount = computed(() => selectedContactIds.value.length);
|
||||
const bulkDeleteDialogTitle = computed(() =>
|
||||
selectedCount.value > 1
|
||||
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.TITLE')
|
||||
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_TITLE')
|
||||
);
|
||||
const bulkDeleteDialogDescription = computed(() =>
|
||||
selectedCount.value > 1
|
||||
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.DESCRIPTION', {
|
||||
count: selectedCount.value,
|
||||
})
|
||||
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_DESCRIPTION')
|
||||
);
|
||||
const bulkDeleteDialogConfirmLabel = computed(() =>
|
||||
selectedCount.value > 1
|
||||
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_MULTIPLE')
|
||||
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_SINGLE')
|
||||
);
|
||||
const hasSelection = computed(() => selectedCount.value > 0);
|
||||
const activeSegment = computed(() => {
|
||||
if (!activeSegmentId.value) return undefined;
|
||||
return segments.value.find(view => view.id === Number(activeSegmentId.value));
|
||||
@@ -120,6 +140,11 @@ const clearSelection = () => {
|
||||
selectedContactIds.value = [];
|
||||
};
|
||||
|
||||
const openBulkDeleteDialog = () => {
|
||||
if (!selectedContactIds.value.length || isBulkActionLoading.value) return;
|
||||
bulkDeleteDialogRef.value?.open?.();
|
||||
};
|
||||
|
||||
const toggleSelectAll = shouldSelect => {
|
||||
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
|
||||
};
|
||||
@@ -256,6 +281,29 @@ const assignLabels = async labels => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteContacts = async () => {
|
||||
if (!selectedContactIds.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
isBulkActionLoading.value = true;
|
||||
try {
|
||||
await BulkActionsAPI.create({
|
||||
type: 'Contact',
|
||||
ids: selectedContactIds.value,
|
||||
action_name: 'delete',
|
||||
});
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_SUCCESS'));
|
||||
clearSelection();
|
||||
await fetchContactsBasedOnContext(pageNumber.value);
|
||||
bulkDeleteDialogRef.value?.close?.();
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_FAILED'));
|
||||
} finally {
|
||||
isBulkActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = async ({ sort, order }) => {
|
||||
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||
|
||||
@@ -297,6 +345,12 @@ watch(
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(hasSelection, value => {
|
||||
if (!value) {
|
||||
bulkDeleteDialogRef.value?.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => uiSettings.value?.contacts_sort_by,
|
||||
newSortBy => {
|
||||
@@ -391,6 +445,7 @@ onMounted(async () => {
|
||||
@toggle-all="toggleSelectAll"
|
||||
@clear-selection="clearSelection"
|
||||
@assign-labels="assignLabels"
|
||||
@delete-selected="openBulkDeleteDialog"
|
||||
/>
|
||||
<ContactEmptyState
|
||||
v-if="showEmptyStateLayout"
|
||||
@@ -408,12 +463,22 @@ onMounted(async () => {
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 px-6 pt-2 pb-6">
|
||||
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<ContactsList
|
||||
:contacts="contacts"
|
||||
:selected-contact-ids="selectedContactIds"
|
||||
@toggle-contact="toggleContactSelection"
|
||||
/>
|
||||
<Dialog
|
||||
v-if="selectedCount"
|
||||
ref="bulkDeleteDialogRef"
|
||||
type="alert"
|
||||
:title="bulkDeleteDialogTitle"
|
||||
:description="bulkDeleteDialogDescription"
|
||||
:confirm-button-label="bulkDeleteDialogConfirmLabel"
|
||||
:is-loading="isBulkActionLoading"
|
||||
@confirm="deleteContacts"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContactsListLayout>
|
||||
|
||||
@@ -6,6 +6,7 @@ class Contacts::BulkActionService
|
||||
end
|
||||
|
||||
def perform
|
||||
return delete_contacts if delete_requested?
|
||||
return assign_labels if labels_to_add.any?
|
||||
|
||||
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
|
||||
@@ -22,6 +23,13 @@ class Contacts::BulkActionService
|
||||
).perform
|
||||
end
|
||||
|
||||
def delete_contacts
|
||||
Contacts::BulkDeleteService.new(
|
||||
account: @account,
|
||||
contact_ids: ids
|
||||
).perform
|
||||
end
|
||||
|
||||
def ids
|
||||
Array(@params[:ids]).compact
|
||||
end
|
||||
@@ -29,4 +37,8 @@ class Contacts::BulkActionService
|
||||
def labels_to_add
|
||||
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
|
||||
end
|
||||
|
||||
def delete_requested?
|
||||
@params[:action_name] == 'delete'
|
||||
end
|
||||
end
|
||||
|
||||
18
app/services/contacts/bulk_delete_service.rb
Normal file
18
app/services/contacts/bulk_delete_service.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class Contacts::BulkDeleteService
|
||||
def initialize(account:, contact_ids: [])
|
||||
@account = account
|
||||
@contact_ids = Array(contact_ids).compact
|
||||
end
|
||||
|
||||
def perform
|
||||
return if @contact_ids.blank?
|
||||
|
||||
contacts.find_each(&:destroy!)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contacts
|
||||
@account.contacts.where(id: @contact_ids)
|
||||
end
|
||||
end
|
||||
@@ -195,37 +195,6 @@ 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
|
||||
|
||||
@@ -256,4 +225,49 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/bulk_actions (contacts)' do
|
||||
context 'when it is an authenticated user' do
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'enqueues Contacts::BulkActionJob with permitted params' do
|
||||
contact_one = create(:contact, account: account)
|
||||
contact_two = create(:contact, account: account)
|
||||
|
||||
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.to_s, contact_two.id.to_s],
|
||||
'labels' => hash_including('add' => %w[vip support])
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for delete action when user is not admin' do
|
||||
contact = create(:contact, account: account)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: {
|
||||
type: 'Contact',
|
||||
ids: [contact.id],
|
||||
action_name: 'delete'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
38
spec/services/contacts/bulk_action_service_spec.rb
Normal file
38
spec/services/contacts/bulk_action_service_spec.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkActionService do
|
||||
subject(:service) { described_class.new(account: account, user: user, params: params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when delete action is requested via action_name' do
|
||||
let(:params) { { ids: [1, 2], action_name: 'delete' } }
|
||||
|
||||
it 'delegates to the bulk delete service' do
|
||||
bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true)
|
||||
|
||||
expect(Contacts::BulkDeleteService).to receive(:new)
|
||||
.with(account: account, contact_ids: [1, 2])
|
||||
.and_return(bulk_delete_service)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when labels are provided' do
|
||||
let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } }
|
||||
|
||||
it 'delegates to the bulk assign labels service with permitted params' do
|
||||
bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true)
|
||||
|
||||
expect(Contacts::BulkAssignLabelsService).to receive(:new)
|
||||
.with(account: account, contact_ids: [10, 20], labels: %w[vip support])
|
||||
.and_return(bulk_assign_service)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
24
spec/services/contacts/bulk_delete_service_spec.rb
Normal file
24
spec/services/contacts/bulk_delete_service_spec.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkDeleteService do
|
||||
subject(:service) { described_class.new(account: account, contact_ids: contact_ids) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact_one) { create(:contact, account: account) }
|
||||
let!(:contact_two) { create(:contact, account: account) }
|
||||
let(:contact_ids) { [contact_one.id, contact_two.id] }
|
||||
|
||||
describe '#perform' do
|
||||
it 'deletes the provided contacts' do
|
||||
expect { service.perform }
|
||||
.to change { account.contacts.exists?(contact_one.id) }.from(true).to(false)
|
||||
.and change { account.contacts.exists?(contact_two.id) }.from(true).to(false)
|
||||
end
|
||||
|
||||
it 'returns when no contact ids are provided' do
|
||||
empty_service = described_class.new(account: account, contact_ids: [])
|
||||
|
||||
expect { empty_service.perform }.not_to change(Contact, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user