Feature: Improve label experience (#975)

Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
Pranav Raj S 2020-06-25 21:04:03 +05:30 committed by GitHub
parent 8b61452d56
commit 97ad39713b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1712 additions and 284 deletions

View file

@ -6,13 +6,14 @@ class ConversationApi extends ApiClient {
super('conversations', { accountScoped: true });
}
get({ inboxId, status, assigneeType, page }) {
get({ inboxId, status, assigneeType, page, labels }) {
return axios.get(this.url, {
params: {
inbox_id: inboxId,
status,
assignee_type: assigneeType,
page,
labels,
},
});
}
@ -44,12 +45,13 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${conversationId}/mute`);
}
meta({ inboxId, status, assigneeType }) {
meta({ inboxId, status, assigneeType, labels }) {
return axios.get(`${this.url}/meta`, {
params: {
inbox_id: inboxId,
status,
assignee_type: assigneeType,
labels,
},
});
}

View file

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

View file

@ -0,0 +1,14 @@
import labels from '../labels';
import ApiClient from '../ApiClient';
describe('#LabelsAPI', () => {
it('creates correct instance', () => {
expect(labels).toBeInstanceOf(ApiClient);
expect(labels).toHaveProperty('get');
expect(labels).toHaveProperty('show');
expect(labels).toHaveProperty('create');
expect(labels).toHaveProperty('update');
expect(labels).toHaveProperty('delete');
expect(labels.url).toBe('/api/v1/labels');
});
});

View file

@ -1,6 +1,6 @@
.button {
font-weight: $font-weight-medium;
font-family: $body-font-family;
font-weight: $font-weight-medium;
&.round {
border-radius: 1000px;
@ -20,10 +20,11 @@
}
.tooltip {
max-width: 15rem;
padding: $space-smaller $space-small;
border-radius: $space-smaller;
font-size: $font-size-mini;
max-width: 15rem;
padding: $space-smaller $space-small;
z-index: 9999;
}
code {

View file

@ -382,7 +382,7 @@ $label-color: $primary-color;
$label-color-alt: $black;
$label-palette: $foundation-palette;
$label-font-size: $font-size-micro;
$label-padding: $space-micro $space-smaller;
$label-padding: $space-smaller $space-small;
$label-radius: $space-micro;
// 21. Media Object

View file

@ -67,6 +67,10 @@
font-size: $font-size-small;
}
.content {
@include padding($space-large);
}
form {
@include padding($space-large);
align-self: center;

View file

@ -3,7 +3,7 @@
<div class="chat-list__top">
<h1 class="page-title">
<woot-sidemenu-icon />
{{ inbox.name || $t('CHAT_LIST.TAB_HEADING') }}
{{ pageTitle }}
</h1>
<chat-filter @statusFilterChange="updateStatusType" />
</div>
@ -15,14 +15,15 @@
@chatTabChange="updateAssigneeTab"
/>
<p v-if="!chatListLoading && !getChatsForTab().length" class="content-box">
<p v-if="!chatListLoading && !conversationList.length" class="content-box">
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<div class="conversations-list">
<conversation-card
v-for="chat in getChatsForTab()"
v-for="chat in conversationList"
:key="chat.id"
:active-label="label"
:chat="chat"
/>
@ -40,7 +41,7 @@
<p
v-if="
getChatsForTab().length &&
conversationList.length &&
hasCurrentPageEndReached &&
!chatListLoading
"
@ -72,7 +73,16 @@ export default {
ChatFilter,
},
mixins: [timeMixin, conversationMixin],
props: ['conversationInbox'],
props: {
conversationInbox: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
},
data() {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
@ -119,18 +129,51 @@ export default {
assigneeType: this.activeAssigneeTab,
status: this.activeStatus,
page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined,
};
},
pageTitle() {
if (this.inbox.name) {
return this.inbox.name;
}
if (this.label) {
return `#${this.label}`;
}
return this.$t('CHAT_LIST.TAB_HEADING');
},
conversationList() {
let conversationList = [];
if (this.activeAssigneeTab === 'me') {
conversationList = this.mineChatsList.slice();
} else if (this.activeAssigneeTab === 'unassigned') {
conversationList = this.unAssignedChatsList.slice();
} else {
conversationList = this.allChatList.slice();
}
if (!this.label) {
return conversationList;
}
return conversationList.filter(conversation => {
const labels = this.$store.getters[
'conversationLabels/getConversationLabels'
](conversation.id);
return labels.includes(this.label);
});
},
},
watch: {
conversationInbox() {
this.resetAndFetchData();
},
label() {
this.resetAndFetchData();
},
},
mounted() {
this.$store.dispatch('setChatFilter', this.activeStatus);
this.resetAndFetchData();
this.$store.dispatch('agents/get');
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
@ -159,17 +202,6 @@ export default {
this.resetAndFetchData();
}
},
getChatsForTab() {
let copyList = [];
if (this.activeAssigneeTab === 'me') {
copyList = this.mineChatsList.slice();
} else if (this.activeAssigneeTab === 'unassigned') {
copyList = this.unAssignedChatsList.slice();
} else {
copyList = this.allChatList.slice();
}
return copyList;
},
},
};
</script>

View file

@ -6,6 +6,7 @@ import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import Input from './widgets/forms/Input.vue';
import Label from './widgets/Label.vue';
import LoadingState from './widgets/LoadingState';
import Modal from './Modal';
import ModalHeader from './ModalHeader';
@ -25,6 +26,7 @@ const WootUIKit = {
DeleteModal,
Input,
LoadingState,
Label,
Modal,
ModalHeader,
ReportStatsCard,

View file

@ -18,6 +18,11 @@
:key="inboxSection.toState"
:menu-item="inboxSection"
/>
<sidebar-item
v-if="shouldShowInboxes"
:key="labelSection.toState"
:menu-item="labelSection"
/>
</transition-group>
</div>
@ -125,6 +130,7 @@ export default {
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
accountLabels: 'labels/getLabelsOnSidebar',
}),
sidemenuItems() {
return getSidebarItems(this.accountId);
@ -170,6 +176,25 @@ export default {
})),
};
},
labelSection() {
return {
icon: 'ion-pound',
label: 'LABELS',
hasSubMenu: true,
key: 'label',
cssClass: 'menu-title align-justify',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
children: this.accountLabels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
toState: frontendURL(
`accounts/${this.accountId}/label/${label.title}`
),
})),
};
},
dashboardPath() {
return frontendURL(`accounts/${this.accountId}/dashboard`);
},

View file

@ -36,7 +36,13 @@
v-if="computedInboxClass(child)"
class="inbox-icon"
:class="computedInboxClass(child)"
></i>
/>
<span
v-if="child.color"
class="label-color--display"
:style="{ backgroundColor: child.color }"
/>
{{ child.label }}
</div>
</a>
@ -126,8 +132,22 @@ export default {
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables';
.sub-menu-title {
display: flex;
justify-content: space-between;
}
.wrap {
display: flex;
align-items: center;
}
.label-color--display {
border-radius: $space-smaller;
height: $space-normal;
margin-right: $space-small;
width: $space-normal;
}
</style>

View file

@ -0,0 +1,91 @@
<template>
<div
:class="labelClass"
:style="{ background: bgColor, color: textColor }"
:title="description"
>
<span v-if="!href">{{ title }}</span>
<a v-else :href="href" :style="{ color: textColor }">{{ title }}</a>
<i v-if="showIcon" class="label--icon" :class="icon" @click="onClick" />
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
href: {
type: String,
default: '',
},
bgColor: {
type: String,
default: '#1f93ff',
},
small: {
type: Boolean,
default: false,
},
showIcon: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: 'ion-close',
},
},
computed: {
textColor() {
const color = this.bgColor.replace('#', '');
const r = parseInt(color.slice(0, 2), 16);
const g = parseInt(color.slice(2, 4), 16);
const b = parseInt(color.slice(4, 6), 16);
// http://stackoverflow.com/a/3943023/112731
return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#FFFFFF';
},
labelClass() {
return `label ${this.small ? 'small' : ''}`;
},
},
methods: {
onClick() {
this.$emit('click', this.title);
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.label {
display: inline-block;
font-size: $font-size-small;
line-height: 1;
margin: $space-micro;
&.small {
font-size: $font-size-mini;
}
a {
&:hover {
text-decoration: underline;
}
}
}
.label--icon {
cursor: pointer;
font-size: $font-size-micro;
line-height: 1.5;
margin-left: $space-smaller;
}
</style>

View file

@ -57,6 +57,10 @@ export default {
mixins: [timeMixin, conversationMixin],
props: {
activeLabel: {
type: String,
default: '',
},
chat: {
type: Object,
default: () => {},
@ -116,7 +120,12 @@ export default {
methods: {
cardClick(chat) {
const { activeInbox } = this;
const path = conversationUrl(this.accountId, activeInbox, chat.id);
const path = conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
});
router.push({ path: frontendURL(path) });
},
inboxName(inboxId) {

View file

@ -5,11 +5,14 @@ export const frontendURL = (path, params) => {
return `/app/${path}${stringifiedParams}`;
};
export const conversationUrl = (accountId, activeInbox, id) => {
const path = activeInbox
? `accounts/${accountId}/inbox/${activeInbox}/conversations/${id}`
: `accounts/${accountId}/conversations/${id}`;
return path;
export const conversationUrl = ({ accountId, activeInbox, id, label }) => {
if (activeInbox) {
return `accounts/${accountId}/inbox/${activeInbox}/conversations/${id}`;
}
if (label) {
return `accounts/${accountId}/label/${label}/conversations/${id}`;
}
return `accounts/${accountId}/conversations/${id}`;
};
export const accountIdFromPathname = pathname => {

View file

@ -7,15 +7,20 @@ import {
describe('#URL Helpers', () => {
describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => {
expect(conversationUrl(1, undefined, 1)).toBe(
expect(conversationUrl({ accountId: 1, id: 1 })).toBe(
'accounts/1/conversations/1'
);
});
it('should return ibox conversation URL if activeInbox is not nil', () => {
expect(conversationUrl(1, 2, 1)).toBe(
it('should return inbox conversation URL if activeInbox is not nil', () => {
expect(conversationUrl({ accountId: 1, id: 1, activeInbox: 2 })).toBe(
'accounts/1/inbox/2/conversations/1'
);
});
it('should return correct conversation URL if label is active', () => {
expect(
conversationUrl({ accountId: 1, label: 'customer-support', id: 1 })
).toBe('accounts/1/label/customer-support/conversations/1');
});
});
describe('frontendURL', () => {
@ -27,16 +32,6 @@ describe('#URL Helpers', () => {
});
});
/*
export const accountIdFromPathname = pathname => {
const isInsideAccountScopedURLs = pathname.includes('/app/accounts');
const accountId = isInsideAccountScopedURLs ? pathname.split('/')[3] : '';
return Number(accountId);
};
*/
describe('accountIdFromPathname', () => {
it('should return account id if accont scoped url is passed', () => {
expect(accountIdFromPathname('/app/accounts/1/settings/general')).toBe(1);

View file

@ -10,6 +10,8 @@ export const getSidebarItems = accountId => ({
'settings_account_reports',
'profile_settings',
'profile_settings_index',
'label_conversations',
'conversations_through_label',
],
menuItems: {
assignedToMe: {
@ -40,9 +42,8 @@ export const getSidebarItems = accountId => ({
settings: {
routes: [
'agent_list',
'agent_new',
'canned_list',
'canned_new',
'labels_list',
'settings_inbox',
'settings_inbox_new',
'settings_inbox_list',
@ -78,6 +79,13 @@ export const getSidebarItems = accountId => ({
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
},
labels: {
icon: 'ion-pricetags',
label: 'LABELS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
},
cannedResponses: {
icon: 'ion-chatbox-working',
label: 'CANNED_RESPONSES',

View file

@ -11,11 +11,19 @@
},
"LABELS": {
"TITLE": "Conversation Labels",
"UPDATE_BUTTON": "Update Labels",
"UPDATE_ERROR": "Couldn't update labels, try again.",
"TAG_PLACEHOLDER": "Add new label",
"PLACEHOLDER": "Search or add a label"
"MODAL": {
"TITLE": "Labels for",
"ACTIVE_LABELS": "Labels added to the conversation",
"INACTIVE_LABELS": "Labels available in the account",
"REMOVE": "Click on X icon to remove the label",
"ADD": "Click on + icon to add the label",
"UPDATE_BUTTON": "Update labels",
"UPDATE_ERROR": "Couldn't update labels, try again."
},
"MUTE_CONTACT": "Mute Contact"
"NO_LABELS_TO_ADD": "There are no more labels defined in the account.",
"NO_AVAILABLE_LABELS": "There are no labels added to this conversation."
},
"MUTE_CONTACT": "Mute Contact",
"EDIT_LABEL": "Edit"
}
}

View file

@ -1,5 +1,5 @@
/* eslint-disable */
import { default as _agentMgmt } from './agentMgmt.json';
import { default as _labelsMgmt } from './labelsMgmt.json';
import { default as _cannedMgmt } from './cannedMgmt.json';
import { default as _chatlist } from './chatlist.json';
import { default as _contact } from './contact.json';
@ -23,6 +23,7 @@ export default {
..._inboxMgmt,
..._login,
..._report,
..._labelsMgmt,
..._resetPassword,
..._setNewPassword,
..._settings,

View file

@ -0,0 +1,68 @@
{
"LABEL_MGMT": {
"HEADER": "Labels",
"HEADER_BTN_TXT": "Add label",
"LOADING": "Fetching labels",
"SEARCH_404": "There are no items matching this query",
"SIDEBAR_TXT": "<p><b>Labels</b> <p>Labels help you to categorize conversations and prioritize them. You can assign label to a conversation from the sidepanel. <br /><br />Labels are tied to the account and can be used to create custom workflows in your organization. You can assign custom color to a label, it makes it easier to identify the label. You will be able to display the label on the sidebar to filter the conversations easily.</p>",
"LIST": {
"404": "There are no labels available in this account.",
"TITLE": "Manage labels",
"DESC": "Labels let you group the conversations together.",
"TABLE_HEADER": [
"Name",
"Description",
"Color"
]
},
"FORM": {
"NAME": {
"LABEL": "Label Name",
"PLACEHOLDER": "Label name",
"ERROR": "Label Name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Label Description"
},
"COLOR": {
"LABEL": "Color"
},
"SHOW_ON_SIDEBAR": {
"LABEL": "Show label on sidebar"
},
"EDIT": "Edit",
"CREATE": "Create",
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"ADD": {
"TITLE": "Add label",
"DESC": "Labels let you group the conversations together.",
"API": {
"SUCCESS_MESSAGE": "Label added successfully",
"ERROR_MESSAGE": "There was an error, please try again"
}
},
"EDIT": {
"TITLE": "Edit label",
"API": {
"SUCCESS_MESSAGE": "Label updated successfully",
"ERROR_MESSAGE": "There was an error, please try again"
}
},
"DELETE": {
"BUTTON_TEXT": "Delete",
"API": {
"SUCCESS_MESSAGE": "Label deleted successfully",
"ERROR_MESSAGE": "There was an error, please try again"
},
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete ",
"YES": "Yes, Delete ",
"NO": "No, Keep "
}
}
}
}

View file

@ -103,6 +103,7 @@
"INBOXES": "Inboxes",
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",
"ACCOUNT_SETTINGS": "Account Settings"
"ACCOUNT_SETTINGS": "Account Settings",
"LABELS": "Labels"
}
}

View file

@ -1,8 +1,13 @@
<template>
<div class="conv-details--item">
<h4 class="conv-details--item__label">
<div>
<i v-if="icon" :class="icon" class="conv-details--item__icon"></i>
{{ title }}
</div>
<button v-if="showEdit" @click="onEdit">
{{ $t('CONTACT_PANEL.EDIT_LABEL') }}
</button>
</h4>
<div v-if="value" class="conv-details--item__value">
{{ value }}
@ -16,6 +21,12 @@ export default {
title: { type: String, required: true },
icon: { type: String, default: '' },
value: { type: [String, Number], default: '' },
showEdit: { type: Boolean, default: false },
},
methods: {
onEdit() {
this.$emit('edit');
},
},
};
</script>
@ -31,14 +42,18 @@ export default {
padding-bottom: 0;
}
.conv-details--item__icon {
padding-right: $space-smaller;
}
.conv-details--item__label {
font-weight: $font-weight-medium;
margin-bottom: $space-micro;
align-items: center;
display: flex;
font-size: $font-size-small;
font-weight: $font-weight-medium;
justify-content: space-between;
margin-bottom: $space-micro;
button {
cursor: pointer;
color: $color-body;
}
}
.conv-details--item__value {

View file

@ -2,7 +2,7 @@
<div class="medium-3 bg-white contact--panel">
<div class="contact--profile">
<span class="close-button" @click="onPanelToggle">
<i class="ion-close-round"></i>
<i class="ion-chevron-right" />
</span>
<div class="contact--info">
<thumbnail
@ -107,7 +107,7 @@ import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import ContactConversations from './ContactConversations.vue';
import ContactDetailsItem from './ContactDetailsItem.vue';
import ConversationLabels from './ConversationLabels.vue';
import ConversationLabels from './labels/LabelBox.vue';
export default {
components: {
@ -168,12 +168,15 @@ export default {
watch: {
conversationId(newConversationId, prevConversationId) {
if (newConversationId && newConversationId !== prevConversationId) {
this.$store.dispatch('contacts/show', { id: this.contactId });
this.getContactDetails();
}
},
contactId() {
this.getContactDetails();
},
},
mounted() {
this.$store.dispatch('contacts/show', { id: this.contactId });
this.getContactDetails();
},
methods: {
onPanelToggle() {
@ -182,6 +185,11 @@ export default {
mute() {
this.$store.dispatch('muteConversation', this.conversationId);
},
getContactDetails() {
if (this.contactId) {
this.$store.dispatch('contacts/show', { id: this.contactId });
}
},
},
};
</script>

View file

@ -1,173 +0,0 @@
<template>
<div
class="contact-conversation--panel sidebar-labels-wrap"
:class="hasEditedClass"
>
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
>
<label class="select-tags">
<contact-details-item
:title="$t('CONTACT_PANEL.LABELS.TITLE')"
icon="ion-pricetags"
/>
<multiselect
v-model="selectedLabels"
:options="savedLabels"
:tag-placeholder="$t('CONTACT_PANEL.LABELS.TAG_PLACEHOLDER')"
:placeholder="$t('CONTACT_PANEL.LABELS.PLACEHOLDER')"
:multiple="true"
:taggable="true"
hide-selected
:show-labels="false"
@tag="addLabel"
/>
</label>
<div class="row align-middle align-justify">
<span v-if="labelUiFlags.isError" class="error">{{
$t('CONTACT_PANEL.LABELS.UPDATE_ERROR')
}}</span>
<button
v-if="hasEdited"
type="button"
class="button nice tiny"
@click="onUpdateLabels"
>
<spinner v-if="labelUiFlags.isUpdating" size="tiny" />
{{
labelUiFlags.isUpdating
? 'saving...'
: $t('CONTACT_PANEL.LABELS.UPDATE_BUTTON')
}}
</button>
</div>
</div>
<spinner v-else></spinner>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import ContactDetailsItem from './ContactDetailsItem';
import Spinner from 'shared/components/Spinner';
export default {
components: {
ContactDetailsItem,
Spinner,
},
props: {
conversationId: {
type: [String, Number],
required: true,
},
},
data() {
return {
isSearching: false,
selectedLabels: [],
};
},
computed: {
hasEdited() {
if (this.selectedLabels.length !== this.savedLabels.length) {
return true;
}
const isSame = this.selectedLabels.every(label =>
this.savedLabels.includes(label)
);
return !isSame;
},
savedLabels() {
const saved = this.$store.getters[
'conversationLabels/getConversationLabels'
](this.conversationId);
return saved;
},
hasEditedClass() {
return this.hasEdited ? 'has-edited' : '';
},
...mapGetters({
conversationUiFlags: 'contactConversations/getUIFlags',
labelUiFlags: 'conversationLabels/getUIFlags',
}),
},
watch: {
conversationId(newConversationId, prevConversationId) {
if (newConversationId && newConversationId !== prevConversationId) {
this.fetchLabels(newConversationId);
}
},
},
mounted() {
const { conversationId } = this;
this.fetchLabels(conversationId);
},
methods: {
addLabel(label) {
this.selectedLabels = [...this.selectedLabels, label];
},
onUpdateLabels() {
this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: this.selectedLabels,
});
},
async fetchLabels(conversationId) {
try {
await this.$store.dispatch('conversationLabels/get', conversationId);
this.selectedLabels = [...this.savedLabels];
// eslint-disable-next-line no-empty
} catch (error) {}
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables';
@import '~dashboard/assets/scss/mixins';
.contact-conversation--panel {
padding: $space-normal;
}
.conversation--label {
color: $color-white;
margin-right: $space-small;
font-size: $font-size-small;
padding: $space-smaller;
}
.select-tags {
.multiselect {
&:hover {
cursor: pointer;
}
transition: $transition-ease-in;
margin-bottom: 0;
}
}
.button {
margin-top: $space-small;
margin-left: auto;
}
.no-results-wrap {
padding: 0 $space-small;
}
.no-results {
margin: $space-normal 0 0 0;
color: $color-gray;
font-weight: $font-weight-normal;
}
.error {
color: $alert-color;
font-size: $font-size-mini;
font-weight: $font-weight-medium;
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<section class="app-content columns">
<chat-list :conversation-inbox="inboxId"></chat-list>
<chat-list :conversation-inbox="inboxId" :label="label"></chat-list>
<conversation-box
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
@ -30,19 +30,34 @@ export default {
ContactPanel,
ConversationBox,
},
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
},
data() {
return {
panelToggleState: false,
panelToggleState: true,
};
},
computed: {
...mapGetters({
chatList: 'getAllConversations',
currentChat: 'getSelectedChat',
}),
isContactPanelOpen: {
get() {
if (this.conversationId) {
if (this.currentChat.id) {
return this.panelToggleState;
}
return false;
@ -52,9 +67,11 @@ export default {
},
},
},
props: ['inboxId', 'conversationId'],
mounted() {
this.$store.dispatch('labels/get');
this.$store.dispatch('agents/get');
this.initialize();
this.$watch('$store.state.route', () => this.initialize());
this.$watch('chatList.length', () => {
@ -65,26 +82,8 @@ export default {
methods: {
initialize() {
switch (this.$store.state.route.name) {
case 'inbox_conversation':
this.setActiveChat();
break;
case 'inbox_dashboard':
if (this.inboxId) {
this.$store.dispatch('setActiveInbox', this.inboxId);
}
break;
case 'conversation_through_inbox':
if (this.inboxId) {
this.$store.dispatch('setActiveInbox', this.inboxId);
}
this.setActiveChat();
break;
default:
this.$store.dispatch('setActiveInbox', null);
this.$store.dispatch('clearSelectedState');
break;
}
},
fetchConversation() {
@ -103,11 +102,17 @@ export default {
},
setActiveChat() {
if (this.conversationId) {
const chat = this.findConversation();
if (!chat) return;
if (!chat) {
return;
}
this.$store.dispatch('setActiveChat', chat).then(() => {
bus.$emit('scrollToMessage');
});
} else {
this.$store.dispatch('clearSelectedState');
}
},
onToggleContactPanel() {
this.isContactPanelOpen = !this.isContactPanelOpen;

View file

@ -45,5 +45,24 @@ export default {
};
},
},
{
path: frontendURL('accounts/:accountId/label/:label'),
name: 'label_conversations',
roles: ['administrator', 'agent'],
component: ConversationView,
props: route => ({ label: route.params.label }),
},
{
path: frontendURL(
'accounts/:accountId/label/:label/conversations/:conversation_id'
),
name: 'conversations_through_label',
roles: ['administrator', 'agent'],
component: ConversationView,
props: route => ({
conversationId: route.params.conversation_id,
label: route.params.label,
}),
},
],
};

View file

@ -0,0 +1,133 @@
<template>
<woot-modal :show.sync="show" :on-close="onClose">
<div class="column content-box">
<woot-modal-header
:header-title="
$t('CONTACT_PANEL.LABELS.MODAL.TITLE') + ' #' + conversationId
"
/>
<div class="content">
<div class="label-content--block">
<div class="label-content--title">
{{ $t('CONTACT_PANEL.LABELS.MODAL.ACTIVE_LABELS') }}
<span v-tooltip.bottom="$t('CONTACT_PANEL.LABELS.MODAL.REMOVE')">
<i class="ion-ios-help-outline" />
</span>
</div>
<div v-if="activeList.length">
<woot-label
v-for="label in activeList"
:key="label.id"
:title="label.title"
:description="label.description"
:bg-color="label.color"
:show-icon="true"
@click="onRemove"
/>
</div>
<p v-else>
{{ $t('CONTACT_PANEL.LABELS.NO_AVAILABLE_LABELS') }}
</p>
</div>
<div class="label-content--block">
<div class="label-content--title">
{{ $t('CONTACT_PANEL.LABELS.MODAL.INACTIVE_LABELS') }}
<span v-tooltip.bottom="$t('CONTACT_PANEL.LABELS.MODAL.ADD')">
<i class="ion-ios-help-outline" />
</span>
</div>
<div v-if="inactiveList.length">
<woot-label
v-for="label in inactiveList"
:key="label.id"
:title="label.title"
:description="label.description"
:bg-color="label.color"
:show-icon="true"
icon="ion-plus"
@click="onAdd"
/>
</div>
<p v-else>
{{ $t('CONTACT_PANEL.LABELS.NO_LABELS_TO_ADD') }}
</p>
</div>
</div>
</div>
</woot-modal>
</template>
<script>
export default {
props: {
show: {
type: Boolean,
default: false,
},
conversationId: {
type: [String, Number],
default: '',
},
accountLabels: {
type: Array,
default: () => [],
},
savedLabels: {
type: Array,
default: () => [],
},
onClose: {
type: Function,
default: () => [],
},
updateLabels: {
type: Function,
default: () => {},
},
},
computed: {
activeList() {
return this.accountLabels.filter(accountLabel =>
this.savedLabels.includes(accountLabel.title)
);
},
inactiveList() {
return this.accountLabels.filter(
accountLabel => !this.savedLabels.includes(accountLabel.title)
);
},
},
methods: {
onAdd(label) {
const activeLabels = this.activeList.map(
activeLabel => activeLabel.title
);
this.updateLabels([...activeLabels, label]);
},
onRemove(label) {
const activeLabels = this.activeList
.filter(activeLabel => activeLabel.title !== label)
.map(activeLabel => activeLabel.title);
this.updateLabels(activeLabels);
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.label-content--block {
margin-bottom: $space-normal;
}
.label-content--title {
font-weight: $font-weight-bold;
margin-bottom: $space-small;
}
.content {
padding-top: $space-normal;
}
</style>

View file

@ -0,0 +1,160 @@
<template>
<div class="contact-conversation--panel sidebar-labels-wrap">
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
>
<contact-details-item
:title="$t('CONTACT_PANEL.LABELS.TITLE')"
icon="ion-pricetags"
:show-edit="true"
@edit="onEdit"
/>
<woot-label
v-for="label in activeLabels"
:key="label.id"
:title="label.title"
:description="label.description"
:bg-color="label.color"
/>
<div v-if="!activeLabels.length">
{{ $t('CONTACT_PANEL.LABELS.NO_AVAILABLE_LABELS') }}
</div>
<add-label-to-conversation
v-if="isEditing"
:conversation-id="conversationId"
:account-labels="accountLabels"
:saved-labels="savedLabels"
:show.sync="isEditing"
:on-close="closeEditModal"
:update-labels="onUpdateLabels"
/>
</div>
<spinner v-else></spinner>
</div>
</template>
<script>
/* global bus */
import { mapGetters } from 'vuex';
import AddLabelToConversation from './AddLabelToConversation';
import ContactDetailsItem from '../ContactDetailsItem';
import Spinner from 'shared/components/Spinner';
export default {
components: {
AddLabelToConversation,
ContactDetailsItem,
Spinner,
},
props: {
conversationId: {
type: [String, Number],
required: true,
},
},
data() {
return {
isEditing: false,
selectedLabels: [],
};
},
computed: {
savedLabels() {
return this.$store.getters['conversationLabels/getConversationLabels'](
this.conversationId
);
},
...mapGetters({
conversationUiFlags: 'contactConversations/getUIFlags',
labelUiFlags: 'conversationLabels/getUIFlags',
accountLabels: 'labels/getLabels',
}),
activeLabels() {
return this.accountLabels.filter(({ title }) =>
this.savedLabels.includes(title)
);
},
},
watch: {
conversationId(newConversationId, prevConversationId) {
if (newConversationId && newConversationId !== prevConversationId) {
this.fetchLabels(newConversationId);
}
},
},
mounted() {
const { conversationId } = this;
this.fetchLabels(conversationId);
},
methods: {
async onUpdateLabels(selectedLabels) {
try {
await this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: selectedLabels,
});
} catch (error) {
// Ignore error
}
},
onEdit() {
this.isEditing = true;
},
closeEditModal() {
bus.$emit('fetch_conversation_stats');
this.isEditing = false;
},
async fetchLabels(conversationId) {
this.$store.dispatch('conversationLabels/get', conversationId);
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/variables';
@import '~dashboard/assets/scss/mixins';
.contact-conversation--panel {
padding: $space-normal;
}
.conversation--label {
color: $color-white;
margin-right: $space-small;
font-size: $font-size-small;
padding: $space-smaller;
}
.select-tags {
.multiselect {
&:hover {
cursor: pointer;
}
transition: $transition-ease-in;
margin-bottom: 0;
}
}
.button {
margin-top: $space-small;
margin-left: auto;
}
.no-results-wrap {
padding: 0 $space-small;
}
.no-results {
margin: $space-normal 0 0 0;
color: $color-gray;
font-weight: $font-weight-normal;
}
.error {
color: $alert-color;
font-size: $font-size-mini;
font-weight: $font-weight-medium;
}
</style>

View file

@ -0,0 +1,123 @@
<template>
<modal :show.sync="show" :on-close="onClose">
<div class="column content-box">
<woot-modal-header
:header-title="$t('LABEL_MGMT.ADD.TITLE')"
:header-content="$t('LABEL_MGMT.ADD.DESC')"
/>
<form class="row" @submit.prevent="addLabel">
<woot-input
v-model.trim="title"
:class="{ error: $v.title.$error }"
class="medium-12 columns"
:label="$t('LABEL_MGMT.FORM.NAME.LABEL')"
:placeholder="$t('LABEL_MGMT.FORM.NAME.PLACEHOLDER')"
@input="$v.title.$touch"
/>
<woot-input
v-model.trim="description"
:class="{ error: $v.description.$error }"
class="medium-12 columns"
:label="$t('LABEL_MGMT.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('LABEL_MGMT.FORM.DESCRIPTION.PLACEHOLDER')"
@input="$v.description.$touch"
/>
<div class="medium-12">
<label>
{{ $t('LABEL_MGMT.FORM.COLOR.LABEL') }}
<woot-color-picker v-model="color" />
</label>
</div>
<div class="medium-12">
<input v-model="showOnSidebar" type="checkbox" :value="true" />
<label for="conversation_creation">
{{ $t('LABEL_MGMT.FORM.SHOW_ON_SIDEBAR.LABEL') }}
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-submit-button
:disabled="$v.title.$invalid || uiFlags.isCreating"
:button-text="$t('LABEL_MGMT.FORM.CREATE')"
:loading="uiFlags.isCreating"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('LABEL_MGMT.FORM.CANCEL') }}
</button>
</div>
</div>
</form>
</div>
</modal>
</template>
<script>
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton';
import Modal from '../../../../components/Modal';
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
import validations from './validations';
export default {
components: {
WootSubmitButton,
Modal,
},
mixins: [alertMixin],
props: {
onClose: {
type: Function,
default: () => {},
},
show: {
type: Boolean,
default: true,
},
},
data() {
return {
color: '#000',
description: '',
title: '',
showOnSidebar: true,
};
},
validations,
computed: {
...mapGetters({
uiFlags: 'labels/getUIFlags',
}),
},
mounted() {
this.color = this.getRandomColor();
},
methods: {
getRandomColor() {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i += 1) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
},
addLabel() {
this.$store
.dispatch('labels/create', {
color: this.color,
description: this.description,
title: this.title,
show_on_sidebar: this.showOnSidebar,
})
.then(() => {
this.showAlert(this.$t('LABEL_MGMT.ADD.API.SUCCESS_MESSAGE'));
this.onClose();
})
.catch(() => {
this.showAlert(this.$t('LABEL_MGMT.ADD.API.ERROR_MESSAGE'));
});
},
},
};
</script>

View file

@ -0,0 +1,129 @@
<template>
<modal :show.sync="show" :on-close="onClose">
<div class="column content-box">
<woot-modal-header :header-title="pageTitle" />
<form class="row" @submit.prevent="editLabel">
<woot-input
v-model.trim="title"
:class="{ error: $v.title.$error }"
class="medium-12 columns"
:label="$t('LABEL_MGMT.FORM.NAME.LABEL')"
:placeholder="$t('LABEL_MGMT.FORM.NAME.PLACEHOLDER')"
@input="$v.title.$touch"
/>
<woot-input
v-model.trim="description"
:class="{ error: $v.description.$error }"
class="medium-12 columns"
:label="$t('LABEL_MGMT.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('LABEL_MGMT.FORM.DESCRIPTION.PLACEHOLDER')"
@input="$v.description.$touch"
/>
<div class="medium-12">
<label>
{{ $t('LABEL_MGMT.FORM.COLOR.LABEL') }}
<woot-color-picker v-model="color" />
</label>
</div>
<div class="medium-12">
<input v-model="showOnSidebar" type="checkbox" :value="true" />
<label for="conversation_creation">
{{ $t('LABEL_MGMT.FORM.SHOW_ON_SIDEBAR.LABEL') }}
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-submit-button
:disabled="$v.title.$invalid || uiFlags.isUpdating"
:button-text="$t('LABEL_MGMT.FORM.EDIT')"
:loading="uiFlags.isUpdating"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('LABEL_MGMT.FORM.CANCEL') }}
</button>
</div>
</div>
</form>
</div>
</modal>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton';
import Modal from '../../../../components/Modal';
import validations from './validations';
export default {
components: {
WootSubmitButton,
Modal,
},
mixins: [alertMixin],
props: {
show: {
type: Boolean,
default: () => {},
},
selectedResponse: {
type: Object,
default: () => {},
},
onClose: {
type: Function,
default: () => {},
},
},
data() {
return {
title: '',
description: '',
showOnSidebar: true,
color: '',
};
},
validations,
computed: {
...mapGetters({
uiFlags: 'labels/getUIFlags',
}),
pageTitle() {
return `${this.$t('LABEL_MGMT.EDIT.TITLE')} - ${
this.selectedResponse.title
}`;
},
},
mounted() {
this.setFormValues();
},
methods: {
setFormValues() {
this.title = this.selectedResponse.title;
this.description = this.selectedResponse.description;
this.showOnSidebar = this.selectedResponse.show_on_sidebar;
this.color = this.selectedResponse.color;
},
editLabel() {
this.$store
.dispatch('labels/update', {
id: this.selectedResponse.id,
color: this.color,
description: this.description,
title: this.title,
show_on_sidebar: this.showOnSidebar,
})
.then(() => {
this.showAlert(this.$t('LABEL_MGMT.EDIT.API.SUCCESS_MESSAGE'));
setTimeout(() => this.onClose(), 10);
})
.catch(() => {
this.showAlert(this.$t('LABEL_MGMT.EDIT.API.ERROR_MESSAGE'));
});
},
},
};
</script>

View file

@ -0,0 +1,201 @@
<template>
<div class="column content-box">
<button
class="button nice icon success button--fixed-right-top"
@click="openAddPopup"
>
<i class="icon ion-android-add-circle"></i>
{{ $t('LABEL_MGMT.HEADER_BTN_TXT') }}
</button>
<div class="row">
<div class="small-8 columns">
<p
v-if="!uiFlags.isFetching && !records.length"
class="no-items-error-message"
>
{{ $t('LABEL_MGMT.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('LABEL_MGMT.LOADING')"
/>
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
<thead>
<th
v-for="thHeader in $t('LABEL_MGMT.LIST.TABLE_HEADER')"
:key="thHeader"
>
{{ thHeader }}
</th>
</thead>
<tbody>
<tr v-for="(label, index) in records" :key="label.title">
<td>{{ label.title }}</td>
<td>{{ label.description }}</td>
<td>
<div class="label-color--container">
<span
class="label-color--display"
:style="{ backgroundColor: label.color }"
/>
{{ label.color }}
</div>
</td>
<td class="button-wrapper">
<woot-submit-button
:button-text="$t('LABEL_MGMT.FORM.EDIT')"
icon-class="ion-edit"
button-class="link hollow grey-btn"
@click="openEditPopup(label)"
/>
<woot-submit-button
:button-text="$t('LABEL_MGMT.FORM.DELETE')"
:loading="loading[label.id]"
icon-class="ion-close-circled"
button-class="link hollow grey-btn"
@click="openDeletePopup(label, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<div class="small-4 columns">
<span v-html="$t('LABEL_MGMT.SIDEBAR_TXT')"></span>
</div>
</div>
<add-label
v-if="showAddPopup"
:show.sync="showAddPopup"
:on-close="hideAddPopup"
/>
<edit-label
v-if="showEditPopup"
:show.sync="showEditPopup"
:selected-response="selectedResponse"
:on-close="hideEditPopup"
/>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('LABEL_MGMT.DELETE.CONFIRM.TITLE')"
:message="deleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import AddLabel from './AddLabel';
import EditLabel from './EditLabel';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
AddLabel,
EditLabel,
},
mixins: [alertMixin],
data() {
return {
loading: {},
showAddPopup: false,
showEditPopup: false,
showDeleteConfirmationPopup: false,
selectedResponse: {},
};
},
computed: {
...mapGetters({
records: 'labels/getLabels',
uiFlags: 'labels/getUIFlags',
}),
// Delete Modal
deleteConfirmText() {
return `${this.$t('LABEL_MGMT.DELETE.CONFIRM.YES')} ${
this.selectedResponse.title
}`;
},
deleteRejectText() {
return `${this.$t('LABEL_MGMT.DELETE.CONFIRM.NO')} ${
this.selectedResponse.title
}`;
},
deleteMessage() {
return `${this.$t('LABEL_MGMT.DELETE.CONFIRM.MESSAGE')} ${
this.selectedResponse.title
} ?`;
},
},
mounted() {
this.$store.dispatch('labels/get');
},
methods: {
openAddPopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
},
openEditPopup(response) {
this.showEditPopup = true;
this.selectedResponse = response;
},
hideEditPopup() {
this.showEditPopup = false;
},
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedResponse = response;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.loading[this.selectedResponse.id] = true;
this.closeDeletePopup();
this.deleteLabel(this.selectedResponse.id);
},
deleteLabel(id) {
this.$store
.dispatch('labels/delete', id)
.then(() => {
this.showAlert(this.$t('LABEL_MGMT.DELETE.API.SUCCESS_MESSAGE'));
})
.catch(() => {
this.showAlert(this.$t('LABEL_MGMT.DELETE.API.ERROR_MESSAGE'));
})
.finally(() => {
this.loading[this.selectedResponse.id] = false;
});
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.label-color--container {
display: flex;
align-items: center;
}
.label-color--display {
border-radius: $space-smaller;
height: $space-normal;
margin-right: $space-smaller;
width: $space-normal;
}
</style>

View file

@ -0,0 +1,31 @@
import SettingsContent from '../Wrapper';
import Index from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/labels'),
component: SettingsContent,
props: {
headerTitle: 'LABEL_MGMT.HEADER',
icon: 'ion-pricetags',
showNewButton: false,
},
children: [
{
path: '',
name: 'labels_wrapper',
roles: ['administrator'],
redirect: 'list',
},
{
path: 'list',
name: 'labels_list',
roles: ['administrator'],
component: Index,
},
],
},
],
};

View file

@ -0,0 +1,10 @@
import { validLabelCharacters } from '../validations';
describe('#validLabelCharacters', () => {
it('validates the label', () => {
expect(validLabelCharacters('')).toEqual(false);
expect(validLabelCharacters('str str')).toEqual(false);
expect(validLabelCharacters('str_str')).toEqual(true);
expect(validLabelCharacters('str-str')).toEqual(true);
});
});

View file

@ -0,0 +1,16 @@
import { required, minLength } from 'vuelidate/lib/validators';
export const validLabelCharacters = (str = '') => /^[\w-_]+$/g.test(str);
export default {
title: {
required,
minLength: minLength(2),
validLabelCharacters,
},
description: {},
color: {
required,
},
showOnSidebar: {},
};

View file

@ -1,11 +1,12 @@
import { frontendURL } from '../../../helper/URLHelper';
import account from './account/account.routes';
import agent from './agents/agent.routes';
import canned from './canned/canned.routes';
import inbox from './inbox/inbox.routes';
import integrations from './integrations/integrations.routes';
import labels from './labels/labels.routes';
import profile from './profile/profile.routes';
import reports from './reports/reports.routes';
import integrations from './integrations/integrations.routes';
import account from './account/account.routes';
import store from '../../../store';
export default {
@ -21,12 +22,13 @@ export default {
return frontendURL('accounts/:accountId/settings/canned-response');
},
},
...account.routes,
...agent.routes,
...canned.routes,
...inbox.routes,
...integrations.routes,
...labels.routes,
...profile.routes,
...reports.routes,
...integrations.routes,
...account.routes,
],
};

View file

@ -18,6 +18,7 @@ import globalConfig from 'shared/store/globalConfig';
import inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers';
import integrations from './modules/integrations';
import labels from './modules/labels';
import reports from './modules/reports';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
@ -35,13 +36,14 @@ export default new Vuex.Store({
conversationLabels,
conversationMetadata,
conversationPage,
conversationStats,
conversations,
conversationStats,
conversationTypingStatus,
globalConfig,
inboxes,
inboxMembers,
integrations,
labels,
reports,
userNotificationSettings,
webhooks,

View file

@ -71,12 +71,18 @@ export const mutations = {
[types.default.SET_CONTACTS]: ($state, data) => {
data.forEach(contact => {
Vue.set($state.records, contact.id, contact);
Vue.set($state.records, contact.id, {
...($state.records[contact.id] || {}),
...contact,
});
});
},
[types.default.SET_CONTACT_ITEM]: ($state, data) => {
Vue.set($state.records, data.id, data);
Vue.set($state.records, data.id, {
...($state.records[data.id] || {}),
...data,
});
},
[types.default.EDIT_CONTACT]: ($state, data) => {

View file

@ -64,6 +64,12 @@ export const actions = {
});
}
},
setBulkConversationLabels({ commit }, conversations) {
commit(types.default.SET_BULK_CONVERSATION_LABELS, conversations);
},
setConversationLabel({ commit }, { id, data }) {
commit(types.default.SET_CONVERSATION_LABELS, { id, data });
},
};
export const mutations = {
@ -76,6 +82,11 @@ export const mutations = {
[types.default.SET_CONVERSATION_LABELS]: ($state, { id, data }) => {
Vue.set($state.records, id, data);
},
[types.default.SET_BULK_CONVERSATION_LABELS]: ($state, conversations) => {
conversations.forEach(conversation => {
Vue.set($state.records, conversation.id, conversation.labels);
});
},
};
export default {

View file

@ -8,7 +8,7 @@ const actions = {
getConversation: async ({ commit }, conversationId) => {
try {
const response = await ConversationApi.show(conversationId);
commit(types.default.ADD_CONVERSATION, response.data);
commit(types.default.UPDATE_CONVERSATION, response.data);
commit(
`contacts/${types.default.SET_CONTACT_ITEM}`,
response.data.meta.sender
@ -26,6 +26,7 @@ const actions = {
const { payload: chatList, meta: metaData } = data;
commit(types.default.SET_ALL_CONVERSATION, chatList);
dispatch('conversationStats/set', metaData);
dispatch('conversationLabels/setBulkConversationLabels', chatList);
commit(types.default.CLEAR_LIST_LOADING_STATUS);
commit(
`contacts/${types.default.SET_CONTACTS}`,

View file

@ -142,13 +142,15 @@ const mutations = {
if (currentConversationIndex > -1) {
const currentConversation = {
...allConversations[currentConversationIndex],
status: conversation.status,
...conversation,
};
Vue.set(allConversations, currentConversationIndex, currentConversation);
if (_state.selectedChat.id === conversation.id) {
_state.selectedChat.status = conversation.status;
window.bus.$emit('scrollToMessage');
}
} else {
_state.allConversations.push(conversation);
}
},
@ -190,7 +192,7 @@ const mutations = {
},
[types.default.SET_ACTIVE_INBOX](_state, inboxId) {
_state.currentInbox = inboxId;
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
},
};

View file

@ -0,0 +1,97 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import LabelsAPI from '../../api/labels';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isCreating: false,
isDeleting: false,
},
};
export const getters = {
getLabels(_state) {
return _state.records;
},
getUIFlags(_state) {
return _state.uiFlags;
},
getLabelsOnSidebar(_state) {
return _state.records.filter(record => record.show_on_sidebar);
},
};
export const actions = {
get: async function getLabels({ commit }) {
commit(types.SET_LABEL_UI_FLAG, { isFetching: true });
try {
const response = await LabelsAPI.get();
commit(types.SET_LABELS, response.data.payload);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_LABEL_UI_FLAG, { isFetching: false });
}
},
create: async function createLabels({ commit }, cannedObj) {
commit(types.SET_LABEL_UI_FLAG, { isCreating: true });
try {
const response = await LabelsAPI.create(cannedObj);
commit(types.ADD_LABEL, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_LABEL_UI_FLAG, { isCreating: false });
}
},
update: async function updateLabels({ commit }, { id, ...updateObj }) {
commit(types.SET_LABEL_UI_FLAG, { isUpdating: true });
try {
const response = await LabelsAPI.update(id, updateObj);
commit(types.EDIT_LABEL, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_LABEL_UI_FLAG, { isUpdating: false });
}
},
delete: async function deleteLabels({ commit }, id) {
commit(types.SET_LABEL_UI_FLAG, { isDeleting: true });
try {
await LabelsAPI.delete(id);
commit(types.DELETE_LABEL, id);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_LABEL_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_LABEL_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_LABELS]: MutationHelpers.set,
[types.ADD_LABEL]: MutationHelpers.create,
[types.EDIT_LABEL]: MutationHelpers.update,
[types.DELETE_LABEL]: MutationHelpers.destroy,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -74,4 +74,33 @@ describe('#actions', () => {
]);
});
});
describe('#setBulkConversationLabels', () => {
it('it send correct mutations', () => {
actions.setBulkConversationLabels({ commit }, [
{ id: 1, labels: ['customer-support'] },
]);
expect(commit.mock.calls).toEqual([
[
types.default.SET_BULK_CONVERSATION_LABELS,
[{ id: 1, labels: ['customer-support'] }],
],
]);
});
});
describe('#setBulkConversationLabels', () => {
it('it send correct mutations', () => {
actions.setConversationLabel(
{ commit },
{ id: 1, data: ['customer-support'] }
);
expect(commit.mock.calls).toEqual([
[
types.default.SET_CONVERSATION_LABELS,
{ id: 1, data: ['customer-support'] },
],
]);
});
});
});

View file

@ -15,7 +15,7 @@ describe('#mutations', () => {
});
describe('#SET_CONVERSATION_LABELS', () => {
it('set contact conversation records', () => {
it('set contact labels', () => {
const state = { records: {} };
mutations[types.default.SET_CONVERSATION_LABELS](state, {
id: 1,
@ -26,4 +26,24 @@ describe('#mutations', () => {
});
});
});
describe('#SET_BULK_CONVERSATION_LABELS', () => {
it('set contact labels in bulk', () => {
const state = { records: {} };
mutations[types.default.SET_BULK_CONVERSATION_LABELS](state, [
{
id: 1,
labels: ['customer-success', 'on-hold'],
},
{
id: 2,
labels: ['customer-success'],
},
]);
expect(state.records).toEqual({
1: ['customer-success', 'on-hold'],
2: ['customer-success'],
});
});
});
});

View file

@ -15,7 +15,7 @@ describe('#actions', () => {
await actions.getConversation({ commit }, 1);
expect(commit.mock.calls).toEqual([
[
types.default.ADD_CONVERSATION,
types.default.UPDATE_CONVERSATION,
{ id: 1, meta: { sender: { id: 1, name: 'Contact 1' } } },
],
['contacts/SET_CONTACT_ITEM', { id: 1, name: 'Contact 1' }],

View file

@ -0,0 +1,94 @@
import axios from 'axios';
import { actions } from '../../labels';
import * as types from '../../../mutation-types';
import labelsList from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: { payload: labelsList } });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_LABEL_UI_FLAG, { isFetching: true }],
[types.default.SET_LABELS, labelsList],
[types.default.SET_LABEL_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_LABEL_UI_FLAG, { isFetching: true }],
[types.default.SET_LABEL_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: labelsList[0] });
await actions.create({ commit }, labelsList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_LABEL_UI_FLAG, { isCreating: true }],
[types.default.ADD_LABEL, labelsList[0]],
[types.default.SET_LABEL_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_LABEL_UI_FLAG, { isCreating: true }],
[types.default.SET_LABEL_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: labelsList[0] });
await actions.update({ commit }, labelsList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_LABEL_UI_FLAG, { isUpdating: true }],
[types.default.EDIT_LABEL, labelsList[0]],
[types.default.SET_LABEL_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, labelsList[0])).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_LABEL_UI_FLAG, { isUpdating: true }],
[types.default.SET_LABEL_UI_FLAG, { isUpdating: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: labelsList[0] });
await actions.delete({ commit }, labelsList[0].id);
expect(commit.mock.calls).toEqual([
[types.default.SET_LABEL_UI_FLAG, { isDeleting: true }],
[types.default.DELETE_LABEL, labelsList[0].id],
[types.default.SET_LABEL_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.delete({ commit }, labelsList[0].id)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_LABEL_UI_FLAG, { isDeleting: true }],
[types.default.SET_LABEL_UI_FLAG, { isDeleting: false }],
]);
});
});
});

View file

@ -0,0 +1,37 @@
export default [
{
id: 1,
title: 'customer-support',
description: 'Customer support queries',
color: '#0076FF',
show_on_sidebar: true,
},
{
id: 4,
title: 'saas-customer',
description: 'Customers who have account on app.chatwoot.com',
color: '#A8DBCB',
show_on_sidebar: false,
},
{
id: 5,
title: 'hosted-customer',
description: 'Customers who have self-hosted instance',
color: '#F50471',
show_on_sidebar: false,
},
{
id: 6,
title: 'enterprise',
description: 'Customers who are using enterprise',
color: '#A90CFD',
show_on_sidebar: false,
},
{
id: 7,
title: 'billing-enquiry',
description: 'Queries on billing issues',
color: '#74B57A',
show_on_sidebar: false,
},
];

View file

@ -0,0 +1,30 @@
import { getters } from '../../labels';
import labels from './fixtures';
describe('#getters', () => {
it('getLabels', () => {
const state = { records: labels };
expect(getters.getLabels(state)).toEqual(labels);
});
it('getLabelsOnSidebar', () => {
const state = { records: labels };
expect(getters.getLabelsOnSidebar(state)).toEqual([labels[0]]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: true,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: true,
isCreating: false,
isUpdating: false,
isDeleting: false,
});
});
});

View file

@ -0,0 +1,39 @@
import types from '../../../mutation-types';
import { mutations } from '../../labels';
import labels from './fixtures';
describe('#mutations', () => {
describe('#SET_LABELS', () => {
it('set label records', () => {
const state = { records: [] };
mutations[types.SET_LABELS](state, labels);
expect(state.records).toEqual(labels);
});
});
describe('#ADD_LABEL', () => {
it('push newly created label to the store', () => {
const state = { records: [labels[0]] };
mutations[types.ADD_LABEL](state, labels[1]);
expect(state.records).toEqual([labels[0], labels[1]]);
});
});
describe('#EDIT_LABEL', () => {
it('update label record', () => {
const state = { records: [labels[0]] };
mutations[types.EDIT_LABEL](state, {
id: 1,
title: 'customer-support-queries',
});
expect(state.records[0].title).toEqual('customer-support-queries');
});
});
describe('#DELETE_LABEL', () => {
it('delete label record', () => {
const state = { records: [labels[0]] };
mutations[types.DELETE_LABEL](state, 1);
expect(state.records).toEqual([]);
});
});
});

View file

@ -67,6 +67,13 @@ export default {
EDIT_CANNED: 'EDIT_CANNED',
DELETE_CANNED: 'DELETE_CANNED',
// Labels
SET_LABEL_UI_FLAG: 'SET_LABEL_UI_FLAG',
SET_LABELS: 'SET_LABELS',
ADD_LABEL: 'ADD_LABEL',
EDIT_LABEL: 'EDIT_LABEL',
DELETE_LABEL: 'DELETE_LABEL',
// Integrations
SET_INTEGRATIONS_UI_FLAG: 'SET_INTEGRATIONS_UI_FLAG',
SET_INTEGRATIONS: 'SET_INTEGRATIONS',
@ -92,6 +99,7 @@ export default {
// Conversation Label
SET_CONVERSATION_LABELS_UI_FLAG: 'SET_CONVERSATION_LABELS_UI_FLAG',
SET_CONVERSATION_LABELS: 'SET_CONVERSATION_LABELS',
SET_BULK_CONVERSATION_LABELS: 'SET_BULK_CONVERSATION_LABELS',
// Reports
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',

View file

@ -14,7 +14,18 @@
# Indexes
#
# index_labels_on_account_id (account_id)
# index_labels_on_title_and_account_id (title,account_id) UNIQUE
#
class Label < ApplicationRecord
include RegexHelper
belongs_to :account
validates :title,
presence: { message: 'must not be blank' },
format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE },
uniqueness: { scope: :account_id }
before_validation do
self.title = title.downcase if attribute_present?('title')
end
end

View file

@ -1,6 +1,6 @@
class LabelPolicy < ApplicationPolicy
def index?
@account_user.administrator?
@account_user.administrator? || @account_user.agent?
end
def update?
@ -14,4 +14,8 @@ class LabelPolicy < ApplicationPolicy
def create?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
end
end

View file

@ -24,3 +24,4 @@ json.agent_last_seen_at conversation.agent_last_seen_at.to_i
json.unread_count conversation.unread_incoming_messages.count
json.additional_attributes conversation.additional_attributes
json.account_id conversation.account_id
json.labels conversation.label_list

View file

@ -0,0 +1,20 @@
class MigrateAndAddUniqueIndexToLabels < ActiveRecord::Migration[6.0]
def change
add_index :labels, [:title, :account_id], unique: true
migrate_existing_tags
end
private
def migrate_existing_tags
::ActsAsTaggableOn::Tag.all.each do |tag|
tag.taggings.each do |tagging|
ensure_label_for_account(tag.name, tagging.taggable.account)
end
end
end
def ensure_label_for_account(name, account)
account.labels.where(title: name.downcase).first_or_create
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_06_10_143132) do
ActiveRecord::Schema.define(version: 2020_06_25_124400) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
@ -280,6 +280,7 @@ ActiveRecord::Schema.define(version: 2020_06_10_143132) do
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["account_id"], name: "index_labels_on_account_id"
t.index ["title", "account_id"], name: "index_labels_on_title_and_account_id", unique: true
end
create_table "messages", id: :serial, force: :cascade do |t|

8
lib/regex_helper.rb Normal file
View file

@ -0,0 +1,8 @@
module RegexHelper
# user https://rubular.com/ to quickly validate your regex
# the following regext needs atleast one character which should be
# valid unicode letter, unicode number, underscore, hyphen
# shouldn't start with a underscore or hyphen
UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE = Regexp.new('\A[\p{L}\p{N}]+[\p{L}\p{N}_-]+\Z')
end

View file

@ -76,7 +76,7 @@ RSpec.describe 'Label API', type: :request do
end
describe 'PATCH /api/v1/accounts/{account.id}/labels/:id' do
let(:valid_params) { { title: 'Test 2' } }
let(:valid_params) { { title: 'Test_2' } }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
@ -97,7 +97,7 @@ RSpec.describe 'Label API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(label.reload.title).to eq('Test 2')
expect(label.reload.title).to eq('test_2')
end
end
end

View file

@ -3,6 +3,6 @@
FactoryBot.define do
factory :label do
account
sequence(:title) { |n| "Label #{n}" }
sequence(:title) { |n| "Label_#{n}" }
end
end

View file

@ -4,4 +4,39 @@ RSpec.describe Label, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'title validations' do
it 'would not let you start title without numbers or letters' do
label = FactoryBot.build(:label, title: '_12')
expect(label.valid?).to eq false
end
it 'would not let you use special characters' do
label = FactoryBot.build(:label, title: 'jell;;2_12')
expect(label.valid?).to eq false
end
it 'would not allow space' do
label = FactoryBot.build(:label, title: 'heeloo _12')
expect(label.valid?).to eq false
end
it 'allows foreign charactes' do
label = FactoryBot.build(:label, title: '学中文_12')
expect(label.valid?).to eq true
end
it 'converts uppercase letters to lowercase' do
label = FactoryBot.build(:label, title: 'Hello_World')
expect(label.valid?).to eq true
expect(label.title).to eq 'hello_world'
end
it 'validates uniqueness of label name for account' do
account = create(:account)
label = FactoryBot.create(:label, account: account)
duplicate_label = FactoryBot.build(:label, title: label.title, account: account)
expect(duplicate_label.valid?).to eq false
end
end
end