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
23 changed files with 811 additions and 12 deletions

View File

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

View File

@@ -1,211 +0,0 @@
<template>
<footer v-if="isFooterVisible" class="footer">
<div class="left-aligned-wrap">
<div class="page-meta">
<strong>{{ firstIndex }}</strong>
- <strong>{{ lastIndex }}</strong> of
<strong>{{ totalCount }}</strong> items
</div>
</div>
<div class="right-aligned-wrap">
<div
v-if="totalCount"
class="primary button-group pagination-button-group"
>
<button
class="button small goto-first"
:class="firstPageButtonClass"
@click="onFirstPage"
>
<i class="ion-chevron-left" />
<i class="ion-chevron-left" />
</button>
<button
class="button small"
:class="prevPageButtonClass"
@click="onPrevPage"
>
<i class="ion-chevron-left" />
</button>
<button class="button" @click.prevent>
{{ currentPage }}
</button>
<button
class="button small"
:class="nextPageButtonClass"
@click="onNextPage"
>
<i class="ion-chevron-right" />
</button>
<button
class="button small goto-last"
:class="lastPageButtonClass"
@click="onLastPage"
>
<i class="ion-chevron-right" />
<i class="ion-chevron-right" />
</button>
</div>
</div>
</footer>
</template>
<script>
export default {
components: {},
props: {
currentPage: {
type: Number,
default: 1,
},
pageSize: {
type: Number,
default: 15,
},
totalCount: {
type: Number,
default: 0,
},
onPageChange: {
type: Function,
default: () => {},
},
},
computed: {
isFooterVisible() {
return this.totalCount && !(this.firstIndex > this.totalCount);
},
firstIndex() {
const firstIndex = this.pageSize * (this.currentPage - 1) + 1;
return firstIndex;
},
lastIndex() {
const index = Math.min(this.totalCount, this.pageSize * this.currentPage);
return index;
},
searchButtonClass() {
return this.searchQuery !== '' ? 'show' : '';
},
hasLastPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
},
lastPageButtonClass() {
const className = this.hasLastPage ? 'disabled' : '';
return className;
},
hasFirstPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
},
firstPageButtonClass() {
const className = this.hasFirstPage ? 'disabled' : '';
return className;
},
hasNextPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
},
nextPageButtonClass() {
const className = this.hasNextPage ? 'disabled' : '';
return className;
},
hasPrevPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
},
prevPageButtonClass() {
const className = this.hasPrevPage ? 'disabled' : '';
return className;
},
},
methods: {
onNextPage() {
if (this.hasNextPage) return;
const newPage = this.currentPage + 1;
this.onPageChange(newPage);
},
onPrevPage() {
if (this.hasPrevPage) return;
const newPage = this.currentPage - 1;
this.onPageChange(newPage);
},
onFirstPage() {
if (this.hasFirstPage) return;
const newPage = 1;
this.onPageChange(newPage);
},
onLastPage() {
if (this.hasLastPage) return;
const newPage = Math.ceil(this.totalCount / this.pageSize);
this.onPageChange(newPage);
},
},
};
</script>
<style lang="scss" scoped>
.footer {
height: 60px;
border-top: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-normal);
}
.page-meta {
font-size: var(--font-size-mini);
}
.pagination-button-group {
margin: 0;
.button {
background: transparent;
border-color: var(--color-border);
color: var(--color-body);
margin-bottom: 0;
margin-left: -2px;
font-size: var(--font-size-small);
padding: var(--space-small) var(--space-normal);
&:hover,
&:focus,
&:active {
background: var(--s-200);
color: white;
}
&:first-child {
border-top-left-radius: var(--space-smaller);
border-bottom-left-radius: var(--space-smaller);
}
&:last-child {
border-top-right-radius: var(--space-smaller);
border-bottom-right-radius: var(--space-smaller);
}
&.small {
font-size: var(--font-size-micro);
}
&.disabled {
background: var(--s-200);
border-color: var(--s-200);
color: var(--b-900);
}
&.goto-first,
&.goto-last {
i:last-child {
margin-left: var(--space-minus-smaller);
}
}
}
}
</style>

View File

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