Feature: Improve label experience (#975)
Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
parent
8b61452d56
commit
97ad39713b
56 changed files with 1712 additions and 284 deletions
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
9
app/javascript/dashboard/api/labels.js
Normal file
9
app/javascript/dashboard/api/labels.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import ApiClient from './ApiClient';
|
||||
|
||||
class LabelsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new LabelsAPI();
|
14
app/javascript/dashboard/api/specs/labels.spec.js
Normal file
14
app/javascript/dashboard/api/specs/labels.spec.js
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -67,6 +67,10 @@
|
|||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
.content {
|
||||
@include padding($space-large);
|
||||
}
|
||||
|
||||
form {
|
||||
@include padding($space-large);
|
||||
align-self: center;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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`);
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
91
app/javascript/dashboard/components/widgets/Label.vue
Normal file
91
app/javascript/dashboard/components/widgets/Label.vue
Normal 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>
|
|
@ -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) {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
68
app/javascript/dashboard/i18n/locale/en/labelsMgmt.json
Normal file
68
app/javascript/dashboard/i18n/locale/en/labelsMgmt.json
Normal 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 "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -103,6 +103,7 @@
|
|||
"INBOXES": "Inboxes",
|
||||
"CANNED_RESPONSES": "Canned Responses",
|
||||
"INTEGRATIONS": "Integrations",
|
||||
"ACCOUNT_SETTINGS": "Account Settings"
|
||||
"ACCOUNT_SETTINGS": "Account Settings",
|
||||
"LABELS": "Labels"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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: {},
|
||||
};
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
97
app/javascript/dashboard/store/modules/labels.js
Normal file
97
app/javascript/dashboard/store/modules/labels.js
Normal 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,
|
||||
};
|
|
@ -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'] },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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' }],
|
||||
|
|
|
@ -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 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
];
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
8
lib/regex_helper.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
FactoryBot.define do
|
||||
factory :label do
|
||||
account
|
||||
sequence(:title) { |n| "Label #{n}" }
|
||||
sequence(:title) { |n| "Label_#{n}" }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue