Feature: As an end-user, I should be able to see the list of agents in the widget. (#461)

Co-authored-by: Pranav Raj S <pranavrajs@gmail.com>
This commit is contained in:
Nithin David Thomas 2020-02-05 11:27:22 +05:30 committed by GitHub
parent 33e0bd434b
commit 83b0bb4062
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 406 additions and 34 deletions

View file

@ -4,13 +4,13 @@
v-if="!imgError && Boolean(src)" v-if="!imgError && Boolean(src)"
id="image" id="image"
:src="src" :src="src"
class="user-thumbnail" :class="thumbnailClass"
@error="onImgError()" @error="onImgError()"
/> />
<Avatar <Avatar
v-else v-else
:username="username" :username="username"
class="user-thumbnail" :class="thumbnailClass"
background-color="#1f93ff" background-color="#1f93ff"
color="white" color="white"
:size="avatarSize" :size="avatarSize"
@ -71,6 +71,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
hasBorder: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@ -89,6 +93,10 @@ export default {
const statusSize = `${this.avatarSize / 4}px`; const statusSize = `${this.avatarSize / 4}px`;
return { width: statusSize, height: statusSize }; return { width: statusSize, height: statusSize };
}, },
thumbnailClass() {
const classname = this.hasBorder ? 'border' : '';
return `user-thumbnail ${classname}`;
},
}, },
methods: { methods: {
onImgError() { onImgError() {
@ -111,6 +119,11 @@ export default {
border-radius: 50%; border-radius: 50%;
height: 100%; height: 100%;
width: 100%; width: 100%;
box-sizing: border-box;
&.border {
border: 1px solid white;
}
} }
.source-badge { .source-badge {

View file

@ -12,6 +12,7 @@ import { IFrameHelper } from 'widget/helpers/utils';
export default { export default {
name: 'App', name: 'App',
mounted() { mounted() {
const { website_token: websiteToken = '' } = window.chatwootWebChannel;
if (IFrameHelper.isIFrame()) { if (IFrameHelper.isIFrame()) {
IFrameHelper.sendMessage({ IFrameHelper.sendMessage({
event: 'loaded', event: 'loaded',
@ -25,15 +26,16 @@ export default {
this.setWidgetColor(window.chatwootWebChannel); this.setWidgetColor(window.chatwootWebChannel);
window.addEventListener('message', e => { window.addEventListener('message', e => {
if ( const wootPrefix = 'chatwoot-widget:';
typeof e.data !== 'string' || const isDataNotString = typeof e.data !== 'string';
e.data.indexOf('chatwoot-widget:') !== 0 const isNotFromWoot = isDataNotString || e.data.indexOf(wootPrefix) !== 0;
) {
return; if (isNotFromWoot) return;
}
const message = JSON.parse(e.data.replace('chatwoot-widget:', '')); const message = JSON.parse(e.data.replace(wootPrefix, ''));
if (message.event === 'config-set') { if (message.event === 'config-set') {
this.fetchOldConversations(); this.fetchOldConversations();
this.fetchAvailableAgents(websiteToken);
} else if (message.event === 'widget-visible') { } else if (message.event === 'widget-visible') {
this.scrollConversationToBottom(); this.scrollConversationToBottom();
} else if (message.event === 'set-current-url') { } else if (message.event === 'set-current-url') {
@ -44,6 +46,7 @@ export default {
methods: { methods: {
...mapActions('appConfig', ['setWidgetColor']), ...mapActions('appConfig', ['setWidgetColor']),
...mapActions('conversation', ['fetchOldConversations']), ...mapActions('conversation', ['fetchOldConversations']),
...mapActions('agent', ['fetchAvailableAgents']),
scrollConversationToBottom() { scrollConversationToBottom() {
const container = this.$el.querySelector('.conversation-wrap'); const container = this.$el.querySelector('.conversation-wrap');
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;

View file

@ -0,0 +1,8 @@
import endPoints from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
export const getAvailableAgents = async websiteToken => {
const urlData = endPoints.getAvailableAgents(websiteToken);
const result = await API.get(urlData.url, { params: urlData.params });
return result;
};

View file

@ -18,8 +18,16 @@ const updateContact = id => ({
url: `/api/v1/widget/messages/${id}${window.location.search}`, url: `/api/v1/widget/messages/${id}${window.location.search}`,
}); });
const getAvailableAgents = token => ({
url: '/api/v1/widget/inbox_members',
params: {
website_token: token,
},
});
export default { export default {
sendMessage, sendMessage,
getConversation, getConversation,
updateContact, updateContact,
getAvailableAgents,
}; };

View file

@ -36,14 +36,14 @@ $color-shadow-outline: rgba(66, 153, 225, 0.5);
} }
@mixin shadow { @mixin shadow {
box-shadow: 0 1px 10px -4 $color-shadow-medium, box-shadow: 0 1px 10px 4px $color-shadow-medium,
0 1px 5px 2px $color-shadow-light; 0 1px 5px 2px $color-shadow-light;
} }
@mixin shadow-medium { @mixin shadow-medium {
box-shadow: 0 4px 6px -8px $color-shadow-medium, box-shadow: 0 4px 24px 8px $color-shadow-medium,
0 2px 4px -4px $color-shadow-light; 0 2px 16px 4px $color-shadow-light;
} }

View file

@ -0,0 +1,79 @@
<template>
<div class="available-agents">
<div class="toast-bg">
<div class="avatars-wrap">
<GroupedAvatars :users="users" />
</div>
<div class="title">
{{ title }}
</div>
</div>
</div>
</template>
<script>
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
import { getAvailableAgentsText } from 'widget/helpers/utils';
export default {
name: 'AvailableAgents',
components: { GroupedAvatars },
props: {
agents: {
type: Array,
default: () => [],
},
onClose: {
type: Function,
default: () => {},
},
},
computed: {
users() {
return this.agents.map(agent => ({
id: agent.id,
avatar: agent.avatar_url,
name: agent.name,
}));
},
title() {
return getAvailableAgentsText(this.agents);
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
@import '~widget/assets/scss/mixins.scss';
.available-agents {
display: flex;
position: relative;
justify-content: center;
margin: $space-normal $space-medium;
box-sizing: border-box;
.toast-bg {
border-radius: $space-large;
background: $color-body;
@include shadow-medium;
}
.title {
font-size: $font-size-default;
font-weight: $font-weight-medium;
color: $color-white;
padding: $space-one $space-normal $space-one $space-small;
line-height: 1.4;
display: inline-block;
vertical-align: middle;
}
.avatars-wrap {
display: inline-block;
vertical-align: middle;
margin-left: $space-small;
}
}
</style>

View file

@ -43,14 +43,10 @@ export default {
.header-collapsed { .header-collapsed {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
background: $color-white;
padding: $space-two $space-medium; padding: $space-two $space-medium;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
color: $color-white; color: $color-white;
border-bottom-left-radius: $space-small;
border-bottom-right-radius: $space-small;
@include shadow-large;
.title { .title {
font-size: $font-size-large; font-size: $font-size-large;

View file

@ -44,16 +44,9 @@ export default {
@import '~widget/assets/scss/mixins.scss'; @import '~widget/assets/scss/mixins.scss';
.header-expanded { .header-expanded {
background: $color-white;
padding: $space-larger $space-medium $space-large; padding: $space-larger $space-medium $space-large;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
border-radius: $space-normal;
@include shadow-large;
@media only screen and (min-device-width: 320px) and (max-device-width: 480px) {
border-radius: 0;
}
.logo { .logo {
width: 64px; width: 64px;
@ -71,7 +64,7 @@ export default {
.body { .body {
color: $color-body; color: $color-body;
font-size: 1.8rem; font-size: 1.8rem;
line-height: 1.6; line-height: 1.5;
} }
} }
</style> </style>

View file

@ -0,0 +1,47 @@
<template>
<div class="avatars">
<span v-for="user in users" :key="user.id" class="avatar">
<Thumbnail
size="24px"
:username="user.name"
status="online"
:src="user.avatar"
has-border
/>
</span>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
name: 'GroupedAvatars',
components: { Thumbnail },
props: {
users: {
type: Array,
default: () => [],
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
@import '~widget/assets/scss/mixins.scss';
.avatars {
display: inline-block;
padding-left: $space-one;
.avatar {
margin-left: -$space-slab;
position: relative;
display: inline-block;
overflow: hidden;
width: $space-medium;
height: $space-medium;
}
}
</style>

View file

@ -0,0 +1,26 @@
import { getAvailableAgentsText } from '../utils';
describe('#getAvailableAgentsText', () => {
it('returns the correct text is there is only one online agent', () => {
expect(getAvailableAgentsText([{ name: 'Pranav' }])).toEqual(
'Pranav is available'
);
});
it('returns the correct text is there are two online agents', () => {
expect(
getAvailableAgentsText([{ name: 'Pranav' }, { name: 'Nithin' }])
).toEqual('Pranav and Nithin is available');
});
it('returns the correct text is there are more than two online agents', () => {
expect(
getAvailableAgentsText([
{ name: 'Pranav' },
{ name: 'Nithin' },
{ name: 'Subin' },
{ name: 'Sojan' },
])
).toEqual('Pranav and 3 others are available');
});
});

View file

@ -18,3 +18,20 @@ export const IFrameHelper = {
); );
}, },
}; };
export const getAvailableAgentsText = (agents = []) => {
const count = agents.length;
if (count === 1) {
const [agent] = agents;
return `${agent.name} is available`;
}
if (count === 2) {
const [first, second] = agents;
return `${first.name} and ${second.name} is available`;
}
const [agent] = agents;
const rest = agents.length - 1;
return `${agent.name} and ${rest} others are available`;
};

View file

@ -3,6 +3,7 @@ import Vuex from 'vuex';
import appConfig from 'widget/store/modules/appConfig'; import appConfig from 'widget/store/modules/appConfig';
import contact from 'widget/store/modules/contact'; import contact from 'widget/store/modules/contact';
import conversation from 'widget/store/modules/conversation'; import conversation from 'widget/store/modules/conversation';
import agent from 'widget/store/modules/agent';
Vue.use(Vuex); Vue.use(Vuex);
@ -11,5 +12,6 @@ export default new Vuex.Store({
appConfig, appConfig,
contact, contact,
conversation, conversation,
agent,
}, },
}); });

View file

@ -0,0 +1,50 @@
import Vue from 'vue';
import { getAvailableAgents } from 'widget/api/agent';
const state = {
records: [],
uiFlags: {
isError: false,
hasFetched: false,
},
};
export const getters = {
availableAgents: $state =>
$state.records.filter(agent => agent.availability_status === 'online'),
};
export const actions = {
fetchAvailableAgents: async ({ commit }, websiteToken) => {
try {
const { data } = await getAvailableAgents(websiteToken);
const { payload = [] } = data;
commit('setAgents', payload);
commit('setError', false);
commit('setHasFetched', true);
} catch (error) {
commit('setError', true);
commit('setHasFetched', true);
}
},
};
export const mutations = {
setAgents($state, data) {
Vue.set($state, 'records', data);
},
setError($state, value) {
Vue.set($state.uiFlags, 'isError', value);
},
setHasFetched($state, value) {
Vue.set($state.uiFlags, 'hasFetched', value);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -0,0 +1,28 @@
import { API } from 'widget/helpers/axios';
import { actions } from '../../agent';
import { agents } from './data';
const commit = jest.fn();
jest.mock('widget/helpers/axios');
describe('#actions', () => {
describe('#fetchAvailableAgents', () => {
it('sends correct actions if API is success', async () => {
API.get.mockResolvedValue({ data: { payload: agents } });
await actions.fetchAvailableAgents({ commit }, 'Hi');
expect(commit.mock.calls).toEqual([
['setAgents', agents],
['setError', false],
['setHasFetched', true],
]);
});
it('sends correct actions if API is error', async () => {
API.get.mockRejectedValue({ message: 'Authentication required' });
await actions.fetchAvailableAgents({ commit }, 'Hi');
expect(commit.mock.calls).toEqual([
['setError', true],
['setHasFetched', true],
]);
});
});
});

View file

@ -0,0 +1,26 @@
export const agents = [
{
id: 1,
name: 'John',
avatar_url: '',
availability_status: 'online',
},
{
id: 2,
name: 'Xavier',
avatar_url: '',
availability_status: 'offline',
},
{
id: 3,
name: 'Pranav',
avatar_url: '',
availability_status: 'online',
},
{
id: 4,
name: 'Nithin',
avatar_url: '',
availability_status: 'online',
},
];

View file

@ -0,0 +1,30 @@
import { getters } from '../../agent';
import { agents } from './data';
describe('#getters', () => {
it('availableAgents', () => {
const state = {
records: agents,
};
expect(getters.availableAgents(state)).toEqual([
{
id: 1,
name: 'John',
avatar_url: '',
availability_status: 'online',
},
{
id: 3,
name: 'Pranav',
avatar_url: '',
availability_status: 'online',
},
{
id: 4,
name: 'Nithin',
avatar_url: '',
availability_status: 'online',
},
]);
});
});

View file

@ -0,0 +1,28 @@
import { mutations } from '../../agent';
import agents from './data';
describe('#mutations', () => {
describe('#setAgents', () => {
it('set agent records', () => {
const state = { records: [] };
mutations.setAgents(state, agents);
expect(state.records).toEqual(agents);
});
});
describe('#setError', () => {
it('set error flag', () => {
const state = { records: [], uiFlags: {} };
mutations.setError(state, true);
expect(state.uiFlags.isError).toEqual(true);
});
});
describe('#setError', () => {
it('set fetched flag', () => {
const state = { records: [], uiFlags: {} };
mutations.setHasFetched(state, true);
expect(state.uiFlags.hasFetched).toEqual(true);
});
});
});

View file

@ -4,6 +4,7 @@
<ChatHeaderExpanded v-if="isHeaderExpanded" /> <ChatHeaderExpanded v-if="isHeaderExpanded" />
<ChatHeader v-else :title="getHeaderName" /> <ChatHeader v-else :title="getHeaderName" />
</div> </div>
<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 class="input-wrap">
@ -22,6 +23,7 @@ import ChatFooter from 'widget/components/ChatFooter.vue';
import ChatHeaderExpanded from 'widget/components/ChatHeaderExpanded.vue'; 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';
export default { export default {
name: 'Home', name: 'Home',
@ -31,19 +33,14 @@ export default {
ConversationWrap, ConversationWrap,
ChatHeader, ChatHeader,
Branding, Branding,
}, AvailableAgents,
methods: {
...mapActions('conversation', ['sendMessage']),
handleSendMessage(content) {
this.sendMessage({
content,
});
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
groupedMessages: 'conversation/getGroupedConversation', groupedMessages: 'conversation/getGroupedConversation',
conversationSize: 'conversation/getConversationSize', conversationSize: 'conversation/getConversationSize',
availableAgents: 'agent/availableAgents',
hasFetched: 'agent/uiFlags/hasFetched',
}), }),
isHeaderExpanded() { isHeaderExpanded() {
return this.conversationSize === 0; return this.conversationSize === 0;
@ -51,6 +48,18 @@ export default {
getHeaderName() { getHeaderName() {
return window.chatwootWebChannel.website_name; return window.chatwootWebChannel.website_name;
}, },
showAvailableAgents() {
return this.availableAgents.length > 0;
},
},
methods: {
...mapActions('conversation', ['sendMessage']),
handleSendMessage(content) {
this.sendMessage({
content,
});
},
}, },
}; };
</script> </script>
@ -68,6 +77,13 @@ export default {
.header-wrap { .header-wrap {
flex-shrink: 0; flex-shrink: 0;
border-radius: $space-normal;
background: white;
@include shadow-large;
@media only screen and (min-device-width: 320px) and (max-device-width: 480px) {
border-radius: 0;
}
} }
.footer-wrap { .footer-wrap {
@ -94,7 +110,7 @@ export default {
} }
.input-wrap { .input-wrap {
padding: 0 $space-medium; padding: 0 $space-normal;
} }
} }
</style> </style>

View file

@ -1,5 +1,6 @@
json.payload do json.payload do
json.array! @inbox_members do |inbox_member| json.array! @inbox_members do |inbox_member|
json.id inbox_member.user.id
json.name inbox_member.user.name json.name inbox_member.user.name
json.avatar_url inbox_member.user.avatar_url json.avatar_url inbox_member.user.avatar_url
json.availability_status inbox_member.user.availability_status json.availability_status inbox_member.user.availability_status

View file

@ -7,7 +7,8 @@
<script> <script>
window.chatwootWebChannel = { window.chatwootWebChannel = {
website_name: '<%= @web_widget.website_name %>', website_name: '<%= @web_widget.website_name %>',
widget_color: '<%= @web_widget.widget_color %>' widget_color: '<%= @web_widget.widget_color %>',
website_token: '<%= @web_widget.website_token %>'
} }
window.chatwootPubsubToken = '<%= @contact.pubsub_token %>' window.chatwootPubsubToken = '<%= @contact.pubsub_token %>'
window.authToken = '<%= @token %>' window.authToken = '<%= @token %>'