feat: New notification panel for unread notifications (#4061)

Fixes: #4052
This commit is contained in:
Sivin Varghese 2022-03-25 19:02:45 +05:30 committed by GitHub
parent ff881fcad9
commit 48389d69d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 606 additions and 21 deletions

View file

@ -8,6 +8,7 @@
:active-menu-item="activePrimaryMenu.key"
@toggle-accounts="toggleAccountModal"
@key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel"
/>
<secondary-sidebar
:account-id="accountId"
@ -176,6 +177,9 @@ export default {
showAddLabelPopup() {
this.$emit('show-add-label-popup');
},
openNotificationPanel() {
this.$emit('open-notification-panel');
},
},
};
</script>

View file

@ -1,19 +1,21 @@
<template>
<div class="notifications-link">
<primary-nav-item
name="NOTIFICATIONS"
icon="alert"
:to="`/app/accounts/${accountId}/notifications`"
:count="unreadCount"
/>
<woot-button
class-names="notifications-link--button"
variant="clear"
color-scheme="secondary"
:class="{ 'is-active': isNotificationPanelActive }"
@click="openNotificationPanel"
>
<fluent-icon icon="alert" />
<span v-if="unreadCount" class="badge warning">{{ unreadCount }}</span>
</woot-button>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import PrimaryNavItem from './PrimaryNavItem';
export default {
components: { PrimaryNavItem },
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
@ -28,8 +30,17 @@ export default {
? `${this.notificationMetadata.unreadCount}`
: '99+';
},
isNotificationPanelActive() {
return this.$route.name === 'notifications_index';
},
},
methods: {
openNotificationPanel() {
if (this.$route.name !== 'notifications_index') {
this.$emit('open-notification-panel');
}
},
},
methods: {},
};
</script>
@ -37,4 +48,32 @@ export default {
.notifications-link {
margin-bottom: var(--space-small);
}
.badge {
position: absolute;
right: var(--space-minus-smaller);
top: var(--space-minus-smaller);
}
.notifications-link--button {
display: flex;
position: relative;
border-radius: var(--border-radius-large);
border: 1px solid transparent;
color: var(--s-600);
margin: var(--space-small) 0;
&:hover {
background: var(--w-50);
color: var(--s-600);
}
&:focus {
border-color: var(--w-500);
}
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
</style>

View file

@ -16,7 +16,7 @@
/>
</nav>
<div class="menu vertical user-menu">
<notification-bell />
<notification-bell @open-notification-panel="openNotificationPanel" />
<agent-details @toggle-menu="toggleOptions" />
<options-menu
:show="showOptionsMenu"
@ -83,6 +83,9 @@ export default {
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},
openNotificationPanel() {
this.$emit('open-notification-panel');
},
},
};
</script>

View file

@ -14,6 +14,10 @@ const i18nConfig = new VueI18n({
messages: i18n,
});
const $route = {
name: 'notifications_index',
};
describe('notificationBell', () => {
const accountId = 1;
const notificationMetadata = { unreadCount: 19 };
@ -45,24 +49,40 @@ describe('notificationBell', () => {
});
it('it should return unread count 19 ', () => {
const notificationBell = shallowMount(NotificationBell, {
store,
const wrapper = shallowMount(NotificationBell, {
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
},
});
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
expect(statusViewTitle.vm.count).toBe('19');
expect(wrapper.vm.unreadCount).toBe('19');
});
it('it should return unread count 99+ ', async () => {
notificationMetadata.unreadCount = 101;
notificationMetadata.unreadCount = 100;
const wrapper = shallowMount(NotificationBell, {
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
},
});
expect(wrapper.vm.unreadCount).toBe('99+');
});
it('isNotificationPanelActive', async () => {
const notificationBell = shallowMount(NotificationBell, {
store,
localVue,
i18n: i18nConfig,
mocks: {
$route,
},
});
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
expect(statusViewTitle.vm.count).toBe('99+');
expect(notificationBell.vm.isNotificationPanelActive).toBe(true);
});
});

View file

@ -60,6 +60,13 @@
"NOTIFICATIONS_PAGE": {
"HEADER": "Notifications",
"MARK_ALL_DONE": "Mark All Done",
"DELETE_TITLE": "deleted",
"UNREAD_NOTIFICATION": {
"TITLE": "Unread Notifications",
"ALL_NOTIFICATIONS": "View all notifications",
"LOADING_UNREAD_MESSAGE": "Loading unread notifications...",
"EMPTY_MESSAGE": "You have no unread notifications"
},
"LIST": {
"LOADING_MESSAGE": "Loading notifications...",
"404": "No Notifications",

View file

@ -3,6 +3,7 @@
<sidebar
:route="currentRoute"
:class="sidebarClassName"
@open-notification-panel="openNotificationPanel"
@toggle-account-modal="toggleAccountModal"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal"
@ -25,6 +26,10 @@
@close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal"
/>
<notification-panel
v-if="isNotificationPanel"
@close="closeNotificationPanel"
/>
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
<add-label-modal @close="hideAddLabelPopup" />
</woot-modal>
@ -40,6 +45,7 @@ import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShor
import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal';
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector';
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
export default {
components: {
@ -49,6 +55,7 @@ export default {
AddAccountModal,
AccountSelector,
AddLabelModal,
NotificationPanel,
},
data() {
return {
@ -58,6 +65,7 @@ export default {
showCreateAccountModal: false,
showAddLabelModal: false,
showShortcutModal: false,
isNotificationPanel: false,
};
},
computed: {
@ -126,6 +134,12 @@ export default {
hideAddLabelPopup() {
this.showAddLabelModal = false;
},
openNotificationPanel() {
this.isNotificationPanel = true;
},
closeNotificationPanel() {
this.isNotificationPanel = false;
},
},
};
</script>

View file

@ -0,0 +1,274 @@
<template>
<div class="modal-mask">
<div
v-on-clickaway="closeNotificationPanel"
class="notification-wrap flex-space-between"
>
<div class="header-wrap w-full flex-space-between">
<div class="header-title--wrap flex-view">
<span class="header-title">
{{ $t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.TITLE') }}
</span>
<span v-if="totalUnreadNotifications" class="total-count block-title">
{{ totalUnreadNotifications }}
</span>
</div>
<div class="flex-view">
<woot-button
v-if="!noUnreadNotificationAvailable"
color-scheme="primary"
variant="smooth"
size="tiny"
class-names="action-button"
:is-loading="uiFlags.isUpdating"
@click="onMarkAllDoneClick"
>
{{ $t('NOTIFICATIONS_PAGE.MARK_ALL_DONE') }}
</woot-button>
<woot-button
color-scheme="secondary"
variant="link"
size="tiny"
icon="dismiss"
@click="closeNotificationPanel"
/>
</div>
</div>
<notification-panel-list
:notifications="getUnreadNotifications"
:is-loading="uiFlags.isFetching"
:on-click-notification="openConversation"
:in-last-page="inLastPage"
/>
<div v-if="records.length !== 0" class="footer-wrap flex-space-between">
<div class="flex-view">
<woot-button
size="medium"
variant="clear"
color-scheme="secondary"
class-names="page-change--button"
:is-disabled="inFirstPage"
@click="onClickFirstPage"
>
<fluent-icon icon="chevron-left" size="16" />
<fluent-icon
icon="chevron-left"
size="16"
class="margin-left-minus-slab"
/>
</woot-button>
<woot-button
color-scheme="secondary"
variant="clear"
size="medium"
icon="chevron-left"
:disabled="inFirstPage"
@click="onClickPreviousPage"
>
</woot-button>
</div>
<span class="page-count"> {{ currentPage }} - {{ lastPage }} </span>
<div class="flex-view">
<woot-button
color-scheme="secondary"
variant="clear"
size="medium"
icon="chevron-right"
:disabled="inLastPage"
@click="onClickNextPage"
>
</woot-button>
<woot-button
size="medium"
variant="clear"
color-scheme="secondary"
class-names="page-change--button"
:disabled="inLastPage"
@click="onClickLastPage"
>
<fluent-icon icon="chevron-right" size="16" />
<fluent-icon
icon="chevron-right"
size="16"
class="margin-left-minus-slab"
/>
</woot-button>
</div>
</div>
<div v-else></div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import NotificationPanelList from './NotificationPanelList';
export default {
components: {
NotificationPanelList,
},
mixins: [clickaway],
data() {
return {
pageSize: 15,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
}),
totalUnreadNotifications() {
return this.meta.unreadCount;
},
noUnreadNotificationAvailable() {
return this.meta.unreadCount === 0;
},
getUnreadNotifications() {
return this.records.filter(notification => notification.read_at === null);
},
currentPage() {
return Number(this.meta.currentPage);
},
lastPage() {
if (this.totalUnreadNotifications > 15) {
return Math.ceil(this.totalUnreadNotifications / this.pageSize);
}
return 1;
},
inFirstPage() {
const page = Number(this.meta.currentPage);
return page === 1;
},
inLastPage() {
return this.currentPage === this.lastPage;
},
},
mounted() {
this.$store.dispatch('notifications/get', { page: 1 });
},
methods: {
onPageChange(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({
name: 'inbox_conversation',
params: { conversation_id: conversationId },
});
this.$emit('close');
},
onClickNextPage() {
if (!this.inLastPage) {
const page = this.currentPage + 1;
this.onPageChange(page);
}
},
onClickPreviousPage() {
if (!this.inFirstPage) {
const page = this.currentPage - 1;
this.onPageChange(page);
}
},
onClickFirstPage() {
if (!this.inFirstPage) {
const page = 1;
this.onPageChange(page);
}
},
onClickLastPage() {
if (!this.inLastPage) {
const page = this.lastPage;
this.onPageChange(page);
}
},
onMarkAllDoneClick() {
this.$store.dispatch('notifications/readAll');
},
closeNotificationPanel() {
this.$emit('close');
},
},
};
</script>
<style lang="scss" scoped>
.flex-view {
display: flex;
}
.flex-space-between {
display: flex;
justify-content: space-between;
}
.notification-wrap {
flex-direction: column;
height: 90vh;
width: 52rem;
background-color: var(--white);
border-radius: var(--border-radius-medium);
position: absolute;
left: var(--space-jumbo);
margin: var(--space-small);
}
.header-wrap {
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--s-50);
padding: var(--space-two) var(--space-medium) var(--space-slab)
var(--space-medium);
.header-title--wrap {
align-items: center;
}
.header-title {
font-size: var(--font-size-two);
font-weight: var(--font-weight-black);
}
.total-count {
padding: var(--space-smaller) var(--space-small);
background: var(--b-50);
border-radius: var(--border-radius-rounded);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
}
.action-button {
padding: var(--space-micro) var(--space-small);
margin-right: var(--space-small);
}
}
.page-count {
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
color: var(--s-500);
}
.footer-wrap {
align-items: center;
padding: var(--space-smaller) var(--space-two);
}
.page-change--button:hover {
background: var(--s-50);
}
</style>

View file

@ -0,0 +1,219 @@
<template>
<div class="notification-list-item--wrap h-full flex-view ">
<woot-button
v-for="notificationItem in notifications"
v-show="!isLoading"
:key="notificationItem.id"
size="expanded"
color-scheme="secondary"
variant="link"
@click="() => onClickNotification(notificationItem)"
>
<div class="notification-list--wrap flex-view w-full">
<div
v-if="!notificationItem.read_at"
class="notification-unread--indicator"
></div>
<div v-else class="empty flex-view"></div>
<div class="notification-content--wrap w-full flex-space-between">
<div class="flex-space-between">
<div class="title-wrap flex-view ">
<span class="notification-title">
{{
`#${
notificationItem.primary_actor
? notificationItem.primary_actor.id
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}`
}}
</span>
<span class="notification-type">
{{
$t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
)
}}
</span>
</div>
<div>
<thumbnail
v-if="notificationItem.primary_actor.meta.assignee"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="16px"
:username="notificationItem.primary_actor.meta.assignee.name"
/>
</div>
</div>
<div class="w-full flex-view ">
<span class="notification-message text-truncate">
{{ notificationItem.push_message_title }}
</span>
</div>
<span class="timestamp flex-view">
{{ dynamicTime(notificationItem.created_at) }}
</span>
</div>
</div>
</woot-button>
<empty-state
v-if="showEmptyResult"
:title="$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.EMPTY_MESSAGE')"
/>
<woot-button
v-if="!isLoading && inLastPage"
size="medium"
variant="clear"
color-scheme="primary"
class-names="action-button"
@click="openNotificationPage"
>
{{ $t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.ALL_NOTIFICATIONS') }}
</woot-button>
<div v-if="isLoading" class="notifications-loader flex-view">
<spinner />
<span>{{
$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.LOADING_UNREAD_MESSAGE')
}}</span>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
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';
export default {
components: {
Thumbnail,
Spinner,
EmptyState,
},
mixins: [timeMixin],
props: {
notifications: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: true,
},
onClickNotification: {
type: Function,
default: () => {},
},
inLastPage: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
notificationMetadata: 'notifications/getMeta',
}),
showEmptyResult() {
return !this.isLoading && this.notifications.length === 0;
},
},
methods: {
openNotificationPage() {
if (this.$route.name !== 'notifications_index') {
this.$router.push({
name: 'notifications_index',
});
}
},
},
};
</script>
<style lang="scss" scoped>
.flex-view {
display: flex;
}
.flex-space-between {
display: flex;
justify-content: space-between;
}
.notification-list-item--wrap {
flex-direction: column;
padding: var(--space-small) var(--space-slab);
overflow: scroll;
}
.empty {
width: var(--space-small);
}
.notification-list--wrap {
flex-direction: row;
align-items: center;
padding: var(--space-slab);
line-height: 1.4;
border-bottom: 1px solid var(--b-50);
}
.notification-list--wrap:hover {
background: var(--b-100);
border-radius: var(--border-radius-normal);
}
.notification-content--wrap {
flex-direction: column;
margin-left: var(--space-slab);
overflow: hidden;
}
.title-wrap {
align-items: center;
}
.notification-title {
font-weight: var(--font-weight-black);
}
.notification-type {
font-size: var(--font-size-micro);
padding: var(--space-micro) var(--space-smaller);
margin-left: var(--space-small);
background: var(--s-50);
border-radius: var(--border-radius-normal);
}
.notification-message {
color: var(--color-body);
font-weight: var(--font-weight-normal);
}
.timestamp {
margin-top: var(--space-smaller);
color: var(--b-500);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
}
.notification-unread--indicator {
width: var(--space-small);
height: var(--space-small);
border-radius: var(--border-radius-rounded);
background: var(--color-woot);
}
.action-button {
margin-top: var(--space-slab);
}
.notifications-loader {
align-items: center;
justify-content: center;
margin: var(--space-larger) var(--space-small);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
</style>

View file

@ -17,17 +17,17 @@
@click="() => onClickNotification(notificationItem)"
>
<td>
<div class="">
<div class="flex-view notification-contant--wrap">
<h5 class="notification--title">
{{
`#${
notificationItem.primary_actor
? notificationItem.primary_actor.id
: 'deleted'
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}`
}}
</h5>
<span class="notification--message-title">
<span class="notification--message-title text-truncate">
{{ notificationItem.push_message_title }}
</span>
</div>
@ -197,6 +197,11 @@ export default {
text-align: right;
}
.notification-contant--wrap {
flex-direction: column;
max-width: 50rem;
}
.notification--message-title {
color: var(--s-700);
}