wip: feat: abstract NotificationItem in dedicated component #2681

Closed
dpschen wants to merge 6 commits from dpschen/frontend:feature/abstract-NotificationItem-component into main
12 changed files with 490 additions and 316 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,15 +26,19 @@
<div class="navbar-end">
<update/>
<BaseButton
@click="openQuickActions"
class="trigger-button pr-0"
<NavbarTriggerButton
@click="baseStore.setQuickActionsActive(true)"
v-shortcut="'Control+k'"
:pressed="baseStore.quickActionsActive"
:title="$t('keyboardShortcuts.quickSearch')"
>
<icon icon="search"/>
</BaseButton>
<notifications/>
</NavbarTriggerButton>
<NotificationList
:notifications="notifications"
:hasUnreadNotifications="hasUnreadNotifications"
@mark-notification-as-read="markNotificationAsRead"
/>
<div class="user">
<dropdown class="is-right" ref="usernameDropdown">
<template #trigger="{toggleOpen}">
@ -99,8 +103,9 @@ import Update from '@/components/home/update.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Notifications from '@/components/notifications/notifications.vue'
import NotificationList from '@/components/notifications/NotificationList.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'
@ -109,6 +114,7 @@ import {getListTitle} from '@/helpers/getListTitle'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
import {useNotificationStore} from '@/stores/notifications'
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
@ -134,9 +140,15 @@ onMounted(async () => {
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
function openQuickActions() {
baseStore.setQuickActionsActive(true)
}
const {
notifications,
hasUnreadNotifications,
markNotificationAsRead,
startNotificationPulling,
} = useNotificationStore()
startNotificationPulling()
</script>
<style lang="scss" scoped>
@ -218,26 +230,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,105 @@
<template>
<div class="single-notification">
<div class="read-indicator" :class="{'read': notification.readAt !== null}" />
<user
v-if="notification.notification.doer"
class="user"
:user="notification.notification.doer"
:show-username="false"
:avatar-size="16"
/>
<div class="detail">
<div>
<span v-if="notification.notification.doer" class="has-text-weight-bold mr-1">
{{ getDisplayName(notification.notification.doer) }}
</span>
<BaseButton :to="getNotificationRoute(notification)" @click="emit('markNotificationAsRead')">
{{ getNotificationTitle(notification, userInfo) }}
</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 type {INotification} from '@/modelTypes/INotification'
import BaseButton from '@/components/base/BaseButton.vue'
import User from '@/components/misc/user.vue'
import {useAuthStore} from '@/stores/auth'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {getNotificationRoute, getNotificationTitle} from '@/services/notification'
defineProps<{
notification: INotification
}>()
const emit = defineEmits<{
(e: 'markNotificationAsRead'): void
}>()
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
</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;
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.
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;

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
}
.avatar {
height: 16px;
}
img {
margin-right: 0;
}
}
.created {
color: var(--grey-400);
}
a {
color: var(--grey-800);
}
</style>

View File

@ -0,0 +1,24 @@
<script lang="ts" setup>
import {reactive} from 'vue'
import type {INotification} from '@/modelTypes/INotification'
import NotificationList from './NotificationList.vue'
const state = reactive({
notifications: [] as INotification[],
hasUnreadNotifications: true,
})
function markNotificationAsRead(notificatioItem: INotification) {
console.log(notificatioItem)
}
</script>
<template>
<Story>
<NotificationList
:notifications="state.notifications"
:hasUnreadNotifications="state.hasUnreadNotifications"
@mark-notification-as-read="markNotificationAsRead"
/>
</Story>
</template>

View File

@ -0,0 +1,125 @@
<template>
<div class="notifications">
<NavbarTriggerButton
:pressed="modalIsOpen"
ref="toggleButton"
:aria-label="modalIsOpen ? $t('notification.hideNotifications') : $t('notification.showNotifications')"
@click="togglePopup"
>
<span v-if="hasUnreadNotifications" class="unread-indicator" />
<icon icon="bell"/>
</NavbarTriggerButton>
<!-- FIXME: create dedicated dropdown menu -->
<CustomTransition name="fade">
<div class="notifications-list" v-if="modalIsOpen" ref="popup">
<h3 class="head">{{ $t('notification.title') }}</h3>
<NotificationItem
v-for="n in notifications"
:key="n.id"
class="notification-item"
:notification="n"
@markNotificationAsRead="emit('markNotificationAsRead', n)"
/>
<p class="nothing" v-if="notifications.length === 0">
{{ $t('notification.none') }}<br/>
<span class="explainer">
{{ $t('notification.explainer') }}
</span>
</p>
</div>
</CustomTransition>
</div>
</template>
<script lang="ts" setup>
import {ref} from 'vue'
import {onClickOutside} from '@vueuse/core'
import type {INotification} from '@/modelTypes/INotification'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import NavbarTriggerButton from '@/components/home/NavbarTriggerButton.vue'
import NotificationItem from '@/components/notifications/NotificationItem.vue'
defineProps<{
notifications: INotification[],
hasUnreadNotifications: boolean,
}>()
const emit = defineEmits<{
(e: 'markNotificationAsRead', notification: INotification): void
}>()
const modalIsOpen = ref(false)
const popup = ref(null)
const toggleButton = ref(null)
function togglePopup() {
modalIsOpen.value = !modalIsOpen.value
}
onClickOutside(
popup,
() => {
if (!modalIsOpen.value) {
return
}
modalIsOpen.value = false
},
{ignore: [toggleButton]},
)
</script>
<style lang="scss" scoped>
.unread-indicator {
position: absolute;
top: .75rem;
right: 1.15rem;
width: .75rem;
height: .75rem;
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;
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;
}
.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

@ -1,250 +0,0 @@
<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>
<CustomTransition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<span class="head">{{ $t('notification.title') }}</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="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>
<p class="nothing" v-if="notifications.length === 0">
{{ $t('notification.none') }}<br/>
<span class="explainer">
{{ $t('notification.explainer') }}
</span>
</p>
</div>
</CustomTransition>
</div>
</template>
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import {useRouter} from 'vue-router'
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'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
const authStore = useAuthStore()
const router = useRouter()
const allNotifications = ref<INotification[]>([])
const showNotifications = ref(false)
const popup = ref(null)
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)
let interval: ReturnType<typeof setInterval>
onMounted(() => {
loadNotifications()
document.addEventListener('click', hidePopup)
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
})
onUnmounted(() => {
document.removeEventListener('click', hidePopup)
clearInterval(interval)
})
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)
}
}
</script>
<style lang="scss" scoped>
.notifications {
width: $navbar-icon-width;
.unread-indicator {
position: absolute;
top: .75rem;
right: 1.15rem;
width: .75rem;
height: .75rem;
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;
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;
}
}
}
}
</style>

View File

@ -943,9 +943,30 @@
"contact": "contact us"
},
"notification": {
"showNotifications": "show Notifications",
"hideNotifications": "hide Notifications",
"title": "Notifications",
"none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen."
"explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen.",
"items": {
"taskComment": {
"message": "commented on {taskIdentifier}"
},
"taskAssigned": {
"message": "assigned {user} to {taskIdentifier}",
"userYou": "you"
},
"taskDeleted": {
"message": "deleted {taskIdentifier}"
},
"listCreated": {
"message": "created {listTitle}"
},
"teamMemberAdded": {
"message": "added ${user} to the {teamName} team",
"userYou": "you"
}
}
},
"quickActions": {
"commands": "Commands",

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() {

View File

@ -1,6 +1,80 @@
import type {IUser} from '@/modelTypes/IUser'
import {NOTIFICATION_NAMES, type INotification} from '@/modelTypes/INotification'
import AbstractService from '@/services/abstractService'
import NotificationModel from '@/models/notification'
import type {INotification} from '@/modelTypes/INotification'
import {getTextIdentifier} from '@/models/task'
import {getDisplayName} from '@/models/user'
import {i18n} from '@/i18n'
export function getNotificationTitle(notificationItem: INotification, user: IUser | null) {
const notification = notificationItem.notification
let who: string
switch (notificationItem.name) {
case NOTIFICATION_NAMES.TASK_COMMENT:
return i18n.global.t('notification.items.taskComment.message', { taskIdentifier: getTextIdentifier(notification.task)})
case NOTIFICATION_NAMES.TASK_ASSIGNED:
who = (user !== null && user.id === notification.assignee.id)
? i18n.global.t('notification.items.taskAssigned.userYou')
: `${getDisplayName(notification.assignee)}`
return i18n.global.t('notification.items.taskAssigned.message', {
user: who,
taskIdentifier: getTextIdentifier(notification.task),
})
case NOTIFICATION_NAMES.TASK_DELETED:
// TODO: add user to title
return i18n.global.t('notification.items.taskDeleted.message', {
taskIdentifier: getTextIdentifier(notification.task),
})
case NOTIFICATION_NAMES.LIST_CREATED:
return i18n.global.t('notification.items.listCreated.message', {
listTitle: notification.list.title,
})
case NOTIFICATION_NAMES.TEAM_MEMBER_ADDED:
who = (user !== null && user.id === notification.member.id)
? i18n.global.t('notification.items.teamMemberAdded.userYou')
: `${getDisplayName(notification.member)}`
return i18n.global.t('notification.items.teamMemberAdded.message', {
user: who,
teamName: notification.team.name,
})
}
}
export function getNotificationRoute(notificationItem: INotification) {
const to = {
name: '',
params: {},
}
switch (notificationItem.name) {
case NOTIFICATION_NAMES.TASK_COMMENT:
case NOTIFICATION_NAMES.TASK_ASSIGNED:
to.name = 'task.detail'
to.params.id = notificationItem.notification.task.id
break
case NOTIFICATION_NAMES.TASK_DELETED:
// Nothing
break
case NOTIFICATION_NAMES.LIST_CREATED:
to.name = 'task.index'
to.params.listId = notificationItem.notification.list.id
break
case NOTIFICATION_NAMES.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
to.params.id = notificationItem.notification.team.id
break
default:
return undefined
}
return to
}
export default class NotificationService extends AbstractService<INotification> {
constructor() {

View File

@ -0,0 +1,74 @@
import {acceptHMRUpdate, defineStore} from 'pinia'
import {computed, ref} from 'vue'
import {tryOnUnmounted} from '@vueuse/core'
import type {INotification} from '@/modelTypes/INotification'
import NotificationService from '@/services/notification'
import {findIndexById} from '@/helpers/utils'
const NOTIFICATIONS_PULL_INTERVAL = 10000
export const useNotificationStore = defineStore('notification', () => {
const allNotifications = ref<INotification[]>([])
const notifications = computed(() => {
return allNotifications.value ? allNotifications.value.filter(n => n.name !== '') : []
})
const unreadNotifications = computed(() => {
return notifications.value.filter(n => n.readAt === null).length
})
const hasUnreadNotifications = computed(() => unreadNotifications.value > 0)
let timeout: ReturnType<typeof setTimeout>
const notificationService = new NotificationService()
async function loadNotifications() {
allNotifications.value = await notificationService.getAll() as INotification[]
}
async function pullNotifications() {
await loadNotifications()
timeout = setTimeout(pullNotifications, NOTIFICATIONS_PULL_INTERVAL)
}
function startNotificationPulling() {
pullNotifications()
return stopNotificationPulling
}
function stopNotificationPulling() {
clearTimeout(timeout)
}
async function markNotificationAsRead(notificationItem: INotification): Promise<INotification | undefined> {
const index = findIndexById(allNotifications.value, notificationItem.id)
if (index === -1) {
return
}
allNotifications.value[index] = {
...notificationItem,
read: true,
}
await notificationService.update(allNotifications.value[index])
}
tryOnUnmounted(stopNotificationPulling)
return {
notifications,
unreadNotifications,
hasUnreadNotifications,
startNotificationPulling,
stopNotificationPulling,
markNotificationAsRead,
}
})
// support hot reloading
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useNotificationStore, import.meta.hot))
}