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
19 changed files with 263 additions and 18 deletions

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);
});
});
});