feat: support bulk label removal (#14534)
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>
This commit is contained in:
co-authored by
Sivin Varghese
iamsivin
parent
37c8e7e699
commit
b981ba766f
+49
-10
@@ -14,6 +14,11 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'conversation',
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
default: 'assign',
|
||||
validator: value => ['assign', 'remove'].includes(value),
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -22,9 +27,13 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
appliedLabels: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['assign']);
|
||||
const emit = defineEmits(['assign', 'remove']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -35,17 +44,43 @@ const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const isTypeContact = computed(() => props.type === 'contact');
|
||||
const isRemoveAction = computed(() => props.action === 'remove');
|
||||
|
||||
const buttonLabel = computed(() =>
|
||||
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
|
||||
const buttonLabel = computed(() => {
|
||||
if (!isTypeContact.value) return '';
|
||||
|
||||
return isRemoveAction.value
|
||||
? t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS')
|
||||
: t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
|
||||
});
|
||||
|
||||
const tooltipLabel = computed(() =>
|
||||
isRemoveAction.value
|
||||
? t('BULK_ACTION.LABELS.REMOVE_LABELS')
|
||||
: t('BULK_ACTION.LABELS.ASSIGN_LABELS')
|
||||
);
|
||||
|
||||
const confirmLabel = computed(() =>
|
||||
isRemoveAction.value
|
||||
? t('BULK_ACTION.LABELS.REMOVE_SELECTED_LABELS')
|
||||
: t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')
|
||||
);
|
||||
|
||||
const isLabelSelected = labelTitle => {
|
||||
return selectedLabels.value.includes(labelTitle);
|
||||
};
|
||||
|
||||
const visibleLabels = computed(() => {
|
||||
if (!isRemoveAction.value || props.appliedLabels === null) {
|
||||
return labels.value;
|
||||
}
|
||||
|
||||
const applied = new Set(props.appliedLabels);
|
||||
return labels.value.filter(label => applied.has(label.title));
|
||||
});
|
||||
|
||||
const labelMenuItems = computed(() => {
|
||||
return labels.value.map(label => ({
|
||||
return visibleLabels.value.map(label => ({
|
||||
action: 'select',
|
||||
value: label.title,
|
||||
label: label.title,
|
||||
@@ -64,9 +99,13 @@ const toggleLabelSelection = labelTitle => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
const handleApply = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
if (isRemoveAction.value) {
|
||||
emit('remove', selectedLabels.value);
|
||||
} else {
|
||||
emit('assign', selectedLabels.value);
|
||||
}
|
||||
toggleDropdown(false);
|
||||
selectedLabels.value = [];
|
||||
}
|
||||
@@ -81,9 +120,9 @@ const handleDismiss = () => {
|
||||
<template>
|
||||
<div ref="containerRef" class="relative">
|
||||
<NextButton
|
||||
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
v-tooltip="tooltipLabel"
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-tag"
|
||||
:icon="isRemoveAction ? 'i-woot-tag-remove' : 'i-lucide-tag'"
|
||||
slate
|
||||
:size="isTypeContact ? 'sm' : 'xs'"
|
||||
ghost
|
||||
@@ -148,9 +187,9 @@ const handleDismiss = () => {
|
||||
<NextButton
|
||||
sm
|
||||
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:label="confirmLabel"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="handleAssign"
|
||||
@click="handleApply"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+19
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -55,12 +56,25 @@ defineOptions({
|
||||
const attrs = useAttrs();
|
||||
|
||||
const {
|
||||
selectedConversations,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk: onAssignTeam,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
|
||||
const getConversationById = useMapGetter('getConversationById');
|
||||
|
||||
const appliedLabelsForSelection = computed(() => {
|
||||
const applied = new Set();
|
||||
selectedConversations.value.forEach(id => {
|
||||
const conversation = getConversationById.value(id);
|
||||
(conversation?.labels || []).forEach(label => applied.add(label));
|
||||
});
|
||||
return Array.from(applied);
|
||||
});
|
||||
|
||||
const showCustomTimeSnoozeModal = ref(false);
|
||||
|
||||
function onCmdSnoozeConversation(snoozeType) {
|
||||
@@ -161,6 +175,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<BulkLabelActions @assign="onAssignLabels" />
|
||||
<BulkLabelActions
|
||||
action="remove"
|
||||
:applied-labels="appliedLabelsForSelection"
|
||||
@remove="onRemoveLabels"
|
||||
/>
|
||||
<BulkUpdateActions
|
||||
:show-resolve="!showResolvedAction"
|
||||
:show-reopen="!showOpenAction"
|
||||
|
||||
@@ -108,7 +108,7 @@ export function useBulkActions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only used in context menu
|
||||
// Used by both context menu and bulk action bar.
|
||||
async function onRemoveLabels(labelsToRemove, conversationId = null) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
@@ -119,14 +119,24 @@ export function useBulkActions() {
|
||||
},
|
||||
});
|
||||
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
// Context-menu remove should not disturb an existing bulk selection.
|
||||
if (conversationId) {
|
||||
useAlert(
|
||||
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
|
||||
labelName: labelsToRemove[0],
|
||||
conversationId,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
useAlert(t('BULK_ACTION.LABELS.REMOVE_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
|
||||
useAlert(
|
||||
conversationId
|
||||
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
|
||||
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,13 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"REMOVE_LABELS": "Remove labels",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"REMOVE_SELECTED_LABELS": "Remove selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again.",
|
||||
"REMOVE_SUCCESFUL": "Labels removed successfully.",
|
||||
"REMOVE_FAILED": "Failed to remove labels. Please try again."
|
||||
},
|
||||
"TEAMS": {
|
||||
"NONE": "None",
|
||||
|
||||
@@ -586,8 +586,11 @@
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
"REMOVE_LABELS": "Remove Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
|
||||
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
||||
"REMOVE_LABELS_SUCCESS": "Labels removed successfully.",
|
||||
"REMOVE_LABELS_FAILED": "Failed to remove labels",
|
||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
|
||||
+12
@@ -25,6 +25,7 @@ const props = defineProps({
|
||||
const emit = defineEmits([
|
||||
'clearSelection',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'toggleAll',
|
||||
'deleteSelected',
|
||||
]);
|
||||
@@ -72,6 +73,10 @@ const selectionModel = computed({
|
||||
const handleAssignLabels = labels => {
|
||||
emit('assignLabels', labels);
|
||||
};
|
||||
|
||||
const handleRemoveLabels = labels => {
|
||||
emit('removeLabels', labels);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,6 +108,13 @@ const handleAssignLabels = labels => {
|
||||
:disabled="!selectedCount"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
<BulkLabelActions
|
||||
type="contact"
|
||||
action="remove"
|
||||
:is-loading="isLoading"
|
||||
:disabled="!selectedCount"
|
||||
@remove="handleRemoveLabels"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-weak rounded-lg" />
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
|
||||
@@ -351,6 +351,28 @@ const assignLabels = async labels => {
|
||||
}
|
||||
};
|
||||
|
||||
const removeLabels = async labels => {
|
||||
if (!labels.length || !selectedContactIds.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
isBulkActionLoading.value = true;
|
||||
try {
|
||||
await BulkActionsAPI.create({
|
||||
type: 'Contact',
|
||||
ids: selectedContactIds.value,
|
||||
labels: { remove: labels },
|
||||
});
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS_SUCCESS'));
|
||||
clearSelection();
|
||||
await fetchContactsBasedOnContext(pageNumber.value);
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.REMOVE_LABELS_FAILED'));
|
||||
} finally {
|
||||
isBulkActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteContacts = async () => {
|
||||
if (!selectedContactIds.value.length) {
|
||||
return;
|
||||
@@ -514,6 +536,7 @@ onMounted(async () => {
|
||||
@toggle-all="toggleSelectAll"
|
||||
@clear-selection="clearSelection"
|
||||
@assign-labels="assignLabels"
|
||||
@remove-labels="removeLabels"
|
||||
@delete-selected="openBulkDeleteDialog"
|
||||
/>
|
||||
<ContactEmptyState
|
||||
|
||||
@@ -8,6 +8,7 @@ class Contacts::BulkActionService
|
||||
def perform
|
||||
return delete_contacts if delete_requested?
|
||||
return assign_labels if labels_to_add.any?
|
||||
return remove_labels if labels_to_remove.any?
|
||||
|
||||
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
|
||||
{ success: false, error: 'unknown_operation' }
|
||||
@@ -23,6 +24,14 @@ class Contacts::BulkActionService
|
||||
).perform
|
||||
end
|
||||
|
||||
def remove_labels
|
||||
Contacts::BulkRemoveLabelsService.new(
|
||||
account: @account,
|
||||
contact_ids: ids,
|
||||
labels: labels_to_remove
|
||||
).perform
|
||||
end
|
||||
|
||||
def delete_contacts
|
||||
Contacts::BulkDeleteService.new(
|
||||
account: @account,
|
||||
@@ -38,6 +47,10 @@ class Contacts::BulkActionService
|
||||
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
|
||||
end
|
||||
|
||||
def labels_to_remove
|
||||
@labels_to_remove ||= Array(@params.dig(:labels, :remove)).reject(&:blank?)
|
||||
end
|
||||
|
||||
def delete_requested?
|
||||
@params[:action_name] == 'delete'
|
||||
end
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class Contacts::BulkRemoveLabelsService
|
||||
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.update!(label_list: contact.label_list - @labels)
|
||||
end
|
||||
|
||||
{ success: true, updated_contact_ids: contacts.pluck(:id) }
|
||||
end
|
||||
end
|
||||
@@ -263,6 +263,31 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
||||
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)
|
||||
|
||||
|
||||
@@ -34,5 +34,19 @@ RSpec.describe Contacts::BulkActionService do
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when labels are removed' do
|
||||
let(:params) { { ids: [10, 20], labels: { remove: %w[vip] }, extra: 'ignored' } }
|
||||
|
||||
it 'delegates to the bulk remove labels service with permitted params' do
|
||||
bulk_remove_service = instance_double(Contacts::BulkRemoveLabelsService, perform: true)
|
||||
|
||||
expect(Contacts::BulkRemoveLabelsService).to receive(:new)
|
||||
.with(account: account, contact_ids: [10, 20], labels: %w[vip])
|
||||
.and_return(bulk_remove_service)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkRemoveLabelsService 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] }
|
||||
|
||||
before do
|
||||
contact_one.add_labels(%w[vip support])
|
||||
contact_two.add_labels(%w[vip priority])
|
||||
other_contact.add_labels(%w[vip support])
|
||||
end
|
||||
|
||||
it 'removes labels from contacts that belong to the account' do
|
||||
service.perform
|
||||
|
||||
expect(contact_one.reload.label_list).to contain_exactly('support')
|
||||
expect(contact_two.reload.label_list).to contain_exactly('priority')
|
||||
end
|
||||
|
||||
it 'does not remove labels from contacts outside the account' do
|
||||
service.perform
|
||||
|
||||
expect(other_contact.reload.label_list).to contain_exactly('vip', 'support')
|
||||
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 contain_exactly('vip', 'support')
|
||||
end
|
||||
end
|
||||
@@ -445,5 +445,10 @@ export const icons = {
|
||||
width: 15,
|
||||
height: 15,
|
||||
},
|
||||
'tag-remove': {
|
||||
body: `<defs><mask id="cw-tagrm-cut"><rect width="24" height="24" fill="white"/><circle cx="7" cy="17" r="6.3" fill="black"/></mask><mask id="cw-tagrm-x"><rect width="24" height="24" fill="white"/><path d="M4.3 14.3 9.7 19.7M9.7 14.3 4.3 19.7" stroke="black" stroke-width="2.4" stroke-linecap="round"/></mask></defs><g transform="translate(24 0) scale(-1 1)"><path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" mask="url(#cw-tagrm-cut)"/><circle cx="7.5" cy="7.5" r=".5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="7" cy="17" r="6.5" fill="currentColor" mask="url(#cw-tagrm-x)"/></g>`,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
/** Ends */
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user