feat: End conversation from widget (#3660)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Aswin Dev P.S 2022-03-15 22:07:30 +05:30 committed by GitHub
parent 4b748e2c8c
commit c4837cd7ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 263 additions and 18 deletions

View file

@ -34,6 +34,8 @@ class Api::V1::Widget::BaseController < ApplicationController
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
Current.contact = @contact
end
def create_conversation

View file

@ -44,6 +44,15 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
head :ok
end
def toggle_status
head :not_found && return if conversation.nil?
unless conversation.resolved?
conversation.status = 'resolved'
conversation.save
end
head :ok
end
private
def trigger_typing_event(event)

View file

@ -2,12 +2,15 @@
"arrow-clockwise-outline": "M12 4.75a7.25 7.25 0 1 0 7.201 6.406c-.068-.588.358-1.156.95-1.156.515 0 .968.358 1.03.87a9.25 9.25 0 1 1-3.432-6.116V4.25a1 1 0 1 1 2.001 0v2.698l.034.052h-.034v.25a1 1 0 0 1-1 1h-3a1 1 0 1 1 0-2h.666A7.219 7.219 0 0 0 12 4.75Z",
"arrow-right-outline": "M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z",
"attach-outline": "M11.772 3.743a6 6 0 0 1 8.66 8.302l-.19.197-8.8 8.798-.036.03a3.723 3.723 0 0 1-5.489-4.973.764.764 0 0 1 .085-.13l.054-.06.086-.088.142-.148.002.003 7.436-7.454a.75.75 0 0 1 .977-.074l.084.073a.75.75 0 0 1 .074.976l-.073.084-7.594 7.613a2.23 2.23 0 0 0 3.174 3.106l8.832-8.83A4.502 4.502 0 0 0 13 4.644l-.168.16-.013.014-9.536 9.536a.75.75 0 0 1-1.133-.977l.072-.084 9.549-9.55h.002Z",
"checkmark-outline": "M4.53 12.97a.75.75 0 0 0-1.06 1.06l4.5 4.5a.75.75 0 0 0 1.06 0l11-11a.75.75 0 0 0-1.06-1.06L8.5 16.94l-3.97-3.97Z",
"chevron-left-outline": "M15.53 4.22a.75.75 0 0 1 0 1.06L8.81 12l6.72 6.72a.75.75 0 1 1-1.06 1.06l-7.25-7.25a.75.75 0 0 1 0-1.06l7.25-7.25a.75.75 0 0 1 1.06 0Z",
"chevron-right-outline": "M8.293 4.293a1 1 0 0 0 0 1.414L14.586 12l-6.293 6.293a1 1 0 1 0 1.414 1.414l7-7a1 1 0 0 0 0-1.414l-7-7a1 1 0 0 0-1.414 0Z",
"dismiss-outline": "m4.397 4.554.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l6.47-6.47a.75.75 0 1 1 1.06 1.061L13.061 12l6.47 6.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-6.47 6.47a.75.75 0 0 1-1.06-1.061L10.939 12l-6.47-6.47a.75.75 0 0 1-.072-.976l.073-.084-.073.084Z",
"document-outline": "M18.5 20a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5h6V8a2 2 0 0 0 2 2h4.5v10Zm-5-15.379L17.378 8.5H14a.5.5 0 0 1-.5-.5V4.621Zm5.914 3.793-5.829-5.828c-.026-.026-.058-.046-.085-.07a2.072 2.072 0 0 0-.219-.18c-.04-.027-.086-.045-.128-.068-.071-.04-.141-.084-.216-.116a1.977 1.977 0 0 0-.624-.138C12.266 2.011 12.22 2 12.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9.828a2 2 0 0 0-.586-1.414Z",
"emoji-outline": "M12 1.999c5.524 0 10.002 4.478 10.002 10.002 0 5.523-4.478 10.001-10.002 10.001-5.524 0-10.002-4.478-10.002-10.001C1.998 6.477 6.476 1.999 12 1.999Zm0 1.5a8.502 8.502 0 1 0 0 17.003A8.502 8.502 0 0 0 12 3.5ZM8.462 14.784A4.491 4.491 0 0 0 12 16.502a4.492 4.492 0 0 0 3.535-1.714.75.75 0 1 1 1.177.93A5.991 5.991 0 0 1 12 18.002a5.991 5.991 0 0 1-4.716-2.29.75.75 0 0 1 1.178-.928ZM9 8.75a1.25 1.25 0 1 1 0 2.499A1.25 1.25 0 0 1 9 8.75Zm6 0a1.25 1.25 0 1 1 0 2.499 1.25 1.25 0 0 1 0-2.499Z",
"link-outline": "M9.25 7a.75.75 0 0 1 .11 1.492l-.11.008H7a3.5 3.5 0 0 0-.206 6.994L7 15.5h2.25a.75.75 0 0 1 .11 1.492L9.25 17H7a5 5 0 0 1-.25-9.994L7 7h2.25ZM17 7a5 5 0 0 1 .25 9.994L17 17h-2.25a.75.75 0 0 1-.11-1.492l.11-.008H17a3.5 3.5 0 0 0 .206-6.994L17 8.5h-2.25a.75.75 0 0 1-.11-1.492L14.75 7H17ZM7 11.25h10a.75.75 0 0 1 .102 1.493L17 12.75H7a.75.75 0 0 1-.102-1.493L7 11.25h10H7Z",
"more-vertical-outline": "M12 7.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM12 13.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM10.25 18a1.75 1.75 0 1 0 3.5 0 1.75 1.75 0 0 0-3.5 0Z",
"open-outline": "M6.25 4.5A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.783 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z",
"send-outline": "M5.694 12 2.299 3.272c-.236-.607.356-1.188.942-.982l.093.04 18 9a.75.75 0 0 1 .097 1.283l-.097.058-18 9c-.583.291-1.217-.244-1.065-.847l.03-.096L5.694 12 2.299 3.272 5.694 12ZM4.402 4.54l2.61 6.71h6.627a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.649.743l-.101.007H7.01l-2.609 6.71L19.322 12 4.401 4.54Z"
"send-outline": "M5.694 12 2.299 3.272c-.236-.607.356-1.188.942-.982l.093.04 18 9a.75.75 0 0 1 .097 1.283l-.097.058-18 9c-.583.291-1.217-.244-1.065-.847l.03-.096L5.694 12 2.299 3.272 5.694 12ZM4.402 4.54l2.61 6.71h6.627a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.649.743l-.101.007H7.01l-2.609 6.71L19.322 12 4.401 4.54Z",
"sign-out-outline": ["M8.502 11.5a1.002 1.002 0 1 1 0 2.004 1.002 1.002 0 0 1 0-2.004Z","M12 4.354v6.651l7.442-.001L17.72 9.28a.75.75 0 0 1-.073-.976l.073-.084a.75.75 0 0 1 .976-.073l.084.073 2.997 2.997a.75.75 0 0 1 .073.976l-.073.084-2.996 3.004a.75.75 0 0 1-1.134-.975l.072-.085 1.713-1.717-7.431.001L12 19.25a.75.75 0 0 1-.88.739l-8.5-1.502A.75.75 0 0 1 2 17.75V5.75a.75.75 0 0 1 .628-.74l8.5-1.396a.75.75 0 0 1 .872.74Zm-1.5.883-7 1.15V17.12l7 1.236V5.237Z","M13 18.501h.765l.102-.006a.75.75 0 0 0 .648-.745l-.007-4.25H13v5.001ZM13.002 10 13 8.725V5h.745a.75.75 0 0 1 .743.647l.007.102.007 4.251h-1.5Z"]
}

View file

@ -48,6 +48,11 @@ const sendEmailTranscript = async ({ email }) => {
{ email }
);
};
const toggleStatus = async () => {
return API.get(
`/api/v1/widget/conversations/toggle_status${window.location.search}`
);
};
export {
createConversationAPI,
@ -58,4 +63,5 @@ export {
toggleTyping,
setUserLastSeenAt,
sendEmailTranscript,
toggleStatus,
};

View file

@ -1,5 +1,13 @@
<template>
<div v-if="showHeaderActions" class="actions flex items-center">
<button
v-if="conversationStatus === 'open'"
class="button transparent compact"
:title="$t('END_CONVERSATION')"
@click="resolveConversation"
>
<fluent-icon icon="sign-out" size="22" class="text-black-900" />
</button>
<button
v-if="showPopoutButton"
class="button transparent compact new-window--button "
@ -19,6 +27,7 @@
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import { buildPopoutURL } from '../helpers/urlParamsHelper';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
@ -33,6 +42,9 @@ export default {
},
},
computed: {
...mapGetters({
conversationAttributes: 'conversationAttributes/getConversationParams',
}),
isIframe() {
return IFrameHelper.isIFrame();
},
@ -40,7 +52,13 @@ export default {
return RNHelper.isRNWebView();
},
showHeaderActions() {
return this.isIframe || this.isRNWebView;
return this.isIframe || this.isRNWebView || this.hasWidgetOptions;
},
conversationStatus() {
return this.conversationAttributes.status;
},
hasWidgetOptions() {
return this.showPopoutButton || this.conversationStatus === 'open';
},
},
methods: {
@ -72,6 +90,9 @@ export default {
RNHelper.sendMessage({ type: 'close-widget' });
}
},
resolveConversation() {
this.$store.dispatch('conversation/resolveConversation');
},
},
};
</script>

View file

@ -0,0 +1,83 @@
<template>
<div class="relative">
<button class="z-10 focus:outline-none select-none" @click="toggleMenu">
<slot name="button"></slot>
</button>
<!-- to close when clicked on space around it-->
<button
v-if="isOpen"
tabindex="-1"
class="fixed inset-0 h-full w-full cursor-default focus:outline-none"
@click="toggleMenu"
></button>
<!--dropdown menu-->
<transition
enter-active-class="transition-all duration-200 ease-out"
leave-active-class="transition-all duration-750 ease-in"
enter-class="opacity-0 scale-75"
enter-to-class="opacity-100 scale-100"
leave-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-75"
>
<div
v-if="isOpen"
class="menu-content absolute shadow-xl rounded-md border-solid border border-slate-100 mt-1 py-1 px-2 bg-white z-10"
:class="menuPlacement === 'right' ? 'right-0' : 'left-0'"
>
<slot name="content"></slot>
</div>
</transition>
</div>
</template>
<script>
export default {
props: {
menuPlacement: {
type: String,
default: 'right',
validator: value => ['right', 'left'].indexOf(value) !== -1,
},
open: {
type: Boolean,
default: false,
},
toggleMenu: {
type: Function,
default: () => {},
},
},
data() {
return {
isOpen: false,
};
},
watch: {
open() {
this.isOpen = !this.isOpen;
},
},
mounted() {
document.addEventListener('keydown', this.onEscape);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onEscape);
},
methods: {
onEscape(e) {
if (e.key === 'Esc' || e.key === 'Escape') {
this.isOpen = false;
}
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.menu-content {
width: max-content;
}
</style>

View file

@ -0,0 +1,67 @@
<template>
<button :class="['menu-item', itemClass]" @click="action">
<fluent-icon
v-if="icon"
:icon="iconName"
:size="iconSize"
:class="iconClass"
/>
<span :class="[{ 'pl-3': icon }, textClass]">{{ text }}</span>
</button>
</template>
<script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
export default {
components: { FluentIcon },
props: {
text: {
type: String,
default: 'Default',
},
textClass: {
type: String,
default: 'text-sm',
},
icon: {
type: Boolean,
default: true,
},
iconName: {
type: String,
default: '',
},
iconSize: {
type: String,
default: '15',
},
iconClass: {
type: String,
default: 'text-black-900',
},
itemClass: {
type: String,
default:
'flex items-center p-3 cursor-pointer ml-0 border-b border-slate-100',
},
action: {
type: Function,
default: () => {},
},
},
};
</script>
<style scoped lang="scss">
@import '~widget/assets/scss/variables.scss';
.menu-item {
margin-left: $zero !important;
outline: none;
&:last-child {
border-bottom: none;
}
&:disabled {
cursor: not-allowed;
}
}
</style>

View file

@ -11,12 +11,16 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
'conversation.status_changed': this.onStatusChange,
'conversation.created': this.onConversationCreated,
'presence.update': this.onPresenceUpdate,
'contact.merged': this.onContactMerge,
};
}
onStatusChange = data => {
if (data.status === 'resolved') {
this.app.$store.dispatch('campaign/resetCampaign');
}
this.app.$store.dispatch('conversationAttributes/update', data);
};
@ -33,6 +37,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('conversation/addOrUpdateMessage', data);
};
onConversationCreated = () => {
this.app.$store.dispatch('conversationAttributes/getAttributes');
};
onPresenceUpdate = data => {
this.app.$store.dispatch('agent/updatePresence', data.users);
};

View file

@ -22,6 +22,7 @@
"IN_A_DAY": "Typically replies in a day"
},
"START_CONVERSATION": "Start Conversation",
"END_CONVERSATION": "End Conversation",
"CONTINUE_CONVERSATION": "Continue conversation",
"START_NEW_CONVERSATION": "Start a new conversation",
"UNREAD_VIEW": {

View file

@ -100,6 +100,7 @@ export const actions = {
{ root: true }
);
await triggerCampaign({ campaignId, websiteToken });
commit('setCampaignExecuted', true);
commit('setActiveCampaign', {});
} catch (error) {
commit('setError', true);
@ -113,6 +114,7 @@ export const actions = {
},
resetCampaign: async ({ commit }) => {
try {
commit('setCampaignExecuted', false);
commit('setActiveCampaign', {});
} catch (error) {
commit('setError', true);
@ -130,6 +132,12 @@ export const mutations = {
setError($state, value) {
Vue.set($state.uiFlags, 'isError', value);
},
setHasFetched($state, value) {
Vue.set($state.uiFlags, 'hasFetched', value);
},
setCampaignExecuted($state, data) {
Vue.set($state, 'campaignHasExecuted', data);
},
};
export default {

View file

@ -5,6 +5,7 @@ import {
sendAttachmentAPI,
toggleTyping,
setUserLastSeenAt,
toggleStatus,
} from 'widget/api/conversation';
import { createTemporaryMessage, getNonDeletedMessages } from './helpers';
@ -130,4 +131,8 @@ export const actions = {
// IgnoreError
}
},
resolveConversation: async () => {
await toggleStatus();
},
};

View file

@ -132,6 +132,7 @@ describe('#actions', () => {
root: true,
},
],
['setCampaignExecuted', true],
['setActiveCampaign', {}],
[
'conversation/setConversationUIFlag',
@ -176,7 +177,10 @@ describe('#actions', () => {
it('sends correct actions if execute campaign API is success', async () => {
API.post.mockResolvedValue({});
await actions.resetCampaign({ commit });
expect(commit.mock.calls).toEqual([['setActiveCampaign', {}]]);
expect(commit.mock.calls).toEqual([
['setCampaignExecuted', false],
['setActiveCampaign', {}],
]);
});
});
});

View file

@ -25,4 +25,12 @@ describe('#mutations', () => {
expect(state.activeCampaign).toEqual(campaigns[0]);
});
});
describe('#setCampaignExecuted', () => {
it('set campaign executed flag', () => {
const state = { records: [], uiFlags: {}, campaignHasExecuted: false };
mutations.setCampaignExecuted(state, true);
expect(state.campaignHasExecuted).toEqual(true);
});
});
});

View file

@ -1,20 +1,6 @@
class ActionCableListener < BaseListener
include Events::Types
def conversation_created(event)
conversation, account = extract_conversation_and_account(event)
tokens = user_tokens(account, conversation.inbox.members)
broadcast(account, tokens, CONVERSATION_CREATED, conversation.push_event_data)
end
def conversation_read(event)
conversation, account = extract_conversation_and_account(event)
tokens = user_tokens(account, conversation.inbox.members)
broadcast(account, tokens, CONVERSATION_READ, conversation.push_event_data)
end
def message_created(event)
message, account = extract_message_and_account(event)
conversation = message.conversation
@ -26,12 +12,25 @@ class ActionCableListener < BaseListener
def message_updated(event)
message, account = extract_message_and_account(event)
conversation = message.conversation
contact = conversation.contact
tokens = user_tokens(account, conversation.inbox.members) + contact_tokens(conversation.contact_inbox, message)
broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data)
end
def conversation_created(event)
conversation, account = extract_conversation_and_account(event)
tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)
broadcast(account, tokens, CONVERSATION_CREATED, conversation.push_event_data)
end
def conversation_read(event)
conversation, account = extract_conversation_and_account(event)
tokens = user_tokens(account, conversation.inbox.members)
broadcast(account, tokens, CONVERSATION_READ, conversation.push_event_data)
end
def conversation_status_changed(event)
conversation, account = extract_conversation_and_account(event)
tokens = user_tokens(account, conversation.inbox.members) + contact_inbox_tokens(conversation.contact_inbox)

View file

@ -20,6 +20,8 @@ module ActivityMessageHandler
def create_status_change_message(user_name)
content = if user_name
I18n.t("conversations.activity.status.#{status}", user_name: user_name)
elsif Current.contact.present?
I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize)
elsif resolved?
I18n.t('conversations.activity.status.auto_resolved', duration: auto_resolve_duration)
end

View file

@ -72,6 +72,7 @@ en:
activity:
status:
resolved: "Conversation was marked resolved by %{user_name}"
contact_resolved: "Conversation was resolved by %{contact_name}"
open: "Conversation was reopened by %{user_name}"
pending: "Conversation was marked as pending by %{user_name}"
snoozed: "Conversation was snoozed by %{user_name}"

View file

@ -188,6 +188,7 @@ Rails.application.routes.draw do
post :update_last_seen
post :toggle_typing
post :transcript
get :toggle_status
end
end
resource :contact, only: [:show, :update] do

View file

@ -2,10 +2,12 @@ module Current
thread_mattr_accessor :user
thread_mattr_accessor :account
thread_mattr_accessor :account_user
thread_mattr_accessor :contact
def self.reset
Current.user = nil
Current.account = nil
Current.account_user = nil
Current.contact = nil
end
end

View file

@ -118,4 +118,19 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do
end
end
end
describe 'GET /api/v1/widget/conversations/toggle_status' do
context 'when user end conversation from widget' do
it 'resolves the conversation' do
expect(conversation.open?).to be true
get '/api/v1/widget/conversations/toggle_status',
headers: { 'X-Auth-Token' => token },
params: { website_token: web_widget.website_token },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.resolved?).to be true
end
end
end
end