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:
parent
d57dc41cee
commit
2082409657
13 changed files with 564 additions and 24 deletions
|
@ -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>
|
Loading…
Add table
Add a link
Reference in a new issue