wip: feat: abstract NotificationItem in dedicated component #2681
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
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;
|
||||
konrad
commented
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.
dpschen
commented
Yes, saw that too! Yes, saw that too!
dpschen
commented
I added translations I added translations
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.created {
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--grey-800);
|
||||
}
|
||||
</style>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
konrad
commented
If vueuse has a helper like ours, do we even need ours? If vueuse has a helper like ours, do we even need ours?
dpschen
commented
No, I'm replacing ours piece by piece. 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>
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 ''
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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))
|
||||
}
|
Reference in New Issue
Shouldn't this be
list.index
? (not sure if the route is actually called that)This name is correct.