feat: label suggestion UI (#7480)

This commit is contained in:
Shivam Mishra
2023-07-13 09:16:09 +05:30
committed by GitHub
parent 91e2da5e74
commit 7c080fa9fa
14 changed files with 656 additions and 55 deletions

View File

@@ -4,7 +4,7 @@
<fluent-icon :icon="icon" size="12" class="label--icon" />
</span>
<span
v-if="variant === 'smooth' && title && !icon"
v-if="['smooth', 'dashed'].includes(variant) && title && !icon"
:style="{ background: color }"
class="label-color-dot"
/>
@@ -69,6 +69,7 @@ export default {
computed: {
textColor() {
if (this.variant === 'smooth') return '';
if (this.variant === 'dashed') return '';
return this.color || getContrastingTextColor(this.bgColor);
},
labelClass() {
@@ -199,8 +200,14 @@ export default {
&.smooth {
background: transparent;
border: 1px solid var(--s-100);
color: var(--s-700);
border: 1px solid var(--s-100);
}
&.dashed {
background: transparent;
color: var(--s-700);
border: 1px dashed var(--s-100);
}
}

View File

@@ -70,16 +70,15 @@
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import OpenAPI from 'dashboard/api/integrations/openapi';
import alertMixin from 'shared/mixins/alertMixin';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import aiMixin from 'dashboard/mixins/aiMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
export default {
mixins: [alertMixin, clickaway, eventListenerMixins],
mixins: [aiMixin, alertMixin, clickaway, eventListenerMixins],
props: {
conversationId: {
type: Number,
@@ -119,29 +118,12 @@ export default {
};
},
computed: {
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
isAIIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
},
buttonText() {
return this.uiFlags.isRephrasing
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
},
},
mounted() {
if (!this.appIntegrations.length) {
this.$store.dispatch('integrations/get');
}
},
methods: {
onKeyDownHandler(event) {
const keyPattern = buildHotKeys(event);
@@ -159,15 +141,6 @@ export default {
closeDropdown() {
this.showDropdown = false;
},
async recordAnalytics({ type, tone }) {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
this.$track(event, {
type,
tone,
});
}
},
async processEvent(type = 'rephrase') {
this.uiFlags[type] = true;
try {
@@ -184,7 +157,7 @@ export default {
this.initialMessage = this.message;
this.$emit('replace-text', generatedMessage || this.message);
this.closeDropdown();
this.recordAnalytics({ type, tone: this.activeTone });
this.recordAnalytics(type, { tone: this.activeTone });
} catch (error) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
} finally {

View File

@@ -1,6 +1,6 @@
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
{{ userInitial }}
<slot>{{ userInitial }}</slot>
</div>
</template>

View File

@@ -5,6 +5,7 @@
:title="title"
>
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
<slot>
<img
v-show="shouldShowImage"
:src="src"
@@ -18,6 +19,7 @@
:class="thumbnailClass"
:size="avatarSize"
/>
</slot>
<img
v-if="badgeSrc"
class="source-badge"

View File

@@ -64,6 +64,12 @@
:has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox"
/>
<conversation-label-suggestion
v-if="isEnterprise && isAIIntegrationEnabled"
:suggested-labels="labelSuggestions"
:chat-labels="currentChat.labels"
:conversation-id="currentChat.id"
/>
</ul>
<div
class="conversation-footer"
@@ -91,29 +97,47 @@
</template>
<script>
import { mapGetters } from 'vuex';
// components
import ReplyBox from './ReplyBox';
import Message from './Message';
import ConversationLabelSuggestion from './conversation/LabelSuggestion';
import Banner from 'dashboard/components/ui/Banner.vue';
// stores and apis
import { mapGetters } from 'vuex';
// mixins
import conversationMixin, {
filterDuplicateSourceMessages,
} from '../../../mixins/conversations';
import Banner from 'dashboard/components/ui/Banner.vue';
import { getTypingUsersText } from '../../../helper/commons';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import inboxMixin from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
export default {
components: {
Message,
ReplyBox,
Banner,
ConversationLabelSuggestion,
},
mixins: [conversationMixin, inboxMixin, eventListenerMixins],
mixins: [
conversationMixin,
inboxMixin,
eventListenerMixins,
configMixin,
aiMixin,
],
props: {
isContactPanelOpen: {
type: Boolean,
@@ -127,7 +151,10 @@ export default {
heightBeforeLoad: null,
conversationPanel: null,
selectedTweetId: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
isPopoutReplyBox: false,
labelSuggestions: [],
};
},
@@ -138,6 +165,8 @@ export default {
inboxesList: 'inboxes/getInboxes',
listLoadingStatus: 'getAllMessagesLoaded',
loadingChatList: 'getChatListLoadingStatus',
appIntegrations: 'integrations/getAppIntegrations',
currentAccountId: 'getCurrentAccountId',
}),
inboxId() {
return this.currentChat.inbox_id;
@@ -280,18 +309,22 @@ export default {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.selectedTweetId = null;
},
},
created() {
bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
bus.$on(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
},
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
},
beforeDestroy() {
@@ -300,6 +333,49 @@ export default {
},
methods: {
async fetchSuggestions() {
// start empty, this ensures that the label suggestions are not shown
this.labelSuggestions = [];
if (this.isLabelSuggestionDismissed()) {
return;
}
if (!this.isEnterprise) {
return;
}
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isAIIntegrationEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
this.$nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = this.$route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
isLabelSuggestionDismissed() {
const dismissed = this.getDismissedConversations(this.currentAccountId);
return dismissed[this.currentAccountId].includes(this.conversationId);
},
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
@@ -314,6 +390,7 @@ export default {
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
@@ -344,18 +421,34 @@ export default {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.isProgrammaticScroll = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestions = this.conversationPanel.querySelector(
'.label-suggestion'
);
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
// capturing only the unread messages
relevantMessages = this.conversationPanel.querySelectorAll(
'.message--unread'
);
} else if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestions];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
this.conversationPanel.querySelectorAll('.message--read')
).slice(-1);
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
@@ -402,6 +495,13 @@ export default {
},
handleScroll(e) {
if (this.isProgrammaticScroll) {
// Reset the flag
this.isProgrammaticScroll = false;
this.hasUserScrolled = false;
} else {
this.hasUserScrolled = true;
}
bus.$emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},

View File

@@ -0,0 +1,271 @@
<template>
<li v-if="shouldShowSuggestions" class="label-suggestion right">
<div class="wrap">
<div class="label-suggestion--container">
<h6 class="label-suggestion--title">Suggested labels</h6>
<div v-if="!fetchingSuggestions" class="label-suggestion--options">
<button
v-for="label in preparedLabels"
:key="label.title"
v-tooltip.top="{
content: selectedLabels.includes(label.title)
? $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DESELECT')
: labelTooltip,
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
class="label-suggestion--option"
@click="pushOrAddLabel(label.title)"
>
<woot-label
variant="dashed"
v-bind="label"
:bg-color="
selectedLabels.includes(label.title) ? 'var(--w-100)' : ''
"
/>
</button>
<woot-button
v-if="preparedLabels.length === 1"
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
variant="smooth"
class="label--add"
icon="dismiss"
size="tiny"
@click="dismissSuggestions"
/>
</div>
<div v-if="preparedLabels.length > 1">
<woot-button
variant="smooth"
class="label--add"
icon="add"
size="tiny"
@click="addAllLabels"
>
{{ addButtonText }}
</woot-button>
<woot-button
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
variant="smooth"
class="label--add"
icon="dismiss"
size="tiny"
@click="dismissSuggestions"
/>
</div>
</div>
<div class="sender--info has-tooltip" data-original-title="null">
<woot-thumbnail
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
size="16px"
>
<avatar class="user-thumbnail thumbnail-rounded">
<fluent-icon class="chatwoot-ai-icon" icon="chatwoot-ai" />
</avatar>
</woot-thumbnail>
</div>
</div>
</li>
</template>
<script>
// components
import WootButton from '../../../ui/WootButton.vue';
import Avatar from '../../Avatar.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
// store & api
import { mapGetters } from 'vuex';
// utils & constants
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { OPEN_AI_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
name: 'LabelSuggestion',
components: {
Avatar,
WootButton,
},
mixins: [aiMixin],
props: {
suggestedLabels: {
type: Array,
required: true,
},
chatLabels: {
type: Array,
required: false,
default: () => [],
},
conversationId: {
type: Number,
required: true,
},
},
data() {
return {
isDismissed: false,
fetchingSuggestions: false,
selectedLabels: [],
};
},
computed: {
...mapGetters({
allLabels: 'labels/getLabels',
currentAccountId: 'getCurrentAccountId',
}),
labelTooltip() {
if (this.preparedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.SINGLE_SUGGESTION');
},
addButtonText() {
if (this.selectedLabels.length === 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABEL');
}
if (this.selectedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABELS');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_ALL_LABELS');
},
preparedLabels() {
return this.allLabels.filter(label =>
this.suggestedLabels.includes(label.title)
);
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.isAIIntegrationEnabled) return false;
return (
!this.fetchingSuggestions &&
this.preparedLabels.length &&
this.chatLabels.length === 0
);
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.selectedLabels = [];
this.isDismissed = this.isConversationDismissed();
},
},
},
methods: {
pushOrAddLabel(label) {
if (this.preparedLabels.length === 1) {
this.addAllLabels();
return;
}
if (!this.selectedLabels.includes(label)) {
this.selectedLabels.push(label);
} else {
this.selectedLabels = this.selectedLabels.filter(l => l !== label);
}
},
dismissSuggestions() {
const dismissed = this.getDismissedConversations(this.currentAccountId);
dismissed[this.currentAccountId].push(this.conversationId);
LocalStorage.set(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
dismissed
);
// dismiss this once the values are set
this.isDismissed = true;
this.trackLabelEvent(OPEN_AI_EVENTS.DISMISS_LABEL_SUGGESTION);
},
isConversationDismissed() {
const dismissed = this.getDismissedConversations(this.currentAccountId);
return dismissed[this.currentAccountId].includes(this.conversationId);
},
addAllLabels() {
let labelsToAdd = this.selectedLabels;
if (!labelsToAdd.length) {
labelsToAdd = this.preparedLabels.map(label => label.title);
}
this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: labelsToAdd,
});
this.trackLabelEvent(OPEN_AI_EVENTS.LABEL_SUGGESTION_APPLIED);
},
trackLabelEvent(event) {
const payload = {
conversationId: this.conversationId,
account: this.currentAccountId,
suggestions: this.suggestedLabels,
labelsApplied: this.selectedLabels.length
? this.selectedLabels
: this.suggestedLabels,
};
this.$track(event, payload);
},
},
};
</script>
<style scoped lang="scss">
.wrap {
display: flex;
}
.label-suggestion {
flex-direction: row;
justify-content: flex-end;
margin-top: var(--space-normal);
.label-suggestion--container {
max-width: 300px;
}
.label-suggestion--options {
text-align: right;
display: flex;
align-items: center;
gap: var(--space-micro);
button.label-suggestion--option {
.label {
cursor: pointer;
margin-bottom: 0;
}
}
}
.chatwoot-ai-icon {
height: var(--font-size-mini);
width: var(--font-size-mini);
}
.label-suggestion--title {
color: var(--b-600);
margin-top: var(--space-micro);
font-size: var(--font-size-micro);
}
}
</style>

View File

@@ -3,4 +3,5 @@ export const LOCAL_STORAGE_KEYS = {
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'dismissedLabelSuggestions',
};

View File

@@ -81,4 +81,6 @@ export const OPEN_AI_EVENTS = Object.freeze({
SUMMARIZE: 'OpenAI: Used summarize',
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
REPHRASE: 'OpenAI: Used rephrase',
APPLY_LABEL_SUGGESTION: 'OpenAI: Apply label from suggestion',
DISMISS_LABEL_SUGGESTION: 'OpenAI: Dismiss label suggestions',
});

View File

@@ -34,6 +34,19 @@
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"SUGGESTIONS": {
"TOOLTIP": {
"SINGLE_SUGGESTION": "Add label to conversation",
"MULTIPLE_SUGGESTION": "Select this label",
"DESELECT": "Deselect label",
"DISMISS": "Dismiss suggestion"
},
"POWERED_BY": "Chatwoot AI",
"DISMISS": "Dismiss",
"ADD_SELECTED_LABELS": "Add selected labels",
"ADD_SELECTED_LABEL": "Add selected label",
"ADD_ALL_LABELS": "Add all labels"
},
"ADD": {
"TITLE": "Add label",
"DESC": "Labels let you group the conversations together.",

View File

@@ -0,0 +1,88 @@
import { mapGetters } from 'vuex';
import { OPEN_AI_EVENTS } from '../helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from '../constants/localStorage';
import { LocalStorage } from '../../shared/helpers/localStorage';
import OpenAPI from '../api/integrations/openapi';
export default {
mounted() {
this.fetchIntegrationsIfRequired();
},
computed: {
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
isAIIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
},
},
methods: {
async fetchIntegrationsIfRequired() {
if (!this.appIntegrations.length) {
await this.$store.dispatch('integrations/get');
}
},
async recordAnalytics(type, payload) {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
this.$track(event, {
type,
...payload,
});
}
},
async fetchLabelSuggestions({ conversationId }) {
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: this.hookId,
conversationId: conversationId,
});
const {
data: { message: labels },
} = result;
return this.cleanLabels(labels);
} catch (error) {
return [];
}
},
getDismissedConversations(accountId) {
const suggestionKey = LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS;
// fetch the value from Storage
const valueFromStorage = LocalStorage.get(suggestionKey);
// Case 1: the key is not initialized
if (!valueFromStorage) {
LocalStorage.set(suggestionKey, {
[accountId]: [],
});
return LocalStorage.get(suggestionKey);
}
// Case 2: the key is initialized, but account ID is not present
if (!valueFromStorage[accountId]) {
valueFromStorage[accountId] = [];
LocalStorage.set(suggestionKey, valueFromStorage);
return LocalStorage.get(suggestionKey);
}
return valueFromStorage;
},
cleanLabels(labels) {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index); // remove any duplicates
},
},
};

View File

@@ -0,0 +1,136 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import aiMixin from '../aiMixin';
import Vuex from 'vuex';
import OpenAPI from '../../api/integrations/openapi';
import { LocalStorage } from '../../../shared/helpers/localStorage';
jest.mock('../../api/integrations/openapi');
jest.mock('../../../shared/helpers/localStorage');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('aiMixin', () => {
let wrapper;
let getters;
let emptyGetters;
let component;
let actions;
beforeEach(() => {
OpenAPI.processEvent = jest.fn();
LocalStorage.set = jest.fn();
LocalStorage.get = jest.fn();
actions = {
['integrations/get']: jest.fn(),
};
getters = {
['integrations/getAppIntegrations']: () => [
{
id: 'openai',
hooks: [{ id: 'hook1' }],
},
],
};
component = {
render() {},
title: 'TestComponent',
mixins: [aiMixin],
};
wrapper = shallowMount(component, {
store: new Vuex.Store({
getters: getters,
actions,
}),
localVue,
});
emptyGetters = {
['integrations/getAppIntegrations']: () => [],
};
});
it('fetches integrations if required', async () => {
wrapper = shallowMount(component, {
store: new Vuex.Store({
getters: emptyGetters,
actions,
}),
localVue,
});
const dispatchSpy = jest.spyOn(wrapper.vm.$store, 'dispatch');
await wrapper.vm.fetchIntegrationsIfRequired();
expect(dispatchSpy).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations', async () => {
const dispatchSpy = jest.spyOn(wrapper.vm.$store, 'dispatch');
await wrapper.vm.fetchIntegrationsIfRequired();
expect(dispatchSpy).not.toHaveBeenCalledWith('integrations/get');
expect(wrapper.vm.isAIIntegrationEnabled).toBeTruthy();
});
it('fetches label suggestions', async () => {
const processEventSpy = jest.spyOn(OpenAPI, 'processEvent');
await wrapper.vm.fetchLabelSuggestions({
conversationId: '123',
});
expect(processEventSpy).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
});
it('gets dismissed conversations', () => {
const getSpy = jest.spyOn(LocalStorage, 'get');
const setSpy = jest.spyOn(LocalStorage, 'set');
const accountId = 123;
const valueFromStorage = { [accountId]: ['conv1', 'conv2'] };
// in case key is not initialized
getSpy.mockReturnValueOnce(null);
wrapper.vm.getDismissedConversations(accountId);
expect(getSpy).toHaveBeenCalledWith('dismissedLabelSuggestions');
expect(setSpy).toHaveBeenCalledWith('dismissedLabelSuggestions', {
[accountId]: [],
});
// rest spy
getSpy.mockReset();
setSpy.mockReset();
// in case we get the value from storage
getSpy.mockReturnValueOnce(valueFromStorage);
const result = wrapper.vm.getDismissedConversations(accountId);
expect(result).toEqual(valueFromStorage);
expect(getSpy).toHaveBeenCalledWith('dismissedLabelSuggestions');
expect(setSpy).not.toHaveBeenCalled();
// rest spy
getSpy.mockReset();
setSpy.mockReset();
// in case we get the value from storage but accountId is not present
getSpy.mockReturnValueOnce(valueFromStorage);
wrapper.vm.getDismissedConversations(234);
expect(getSpy).toHaveBeenCalledWith('dismissedLabelSuggestions');
expect(setSpy).toHaveBeenCalledWith('dismissedLabelSuggestions', {
...valueFromStorage,
234: [],
});
});
it('cleans labels', () => {
const labels = 'label1, label2, label1';
expect(wrapper.vm.cleanLabels(labels)).toEqual(['label1', 'label2']);
});
});

View File

@@ -179,6 +179,7 @@ export const mutations = {
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
if (selectedChatId === conversationId) {
window.bus.$emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
window.bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
@@ -201,6 +202,7 @@ export const mutations = {
};
Vue.set(allConversations, currentConversationIndex, currentConversation);
if (_state.selectedChatId === conversation.id) {
window.bus.$emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
window.bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {

View File

@@ -218,5 +218,10 @@
"M6.03866 3.50012L5.92929 8.29274H4.86205L4.75599 3.50012H6.03866ZM5.39567 10.3609C5.1946 10.3609 5.02225 10.2902 4.87863 10.1488C4.73721 10.0074 4.6665 9.83503 4.6665 9.63175C4.6665 9.43289 4.73721 9.26275 4.87863 9.12133C5.02225 8.97992 5.1946 8.90921 5.39567 8.90921C5.59232 8.90921 5.76246 8.97992 5.90609 9.12133C6.05192 9.26275 6.12484 9.43289 6.12484 9.63175C6.12484 9.76654 6.09059 9.88917 6.02209 9.99965C5.9558 10.1101 5.86742 10.1985 5.75694 10.2648C5.64867 10.3289 5.52825 10.3609 5.39567 10.3609Z",
"M8.63887 3.50012L8.5295 8.29274H7.46226L7.3562 3.50012H8.63887ZM7.99588 10.3609C7.79481 10.3609 7.62246 10.2902 7.47883 10.1488C7.33742 10.0074 7.26671 9.83503 7.26671 9.63175C7.26671 9.43289 7.33742 9.26275 7.47883 9.12133C7.62246 8.97992 7.79481 8.90921 7.99588 8.90921C8.19253 8.90921 8.36267 8.97992 8.5063 9.12133C8.65213 9.26275 8.72505 9.43289 8.72505 9.63175C8.72505 9.76654 8.6908 9.88917 8.6223 9.99965C8.55601 10.1101 8.46763 10.1985 8.35715 10.2648C8.24888 10.3289 8.12845 10.3609 7.99588 10.3609Z"
],
"priority-low-outline": "M7.88754 3.5L7.77816 8.29261H6.71093L6.60487 3.5H7.88754ZM7.24455 10.3608C7.04347 10.3608 6.87113 10.2901 6.7275 10.1487C6.58609 10.0073 6.51538 9.83491 6.51538 9.63163C6.51538 9.43277 6.58609 9.26263 6.7275 9.12121C6.87113 8.9798 7.04347 8.90909 7.24455 8.90909C7.4412 8.90909 7.61134 8.9798 7.75496 9.12121C7.9008 9.26263 7.97371 9.43277 7.97371 9.63163C7.97371 9.76641 7.93947 9.88905 7.87097 9.99953C7.80468 10.11 7.7163 10.1984 7.60582 10.2647C7.49755 10.3288 7.37712 10.3608 7.24455 10.3608Z"
"priority-low-outline": "M7.88754 3.5L7.77816 8.29261H6.71093L6.60487 3.5H7.88754ZM7.24455 10.3608C7.04347 10.3608 6.87113 10.2901 6.7275 10.1487C6.58609 10.0073 6.51538 9.83491 6.51538 9.63163C6.51538 9.43277 6.58609 9.26263 6.7275 9.12121C6.87113 8.9798 7.04347 8.90909 7.24455 8.90909C7.4412 8.90909 7.61134 8.9798 7.75496 9.12121C7.9008 9.26263 7.97371 9.43277 7.97371 9.63163C7.97371 9.76641 7.93947 9.88905 7.87097 9.99953C7.80468 10.11 7.7163 10.1984 7.60582 10.2647C7.49755 10.3288 7.37712 10.3608 7.24455 10.3608Z",
"chatwoot-ai-outline": [
"M6 3.99988L5.728 4.01854C5.24919 4.08427 4.81038 4.32115 4.49271 4.68539C4.17505 5.04962 4.00002 5.51656 4 5.99986V7.99984H2L1.728 8.01851C1.24919 8.08423 0.810374 8.32112 0.492709 8.68535C0.175044 9.04959 2.04617e-05 9.51653 0 9.99982L0.0186659 10.2718C0.0843951 10.7506 0.321281 11.1894 0.685517 11.5071C1.04975 11.8248 1.5167 11.9998 2 11.9998H4V13.9998L4.01867 14.2718C4.0844 14.7506 4.32128 15.1894 4.68551 15.5071C5.04975 15.8247 5.5167 15.9997 6 15.9998L6.272 15.9811C7.248 15.8478 8 15.0131 8 13.9998V11.9998H10L10.272 11.9811C11.248 11.8478 12 11.0131 12 9.99982L11.9813 9.72782C11.9156 9.24902 11.6787 8.81021 11.3145 8.49255C10.9502 8.17489 10.4833 7.99986 10 7.99984H8V5.99986L7.98133 5.72786C7.9156 5.24906 7.67872 4.81025 7.31448 4.49258C6.95024 4.17492 6.4833 3.9999 6 3.99988Z",
"M14 0L13.9093 0.00622212C13.7497 0.0281315 13.6035 0.107093 13.4976 0.228504C13.3917 0.349915 13.3333 0.505562 13.3333 0.666661V1.33332H12.6667L12.576 1.33954C12.4164 1.36145 12.2701 1.44041 12.1642 1.56183C12.0584 1.68324 12 1.83888 12 1.99998L12.0062 2.09065C12.0281 2.25025 12.1071 2.39652 12.2285 2.50241C12.3499 2.60829 12.5056 2.66664 12.6667 2.66664H13.3333V3.3333L13.3396 3.42397C13.3615 3.58357 13.4404 3.72984 13.5618 3.83573C13.6833 3.94162 13.8389 3.99996 14 3.99996L14.0907 3.99374C14.416 3.9493 14.6667 3.67108 14.6667 3.3333V2.66664H15.3333L15.424 2.66042C15.7493 2.61598 16 2.33776 16 1.99998L15.9938 1.90932C15.9719 1.74971 15.8929 1.60345 15.7715 1.49756C15.6501 1.39167 15.4944 1.33333 15.3333 1.33332H14.6667V0.666661L14.6605 0.575995C14.6385 0.416393 14.5596 0.270123 14.4382 0.164236C14.3168 0.0583485 14.1611 6.79363e-06 14 0Z",
"M16 12.0001L15.8187 12.0125C15.4995 12.0563 15.2069 12.2143 14.9951 12.4571C14.7834 12.6999 14.6667 13.0112 14.6667 13.3334V14.6667H13.3333L13.152 14.6792C12.8328 14.723 12.5403 14.8809 12.3285 15.1237C12.1167 15.3665 12 15.6778 12 16L12.0124 16.1814C12.0563 16.5006 12.2142 16.7931 12.457 17.0049C12.6998 17.2167 13.0111 17.3333 13.3333 17.3334H14.6667V18.6667L14.6791 18.848C14.7229 19.1672 14.8809 19.4598 15.1237 19.6715C15.3665 19.8833 15.6778 20 16 20L16.1813 19.9876C16.832 19.8987 17.3333 19.3422 17.3333 18.6667V17.3334H18.6667L18.848 17.3209C19.4987 17.232 20 16.6756 20 16L19.9876 15.8187C19.9437 15.4995 19.7858 15.207 19.543 14.9952C19.3002 14.7834 18.9889 14.6667 18.6667 14.6667H17.3333V13.3334L17.3209 13.1521C17.2771 12.8329 17.1191 12.5403 16.8763 12.3285C16.6335 12.1168 16.3222 12.0001 16 12.0001Z"
]
}

View File

@@ -4,6 +4,7 @@ export const BUS_EVENTS = {
START_NEW_CONVERSATION: 'START_NEW_CONVERSATION',
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
SCROLL_TO_MESSAGE: 'SCROLL_TO_MESSAGE',
FETCH_LABEL_SUGGESTIONS: 'FETCH_LABEL_SUGGESTIONS',
TOGGLE_SIDEMENU: 'TOGGLE_SIDEMENU',
ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL',
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',