feat: abstract NotificationItem in dedicated component

This commit is contained in:
Dominik Pschenitschni 2022-11-11 20:28:41 +01:00
parent 72e80f637d
commit def270bd08
Signed by: dpschen
GPG Key ID: B257AC0149F43A77
7 changed files with 314 additions and 254 deletions

View File

@ -0,0 +1,32 @@
<template>
<BaseButton
class="trigger-button"
:aria-pressed="pressed || undefined"
>
<slot />
</BaseButton>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
defineProps<{
pressed?: boolean
}>()
</script>
<style scoped lang="scss">
.trigger-button {
cursor: pointer;
color: var(--grey-400);
transition: $transition;
padding: .5rem;
font-size: 1.25rem;
position: relative;
width: $navbar-icon-width;
}
[aria-pressed] {
color: var(--primary);
}
</style>

View File

@ -26,14 +26,14 @@
<div class="navbar-end">
<update/>
<BaseButton
<NavbarTriggerButton
@click="openQuickActions"
class="trigger-button pr-0"
v-shortcut="'Control+k'"
:pressed="quickActionsActive"
:title="$t('keyboardShortcuts.quickSearch')"
>
<icon icon="search"/>
</BaseButton>
</NavbarTriggerButton>
<notifications/>
<div class="user">
<dropdown class="is-right" ref="usernameDropdown">
@ -101,6 +101,7 @@ import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Notifications from '@/components/notifications/notifications.vue'
import Logo from '@/components/home/Logo.vue'
import NavbarTriggerButton from '@/components/home/NavbarTriggerButton.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
@ -134,6 +135,8 @@ onMounted(async () => {
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
const quickActionsActive = computed(() => baseStore.quickActionsActive)
function openQuickActions() {
baseStore.setQuickActionsActive(true)
}
@ -218,26 +221,11 @@ $hamburger-menu-icon-width: 28px;
}
.navbar {
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
:deep() {
.trigger-button {
cursor: pointer;
color: var(--grey-400);
padding: .5rem;
font-size: 1.25rem;
position: relative;
}
> * > .trigger-button {
width: $navbar-icon-width;
}
}
.user {
display: flex;
align-items: center;
span {
.username {
font-family: $vikunja-font;
}

View File

@ -0,0 +1,165 @@
<template>
<div class="single-notification">
<div class="read-indicator" :class="{'read': notification.readAt !== null}" />
<user
class="user"
v-if="notification.notification.doer"
:user="notification.notification.doer"
:show-username="false"
:avatar-size="16"
/>
<div class="detail">
<div>
<span class="has-text-weight-bold mr-1" v-if="notification.notification.doer">
{{ getDisplayName(notification.notification.doer) }}
</span>
<BaseButton :to="to" @click="emit('markNotificationAsRead')">
{{ notificationText }}
</BaseButton>
</div>
<span class="created" v-tooltip="formatDateLong(notification.created)">
{{ formatDateSince(notification.created) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useAuthStore} from '@/stores/auth'
import {NOTIFICATION_NAMES, type INotification} from '@/modelTypes/INotification'
import BaseButton from '@/components/base/BaseButton.vue'
import User from '@/components/misc/user.vue'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {getTextIdentifier} from '@/models/task'
const props = defineProps<{
notification: INotification
}>()
const emit = defineEmits<{
(e: 'markNotificationAsRead'): void
}>()
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const to = computed(() => {
const to = {
name: '',
params: {},
}
switch (props.notification.name) {
case NOTIFICATION_NAMES.TASK_COMMENT:
case NOTIFICATION_NAMES.TASK_ASSIGNED:
to.name = 'task.detail'
to.params.id = props.notification.notification.task.id
break
case NOTIFICATION_NAMES.TASK_DELETED:
// Nothing
break
case NOTIFICATION_NAMES.LIST_CREATED:
to.name = 'task.index'
to.params.listId = props.notification.notification.list.id
break
case NOTIFICATION_NAMES.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
to.params.id = props.notification.notification.team.id
break
default:
}
return to
})
const notificationText = computed(() => {
const notification = props.notification.notification
let who = ''
switch (props.notification.name) {
case NOTIFICATION_NAMES.TASK_COMMENT:
return `commented on ${getTextIdentifier(notification.task)}`
case NOTIFICATION_NAMES.TASK_ASSIGNED:
if (userInfo.value !== null && userInfo.value.id === notification.assignee.id) {
who = 'you'
} else {
who = `${getDisplayName(notification.assignee)}`
}
return `assigned ${who} to ${getTextIdentifier(notification.task)}`
case NOTIFICATION_NAMES.TASK_DELETED:
return `deleted ${getTextIdentifier(notification.task)}`
case NOTIFICATION_NAMES.LIST_CREATED:
return `created ${notification.list.title}`
case NOTIFICATION_NAMES.TEAM_MEMBER_ADDED:
if (userInfo.value !== null && userInfo.value.id === notification.member.id) {
who = 'you'
} else {
who = `${getDisplayName(notification.member)}`
}
return `added ${who} to the ${notification.team.name} team`
}
return ''
})
</script>
<style scoped lang="scss">
.single-notification {
display: flex;
align-items: center;
padding: 0.25rem 0;
transition: background-color $transition;
&:hover {
background: var(--grey-100);
border-radius: $radius;
}
}
.read-indicator {
width: .35rem;
height: .35rem;
background: var(--primary);
border-radius: 100%;
margin-left: .5rem;
&.read {
background: transparent;
}
}
// FIXME: this deep styling of user should not be in here
.user {
display: inline-flex;
align-items: center;
width: auto;
margin: 0 .5rem;
span {
font-family: $family-sans-serif;
}
.avatar {
height: 16px;
}
img {
margin-right: 0;
}
}
.created {
color: var(--grey-400);
}
a {
color: var(--grey-800);
}
</style>

View File

@ -1,40 +1,28 @@
<template>
<div class="notifications">
<div class="is-flex is-justify-content-center">
<BaseButton @click.stop="showNotifications = !showNotifications" class="trigger-button">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
</BaseButton>
</div>
<!-- FIXME: add label -->
<slot :togglePopup="togglePopup" :hasUnreadNotifications="hasUnreadNotifications">
<NavbarTriggerButton
:pressed="showNotifications"
ref="toggleButton"
@click="togglePopup"
>
<span v-if="hasUnreadNotifications" class="unread-indicator" />
<icon icon="bell"/>
</NavbarTriggerButton>
</slot>
<!-- FIXME: create dedicated dropdown menu -->
<CustomTransition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<span class="head">{{ $t('notification.title') }}</span>
<div
<h3 class="head">{{ $t('notification.title') }}</h3>
<NotificationItem
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="false"
:avatar-size="16"
v-if="n.notification.doer"/>
<div class="detail">
<div>
<span class="has-text-weight-bold mr-1" v-if="n.notification.doer">
{{ getDisplayName(n.notification.doer) }}
</span>
<BaseButton @click="() => to(n, index)()">
{{ n.toText(userInfo) }}
</BaseButton>
</div>
<span class="created" v-tooltip="formatDateLong(n.created)">
{{ formatDateSince(n.created) }}
</span>
</div>
</div>
class="notification-item"
:notification="n"
@markNotificationAsRead="markNotificationAsRead(index, n)"
/>
<p class="nothing" v-if="notifications.length === 0">
{{ $t('notification.none') }}<br/>
<span class="explainer">
@ -48,203 +36,118 @@
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import {useRouter} from 'vue-router'
import {onClickOutside} from '@vueuse/core'
import NotificationService from '@/services/notification'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import User from '@/components/misc/user.vue'
import { NOTIFICATION_NAMES as names, type INotification} from '@/modelTypes/INotification'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {useAuthStore} from '@/stores/auth'
import type {INotification} from '@/modelTypes/INotification'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
import NavbarTriggerButton from '@/components/home/NavbarTriggerButton.vue'
import NotificationItem from '@/components/notifications/NotificationItem.vue'
const authStore = useAuthStore()
const router = useRouter()
const NOTIFICATIONS_PULL_INTERVAL = 10000
const allNotifications = ref<INotification[]>([])
const showNotifications = ref(false)
const popup = ref(null)
const toggleButton = ref(null)
function togglePopup() {
showNotifications.value = !showNotifications.value
}
onClickOutside(
popup,
() => {
if (!showNotifications.value) {
return
}
showNotifications.value = false
},
{ ignore: [toggleButton]},
)
const unreadNotifications = computed(() => {
return notifications.value.filter(n => n.readAt === null).length
})
const notifications = computed(() => {
return allNotifications.value ? allNotifications.value.filter(n => n.name !== '') : []
})
const userInfo = computed(() => authStore.info)
const unreadNotifications = computed(() => {
return notifications.value.filter(n => n.readAt === null).length
})
const hasUnreadNotifications = computed(() => unreadNotifications.value > 0)
let interval: ReturnType<typeof setInterval>
onMounted(() => {
loadNotifications()
document.addEventListener('click', hidePopup)
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
interval = setInterval(loadNotifications, NOTIFICATIONS_PULL_INTERVAL)
})
onUnmounted(() => {
document.removeEventListener('click', hidePopup)
clearInterval(interval)
})
onUnmounted(() => clearInterval(interval))
const notificationService = new NotificationService()
loadNotifications()
async function loadNotifications() {
// We're recreating the notification service here to make sure it uses the latest api user token
const notificationService = new NotificationService()
allNotifications.value = await notificationService.getAll()
}
function hidePopup(e) {
if (showNotifications.value) {
closeWhenClickedOutside(e, popup.value, () => showNotifications.value = false)
}
}
function 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 async () => {
if (to.name !== '') {
router.push(to)
}
n.read = true
const notificationService = new NotificationService()
allNotifications.value[index] = await notificationService.update(n)
}
async function markNotificationAsRead(index: number, notification: INotification) {
allNotifications.value[index] = await notificationService.update({
...notification,
read: true,
})
}
</script>
<style lang="scss" scoped>
.notifications {
width: $navbar-icon-width;
.unread-indicator {
position: absolute;
top: .75rem;
right: 1.15rem;
width: .75rem;
height: .75rem;
.unread-indicator {
position: absolute;
top: .75rem;
right: 1.15rem;
width: .75rem;
height: .75rem;
background: var(--primary);
border-radius: 100%;
border: 2px solid var(--white);
}
background: var(--primary);
border-radius: 100%;
border: 2px solid var(--white);
}
.notifications-list {
position: fixed;
right: 1rem;
margin-top: 1rem;
max-height: 400px;
overflow-y: auto;
.notifications-list {
position: fixed;
right: 1rem;
margin-top: 1rem;
max-height: 400px;
overflow-y: auto;
background: var(--white);
width: 350px;
max-width: calc(100vw - 2rem);
padding: .75rem .25rem;
border-radius: $radius;
box-shadow: var(--shadow-sm);
font-size: .85rem;
background: var(--white);
width: 350px;
max-width: calc(100vw - 2rem);
padding: .75rem .25rem;
border-radius: $radius;
box-shadow: var(--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;
padding: 0.25rem 0;
transition: background-color $transition;
&:hover {
background: var(--grey-100);
border-radius: $radius;
}
.read-indicator {
width: .35rem;
height: .35rem;
background: var(--primary);
border-radius: 100%;
margin-left: .5rem;
&.read {
background: transparent;
}
}
.user {
display: inline-flex;
align-items: center;
width: auto;
margin: 0 .5rem;
span {
font-family: $family-sans-serif;
}
.avatar {
height: 16px;
}
img {
margin-right: 0;
}
}
.created {
color: var(--grey-400);
}
&:last-child {
margin-bottom: .25rem;
}
a {
color: var(--grey-800);
}
}
.nothing {
text-align: center;
padding: 1rem 0;
color: var(--grey-500);
.explainer {
font-size: .75rem;
}
}
@media screen and (max-width: $tablet) {
max-height: calc(100vh - 1rem - #{$navbar-height});
}
}
.head {
font-family: $vikunja-font;
font-size: 1rem;
padding: .5rem;
}
.notification-item:last-child {
margin-bottom: .25rem;
}
.nothing {
text-align: center;
padding: 1rem 0;
color: var(--grey-500);
}
.explainer {
font-size: .75rem;
}
</style>

View File

@ -3,7 +3,7 @@ import type {IUser} from './IUser'
import type {ITask} from './ITask'
import type {ITaskComment} from './ITaskComment'
import type {ITeam} from './ITeam'
import type { IList } from './IList'
import type {IList} from './IList'
export const NOTIFICATION_NAMES = {
'TASK_COMMENT': 'task.comment',

View File

@ -1,13 +1,12 @@
import AbstractModel from './abstractModel'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import UserModel, {getDisplayName} from '@/models/user'
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 {NOTIFICATION_NAMES, type INotification} from '@/modelTypes/INotification'
import type { IUser } from '@/modelTypes/IUser'
export default class NotificationModel extends AbstractModel<INotification> implements INotification {
id = 0
@ -61,35 +60,4 @@ export default class NotificationModel extends AbstractModel<INotification> impl
this.created = new Date(this.created)
this.readAt = parseDateOrNull(this.readAt)
}
toText(user: IUser | null = null) {
let who = ''
switch (this.name) {
case NOTIFICATION_NAMES.TASK_COMMENT:
return `commented on ${this.notification.task.getTextIdentifier()}`
case NOTIFICATION_NAMES.TASK_ASSIGNED:
who = `${getDisplayName(this.notification.assignee)}`
if (user !== null && user.id === this.notification.assignee.id) {
who = 'you'
}
return `assigned ${who} to ${this.notification.task.getTextIdentifier()}`
case NOTIFICATION_NAMES.TASK_DELETED:
return `deleted ${this.notification.task.getTextIdentifier()}`
case NOTIFICATION_NAMES.LIST_CREATED:
return `created ${this.notification.list.title}`
case NOTIFICATION_NAMES.TEAM_MEMBER_ADDED:
who = `${getDisplayName(this.notification.member)}`
if (user !== null && user.id === this.notification.member.id) {
who = 'you'
}
return `added ${who} to the ${this.notification.team.name} team`
}
return ''
}
}

View File

@ -36,6 +36,14 @@ export function getHexColor(hexColor: string): string {
return hexColor
}
export function getTextIdentifier(task: ITask) {
if (task.identifier === '') {
return `#${task.index}`
}
return task.identifier
}
/**
* Parses `repeatAfterSeconds` into a usable js object.
*/
@ -159,11 +167,7 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
}
getTextIdentifier() {
if (this.identifier === '') {
return `#${this.index}`
}
return this.identifier
return getTextIdentifier(this)
}
getHexColor() {