Add notifications overview (#414)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#414
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2021-02-21 15:13:58 +00:00
parent 971d3cc358
commit c076298cf0
11 changed files with 385 additions and 17 deletions

View File

@ -37,6 +37,7 @@
<div class="navbar-end">
<update/>
<notifications/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar"/>
<dropdown class="is-right">
@ -86,10 +87,12 @@ import Rights from '@/models/rights.json'
import Update from '@/components/home/update'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
import Dropdown from '@/components/misc/dropdown'
import Notifications from '@/components/notifications/notifications'
export default {
name: 'topNavigation',
components: {
Notifications,
Dropdown,
ListSettingsDropdown,
Update,

View File

@ -0,0 +1,135 @@
<template>
<div class="notifications">
<a @click.stop="showNotifications = !showNotifications" class="trigger">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
</a>
<transition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<span class="head">Notifications</span>
<div
v-for="(n, index) in notifications"
:key="n.id"
class="single-notification"
>
<div class="read-indicator" :class="{'read': n.readAt !== null}"></div>
<user
:user="n.notification.doer"
:show-username="true"
:avatar-size="16"
v-if="n.notification.doer"/>
<span class="detail">
<a @click="() => to(n, index)()">
{{ n.toText(userInfo) }}
</a>
<span class="created" v-tooltip="formatDate(n.created)">
{{ formatDateSince(n.created) }}
</span>
</span>
</div>
<p class="nothing" v-if="notifications.length === 0">
You don't have any notifications. Have a nice day!<br/>
<span class="explainer">
Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen.
</span>
</p>
</div>
</transition>
</div>
</template>
<script>
import NotificationService from '@/services/notification'
import User from '@/components/misc/user'
import names from '@/models/notificationNames.json'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {mapState} from 'vuex'
export default {
name: 'notifications',
components: {User},
data() {
return {
notificationService: NotificationService,
notifications: [],
showNotifications: false,
interval: null,
}
},
created() {
this.notificationService = new NotificationService()
},
mounted() {
this.loadNotifications()
document.addEventListener('click', this.hidePopup)
this.interval = setInterval(this.loadNotifications, 10000)
},
beforeDestroy() {
document.removeEventListener('click', this.hidePopup)
clearInterval(this.interval)
},
computed: {
unreadNotifications() {
return this.notifications.filter(n => n.readAt === null).length
},
...mapState({
userInfo: state => state.auth.info,
}),
},
methods: {
hidePopup(e) {
if (this.showNotifications) {
closeWhenClickedOutside(e, this.$refs.popup, () => this.showNotifications = false)
}
},
loadNotifications() {
this.notificationService.getAll()
.then(r => {
this.$set(this, 'notifications', r)
})
.catch(e => {
this.error(e, this)
})
},
to(n, index) {
const to = {
name: '',
params: {},
}
switch (n.name) {
case names.TASK_COMMENT:
case names.TASK_ASSIGNED:
to.name = 'task.detail'
to.params.id = n.notification.task.id
break
case names.TASK_DELETED:
// Nothing
break
case names.LIST_CREATED:
to.name = 'task.index'
to.params.listId = n.notification.list.id
break
case names.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
to.params.id = n.notification.team.id
break
}
return () => {
if (to.name !== '') {
this.$router.push(to)
}
n.read = true
this.notificationService.update(n)
.then(r => {
this.$set(this.notifications, index, r)
})
.catch(e => this.error(e, this))
}
},
},
}
</script>

View File

@ -1,10 +1,7 @@
<template>
<div class="heading">
<h1 class="title task-id" v-if="task.identifier === ''">
#{{ task.index }}
</h1>
<h1 class="title task-id" v-else>
{{ task.identifier }}
<h1 class="title task-id">
{{ task.getTextIdentifier() }}
</h1>
<div class="is-done" v-if="task.done">Done</div>
<h1

View File

@ -0,0 +1,6 @@
export const parseDateOrNull = date => {
if (date && !date.startsWith('0001')) {
return new Date(date)
}
return null
}

View File

@ -0,0 +1,84 @@
import AbstractModel from '@/models/abstractModel'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import UserModel from '@/models/user'
import TaskModel from '@/models/task'
import TaskCommentModel from '@/models/taskComment'
import ListModel from '@/models/list'
import TeamModel from '@/models/team'
import names from './notificationNames.json'
export default class NotificationModel extends AbstractModel {
constructor(data) {
super(data)
switch (this.name) {
case names.TASK_COMMENT:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.task = new TaskModel(this.notification.task)
this.notification.comment = new TaskCommentModel(this.notification.comment)
break
case names.TASK_ASSIGNED:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.task = new TaskModel(this.notification.task)
this.notification.assignee = new UserModel(this.notification.assignee)
break
case names.TASK_DELETED:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.task = new TaskModel(this.notification.task)
break
case names.LIST_CREATED:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.list = new ListModel(this.notification.list)
break
case names.TEAM_MEMBER_ADDED:
this.notification.doer = new UserModel(this.notification.doer)
this.notification.member = new UserModel(this.notification.member)
this.notification.team = new TeamModel(this.notification.team)
break
}
this.created = new Date(this.created)
this.readAt = parseDateOrNull(this.readAt)
}
defaults() {
return {
id: 0,
name: '',
notification: null,
read: false,
readAt: null,
}
}
toText(user = null) {
let who = ''
switch (this.name) {
case names.TASK_COMMENT:
return `commented on ${this.notification.task.getTextIdentifier()}`
case names.TASK_ASSIGNED:
who = `${this.notification.assignee.getDisplayName()}`
if (user !== null && user.id === this.notification.assignee.id) {
who = 'you'
}
return `assigned ${who} to ${this.notification.task.getTextIdentifier()}`
case names.TASK_DELETED:
return `deleted ${this.notification.task.getTextIdentifier()}`
case names.LIST_CREATED:
return `created ${this.notification.list.title}`
case names.TEAM_MEMBER_ADDED:
who = `${this.notification.member.getDisplayName()}`
if (user !== null && user.id === this.notification.memeber.id) {
who = 'you'
}
return `added ${who} to the ${this.notification.team.title} team`
}
return ''
}
}

View File

@ -0,0 +1,7 @@
{
"TASK_COMMENT": "task.comment",
"TASK_ASSIGNED": "task.assigned",
"TASK_DELETED": "task.deleted",
"LIST_CREATED": "list.created",
"TEAM_MEMBER_ADDED": "team.member.added"
}

View File

@ -3,13 +3,7 @@ import UserModel from './user'
import LabelModel from './label'
import AttachmentModel from './attachment'
import SubscriptionModel from '@/models/subscription'
const parseDate = date => {
if (date && !date.startsWith('0001')) {
return new Date(date)
}
return null
}
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
export default class TaskModel extends AbstractModel {
@ -22,10 +16,10 @@ export default class TaskModel extends AbstractModel {
this.listId = Number(this.listId)
// Make date objects from timestamps
this.dueDate = parseDate(this.dueDate)
this.startDate = parseDate(this.startDate)
this.endDate = parseDate(this.endDate)
this.doneAt = parseDate(this.doneAt)
this.dueDate = parseDateOrNull(this.dueDate)
this.startDate = parseDateOrNull(this.startDate)
this.endDate = parseDateOrNull(this.endDate)
this.doneAt = parseDateOrNull(this.doneAt)
// Cancel all scheduled notifications for this task to be sure to only have available notifications
this.cancelScheduledNotifications()
@ -76,7 +70,7 @@ export default class TaskModel extends AbstractModel {
this.identifier = ''
}
if(typeof this.subscription !== 'undefined' && this.subscription !== null) {
if (typeof this.subscription !== 'undefined' && this.subscription !== null) {
this.subscription = new SubscriptionModel(this.subscription)
}
@ -119,6 +113,14 @@ export default class TaskModel extends AbstractModel {
}
}
getTextIdentifier() {
if(this.identifier === '') {
return `#${this.index}`
}
return this.identifier
}
/////////////////
// Helper functions
///////////////

View File

@ -0,0 +1,22 @@
import AbstractService from '@/services/abstractService'
import {formatISO} from 'date-fns'
import NotificationModel from '@/models/notification'
export default class NotificationService extends AbstractService {
constructor() {
super({
getAll: '/notifications',
update: '/notifications/{id}',
})
}
modelFactory(data) {
return new NotificationModel(data)
}
beforeUpdate(model) {
model.created = formatISO(new Date(model.created))
model.readAt = formatISO(new Date(model.readAt))
return model
}
}

View File

@ -22,3 +22,4 @@
@import 'keyboard-shortcuts';
@import 'api-config';
@import 'datepicker';
@import 'notifications';

View File

@ -0,0 +1,110 @@
.notifications {
width: 50px;
.trigger {
cursor: pointer;
color: $grey-400;
padding: 1rem;
font-size: 1.25rem;
position: relative;
.unread-indicator {
position: absolute;
top: 1rem;
right: .75rem;
width: .75rem;
height: .75rem;
background: $primary;
border-radius: 100%;
border: 2px solid $white;
}
}
.notifications-list {
position: fixed;
right: 1rem;
margin-top: 1rem;
max-height: 400px;
overflow-y: auto;
background: $white;
width: 350px;
max-width: calc(100vw - 2rem);
padding: .75rem .25rem;
border-radius: $radius;
box-shadow: $shadow-sm;
font-size: .85rem;
@media screen and (max-width: $tablet) {
max-height: calc(100vh - 1rem - #{$navbar-height});
}
.head {
font-family: $vikunja-font;
font-size: 1rem;
padding: .5rem;
}
.single-notification {
display: flex;
align-items: center;
transition: background-color $transition;
&:hover {
background: $grey-100;
border-radius: $radius;
}
.read-indicator {
width: .35rem;
height: .35rem;
background: $primary;
border-radius: 100%;
margin-left: .5rem;
&.read {
background: transparent;
}
}
.user {
display: flex;
align-items: center;
width: auto;
margin-right: .25rem;
span {
font-family: $family-sans-serif;
}
.avatar {
height: 16px;
}
}
.detail .created {
color: $grey-400;
}
&:last-child {
margin-bottom: .25rem;
}
a {
color: $grey-800;
}
}
.nothing {
text-align: center;
padding: 1rem 0;
color: $grey-500;
.explainer {
font-size: .75rem;
}
}
}
}

View File

@ -29,6 +29,7 @@
.navbar-end {
margin-left: 0;
align-items: center;
display: flex;
}
@media screen and (max-width: $desktop) {