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:
parent
33e0bd434b
commit
83b0bb4062
20 changed files with 406 additions and 34 deletions
|
@ -4,13 +4,13 @@
|
|||
v-if="!imgError && Boolean(src)"
|
||||
id="image"
|
||||
:src="src"
|
||||
class="user-thumbnail"
|
||||
:class="thumbnailClass"
|
||||
@error="onImgError()"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:username="username"
|
||||
class="user-thumbnail"
|
||||
:class="thumbnailClass"
|
||||
background-color="#1f93ff"
|
||||
color="white"
|
||||
:size="avatarSize"
|
||||
|
@ -71,6 +71,10 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasBorder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -89,6 +93,10 @@ export default {
|
|||
const statusSize = `${this.avatarSize / 4}px`;
|
||||
return { width: statusSize, height: statusSize };
|
||||
},
|
||||
thumbnailClass() {
|
||||
const classname = this.hasBorder ? 'border' : '';
|
||||
return `user-thumbnail ${classname}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onImgError() {
|
||||
|
@ -111,6 +119,11 @@ export default {
|
|||
border-radius: 50%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.border {
|
||||
border: 1px solid white;
|
||||
}
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
|
|
|
@ -12,6 +12,7 @@ import { IFrameHelper } from 'widget/helpers/utils';
|
|||
export default {
|
||||
name: 'App',
|
||||
mounted() {
|
||||
const { website_token: websiteToken = '' } = window.chatwootWebChannel;
|
||||
if (IFrameHelper.isIFrame()) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'loaded',
|
||||
|
@ -25,15 +26,16 @@ export default {
|
|||
this.setWidgetColor(window.chatwootWebChannel);
|
||||
|
||||
window.addEventListener('message', e => {
|
||||
if (
|
||||
typeof e.data !== 'string' ||
|
||||
e.data.indexOf('chatwoot-widget:') !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
|
||||
const wootPrefix = 'chatwoot-widget:';
|
||||
const isDataNotString = typeof e.data !== 'string';
|
||||
const isNotFromWoot = isDataNotString || e.data.indexOf(wootPrefix) !== 0;
|
||||
|
||||
if (isNotFromWoot) return;
|
||||
|
||||
const message = JSON.parse(e.data.replace(wootPrefix, ''));
|
||||
if (message.event === 'config-set') {
|
||||
this.fetchOldConversations();
|
||||
this.fetchAvailableAgents(websiteToken);
|
||||
} else if (message.event === 'widget-visible') {
|
||||
this.scrollConversationToBottom();
|
||||
} else if (message.event === 'set-current-url') {
|
||||
|
@ -44,6 +46,7 @@ export default {
|
|||
methods: {
|
||||
...mapActions('appConfig', ['setWidgetColor']),
|
||||
...mapActions('conversation', ['fetchOldConversations']),
|
||||
...mapActions('agent', ['fetchAvailableAgents']),
|
||||
scrollConversationToBottom() {
|
||||
const container = this.$el.querySelector('.conversation-wrap');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
|
8
app/javascript/widget/api/agent.js
Normal file
8
app/javascript/widget/api/agent.js
Normal 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;
|
||||
};
|
|
@ -18,8 +18,16 @@ const updateContact = id => ({
|
|||
url: `/api/v1/widget/messages/${id}${window.location.search}`,
|
||||
});
|
||||
|
||||
const getAvailableAgents = token => ({
|
||||
url: '/api/v1/widget/inbox_members',
|
||||
params: {
|
||||
website_token: token,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
sendMessage,
|
||||
getConversation,
|
||||
updateContact,
|
||||
getAvailableAgents,
|
||||
};
|
||||
|
|
|
@ -36,14 +36,14 @@ $color-shadow-outline: rgba(66, 153, 225, 0.5);
|
|||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
|
||||
@mixin shadow-medium {
|
||||
box-shadow: 0 4px 6px -8px $color-shadow-medium,
|
||||
0 2px 4px -4px $color-shadow-light;
|
||||
box-shadow: 0 4px 24px 8px $color-shadow-medium,
|
||||
0 2px 16px 4px $color-shadow-light;
|
||||
}
|
||||
|
||||
|
||||
|
|
79
app/javascript/widget/components/AvailableAgents.vue
Normal file
79
app/javascript/widget/components/AvailableAgents.vue
Normal 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>
|
|
@ -43,14 +43,10 @@ export default {
|
|||
.header-collapsed {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: $color-white;
|
||||
padding: $space-two $space-medium;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: $color-white;
|
||||
border-bottom-left-radius: $space-small;
|
||||
border-bottom-right-radius: $space-small;
|
||||
@include shadow-large;
|
||||
|
||||
.title {
|
||||
font-size: $font-size-large;
|
||||
|
|
|
@ -44,16 +44,9 @@ export default {
|
|||
@import '~widget/assets/scss/mixins.scss';
|
||||
|
||||
.header-expanded {
|
||||
background: $color-white;
|
||||
padding: $space-larger $space-medium $space-large;
|
||||
width: 100%;
|
||||
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 {
|
||||
width: 64px;
|
||||
|
@ -71,7 +64,7 @@ export default {
|
|||
.body {
|
||||
color: $color-body;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.6;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
47
app/javascript/widget/components/GroupedAvatars.vue
Normal file
47
app/javascript/widget/components/GroupedAvatars.vue
Normal 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>
|
26
app/javascript/widget/helpers/specs/utils.spec.js
Normal file
26
app/javascript/widget/helpers/specs/utils.spec.js
Normal 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');
|
||||
});
|
||||
});
|
|
@ -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`;
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@ import Vuex from 'vuex';
|
|||
import appConfig from 'widget/store/modules/appConfig';
|
||||
import contact from 'widget/store/modules/contact';
|
||||
import conversation from 'widget/store/modules/conversation';
|
||||
import agent from 'widget/store/modules/agent';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
|
@ -11,5 +12,6 @@ export default new Vuex.Store({
|
|||
appConfig,
|
||||
contact,
|
||||
conversation,
|
||||
agent,
|
||||
},
|
||||
});
|
||||
|
|
50
app/javascript/widget/store/modules/agent.js
Normal file
50
app/javascript/widget/store/modules/agent.js
Normal 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,
|
||||
};
|
|
@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
26
app/javascript/widget/store/modules/specs/agent/data.js
Normal file
26
app/javascript/widget/store/modules/specs/agent/data.js
Normal 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',
|
||||
},
|
||||
];
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,6 +4,7 @@
|
|||
<ChatHeaderExpanded v-if="isHeaderExpanded" />
|
||||
<ChatHeader v-else :title="getHeaderName" />
|
||||
</div>
|
||||
<AvailableAgents v-if="showAvailableAgents" :agents="availableAgents" />
|
||||
<ConversationWrap :grouped-messages="groupedMessages" />
|
||||
<div class="footer-wrap">
|
||||
<div class="input-wrap">
|
||||
|
@ -22,6 +23,7 @@ import ChatFooter from 'widget/components/ChatFooter.vue';
|
|||
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';
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
|
@ -31,19 +33,14 @@ export default {
|
|||
ConversationWrap,
|
||||
ChatHeader,
|
||||
Branding,
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage']),
|
||||
handleSendMessage(content) {
|
||||
this.sendMessage({
|
||||
content,
|
||||
});
|
||||
},
|
||||
AvailableAgents,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
groupedMessages: 'conversation/getGroupedConversation',
|
||||
conversationSize: 'conversation/getConversationSize',
|
||||
availableAgents: 'agent/availableAgents',
|
||||
hasFetched: 'agent/uiFlags/hasFetched',
|
||||
}),
|
||||
isHeaderExpanded() {
|
||||
return this.conversationSize === 0;
|
||||
|
@ -51,6 +48,18 @@ export default {
|
|||
getHeaderName() {
|
||||
return window.chatwootWebChannel.website_name;
|
||||
},
|
||||
showAvailableAgents() {
|
||||
return this.availableAgents.length > 0;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage']),
|
||||
handleSendMessage(content) {
|
||||
this.sendMessage({
|
||||
content,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -68,6 +77,13 @@ export default {
|
|||
|
||||
.header-wrap {
|
||||
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 {
|
||||
|
@ -94,7 +110,7 @@ export default {
|
|||
}
|
||||
|
||||
.input-wrap {
|
||||
padding: 0 $space-medium;
|
||||
padding: 0 $space-normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
json.payload do
|
||||
json.array! @inbox_members do |inbox_member|
|
||||
json.id inbox_member.user.id
|
||||
json.name inbox_member.user.name
|
||||
json.avatar_url inbox_member.user.avatar_url
|
||||
json.availability_status inbox_member.user.availability_status
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
<script>
|
||||
window.chatwootWebChannel = {
|
||||
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.authToken = '<%= @token %>'
|
||||
|
|
Loading…
Reference in a new issue