Feature: Customise widget for bot conversations (#834)
* Feature: Customise widget for bot conversations
This commit is contained in:
parent
05ea6308f2
commit
f28ec29b8c
24 changed files with 298 additions and 26 deletions
|
@ -106,3 +106,6 @@ CHARGEBEE_WEBHOOK_PASSWORD=
|
|||
## generate a new key value here : https://d3v.one/vapid-key-generator/
|
||||
# VAPID_PUBLIC_KEY=
|
||||
# VAPID_PRIVATE_KEY=
|
||||
|
||||
## Bot Customizations
|
||||
USE_INBOX_AVATAR_FOR_BOT=true
|
||||
|
|
|
@ -3,6 +3,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||
before_action :set_web_widget
|
||||
before_action :set_contact
|
||||
|
||||
def index
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def toggle_typing
|
||||
head :ok && return if conversation.nil?
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
class="button block"
|
||||
type="submit"
|
||||
:disabled="!isFormValid"
|
||||
:style="{ background: widgetColor, borderColor: widgetColor }"
|
||||
>
|
||||
{{ $t('COMPONENTS.FORM_BUBBLE.SUBMIT') }}
|
||||
</button>
|
||||
|
@ -32,6 +33,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
|
@ -49,6 +51,9 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
}),
|
||||
isFormValid() {
|
||||
return this.items.reduce((acc, { name }) => {
|
||||
return !!this.formValues[name] && acc;
|
||||
|
|
|
@ -60,6 +60,8 @@ export default {
|
|||
this.$store.dispatch('contacts/update', message);
|
||||
}
|
||||
});
|
||||
|
||||
this.$store.dispatch('conversationAttributes/get');
|
||||
},
|
||||
methods: {
|
||||
...mapActions('appConfig', ['setWidgetColor']),
|
||||
|
|
|
@ -13,12 +13,16 @@ const sendAttachmentAPI = async attachment => {
|
|||
return result;
|
||||
};
|
||||
|
||||
const getConversationAPI = async ({ before }) => {
|
||||
const getMessagesAPI = async ({ before }) => {
|
||||
const urlData = endPoints.getConversation({ before });
|
||||
const result = await API.get(urlData.url, { params: urlData.params });
|
||||
return result;
|
||||
};
|
||||
|
||||
const getConversationAPI = async () => {
|
||||
return API.get(`/api/v1/widget/conversations${window.location.search}`);
|
||||
};
|
||||
|
||||
const toggleTyping = async ({ typingStatus }) => {
|
||||
return API.post(
|
||||
`/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,
|
||||
};
|
||||
|
|
|
@ -53,6 +53,7 @@ import ImageBubble from 'widget/components/ImageBubble';
|
|||
import FileBubble from 'widget/components/FileBubble';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
|
||||
export default {
|
||||
name: 'AgentMessage',
|
||||
|
@ -63,7 +64,7 @@ export default {
|
|||
UserMessage,
|
||||
FileBubble,
|
||||
},
|
||||
mixins: [timeMixin],
|
||||
mixins: [timeMixin, configMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
|
@ -112,11 +113,17 @@ export default {
|
|||
avatarUrl() {
|
||||
// eslint-disable-next-line
|
||||
const BotImage = require('dashboard/assets/images/chatwoot_bot.png');
|
||||
const displayImage = this.useInboxAvatarForBot
|
||||
? this.inboxAvatarUrl
|
||||
: BotImage;
|
||||
|
||||
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() {
|
||||
return (
|
||||
|
|
|
@ -8,9 +8,15 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||
'message.updated': this.onMessageUpdated,
|
||||
'conversation.typing_on': this.onTypingOn,
|
||||
'conversation.typing_off': this.onTypingOff,
|
||||
'conversation.resolved': this.onStatusChange,
|
||||
'conversation.opened': this.onStatusChange,
|
||||
};
|
||||
}
|
||||
|
||||
onStatusChange = data => {
|
||||
this.app.$store.dispatch('conversationAttributes/update', data);
|
||||
};
|
||||
|
||||
onMessageCreated = data => {
|
||||
this.app.$store.dispatch('conversation/addMessage', data);
|
||||
};
|
||||
|
|
19
app/javascript/widget/mixins/configMixin.js
Normal file
19
app/javascript/widget/mixins/configMixin.js
Normal 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;
|
||||
},
|
||||
},
|
||||
};
|
35
app/javascript/widget/mixins/specs/configMixin.spec.js
Normal file
35
app/javascript/widget/mixins/specs/configMixin.spec.js
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,6 +4,7 @@ import agent from 'widget/store/modules/agent';
|
|||
import appConfig from 'widget/store/modules/appConfig';
|
||||
import contacts from 'widget/store/modules/contacts';
|
||||
import conversation from 'widget/store/modules/conversation';
|
||||
import conversationAttributes from 'widget/store/modules/conversationAttributes';
|
||||
import conversationLabels from 'widget/store/modules/conversationLabels';
|
||||
import events from 'widget/store/modules/events';
|
||||
import message from 'widget/store/modules/message';
|
||||
|
@ -16,6 +17,7 @@ export default new Vuex.Store({
|
|||
appConfig,
|
||||
contacts,
|
||||
conversation,
|
||||
conversationAttributes,
|
||||
conversationLabels,
|
||||
events,
|
||||
message,
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import Vue from 'vue';
|
||||
import {
|
||||
sendMessageAPI,
|
||||
getConversationAPI,
|
||||
getMessagesAPI,
|
||||
sendAttachmentAPI,
|
||||
toggleTyping,
|
||||
} from 'widget/api/conversation';
|
||||
|
@ -116,7 +116,7 @@ export const actions = {
|
|||
fetchOldConversations: async ({ commit }, { before } = {}) => {
|
||||
try {
|
||||
commit('setConversationListLoading', true);
|
||||
const { data } = await getConversationAPI({ before });
|
||||
const { data } = await getMessagesAPI({ before });
|
||||
commit('setMessagesInConversation', data);
|
||||
commit('setConversationListLoading', false);
|
||||
} catch (error) {
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1 +1,3 @@
|
|||
export const SET_WIDGET_COLOR = 'SET_WIDGET_COLOR';
|
||||
export const SET_CONVERSATION_ATTRIBUTES = 'SET_CONVERSATION_ATTRIBUTES';
|
||||
export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div class="home">
|
||||
<div class="header-wrap">
|
||||
<ChatHeaderExpanded
|
||||
v-if="isHeaderExpanded"
|
||||
v-if="isHeaderExpanded && !hideWelcomeHeader"
|
||||
:intro-heading="introHeading"
|
||||
:intro-body="introBody"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
|
@ -16,7 +16,7 @@
|
|||
<AvailableAgents v-if="showAvailableAgents" :agents="availableAgents" />
|
||||
<ConversationWrap :grouped-messages="groupedMessages" />
|
||||
<div class="footer-wrap">
|
||||
<div class="input-wrap">
|
||||
<div v-if="showInputTextArea" class="input-wrap">
|
||||
<ChatFooter />
|
||||
</div>
|
||||
<branding></branding>
|
||||
|
@ -33,6 +33,7 @@ import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue';
|
|||
import ChatHeader from 'widget/components/ChatHeader.vue';
|
||||
import ConversationWrap from 'widget/components/ConversationWrap.vue';
|
||||
import AvailableAgents from 'widget/components/AvailableAgents.vue';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
|
@ -44,30 +45,41 @@ export default {
|
|||
Branding,
|
||||
AvailableAgents,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
groupedMessages: 'conversation/getGroupedConversation',
|
||||
conversationSize: 'conversation/getConversationSize',
|
||||
availableAgents: 'agent/availableAgents',
|
||||
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() {
|
||||
return this.conversationSize === 0;
|
||||
},
|
||||
channelConfig() {
|
||||
return window.chatwootWebChannel;
|
||||
},
|
||||
showAvailableAgents() {
|
||||
return this.availableAgents.length > 0 && this.conversationSize < 1;
|
||||
},
|
||||
introHeading() {
|
||||
return this.channelConfig.welcomeTitle || 'Hi there ! 🙌🏼';
|
||||
return this.channelConfig.welcomeTitle;
|
||||
},
|
||||
introBody() {
|
||||
return (
|
||||
this.channelConfig.welcomeTagline ||
|
||||
'We make it simple to connect with us. Ask us anything, or share your feedback.'
|
||||
);
|
||||
return this.channelConfig.welcomeTagline;
|
||||
},
|
||||
hideWelcomeHeader() {
|
||||
return !(this.introHeading || this.introBody);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -32,14 +32,16 @@ class ActionCableListener < BaseListener
|
|||
|
||||
def conversation_resolved(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
|
||||
|
||||
def conversation_opened(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
|
||||
|
||||
def conversation_lock_toggle(event)
|
||||
|
|
|
@ -2,12 +2,13 @@
|
|||
#
|
||||
# Table name: agent_bots
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :string
|
||||
# name :string
|
||||
# outgoing_url :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# id :bigint not null, primary key
|
||||
# description :string
|
||||
# hide_input_for_bot_conversations :boolean default(FALSE)
|
||||
# name :string
|
||||
# outgoing_url :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
|
||||
class AgentBot < ApplicationRecord
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
if @conversation
|
||||
json.id @conversation.display_id
|
||||
json.inbox_id @conversation.inbox_id
|
||||
json.status @conversation.status
|
||||
end
|
|
@ -7,6 +7,8 @@
|
|||
<script>
|
||||
window.chatwootWebChannel = {
|
||||
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 %>',
|
||||
websiteName: '<%= @web_widget.inbox.name %>',
|
||||
websiteToken: '<%= @web_widget.website_token %>',
|
||||
|
@ -14,6 +16,9 @@
|
|||
welcomeTitle: '<%= @web_widget.welcome_title %>',
|
||||
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.authToken = '<%= @token %>'
|
||||
</script>
|
||||
|
|
|
@ -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
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# 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
|
||||
enable_extension "pgcrypto"
|
||||
|
@ -94,6 +94,7 @@ ActiveRecord::Schema.define(version: 2020_05_04_144712) do
|
|||
t.string "outgoing_url"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.boolean "hide_input_for_bot_conversations", default: false
|
||||
end
|
||||
|
||||
create_table "attachments", id: :serial, force: :cascade do |t|
|
||||
|
|
|
@ -24,4 +24,22 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
|
|||
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
|
||||
|
|
Loading…
Reference in a new issue