mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	Feature: Improve label experience (#975)
Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
		| @@ -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." | ||||
|       }, | ||||
|       "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" | ||||
|     "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"> | ||||
|       <i v-if="icon" :class="icon" class="conv-details--item__icon"></i> | ||||
|       {{ title }} | ||||
|       <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; | ||||
|       } | ||||
|       this.$store.dispatch('setActiveInbox', this.inboxId); | ||||
|       this.setActiveChat(); | ||||
|     }, | ||||
|  | ||||
|     fetchConversation() { | ||||
| @@ -103,11 +102,17 @@ export default { | ||||
|     }, | ||||
|  | ||||
|     setActiveChat() { | ||||
|       const chat = this.findConversation(); | ||||
|       if (!chat) return; | ||||
|       this.$store.dispatch('setActiveChat', chat).then(() => { | ||||
|         bus.$emit('scrollToMessage'); | ||||
|       }); | ||||
|       if (this.conversationId) { | ||||
|         const chat = this.findConversation(); | ||||
|         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', | ||||
|   | ||||
| @@ -13,8 +13,19 @@ | ||||
| # | ||||
| # Indexes | ||||
| # | ||||
| #  index_labels_on_account_id  (account_id) | ||||
| #  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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Pranav Raj S
					Pranav Raj S