Feature: Customise widget for bot conversations (#834)

* Feature: Customise widget for bot conversations
This commit is contained in:
Pranav Raj S 2020-05-09 22:02:43 +05:30 committed by GitHub
parent 05ea6308f2
commit f28ec29b8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 298 additions and 26 deletions

View file

@ -106,3 +106,6 @@ CHARGEBEE_WEBHOOK_PASSWORD=
## generate a new key value here : https://d3v.one/vapid-key-generator/ ## generate a new key value here : https://d3v.one/vapid-key-generator/
# VAPID_PUBLIC_KEY= # VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY= # VAPID_PRIVATE_KEY=
## Bot Customizations
USE_INBOX_AVATAR_FOR_BOT=true

View file

@ -3,6 +3,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
before_action :set_web_widget before_action :set_web_widget
before_action :set_contact before_action :set_contact
def index
@conversation = conversation
end
def toggle_typing def toggle_typing
head :ok && return if conversation.nil? head :ok && return if conversation.nil?

View file

@ -24,6 +24,7 @@
class="button block" class="button block"
type="submit" type="submit"
:disabled="!isFormValid" :disabled="!isFormValid"
:style="{ background: widgetColor, borderColor: widgetColor }"
> >
{{ $t('COMPONENTS.FORM_BUBBLE.SUBMIT') }} {{ $t('COMPONENTS.FORM_BUBBLE.SUBMIT') }}
</button> </button>
@ -32,6 +33,7 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
export default { export default {
props: { props: {
items: { items: {
@ -49,6 +51,9 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
}),
isFormValid() { isFormValid() {
return this.items.reduce((acc, { name }) => { return this.items.reduce((acc, { name }) => {
return !!this.formValues[name] && acc; return !!this.formValues[name] && acc;

View file

@ -60,6 +60,8 @@ export default {
this.$store.dispatch('contacts/update', message); this.$store.dispatch('contacts/update', message);
} }
}); });
this.$store.dispatch('conversationAttributes/get');
}, },
methods: { methods: {
...mapActions('appConfig', ['setWidgetColor']), ...mapActions('appConfig', ['setWidgetColor']),

View file

@ -13,12 +13,16 @@ const sendAttachmentAPI = async attachment => {
return result; return result;
}; };
const getConversationAPI = async ({ before }) => { const getMessagesAPI = async ({ before }) => {
const urlData = endPoints.getConversation({ before }); const urlData = endPoints.getConversation({ before });
const result = await API.get(urlData.url, { params: urlData.params }); const result = await API.get(urlData.url, { params: urlData.params });
return result; return result;
}; };
const getConversationAPI = async () => {
return API.get(`/api/v1/widget/conversations${window.location.search}`);
};
const toggleTyping = async ({ typingStatus }) => { const toggleTyping = async ({ typingStatus }) => {
return API.post( return API.post(
`/api/v1/widget/conversations/toggle_typing${window.location.search}`, `/api/v1/widget/conversations/toggle_typing${window.location.search}`,
@ -26,4 +30,10 @@ const toggleTyping = async ({ typingStatus }) => {
); );
}; };
export { sendMessageAPI, getConversationAPI, sendAttachmentAPI, toggleTyping }; export {
sendMessageAPI,
getConversationAPI,
getMessagesAPI,
sendAttachmentAPI,
toggleTyping,
};

View file

@ -53,6 +53,7 @@ import ImageBubble from 'widget/components/ImageBubble';
import FileBubble from 'widget/components/FileBubble'; import FileBubble from 'widget/components/FileBubble';
import Thumbnail from 'dashboard/components/widgets/Thumbnail'; import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import { MESSAGE_TYPE } from 'widget/helpers/constants'; import { MESSAGE_TYPE } from 'widget/helpers/constants';
import configMixin from '../mixins/configMixin';
export default { export default {
name: 'AgentMessage', name: 'AgentMessage',
@ -63,7 +64,7 @@ export default {
UserMessage, UserMessage,
FileBubble, FileBubble,
}, },
mixins: [timeMixin], mixins: [timeMixin, configMixin],
props: { props: {
message: { message: {
type: Object, type: Object,
@ -112,11 +113,17 @@ export default {
avatarUrl() { avatarUrl() {
// eslint-disable-next-line // eslint-disable-next-line
const BotImage = require('dashboard/assets/images/chatwoot_bot.png'); const BotImage = require('dashboard/assets/images/chatwoot_bot.png');
const displayImage = this.useInboxAvatarForBot
? this.inboxAvatarUrl
: BotImage;
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) { if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
return BotImage; return displayImage;
} }
return this.message.sender ? this.message.sender.avatar_url : BotImage; return this.message.sender
? this.message.sender.avatar_url
: displayImage;
}, },
hasRecordedResponse() { hasRecordedResponse() {
return ( return (

View file

@ -8,9 +8,15 @@ class ActionCableConnector extends BaseActionCableConnector {
'message.updated': this.onMessageUpdated, 'message.updated': this.onMessageUpdated,
'conversation.typing_on': this.onTypingOn, 'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff, 'conversation.typing_off': this.onTypingOff,
'conversation.resolved': this.onStatusChange,
'conversation.opened': this.onStatusChange,
}; };
} }
onStatusChange = data => {
this.app.$store.dispatch('conversationAttributes/update', data);
};
onMessageCreated = data => { onMessageCreated = data => {
this.app.$store.dispatch('conversation/addMessage', data); this.app.$store.dispatch('conversation/addMessage', data);
}; };

View file

@ -0,0 +1,19 @@
export default {
computed: {
hideInputForBotConversations() {
return window.chatwootWebChannel.hideInputForBotConversations;
},
useInboxAvatarForBot() {
return window.chatwootWidgetDefaults.useInboxAvatarForBot;
},
hasAConnectedAgentBot() {
return !!window.chatwootWebChannel.hasAConnectedAgentBot;
},
inboxAvatarUrl() {
return window.chatwootWebChannel.avatarUrl;
},
channelConfig() {
return window.chatwootWebChannel;
},
},
};

View file

@ -0,0 +1,35 @@
import { createWrapper } from '@vue/test-utils';
import configMixin from '../configMixin';
import Vue from 'vue';
global.chatwootWebChannel = {
hideInputForBotConversations: true,
avatarUrl: 'https://test.url',
hasAConnectedAgentBot: 'AgentBot',
};
global.chatwootWidgetDefaults = {
useInboxAvatarForBot: true,
};
describe('configMixin', () => {
test('returns config', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [configMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.hideInputForBotConversations).toBe(true);
expect(wrapper.vm.hasAConnectedAgentBot).toBe(true);
expect(wrapper.vm.useInboxAvatarForBot).toBe(true);
expect(wrapper.vm.inboxAvatarUrl).toBe('https://test.url');
expect(wrapper.vm.channelConfig).toEqual({
hideInputForBotConversations: true,
avatarUrl: 'https://test.url',
hasAConnectedAgentBot: 'AgentBot',
});
});
});

View file

@ -4,6 +4,7 @@ import agent from 'widget/store/modules/agent';
import appConfig from 'widget/store/modules/appConfig'; import appConfig from 'widget/store/modules/appConfig';
import contacts from 'widget/store/modules/contacts'; import contacts from 'widget/store/modules/contacts';
import conversation from 'widget/store/modules/conversation'; import conversation from 'widget/store/modules/conversation';
import conversationAttributes from 'widget/store/modules/conversationAttributes';
import conversationLabels from 'widget/store/modules/conversationLabels'; import conversationLabels from 'widget/store/modules/conversationLabels';
import events from 'widget/store/modules/events'; import events from 'widget/store/modules/events';
import message from 'widget/store/modules/message'; import message from 'widget/store/modules/message';
@ -16,6 +17,7 @@ export default new Vuex.Store({
appConfig, appConfig,
contacts, contacts,
conversation, conversation,
conversationAttributes,
conversationLabels, conversationLabels,
events, events,
message, message,

View file

@ -2,7 +2,7 @@
import Vue from 'vue'; import Vue from 'vue';
import { import {
sendMessageAPI, sendMessageAPI,
getConversationAPI, getMessagesAPI,
sendAttachmentAPI, sendAttachmentAPI,
toggleTyping, toggleTyping,
} from 'widget/api/conversation'; } from 'widget/api/conversation';
@ -116,7 +116,7 @@ export const actions = {
fetchOldConversations: async ({ commit }, { before } = {}) => { fetchOldConversations: async ({ commit }, { before } = {}) => {
try { try {
commit('setConversationListLoading', true); commit('setConversationListLoading', true);
const { data } = await getConversationAPI({ before }); const { data } = await getMessagesAPI({ before });
commit('setMessagesInConversation', data); commit('setMessagesInConversation', data);
commit('setConversationListLoading', false); commit('setConversationListLoading', false);
} catch (error) { } catch (error) {

View file

@ -0,0 +1,49 @@
import {
SET_CONVERSATION_ATTRIBUTES,
UPDATE_CONVERSATION_ATTRIBUTES,
} from '../types';
import { getConversationAPI } from '../../api/conversation';
const state = {
id: '',
status: '',
};
export const getters = {
getConversationParams: $state => $state,
};
export const actions = {
get: async ({ commit }) => {
try {
const { data } = await getConversationAPI();
commit(SET_CONVERSATION_ATTRIBUTES, data);
} catch (error) {
// Ignore error
}
},
update({ commit }, data) {
commit(UPDATE_CONVERSATION_ATTRIBUTES, data);
},
};
export const mutations = {
[SET_CONVERSATION_ATTRIBUTES]($state, data) {
$state.id = data.id;
$state.status = data.status;
},
[UPDATE_CONVERSATION_ATTRIBUTES]($state, data) {
if (data.id === $state.id) {
$state.id = data.id;
$state.status = data.status;
}
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -0,0 +1,32 @@
import { actions } from '../../conversationAttributes';
import { API } from 'widget/helpers/axios';
const commit = jest.fn();
jest.mock('widget/helpers/axios');
describe('#actions', () => {
describe('#update', () => {
it('sends mutation if api is success', async () => {
API.get.mockResolvedValue({ data: { id: 1, status: 'bot' } });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
['SET_CONVERSATION_ATTRIBUTES', { id: 1, status: 'bot' }],
]);
});
it('doesnot send mutation if api is error', async () => {
API.get.mockRejectedValue({ message: 'Invalid Headers' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([]);
});
});
describe('#update', () => {
it('sends correct mutations', () => {
actions.update({ commit }, { id: 1, status: 'bot' });
expect(commit).toBeCalledWith('UPDATE_CONVERSATION_ATTRIBUTES', {
id: 1,
status: 'bot',
});
});
});
});

View file

@ -0,0 +1,14 @@
import { getters } from '../../conversationAttributes';
describe('#getters', () => {
it('getConversationParams', () => {
const state = {
id: 1,
status: 'bot',
};
expect(getters.getConversationParams(state)).toEqual({
id: 1,
status: 'bot',
});
});
});

View file

@ -0,0 +1,33 @@
import { mutations } from '../../conversationAttributes';
describe('#mutations', () => {
describe('#SET_CONVERSATION_ATTRIBUTES', () => {
it('set status of the conversation', () => {
const state = { id: '', status: '' };
mutations.SET_CONVERSATION_ATTRIBUTES(state, {
id: 1,
status: 'open',
});
expect(state).toEqual({ id: 1, status: 'open' });
});
});
describe('#UPDATE_CONVERSATION_ATTRIBUTES', () => {
it('update status if it is same conversation', () => {
const state = { id: 1, status: 'bot' };
mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, {
id: 1,
status: 'open',
});
expect(state).toEqual({ id: 1, status: 'open' });
});
it('doesnot update status if it is not the same conversation', () => {
const state = { id: 1, status: 'bot' };
mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, {
id: 2,
status: 'open',
});
expect(state).toEqual({ id: 1, status: 'bot' });
});
});
});

View file

@ -1 +1,3 @@
export const SET_WIDGET_COLOR = 'SET_WIDGET_COLOR'; export const SET_WIDGET_COLOR = 'SET_WIDGET_COLOR';
export const SET_CONVERSATION_ATTRIBUTES = 'SET_CONVERSATION_ATTRIBUTES';
export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';

View file

@ -2,7 +2,7 @@
<div class="home"> <div class="home">
<div class="header-wrap"> <div class="header-wrap">
<ChatHeaderExpanded <ChatHeaderExpanded
v-if="isHeaderExpanded" v-if="isHeaderExpanded && !hideWelcomeHeader"
:intro-heading="introHeading" :intro-heading="introHeading"
:intro-body="introBody" :intro-body="introBody"
:avatar-url="channelConfig.avatarUrl" :avatar-url="channelConfig.avatarUrl"
@ -16,7 +16,7 @@
<AvailableAgents v-if="showAvailableAgents" :agents="availableAgents" /> <AvailableAgents v-if="showAvailableAgents" :agents="availableAgents" />
<ConversationWrap :grouped-messages="groupedMessages" /> <ConversationWrap :grouped-messages="groupedMessages" />
<div class="footer-wrap"> <div class="footer-wrap">
<div class="input-wrap"> <div v-if="showInputTextArea" class="input-wrap">
<ChatFooter /> <ChatFooter />
</div> </div>
<branding></branding> <branding></branding>
@ -33,6 +33,7 @@ import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue';
import ChatHeader from 'widget/components/ChatHeader.vue'; import ChatHeader from 'widget/components/ChatHeader.vue';
import ConversationWrap from 'widget/components/ConversationWrap.vue'; import ConversationWrap from 'widget/components/ConversationWrap.vue';
import AvailableAgents from 'widget/components/AvailableAgents.vue'; import AvailableAgents from 'widget/components/AvailableAgents.vue';
import configMixin from '../mixins/configMixin';
export default { export default {
name: 'Home', name: 'Home',
@ -44,30 +45,41 @@ export default {
Branding, Branding,
AvailableAgents, AvailableAgents,
}, },
mixins: [configMixin],
computed: { computed: {
...mapGetters({ ...mapGetters({
groupedMessages: 'conversation/getGroupedConversation', groupedMessages: 'conversation/getGroupedConversation',
conversationSize: 'conversation/getConversationSize', conversationSize: 'conversation/getConversationSize',
availableAgents: 'agent/availableAgents', availableAgents: 'agent/availableAgents',
hasFetched: 'agent/uiFlags/hasFetched', hasFetched: 'agent/uiFlags/hasFetched',
conversationAttributes: 'conversationAttributes/getConversationParams',
}), }),
isOpen() {
return this.conversationAttributes.status === 'open';
},
showInputTextArea() {
if (this.hideInputForBotConversations) {
if (this.isOpen) {
return true;
}
return false;
}
return true;
},
isHeaderExpanded() { isHeaderExpanded() {
return this.conversationSize === 0; return this.conversationSize === 0;
}, },
channelConfig() {
return window.chatwootWebChannel;
},
showAvailableAgents() { showAvailableAgents() {
return this.availableAgents.length > 0 && this.conversationSize < 1; return this.availableAgents.length > 0 && this.conversationSize < 1;
}, },
introHeading() { introHeading() {
return this.channelConfig.welcomeTitle || 'Hi there ! 🙌🏼'; return this.channelConfig.welcomeTitle;
}, },
introBody() { introBody() {
return ( return this.channelConfig.welcomeTagline;
this.channelConfig.welcomeTagline || },
'We make it simple to connect with us. Ask us anything, or share your feedback.' hideWelcomeHeader() {
); return !(this.introHeading || this.introBody);
}, },
}, },
}; };

View file

@ -32,14 +32,16 @@ class ActionCableListener < BaseListener
def conversation_resolved(event) def conversation_resolved(event)
conversation, account, timestamp = extract_conversation_and_account(event) conversation, account, timestamp = extract_conversation_and_account(event)
tokens = user_tokens(account, conversation.inbox.members) + [conversation.contact&.pubsub_token]
broadcast(user_tokens(account, conversation.inbox.members), CONVERSATION_RESOLVED, conversation.push_event_data) broadcast(tokens, CONVERSATION_RESOLVED, conversation.push_event_data)
end end
def conversation_opened(event) def conversation_opened(event)
conversation, account, timestamp = extract_conversation_and_account(event) conversation, account, timestamp = extract_conversation_and_account(event)
tokens = user_tokens(account, conversation.inbox.members) + [conversation.contact&.pubsub_token]
broadcast(user_tokens(account, conversation.inbox.members), CONVERSATION_OPENED, conversation.push_event_data) broadcast(tokens, CONVERSATION_OPENED, conversation.push_event_data)
end end
def conversation_lock_toggle(event) def conversation_lock_toggle(event)

View file

@ -2,12 +2,13 @@
# #
# Table name: agent_bots # Table name: agent_bots
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# description :string # description :string
# name :string # hide_input_for_bot_conversations :boolean default(FALSE)
# outgoing_url :string # name :string
# created_at :datetime not null # outgoing_url :string
# updated_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null
# #
class AgentBot < ApplicationRecord class AgentBot < ApplicationRecord

View file

@ -0,0 +1,5 @@
if @conversation
json.id @conversation.display_id
json.inbox_id @conversation.inbox_id
json.status @conversation.status
end

View file

@ -7,6 +7,8 @@
<script> <script>
window.chatwootWebChannel = { window.chatwootWebChannel = {
avatarUrl: '<%= @web_widget.inbox.avatar_url %>', avatarUrl: '<%= @web_widget.inbox.avatar_url %>',
hasAConnectedAgentBot: '<%= @web_widget.inbox.agent_bot&.name %>',
hideInputForBotConversations: <%= ActiveModel::Type::Boolean.new.cast(@web_widget.inbox.agent_bot&.hide_input_for_bot_conversations) %>,
locale: '<%= @web_widget.account.locale %>', locale: '<%= @web_widget.account.locale %>',
websiteName: '<%= @web_widget.inbox.name %>', websiteName: '<%= @web_widget.inbox.name %>',
websiteToken: '<%= @web_widget.website_token %>', websiteToken: '<%= @web_widget.website_token %>',
@ -14,6 +16,9 @@
welcomeTitle: '<%= @web_widget.welcome_title %>', welcomeTitle: '<%= @web_widget.welcome_title %>',
widgetColor: '<%= @web_widget.widget_color %>', widgetColor: '<%= @web_widget.widget_color %>',
} }
window.chatwootWidgetDefaults = {
useInboxAvatarForBot: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) %>,
}
window.chatwootPubsubToken = '<%= @contact.pubsub_token %>' window.chatwootPubsubToken = '<%= @contact.pubsub_token %>'
window.authToken = '<%= @token %>' window.authToken = '<%= @token %>'
</script> </script>

View file

@ -0,0 +1,5 @@
class AddHideInputFlagToBotConfig < ActiveRecord::Migration[6.0]
def change
add_column :agent_bots, :hide_input_for_bot_conversations, :boolean, default: false
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_05_04_144712) do ActiveRecord::Schema.define(version: 2020_05_09_044639) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
@ -94,6 +94,7 @@ ActiveRecord::Schema.define(version: 2020_05_04_144712) do
t.string "outgoing_url" t.string "outgoing_url"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.boolean "hide_input_for_bot_conversations", default: false
end end
create_table "attachments", id: :serial, force: :cascade do |t| create_table "attachments", id: :serial, force: :cascade do |t|

View file

@ -24,4 +24,22 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
end end
end end
end end
describe 'POST /api/v1/widget/conversations' do
context 'with a conversation' do
it 'returns the correct conversation params' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
get '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['id']).to eq(conversation.display_id)
expect(json_response['status']).to eq(conversation.status)
end
end
end
end end