From b981ba766f2416f5fc30762b2270f5655cf50924 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 26 May 2026 15:23:51 +0530 Subject: [PATCH] feat: support bulk label removal (#14534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Conversation bulk remove label
selector Contact bulk remove selector: Contact bulk remove label selector 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 --- .../BulkLabelActions.vue | 59 +++++++++++++++---- .../conversationBulkActions/Index.vue | 19 ++++++ .../composables/chatlist/useBulkActions.js | 26 +++++--- .../dashboard/i18n/locale/en/bulkActions.json | 6 +- .../dashboard/i18n/locale/en/contact.json | 3 + .../components/ContactsBulkActionBar.vue | 12 ++++ .../contacts/pages/ContactsIndex.vue | 23 ++++++++ app/services/contacts/bulk_action_service.rb | 13 ++++ .../contacts/bulk_remove_labels_service.rb | 19 ++++++ .../accounts/bulk_actions_controller_spec.rb | 25 ++++++++ .../contacts/bulk_action_service_spec.rb | 14 +++++ .../bulk_remove_labels_service_spec.rb | 54 +++++++++++++++++ theme/icons.js | 5 ++ 13 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 app/services/contacts/bulk_remove_labels_service.rb create mode 100644 spec/services/contacts/bulk_remove_labels_service_spec.rb diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue index bbbf30090..e46f45da5 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue @@ -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 = () => { diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue index 145e89bde..eef70cf02 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue @@ -1,5 +1,6 @@