feat: Inbox list API integration (#8825)

* feat: Inbox view

* feat: Bind real values

* chore: code cleanup

* feat: add observer

* fix: Inbox icon

* chore: more code cleanup

* chore: Replace conversation id

* chore: Minor fix

* chore: Hide from side bar

* chore: Fix eslint

* chore: Minor fix

* fix: dark mode color

* chore: Minor fix

* feat: Add description for each notification types

* chore: remove commented code

* Update InboxList.vue

* Update InboxView.vue

* chore: fix specs

* fix: specs

* Update InboxView.vue

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2024-02-01 12:10:58 +05:30
committed by GitHub
co-authored by iamsivin Sivin Varghese
parent b9c62b3fed
commit b7a7e5a0d3
27 changed files with 541 additions and 210 deletions
@@ -1,9 +1,17 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
const InboxView = () => import('../inbox/InboxView.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/inbox'),
name: 'inbox',
roles: ['administrator', 'agent'],
component: InboxView,
props: () => {},
},
{
path: frontendURL('accounts/:accountId/dashboard'),
name: 'home',
@@ -5,7 +5,6 @@ import { routes as contactRoutes } from './contacts/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import { routes as inboxRoutes } from './inbox/routes';
const AppContainer = () => import('./Dashboard.vue');
const Suspended = () => import('./suspended/Index.vue');
@@ -22,7 +21,6 @@ export default {
...contactRoutes,
...searchRoutes,
...notificationRoutes,
...inboxRoutes,
],
},
{
@@ -1,70 +0,0 @@
<script setup>
// import { defineProps } from 'vue';
import PriorityIcon from './components/PriorityIcon.vue';
import StatusIcon from './components/StatusIcon.vue';
import InboxNameAndId from './components/InboxNameAndId.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
// const props = defineProps({
// notificationItem: {
// type: Object,
// default: () => {},
// },
// });
const assigneeMeta = {
thumbnail: '',
name: 'Michael Johnson',
};
const { thumbnail, name } = assigneeMeta || {};
const status = 'open';
const priority = 'high';
const inboxTypeMessage = 'Mentioned by Michael';
const inboxMessage = 'What is the best way to get started?';
const inbox = {
inbox_id: 16787,
inbox_name: 'Chatwoot Support',
};
</script>
<template>
<div
class="flex max-w-[360px] flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-500 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
>
<div class="flex relative items-center justify-between w-full">
<div
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" />
<div class="flex gap-2">
<PriorityIcon :priority="priority" />
<StatusIcon :status="status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[80%]">
<Thumbnail
v-if="assigneeMeta"
:src="thumbnail"
:username="name"
size="20px"
/>
<div class="flex min-w-0">
<span
class="font-medium text-slate-800 dark:text-slate-100 text-xs overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ inboxTypeMessage }}<span v-if="inboxTypeMessage">:</span>
<span class="font-normal">{{ inboxMessage }}</span>
</span>
</div>
</div>
<span
class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
10h ago
</span>
</div>
</div>
</template>
@@ -0,0 +1,103 @@
<script>
import { mapGetters } from 'vuex';
import InboxCard from './components/InboxCard.vue';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
export default {
components: {
InboxCard,
IntersectionObserver,
},
data() {
return {
infiniteLoaderOptions: {
root: this.$refs.notificationList,
rootMargin: '100px 0px 100px 0px',
},
page: 1,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
}),
showEndOfList() {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
},
mounted() {
this.$store.dispatch('notifications/clear');
this.$store.dispatch('notifications/index', { page: 1 });
},
methods: {
openConversation(notification) {
const {
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId },
notification_type: notificationType,
} = notification;
this.$track(ACCOUNT_EVENTS.OPEN_CONVERSATION_VIA_NOTIFICATION, {
notificationType,
});
this.$store.dispatch('notifications/read', {
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push(
`/app/accounts/${this.accountId}/conversations/${conversationId}`
);
},
onMarkAllDoneClick() {
this.$track(ACCOUNT_EVENTS.MARK_AS_READ_NOTIFICATIONS);
this.$store.dispatch('notifications/readAll');
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', { page: this.page + 1 });
this.page += 1;
},
},
};
</script>
<template>
<div
class="flex flex-col min-w-[360px] w-full max-w-[360px] h-full ltr:border-r border-slate-50 dark:border-slate-800/50"
>
<div
class="flex text-xl w-full pl-5 pr-3 py-2 h-14 items-center font-medium text-slate-900 dark:text-slate-25 border-b border-slate-50 dark:border-slate-800/50"
>
{{ $t('INBOX.LIST.TITLE') }}
</div>
<div
ref="notificationList"
class="flex flex-col w-full h-full overflow-x-hidden overflow-y-auto"
>
<inbox-card
v-for="notificationItem in records"
:key="notificationItem.id"
:notification-item="notificationItem"
/>
<div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<p
v-if="showEndOfList"
class="text-center text-slate-300 dark:text-slate-400 p-4"
>
{{ $t('INBOX.LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<script>
import { mapGetters } from 'vuex';
import InboxList from './InboxList.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
components: {
InboxList,
InboxItemHeader,
ConversationBox,
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
notifications: 'notifications/getNotifications',
}),
isInboxViewEnabled() {
return this.$store.getters['accounts/isFeatureEnabledGlobally'](
this.currentAccountId,
FEATURE_FLAGS.INBOX_VIEW
);
},
},
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',
});
}
},
};
</script>
<template>
<section class="flex w-full h-full bg-white dark:bg-slate-900">
<InboxList />
<div class="flex flex-col w-full h-full">
<InboxItemHeader :total-length="28" :current-index="1" />
<ConversationBox class="h-full" />
</div>
</section>
</template>
@@ -0,0 +1,102 @@
<template>
<div
role="button"
class="flex flex-col pl-5 pr-3 gap-2.5 py-3 w-full bg-white dark:bg-slate-900 border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
@click="openConversation(notificationItem)"
>
<div class="flex relative items-center justify-between w-full">
<div
v-if="isUnread"
class="absolute -left-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" :conversation-id="primaryActor.id" />
<div class="flex gap-2">
<PriorityIcon :priority="primaryActor.priority" />
<StatusIcon :status="primaryActor.status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full">
<div class="flex gap-1.5 items-center max-w-[calc(100%-70px)]">
<Thumbnail
v-if="assigneeMeta"
:src="assigneeMeta.thumbnail"
:username="assigneeMeta.name"
size="20px"
/>
<div class="flex min-w-0">
<span
class="font-medium text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
>
<span class="font-normal text-sm">
{{ pushTitle }}
</span>
</span>
</div>
</div>
<span
class="font-medium max-w-[60px] text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
{{ lastActivityAt }}
</span>
</div>
</div>
</template>
<script>
import PriorityIcon from './PriorityIcon.vue';
import StatusIcon from './StatusIcon.vue';
import InboxNameAndId from './InboxNameAndId.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
export default {
components: {
PriorityIcon,
StatusIcon,
InboxNameAndId,
Thumbnail,
},
mixins: [timeMixin],
props: {
notificationItem: {
type: Object,
default: () => {},
},
},
computed: {
primaryActor() {
return this.notificationItem?.primary_actor;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](
this.primaryActor.inbox_id
);
},
isUnread() {
return !this.notificationItem?.read_at;
},
meta() {
return this.primaryActor?.meta;
},
assigneeMeta() {
return this.meta?.assignee;
},
pushTitle() {
return this.$t(
`INBOX.TYPES.${this.notificationItem.notification_type.toUpperCase()}`
);
},
lastActivityAt() {
const dynamicTime = this.dynamicTime(
this.notificationItem?.last_activity_at
);
return this.shortTimestamp(dynamicTime, true);
},
},
methods: {
openConversation(notification) {
this.$emit('open-conversation', notification);
},
},
};
</script>
@@ -1,33 +1,34 @@
<script setup>
<script>
import PaginationButton from './PaginationButton.vue';
const props = defineProps({
totalLength: {
type: Number,
default: 0,
export default {
components: {
PaginationButton,
},
currentIndex: {
type: Number,
default: 0,
props: {
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
},
methods: {
onSnooze() {},
onDelete() {},
},
});
const onSnooze = () => {
// TODO: Implement snooze
};
const onDelete = () => {
// TODO: Implement delete
};
</script>
<template>
<div
class="flex gap-2 py-2 pl-4 pr-2 justify-between items-center w-full border-b border-slate-200 dark:border-slate-500"
class="flex gap-2 py-2 pl-4 h-14 pr-2 justify-between items-center w-full border-b border-slate-50 dark:border-slate-800/50"
>
<pagination-button
:total-length="props.totalLength"
:current-index="props.currentIndex"
:total-length="totalLength"
:current-index="currentIndex"
/>
<div class="flex items-center gap-2">
<woot-button
@@ -1,33 +1,43 @@
<script setup>
import { defineProps } from 'vue';
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
const props = defineProps({
inbox: {
type: Object,
default: () => {},
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
conversationId: {
type: Number,
default: 0,
},
},
});
const { inbox } = props;
const { inbox_id: inboxId, inbox_name: inboxName } = inbox;
computed: {
inboxIcon() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
</script>
<template>
<div
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-600 divide-x divide-slate-100 dark:divide-slate-600 bg-none"
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-700/50 divide-x divide-slate-100 dark:divide-slate-700/50 bg-none"
>
<div class="flex items-center gap-0.5 py-0.5 px-1.5">
<div v-if="inbox" class="flex items-center gap-0.5 py-0.5 px-1.5">
<fluent-icon
class="text-slate-600 dark:text-slate-300"
icon="globe-desktop"
:icon="inboxIcon"
size="14"
/>
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
{{ inboxName }}
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ inbox.name }}
</span>
</div>
<div class="flex items-center py-0.5 px-1.5">
<span class="font-medium text-slate-600 dark:text-slate-300 text-xs">
{{ inboxId }}
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ conversationId }}
</span>
</div>
</div>
@@ -1,5 +0,0 @@
<template>
<h1>Inbox View</h1>
</template>
<script setup></script>
@@ -1,20 +1,25 @@
<script setup>
<script>
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import { defineProps } from 'vue';
const props = defineProps({
priority: {
type: String,
default: '',
export default {
props: {
priority: {
type: String,
default: '',
},
},
});
data() {
return {
CONVERSATION_PRIORITY,
};
},
};
</script>
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- High Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.HIGH"
v-if="priority === CONVERSATION_PRIORITY.HIGH"
class="h-4 w-4"
width="24"
height="24"
@@ -29,7 +34,7 @@ const props = defineProps({
<!-- Low Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.LOW"
v-if="priority === CONVERSATION_PRIORITY.LOW"
class="h-4 w-4"
width="24"
height="24"
@@ -44,7 +49,7 @@ const props = defineProps({
<!-- Medium Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.MEDIUM"
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
class="h-4 w-4"
width="24"
height="24"
@@ -59,7 +64,7 @@ const props = defineProps({
<!-- Urgent Priority -->
<svg
v-if="props.priority === CONVERSATION_PRIORITY.URGENT"
v-if="priority === CONVERSATION_PRIORITY.URGENT"
class="h-4 w-4"
width="24"
height="24"
@@ -1,20 +1,8 @@
<script setup>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import { defineProps } from 'vue';
const props = defineProps({
status: {
type: String,
default: '',
},
});
</script>
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Pending -->
<svg
v-if="props.status === CONVERSATION_STATUS.PENDING"
v-if="status === CONVERSATION_STATUS.PENDING"
class="h-3.5 w-3.5"
width="18"
height="18"
@@ -29,7 +17,7 @@ const props = defineProps({
</svg>
<!-- Open -->
<svg
v-if="props.status === CONVERSATION_STATUS.OPEN"
v-if="status === CONVERSATION_STATUS.OPEN"
class="h-3.5 w-3.5"
width="19"
height="19"
@@ -45,7 +33,7 @@ const props = defineProps({
<!-- Snoozed -->
<svg
v-if="props.status === CONVERSATION_STATUS.SNOOZED"
v-if="status === CONVERSATION_STATUS.SNOOZED"
class="h-3.5 w-3.5"
width="18"
height="18"
@@ -61,7 +49,7 @@ const props = defineProps({
<!-- Resolved -->
<svg
v-if="props.status === CONVERSATION_STATUS.RESOLVED"
v-if="status === CONVERSATION_STATUS.RESOLVED"
class="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -78,3 +66,21 @@ const props = defineProps({
</svg>
</div>
</template>
<script>
import { CONVERSATION_STATUS } from 'shared/constants/messages';
export default {
props: {
status: {
type: String,
default: '',
},
},
data() {
return {
CONVERSATION_STATUS,
};
},
};
</script>
@@ -1,25 +0,0 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
const SettingsWrapper = () => import('../settings/Wrapper.vue');
const InboxView = () => import('./components/InboxView.vue');
export const routes = [
{
path: frontendURL('accounts/:accountId/inbox'),
component: SettingsWrapper,
props: {
headerTitle: 'INBOX_PAGE.HEADER',
icon: 'alert',
showNewButton: false,
showSidemenuIcon: false,
},
children: [
{
path: '',
name: 'inbox_index',
component: InboxView,
roles: ['administrator', 'agent'],
},
],
},
];
-11
View File
@@ -5,7 +5,6 @@ import dashboard from './dashboard/dashboard.routes';
import store from '../store';
import { validateLoggedInRoutes } from '../helper/routeHelpers';
import AnalyticsHelper from '../helper/AnalyticsHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const routes = [...dashboard.routes];
@@ -42,16 +41,6 @@ export const validateAuthenticateRoutePermission = (to, next, { getters }) => {
return '/app/login';
}
// 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
const isInboxViewEnabled = store.getters['accounts/isFeatureEnabledGlobally'](
user.account_id,
FEATURE_FLAGS.INBOX_VIEW
);
if (to.name === 'inbox_index' && !isInboxViewEnabled) {
return next(frontendURL(`accounts/${user.account_id}/dashboard`));
}
if (!to.name) {
return next(frontendURL(`accounts/${user.account_id}/dashboard`));
}