[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:
parent
1005b9e227
commit
2b41e91768
17 changed files with 406 additions and 84 deletions
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -149,7 +149,9 @@
|
|||
|
||||
@mixin color-spinner() {
|
||||
@keyframes spinner {
|
||||
to {transform: rotate(360deg);}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:before {
|
||||
|
|
67
app/javascript/shared/components/Spinner.vue
Normal file
67
app/javascript/shared/components/Spinner.vue
Normal 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>
|
10
app/javascript/shared/components/specs/Spinner.spec.js
Normal file
10
app/javascript/shared/components/specs/Spinner.spec.js
Normal 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();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Spinner matches snapshot 1`] = `
|
||||
<span
|
||||
class="spinner small"
|
||||
/>
|
||||
`;
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -7,8 +7,9 @@ const sendMessage = content => ({
|
|||
},
|
||||
});
|
||||
|
||||
const getConversation = () => ({
|
||||
const getConversation = ({ before }) => ({
|
||||
url: `/api/v1/widget/messages${window.location.search}`,
|
||||
params: { before },
|
||||
});
|
||||
|
||||
export default {
|
||||
|
|
25
app/javascript/widget/api/specs/endPoints.spec.js
Normal file
25
app/javascript/widget/api/specs/endPoints.spec.js
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -13,7 +13,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Spinner from 'widget/components/Spinner.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
findUndeliveredMessage,
|
||||
createTemporaryMessage,
|
||||
} from '../conversation';
|
||||
} from '../../conversation';
|
||||
|
||||
describe('#findUndeliveredMessage', () => {
|
||||
it('returns message objects if exist', () => {
|
Loading…
Add table
Add a link
Reference in a new issue