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
parent b9c62b3fed
commit b7a7e5a0d3
27 changed files with 541 additions and 210 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 flex-basis-custom overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="{
hide: !showConversationList,
'list--full-width': isOnExpandedLayout,

View File

@@ -90,9 +90,6 @@
"conversation_mention": "Mention"
}
},
"INBOX_PAGE": {
"HEADER": "Inbox"
},
"NETWORK": {
"NOTIFICATION": {
"OFFLINE": "Offline"

View File

@@ -0,0 +1,17 @@
{
"INBOX": {
"LIST": {
"TITLE": "Inbox",
"LOADING": "Fetching notifications",
"EOF": "All notifications loaded 🎉",
"404": "There are no active notifications in this group."
},
"TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation",
"CONVERSATION_CREATION": "New conversation created",
"CONVERSATION_ASSIGNMENT": "A conversation has been assigned to you",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "New message in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in"
}
}
}

View File

@@ -29,6 +29,7 @@ import settings from './settings.json';
import signup from './signup.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import inbox from './inbox.json';
export default {
...advancedFilters,
@@ -62,4 +63,5 @@ export default {
...signup,
...teamsSettings,
...whatsappTemplates,
...inbox,
};

View File

@@ -193,6 +193,7 @@
"CURRENTLY_VIEWING_ACCOUNT": "Currently viewing:",
"SWITCH": "Switch",
"CONVERSATIONS": "Conversations",
"INBOX": "Inbox",
"ALL_CONVERSATIONS": "All Conversations",
"MENTIONED_CONVERSATIONS": "Mentions",
"PARTICIPATING_CONVERSATIONS": "Participating",

View File

@@ -52,19 +52,77 @@ describe('#dateFormat', () => {
});
describe('#shortTimestamp', () => {
it('returns correct value', () => {
// Test cases when withAgo is false or not provided
it('returns correct value without ago', () => {
expect(TimeMixin.methods.shortTimestamp('less than a minute ago')).toEqual(
'now'
);
expect(TimeMixin.methods.shortTimestamp(' minute ago')).toEqual('m');
expect(TimeMixin.methods.shortTimestamp(' minutes ago')).toEqual('m');
expect(TimeMixin.methods.shortTimestamp(' hour ago')).toEqual('h');
expect(TimeMixin.methods.shortTimestamp(' hours ago')).toEqual('h');
expect(TimeMixin.methods.shortTimestamp(' day ago')).toEqual('d');
expect(TimeMixin.methods.shortTimestamp(' days ago')).toEqual('d');
expect(TimeMixin.methods.shortTimestamp(' month ago')).toEqual('mo');
expect(TimeMixin.methods.shortTimestamp(' months ago')).toEqual('mo');
expect(TimeMixin.methods.shortTimestamp(' year ago')).toEqual('y');
expect(TimeMixin.methods.shortTimestamp(' years ago')).toEqual('y');
expect(TimeMixin.methods.shortTimestamp('1 minute ago')).toEqual('1m');
expect(TimeMixin.methods.shortTimestamp('12 minutes ago')).toEqual('12m');
expect(TimeMixin.methods.shortTimestamp('a minute ago')).toEqual('1m');
expect(TimeMixin.methods.shortTimestamp('an hour ago')).toEqual('1h');
expect(TimeMixin.methods.shortTimestamp('1 hour ago')).toEqual('1h');
expect(TimeMixin.methods.shortTimestamp('2 hours ago')).toEqual('2h');
expect(TimeMixin.methods.shortTimestamp('1 day ago')).toEqual('1d');
expect(TimeMixin.methods.shortTimestamp('a day ago')).toEqual('1d');
expect(TimeMixin.methods.shortTimestamp('3 days ago')).toEqual('3d');
expect(TimeMixin.methods.shortTimestamp('a month ago')).toEqual('1mo');
expect(TimeMixin.methods.shortTimestamp('1 month ago')).toEqual('1mo');
expect(TimeMixin.methods.shortTimestamp('2 months ago')).toEqual('2mo');
expect(TimeMixin.methods.shortTimestamp('a year ago')).toEqual('1y');
expect(TimeMixin.methods.shortTimestamp('1 year ago')).toEqual('1y');
expect(TimeMixin.methods.shortTimestamp('4 years ago')).toEqual('4y');
});
// Test cases when withAgo is true
it('returns correct value with ago', () => {
expect(
TimeMixin.methods.shortTimestamp('less than a minute ago', true)
).toEqual('now');
expect(TimeMixin.methods.shortTimestamp('1 minute ago', true)).toEqual(
'1m ago'
);
expect(TimeMixin.methods.shortTimestamp('12 minutes ago', true)).toEqual(
'12m ago'
);
expect(TimeMixin.methods.shortTimestamp('a minute ago', true)).toEqual(
'1m ago'
);
expect(TimeMixin.methods.shortTimestamp('an hour ago', true)).toEqual(
'1h ago'
);
expect(TimeMixin.methods.shortTimestamp('1 hour ago', true)).toEqual(
'1h ago'
);
expect(TimeMixin.methods.shortTimestamp('2 hours ago', true)).toEqual(
'2h ago'
);
expect(TimeMixin.methods.shortTimestamp('1 day ago', true)).toEqual(
'1d ago'
);
expect(TimeMixin.methods.shortTimestamp('a day ago', true)).toEqual(
'1d ago'
);
expect(TimeMixin.methods.shortTimestamp('3 days ago', true)).toEqual(
'3d ago'
);
expect(TimeMixin.methods.shortTimestamp('a month ago', true)).toEqual(
'1mo ago'
);
expect(TimeMixin.methods.shortTimestamp('1 month ago', true)).toEqual(
'1mo ago'
);
expect(TimeMixin.methods.shortTimestamp('2 months ago', true)).toEqual(
'2mo ago'
);
expect(TimeMixin.methods.shortTimestamp('a year ago', true)).toEqual(
'1y ago'
);
expect(TimeMixin.methods.shortTimestamp('1 year ago', true)).toEqual(
'1y ago'
);
expect(TimeMixin.methods.shortTimestamp('4 years ago', true)).toEqual(
'4y ago'
);
});
});

View File

@@ -28,25 +28,36 @@ export default {
const unixTime = fromUnixTime(time);
return format(unixTime, dateFormat);
},
shortTimestamp(time) {
shortTimestamp(time, withAgo = false) {
// This function takes a time string and converts it to a short time string
// with the following format: 1m, 1h, 1d, 1mo, 1y
// The function also takes an optional boolean parameter withAgo
// which will add the word "ago" to the end of the time string
const suffix = withAgo ? ' ago' : '';
const timeMappings = {
'less than a minute ago': 'now',
'a minute ago': `1m${suffix}`,
'an hour ago': `1h${suffix}`,
'a day ago': `1d${suffix}`,
'a month ago': `1mo${suffix}`,
'a year ago': `1y${suffix}`,
};
// Check if the time string is one of the specific cases
if (timeMappings[time]) {
return timeMappings[time];
}
const convertToShortTime = time
.replace(/about|over|almost|/g, '')
.replace('less than a minute ago', 'now')
.replace(' minute ago', 'm')
.replace(' minutes ago', 'm')
.replace('a minute ago', 'm')
.replace('an hour ago', 'h')
.replace(' hour ago', 'h')
.replace(' hours ago', 'h')
.replace(' day ago', 'd')
.replace('a day ago', 'd')
.replace(' days ago', 'd')
.replace('a month ago', 'mo')
.replace(' months ago', 'mo')
.replace(' month ago', 'mo')
.replace('a year ago', 'y')
.replace(' year ago', 'y')
.replace(' years ago', 'y');
.replace(' minute ago', `m${suffix}`)
.replace(' minutes ago', `m${suffix}`)
.replace(' hour ago', `h${suffix}`)
.replace(' hours ago', `h${suffix}`)
.replace(' day ago', `d${suffix}`)
.replace(' days ago', `d${suffix}`)
.replace(' month ago', `mo${suffix}`)
.replace(' months ago', `mo${suffix}`)
.replace(' year ago', `y${suffix}`)
.replace(' years ago', `y${suffix}`);
return convertToShortTime;
},
},

View File

@@ -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',

View File

@@ -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,
],
},
{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -1,5 +0,0 @@
<template>
<h1>Inbox View</h1>
</template>
<script setup></script>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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'],
},
],
},
];

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

View File

@@ -18,6 +18,24 @@ export const actions = {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
index: async ({ commit }, { page = 1 } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true });
try {
const {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get(page);
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
if (payload.length < 15) {
commit(types.SET_ALL_NOTIFICATIONS_LOADED);
}
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
unReadCount: async ({ commit } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true });
try {
@@ -59,4 +77,7 @@ export const actions = {
deleteNotification({ commit }, data) {
commit(types.DELETE_NOTIFICATION, data);
},
clear({ commit }) {
commit(types.CLEAR_NOTIFICATIONS);
},
};

View File

@@ -14,6 +14,7 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isUpdatingUnreadCount: false,
isAllNotificationsLoaded: false,
},
};

View File

@@ -10,6 +10,7 @@ export const mutations = {
},
[types.CLEAR_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {});
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', false);
},
[types.SET_NOTIFICATIONS_META]: ($state, data) => {
const {
@@ -61,4 +62,7 @@ export const mutations = {
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
[types.SET_ALL_NOTIFICATIONS_LOADED]: $state => {
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', true);
},
};

View File

@@ -39,6 +39,38 @@ describe('#actions', () => {
});
});
describe('#index', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: {
data: {
payload: [{ id: 1 }],
meta: { count: 3, current_page: 1, unread_count: 2 },
},
},
});
await actions.index({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.SET_NOTIFICATIONS, [{ id: 1 }]],
[
types.SET_NOTIFICATIONS_META,
{ count: 3, current_page: 1, unread_count: 2 },
],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
[types.SET_ALL_NOTIFICATIONS_LOADED],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.index({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#unReadCount', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: 1 });
@@ -107,4 +139,11 @@ describe('#actions', () => {
]);
});
});
describe('clear', () => {
it('sends correct actions', async () => {
await actions.clear({ commit });
expect(commit.mock.calls).toEqual([[types.CLEAR_NOTIFICATIONS]]);
});
});
});

View File

@@ -12,7 +12,10 @@ describe('#mutations', () => {
describe('#CLEAR_NOTIFICATIONS', () => {
it('clear notifications', () => {
const state = { records: { 1: { id: 1 } } };
const state = {
records: { 1: { id: 1 } },
uiFlags: { isAllNotificationsLoaded: true },
};
mutations[types.CLEAR_NOTIFICATIONS](state);
expect(state.records).toEqual({});
});
@@ -141,4 +144,12 @@ describe('#mutations', () => {
expect(state.meta.count).toEqual(232);
});
});
describe('#SET_ALL_NOTIFICATIONS_LOADED', () => {
it('set all notifications loaded', () => {
const state = { uiFlags: { isAllNotificationsLoaded: false } };
mutations[types.SET_ALL_NOTIFICATIONS_LOADED](state);
expect(state.uiFlags).toEqual({ isAllNotificationsLoaded: true });
});
});
});

View File

@@ -140,6 +140,7 @@ export default {
CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS',
EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS',
UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE',
SET_ALL_NOTIFICATIONS_LOADED: 'SET_ALL_NOTIFICATIONS_LOADED',
// Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',

View File

@@ -125,6 +125,7 @@
"location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z",
"lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z",
"lock-shield-outline": "M10 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 18 10.25V11c-.319 0-.637.11-.896.329l-.107.1c-.164.17-.33.323-.496.457L16.5 10.25a.75.75 0 0 0-.75-.75H4.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h9.888a6.024 6.024 0 0 0 1.54 1.5H4.25A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H6V6a4 4 0 0 1 4-4Zm8.284 10.122c.992 1.036 2.091 1.545 3.316 1.545.193 0 .355.143.392.332l.008.084v2.501c0 2.682-1.313 4.506-3.873 5.395a.385.385 0 0 1-.253 0c-2.476-.86-3.785-2.592-3.87-5.13L14 16.585v-2.5c0-.23.18-.417.4-.417 1.223 0 2.323-.51 3.318-1.545a.389.389 0 0 1 .566 0ZM10 13.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 7.5 6v2h5V6A2.5 2.5 0 0 0 10 3.5Z",
"mail-inbox-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z",
"mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z",
"mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z",
"mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z",