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>
237 lines
7.1 KiB
JavaScript
237 lines
7.1 KiB
JavaScript
import { ref, unref } from 'vue';
|
|
import { useStore } from 'vuex';
|
|
import { useAlert } from 'dashboard/composables';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
|
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
|
import wootConstants from 'dashboard/constants/globals';
|
|
|
|
export function useBulkActions() {
|
|
const store = useStore();
|
|
const { t } = useI18n();
|
|
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
|
|
|
const selectedConversations = useMapGetter(
|
|
'bulkActions/getSelectedConversationIds'
|
|
);
|
|
const selectedInboxes = ref([]);
|
|
|
|
function selectConversation(conversationId, inboxId) {
|
|
store.dispatch('bulkActions/setSelectedConversationIds', conversationId);
|
|
selectedInboxes.value = [...selectedInboxes.value, inboxId];
|
|
}
|
|
|
|
function deSelectConversation(conversationId, inboxId) {
|
|
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
|
|
// Only remove one instance of the inboxId, not all
|
|
// This handles the case where multiple conversations from the same inbox are selected
|
|
const index = selectedInboxes.value.indexOf(inboxId);
|
|
if (index > -1) {
|
|
selectedInboxes.value = [
|
|
...selectedInboxes.value.slice(0, index),
|
|
...selectedInboxes.value.slice(index + 1),
|
|
];
|
|
}
|
|
}
|
|
|
|
function resetBulkActions() {
|
|
store.dispatch('bulkActions/clearSelectedConversationIds');
|
|
selectedInboxes.value = [];
|
|
}
|
|
|
|
function selectAllConversations(check, conversationList) {
|
|
const availableConversations = unref(conversationList);
|
|
if (check) {
|
|
store.dispatch(
|
|
'bulkActions/setSelectedConversationIds',
|
|
availableConversations.map(item => item.id)
|
|
);
|
|
selectedInboxes.value = availableConversations.map(item => item.inbox_id);
|
|
} else {
|
|
resetBulkActions();
|
|
}
|
|
}
|
|
|
|
function isConversationSelected(id) {
|
|
return selectedConversations.value.includes(id);
|
|
}
|
|
|
|
// Same method used in context menu, conversationId being passed from there.
|
|
async function onAssignAgent(agent, conversationId = null) {
|
|
try {
|
|
await store.dispatch('bulkActions/process', {
|
|
type: 'Conversation',
|
|
ids: conversationId || selectedConversations.value,
|
|
fields: {
|
|
assignee_id: agent.id,
|
|
},
|
|
});
|
|
store.dispatch('bulkActions/clearSelectedConversationIds');
|
|
if (conversationId) {
|
|
useAlert(
|
|
t('CONVERSATION.CARD_CONTEXT_MENU.API.AGENT_ASSIGNMENT.SUCCESFUL', {
|
|
agentName: agent.name,
|
|
conversationId,
|
|
})
|
|
);
|
|
} else {
|
|
useAlert(t('BULK_ACTION.ASSIGN_SUCCESFUL'));
|
|
}
|
|
} catch (err) {
|
|
useAlert(t('BULK_ACTION.ASSIGN_FAILED'));
|
|
}
|
|
}
|
|
|
|
// Same method used in context menu, conversationId being passed from there.
|
|
async function onAssignLabels(newLabels, conversationId = null) {
|
|
try {
|
|
await store.dispatch('bulkActions/process', {
|
|
type: 'Conversation',
|
|
ids: conversationId || selectedConversations.value,
|
|
labels: {
|
|
add: newLabels,
|
|
},
|
|
});
|
|
store.dispatch('bulkActions/clearSelectedConversationIds');
|
|
if (conversationId) {
|
|
useAlert(
|
|
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_ASSIGNMENT.SUCCESFUL', {
|
|
labelName: newLabels[0],
|
|
conversationId,
|
|
})
|
|
);
|
|
} else {
|
|
useAlert(t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
|
|
}
|
|
} catch (err) {
|
|
useAlert(t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
|
|
}
|
|
}
|
|
|
|
// Used by both context menu and bulk action bar.
|
|
async function onRemoveLabels(labelsToRemove, conversationId = null) {
|
|
try {
|
|
await store.dispatch('bulkActions/process', {
|
|
type: 'Conversation',
|
|
ids: conversationId || selectedConversations.value,
|
|
labels: {
|
|
remove: labelsToRemove,
|
|
},
|
|
});
|
|
|
|
// 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(
|
|
conversationId
|
|
? t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED')
|
|
: t('BULK_ACTION.LABELS.REMOVE_FAILED')
|
|
);
|
|
}
|
|
}
|
|
|
|
async function onAssignTeamsForBulk(team) {
|
|
try {
|
|
await store.dispatch('bulkActions/process', {
|
|
type: 'Conversation',
|
|
ids: selectedConversations.value,
|
|
fields: {
|
|
team_id: team.id,
|
|
},
|
|
});
|
|
store.dispatch('bulkActions/clearSelectedConversationIds');
|
|
useAlert(t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
|
|
} catch (err) {
|
|
useAlert(t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
|
|
}
|
|
}
|
|
|
|
async function onUpdateConversations(status, snoozedUntil) {
|
|
if (selectedConversations.value.length === 0) return;
|
|
|
|
let conversationIds = selectedConversations.value;
|
|
let skippedCount = 0;
|
|
|
|
// If resolving, check for required attributes
|
|
if (status === wootConstants.STATUS_TYPE.RESOLVED) {
|
|
const { validIds, skippedIds } = selectedConversations.value.reduce(
|
|
(acc, id) => {
|
|
const conversation = store.getters.getConversationById(id);
|
|
const currentCustomAttributes = conversation?.custom_attributes || {};
|
|
const { hasMissing } = checkMissingAttributes(
|
|
currentCustomAttributes
|
|
);
|
|
|
|
if (!hasMissing) {
|
|
acc.validIds.push(id);
|
|
} else {
|
|
acc.skippedIds.push(id);
|
|
}
|
|
return acc;
|
|
},
|
|
{ validIds: [], skippedIds: [] }
|
|
);
|
|
|
|
conversationIds = validIds;
|
|
skippedCount = skippedIds.length;
|
|
|
|
if (skippedCount > 0 && validIds.length === 0) {
|
|
// All conversations have missing attributes
|
|
useAlert(
|
|
t('BULK_ACTION.RESOLVE.ALL_MISSING_ATTRIBUTES') ||
|
|
'Cannot resolve conversations due to missing required attributes'
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (conversationIds.length > 0) {
|
|
await store.dispatch('bulkActions/process', {
|
|
type: 'Conversation',
|
|
ids: conversationIds,
|
|
fields: {
|
|
status,
|
|
},
|
|
snoozed_until: snoozedUntil,
|
|
});
|
|
}
|
|
|
|
store.dispatch('bulkActions/clearSelectedConversationIds');
|
|
|
|
if (skippedCount > 0) {
|
|
useAlert(t('BULK_ACTION.RESOLVE.PARTIAL_SUCCESS'));
|
|
} else {
|
|
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
|
}
|
|
} catch (err) {
|
|
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
|
}
|
|
}
|
|
|
|
return {
|
|
selectedConversations,
|
|
selectedInboxes,
|
|
selectConversation,
|
|
deSelectConversation,
|
|
selectAllConversations,
|
|
resetBulkActions,
|
|
isConversationSelected,
|
|
onAssignAgent,
|
|
onAssignLabels,
|
|
onRemoveLabels,
|
|
onAssignTeamsForBulk,
|
|
onUpdateConversations,
|
|
};
|
|
}
|