[Enhancement] Fetch previous messages in the conversation (#355)

* Fetch previous messages in the conversation

* Add specs for conversation store

* Fix codeclimate issues

* Exclude specs folder

* Exclude globally

* Fix path in exclude patterns

* Add endPoints spec

* Add snapshots for Spinner

* Add specs for actions
This commit is contained in:
Pranav Raj S 2019-12-11 20:57:06 +05:30 committed by Sojan Jose
parent 1005b9e227
commit 2b41e91768
17 changed files with 406 additions and 84 deletions

View file

@ -11,3 +11,16 @@ plugins:
enabled: true enabled: true
brakeman: brakeman:
enabled: true enabled: true
exclude_patterns:
- "spec/"
- "**/specs/"
- "db/*"
- "bin/**/*"
- "db/**/*"
- "config/**/*"
- "public/**/*"
- "vendor/**/*"
- "node_modules/**/*"
- "lib/tasks/auto_annotate_models.rake"
- "app/test-matchers.js"

View file

@ -28,7 +28,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: :incoming, message_type: :incoming,
content: permitted_params[:content] content: permitted_params[:message][:content]
} }
end end
@ -65,7 +65,8 @@ class Api::V1::Widget::MessagesController < ActionController::Base
def message_finder_params def message_finder_params
{ {
filter_internal_messages: true filter_internal_messages: true,
before: permitted_params[:before]
} }
end end
@ -78,7 +79,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base
end end
def permitted_params def permitted_params
params.fetch(:message).permit(:content) params.permit(:before, message: [:content])
end end
def secret_key def secret_key

View file

@ -149,7 +149,9 @@
@mixin color-spinner() { @mixin color-spinner() {
@keyframes spinner { @keyframes spinner {
to {transform: rotate(360deg);} to {
transform: rotate(360deg);
}
} }
&:before { &:before {

View file

@ -0,0 +1,67 @@
<template>
<span class="spinner small"></span>
</template>
<style scoped lang="scss">
@import '~widget/assets/scss/variables';
@mixin color-spinner() {
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
&:before {
content: '';
box-sizing: border-box;
position: absolute;
top: 50%;
left: 50%;
width: $space-medium;
height: $space-medium;
margin-top: -$space-one;
margin-left: -$space-one;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.7);
border-top-color: lighten($color-woot, 10%);
animation: spinner 0.9s linear infinite;
}
}
.spinner {
@include color-spinner();
position: relative;
display: inline-block;
width: $space-medium;
height: $space-medium;
padding: $zero $space-medium;
vertical-align: middle;
&.message {
padding: $space-normal;
top: 0;
left: 0;
margin: 0 auto;
margin-top: $space-slab;
background: $color-white;
border-radius: $space-large;
&:before {
margin-top: -$space-slab;
margin-left: -$space-slab;
}
}
&.small {
width: $space-normal;
height: $space-normal;
&:before {
width: $space-normal;
height: $space-normal;
margin-top: -$space-small;
}
}
}
</style>

View file

@ -0,0 +1,10 @@
import { mount } from '@vue/test-utils';
import Spinner from '../Spinner';
describe('Spinner', () => {
test('matches snapshot', () => {
const wrapper = mount(Spinner);
expect(wrapper.isVueInstance()).toBeTruthy();
expect(wrapper.element).toMatchSnapshot();
});
});

View file

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Spinner matches snapshot 1`] = `
<span
class="spinner small"
/>
`;

View file

@ -7,9 +7,9 @@ const sendMessageAPI = async content => {
return result; return result;
}; };
const getConversationAPI = async conversationId => { const getConversationAPI = async ({ before }) => {
const urlData = endPoints.getConversation(conversationId); const urlData = endPoints.getConversation({ before });
const result = await API.get(urlData.url); const result = await API.get(urlData.url, { params: urlData.params });
return result; return result;
}; };

View file

@ -7,8 +7,9 @@ const sendMessage = content => ({
}, },
}); });
const getConversation = () => ({ const getConversation = ({ before }) => ({
url: `/api/v1/widget/messages${window.location.search}`, url: `/api/v1/widget/messages${window.location.search}`,
params: { before },
}); });
export default { export default {

View file

@ -0,0 +1,25 @@
import endPoints from '../endPoints';
describe('#sendMessage', () => {
it('returns correct payload', () => {
expect(endPoints.sendMessage('hello')).toEqual({
url: `/api/v1/widget/messages`,
params: {
message: {
content: 'hello',
},
},
});
});
});
describe('#getConversation', () => {
it('returns correct payload', () => {
expect(endPoints.getConversation({ before: 123 })).toEqual({
url: `/api/v1/widget/messages`,
params: {
before: 123,
},
});
});
});

View file

@ -13,7 +13,7 @@
</template> </template>
<script> <script>
import Spinner from 'widget/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
export default { export default {
components: { components: {

View file

@ -1,6 +1,9 @@
<template> <template>
<div class="conversation--container"> <div class="conversation--container">
<div class="conversation-wrap"> <div class="conversation-wrap">
<div v-if="isFetchingList" class="message--loader">
<spinner></spinner>
</div>
<ChatMessage <ChatMessage
v-for="message in messages" v-for="message in messages"
:key="message.id" :key="message.id"
@ -14,29 +17,71 @@
<script> <script>
import Branding from 'widget/components/Branding.vue'; import Branding from 'widget/components/Branding.vue';
import ChatMessage from 'widget/components/ChatMessage.vue'; import ChatMessage from 'widget/components/ChatMessage.vue';
import Spinner from 'shared/components/Spinner.vue';
import { mapActions, mapGetters } from 'vuex';
export default { export default {
name: 'ConversationWrap', name: 'ConversationWrap',
components: { components: {
Branding, Branding,
ChatMessage, ChatMessage,
Spinner,
}, },
props: { props: {
messages: Object, messages: Object,
}, },
data() {
return {
previousScrollHeight: 0,
previousConversationSize: 0,
};
},
computed: {
...mapGetters({
earliestMessage: 'conversation/getEarliestMessage',
allMessagesLoaded: 'conversation/getAllMessagesLoaded',
isFetchingList: 'conversation/getIsFetchingList',
conversationSize: 'conversation/getConversationSize',
}),
},
watch: {
allMessagesLoaded() {
this.previousScrollHeight = 0;
},
},
mounted() { mounted() {
this.$el.addEventListener('scroll', this.handleScroll);
this.scrollToBottom(); this.scrollToBottom();
}, },
updated() { updated() {
this.scrollToBottom(); if (this.previousConversationSize !== this.conversationSize) {
this.previousConversationSize = this.conversationSize;
this.scrollToBottom();
}
},
unmounted() {
this.$el.removeEventListener('scroll', this.handleScroll);
}, },
methods: { methods: {
...mapActions('conversation', ['fetchOldConversations']),
scrollToBottom() { scrollToBottom() {
const container = this.$el; const container = this.$el;
container.scrollTop = container.scrollTop = container.scrollHeight - this.previousScrollHeight;
container.scrollHeight < this.minScrollHeight this.previousScrollHeight = 0;
? this.minScrollHeight },
: container.scrollHeight; handleScroll() {
if (
this.isFetchingList ||
this.allMessagesLoaded ||
!this.conversationSize
) {
return;
}
if (this.$el.scrollTop < 100) {
this.fetchOldConversations({ before: this.earliestMessage.id });
this.previousScrollHeight = this.$el.scrollHeight;
}
}, },
}, },
}; };
@ -56,4 +101,8 @@ export default {
flex: 1; flex: 1;
padding: $space-large $space-small $zero $space-small; padding: $space-large $space-small $zero $space-small;
} }
.message--loader {
text-align: center;
}
</style> </style>

View file

@ -1,52 +0,0 @@
<template>
<span class="spinner" :class="size"></span>
</template>
<script>
const SIZES = ['small', 'medium', 'large'];
export default {
props: {
size: {
validator: value => SIZES.includes(value),
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.spinner {
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
&:before {
animation: spinner 0.7s linear infinite;
border-radius: 50%;
border-top-color: lighten($color-woot, 10%);
border: 2px solid rgba(255, 255, 255, 0.7);
box-sizing: border-box;
content: '';
height: $space-medium;
left: 50%;
margin-left: -$space-slab;
margin-top: -$space-slab;
position: absolute;
top: 50%;
width: $space-medium;
}
&.small:before {
border-width: 1px;
height: $space-slab;
margin-left: -$space-slab/2;
margin-top: -$space-slab/2;
width: $space-slab;
}
}
</style>

View file

@ -21,42 +21,54 @@ export const findUndeliveredMessage = (messageInbox, { content }) =>
); );
export const DEFAULT_CONVERSATION = 'default'; export const DEFAULT_CONVERSATION = 'default';
const state = { const state = {
conversations: {}, conversations: {},
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
},
}; };
const getters = { export const getters = {
getConversation: _state => _state.conversations, getConversation: _state => _state.conversations,
getConversationSize: _state => Object.keys(_state.conversations).length, getConversationSize: _state => Object.keys(_state.conversations).length,
getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
getEarliestMessage: _state => {
const conversation = Object.values(_state.conversations);
if (conversation.length) {
return conversation[0];
}
return {};
},
}; };
const actions = { export const actions = {
sendMessage: async ({ commit }, params) => { sendMessage: async ({ commit }, params) => {
const { content } = params; const { content } = params;
commit('pushMessageToConversations', createTemporaryMessage(content)); commit('pushMessageToConversation', createTemporaryMessage(content));
await sendMessageAPI(content); await sendMessageAPI(content);
}, },
fetchOldConversations: async ({ commit }) => { fetchOldConversations: async ({ commit }, { before } = {}) => {
try { try {
const { data } = await getConversationAPI(); commit('setConversationListLoading', true);
commit('initMessagesInConversation', data); const { data } = await getConversationAPI({ before });
commit('setMessagesInConversation', data);
commit('setConversationListLoading', false);
} catch (error) { } catch (error) {
// Handle error commit('setConversationListLoading', false);
} }
}, },
addMessage({ commit }, data) { addMessage({ commit }, data) {
commit('pushMessageToConversations', data); commit('pushMessageToConversation', data);
}, },
}; };
const mutations = { export const mutations = {
initInboxInConversations($state, lastConversation) { pushMessageToConversation($state, message) {
Vue.set($state.conversations, lastConversation, {});
},
pushMessageToConversations($state, message) {
const { id, status, message_type: type } = message; const { id, status, message_type: type } = message;
const messagesInbox = $state.conversations; const messagesInbox = $state.conversations;
const isMessageIncoming = type === MESSAGE_TYPE.INCOMING; const isMessageIncoming = type === MESSAGE_TYPE.INCOMING;
@ -71,7 +83,6 @@ const mutations = {
messagesInbox, messagesInbox,
message message
); );
if (!messageInConversation) { if (!messageInConversation) {
Vue.set(messagesInbox, id, message); Vue.set(messagesInbox, id, message);
} else { } else {
@ -80,12 +91,17 @@ const mutations = {
} }
}, },
initMessagesInConversation(_state, payload) { setConversationListLoading($state, status) {
$state.uiFlags.isFetchingList = status;
},
setMessagesInConversation($state, payload) {
if (!payload.length) { if (!payload.length) {
$state.uiFlags.allMessagesLoaded = true;
return; return;
} }
payload.map(message => Vue.set(_state.conversations, message.id, message)); payload.map(message => Vue.set($state.conversations, message.id, message));
}, },
}; };

View file

@ -0,0 +1,32 @@
import { actions } from '../../conversation';
import getUuid from '../../../../helpers/uuid';
jest.mock('../../../../helpers/uuid');
const commit = jest.fn();
describe('#actions', () => {
describe('#addMessage', () => {
it('sends correct mutations', () => {
actions.addMessage({ commit }, { id: 1 });
expect(commit).toBeCalledWith('pushMessageToConversation', { id: 1 });
});
});
describe('#sendMessage', () => {
it('sends correct mutations', () => {
const mockDate = new Date(1466424490000);
getUuid.mockImplementationOnce(() => '1111');
const spy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
actions.sendMessage({ commit }, { content: 'hello' });
spy.mockRestore();
expect(commit).toBeCalledWith('pushMessageToConversation', {
id: '1111',
content: 'hello',
status: 'in_progress',
created_at: 1466424490000,
message_type: 0,
});
});
});
});

View file

@ -0,0 +1,56 @@
import { getters } from '../../conversation';
describe('#getters', () => {
it('getConversation', () => {
const state = {
conversations: {
1: {
content: 'hello',
},
},
};
expect(getters.getConversation(state)).toEqual({
1: {
content: 'hello',
},
});
});
it('getConversationSize', () => {
const state = {
conversations: {
1: {
content: 'hello',
},
},
};
expect(getters.getConversationSize(state)).toEqual(1);
});
it('getEarliestMessage', () => {
const state = {
conversations: {
1: {
content: 'hello',
},
2: {
content: 'hello1',
},
},
};
expect(getters.getEarliestMessage(state)).toEqual({
content: 'hello',
});
});
it('uiFlags', () => {
const state = {
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
},
};
expect(getters.getAllMessagesLoaded(state)).toEqual(false);
expect(getters.getIsFetchingList(state)).toEqual(false);
});
});

View file

@ -0,0 +1,95 @@
import { mutations } from '../../conversation';
const temporaryMessagePayload = {
content: 'hello',
id: 1,
message_type: 0,
status: 'in_progress',
};
const incomingMessagePayload = {
content: 'hello',
id: 1,
message_type: 0,
status: 'sent',
};
const outgoingMessagePayload = {
content: 'hello',
id: 1,
message_type: 1,
status: 'sent',
};
describe('#mutations', () => {
describe('#pushMessageToConversation', () => {
it('add message to conversation if outgoing', () => {
const state = { conversations: {} };
mutations.pushMessageToConversation(state, outgoingMessagePayload);
expect(state.conversations).toEqual({
1: outgoingMessagePayload,
});
});
it('add message to conversation if message in undelivered', () => {
const state = { conversations: {} };
mutations.pushMessageToConversation(state, temporaryMessagePayload);
expect(state.conversations).toEqual({
1: temporaryMessagePayload,
});
});
it('replaces temporary message in conversation with actual message', () => {
const state = {
conversations: {
rand_id_123: {
content: 'hello',
id: 'rand_id_123',
message_type: 0,
status: 'in_progress',
},
},
};
mutations.pushMessageToConversation(state, incomingMessagePayload);
expect(state.conversations).toEqual({
1: incomingMessagePayload,
});
});
it('adds message in conversation if it is a new message', () => {
const state = { conversations: {} };
mutations.pushMessageToConversation(state, incomingMessagePayload);
expect(state.conversations).toEqual({
1: incomingMessagePayload,
});
});
});
describe('#setConversationListLoading', () => {
it('set status correctly', () => {
const state = { uiFlags: { isFetchingList: false } };
mutations.setConversationListLoading(state, true);
expect(state.uiFlags.isFetchingList).toEqual(true);
});
});
describe('#setMessagesInConversation', () => {
it('sets allMessagesLoaded flag if payload is empty', () => {
const state = { uiFlags: { allMessagesLoaded: false } };
mutations.setMessagesInConversation(state, []);
expect(state.uiFlags.allMessagesLoaded).toEqual(true);
});
it('sets messages if payload is not empty', () => {
const state = {
uiFlags: { allMessagesLoaded: false },
conversations: {},
};
mutations.setMessagesInConversation(state, [{ id: 1, content: 'hello' }]);
expect(state.conversations).toEqual({
1: { id: 1, content: 'hello' },
});
expect(state.uiFlags.allMessagesLoaded).toEqual(false);
});
});
});

View file

@ -1,7 +1,7 @@
import { import {
findUndeliveredMessage, findUndeliveredMessage,
createTemporaryMessage, createTemporaryMessage,
} from '../conversation'; } from '../../conversation';
describe('#findUndeliveredMessage', () => { describe('#findUndeliveredMessage', () => {
it('returns message objects if exist', () => { it('returns message objects if exist', () => {