wip: feat: abstract NotificationItem in dedicated component #2681

Closed
dpschen wants to merge 6 commits from dpschen/frontend:feature/abstract-NotificationItem-component into main
7 changed files with 314 additions and 254 deletions
Showing only changes of commit def270bd08 - Show all commits

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'
dpschen marked this conversation as resolved Outdated

Shouldn't this be list.index? (not sure if the route is actually called that)

Shouldn't this be `list.index`? (not sure if the route is actually called that)

This name is correct.

This name is correct.
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)}`

I wonder if we can translate these but that's for another PR.

I wonder if we can translate these but that's for another PR.

Yes, saw that too!

Yes, saw that too!

I added translations

I added translations
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'
dpschen marked this conversation as resolved Outdated

If vueuse has a helper like ours, do we even need ours?

If vueuse has a helper like ours, do we even need ours?

No, I'm replacing ours piece by piece.
Not all vueuse composables are perfect though.
E.g. there is one useTextareaAutosize, but it e.g. doesn't cover the case where the textarea scales with the width of the window. So we should be careful. The onClickoutside seems well written though!

No, I'm replacing ours piece by piece. Not all vueuse composables are perfect though. E.g. there is one `useTextareaAutosize`, but it e.g. doesn't cover the case where the textarea scales with the width of the window. So we should be careful. The `onClickoutside` seems well written though!
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() {