feat: notification center (#1612)

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Muhsin Keloth 2021-01-24 11:29:44 -08:00 committed by GitHub
parent e75916d562
commit c087e75808
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 811 additions and 12 deletions

View file

@ -1,6 +1,7 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
protect_from_forgery with: :null_session RESULTS_PER_PAGE = 15
protect_from_forgery with: :null_session
before_action :fetch_notification, only: [:update] before_action :fetch_notification, only: [:update]
before_action :set_primary_actor, only: [:read_all] before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@ -8,17 +9,18 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def index def index
@unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count @unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count
@count = notifications.count @count = notifications.count
@notifications = notifications.page @current_page @notifications = notifications.page(@current_page).per(RESULTS_PER_PAGE)
end end
def read_all def read_all
# rubocop:disable Rails/SkipsModelValidations
if @primary_actor if @primary_actor
current_user.notifications.where(account_id: current_account.id, primary_actor: @primary_actor, read_at: nil) current_user.notifications.where(account_id: current_account.id, primary_actor: @primary_actor, read_at: nil)
.update(read_at: DateTime.now.utc) .update_all(read_at: DateTime.now.utc)
else else
current_user.notifications.where(account_id: current_account.id, read_at: nil).update(read_at: DateTime.now.utc) current_user.notifications.where(account_id: current_account.id, read_at: nil).update_all(read_at: DateTime.now.utc)
end end
# rubocop:enable Rails/SkipsModelValidations
head :ok head :ok
end end

View file

@ -0,0 +1,33 @@
/* global axios */
import ApiClient from './ApiClient';
class NotificationsAPI extends ApiClient {
constructor() {
super('notifications', { accountScoped: true });
}
get(page) {
return axios.get(`${this.url}?page=${page}`);
}
getNotifications(contactId) {
return axios.get(`${this.url}/${contactId}/notifications`);
}
getUnreadCount() {
return axios.get(`${this.url}/unread_count`);
}
read(primaryActorType, primaryActorId) {
return axios.post(`${this.url}/read_all`, {
primary_actor_type: primaryActorType,
primary_actor_id: primaryActorId,
});
}
readAll() {
return axios.post(`${this.url}/read_all`);
}
}
export default new NotificationsAPI();

View file

@ -0,0 +1,13 @@
import notifications from '../notifications';
import ApiClient from '../ApiClient';
describe('#NotificationAPI', () => {
it('creates correct instance', () => {
expect(notifications).toBeInstanceOf(ApiClient);
expect(notifications).toHaveProperty('get');
expect(notifications).toHaveProperty('getNotifications');
expect(notifications).toHaveProperty('getUnreadCount');
expect(notifications).toHaveProperty('read');
expect(notifications).toHaveProperty('readAll');
});
});

View file

@ -106,13 +106,13 @@
font-size: $font-size-medium; font-size: $font-size-medium;
margin-top: $space-medium; margin-top: $space-medium;
>span { > span {
margin-left: $space-one; margin-left: $space-one;
} }
} }
} }
.menu-title+ul>li>a { .menu-title + ul > li > a {
@include padding($space-micro null); @include padding($space-micro null);
color: $medium-gray; color: $medium-gray;
line-height: $global-lineheight; line-height: $global-lineheight;
@ -152,6 +152,26 @@
margin-left: auto; margin-left: auto;
margin-top: auto; margin-top: auto;
} }
.notifications {
font-size: var(--font-size-big);
margin-bottom: auto;
margin-left: auto;
margin-top: auto;
position: relative;
.unread-badge {
background: var(--r-300);
border-radius: var(--space-small);
color: var(--white);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-black);
left: var(--space-slab);
padding: 0 var(--space-smaller);
position: absolute;
top: var(--space-smaller);
}
}
} }
.hamburger--menu { .hamburger--menu {

View file

@ -73,6 +73,13 @@
{{ $t(`AGENT_MGMT.AGENT_TYPES.${currentRole.toUpperCase()}`) }} {{ $t(`AGENT_MGMT.AGENT_TYPES.${currentRole.toUpperCase()}`) }}
</h5> </h5>
</div> </div>
<span
class="notifications icon ion-ios-bell"
@click.stop="showNotification"
>
<span v-if="unreadCount" class="unread-badge">{{ unreadCount }}</span>
</span>
<span class="current-user--options icon ion-android-more-vertical" /> <span class="current-user--options icon ion-android-more-vertical" />
</div> </div>
</div> </div>
@ -134,7 +141,7 @@
/> />
</label> </label>
</div> </div>
<div class="modal-footer medium-12 columns"> <div class="modal-footer medium-12 columns">
<div class="medium-12 columns"> <div class="medium-12 columns">
<woot-submit-button <woot-submit-button
:disabled=" :disabled="
@ -206,6 +213,7 @@ export default {
currentRole: 'getCurrentRole', currentRole: 'getCurrentRole',
uiFlags: 'agents/getUIFlags', uiFlags: 'agents/getUIFlags',
accountLabels: 'labels/getLabelsOnSidebar', accountLabels: 'labels/getLabelsOnSidebar',
notificationMetadata: 'notifications/getMeta',
}), }),
currentUserAvailableName() { currentUserAvailableName() {
return this.currentUser.name; return this.currentUser.name;
@ -284,10 +292,20 @@ export default {
dashboardPath() { dashboardPath() {
return frontendURL(`accounts/${this.accountId}/dashboard`); return frontendURL(`accounts/${this.accountId}/dashboard`);
}, },
unreadCount() {
if (!this.notificationMetadata.unreadCount) {
return 0;
}
return this.notificationMetadata.unreadCount < 100
? this.notificationMetadata.unreadCount
: '99+';
},
}, },
mounted() { mounted() {
this.$store.dispatch('labels/get'); this.$store.dispatch('labels/get');
this.$store.dispatch('inboxes/get'); this.$store.dispatch('inboxes/get');
this.$store.dispatch('notifications/unReadCount');
}, },
methods: { methods: {
filterMenuItemsByRole(menuItems) { filterMenuItemsByRole(menuItems) {
@ -307,6 +325,9 @@ export default {
showOptions() { showOptions() {
this.showOptionsMenu = !this.showOptionsMenu; this.showOptionsMenu = !this.showOptionsMenu;
}, },
showNotification() {
this.$router.push(`/app/accounts/${this.accountId}/notifications`);
},
changeAccount() { changeAccount() {
this.showAccountModal = true; this.showAccountModal = true;
}, },

View file

@ -8,11 +8,13 @@ export const getSidebarItems = accountId => ({
'inbox_conversation', 'inbox_conversation',
'conversation_through_inbox', 'conversation_through_inbox',
'contacts_dashboard', 'contacts_dashboard',
'notifications_dashboard',
'settings_account_reports', 'settings_account_reports',
'profile_settings', 'profile_settings',
'profile_settings_index', 'profile_settings_index',
'label_conversations', 'label_conversations',
'conversations_through_label', 'conversations_through_label',
'notifications_index',
], ],
menuItems: { menuItems: {
assignedToMe: { assignedToMe: {
@ -31,6 +33,13 @@ export const getSidebarItems = accountId => ({
toState: frontendURL(`accounts/${accountId}/contacts`), toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard', toStateName: 'contacts_dashboard',
}, },
notifications: {
icon: 'ion-ios-bell',
label: 'NOTIFICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/notifications`),
toStateName: 'notifications_dashboard',
},
report: { report: {
icon: 'ion-arrow-graph-up-right', icon: 'ion-arrow-graph-up-right',
label: 'REPORTS', label: 'REPORTS',

View file

@ -51,5 +51,24 @@
"ENTER_TO_REMOVE": "Press enter to remove", "ENTER_TO_REMOVE": "Press enter to remove",
"SELECT_ONE": "Select one" "SELECT_ONE": "Select one"
} }
},
"NOTIFICATIONS_PAGE": {
"HEADER": "Notifications",
"MARK_ALL_DONE": "Mark All Done",
"LIST": {
"LOADING_MESSAGE": "Loading notifications...",
"404": "No Notifications",
"TABLE_HEADER": [
"Name",
"Phone Number",
"Conversations",
"Last Contacted"
]
},
"TYPE_LABEL": {
"conversation_creation": "New conversation",
"conversation_assignment": "Conversation Assigned",
"assigned_conversation_new_message": "New Message"
}
} }
} }

View file

@ -118,6 +118,7 @@
"HOME": "Home", "HOME": "Home",
"AGENTS": "Agents", "AGENTS": "Agents",
"INBOXES": "Inboxes", "INBOXES": "Inboxes",
"NOTIFICATIONS": "Notifications",
"CANNED_RESPONSES": "Canned Responses", "CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations", "INTEGRATIONS": "Integrations",
"ACCOUNT_SETTINGS": "Account Settings", "ACCOUNT_SETTINGS": "Account Settings",

View file

@ -14,7 +14,7 @@
:on-click-contact="openContactInfoPanel" :on-click-contact="openContactInfoPanel"
:active-contact-id="selectedContactId" :active-contact-id="selectedContactId"
/> />
<contacts-footer <table-footer
:on-page-change="onPageChange" :on-page-change="onPageChange"
:current-page="Number(meta.currentPage)" :current-page="Number(meta.currentPage)"
:total-count="meta.count" :total-count="meta.count"
@ -34,13 +34,13 @@ import { mapGetters } from 'vuex';
import ContactsHeader from './Header'; import ContactsHeader from './Header';
import ContactsTable from './ContactsTable'; import ContactsTable from './ContactsTable';
import ContactInfoPanel from './ContactInfoPanel'; import ContactInfoPanel from './ContactInfoPanel';
import ContactsFooter from './Footer'; import TableFooter from 'dashboard/components/widgets/TableFooter';
export default { export default {
components: { components: {
ContactsHeader, ContactsHeader,
ContactsTable, ContactsTable,
ContactsFooter, TableFooter,
ContactInfoPanel, ContactInfoPanel,
}, },
data() { data() {

View file

@ -2,6 +2,7 @@ import AppContainer from './Dashboard';
import settings from './settings/settings.routes'; import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes'; import conversation from './conversation/conversation.routes';
import { routes as contactRoutes } from './contacts/routes'; import { routes as contactRoutes } from './contacts/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../helper/URLHelper';
export default { export default {
@ -9,7 +10,12 @@ export default {
{ {
path: frontendURL('accounts/:account_id'), path: frontendURL('accounts/:account_id'),
component: AppContainer, component: AppContainer,
children: [...conversation.routes, ...settings.routes, ...contactRoutes], children: [
...conversation.routes,
...settings.routes,
...contactRoutes,
...notificationRoutes,
],
}, },
], ],
}; };

View file

@ -0,0 +1,182 @@
<template>
<section class="notification--table-wrap">
<woot-submit-button
v-if="notificationMetadata.unreadCount"
class="button nice success button--fixed-right-top"
:button-text="$t('NOTIFICATIONS_PAGE.MARK_ALL_DONE')"
:loading="isUpdating"
@click="onMarkAllDoneClick"
>
</woot-submit-button>
<table class="woot-table notifications-table">
<tbody v-show="!isLoading">
<tr
v-for="notificationItem in notifications"
:key="notificationItem.id"
@click="() => onClickNotification(notificationItem)"
>
<td>
<div class="notification--thumbnail">
<thumbnail
:src="notificationItem.primary_actor.meta.sender.thumbnail"
size="36px"
:username="notificationItem.primary_actor.meta.sender.name"
:status="
notificationItem.primary_actor.meta.sender.availability_status
"
/>
<div>
<h4 class="notification--name">
{{ `#${notificationItem.id}` }}
</h4>
<p class="notification--title">
{{ notificationItem.push_message_title }}
</p>
</div>
</div>
</td>
<td>
<span class="label">
{{
$t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
)
}}
</span>
</td>
<td>
{{ dynamicTime(notificationItem.created_at) }}
</td>
<td>
<div
v-if="!notificationItem.read_at"
class="notification--unread-indicator"
/>
</td>
</tr>
</tbody>
</table>
<empty-state
v-if="showEmptyResult"
:title="$t('NOTIFICATIONS_PAGE.LIST.404')"
/>
<div v-if="isLoading" class="notifications--loader">
<spinner />
<span>{{ $t('NOTIFICATIONS_PAGE.LIST.LOADING_MESSAGE') }}</span>
</div>
</section>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import timeMixin from '../../../../mixins/time';
import { mapGetters } from 'vuex';
export default {
components: {
Thumbnail,
Spinner,
EmptyState,
},
mixins: [timeMixin],
props: {
notifications: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
isUpdating: {
type: Boolean,
default: false,
},
onClickNotification: {
type: Function,
default: () => {},
},
onMarkAllDoneClick: {
type: Function,
default: () => {},
},
},
computed: {
...mapGetters({
notificationMetadata: 'notifications/getMeta',
}),
showEmptyResult() {
return !this.isLoading && this.notifications.length === 0;
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/mixins';
.notification--name {
font-size: var(--font-size-small);
margin-bottom: 0;
}
.notification--title {
font-size: var(--font-size-mini);
margin: 0;
}
.notification--table-wrap {
@include scroll-on-hover;
flex: 1 1;
height: 100%;
padding: var(--space-normal);
}
.notifications-table {
> tbody {
> tr {
cursor: pointer;
&:hover {
background: var(--b-50);
}
&.is-active {
background: var(--b-100);
}
> td {
&.conversation-count-item {
padding-left: var(--space-medium);
}
}
}
}
.notification--thumbnail {
display: flex;
align-items: center;
.user-thumbnail-box {
margin-right: var(--space-small);
}
}
}
.notifications--loader {
font-size: var(--font-size-default);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-big);
}
.notification--unread-indicator {
width: var(--space-one);
height: var(--space-one);
border-radius: 50%;
background: var(--color-woot);
}
</style>

View file

@ -0,0 +1,82 @@
<template>
<div class="columns notification--page">
<div class="notification--content medium-12">
<notification-table
:notifications="records"
:is-loading="uiFlags.isFetching"
:is-updating="uiFlags.isUpdating"
:on-click-notification="openConversation"
:on-mark-all-done-click="onMarkAllDoneClick"
/>
<table-footer
:on-page-change="onPageChange"
:current-page="Number(meta.currentPage)"
:total-count="meta.count"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import TableFooter from 'dashboard/components/widgets/TableFooter';
import NotificationTable from './NotificationTable';
export default {
components: {
NotificationTable,
TableFooter,
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
}),
},
mounted() {
this.$store.dispatch('notifications/get', { page: 1 });
},
methods: {
onPageChange(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
this.$store.dispatch('notifications/get', { page });
},
openConversation(notification) {
const {
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId },
} = notification;
this.$store.dispatch('notifications/read', {
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push(
`/app/accounts/${this.accountId}/conversations/${conversationId}`
);
},
onMarkAllDoneClick() {
this.$store.dispatch('notifications/readAll');
},
},
};
</script>
<style lang="scss" scoped>
.notification--page {
background: var(--white);
overflow-y: auto;
width: 100%;
}
.notification--content {
display: flex;
flex-direction: column;
height: 100%;
}
</style>

View file

@ -0,0 +1,24 @@
/* eslint arrow-body-style: 0 */
import NotificationsView from './components/NotificationsView.vue';
import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/Wrapper';
export const routes = [
{
path: frontendURL('accounts/:accountId/notifications'),
component: SettingsWrapper,
props: {
headerTitle: 'NOTIFICATIONS_PAGE.HEADER',
icon: 'ion-ios-bell',
showNewButton: false,
},
children: [
{
path: '',
name: 'notifications_index',
component: NotificationsView,
roles: ['administrator', 'agent'],
},
],
},
];

View file

@ -7,6 +7,7 @@ import auth from './modules/auth';
import cannedResponse from './modules/cannedResponse'; import cannedResponse from './modules/cannedResponse';
import contactConversations from './modules/contactConversations'; import contactConversations from './modules/contactConversations';
import contacts from './modules/contacts'; import contacts from './modules/contacts';
import notifications from './modules/notifications';
import conversationLabels from './modules/conversationLabels'; import conversationLabels from './modules/conversationLabels';
import conversationMetadata from './modules/conversationMetadata'; import conversationMetadata from './modules/conversationMetadata';
import conversationPage from './modules/conversationPage'; import conversationPage from './modules/conversationPage';
@ -32,6 +33,7 @@ export default new Vuex.Store({
cannedResponse, cannedResponse,
contactConversations, contactConversations,
contacts, contacts,
notifications,
conversationLabels, conversationLabels,
conversationMetadata, conversationMetadata,
conversationPage, conversationPage,

View file

@ -0,0 +1,55 @@
import types from '../../mutation-types';
import NotificationsAPI from '../../../api/notifications';
export const actions = {
get: async ({ commit }, { page = 1 } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true });
try {
const {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get(page);
commit(types.CLEAR_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
unReadCount: async ({ commit } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true });
try {
const { data } = await NotificationsAPI.getUnreadCount();
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, data);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: false });
}
},
read: async (
{ commit },
{ primaryActorType, primaryActorId, unreadCount }
) => {
try {
await NotificationsAPI.read(primaryActorType, primaryActorId);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
commit(types.UPDATE_NOTIFICATION, primaryActorId);
} catch (error) {
throw new Error(error);
}
},
readAll: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
await NotificationsAPI.readAll();
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, 0);
commit(types.UPDATE_ALL_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
throw new Error(error);
}
},
};

View file

@ -0,0 +1,15 @@
export const getters = {
getNotifications($state) {
return Object.values($state.records).sort((n1, n2) => n2.id - n1.id);
},
getUIFlags($state) {
return $state.uiFlags;
},
getNotification: $state => id => {
const notification = $state.records[id];
return notification || {};
},
getMeta: $state => {
return $state.meta;
},
};

View file

@ -0,0 +1,26 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
meta: {
count: 0,
currentPage: 1,
unReadCount: 0,
},
records: {},
uiFlags: {
isFetching: false,
isFetchingItem: false,
isUpdating: false,
isUpdatingUnreadCount: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -0,0 +1,48 @@
import Vue from 'vue';
import types from '../../mutation-types';
export const mutations = {
[types.SET_NOTIFICATIONS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.CLEAR_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {});
},
[types.SET_NOTIFICATIONS_META]: ($state, data) => {
const {
count,
current_page: currentPage,
unread_count: unreadCount,
} = data;
Vue.set($state.meta, 'count', count);
Vue.set($state.meta, 'currentPage', currentPage);
Vue.set($state.meta, 'unreadCount', unreadCount);
},
[types.SET_NOTIFICATIONS_UNREAD_COUNT]: ($state, count) => {
Vue.set($state.meta, 'unreadCount', count);
},
[types.SET_NOTIFICATIONS]: ($state, data) => {
data.forEach(notification => {
Vue.set($state.records, notification.id, {
...($state.records[notification.id] || {}),
...notification,
});
});
},
[types.UPDATE_NOTIFICATION]: ($state, primaryActorId) => {
Object.values($state.records).forEach(item => {
if (item.primary_actor_id === primaryActorId) {
Vue.set($state.records[item.id], 'read_at', true);
}
});
},
[types.UPDATE_ALL_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
Vue.set($state.records[item.id], 'read_at', true);
});
},
};

View file

@ -0,0 +1,93 @@
import axios from 'axios';
import { actions } from '../../notifications/actions';
import types from '../../../mutation-types';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#get', () => {
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.get({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true }],
[types.CLEAR_NOTIFICATIONS],
[types.SET_NOTIFICATIONS, [{ id: 1 }]],
[
types.SET_NOTIFICATIONS_META,
{ count: 3, current_page: 1, unread_count: 2 },
],
[types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ 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 });
await actions.unReadCount({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true }],
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 1],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.unReadCount({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: false }],
]);
});
});
describe('#read', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({});
await actions.read({ commit }, { unreadCount: 2, primaryActorId: 1 });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 1],
[types.UPDATE_NOTIFICATION, 1],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.read({ commit })).rejects.toThrow(Error);
});
});
describe('#readAll', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: 1 });
await actions.readAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 0],
[types.UPDATE_ALL_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.readAll({ commit })).rejects.toThrow(Error);
});
});
});

View file

@ -0,0 +1,46 @@
import { getters } from '../../notifications/getters';
describe('#getters', () => {
it('getNotifications', () => {
const state = {
records: {
1: { id: 1 },
2: { id: 2 },
3: { id: 3 },
},
};
expect(getters.getNotifications(state)).toEqual([
{ id: 3 },
{ id: 2 },
{ id: 1 },
]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: true,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: true,
});
});
it('getNotification', () => {
const state = {
records: {
1: { id: 1 },
},
};
expect(getters.getNotification(state)(1)).toEqual({ id: 1 });
expect(getters.getNotification(state)(2)).toEqual({});
});
it('getMeta', () => {
const state = {
meta: { unreadCount: 1 },
};
expect(getters.getMeta(state)).toEqual({ unreadCount: 1 });
});
});

View file

@ -0,0 +1,90 @@
import types from '../../../mutation-types';
import { mutations } from '../../notifications/mutations';
describe('#mutations', () => {
describe('#SET_NOTIFICATIONS_UI_FLAG', () => {
it('set notification ui flag', () => {
const state = { uiFlags: { isFetching: true } };
mutations[types.SET_NOTIFICATIONS_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags).toEqual({ isFetching: false });
});
});
describe('#CLEAR_NOTIFICATIONS', () => {
it('clear notifications', () => {
const state = { records: { 1: { id: 1 } } };
mutations[types.CLEAR_NOTIFICATIONS](state);
expect(state.records).toEqual({});
});
});
describe('#SET_NOTIFICATIONS_META', () => {
it('set notifications meta data', () => {
const state = { meta: {} };
mutations[types.SET_NOTIFICATIONS_META](state, {
count: 3,
current_page: 1,
unread_count: 2,
});
expect(state.meta).toEqual({
count: 3,
currentPage: 1,
unreadCount: 2,
});
});
});
describe('#SET_NOTIFICATIONS_UNREAD_COUNT', () => {
it('set notifications unread count', () => {
const state = { meta: { unreadCount: 4 } };
mutations[types.SET_NOTIFICATIONS_UNREAD_COUNT](state, 3);
expect(state.meta).toEqual({ unreadCount: 3 });
});
});
describe('#SET_NOTIFICATIONS', () => {
it('set notifications ', () => {
const state = { records: {} };
mutations[types.SET_NOTIFICATIONS](state, [
{ id: 1 },
{ id: 2 },
{ id: 3 },
{ id: 4 },
]);
expect(state.records).toEqual({
1: { id: 1 },
2: { id: 2 },
3: { id: 3 },
4: { id: 4 },
});
});
});
describe('#UPDATE_NOTIFICATION', () => {
it('update notifications ', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1 },
},
};
mutations[types.UPDATE_NOTIFICATION](state, 1);
expect(state.records).toEqual({
1: { id: 1, primary_actor_id: 1, read_at: true },
});
});
});
describe('#UPDATE_ALL_NOTIFICATIONS', () => {
it('update all notifications ', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1 },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.UPDATE_ALL_NOTIFICATIONS](state);
expect(state.records).toEqual({
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2, read_at: true },
});
});
});
});

View file

@ -98,6 +98,18 @@ export default {
EDIT_CONTACT: 'EDIT_CONTACT', EDIT_CONTACT: 'EDIT_CONTACT',
UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE', UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE',
// Notifications
SET_NOTIFICATIONS_META: 'SET_NOTIFICATIONS_META',
SET_NOTIFICATIONS_UNREAD_COUNT: 'SET_NOTIFICATIONS_UNREAD_COUNT',
SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG',
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION',
UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS',
SET_NOTIFICATIONS_ITEM: 'SET_NOTIFICATIONS_ITEM',
SET_NOTIFICATIONS: 'SET_NOTIFICATIONS',
CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS',
EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS',
UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE',
// Contact Conversation // Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG', SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS', SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',