feat: Add a pre-chat form on widget (#1769)

This commit is contained in:
Pranav Raj S 2021-02-16 00:14:13 +05:30 committed by GitHub
parent 5f2bf7dfd2
commit 037ffc7419
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 604 additions and 200 deletions

View file

@ -20,11 +20,7 @@ class Api::V1::Widget::BaseController < ApplicationController
end end
def auth_token_params def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers[header_name]).decode_token @auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
end
def header_name
'X-Auth-Token'
end end
def set_web_widget def set_web_widget
@ -39,6 +35,50 @@ class Api::V1::Widget::BaseController < ApplicationController
@contact = @contact_inbox.contact @contact = @contact_inbox.contact
end end
def create_conversation
::Conversation.create!(conversation_params)
end
def inbox
@inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id])
end
def conversation_params
# FIXME: typo referrer in additional attributes, will probably require a migration.
{
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
browser: browser_params,
referer: permitted_params[:message][:referer_url],
initiated_at: timestamp_params
}
}
end
def update_contact(email)
contact_with_email = @current_account.contacts.find_by(email: email)
if contact_with_email
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_email,
mergee_contact: @contact
).perform
else
@contact.update!(email: email, name: contact_name)
end
end
def contact_email
permitted_params[:contact][:email].downcase
end
def contact_name
params[:contact][:name] || contact_email.split('@')[0]
end
def browser_params def browser_params
{ {
browser_name: browser.name, browser_name: browser.name,
@ -48,4 +88,19 @@ class Api::V1::Widget::BaseController < ApplicationController
platform_version: browser.platform.version platform_version: browser.platform.version
} }
end end
def timestamp_params
{ timestamp: permitted_params[:message][:timestamp] }
end
def message_params
{
account_id: conversation.account_id,
sender: @contact,
content: permitted_params[:message][:content],
inbox_id: conversation.inbox_id,
echo_id: permitted_params[:message][:echo_id],
message_type: :incoming
}
end
end end

View file

@ -5,6 +5,14 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
@conversation = conversation @conversation = conversation
end end
def create
ActiveRecord::Base.transaction do
update_contact(contact_email) if @contact.email.blank?
@conversation = create_conversation
conversation.messages.create(message_params)
end
end
def update_last_seen def update_last_seen
head :ok && return if conversation.nil? head :ok && return if conversation.nil?
@ -43,6 +51,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def permitted_params def permitted_params
params.permit(:id, :typing_status, :website_token, :email) params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
end end
end end

View file

@ -39,44 +39,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end end
def set_conversation def set_conversation
@conversation = ::Conversation.create!(conversation_params) if conversation.nil? @conversation = create_conversation if conversation.nil?
end
def message_params
{
account_id: conversation.account_id,
sender: @contact,
content: permitted_params[:message][:content],
inbox_id: conversation.inbox_id,
echo_id: permitted_params[:message][:echo_id],
message_type: :incoming
}
end
def conversation_params
# FIXME: typo referrer in additional attributes
# will probably require a migration.
{
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
browser: browser_params,
referer: permitted_params[:message][:referer_url],
initiated_at: timestamp_params
}
}
end
def timestamp_params
{
timestamp: permitted_params[:message][:timestamp]
}
end
def inbox
@inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id])
end end
def message_finder_params def message_finder_params
@ -90,36 +53,12 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
@message_finder ||= MessageFinder.new(conversation, message_finder_params) @message_finder ||= MessageFinder.new(conversation, message_finder_params)
end end
def update_contact(email)
contact_with_email = @current_account.contacts.find_by(email: email)
if contact_with_email
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_email,
mergee_contact: @contact
).perform
else
@contact.update!(
email: email,
name: contact_name
)
end
end
def contact_email
permitted_params[:contact][:email].downcase
end
def contact_name
contact_email.split('@')[0]
end
def message_update_params def message_update_params
params.permit(message: [{ submitted_values: [:name, :title, :value] }]) params.permit(message: [{ submitted_values: [:name, :title, :value] }])
end end
def permitted_params def permitted_params
params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp, :echo_id]) params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
end end
def set_message def set_message

View file

@ -2,12 +2,7 @@
<router <router
:show-unread-view="showUnreadView" :show-unread-view="showUnreadView"
:is-mobile="isMobile" :is-mobile="isMobile"
:grouped-messages="groupedMessages"
:unread-messages="unreadMessages"
:conversation-size="conversationSize"
:available-agents="availableAgents"
:has-fetched="hasFetched" :has-fetched="hasFetched"
:conversation-attributes="conversationAttributes"
:unread-message-count="unreadMessageCount" :unread-message-count="unreadMessageCount"
:is-left-aligned="isLeftAligned" :is-left-aligned="isLeftAligned"
:hide-message-bubble="hideMessageBubble" :hide-message-bubble="hideMessageBubble"
@ -40,12 +35,7 @@ export default {
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
groupedMessages: 'conversation/getGroupedConversation',
unreadMessages: 'conversation/getUnreadTextMessages',
conversationSize: 'conversation/getConversationSize',
availableAgents: 'agent/availableAgents',
hasFetched: 'agent/getHasFetched', hasFetched: 'agent/getHasFetched',
conversationAttributes: 'conversationAttributes/getConversationParams',
unreadMessageCount: 'conversation/getUnreadMessageCount', unreadMessageCount: 'conversation/getUnreadMessageCount',
}), }),
isLeftAligned() { isLeftAligned() {

View file

@ -1,6 +1,12 @@
import endPoints from 'widget/api/endPoints'; import endPoints from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios'; import { API } from 'widget/helpers/axios';
const createConversationAPI = async content => {
const urlData = endPoints.createConversation(content);
const result = await API.post(urlData.url, urlData.params);
return result;
};
const sendMessageAPI = async content => { const sendMessageAPI = async content => {
const urlData = endPoints.sendMessage(content); const urlData = endPoints.sendMessage(content);
const result = await API.post(urlData.url, urlData.params); const result = await API.post(urlData.url, urlData.params);
@ -38,6 +44,7 @@ const setUserLastSeenAt = async ({ lastSeen }) => {
}; };
export { export {
createConversationAPI,
sendMessageAPI, sendMessageAPI,
getConversationAPI, getConversationAPI,
getMessagesAPI, getMessagesAPI,

View file

@ -1,5 +1,24 @@
import { buildSearchParamsWithLocale } from '../helpers/urlParamsHelper'; import { buildSearchParamsWithLocale } from '../helpers/urlParamsHelper';
const createConversation = params => {
const referrerURL = window.referrerURL || '';
const search = buildSearchParamsWithLocale(window.location.search);
return {
url: `/api/v1/widget/conversations${search}`,
params: {
contact: {
name: params.fullName,
email: params.emailAddress,
},
message: {
content: params.message,
timestamp: new Date().toString(),
referer_url: referrerURL,
},
},
};
};
const sendMessage = content => { const sendMessage = content => {
const referrerURL = window.referrerURL || ''; const referrerURL = window.referrerURL || '';
const search = buildSearchParamsWithLocale(window.location.search); const search = buildSearchParamsWithLocale(window.location.search);
@ -47,6 +66,7 @@ const getAvailableAgents = token => ({
}); });
export default { export default {
createConversation,
sendMessage, sendMessage,
sendAttachment, sendAttachment,
getConversation, getConversation,

View file

@ -75,8 +75,6 @@ export default {
padding: $space-two $space-medium; padding: $space-two $space-medium;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
background: white;
@include shadow-large;
.header-branding { .header-branding {
display: flex; display: flex;

View file

@ -1,5 +1,5 @@
<template> <template>
<header class="header-expanded py-8 px-6 bg-white relative box-border w-full"> <header class="header-expanded bg-white py-8 px-6 relative box-border w-full">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<img v-if="avatarUrl" class="logo" :src="avatarUrl" /> <img v-if="avatarUrl" class="logo" :src="avatarUrl" />
<header-actions :show-popout-button="showPopoutButton" /> <header-actions :show-popout-button="showPopoutButton" />
@ -50,8 +50,6 @@ export default {
@import '~widget/assets/scss/mixins.scss'; @import '~widget/assets/scss/mixins.scss';
.header-expanded { .header-expanded {
@include shadow-large;
.logo { .logo {
width: 56px; width: 56px;
height: 56px; height: 56px;

View file

@ -0,0 +1,59 @@
<template>
<label class="block">
<div
v-if="label"
class="mb-2 text-xs font-medium"
:class="{
'text-black-800': !error,
'text-red-400': error,
}"
>
{{ label }}
</div>
<input
:type="type"
class="border rounded w-full py-2 px-3 text-slate-700 leading-tight outline-none"
:class="{
'border-black-200 hover:border-black-300 focus:border-black-300': !error,
'border-red-200 hover:border-red-300 focus:border-red-300': error,
}"
:placeholder="placeholder"
:value="value"
@change="onChange"
/>
<div v-if="error" class="text-red-400 mt-2 text-xs font-medium">
{{ error }}
</div>
</label>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
type: {
type: String,
default: 'text',
},
placeholder: {
type: String,
default: '',
},
value: {
type: [String, Number],
required: true,
},
error: {
type: String,
default: '',
},
},
methods: {
onChange(event) {
this.$emit('input', event.target.value);
},
},
};
</script>

View file

@ -0,0 +1,63 @@
<template>
<label class="block">
<div
v-if="label"
class="mb-2 text-xs font-medium"
:class="{
'text-black-800': !error,
'text-red-400': error,
}"
>
{{ label }}
</div>
<textarea
class="resize-none border rounded w-full py-2 px-3 text-slate-700 leading-tight outline-none"
:class="{
'border-black-200 hover:border-black-300 focus:border-black-300': !error,
'border-red-200 hover:border-red-300 focus:border-red-300': error,
}"
:placeholder="placeholder"
:value="value"
@change="onChange"
/>
<div v-if="error" class="text-red-400 mt-2 text-xs font-medium">
{{ error }}
</div>
</label>
</template>
<script>
export default {
props: {
label: {
type: String,
default: '',
},
type: {
type: String,
default: 'text',
},
placeholder: {
type: String,
default: '',
},
value: {
type: [String, Number],
required: true,
},
error: {
type: String,
default: '',
},
},
methods: {
onChange(event) {
this.$emit('input', event.target.value);
},
},
};
</script>
<style lang="scss" scoped>
textarea {
min-height: 8rem;
}
</style>

View file

@ -0,0 +1,117 @@
<template>
<form
class="flex flex-1 flex-col p-6 overflow-y-scroll"
@submit.prevent="onSubmit"
>
<div v-if="options.preChatMessage" class="text-black-800 text-sm leading-5">
{{ options.preChatMessage }}
</div>
<form-input
v-if="options.requireEmail"
v-model="fullName"
class="mt-5"
:label="$t('PRE_CHAT_FORM.FIELDS.FULL_NAME.LABEL')"
:placeholder="$t('PRE_CHAT_FORM.FIELDS.FULL_NAME.PLACEHOLDER')"
type="text"
:error="
$v.fullName.$error ? $t('PRE_CHAT_FORM.FIELDS.FULL_NAME.ERROR') : ''
"
/>
<form-input
v-if="options.requireEmail"
v-model="emailAddress"
class="mt-5"
:label="$t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.LABEL')"
:placeholder="$t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.PLACEHOLDER')"
type="email"
:error="
$v.emailAddress.$error
? $t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.ERROR')
: ''
"
/>
<form-text-area
v-model="message"
class="my-5"
:label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')"
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
:error="$v.message.$error ? $t('PRE_CHAT_FORM.FIELDS.MESSAGE.ERROR') : ''"
/>
<woot-button
class="font-medium"
block
:bg-color="widgetColor"
:text-color="textColor"
:disabled="isCreating"
>
<spinner v-if="isCreating" class="p-0" />
{{ $t('START_CONVERSATION') }}
</woot-button>
</form>
</template>
<script>
import WootButton from 'shared/components/Button';
import FormInput from '../Form/Input';
import FormTextArea from '../Form/TextArea';
import Spinner from 'shared/components/Spinner';
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from 'shared/helpers/ColorHelper';
import { required, minLength, email } from 'vuelidate/lib/validators';
export default {
components: {
FormInput,
FormTextArea,
WootButton,
Spinner,
},
props: {
options: {
type: Object,
default: () => ({}),
},
},
validations: {
fullName: {
required,
},
emailAddress: {
required,
email,
},
message: {
required,
minLength: minLength(10),
},
},
data() {
return {
fullName: '',
emailAddress: '',
message: '',
};
},
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
isCreating: 'conversation/getIsCreating',
}),
textColor() {
return getContrastingTextColor(this.widgetColor);
},
},
methods: {
onSubmit() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
this.$store.dispatch('conversation/createConversation', {
fullName: this.fullName,
emailAddress: this.emailAddress,
message: this.message,
});
},
},
};
</script>

View file

@ -29,5 +29,24 @@
"EMAIL_PLACEHOLDER": "Please enter your email", "EMAIL_PLACEHOLDER": "Please enter your email",
"CHAT_PLACEHOLDER": "Type your message", "CHAT_PLACEHOLDER": "Type your message",
"TODAY": "Today", "TODAY": "Today",
"YESTERDAY": "Yesterday" "YESTERDAY": "Yesterday",
"PRE_CHAT_FORM": {
"FIELDS": {
"FULL_NAME": {
"LABEL": "Full Name",
"PLACEHOLDER": "Please enter your full name",
"ERROR": "Full Name is required"
},
"EMAIL_ADDRESS": {
"LABEL": "Email Address",
"PLACEHOLDER": "Please enter your email address",
"ERROR": "Invalid email address"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter your message",
"ERROR": "Message too short"
}
}
}
} }

View file

@ -24,6 +24,16 @@ export default {
replyTime() { replyTime() {
return window.chatwootWebChannel.replyTime; return window.chatwootWebChannel.replyTime;
}, },
preChatFormEnabled() {
return window.chatwootWebChannel.preChatFormEnabled;
},
preChatFormOptions() {
const options = window.chatwootWebChannel.preChatFormOptions || {};
return {
requireEmail: options.require_email,
preChatMessage: options.pre_chat_message,
};
},
replyTimeStatus() { replyTimeStatus() {
switch (this.replyTime) { switch (this.replyTime) {
case 'in_a_few_minutes': case 'in_a_few_minutes':

View file

@ -1,14 +1,34 @@
import { import {
createConversationAPI,
sendMessageAPI, sendMessageAPI,
getMessagesAPI, getMessagesAPI,
sendAttachmentAPI, sendAttachmentAPI,
toggleTyping, toggleTyping,
setUserLastSeenAt, setUserLastSeenAt,
} from 'widget/api/conversation'; } from 'widget/api/conversation';
import { refreshActionCableConnector } from '../../../helpers/actionCable';
import { createTemporaryMessage, onNewMessageCreated } from './helpers'; import { createTemporaryMessage, onNewMessageCreated } from './helpers';
export const actions = { export const actions = {
createConversation: async ({ commit }, params) => {
commit('setConversationUIFlag', { isCreating: true });
try {
const { data } = await createConversationAPI(params);
const {
contact: { pubsub_token: pubsubToken },
messages,
} = data;
const [message = {}] = messages;
commit('pushMessageToConversation', message);
refreshActionCableConnector(pubsubToken);
} catch (error) {
console.log(error);
// Ignore error
} finally {
commit('setConversationUIFlag', { isCreating: false });
}
},
sendMessage: async ({ commit }, params) => { sendMessage: async ({ commit }, params) => {
const { content } = params; const { content } = params;
commit('pushMessageToConversation', createTemporaryMessage({ content })); commit('pushMessageToConversation', createTemporaryMessage({ content }));

View file

@ -5,6 +5,7 @@ import { formatUnixDate } from 'shared/helpers/DateHelper';
export const getters = { export const getters = {
getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded, getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
getIsCreating: _state => _state.uiFlags.isCreating,
getIsAgentTyping: _state => _state.uiFlags.isAgentTyping, getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
getConversation: _state => _state.conversations, getConversation: _state => _state.conversations,
getConversationSize: _state => Object.keys(_state.conversations).length, getConversationSize: _state => Object.keys(_state.conversations).length,

View file

@ -11,6 +11,7 @@ const state = {
allMessagesLoaded: false, allMessagesLoaded: false,
isFetchingList: false, isFetchingList: false,
isAgentTyping: false, isAgentTyping: false,
isCreating: false,
}, },
}; };

View file

@ -41,6 +41,13 @@ export const mutations = {
} }
}, },
setConversationUIFlag($state, uiFlags) {
$state.uiFlags = {
...$state.uiFlags,
...uiFlags,
};
},
setConversationListLoading($state, status) { setConversationListLoading($state, status) {
$state.uiFlags.isFetchingList = status; $state.uiFlags.isFetchingList = status;
}, },

View file

@ -28,6 +28,43 @@ describe('#actions', () => {
}); });
}); });
describe('#createConversation', () => {
it('sends correct mutations', async () => {
API.post.mockResolvedValue({
data: {
contact: { name: 'contact-name' },
messages: [{ id: 1, content: 'This is a test message' }],
},
});
let windowSpy = jest.spyOn(window, 'window', 'get');
windowSpy.mockImplementation(() => ({
WOOT_WIDGET: {
$root: {
$i18n: {
locale: 'el',
},
},
},
location: {
search: '?param=1',
},
}));
await actions.createConversation(
{ commit },
{ contact: {}, message: 'This is a test message' }
);
expect(commit.mock.calls).toEqual([
['setConversationUIFlag', { isCreating: true }],
[
'pushMessageToConversation',
{ id: 1, content: 'This is a test message' },
],
['setConversationUIFlag', { isCreating: false }],
]);
windowSpy.mockRestore();
});
});
describe('#updateMessage', () => { describe('#updateMessage', () => {
it('sends correct mutations', () => { it('sends correct mutations', () => {
actions.updateMessage({ commit }, { id: 1 }); actions.updateMessage({ commit }, { id: 1 });

View file

@ -16,6 +16,11 @@ describe('#getters', () => {
}); });
}); });
it('getIsCreating', () => {
const state = { uiFlags: { isCreating: true } };
expect(getters.getIsCreating(state)).toEqual(true);
});
it('getConversationSize', () => { it('getConversationSize', () => {
const state = { const state = {
conversations: { conversations: {

View file

@ -73,6 +73,17 @@ describe('#mutations', () => {
}); });
}); });
describe('#setConversationUIFlag', () => {
it('set uiFlags correctly', () => {
const state = { uiFlags: { isFetchingList: false } };
mutations.setConversationUIFlag(state, { isCreating: true });
expect(state.uiFlags).toEqual({
isFetchingList: false,
isCreating: true,
});
});
});
describe('#setMessagesInConversation', () => { describe('#setMessagesInConversation', () => {
it('sets allMessagesLoaded flag if payload is empty', () => { it('sets allMessagesLoaded flag if payload is empty', () => {
const state = { uiFlags: { allMessagesLoaded: false } }; const state = { uiFlags: { allMessagesLoaded: false } };

View file

@ -3,27 +3,30 @@
v-if="!conversationSize && isFetchingList" v-if="!conversationSize && isFetchingList"
class="flex flex-1 items-center h-full bg-black-25 justify-center" class="flex flex-1 items-center h-full bg-black-25 justify-center"
> >
<spinner size=""></spinner> <spinner size="" />
</div> </div>
<div v-else class="home"> <div v-else class="home">
<div class="header-wrap"> <div
class="header-wrap bg-white"
:class="{ expanded: !isHeaderCollapsed, collapsed: isHeaderCollapsed }"
>
<transition <transition
enter-active-class="transition-all delay-200 duration-300 ease" enter-active-class="transition-all delay-200 duration-300 ease"
leave-active-class="transition-all duration-200 ease-in" leave-active-class="transition-all duration-200 ease-in"
enter-class="opacity-0 transform -translate-y-32" enter-class="opacity-0 transform"
enter-to-class="opacity-100 transform translate-y-0" enter-to-class="opacity-100 transform"
leave-class="opacity-100 transform translate-y-0" leave-class="opacity-100 transform"
leave-to-class="opacity-0 transform -translate-y-32" leave-to-class="opacity-0 transform"
> >
<chat-header-expanded <chat-header-expanded
v-if="!isOnMessageView" v-if="!isHeaderCollapsed"
:intro-heading="introHeading" :intro-heading="channelConfig.welcomeTitle"
:intro-body="introBody" :intro-body="channelConfig.welcomeTagline"
:avatar-url="channelConfig.avatarUrl" :avatar-url="channelConfig.avatarUrl"
:show-popout-button="showPopoutButton" :show-popout-button="showPopoutButton"
/> />
<chat-header <chat-header
v-if="isOnMessageView" v-if="isHeaderCollapsed"
:title="channelConfig.websiteName" :title="channelConfig.websiteName"
:avatar-url="channelConfig.avatarUrl" :avatar-url="channelConfig.avatarUrl"
:show-popout-button="showPopoutButton" :show-popout-button="showPopoutButton"
@ -31,21 +34,33 @@
/> />
</transition> </transition>
</div> </div>
<conversation-wrap :grouped-messages="groupedMessages" /> <div class="flex flex-1 overflow-scroll">
<conversation-wrap
v-if="currentView === 'messageView'"
:grouped-messages="groupedMessages"
/>
<pre-chat-form
v-if="currentView === 'preChatFormView'"
:options="preChatFormOptions"
/>
</div>
<div class="footer-wrap"> <div class="footer-wrap">
<transition <transition
enter-active-class="transition-all delay-300 duration-300 ease" enter-active-class="transition-all delay-300 duration-300 ease"
leave-active-class="transition-all duration-200 ease-in" leave-active-class="transition-all duration-200 ease-in"
enter-class="opacity-0 transform translate-y-32" enter-class="opacity-0 transform"
enter-to-class="opacity-100 transform translate-y-0" enter-to-class="opacity-100 transform translate-y-0"
leave-class="opacity-100 transform translate-y-0" leave-class="opacity-100 transform translate-y-0"
leave-to-class="opacity-0 transform translate-y-32 " leave-to-class="opacity-0 transform "
>
<div
v-if="showInputTextArea && currentView === 'messageView'"
class="input-wrap"
> >
<div v-if="showInputTextArea && isOnMessageView" class="input-wrap">
<chat-footer /> <chat-footer />
</div> </div>
<team-availability <team-availability
v-if="!isOnMessageView" v-if="currentView === 'cardView'"
:available-agents="availableAgents" :available-agents="availableAgents"
@start-conversation="startConversation" @start-conversation="startConversation"
/> />
@ -65,7 +80,7 @@ import configMixin from '../mixins/configMixin';
import TeamAvailability from 'widget/components/TeamAvailability'; import TeamAvailability from 'widget/components/TeamAvailability';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import PreChatForm from '../components/PreChat/Form';
export default { export default {
name: 'Home', name: 'Home',
components: { components: {
@ -74,45 +89,44 @@ export default {
ChatHeader, ChatHeader,
ChatHeaderExpanded, ChatHeaderExpanded,
ConversationWrap, ConversationWrap,
PreChatForm,
Spinner, Spinner,
TeamAvailability, TeamAvailability,
}, },
mixins: [configMixin], mixins: [configMixin],
props: { props: {
groupedMessages: {
type: Array,
default: () => [],
},
conversationSize: {
type: Number,
default: 0,
},
availableAgents: {
type: Array,
default: () => [],
},
hasFetched: { hasFetched: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
conversationAttributes: {
type: Object,
default: () => {},
},
showPopoutButton: { showPopoutButton: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}, },
data() { data() {
return { return { isOnCollapsedView: false };
showMessageView: false,
};
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
availableAgents: 'agent/availableAgents',
conversationAttributes: 'conversationAttributes/getConversationParams',
conversationSize: 'conversation/getConversationSize',
groupedMessages: 'conversation/getGroupedConversation',
isFetchingList: 'conversation/getIsFetchingList', isFetchingList: 'conversation/getIsFetchingList',
}), }),
currentView() {
if (this.isHeaderCollapsed) {
if (this.conversationSize) {
return 'messageView';
}
if (this.preChatFormEnabled) {
return 'preChatFormView';
}
return 'messageView';
}
return 'cardView';
},
isOpen() { isOpen() {
return this.conversationAttributes.status === 'open'; return this.conversationAttributes.status === 'open';
}, },
@ -125,31 +139,22 @@ export default {
} }
return true; return true;
}, },
isOnMessageView() { isHeaderCollapsed() {
if (this.hideWelcomeHeader) { if (!this.hasIntroText || this.conversationSize) {
return true; return true;
} }
if (this.conversationSize === 0) {
return this.showMessageView; return this.isOnCollapsedView;
}
return true;
}, },
isHeaderExpanded() { hasIntroText() {
return this.conversationSize === 0; return (
}, this.channelConfig.welcomeTitle || this.channelConfig.welcomeTagline
introHeading() { );
return this.channelConfig.welcomeTitle;
},
introBody() {
return this.channelConfig.welcomeTagline;
},
hideWelcomeHeader() {
return !(this.introHeading || this.introBody);
}, },
}, },
methods: { methods: {
startConversation() { startConversation() {
this.showMessageView = !this.showMessageView; this.isOnCollapsedView = !this.isOnCollapsedView;
}, },
}, },
}; };
@ -157,6 +162,7 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
@import '~widget/assets/scss/variables'; @import '~widget/assets/scss/variables';
@import '~widget/assets/scss/mixins';
.home { .home {
width: 100%; width: 100%;
@ -168,9 +174,19 @@ export default {
background: $color-background; background: $color-background;
.header-wrap { .header-wrap {
border-radius: $space-normal $space-normal 0 0;
flex-shrink: 0; flex-shrink: 0;
border-radius: $space-normal $space-normal $space-small $space-small; transition: max-height 300ms;
z-index: 99; z-index: 99;
@include shadow-large;
&.expanded {
max-height: 16rem;
}
&.collapsed {
max-height: 4.5rem;
}
@media only screen and (min-device-width: 320px) and (max-device-width: 667px) { @media only screen and (min-device-width: 320px) and (max-device-width: 667px) {
border-radius: 0; border-radius: 0;

View file

@ -10,21 +10,13 @@
> >
<home <home
v-if="!showUnreadView" v-if="!showUnreadView"
:grouped-messages="groupedMessages"
:conversation-size="conversationSize"
:available-agents="availableAgents"
:has-fetched="hasFetched" :has-fetched="hasFetched"
:conversation-attributes="conversationAttributes"
:unread-message-count="unreadMessageCount" :unread-message-count="unreadMessageCount"
:show-popout-button="showPopoutButton" :show-popout-button="showPopoutButton"
/> />
<unread <unread
v-else v-else
:unread-messages="unreadMessages"
:conversation-size="conversationSize"
:available-agents="availableAgents"
:has-fetched="hasFetched" :has-fetched="hasFetched"
:conversation-attributes="conversationAttributes"
:unread-message-count="unreadMessageCount" :unread-message-count="unreadMessageCount"
:hide-message-bubble="hideMessageBubble" :hide-message-bubble="hideMessageBubble"
/> />
@ -42,22 +34,6 @@ export default {
Unread, Unread,
}, },
props: { props: {
groupedMessages: {
type: Array,
default: () => [],
},
unreadMessages: {
type: Array,
default: () => [],
},
conversationSize: {
type: Number,
default: 0,
},
availableAgents: {
type: Array,
default: () => [],
},
hasFetched: { hasFetched: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -78,10 +54,6 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
conversationAttributes: {
type: Object,
default: () => {},
},
unreadMessageCount: { unreadMessageCount: {
type: Number, type: Number,
default: 0, default: 0,

View file

@ -35,6 +35,7 @@
import { IFrameHelper } from 'widget/helpers/utils'; import { IFrameHelper } from 'widget/helpers/utils';
import AgentBubble from 'widget/components/AgentMessageBubble.vue'; import AgentBubble from 'widget/components/AgentMessageBubble.vue';
import configMixin from '../mixins/configMixin'; import configMixin from '../mixins/configMixin';
import { mapGetters } from 'vuex';
export default { export default {
name: 'Unread', name: 'Unread',
@ -43,26 +44,10 @@ export default {
}, },
mixins: [configMixin], mixins: [configMixin],
props: { props: {
unreadMessages: {
type: Array,
default: () => [],
},
conversationSize: {
type: Number,
default: 0,
},
availableAgents: {
type: Array,
default: () => [],
},
hasFetched: { hasFetched: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
conversationAttributes: {
type: Object,
default: () => {},
},
unreadMessageCount: { unreadMessageCount: {
type: Number, type: Number,
default: 0, default: 0,
@ -73,6 +58,9 @@ export default {
}, },
}, },
computed: { computed: {
...mapGetters({
unreadMessages: 'conversation/getUnreadTextMessages',
}),
showCloseButton() { showCloseButton() {
return this.unreadMessageCount && this.hideMessageBubble; return this.unreadMessageCount && this.hideMessageBubble;
}, },

View file

@ -5,6 +5,8 @@
# id :integer not null, primary key # id :integer not null, primary key
# feature_flags :integer default(3), not null # feature_flags :integer default(3), not null
# hmac_token :string # hmac_token :string
# pre_chat_form_enabled :boolean default(FALSE)
# pre_chat_form_options :jsonb
# reply_time :integer default("in_a_few_minutes") # reply_time :integer default("in_a_few_minutes")
# website_token :string # website_token :string
# website_url :string # website_url :string

View file

@ -0,0 +1,9 @@
json.id resource.id
json.content resource.content
json.message_type resource.message_type_before_type_cast
json.content_type resource.content_type
json.content_attributes resource.content_attributes
json.created_at resource.created_at.to_i
json.conversation_id resource.conversation.display_id
json.attachments resource.attachments.map(&:push_event_data) if resource.attachments.present?
json.sender resource.sender.push_event_data if resource.sender

View file

@ -0,0 +1,10 @@
json.id @conversation.display_id
json.inbox_id @conversation.inbox_id
json.contact_last_seen_at @conversation.contact_last_seen_at.to_i
json.status @conversation.status
json.messages do
json.array! @conversation.messages do |message|
json.partial! 'api/v1/models/widget_message', resource: message
end
end
json.contact @conversation.contact

View file

@ -19,7 +19,9 @@
widgetColor: '<%= @web_widget.widget_color %>', widgetColor: '<%= @web_widget.widget_color %>',
enabledFeatures: <%= @web_widget.selected_feature_flags.to_json.html_safe %>, enabledFeatures: <%= @web_widget.selected_feature_flags.to_json.html_safe %>,
enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>, enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>,
replyTime: '<%= @web_widget.reply_time %>' replyTime: '<%= @web_widget.reply_time %>',
preChatFormEnabled: <%= @web_widget.pre_chat_form_enabled %>,
preChatFormOptions: <%= @web_widget.pre_chat_form_options.to_json.html_safe %>
} }
window.chatwootWidgetDefaults = { window.chatwootWidgetDefaults = {
useInboxAvatarForBot: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) %>, useInboxAvatarForBot: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) %>,

View file

@ -139,7 +139,7 @@ Rails.application.routes.draw do
namespace :widget do namespace :widget do
resources :events, only: [:create] resources :events, only: [:create]
resources :messages, only: [:index, :create, :update] resources :messages, only: [:index, :create, :update]
resources :conversations, only: [:index] do resources :conversations, only: [:index, :create] do
collection do collection do
post :update_last_seen post :update_last_seen
post :toggle_typing post :toggle_typing

View file

@ -0,0 +1,15 @@
class AddRequestForEmailOnChannelWebWidget < ActiveRecord::Migration[6.0]
def up
change_table :channel_web_widgets, bulk: true do |t|
t.column :pre_chat_form_enabled, :boolean, default: false
t.column :pre_chat_form_options, :jsonb, default: {}
end
end
def down
change_table :channel_web_widgets, bulk: true do |t|
t.remove :pre_chat_form_enabled
t.remove :pre_chat_form_options
end
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_02_01_150037) do ActiveRecord::Schema.define(version: 2021_02_12_154240) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@ -183,6 +183,8 @@ ActiveRecord::Schema.define(version: 2021_02_01_150037) do
t.integer "feature_flags", default: 3, null: false t.integer "feature_flags", default: 3, null: false
t.integer "reply_time", default: 0 t.integer "reply_time", default: 0
t.string "hmac_token" t.string "hmac_token"
t.boolean "pre_chat_form_enabled", default: false
t.jsonb "pre_chat_form_options", default: {}
t.index ["hmac_token"], name: "index_channel_web_widgets_on_hmac_token", unique: true t.index ["hmac_token"], name: "index_channel_web_widgets_on_hmac_token", unique: true
t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true
end end
@ -492,11 +494,9 @@ ActiveRecord::Schema.define(version: 2021_02_01_150037) do
t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context"
t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy" t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy"
t.index ["taggable_id"], name: "index_taggings_on_taggable_id" t.index ["taggable_id"], name: "index_taggings_on_taggable_id"
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable_type_and_taggable_id"
t.index ["taggable_type"], name: "index_taggings_on_taggable_type" t.index ["taggable_type"], name: "index_taggings_on_taggable_type"
t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type" t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type"
t.index ["tagger_id"], name: "index_taggings_on_tagger_id" t.index ["tagger_id"], name: "index_taggings_on_tagger_id"
t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id"
end end
create_table "tags", id: :serial, force: :cascade do |t| create_table "tags", id: :serial, force: :cascade do |t|

View file

@ -27,6 +27,31 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
end end
end end
describe 'POST /api/v1/widget/conversations' do
it 'creates a conversation' do
post '/api/v1/widget/conversations',
headers: { 'X-Auth-Token' => token },
params: {
website_token: web_widget.website_token,
contact: {
name: 'contact-name',
email: 'contact-email@chatwoot.com'
},
message: {
content: 'This is a test message'
}
},
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['id']).not_to eq nil
expect(json_response['contact']['email']).to eq 'contact-email@chatwoot.com'
expect(json_response['messages'][0]['content']).to eq 'This is a test message'
end
end
describe 'POST /api/v1/widget/conversations/toggle_typing' do describe 'POST /api/v1/widget/conversations/toggle_typing' do
context 'with a conversation' do context 'with a conversation' do
it 'dispatches the correct typing status' do it 'dispatches the correct typing status' do