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">
|
<div class="navbar-end">
|
||||||
<update/>
|
<update/>
|
||||||
|
<notifications/>
|
||||||
<div class="user">
|
<div class="user">
|
||||||
<img :src="userAvatar" alt="" class="avatar"/>
|
<img :src="userAvatar" alt="" class="avatar"/>
|
||||||
<dropdown class="is-right">
|
<dropdown class="is-right">
|
||||||
|
@ -86,10 +87,12 @@ import Rights from '@/models/rights.json'
|
||||||
import Update from '@/components/home/update'
|
import Update from '@/components/home/update'
|
||||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
|
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
|
||||||
import Dropdown from '@/components/misc/dropdown'
|
import Dropdown from '@/components/misc/dropdown'
|
||||||
|
import Notifications from '@/components/notifications/notifications'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'topNavigation',
|
name: 'topNavigation',
|
||||||
components: {
|
components: {
|
||||||
|
Notifications,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
ListSettingsDropdown,
|
ListSettingsDropdown,
|
||||||
Update,
|
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>
|
<template>
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
<h1 class="title task-id" v-if="task.identifier === ''">
|
<h1 class="title task-id">
|
||||||
#{{ task.index }}
|
{{ task.getTextIdentifier() }}
|
||||||
</h1>
|
|
||||||
<h1 class="title task-id" v-else>
|
|
||||||
{{ task.identifier }}
|
|
||||||
</h1>
|
</h1>
|
||||||
<div class="is-done" v-if="task.done">Done</div>
|
<div class="is-done" v-if="task.done">Done</div>
|
||||||
<h1
|
<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 LabelModel from './label'
|
||||||
import AttachmentModel from './attachment'
|
import AttachmentModel from './attachment'
|
||||||
import SubscriptionModel from '@/models/subscription'
|
import SubscriptionModel from '@/models/subscription'
|
||||||
|
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
|
||||||
const parseDate = date => {
|
|
||||||
if (date && !date.startsWith('0001')) {
|
|
||||||
return new Date(date)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class TaskModel extends AbstractModel {
|
export default class TaskModel extends AbstractModel {
|
||||||
|
|
||||||
|
@ -22,10 +16,10 @@ export default class TaskModel extends AbstractModel {
|
||||||
this.listId = Number(this.listId)
|
this.listId = Number(this.listId)
|
||||||
|
|
||||||
// Make date objects from timestamps
|
// Make date objects from timestamps
|
||||||
this.dueDate = parseDate(this.dueDate)
|
this.dueDate = parseDateOrNull(this.dueDate)
|
||||||
this.startDate = parseDate(this.startDate)
|
this.startDate = parseDateOrNull(this.startDate)
|
||||||
this.endDate = parseDate(this.endDate)
|
this.endDate = parseDateOrNull(this.endDate)
|
||||||
this.doneAt = parseDate(this.doneAt)
|
this.doneAt = parseDateOrNull(this.doneAt)
|
||||||
|
|
||||||
// Cancel all scheduled notifications for this task to be sure to only have available notifications
|
// Cancel all scheduled notifications for this task to be sure to only have available notifications
|
||||||
this.cancelScheduledNotifications()
|
this.cancelScheduledNotifications()
|
||||||
|
@ -76,7 +70,7 @@ export default class TaskModel extends AbstractModel {
|
||||||
this.identifier = ''
|
this.identifier = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if(typeof this.subscription !== 'undefined' && this.subscription !== null) {
|
if (typeof this.subscription !== 'undefined' && this.subscription !== null) {
|
||||||
this.subscription = new SubscriptionModel(this.subscription)
|
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
|
// 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 'keyboard-shortcuts';
|
||||||
@import 'api-config';
|
@import 'api-config';
|
||||||
@import 'datepicker';
|
@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 {
|
.navbar-end {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $desktop) {
|
@media screen and (max-width: $desktop) {
|
||||||
|
|
Loading…
Reference in a new issue