Adds bulk label removal alongside the existing assign-label action for conversations and contacts, so teams can clean up labels across selected records without opening each item individually. For conversations, the remove dropdown is scoped to labels that are actually applied across the current selection — so agents no longer see (or accidentally "remove") labels that aren't on any of the selected items. For contacts, the dropdown still lists all account labels for now; label data isn't carried on the contact list payload today, so scoping the contact remove menu cleanly is being tracked as a follow-up. ## Closes N/A ## How to test - Open the conversation list, select multiple conversations, open **Remove labels**, and confirm the dropdown only lists labels that are applied to at least one selected conversation. Pick a label and confirm it's removed from the selection. - Open Contacts, select multiple contacts, use **Remove Labels**, choose a label, and confirm the selected contacts are refreshed without that label. - Verify **Assign Labels** still works for conversations and contacts, and continues to show every available label. ## What changed - Adds an `action` prop to the shared `BulkLabelActions` dropdown so it can render in `assign` or `remove` mode. - Wires conversation bulk remove to the existing `labels.remove` backend path and filters the dropdown to the union of labels applied across the selected conversations. - Adds contact bulk remove support through `Contacts::BulkRemoveLabelsService`, routed by `Contacts::BulkActionService`. - Raises contact label save failures instead of reporting a successful bulk action when a contact update is invalid. ## Follow-ups - Scope the contact remove dropdown to applied labels (needs a lightweight endpoint, or eventually `cached_label_list` on `Contact`). ## Verification Conversation bulk remove selector: <img width="1680" height="1050" alt="Conversation bulk remove label selector" src="https://github.com/user-attachments/assets/2dba4a06-c497-45e1-85b0-e700164b6b2f" /> Contact bulk remove selector: <img width="1680" height="1050" alt="Contact bulk remove label selector" src="https://github.com/user-attachments/assets/b3b89959-5978-4064-b5f9-82b1a3e571dc" /> Video proof: https://github.com/user-attachments/assets/fffafe19-4e1c-4e2a-a135-c7182c06bb4d --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
307 lines
12 KiB
Ruby
307 lines
12 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
|
include ActiveJob::TestHelper
|
|
let(:account) { create(:account) }
|
|
let(:agent_1) { create(:user, account: account, role: :agent) }
|
|
let(:agent_2) { create(:user, account: account, role: :agent) }
|
|
let(:team_1) { create(:team, account: account) }
|
|
|
|
before do
|
|
create(:conversation, account_id: account.id, status: :open, team_id: team_1.id)
|
|
create(:conversation, account_id: account.id, status: :open, team_id: team_1.id)
|
|
create(:conversation, account_id: account.id, status: :open)
|
|
create(:conversation, account_id: account.id, status: :open)
|
|
Conversation.all.find_each do |conversation|
|
|
create(:inbox_member, inbox: conversation.inbox, user: agent_1)
|
|
create(:inbox_member, inbox: conversation.inbox, user: agent_2)
|
|
end
|
|
end
|
|
|
|
describe 'POST /api/v1/accounts/{account.id}/bulk_action' do
|
|
context 'when it is an unauthenticated user' do
|
|
let!(:agent) { create(:user) }
|
|
|
|
it 'returns unauthorized' do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: { type: 'Conversation', fields: { status: 'open' }, ids: [1, 2, 3] }
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when it is an authenticated user' do
|
|
let!(:agent) { create(:user, account: account, role: :agent) }
|
|
|
|
before do
|
|
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
|
|
end
|
|
|
|
it 'Ignores bulk_actions for wrong type' do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: { type: 'Test', fields: { status: 'snoozed' }, ids: %w[1 2 3] }
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
|
|
it 'Bulk update conversation status' do
|
|
expect(Conversation.first.status).to eq('open')
|
|
expect(Conversation.last.status).to eq('open')
|
|
expect(Conversation.first.assignee_id).to be_nil
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: { type: 'Conversation', fields: { status: 'snoozed' }, ids: Conversation.first(3).pluck(:display_id) }
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.first.status).to eq('snoozed')
|
|
expect(Conversation.last.status).to eq('open')
|
|
expect(Conversation.first.assignee_id).to be_nil
|
|
end
|
|
|
|
it 'Bulk update conversation team id to none' do
|
|
params = { type: 'Conversation', fields: { team_id: 0 }, ids: Conversation.first(1).pluck(:display_id) }
|
|
expect(Conversation.first.team).not_to be_nil
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: params
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.first.team).to be_nil
|
|
|
|
last_activity_message = Conversation.first.messages.activity.last
|
|
|
|
expect(last_activity_message.content).to eq("Unassigned from #{team_1.name} by #{agent.name}")
|
|
end
|
|
|
|
it 'Bulk update conversation team id to team' do
|
|
params = { type: 'Conversation', fields: { team_id: team_1.id }, ids: Conversation.last(2).pluck(:display_id) }
|
|
expect(Conversation.last.team_id).to be_nil
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: params
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.last.team).to eq(team_1)
|
|
|
|
last_activity_message = Conversation.last.messages.activity.last
|
|
|
|
expect(last_activity_message.content).to eq("Assigned to #{team_1.name} by #{agent.name}")
|
|
end
|
|
|
|
it 'Bulk update conversation assignee id' do
|
|
params = { type: 'Conversation', fields: { assignee_id: agent_1.id }, ids: Conversation.first(3).pluck(:display_id) }
|
|
|
|
expect(Conversation.first.status).to eq('open')
|
|
expect(Conversation.first.assignee_id).to be_nil
|
|
expect(Conversation.second.assignee_id).to be_nil
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: params
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.first.assignee_id).to eq(agent_1.id)
|
|
expect(Conversation.second.assignee_id).to eq(agent_1.id)
|
|
expect(Conversation.first.status).to eq('open')
|
|
end
|
|
|
|
it 'Bulk remove assignee id from conversations' do
|
|
Conversation.first.update(assignee_id: agent_1.id)
|
|
Conversation.second.update(assignee_id: agent_2.id)
|
|
params = { type: 'Conversation', fields: { assignee_id: nil }, ids: Conversation.first(3).pluck(:display_id) }
|
|
|
|
expect(Conversation.first.status).to eq('open')
|
|
expect(Conversation.first.assignee_id).to eq(agent_1.id)
|
|
expect(Conversation.second.assignee_id).to eq(agent_2.id)
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: params
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.first.assignee_id).to be_nil
|
|
expect(Conversation.second.assignee_id).to be_nil
|
|
expect(Conversation.first.status).to eq('open')
|
|
end
|
|
|
|
it 'Do not bulk update status to nil' do
|
|
Conversation.first.update(assignee_id: agent_1.id)
|
|
Conversation.second.update(assignee_id: agent_2.id)
|
|
params = { type: 'Conversation', fields: { status: nil }, ids: Conversation.first(3).pluck(:display_id) }
|
|
|
|
expect(Conversation.first.status).to eq('open')
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: params
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.first.status).to eq('open')
|
|
end
|
|
|
|
it 'Bulk update conversation status and assignee id' do
|
|
params = { type: 'Conversation', fields: { assignee_id: agent_1.id, status: 'snoozed' }, ids: Conversation.first(3).pluck(:display_id) }
|
|
|
|
expect(Conversation.first.status).to eq('open')
|
|
expect(Conversation.second.assignee_id).to be_nil
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: params
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.first.assignee_id).to eq(agent_1.id)
|
|
expect(Conversation.second.assignee_id).to eq(agent_1.id)
|
|
expect(Conversation.first.status).to eq('snoozed')
|
|
expect(Conversation.second.status).to eq('snoozed')
|
|
end
|
|
|
|
it 'Bulk update conversation labels' do
|
|
params = { type: 'Conversation', ids: Conversation.first(3).pluck(:display_id), labels: { add: %w[support priority_customer] } }
|
|
|
|
expect(Conversation.first.labels).to eq([])
|
|
expect(Conversation.second.labels).to eq([])
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: params
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer')
|
|
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'POST /api/v1/accounts/{account.id}/bulk_actions' do
|
|
context 'when it is an authenticated user' do
|
|
let!(:agent) { create(:user, account: account, role: :agent) }
|
|
|
|
before do
|
|
Conversation.all.find_each { |conversation| create(:inbox_member, inbox: conversation.inbox, user: agent) }
|
|
end
|
|
|
|
it 'Bulk delete conversation labels' do
|
|
Conversation.first.add_labels(%w[support priority_customer])
|
|
Conversation.second.add_labels(%w[support priority_customer])
|
|
Conversation.third.add_labels(%w[support priority_customer])
|
|
|
|
params = { type: 'Conversation', ids: Conversation.first(3).pluck(:display_id), labels: { remove: %w[support] } }
|
|
|
|
expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer')
|
|
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
|
|
|
|
perform_enqueued_jobs do
|
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
headers: agent.create_new_auth_token,
|
|
params: params
|
|
|
|
expect(response).to have_http_status(:success)
|
|
end
|
|
|
|
expect(Conversation.first.label_list).to contain_exactly('priority_customer')
|
|
expect(Conversation.second.label_list).to contain_exactly('priority_customer')
|
|
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 'permits contact label removal 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: { remove: %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('remove' => %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
|