feat: Add support for right click context menu in conversations (#4923)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Fayaz Ahmed
2022-07-26 10:47:28 +05:30
committed by GitHub
co-authored by Pranav Raj S
parent d57dc41cee
commit 2082409657
13 changed files with 564 additions and 24 deletions
@@ -97,7 +97,11 @@
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
/>
<div ref="activeConversation" class="conversations-list">
<div
ref="activeConversation"
class="conversations-list"
:class="{ 'is-context-menu-open': isContextMenuOpen }"
>
<conversation-card
v-for="chat in conversationList"
:key="chat.id"
@@ -110,6 +114,10 @@
:selected="isConversationSelected(chat.id)"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabels"
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
/>
<div v-if="chatListLoading" class="text-center">
@@ -217,6 +225,7 @@ export default {
showDeleteFoldersModal: false,
selectedConversations: [],
selectedInboxes: [],
isContextMenuOpen: false,
};
},
computed: {
@@ -584,11 +593,12 @@ export default {
this.resetBulkActions();
}
},
async onAssignAgent(agent) {
// Same method used in context menu, conversationId being passed from there.
async onAssignAgent(agent, conversationId = null) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
ids: conversationId || this.selectedConversations,
fields: {
assignee_id: agent.id,
},
@@ -599,11 +609,12 @@ export default {
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async onAssignLabels(labels) {
// Same method used in context menu, conversationId being passed from there.
async onAssignLabels(labels, conversationId = null) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: this.selectedConversations,
ids: conversationId || this.selectedConversations,
labels: {
add: labels,
},
@@ -629,12 +640,27 @@ export default {
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
},
toggleConversationStatus(conversationId, status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId,
status,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
allSelectedConversationsStatus(status) {
if (!this.selectedConversations.length) return false;
return this.selectedConversations.every(item => {
return this.$store.getters.getConversationById(item).status === status;
});
},
onContextMenuToggle(state) {
this.isContextMenuOpen = state;
},
},
};
</script>
@@ -647,6 +673,13 @@ export default {
margin-bottom: var(--space-normal);
}
.conversations-list {
// Prevent the list from scrolling if the submenu is opened
&.is-context-menu-open {
overflow: hidden !important;
}
}
.conversations-list-wrap {
flex-shrink: 0;
width: 34rem;
@@ -113,6 +113,7 @@
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin.js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedAltAndEKey,
@@ -126,13 +127,6 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import wootConstants from '../../constants';
import {
getUnixTime,
addHours,
addWeeks,
startOfTomorrow,
startOfWeek,
} from 'date-fns';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
@@ -146,7 +140,7 @@ export default {
WootDropdownSubMenu,
WootDropdownDivider,
},
mixins: [clickaway, alertMixin, eventListenerMixins],
mixins: [clickaway, alertMixin, eventListenerMixins, snoozeTimesMixin],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
@@ -178,16 +172,6 @@ export default {
showAdditionalActions() {
return !this.isPending && !this.isSnoozed;
},
snoozeTimes() {
return {
// tomorrow = 9AM next day
tomorrow: getUnixTime(addHours(startOfTomorrow(), 9)),
// next week = 9AM Monday, next week
nextWeek: getUnixTime(
addHours(startOfWeek(addWeeks(new Date(), 1), { weekStartsOn: 1 }), 9)
),
};
},
},
mounted() {
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
@@ -22,6 +22,7 @@ import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
const WootUIKit = {
AvatarUploader,
@@ -47,6 +48,7 @@ const WootUIKit = {
TabsItem,
Thumbnail,
ConfirmModal,
ContextMenu,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys
@@ -0,0 +1,53 @@
<template>
<div
v-show="show"
ref="context"
class="context-menu-container"
:style="style"
tabindex="0"
@blur="$emit('close')"
>
<slot />
</div>
</template>
<script>
export default {
props: {
x: {
type: Number,
default: 0,
},
y: {
type: Number,
default: 0,
},
},
data() {
return {
left: this.x,
top: this.y,
show: false,
};
},
computed: {
style() {
return {
top: this.top + 'px',
left: this.left + 'px',
};
},
},
mounted() {
this.$nextTick(() => this.$el.focus());
this.show = true;
},
};
</script>
<style>
.context-menu-container {
position: fixed;
z-index: var(--z-index-very-high);
outline: none;
cursor: pointer;
}
</style>
@@ -10,6 +10,7 @@
@mouseenter="onCardHover"
@mouseleave="onCardLeave"
@click="cardClick(chat)"
@contextmenu="openContextMenu($event)"
>
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
<input
@@ -91,6 +92,21 @@
<span class="unread">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
</div>
</div>
<woot-context-menu
v-if="showContextMenu"
ref="menu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<conversation-context-menu
:status="chat.status"
:inbox-id="inbox.id"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
/>
</woot-context-menu>
</div>
</template>
<script>
@@ -104,6 +120,8 @@ import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
const ATTACHMENT_ICONS = {
image: 'image',
@@ -118,9 +136,16 @@ export default {
components: {
InboxName,
Thumbnail,
ConversationContextMenu,
},
mixins: [inboxMixin, timeMixin, conversationMixin, messageFormatterMixin],
mixins: [
inboxMixin,
timeMixin,
conversationMixin,
messageFormatterMixin,
alertMixin,
],
props: {
activeLabel: {
type: String,
@@ -162,6 +187,11 @@ export default {
data() {
return {
hovered: false,
showContextMenu: false,
contextMenu: {
x: null,
y: null,
},
};
},
computed: {
@@ -292,6 +322,36 @@ export default {
const action = checked ? 'select-conversation' : 'de-select-conversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
e.preventDefault();
this.$emit('context-menu-toggle', true);
this.contextMenu.x = e.pageX || e.clientX;
this.contextMenu.y = e.pageY || e.clientY;
this.showContextMenu = true;
},
closeContextMenu() {
this.$emit('context-menu-toggle', false);
this.showContextMenu = false;
this.contextMenu.x = null;
this.contextMenu.y = null;
},
onUpdateConversation(status, snoozedUntil) {
this.closeContextMenu();
this.$emit(
'update-conversation-status',
this.chat.id,
status,
snoozedUntil
);
},
async onAssignAgent(agent) {
this.$emit('assign-agent', agent, [this.chat.id]);
this.closeContextMenu();
},
async onAssignLabel(label) {
this.$emit('assign-label', [label.title], [this.chat.id]);
this.closeContextMenu();
},
},
};
</script>
@@ -0,0 +1,176 @@
<template>
<div class="menu-container">
<template v-for="option in statusMenuConfig">
<menu-item
v-if="show(option.key)"
:key="option.key"
:option="option"
variant="icon"
@click.native="toggleStatus(option.key, null)"
/>
</template>
<menu-item-with-submenu :option="snoozeMenuConfig">
<menu-item
v-for="(option, i) in snoozeMenuConfig.options"
:key="i"
:option="option"
@click.native="snoozeConversation(option.snoozedUntil)"
/>
</menu-item-with-submenu>
<menu-item-with-submenu :option="labelMenuConfig">
<template>
<menu-item
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.native="$emit('assign-label', label)"
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="agentMenuConfig">
<agent-loading-placeholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<menu-item
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.native="$emit('assign-agent', agent)"
/>
</template>
</menu-item-with-submenu>
</div>
</template>
<script>
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants.js';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default {
components: {
MenuItem,
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [snoozeTimesMixin],
props: {
status: {
type: String,
default: '',
},
inboxId: {
type: Number,
default: null,
},
},
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
statusMenuConfig: [
{
key: wootConstants.STATUS_TYPE.RESOLVED,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
icon: 'checkmark',
},
{
key: wootConstants.STATUS_TYPE.PENDING,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
icon: 'book-clock',
},
{
key: wootConstants.STATUS_TYPE.OPEN,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'),
icon: 'arrow-redo',
},
],
snoozeMenuConfig: {
key: 'snooze',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TITLE'),
icon: 'snooze',
options: [
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_REPLY'),
key: 'next-reply',
snoozedUntil: null,
},
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.TOMORROW'),
key: 'tomorrow',
snoozedUntil: 'tomorrow',
},
{
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.SNOOZE.NEXT_WEEK'),
key: 'next-week',
snoozedUntil: 'nextWeek',
},
],
},
labelMenuConfig: {
key: 'label',
icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
},
agentMenuConfig: {
key: 'agent',
icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
},
};
},
computed: {
...mapGetters({
labels: 'labels/getLabels',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
);
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
},
methods: {
toggleStatus(status, snoozedUntil) {
this.$emit('update-conversation', status, snoozedUntil);
},
snoozeConversation(snoozedUntil) {
this.$emit(
'update-conversation',
this.STATUS_TYPE.SNOOZED,
this.snoozeTimes[snoozedUntil] || null
);
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
return this.status !== key;
},
generateMenuLabelConfig(option, type = 'text') {
return {
key: option.id,
...(type === 'icon' && { icon: option.icon }),
...(type === 'label' && { color: option.color }),
...(type === 'agent' && { thumbnail: option.thumbnail }),
...(type === 'text' && { label: option.label }),
...(type === 'label' && { label: option.title }),
...(type === 'agent' && { label: option.name }),
};
},
},
};
</script>
<style lang="scss" scoped>
.menu-container {
padding: var(--space-smaller);
background-color: var(--white);
box-shadow: var(--shadow-context-menu);
border-radius: var(--border-radius-normal);
}
</style>
@@ -0,0 +1,31 @@
<template>
<div class="agent-placeholder">
<spinner />
<p>{{ $t('CONVERSATION.CARD_CONTEXT_MENU.AGENTS_LOADING') }}</p>
</div>
</template>
<script>
import Spinner from 'shared/components/Spinner';
export default {
components: {
Spinner,
},
};
</script>
<style scoped lang="scss">
.agent-placeholder {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
padding: var(--space-normal) 0;
min-width: calc(var(--space-mega) * 2);
p {
margin: var(--space-small) 0 0 0;
}
}
</style>
@@ -0,0 +1,81 @@
<template>
<div class="menu">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"
size="14"
class="menu-icon"
/>
<span
v-if="variant === 'label' && option.color"
class="label-pill"
:style="{ backgroundColor: option.color }"
/>
<thumbnail
v-if="variant === 'agent'"
:username="option.label"
:src="option.thumbnail"
size="20px"
class="agent-thumbnail"
/>
<p class="menu-label">{{ option.label }}</p>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
props: {
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
},
};
</script>
<style scoped lang="scss">
.menu {
display: flex;
align-items: center;
width: 100%;
padding: var(--space-smaller);
border-radius: var(--border-radius-small);
min-width: calc(var(--space-mega) * 2);
.menu-label {
margin: 0;
font-size: var(--font-size-mini);
}
.menu-icon {
margin-right: var(--space-small);
}
&:hover {
background-color: var(--w-500);
color: var(--white);
}
}
.agent-thumbnail {
margin-top: 0 !important;
margin-right: var(--space-small);
}
.label-pill {
width: var(--space-normal);
height: var(--space-normal);
border-radius: var(--border-radius-rounded);
border: 1px solid var(--s-50);
flex-shrink: 0;
margin-right: var(--space-small);
}
</style>
@@ -0,0 +1,77 @@
<template>
<div class="menu-with-submenu flex-between">
<div class="menu-left">
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
<p class="menu-label">{{ option.label }}</p>
</div>
<fluent-icon icon="chevron-right" size="12" />
<div class="submenu">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
option: {
type: Object,
default: () => {},
},
},
};
</script>
<style scoped lang="scss">
.menu-with-submenu {
width: 100%;
padding: var(--space-smaller);
border-radius: var(--border-radius-small);
position: relative;
min-width: calc(var(--space-mega) * 2);
background-color: var(--white);
.menu-left {
display: flex;
align-items: center;
.menu-label {
margin: 0;
font-size: var(--font-size-mini);
}
.menu-icon {
margin-right: var(--space-small);
}
}
.submenu {
padding: var(--space-smaller);
background-color: var(--white);
box-shadow: var(--shadow-context-menu);
border-radius: var(--border-radius-normal);
position: absolute;
position: absolute;
left: 100%;
top: 0;
display: none;
}
&:hover {
background-color: var(--w-75);
.submenu {
display: block;
}
&:before {
content: '';
position: absolute;
z-index: var(--z-index-highest);
bottom: -65%;
height: 75%;
right: 0%;
width: 50%;
clip-path: polygon(100% 0, 0% 0%, 100% 100%);
}
}
}
</style>