feat: Add inbox view under feature flag on the sidebar (#9049)

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Sivin Varghese
2024-03-08 07:57:39 +05:30
committed by GitHub
parent b6bf6bd414
commit 0685e04aae
18 changed files with 340 additions and 256 deletions

View File

@@ -48,7 +48,7 @@
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open" class="dropdown-pane dropdown-pane--open"
> >
<woot-dropdown-menu> <woot-dropdown-menu class="mb-0">
<woot-dropdown-item v-if="!isPending"> <woot-dropdown-item v-if="!isPending">
<woot-button <woot-button
variant="clear" variant="clear"

View File

@@ -2,6 +2,15 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const primaryMenuItems = accountId => [ const primaryMenuItems = accountId => [
{
icon: 'mail-inbox',
key: 'inboxView',
label: 'INBOX_VIEW',
featureFlag: FEATURE_FLAGS.INBOX_VIEW,
toState: frontendURL(`accounts/${accountId}/inbox-view`),
toStateName: 'inbox_view',
roles: ['administrator', 'agent'],
},
{ {
icon: 'chat', icon: 'chat',
key: 'conversations', key: 'conversations',

View File

@@ -8,7 +8,7 @@
:chat="currentChat" :chat="currentChat"
:is-inbox-view="isInboxView" :is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen" :is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout" :show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel" @contact-panel-toggle="onToggleContactPanel"
/> />
<woot-tabs <woot-tabs
@@ -35,7 +35,10 @@
:is-contact-panel-open="isContactPanelOpen" :is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel" @contact-panel-toggle="onToggleContactPanel"
/> />
<empty-state v-else :is-on-expanded-layout="isOnExpandedLayout" /> <empty-state
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div <div
v-show="showContactPanel" v-show="showContactPanel"
class="conversation-sidebar-wrap basis-full sm:basis-[17.5rem] md:basis-[18.75rem] lg:basis-[19.375rem] xl:basis-[20.625rem] 2xl:basis-[25rem] rtl:border-r border-slate-50 dark:border-slate-700 h-auto overflow-auto z-10 flex-shrink-0 flex-grow-0" class="conversation-sidebar-wrap basis-full sm:basis-[17.5rem] md:basis-[18.75rem] lg:basis-[19.375rem] xl:basis-[20.625rem] 2xl:basis-[25rem] rtl:border-r border-slate-50 dark:border-slate-700 h-auto overflow-auto z-10 flex-shrink-0 flex-grow-0"

View File

@@ -3,7 +3,8 @@
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row" class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row"
> >
<div <div
class="flex-1 w-full min-w-0 flex flex-col md:flex-row items-center justify-center" class="flex-1 w-full min-w-0 flex flex-col items-center justify-center"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
> >
<div class="flex justify-start items-center min-w-0 w-fit max-w-full"> <div class="flex justify-start items-center min-w-0 w-fit max-w-full">
<back-button <back-button
@@ -87,7 +88,6 @@ import Thumbnail from '../Thumbnail.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default { export default {
components: { components: {
@@ -128,9 +128,6 @@ export default {
params: { accountId, inbox_id: inboxId, label, teamId }, params: { accountId, inbox_id: inboxId, label, teamId },
name, name,
} = this.$route; } = this.$route;
if (this.isInboxView) {
return frontendURL(`accounts/${accountId}/inbox`);
}
return conversationListPageURL({ return conversationListPageURL({
accountId, accountId,
inboxId, inboxId,

View File

@@ -4,17 +4,18 @@
"TITLE": "Inbox", "TITLE": "Inbox",
"DISPLAY_DROPDOWN": "Display", "DISPLAY_DROPDOWN": "Display",
"LOADING": "Fetching notifications", "LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group.", "404": "There are no active notifications in this group.",
"NO_NOTIFICATIONS": "No notifications", "NO_NOTIFICATIONS": "No notifications",
"NOTE": "Notifications from all subscribed inboxes", "NOTE": "Notifications from all subscribed inboxes",
"NO_MESSAGES_AVAILABLE": "Oops! Not able to fetch messages",
"SNOOZED_UNTIL": "Snoozed until", "SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow", "SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week" "SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week"
}, },
"ACTION_HEADER": { "ACTION_HEADER": {
"SNOOZE": "Snooze notification", "SNOOZE": "Snooze notification",
"DELETE": "Delete notification" "DELETE": "Delete notification",
"BACK": "Back"
}, },
"TYPES": { "TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation", "CONVERSATION_MENTION": "You have been mentioned in a conversation",

View File

@@ -195,6 +195,7 @@
"SIDEBAR": { "SIDEBAR": {
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:", "CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
"SWITCH": "Switch", "SWITCH": "Switch",
"INBOX_VIEW": "Inbox View",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"INBOX": "Inbox", "INBOX": "Inbox",
"ALL_CONVERSATIONS": "All Conversations", "ALL_CONVERSATIONS": "All Conversations",

View File

@@ -1,28 +1,9 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView'); const ConversationView = () => import('./ConversationView');
const InboxView = () => import('../inbox/InboxView.vue');
export default { export default {
routes: [ routes: [
{
path: frontendURL('accounts/:accountId/inbox-view'),
name: 'inbox_view',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {
return { inboxId: 0 };
},
},
{
path: frontendURL('accounts/:accountId/inbox-view/:conversation_id'),
name: 'inbox_view_conversation',
roles: ['administrator', 'agent'],
component: InboxView,
props: route => {
return { inboxId: 0, conversationId: route.params.conversation_id };
},
},
{ {
path: frontendURL('accounts/:accountId/dashboard'), path: frontendURL('accounts/:accountId/dashboard'),
name: 'home', name: 'home',

View File

@@ -3,6 +3,7 @@ import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes'; import { routes as searchRoutes } from '../../modules/search/search.routes';
import { routes as contactRoutes } from './contacts/routes'; import { routes as contactRoutes } from './contacts/routes';
import { routes as notificationRoutes } from './notifications/routes'; import { routes as notificationRoutes } from './notifications/routes';
import { routes as inboxRoutes } from './inbox/routes';
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes'; import helpcenterRoutes from './helpcenter/helpcenter.routes';
@@ -16,6 +17,7 @@ export default {
path: frontendURL('accounts/:account_id'), path: frontendURL('accounts/:account_id'),
component: AppContainer, component: AppContainer,
children: [ children: [
...inboxRoutes,
...conversation.routes, ...conversation.routes,
...settings.routes, ...settings.routes,
...contactRoutes, ...contactRoutes,

View File

@@ -0,0 +1,40 @@
<template>
<div
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full hidden md:flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner my-4" />
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
{{ emptyMessage }}
</span>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
props: {
emptyStateMessage: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
uiFlags: 'notifications/getUIFlags',
}),
emptyMessage() {
if (this.emptyStateMessage) {
return this.emptyStateMessage;
}
return this.$t('INBOX.LIST.NOTE');
},
},
};
</script>

View File

@@ -1,9 +1,14 @@
<template> <template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<div <div
class="flex flex-col h-full w-full ltr:border-r border-slate-50 dark:border-slate-800/50" class="flex flex-col h-full w-full md:min-w-[360px] md:max-w-[360px] ltr:border-r border-slate-50 dark:border-slate-800/50"
:class="isOnExpandedLayout ? '' : 'min-w-[360px] max-w-[360px]'" :class="!currentNotificationId ? 'flex' : 'hidden md:flex'"
> >
<inbox-list-header @filter="onFilterChange" /> <inbox-list-header
:is-context-menu-open="isInboxContextMenuOpen"
@filter="onFilterChange"
@redirect="redirectToInbox"
/>
<div <div
ref="notificationList" ref="notificationList"
class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto" class="flex flex-col w-full h-[calc(100%-56px)] overflow-x-hidden overflow-y-auto"
@@ -11,10 +16,13 @@
<inbox-card <inbox-card
v-for="notificationItem in notifications" v-for="notificationItem in notifications"
:key="notificationItem.id" :key="notificationItem.id"
:active="currentNotificationId === notificationItem.id"
:notification-item="notificationItem" :notification-item="notificationItem"
@mark-notification-as-read="markNotificationAsRead" @mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-unread="markNotificationAsUnRead" @mark-notification-as-unread="markNotificationAsUnRead"
@delete-notification="deleteNotification" @delete-notification="deleteNotification"
@context-menu-open="isInboxContextMenuOpen = true"
@context-menu-close="isInboxContextMenuOpen = false"
/> />
<div v-if="uiFlags.isFetching" class="text-center"> <div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" /> <span class="spinner mt-4 mb-4" />
@@ -25,12 +33,6 @@
> >
{{ $t('INBOX.LIST.NO_NOTIFICATIONS') }} {{ $t('INBOX.LIST.NO_NOTIFICATIONS') }}
</p> </p>
<p
v-if="showEndOfListMessage"
class="text-center text-slate-400 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
<intersection-observer <intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching" v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions" :options="infiniteLoaderOptions"
@@ -38,6 +40,8 @@
/> />
</div> </div>
</div> </div>
<router-view />
</section>
</template> </template>
<script> <script>
@@ -49,6 +53,7 @@ import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue'; import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
export default { export default {
components: { components: {
InboxCard, InboxCard,
@@ -56,16 +61,6 @@ export default {
IntersectionObserver, IntersectionObserver,
}, },
mixins: [alertMixin, uiSettingsMixin], mixins: [alertMixin, uiSettingsMixin],
props: {
conversationId: {
type: [String, Number],
default: 0,
},
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
data() { data() {
return { return {
infiniteLoaderOptions: { infiniteLoaderOptions: {
@@ -76,6 +71,8 @@ export default {
status: '', status: '',
type: '', type: '',
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST, sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
isInboxContextMenuOpen: false,
notificationIdToSnooze: null,
}; };
}, },
computed: { computed: {
@@ -85,6 +82,9 @@ export default {
uiFlags: 'notifications/getUIFlags', uiFlags: 'notifications/getUIFlags',
notification: 'notifications/getFilteredNotifications', notification: 'notifications/getFilteredNotifications',
}), }),
currentNotificationId() {
return Number(this.$route.params.notification_id);
},
inboxFilters() { inboxFilters() {
return { return {
page: this.page, page: this.page,
@@ -102,9 +102,6 @@ export default {
showEmptyState() { showEmptyState() {
return !this.uiFlags.isFetching && !this.notifications.length; return !this.uiFlags.isFetching && !this.notifications.length;
}, },
showEndOfListMessage() {
return this.showEndOfList && this.notifications.length;
},
}, },
mounted() { mounted() {
this.setSavedFilter(); this.setSavedFilter();
@@ -115,13 +112,11 @@ export default {
this.page = 1; this.page = 1;
this.$store.dispatch('notifications/clear'); this.$store.dispatch('notifications/clear');
const filter = this.inboxFilters; const filter = this.inboxFilters;
this.$store.dispatch('notifications/index', filter); this.$store.dispatch('notifications/index', filter);
}, },
redirectToInbox() { redirectToInbox() {
if (!this.conversationId) return;
if (this.$route.name === 'inbox_view') return; if (this.$route.name === 'inbox_view') return;
this.$router.push({ name: 'inbox_view' }); this.$router.replace({ name: 'inbox_view' });
}, },
loadMoreNotifications() { loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return; if (this.uiFlags.isAllNotificationsLoaded) return;
@@ -177,13 +172,14 @@ export default {
}); });
}, },
onFilterChange(option) { onFilterChange(option) {
if (option.type === wootConstants.INBOX_FILTER_TYPE.STATUS) { const { STATUS, TYPE, SORT_ORDER } = wootConstants.INBOX_FILTER_TYPE;
if (option.type === STATUS) {
this.status = option.selected ? option.key : ''; this.status = option.selected ? option.key : '';
} }
if (option.type === wootConstants.INBOX_FILTER_TYPE.TYPE) { if (option.type === TYPE) {
this.type = option.selected ? option.key : ''; this.type = option.selected ? option.key : '';
} }
if (option.type === wootConstants.INBOX_FILTER_TYPE.SORT_ORDER) { if (option.type === SORT_ORDER) {
this.sortOrder = option.key; this.sortOrder = option.key;
} }
this.fetchNotifications(); this.fetchNotifications();

View File

@@ -1,127 +1,102 @@
<template> <template>
<section class="flex w-full h-full bg-white dark:bg-slate-900"> <div class="h-full w-full md:w-[calc(100%-360px)]">
<inbox-list <div v-if="showEmptyState" class="flex w-full h-full">
v-show="showConversationList" <inbox-empty-state
:conversation-id="conversationId" :empty-state-message="$t('INBOX.LIST.NO_MESSAGES_AVAILABLE')"
:is-on-expanded-layout="isOnExpandedLayout"
/> />
<div </div>
v-if="showInboxMessageView" <div v-else class="flex flex-col h-full w-full">
class="flex flex-col h-full"
:class="isOnExpandedLayout ? 'w-full' : 'w-[calc(100%-360px)]'"
>
<inbox-item-header <inbox-item-header
:total-length="totalNotifications" class="flex-1"
:total-length="totalNotificationCount"
:current-index="activeNotificationIndex" :current-index="activeNotificationIndex"
:active-notification="activeNotification" :active-notification="activeNotification"
@next="onClickNext" @next="onClickNext"
@prev="onClickPrev" @prev="onClickPrev"
/> />
<div
v-if="isConversationLoading"
class="flex items-center h-[calc(100%-56px)] justify-center bg-slate-25 dark:bg-slate-800"
>
<span class="spinner my-4" />
</div>
<conversation-box <conversation-box
v-else
class="h-[calc(100%-56px)]" class="h-[calc(100%-56px)]"
is-inbox-view is-inbox-view
:inbox-id="inboxId" :inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen" :is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout" :is-on-expanded-layout="false"
@contact-panel-toggle="onToggleContactPanel" @contact-panel-toggle="onToggleContactPanel"
/> />
</div> </div>
<div
v-if="!showInboxMessageView && !isOnExpandedLayout"
class="text-center bg-slate-25 dark:bg-slate-800 justify-center w-full h-full flex items-center"
>
<span v-if="uiFlags.isFetching" class="spinner mt-4 mb-4" />
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon
icon="mail-inbox"
size="40"
class="text-slate-600 dark:text-slate-400"
/>
<span class="text-slate-500 text-sm font-medium dark:text-slate-300">
{{ $t('INBOX.LIST.NOTE') }}
</span>
</div> </div>
</div>
</section>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import InboxList from './InboxList.vue';
import InboxItemHeader from './components/InboxItemHeader.vue'; import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue'; import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default { export default {
components: { components: {
InboxList,
InboxItemHeader, InboxItemHeader,
InboxEmptyState,
ConversationBox, ConversationBox,
}, },
mixins: [uiSettingsMixin], mixins: [uiSettingsMixin],
props: { data() {
inboxId: { return {
type: [String, Number], isConversationLoading: false,
default: 0, };
},
conversationId: {
type: [String, Number],
default: 0,
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
currentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
notifications: 'notifications/getNotifications', notification: 'notifications/getFilteredNotifications',
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
allConversation: 'getAllConversations', activeNotificationById: 'notifications/getNotificationById',
conversationById: 'getConversationById',
uiFlags: 'notifications/getUIFlags', uiFlags: 'notifications/getUIFlags',
meta: 'notifications/getMeta',
}), }),
notifications() {
return this.notification({
sortOrder: this.activeSortOrder,
});
},
inboxId() {
return Number(this.$route.params.inboxId);
},
notificationId() {
return Number(this.$route.params.notification_id);
},
activeNotification() { activeNotification() {
return this.notifications.find( return this.activeNotificationById(this.notificationId);
n => n.primary_actor.id === Number(this.conversationId)
);
}, },
isInboxViewEnabled() { conversationId() {
return this.$store.getters['accounts/isFeatureEnabledGlobally']( return this.activeNotification?.primary_actor?.id;
this.currentAccountId,
FEATURE_FLAGS.INBOX_VIEW
);
}, },
showConversationList() { totalNotificationCount() {
return this.isOnExpandedLayout ? !this.conversationId : true; return this.meta.count;
}, },
isFetchingInitialData() { showEmptyState() {
return this.uiFlags.isFetching && !this.notifications.length;
},
showInboxMessageView() {
return ( return (
Boolean(this.conversationId) && !this.conversationId ||
Boolean(this.currentChat.id) && (!this.notifications?.length && this.uiFlags.isFetching)
!this.isFetchingInitialData
); );
}, },
totalNotifications() {
return this.notifications?.length ?? 0;
},
activeNotificationIndex() { activeNotificationIndex() {
const conversationId = Number(this.conversationId); return this.notifications?.findIndex(n => n.id === this.notificationId);
const notificationIndex = this.notifications.findIndex(
n => n.primary_actor.id === conversationId
);
return notificationIndex >= 0 ? notificationIndex + 1 : 0;
}, },
isOnExpandedLayout() { activeSortOrder() {
const { const { inbox_filter_by: filterBy = {} } = this.uiSettings;
LAYOUT_TYPES: { CONDENSED }, const { sort_by: sortBy } = filterBy;
} = wootConstants; return sortBy || 'desc';
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
}, },
isContactPanelOpen() { isContactPanelOpen() {
if (this.currentChat.id) { if (this.currentChat.id) {
@@ -135,29 +110,29 @@ export default {
watch: { watch: {
conversationId: { conversationId: {
immediate: true, immediate: true,
handler() { handler(newVal, oldVal) {
if (newVal !== oldVal) {
this.fetchConversationById(); this.fetchConversationById();
}
}, },
}, },
}, },
mounted() { mounted() {
// Open inbox view if inbox view feature is enabled, else redirect to dashboard
// TODO: Remove this code once inbox view feature is enabled for all accounts
if (!this.isInboxViewEnabled) {
this.$router.push({
name: 'home',
});
}
this.$store.dispatch('agents/get'); this.$store.dispatch('agents/get');
}, },
methods: { methods: {
async fetchConversationById() { async fetchConversationById() {
if (!this.conversationId) return; if (!this.notificationId || !this.conversationId) return;
const chat = this.findConversation(); this.$store.dispatch('clearSelectedState');
if (!chat) { const existingChat = this.findConversation();
await this.$store.dispatch('getConversation', this.conversationId); if (existingChat) {
this.setActiveChat(existingChat);
return;
} }
this.isConversationLoading = true;
await this.$store.dispatch('getConversation', this.conversationId);
this.setActiveChat(); this.setActiveChat();
this.isConversationLoading = false;
}, },
setActiveChat() { setActiveChat() {
const selectedConversation = this.findConversation(); const selectedConversation = this.findConversation();
@@ -169,20 +144,31 @@ export default {
}); });
}, },
findConversation() { findConversation() {
const conversationId = Number(this.conversationId); return this.conversationById(this.conversationId);
return this.allConversation.find(c => c.id === conversationId);
}, },
navigateToConversation(activeIndex, direction) { navigateToConversation(activeIndex, direction) {
const indexOffset = direction === 'next' ? 0 : -2; let updatedIndex;
const targetNotification = this.notifications[activeIndex + indexOffset]; if (direction === 'prev' && activeIndex) {
updatedIndex = activeIndex - 1;
} else if (
direction === 'next' &&
activeIndex < this.totalNotificationCount
) {
updatedIndex = activeIndex + 1;
}
const targetNotification = this.notifications[updatedIndex];
if (targetNotification) { if (targetNotification) {
this.openNotification(targetNotification);
}
},
openNotification(notification) {
const { const {
id, id,
primary_actor_id: primaryActorId, primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType, primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, meta: { unreadCount } = {} }, primary_actor: { meta: { unreadCount } = {} },
notification_type: notificationType, notification_type: notificationType,
} = targetNotification; } = notification;
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, { this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType, notificationType,
@@ -197,9 +183,8 @@ export default {
this.$router.push({ this.$router.push({
name: 'inbox_view_conversation', name: 'inbox_view_conversation',
params: { conversation_id: conversationId }, params: { notification_id: id },
}); });
}
}, },
onClickNext() { onClickNext() {
this.navigateToConversation(this.activeNotificationIndex, 'next'); this.navigateToConversation(this.activeNotificationIndex, 'next');

View File

@@ -3,7 +3,7 @@
role="button" role="button"
class="flex flex-col ltr:pl-5 rtl:pl-3 rtl:pr-5 ltr:pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer" class="flex flex-col ltr:pl-5 rtl:pl-3 rtl:pr-5 ltr:pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
:class=" :class="
isInboxCardActive active
? 'bg-slate-25 dark:bg-slate-800 click-animation' ? 'bg-slate-25 dark:bg-slate-800 click-animation'
: 'bg-white dark:bg-slate-900' : 'bg-white dark:bg-slate-900'
" "
@@ -23,26 +23,21 @@
</div> </div>
</div> </div>
<div class="flex flex-row justify-between items-center w-full"> <div class="flex flex-row justify-between items-center w-full gap-2">
<div class="flex gap-1.5 items-center max-w-[calc(100%-70px)]">
<Thumbnail <Thumbnail
v-if="assigneeMeta" v-if="assigneeMeta"
:src="assigneeMeta.thumbnail" :src="assigneeMeta.thumbnail"
:username="assigneeMeta.name" :username="assigneeMeta.name"
size="16px" size="16px"
class="relative bottom-0.5"
/> />
<div class="flex min-w-0">
<span <span
class="text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap" class="flex-1 text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
:class="isUnread ? 'font-medium' : 'font-normal'" :class="isUnread ? 'font-medium' : 'font-normal'"
> >
{{ pushTitle }} {{ pushTitle }}
</span> </span>
</div>
</div>
<span <span
class="font-medium max-w-[60px] text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap" class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
> >
{{ lastActivityAt }} {{ lastActivityAt }}
</span> </span>
@@ -61,6 +56,7 @@
/> />
</div> </div>
</template> </template>
<script> <script>
import PriorityIcon from './PriorityIcon.vue'; import PriorityIcon from './PriorityIcon.vue';
import StatusIcon from './StatusIcon.vue'; import StatusIcon from './StatusIcon.vue';
@@ -84,6 +80,10 @@ export default {
type: Object, type: Object,
default: () => {}, default: () => {},
}, },
active: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@@ -95,9 +95,6 @@ export default {
primaryActor() { primaryActor() {
return this.notificationItem?.primary_actor; return this.notificationItem?.primary_actor;
}, },
isInboxCardActive() {
return this.$route.params.conversation_id === this.primaryActor?.id;
},
inbox() { inbox() {
return this.$store.getters['inboxes/getInbox']( return this.$store.getters['inboxes/getInbox'](
this.primaryActor.inbox_id this.primaryActor.inbox_id
@@ -166,11 +163,11 @@ export default {
id, id,
primary_actor_id: primaryActorId, primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType, primary_actor_type: primaryActorType,
primary_actor: { id: conversationId, inbox_id: inboxId }, primary_actor: { inbox_id: inboxId },
notification_type: notificationType, notification_type: notificationType,
} = notification; } = notification;
if (this.$route.params.conversation_id !== conversationId) { if (this.$route.params.notification_id !== id) {
this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, { this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType, notificationType,
}); });
@@ -184,13 +181,14 @@ export default {
this.$router.push({ this.$router.push({
name: 'inbox_view_conversation', name: 'inbox_view_conversation',
params: { inboxId, conversation_id: conversationId }, params: { inboxId, notification_id: id },
}); });
} }
}, },
closeContextMenu() { closeContextMenu() {
this.isContextMenuOpen = false; this.isContextMenuOpen = false;
this.contextMenuPosition = { x: null, y: null }; this.contextMenuPosition = { x: null, y: null };
this.$emit('context-menu-close');
}, },
openContextMenu(e) { openContextMenu(e) {
this.closeContextMenu(); this.closeContextMenu();
@@ -200,6 +198,7 @@ export default {
y: e.pageY || e.clientY, y: e.pageY || e.clientY,
}; };
this.isContextMenuOpen = true; this.isContextMenuOpen = true;
this.$emit('context-menu-open');
}, },
handleAction(key) { handleAction(key) {
switch (key) { switch (key) {
@@ -218,17 +217,21 @@ export default {
}, },
}; };
</script> </script>
<style scoped> <style scoped>
.click-animation { .click-animation {
animation: click-animation 0.3s ease-in-out; animation: click-animation 0.3s ease-in-out;
} }
@keyframes click-animation { @keyframes click-animation {
0% { 0% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
transform: scale(0.99); transform: scale(0.99);
} }
100% { 100% {
transform: scale(1); transform: scale(1);
} }

View File

@@ -2,10 +2,20 @@
<div <div
class="flex gap-2 py-2 ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50" class="flex gap-2 py-2 ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
> >
<woot-button
variant="clear link"
class="flex md:hidden !pt-1 !pb-1 rounded-md ltr:pr-1 rtl:pl-1 !no-underline"
size="medium"
color-scheme="primary"
icon="chevron-left"
@click="onClickGoToInboxList"
>
{{ $t('INBOX.ACTION_HEADER.BACK') }}
</woot-button>
<pagination-button <pagination-button
v-if="totalLength > 1" v-if="totalLength > 1"
:total-length="totalLength" :total-length="totalLength"
:current-index="currentIndex" :current-index="currentIndex + 1"
@next="onClickNext" @next="onClickNext"
@prev="onClickPrev" @prev="onClickPrev"
/> />
@@ -16,6 +26,7 @@
size="small" size="small"
color-scheme="secondary" color-scheme="secondary"
icon="snooze" icon="snooze"
class="[&>span]:hidden md:[&>span]:inline-flex"
@click="openSnoozeNotificationModal" @click="openSnoozeNotificationModal"
> >
{{ $t('INBOX.ACTION_HEADER.SNOOZE') }} {{ $t('INBOX.ACTION_HEADER.SNOOZE') }}
@@ -25,6 +36,7 @@
size="small" size="small"
color-scheme="secondary" color-scheme="secondary"
variant="hollow" variant="hollow"
class="[&>span]:hidden md:[&>span]:inline-flex"
@click="deleteNotification" @click="deleteNotification"
> >
{{ $t('INBOX.ACTION_HEADER.DELETE') }} {{ $t('INBOX.ACTION_HEADER.DELETE') }}
@@ -41,6 +53,7 @@
</woot-modal> </woot-modal>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { getUnixTime } from 'date-fns'; import { getUnixTime } from 'date-fns';
@@ -73,14 +86,10 @@ export default {
}, },
}, },
data() { data() {
return { return { showCustomSnoozeModal: false };
showCustomSnoozeModal: false,
};
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({ meta: 'notifications/getMeta' }),
meta: 'notifications/getMeta',
}),
}, },
mounted() { mounted() {
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification); bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
@@ -96,15 +105,17 @@ export default {
hideCustomSnoozeModal() { hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false; this.showCustomSnoozeModal = false;
}, },
snoozeNotification(snoozedUntil) { async snoozeNotification(snoozedUntil) {
this.$store try {
.dispatch('notifications/snooze', { await this.$store.dispatch('notifications/snooze', {
id: this.activeNotification?.id, id: this.activeNotification?.id,
snoozedUntil, snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
}); });
this.showAlert(this.$t('INBOX.ALERTS.SNOOZE'));
} catch (error) {
// Silently fail without any change in the UI
}
}, },
onCmdSnoozeNotification(snoozeType) { onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) { if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
@@ -132,7 +143,7 @@ export default {
.then(() => { .then(() => {
this.showAlert(this.$t('INBOX.ALERTS.DELETE')); this.showAlert(this.$t('INBOX.ALERTS.DELETE'));
}); });
this.$router.push({ name: 'inbox_view' }); this.$router.replace({ name: 'inbox_view' });
}, },
onClickNext() { onClickNext() {
this.$emit('next'); this.$emit('next');
@@ -140,6 +151,9 @@ export default {
onClickPrev() { onClickPrev() {
this.$emit('prev'); this.$emit('prev');
}, },
onClickGoToInboxList() {
this.$router.replace({ name: 'inbox_view' });
},
}, },
}; };
</script> </script>

View File

@@ -26,7 +26,7 @@
<inbox-display-menu <inbox-display-menu
v-if="showInboxDisplayMenu" v-if="showInboxDisplayMenu"
v-on-clickaway="openInboxDisplayMenu" v-on-clickaway="openInboxDisplayMenu"
class="absolute top-8" class="absolute top-9 ltr:left-0 rtl:right-0"
@filter="onFilterChange" @filter="onFilterChange"
/> />
</div> </div>
@@ -49,7 +49,7 @@
<inbox-option-menu <inbox-option-menu
v-if="showInboxOptionMenu" v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu" v-on-clickaway="openInboxOptionsMenu"
class="absolute top-9" class="absolute top-9 ltr:right-0 ltr:md:right-[unset] rtl:left-0 rtl:md:left-[unset]"
@option-click="onInboxOptionMenuClick" @option-click="onInboxOptionMenuClick"
/> />
</div> </div>
@@ -69,12 +69,29 @@ export default {
InboxDisplayMenu, InboxDisplayMenu,
}, },
mixins: [clickaway, alertMixin], mixins: [clickaway, alertMixin],
props: {
isContextMenuOpen: {
type: Boolean,
default: false,
},
},
data() { data() {
return { return {
showInboxDisplayMenu: false, showInboxDisplayMenu: false,
showInboxOptionMenu: false, showInboxOptionMenu: false,
}; };
}, },
watch: {
isContextMenuOpen: {
handler(val) {
if (val) {
this.showInboxDisplayMenu = false;
this.showInboxOptionMenu = false;
}
},
immediate: true,
},
},
methods: { methods: {
markAllRead() { markAllRead() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ); this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
@@ -99,22 +116,19 @@ export default {
this.showInboxOptionMenu = !this.showInboxOptionMenu; this.showInboxOptionMenu = !this.showInboxOptionMenu;
}, },
onInboxOptionMenuClick(key) { onInboxOptionMenuClick(key) {
this.showInboxOptionMenu = false; const actions = {
if (key === 'mark_all_read') { mark_all_read: () => this.markAllRead(),
this.markAllRead(); delete_all: () => this.deleteAll(),
} delete_all_read: () => this.deleteAllRead(),
if (key === 'delete_all') { };
this.deleteAll(); const action = actions[key];
} if (action) action();
if (key === 'delete_all_read') { this.$emit('redirect');
this.deleteAllRead();
}
}, },
onFilterChange(option) { onFilterChange(option) {
this.$emit('filter', option); this.$emit('filter', option);
this.showInboxDisplayMenu = false; this.showInboxDisplayMenu = false;
if (this.$route.name === 'inbox_view') return; this.$emit('redirect');
this.$router.push({ name: 'inbox_view' });
}, },
}, },
}; };

View File

@@ -19,7 +19,7 @@
/> />
</div> </div>
<div class="flex items-center gap-1 whitespace-nowrap"> <div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-sm font-medium text-gray-600"> <span class="text-sm font-medium text-gray-600 tabular-nums">
{{ totalLength <= 1 ? '1' : currentIndex }} {{ totalLength <= 1 ? '1' : currentIndex }}
</span> </span>
<span <span
@@ -28,7 +28,7 @@
> >
/ /
</span> </span>
<span v-if="totalLength > 1" class="text-sm text-slate-400"> <span v-if="totalLength > 1" class="text-sm text-slate-400 tabular-nums">
{{ totalLength }} {{ totalLength }}
</span> </span>
</div> </div>

View File

@@ -0,0 +1,25 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
const InboxListView = () => import('./InboxList.vue');
const InboxDetailView = () => import('./InboxView.vue');
const InboxEmptyStateView = () => import('./InboxEmptyState.vue');
export const routes = [
{
path: frontendURL('accounts/:accountId/inbox-view'),
component: InboxListView,
children: [
{
path: '',
name: 'inbox_view',
component: InboxEmptyStateView,
roles: ['administrator', 'agent'],
},
{
path: ':notification_id',
name: 'inbox_view_conversation',
component: InboxDetailView,
roles: ['administrator', 'agent'],
},
],
},
];

View File

@@ -11,6 +11,9 @@ export const getters = {
); );
return sortedNotifications; return sortedNotifications;
}, },
getNotificationById: $state => id => {
return $state.records[id] || {};
},
getUIFlags($state) { getUIFlags($state) {
return $state.uiFlags; return $state.uiFlags;
}, },

View File

@@ -44,6 +44,16 @@ describe('#getters', () => {
]); ]);
}); });
it('getNotificationById', () => {
const state = {
records: {
1: { id: 1 },
},
};
expect(getters.getNotificationById(state)(1)).toEqual({ id: 1 });
expect(getters.getNotificationById(state)(2)).toEqual({});
});
it('getUIFlags', () => { it('getUIFlags', () => {
const state = { const state = {
uiFlags: { uiFlags: {