Add notifications overview (#414)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/414 Co-authored-by: konrad <konrad@kola-entertainments.de> Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
parent
971d3cc358
commit
c076298cf0
11 changed files with 385 additions and 17 deletions
|
@ -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,
|
||||
|
|
135
src/components/notifications/notifications.vue
Normal file
135
src/components/notifications/notifications.vue
Normal 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>
|
|
@ -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
|
||||
|
|
6
src/helpers/parseDateOrNull.js
Normal file
6
src/helpers/parseDateOrNull.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const parseDateOrNull = date => {
|
||||
if (date && !date.startsWith('0001')) {
|
||||
return new Date(date)
|
||||
}
|
||||
return null
|
||||
}
|
84
src/models/notification.js
Normal file
84
src/models/notification.js
Normal 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 ''
|
||||
}
|
||||
}
|
7
src/models/notificationNames.json
Normal file
7
src/models/notificationNames.json
Normal 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"
|
||||
}
|
|
@ -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()
|
||||
|
@ -119,6 +113,14 @@ export default class TaskModel extends AbstractModel {
|
|||
}
|
||||
}
|
||||
|
||||
getTextIdentifier() {
|
||||
if(this.identifier === '') {
|
||||
return `#${this.index}`
|
||||
}
|
||||
|
||||
return this.identifier
|
||||
}
|
||||
|
||||
/////////////////
|
||||
// Helper functions
|
||||
///////////////
|
||||
|
|
22
src/services/notification.js
Normal file
22
src/services/notification.js
Normal 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
|
||||
}
|
||||
}
|
|
@ -22,3 +22,4 @@
|
|||
@import 'keyboard-shortcuts';
|
||||
@import 'api-config';
|
||||
@import 'datepicker';
|
||||
@import 'notifications';
|
||||
|
|
110
src/styles/components/notifications.scss
Normal file
110
src/styles/components/notifications.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -29,6 +29,7 @@
|
|||
.navbar-end {
|
||||
margin-left: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
|
|
Loading…
Reference in a new issue