feat: Add Bulk actions to conversations (#4647)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Fayaz Ahmed
2022-06-03 11:12:22 +05:30
committed by GitHub
parent 43a0b4c039
commit 79a525aa62
21 changed files with 693 additions and 12 deletions

View File

@@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class BulkActionsAPI extends ApiClient {
constructor() {
super('bulk_actions', { accountScoped: true });
}
}
export default new BulkActionsAPI();

View File

@@ -0,0 +1,9 @@
import bulkActions from '../bulkActions';
import ApiClient from '../ApiClient';
describe('#BulkActionsAPI', () => {
it('creates correct instance', () => {
expect(bulkActions).toBeInstanceOf(ApiClient);
expect(bulkActions).toHaveProperty('create');
});
});

View File

@@ -96,6 +96,9 @@
:chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:selected="isConversationSelected(chat.id)"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
<div v-if="chatListLoading" class="text-center">
@@ -134,6 +137,16 @@
@applyFilter="onApplyFilter"
/>
</woot-modal>
<conversation-bulk-actions
v-if="selectedConversations.length"
:conversations="selectedConversations"
:all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes"
@select-all-conversations="selectAllConversations"
@assign-agent="onAssignAgent"
@resolve-conversations="onResolveConversations"
/>
</div>
</template>
@@ -152,6 +165,8 @@ import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Actions.vue';
import alertMixin from 'shared/mixins/alertMixin';
import {
hasPressedAltAndJKey,
@@ -166,8 +181,9 @@ export default {
ChatFilter,
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
},
mixins: [timeMixin, conversationMixin, eventListenerMixins],
mixins: [timeMixin, conversationMixin, eventListenerMixins, alertMixin],
props: {
conversationInbox: {
type: [String, Number],
@@ -202,6 +218,8 @@ export default {
foldersQuery: {},
showAddFoldersModal: false,
showDeleteFoldersModal: false,
selectedConversations: [],
selectedInboxes: [],
};
},
computed: {
@@ -217,6 +235,7 @@ export default {
conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews',
inboxes: 'inboxes/getInboxes',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
@@ -343,6 +362,15 @@ export default {
}
return {};
},
allConversationsSelected() {
return (
JSON.stringify(this.selectedConversations) ===
JSON.stringify(this.conversationList.map(item => item.id))
);
},
uniqueInboxes() {
return [...new Set(this.selectedInboxes)];
},
},
watch: {
activeTeam() {
@@ -376,6 +404,7 @@ export default {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload);
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
@@ -441,6 +470,7 @@ export default {
}
},
resetAndFetchData() {
this.resetBulkActions();
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.$store.dispatch('clearConversationFilters');
@@ -491,6 +521,7 @@ export default {
},
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
this.resetBulkActions();
bus.$emit('clearSearchInput');
this.activeAssigneeTab = selectedTab;
if (!this.currentPage) {
@@ -498,6 +529,10 @@ export default {
}
}
},
resetBulkActions() {
this.selectedConversations = [];
this.selectedInboxes = [];
},
updateStatusType(index) {
if (this.activeStatus !== index) {
this.activeStatus = index;
@@ -520,6 +555,59 @@ export default {
this.fetchConversations();
}
},
isConversationSelected(id) {
return this.selectedConversations.includes(id);
},
selectConversation(conversationId, inboxId) {
this.selectedConversations.push(conversationId);
this.selectedInboxes.push(inboxId);
},
deSelectConversation(conversationId, inboxId) {
this.selectedConversations = this.selectedConversations.filter(
item => item !== conversationId
);
this.selectedInboxes = this.selectedInboxes.filter(
item => item !== inboxId
);
},
selectAllConversations(check) {
if (check) {
this.selectedConversations = this.conversationList.map(item => item.id);
this.selectedInboxes = this.conversationList.map(item => item.inbox_id);
} else {
this.resetBulkActions();
}
},
async onAssignAgent(agent) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
fields: {
assignee_id: agent.id,
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async onResolveConversations() {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
fields: {
status: 'resolved',
},
});
this.selectedConversations = [];
this.showAlert(this.$t('BULK_ACTION.RESOLVE_SUCCESFUL'));
} catch (error) {
this.showAlert(this.$t('BULK_ACTION.RESOLVE_FAILED'));
}
},
},
};
</script>
@@ -535,7 +623,7 @@ export default {
.conversations-list-wrap {
flex-shrink: 0;
width: 34rem;
overflow: hidden;
@include breakpoint(large up) {
width: 36rem;
}

View File

@@ -99,7 +99,7 @@ export default {
watch: {
'currentChat.inbox_id'(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', { inboxId });
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
},
'currentChat.id'() {

View File

@@ -5,11 +5,24 @@
active: isActiveChat,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,
'conversation-selected': selected,
}"
@mouseenter="onCardHover"
@mouseleave="onCardLeave"
@click="cardClick(chat)"
>
<label v-if="hovered || selected" class="checkbox-wrapper">
<input
:value="selected"
:checked="selected"
class="checkbox"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
@click.stop
/>
</label>
<thumbnail
v-if="!hideThumbnail"
v-if="bulkActionCheck"
:src="currentContact.thumbnail"
:badge="inboxBadge"
class="columns"
@@ -142,8 +155,16 @@ export default {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
},
data() {
return {
hovered: false,
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
@@ -152,7 +173,9 @@ export default {
currentUser: 'getCurrentUser',
accountId: 'getCurrentAccountId',
}),
bulkActionCheck() {
return !this.hideThumbnail && !this.hovered && !this.selected;
},
chatMetadata() {
return this.chat.meta || {};
},
@@ -260,6 +283,16 @@ export default {
}
router.push({ path: frontendURL(path) });
},
onCardHover() {
this.hovered = !this.hideThumbnail;
},
onCardLeave() {
this.hovered = false;
},
onSelectConversation(checked) {
const action = checked ? 'select-conversation' : 'de-select-conversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
},
};
</script>
@@ -272,6 +305,10 @@ export default {
}
}
.conversation-selected {
background: var(--color-background-light);
}
.has-inbox-name {
&::v-deep .user-thumbnail-box {
margin-top: var(--space-normal);
@@ -320,4 +357,22 @@ export default {
margin-top: var(--space-minus-micro);
vertical-align: middle;
}
.checkbox-wrapper {
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
margin-top: var(--space-normal);
cursor: pointer;
&:hover {
background-color: var(--w-100);
}
input[type='checkbox'] {
margin: var(--space-zero);
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="bulk-action__container">
<div class="flex-between">
<label class="bulk-action__panel flex-between">
<input
ref="selectAllCheck"
type="checkbox"
class="checkbox"
:checked="allConversationsSelected"
@change="selectAll($event)"
/>
<span>
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="bulk-action__actions flex-between">
<woot-button
v-tooltip="$t('BULK_ACTION.RESOLVE_TOOLTIP')"
size="tiny"
variant="flat"
color-scheme="success"
icon="checkmark"
class="margin-right-smaller"
@click="resolveConversations"
/>
<woot-button
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
size="tiny"
variant="flat"
color-scheme="secondary"
icon="person-assign"
@click="showAgentsList = true"
/>
</div>
<transition name="menu-slide">
<agent-selector
v-if="showAgentsList"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
</div>
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
</div>
</template>
<script>
import AgentSelector from './AgentSelector.vue';
export default {
components: {
AgentSelector,
},
props: {
conversations: {
type: Array,
default: () => [],
},
allConversationsSelected: {
type: Boolean,
default: false,
},
selectedInboxes: {
type: Array,
default: () => [],
},
},
data() {
return {
showAgentsList: false,
};
},
mounted() {
this.$refs.selectAllCheck.indeterminate = true;
},
methods: {
selectAll(e) {
this.$emit('select-all-conversations', e.target.checked);
},
submit(agent) {
this.$emit('assign-agent', agent);
},
resolveConversations() {
this.$emit('resolve-conversations');
},
},
};
</script>
<style scoped lang="scss">
.flex-between {
align-items: center;
display: flex;
justify-content: space-between;
}
.bulk-action__container {
background-color: var(--s-50);
border-top: 1px solid var(--s-100);
box-shadow: var(--shadow-bulk-action-container);
padding: var(--space-normal) var(--space-one);
position: relative;
}
.bulk-action__panel {
cursor: pointer;
span {
font-size: var(--font-size-mini);
margin-left: var(--space-smaller);
}
input[type='checkbox'] {
cursor: pointer;
margin: var(--space-zero);
}
}
.bulk-action__alert {
background-color: var(--y-50);
border-radius: var(--border-radius-small);
border: 1px solid var(--y-300);
color: var(--y-700);
font-size: var(--font-size-mini);
margin-top: var(--space-small);
padding: var(--space-half) var(--space-one);
}
</style>

View File

@@ -0,0 +1,249 @@
<template>
<div class="bulk-action__agents">
<div class="header flex-between">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="dismiss"
@click="onClose"
/>
</div>
<div class="container">
<div v-if="uiFlags.isUpdating" class="agent__list-loading">
<spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
<div v-else class="agent__list-container">
<ul v-if="!selectedAgent">
<li class="search-container">
<div class="agent-list-search flex-between">
<fluent-icon icon="search" class="search-icon" size="16" />
<input
ref="search"
v-model="query"
type="search"
placeholder="Search"
class="agent--search_input"
/>
</div>
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<thumbnail
src="agent.thumbnail"
:username="agent.name"
size="22px"
class="margin-right-small"
/>
<span class="reports-option__title">{{ agent.name }}</span>
</div>
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p>
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
<strong>
{{ selectedAgent.name }}
</strong>
</p>
<div class="agent-confirmation-actions">
<woot-button
color-scheme="primary"
variant="smooth"
@click="goBack"
>
{{ $t('BULK_ACTION.GO_BACK_LABEL') }}
</woot-button>
<woot-button
color-scheme="primary"
variant="flat"
:is-loading="uiFlags.isUpdating"
@click="submit"
>
{{ $t('BULK_ACTION.ASSIGN_LABEL') }}
</woot-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Thumbnail,
Spinner,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
},
data() {
return {
query: '',
selectedAgent: null,
};
},
computed: {
...mapGetters({
uiFlags: 'bulkActions/getUIFlags',
inboxes: 'inboxes/getInboxes',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgents() {
if (this.query) {
return this.assignableAgents.filter(agent =>
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return this.assignableAgents;
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.selectedInboxes.join(',')
);
},
conversationLabel() {
return this.conversationCount > 1 ? 'conversations' : 'conversation';
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
},
methods: {
submit() {
this.$emit('select', this.selectedAgent);
},
goBack() {
this.selectedAgent = null;
},
assignAgent(agent) {
this.selectedAgent = agent;
},
onClose() {
this.$emit('close');
},
},
};
</script>
<style scoped lang="scss">
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.bulk-action__agents {
position: absolute;
bottom: 40px;
right: var(--space-small);
width: 100%;
box-shadow: var(--shadow-dropdown-pane);
border-radius: var(--border-radius-large);
border: 1px solid var(--s-50);
background-color: var(--white);
width: 75%;
.header {
padding: var(--space-one);
span {
font-size: var(--font-size-default);
font-weight: var(--font-weight-medium);
}
}
.container {
height: 240px;
overflow-y: auto;
.agent__list-container {
height: 100%;
}
.agent-list-search {
padding: 0 var(--space-one);
border: 1px solid var(--s-100);
border-radius: var(--border-radius-medium);
background-color: var(--s-50);
.search-icon {
color: var(--s-400);
}
.agent--search_input {
border: 0;
font-size: var(--font-size-mini);
margin: 0;
background-color: transparent;
height: unset;
}
}
}
}
ul {
margin: 0;
list-style: none;
}
.agent-list-item {
display: flex;
align-items: center;
padding: var(--space-one);
cursor: pointer;
&:hover {
background-color: var(--s-50);
}
span {
font-size: var(--font-size-small);
}
}
.agent-confirmation-container {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--space-one);
p {
flex-grow: 1;
}
.agent-confirmation-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-one);
}
}
.search-container {
padding: 0 var(--space-one);
position: sticky;
top: 0;
z-index: var(--z-index-twenty);
background-color: var(--white);
}
.agent__list-loading {
height: calc(95% - var(--space-one));
margin: var(--space-one);
border-radius: var(--border-radius-medium);
background-color: var(--s-50);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,17 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select Agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"RESOLVE_TOOLTIP": "Resolve",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",
"RESOLVE_SUCCESFUL": "Conversations resolved successfully",
"RESOLVE_FAILED": "Failed to resolve conversations, please try again",
"ALL_CONVERSATIONS_SELECTED_ALERT": "Conversations visible on this page are only selected.",
"AGENT_LIST_LOADING": "Loading Agents"
}
}

View File

@@ -21,6 +21,7 @@ import { default as _setNewPassword } from './setNewPassword.json';
import { default as _settings } from './settings.json';
import { default as _signup } from './signup.json';
import { default as _teamsSettings } from './teamsSettings.json';
import { default as _bulkActions } from './bulkActions.json';
export default {
..._advancedFilters,
@@ -46,4 +47,5 @@ export default {
..._settings,
..._signup,
..._teamsSettings,
..._bulkActions,
};

View File

@@ -6,6 +6,7 @@ import agents from './modules/agents';
import attributes from './modules/attributes';
import auth from './modules/auth';
import automations from './modules/automations';
import bulkActions from './modules/bulkActions';
import campaigns from './modules/campaigns';
import cannedResponse from './modules/cannedResponse';
import contactConversations from './modules/contactConversations';
@@ -43,6 +44,7 @@ export default new Vuex.Store({
attributes,
auth,
automations,
bulkActions,
campaigns,
cannedResponse,
contactConversations,

View File

@@ -0,0 +1,44 @@
import types from '../mutation-types';
import BulkActionsAPI from '../../api/bulkActions';
export const state = {
uiFlags: {
isUpdating: false,
},
};
export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
};
export const actions = {
process: async function processAction({ commit }, payload) {
commit(types.SET_BULK_ACTIONS_FLAG, { isUpdating: true });
try {
await BulkActionsAPI.create(payload);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_BULK_ACTIONS_FLAG, { isUpdating: false });
}
},
};
export const mutations = {
[types.SET_BULK_ACTIONS_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -26,13 +26,16 @@ export const getters = {
};
export const actions = {
async fetch({ commit }, { inboxId }) {
async fetch({ commit }, inboxIds) {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await AssignableAgentsAPI.get([inboxId]);
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, { inboxId, members: payload });
} = await AssignableAgentsAPI.get(inboxIds);
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
members: payload,
});
} catch (error) {
throw new Error(error);
} finally {

View File

@@ -0,0 +1,28 @@
import axios from 'axios';
import { actions } from '../../bulkActions';
import * as types from '../../../mutation-types';
import payload from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: payload });
await actions.process({ commit }, payload);
expect(commit.mock.calls).toEqual([
[types.default.SET_BULK_ACTIONS_FLAG, { isUpdating: true }],
[types.default.SET_BULK_ACTIONS_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.process({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_BULK_ACTIONS_FLAG, { isUpdating: true }],
[types.default.SET_BULK_ACTIONS_FLAG, { isUpdating: false }],
]);
});
});
});

View File

@@ -0,0 +1,5 @@
export default {
type: 'Conversation',
ids: [64, 39],
fields: { assignee_id: 6 },
};

View File

@@ -0,0 +1,14 @@
import { getters } from '../../bulkActions';
describe('#getters', () => {
it('getUIFlags', () => {
const state = {
uiFlags: {
isUpdating: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isUpdating: false,
});
});
});

View File

@@ -0,0 +1,12 @@
import types from '../../../mutation-types';
import { mutations } from '../../bulkActions';
describe('#mutations', () => {
describe('#toggleUiFlag', () => {
it('set update flags', () => {
const state = { uiFlags: { isUpdating: false } };
mutations[types.SET_BULK_ACTIONS_FLAG](state, { isUpdating: true });
expect(state.uiFlags.isUpdating).toEqual(true);
});
});
});

View File

@@ -12,12 +12,12 @@ describe('#actions', () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch({ commit }, { inboxId: 1 });
await actions.fetch({ commit }, [1]);
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[
types.SET_INBOX_ASSIGNABLE_AGENTS,
{ inboxId: 1, members: agentsData },
{ inboxId: '1', members: agentsData },
],
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false }],
]);

View File

@@ -211,6 +211,9 @@ export default {
ADD_CUSTOM_VIEW: 'ADD_CUSTOM_VIEW',
DELETE_CUSTOM_VIEW: 'DELETE_CUSTOM_VIEW',
// Bulk Actions
SET_BULK_ACTIONS_FLAG: 'SET_BULK_ACTIONS_FLAG',
// Dashboard Apps
SET_DASHBOARD_APPS_UI_FLAG: 'SET_DASHBOARD_APPS_UI_FLAG',
SET_DASHBOARD_APPS: 'SET_DASHBOARD_APPS',