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

View file

@ -149,7 +149,9 @@
@mixin color-spinner() {
@keyframes spinner {
to {transform: rotate(360deg);}
to {
transform: rotate(360deg);
}
}
&: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;
};
const getConversationAPI = async conversationId => {
const urlData = endPoints.getConversation(conversationId);
const result = await API.get(urlData.url);
const getConversationAPI = async ({ before }) => {
const urlData = endPoints.getConversation({ before });
const result = await API.get(urlData.url, { params: urlData.params });
return result;
};

View file

@ -7,8 +7,9 @@ const sendMessage = content => ({
},
});
const getConversation = () => ({
const getConversation = ({ before }) => ({
url: `/api/v1/widget/messages${window.location.search}`,
params: { before },
});
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>
<script>
import Spinner from 'widget/components/Spinner.vue';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {

View file

@ -1,6 +1,9 @@
<template>
<div class="conversation--container">
<div class="conversation-wrap">
<div v-if="isFetchingList" class="message--loader">
<spinner></spinner>
</div>
<ChatMessage
v-for="message in messages"
:key="message.id"
@ -14,29 +17,71 @@
<script>
import Branding from 'widget/components/Branding.vue';
import ChatMessage from 'widget/components/ChatMessage.vue';
import Spinner from 'shared/components/Spinner.vue';
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'ConversationWrap',
components: {
Branding,
ChatMessage,
Spinner,
},
props: {
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() {
this.$el.addEventListener('scroll', this.handleScroll);
this.scrollToBottom();
},
updated() {
if (this.previousConversationSize !== this.conversationSize) {
this.previousConversationSize = this.conversationSize;
this.scrollToBottom();
}
},
unmounted() {
this.$el.removeEventListener('scroll', this.handleScroll);
},
methods: {
...mapActions('conversation', ['fetchOldConversations']),
scrollToBottom() {
const container = this.$el;
container.scrollTop =
container.scrollHeight < this.minScrollHeight
? this.minScrollHeight
: container.scrollHeight;
container.scrollTop = container.scrollHeight - this.previousScrollHeight;
this.previousScrollHeight = 0;
},
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;
padding: $space-large $space-small $zero $space-small;
}
.message--loader {
text-align: center;
}
</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';
const state = {
conversations: {},
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
},
};
const getters = {
export const getters = {
getConversation: _state => _state.conversations,
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) => {
const { content } = params;
commit('pushMessageToConversations', createTemporaryMessage(content));
commit('pushMessageToConversation', createTemporaryMessage(content));
await sendMessageAPI(content);
},
fetchOldConversations: async ({ commit }) => {
fetchOldConversations: async ({ commit }, { before } = {}) => {
try {
const { data } = await getConversationAPI();
commit('initMessagesInConversation', data);
commit('setConversationListLoading', true);
const { data } = await getConversationAPI({ before });
commit('setMessagesInConversation', data);
commit('setConversationListLoading', false);
} catch (error) {
// Handle error
commit('setConversationListLoading', false);
}
},
addMessage({ commit }, data) {
commit('pushMessageToConversations', data);
commit('pushMessageToConversation', data);
},
};
const mutations = {
initInboxInConversations($state, lastConversation) {
Vue.set($state.conversations, lastConversation, {});
},
pushMessageToConversations($state, message) {
export const mutations = {
pushMessageToConversation($state, message) {
const { id, status, message_type: type } = message;
const messagesInbox = $state.conversations;
const isMessageIncoming = type === MESSAGE_TYPE.INCOMING;
@ -71,7 +83,6 @@ const mutations = {
messagesInbox,
message
);
if (!messageInConversation) {
Vue.set(messagesInbox, id, message);
} else {
@ -80,12 +91,17 @@ const mutations = {
}
},
initMessagesInConversation(_state, payload) {
setConversationListLoading($state, status) {
$state.uiFlags.isFetchingList = status;
},
setMessagesInConversation($state, payload) {
if (!payload.length) {
$state.uiFlags.allMessagesLoaded = true;
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 {
findUndeliveredMessage,
createTemporaryMessage,
} from '../conversation';
} from '../../conversation';
describe('#findUndeliveredMessage', () => {
it('returns message objects if exist', () => {