Feature: View a contact's previous conversation (#422)
* Add API to fetch conversations of a contact * Add conversation list in sidebar
This commit is contained in:
parent
fc6a8c2601
commit
655c585358
19 changed files with 491 additions and 5 deletions
23
app/controllers/api/v1/contacts/conversations_controller.rb
Normal file
23
app/controllers/api/v1/contacts/conversations_controller.rb
Normal file
|
@ -0,0 +1,23 @@
|
|||
class Api::V1::Contacts::ConversationsController < Api::BaseController
|
||||
def index
|
||||
@conversations = current_account.conversations.includes(
|
||||
:assignee, :contact, :inbox
|
||||
).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox_ids
|
||||
if current_user.administrator?
|
||||
current_account.inboxes.pluck(:id)
|
||||
elsif current_user.agent?
|
||||
current_user.assigned_inboxes.pluck(:id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:contact_id)
|
||||
end
|
||||
end
|
|
@ -1,9 +1,14 @@
|
|||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class ContactAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('contacts');
|
||||
}
|
||||
|
||||
getConversations(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactAPI();
|
||||
|
|
14
app/javascript/dashboard/api/specs/contacts.spec.js
Normal file
14
app/javascript/dashboard/api/specs/contacts.spec.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import agents from '../contacts';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#ContactsAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(agents).toBeInstanceOf(ApiClient);
|
||||
expect(agents).toHaveProperty('get');
|
||||
expect(agents).toHaveProperty('show');
|
||||
expect(agents).toHaveProperty('create');
|
||||
expect(agents).toHaveProperty('update');
|
||||
expect(agents).toHaveProperty('delete');
|
||||
expect(agents).toHaveProperty('getConversations');
|
||||
});
|
||||
});
|
|
@ -41,6 +41,7 @@
|
|||
color: $color-body;
|
||||
width: 27rem;
|
||||
white-space: nowrap;
|
||||
max-width: 96%;
|
||||
}
|
||||
|
||||
.conversation--meta {
|
||||
|
@ -91,4 +92,12 @@
|
|||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
&.compact {
|
||||
padding-left: 0;
|
||||
|
||||
.conversation--details {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
@click="cardClick(chat)"
|
||||
>
|
||||
<Thumbnail
|
||||
v-if="!hideThumbnail"
|
||||
:src="chat.meta.sender.thumbnail"
|
||||
:badge="chat.meta.sender.channel"
|
||||
class="columns"
|
||||
|
@ -15,7 +16,7 @@
|
|||
<h4 class="conversation--user">
|
||||
{{ chat.meta.sender.name }}
|
||||
<span
|
||||
v-if="isInboxNameVisible"
|
||||
v-if="!hideInboxName && isInboxNameVisible"
|
||||
v-tooltip.bottom="inboxName(chat.inbox_id)"
|
||||
class="label"
|
||||
>
|
||||
|
@ -58,6 +59,14 @@ export default {
|
|||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
hideInboxName: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideThumbnail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
|
|
@ -3,6 +3,10 @@
|
|||
"BROWSER": "Browser",
|
||||
"OS": "Operating System",
|
||||
"INITIATED_FROM": "Initiated from",
|
||||
"INITIATED_AT": "Initiated at"
|
||||
"INITIATED_AT": "Initiated at",
|
||||
"CONVERSATIONS": {
|
||||
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
|
||||
"TITLE": "Previous Conversations"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
<template>
|
||||
<div class="contact-conversation--panel">
|
||||
<contact-details-item
|
||||
icon="ion-chatbubbles"
|
||||
:title="$t('CONTACT_PANEL.CONVERSATIONS.TITLE')"
|
||||
/>
|
||||
<div v-if="!uiFlags.isFetching">
|
||||
<i v-if="!previousConversations.length">
|
||||
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
|
||||
</i>
|
||||
<div v-else class="contact-conversation--list">
|
||||
<conversation-card
|
||||
v-for="conversation in previousConversations"
|
||||
:key="conversation.id"
|
||||
:chat="conversation"
|
||||
:hide-inbox-name="true"
|
||||
:hide-thumbnail="true"
|
||||
class="compact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<spinner v-else></spinner>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
ContactDetailsItem,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
contactId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
conversations() {
|
||||
return this.$store.getters['contactConversations/getContactConversation'](
|
||||
this.contactId
|
||||
);
|
||||
},
|
||||
previousConversations() {
|
||||
return this.conversations.filter(
|
||||
conversation => conversation.id !== Number(this.conversationId)
|
||||
);
|
||||
},
|
||||
...mapGetters({
|
||||
uiFlags: 'contactConversations/getUIFlags',
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
contactId(newContactId, prevContactId) {
|
||||
if (newContactId && newContactId !== prevContactId) {
|
||||
this.$store.dispatch('contactConversations/get', newContactId);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('contactConversations/get', this.contactId);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import '~dashboard/assets/scss/mixins';
|
||||
|
||||
.contact-conversation--panel {
|
||||
@include border-normal-top;
|
||||
padding: $space-medium;
|
||||
}
|
||||
|
||||
.contact-conversation--list {
|
||||
margin-top: -$space-normal;
|
||||
}
|
||||
</style>
|
|
@ -4,7 +4,7 @@
|
|||
<i :class="icon" class="conv-details--item__icon"></i>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="conv-details--item__value">
|
||||
<div v-if="value" class="conv-details--item__value">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -54,15 +54,22 @@
|
|||
icon="ion-clock"
|
||||
/>
|
||||
</div>
|
||||
<contact-conversations
|
||||
v-if="contact.id"
|
||||
:contact-id="contact.id"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
import ContactConversations from './ContactConversations.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactConversations,
|
||||
ContactDetailsItem,
|
||||
Thumbnail,
|
||||
},
|
||||
|
@ -179,7 +186,7 @@ export default {
|
|||
}
|
||||
|
||||
.conversation--details {
|
||||
padding: $space-normal $space-medium;
|
||||
padding: $space-medium;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import billing from './modules/billing';
|
|||
import cannedResponse from './modules/cannedResponse';
|
||||
import Channel from './modules/channels';
|
||||
import contacts from './modules/contacts';
|
||||
import contactConversations from './modules/contactConversations';
|
||||
import conversationMetadata from './modules/conversationMetadata';
|
||||
import conversations from './modules/conversations';
|
||||
import inboxes from './modules/inboxes';
|
||||
|
@ -22,6 +23,7 @@ export default new Vuex.Store({
|
|||
cannedResponse,
|
||||
Channel,
|
||||
contacts,
|
||||
contactConversations,
|
||||
conversationMetadata,
|
||||
conversations,
|
||||
inboxes,
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
import Vue from 'vue';
|
||||
import * as types from '../mutation-types';
|
||||
import ContactAPI from '../../api/contacts';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
getContactConversation: $state => id => {
|
||||
return $state.records[Number(id)] || [];
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }, contactId) => {
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isFetching: true,
|
||||
});
|
||||
try {
|
||||
const response = await ContactAPI.getConversations(contactId);
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS, {
|
||||
id: contactId,
|
||||
data: response.data.payload,
|
||||
});
|
||||
commit(types.default.SET_ALL_CONVERSATION, response.data.payload, {
|
||||
root: true,
|
||||
});
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isFetching: false,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
|
||||
isFetching: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG]($state, data) {
|
||||
$state.uiFlags = {
|
||||
...$state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
|
||||
Vue.set($state.records, id, data);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
import axios from 'axios';
|
||||
import { actions } from '../../contactConversations';
|
||||
import * as types from '../../../mutation-types';
|
||||
import conversationList from './fixtures';
|
||||
|
||||
const commit = jest.fn();
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({ data: { payload: conversationList } });
|
||||
await actions.get({ commit }, 1);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isFetching: true }],
|
||||
|
||||
[
|
||||
types.default.SET_CONTACT_CONVERSATIONS,
|
||||
{ id: 1, data: conversationList },
|
||||
],
|
||||
[types.default.SET_ALL_CONVERSATION, conversationList, { root: true }],
|
||||
[
|
||||
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
|
||||
{ isFetching: false },
|
||||
],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await actions.get({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isFetching: true }],
|
||||
[
|
||||
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
|
||||
{ isFetching: false },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
export default [
|
||||
{
|
||||
meta: {
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'sender1',
|
||||
thumbnail: '',
|
||||
channel: 'Channel::WebWidget',
|
||||
},
|
||||
assignee: null,
|
||||
},
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
content: 'Hello',
|
||||
account_id: 1,
|
||||
inbox_id: 1,
|
||||
conversation_id: 1,
|
||||
message_type: 1,
|
||||
created_at: 1578555084,
|
||||
updated_at: '2020-01-09T07:31:24.419Z',
|
||||
private: false,
|
||||
user_id: 1,
|
||||
status: 'sent',
|
||||
fb_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender: {
|
||||
name: 'Sender 1',
|
||||
avatar_url: 'random_url',
|
||||
},
|
||||
},
|
||||
],
|
||||
inbox_id: 1,
|
||||
status: 0,
|
||||
timestamp: 1578555084,
|
||||
user_last_seen_at: 0,
|
||||
agent_last_seen_at: 1578555084,
|
||||
unread_count: 0,
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
sender: {
|
||||
id: 2,
|
||||
name: 'sender1',
|
||||
thumbnail: '',
|
||||
channel: 'Channel::WebWidget',
|
||||
},
|
||||
assignee: null,
|
||||
},
|
||||
id: 2,
|
||||
messages: [
|
||||
{
|
||||
id: 2,
|
||||
content: 'Hello',
|
||||
account_id: 1,
|
||||
inbox_id: 2,
|
||||
conversation_id: 2,
|
||||
message_type: 1,
|
||||
created_at: 1578555084,
|
||||
updated_at: '2020-01-09T07:31:24.419Z',
|
||||
private: false,
|
||||
user_id: 2,
|
||||
status: 'sent',
|
||||
fb_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender: {
|
||||
name: 'Sender 1',
|
||||
avatar_url: 'random_url',
|
||||
},
|
||||
},
|
||||
],
|
||||
inbox_id: 2,
|
||||
status: 0,
|
||||
timestamp: 1578555084,
|
||||
user_last_seen_at: 0,
|
||||
agent_last_seen_at: 1578555084,
|
||||
unread_count: 0,
|
||||
},
|
||||
];
|
|
@ -0,0 +1,23 @@
|
|||
import { getters } from '../../contactConversations';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getContactConversation', () => {
|
||||
const state = {
|
||||
records: { 1: [{ id: 1, contact_id: 1, message: 'Hello' }] },
|
||||
};
|
||||
expect(getters.getContactConversation(state)(1)).toEqual([
|
||||
{ id: 1, contact_id: 1, message: 'Hello' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('getUIFlags', () => {
|
||||
const state = {
|
||||
uiFlags: {
|
||||
isFetching: true,
|
||||
},
|
||||
};
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
isFetching: true,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import * as types from '../../../mutation-types';
|
||||
import { mutations } from '../../contactConversations';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONTACT_CONVERSATIONS_UI_FLAG', () => {
|
||||
it('set ui flags', () => {
|
||||
const state = { uiFlags: { isFetching: true } };
|
||||
mutations[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG](state, {
|
||||
isFetching: false,
|
||||
});
|
||||
expect(state.uiFlags).toEqual({
|
||||
isFetching: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#SET_CONTACT_CONVERSATIONS', () => {
|
||||
it('set contact conversation records', () => {
|
||||
const state = { records: {} };
|
||||
mutations[types.default.SET_CONTACT_CONVERSATIONS](state, {
|
||||
id: 1,
|
||||
data: [{ id: 1, contact_id: 1, message: 'hello' }],
|
||||
});
|
||||
expect(state.records).toEqual({
|
||||
1: [{ id: 1, contact_id: 1, message: 'hello' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -62,6 +62,10 @@ export default {
|
|||
SET_CONTACTS: 'SET_CONTACTS',
|
||||
EDIT_CONTACT: 'EDIT_CONTACT',
|
||||
|
||||
// Contact Conversation
|
||||
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
|
||||
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
|
||||
|
||||
// Reports
|
||||
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
|
||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||
|
|
26
app/views/api/v1/contacts/conversations/index.json.jbuilder
Normal file
26
app/views/api/v1/contacts/conversations/index.json.jbuilder
Normal file
|
@ -0,0 +1,26 @@
|
|||
json.payload do
|
||||
json.array! @conversations do |conversation|
|
||||
json.meta do
|
||||
json.sender do
|
||||
json.id conversation.contact.id
|
||||
json.name conversation.contact.name
|
||||
json.thumbnail conversation.contact.avatar_url
|
||||
json.channel conversation.inbox.try(:channel_type)
|
||||
end
|
||||
json.assignee conversation.assignee
|
||||
end
|
||||
|
||||
json.id conversation.display_id
|
||||
if conversation.unread_incoming_messages.count.zero?
|
||||
json.messages [conversation.messages.last.try(:push_event_data)]
|
||||
else
|
||||
json.messages conversation.unread_messages.map(&:push_event_data)
|
||||
end
|
||||
json.inbox_id conversation.inbox_id
|
||||
json.status conversation.status_before_type_cast
|
||||
json.timestamp conversation.messages.last.try(:created_at).try(:to_i)
|
||||
json.user_last_seen_at conversation.user_last_seen_at.to_i
|
||||
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
|
||||
json.unread_count conversation.unread_incoming_messages.count
|
||||
end
|
||||
end
|
|
@ -37,7 +37,6 @@ Rails.application.routes.draw do
|
|||
resources :accounts, only: [:create]
|
||||
resources :inboxes, only: [:index, :destroy]
|
||||
resources :agents, except: [:show, :edit, :new]
|
||||
resources :contacts, only: [:index, :show, :update, :create]
|
||||
resources :labels, only: [:index]
|
||||
resources :canned_responses, except: [:show, :edit, :new]
|
||||
resources :inbox_members, only: [:create, :show], param: :inbox_id
|
||||
|
@ -73,6 +72,12 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
resources :contacts, only: [:index, :show, :update, :create] do
|
||||
scope module: :contacts do
|
||||
resources :conversations, only: [:index]
|
||||
end
|
||||
end
|
||||
|
||||
# this block is only required if subscription via chargebee is enabled
|
||||
if ENV['BILLING_ENABLED']
|
||||
resources :subscriptions, only: [:index] do
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/api/v1/contacts/:id/conversations', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:inbox_1) { create(:inbox, account: account) }
|
||||
let(:inbox_2) { create(:inbox, account: account) }
|
||||
let(:contact_inbox_1) { create(:contact_inbox, contact: contact, inbox: inbox_1) }
|
||||
let(:contact_inbox_2) { create(:contact_inbox, contact: contact, inbox: inbox_2) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox_1)
|
||||
2.times.each { create(:conversation, account: account, inbox: inbox_1, contact: contact, contact_inbox: contact_inbox_1) }
|
||||
2.times.each { create(:conversation, account: account, inbox: inbox_2, contact: contact, contact_inbox: contact_inbox_2) }
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/contacts/:id/conversations' do
|
||||
context 'when unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/contacts/#{contact.id}/conversations"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is logged in' do
|
||||
context 'with user as administrator' do
|
||||
it 'returns conversations from all inboxes' do
|
||||
get "/api/v1/contacts/#{contact.id}/conversations", headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response['payload'].length).to eq 4
|
||||
end
|
||||
end
|
||||
|
||||
context 'with user as agent' do
|
||||
it 'returns conversations from the inboxes which agent has access to' do
|
||||
get "/api/v1/contacts/#{contact.id}/conversations", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
expect(json_response['payload'].length).to eq 2
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue