feat(#36): Notification rework with single and all comments mark as read
This commit is contained in:
@@ -1,61 +1,98 @@
|
||||
<template>
|
||||
<USlideover v-model:open="isOpen" :title="$t('notifications.title')">
|
||||
<template #body>
|
||||
<!-- Action buttons at the top -->
|
||||
<div v-if="notifications.length > 0" class="flex gap-2 mb-4 -mt-2">
|
||||
<UButton
|
||||
:label="$t('notifications.markAllRead')"
|
||||
icon="i-lucide-check-check"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!hasUnreadNotifications"
|
||||
@click="onMarkAllAsRead"
|
||||
/>
|
||||
<UButton
|
||||
:label="$t('notifications.deleteAll')"
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="onDeleteAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="notifications.length === 0" class="text-center py-8 text-muted">
|
||||
<UIcon name="i-heroicons-bell-slash" class="h-8 w-8 mx-auto mb-2" />
|
||||
<p>{{ $t('notifications.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:to="notification.clickTarget"
|
||||
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3"
|
||||
@click="onNotificationClick(notification)"
|
||||
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3 group"
|
||||
>
|
||||
<UChip
|
||||
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'primary'"
|
||||
:show="!notification.isRead"
|
||||
inset
|
||||
<NuxtLink
|
||||
:to="notification.clickTarget"
|
||||
class="flex items-center gap-3 flex-1"
|
||||
@click="onNotificationClick(notification)"
|
||||
>
|
||||
<UIcon
|
||||
:name="
|
||||
notification.type === 'ERROR'
|
||||
? 'i-heroicons-x-circle'
|
||||
: notification.type === 'WARNING'
|
||||
? 'i-heroicons-exclamation-triangle'
|
||||
: 'i-heroicons-information-circle'
|
||||
"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</UChip>
|
||||
|
||||
<div class="text-sm flex-1">
|
||||
<p class="flex items-center justify-between">
|
||||
<span class="text-highlighted font-medium">{{ notification.title }}</span>
|
||||
|
||||
<time
|
||||
:datetime="notification.createdAt.toISOString()"
|
||||
class="text-muted text-xs"
|
||||
v-text="formatTimeAgo(notification.createdAt)"
|
||||
<UChip
|
||||
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'primary'"
|
||||
:show="!notification.isRead"
|
||||
inset
|
||||
>
|
||||
<UIcon
|
||||
:name="
|
||||
notification.type === 'ERROR'
|
||||
? 'i-heroicons-x-circle'
|
||||
: notification.type === 'WARNING'
|
||||
? 'i-heroicons-exclamation-triangle'
|
||||
: 'i-heroicons-information-circle'
|
||||
"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
</p>
|
||||
</UChip>
|
||||
|
||||
<p class="text-dimmed">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
<div class="text-sm flex-1">
|
||||
<p class="flex items-center justify-between">
|
||||
<span class="text-highlighted font-medium">{{ notification.title }}</span>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<UBadge
|
||||
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'info'"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ notification.type }}
|
||||
</UBadge>
|
||||
<time
|
||||
:datetime="notification.createdAt.toISOString()"
|
||||
class="text-muted text-xs"
|
||||
v-text="formatTimeAgo(notification.createdAt)"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<p class="text-dimmed">
|
||||
{{ notification.message }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<UBadge
|
||||
:color="notification.type === 'ERROR' ? 'error' : notification.type === 'WARNING' ? 'warning' : 'info'"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ notification.type }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Delete button for individual notification -->
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
square
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
:aria-label="$t('notifications.delete')"
|
||||
@click.stop.prevent="onDeleteNotification(notification.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -63,6 +100,9 @@
|
||||
<script setup lang="ts">
|
||||
import { formatTimeAgo } from '@vueuse/core'
|
||||
import type { NotificationDto } from '~~/.api-client'
|
||||
import { useNotificationStore } from '~~/stores/useNotificationStore'
|
||||
|
||||
const { t: $t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
@@ -77,16 +117,31 @@ const isOpen = computed({
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const { notifications, fetchNotifications, handleNotificationClick } = useNotification()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { notifications } = storeToRefs(notificationStore)
|
||||
|
||||
const hasUnreadNotifications = computed(() => notifications.value.some((n) => !n.isRead))
|
||||
|
||||
watch(isOpen, async (newValue) => {
|
||||
if (newValue) {
|
||||
await fetchNotifications()
|
||||
await notificationStore.fetchNotifications()
|
||||
}
|
||||
})
|
||||
|
||||
function onNotificationClick(notification: NotificationDto) {
|
||||
handleNotificationClick(notification)
|
||||
notificationStore.handleNotificationClick(notification)
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function onMarkAllAsRead() {
|
||||
await notificationStore.markAllAsRead()
|
||||
}
|
||||
|
||||
async function onDeleteAll() {
|
||||
await notificationStore.deleteAllNotifications()
|
||||
}
|
||||
|
||||
async function onDeleteNotification(notificationId: string) {
|
||||
await notificationStore.deleteSingleNotification(notificationId)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,6 @@ export { useApplicationFormTemplate } from './applicationFormTemplate/useApplica
|
||||
export { useApplicationForm } from './applicationForm/useApplicationForm'
|
||||
export { useApplicationFormVersion } from './applicationFormVersion/useApplicationFormVersion'
|
||||
export { useApplicationFormVersionApi } from './applicationFormVersion/useApplicationFormVersionApi'
|
||||
export { useNotification } from './notification/useNotification'
|
||||
export { useNotificationApi } from './notification/useNotificationApi'
|
||||
export { useUser } from './user/useUser'
|
||||
export { useUserApi } from './user/useUserApi'
|
||||
|
||||
@@ -44,8 +44,12 @@ export function useNotificationApi() {
|
||||
return notificationApiClient.markNotificationAsRead({ id, organizationId })
|
||||
}
|
||||
|
||||
async function clearAllNotifications(organizationId: string): Promise<void> {
|
||||
return notificationApiClient.clearAllNotifications({ organizationId })
|
||||
async function clearAllNotifications(): Promise<void> {
|
||||
return notificationApiClient.clearAllNotifications()
|
||||
}
|
||||
|
||||
async function deleteNotification(id: string): Promise<void> {
|
||||
return notificationApiClient.deleteNotification({ id })
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -55,6 +59,7 @@ export function useNotificationApi() {
|
||||
getUnreadNotificationCount,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead,
|
||||
clearAllNotifications
|
||||
clearAllNotifications,
|
||||
deleteNotification
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,17 @@ export function useUser() {
|
||||
userId: string,
|
||||
email: string | null,
|
||||
emailOnFormCreated: boolean,
|
||||
emailOnFormSubmitted: boolean
|
||||
emailOnFormSubmitted: boolean,
|
||||
emailOnFormUpdated: boolean,
|
||||
emailOnCommentAdded: boolean
|
||||
): Promise<UserDto> {
|
||||
const updateDto: UpdateEmailPreferencesDto = { email, emailOnFormCreated, emailOnFormSubmitted }
|
||||
const updateDto: UpdateEmailPreferencesDto = {
|
||||
email,
|
||||
emailOnFormCreated,
|
||||
emailOnFormSubmitted,
|
||||
emailOnFormUpdated,
|
||||
emailOnCommentAdded
|
||||
}
|
||||
|
||||
return await userApi.updateEmailPreferences(userId, updateDto)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNotificationStore } from '~~/stores/useNotificationStore'
|
||||
|
||||
const { t: $t } = useI18n()
|
||||
|
||||
const links = [
|
||||
@@ -60,16 +62,17 @@ const open = ref(false)
|
||||
const logger = useLogger().withTag('layout')
|
||||
|
||||
const isNotificationsSlideoverOpen = ref(false)
|
||||
const { unreadCount, fetchUnreadCount, startPeriodicRefresh } = useNotification()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { hasUnread } = storeToRefs(notificationStore)
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchUnreadCount()
|
||||
startPeriodicRefresh()
|
||||
await notificationStore.fetchUnreadCount()
|
||||
notificationStore.startPeriodicRefresh()
|
||||
})
|
||||
|
||||
provide('notificationState', {
|
||||
isNotificationsSlideoverOpen,
|
||||
unreadCount
|
||||
hasUnread
|
||||
})
|
||||
|
||||
async function copyAccessTokenToClipboard() {
|
||||
|
||||
@@ -22,9 +22,8 @@
|
||||
|
||||
<UTooltip :text="$t('notifications.tooltip')" :shortcuts="['N']">
|
||||
<UButton color="neutral" variant="ghost" square @click="isNotificationsSlideoverOpen = true">
|
||||
<UChip :show="unreadCount > 0" color="error" inset>
|
||||
<UChip :show="hasUnread" color="error" inset>
|
||||
<UIcon name="i-lucide-bell" class="size-5 shrink-0" />
|
||||
<span v-if="unreadCount > 0" class="ml-1 text-xs">{{ unreadCount }}</span>
|
||||
</UChip>
|
||||
</UButton>
|
||||
</UTooltip>
|
||||
@@ -144,9 +143,9 @@ const { organizations, selectedOrganization } = storeToRefs(userStore)
|
||||
const { t: $t } = useI18n()
|
||||
|
||||
// Inject notification state from layout
|
||||
const { isNotificationsSlideoverOpen, unreadCount } = inject('notificationState', {
|
||||
const { isNotificationsSlideoverOpen, hasUnread } = inject('notificationState', {
|
||||
isNotificationsSlideoverOpen: ref(false),
|
||||
unreadCount: ref(0)
|
||||
hasUnread: ref(false)
|
||||
})
|
||||
|
||||
const { data } = await useAsyncData<PagedApplicationFormDto>(
|
||||
|
||||
@@ -94,6 +94,8 @@
|
||||
<div class="space-y-3">
|
||||
<UCheckbox v-model="emailOnFormCreated" :label="$t('settings.email.onFormCreated')" />
|
||||
<UCheckbox v-model="emailOnFormSubmitted" :label="$t('settings.email.onFormSubmitted')" />
|
||||
<UCheckbox v-model="emailOnFormUpdated" :label="$t('settings.email.onFormUpdated')" />
|
||||
<UCheckbox v-model="emailOnCommentAdded" :label="$t('settings.email.onCommentAdded')" />
|
||||
</div>
|
||||
|
||||
<UButton :label="$t('common.save')" color="primary" :loading="isSaving" @click="saveEmailPreferences" />
|
||||
@@ -143,6 +145,8 @@ const colors = [
|
||||
const emailAddress = ref('')
|
||||
const emailOnFormCreated = ref(true)
|
||||
const emailOnFormSubmitted = ref(true)
|
||||
const emailOnFormUpdated = ref(true)
|
||||
const emailOnCommentAdded = ref(true)
|
||||
const isSaving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -152,6 +156,8 @@ onMounted(async () => {
|
||||
emailAddress.value = userData.email || ''
|
||||
emailOnFormCreated.value = userData.emailOnFormCreated ?? true
|
||||
emailOnFormSubmitted.value = userData.emailOnFormSubmitted ?? true
|
||||
emailOnFormUpdated.value = userData.emailOnFormUpdated ?? true
|
||||
emailOnCommentAdded.value = userData.emailOnCommentAdded ?? true
|
||||
} catch (error) {
|
||||
logger.error('Failed to load user email preferences:', error)
|
||||
}
|
||||
@@ -167,7 +173,9 @@ async function saveEmailPreferences() {
|
||||
userStore.user.keycloakId,
|
||||
emailAddress.value || null,
|
||||
emailOnFormCreated.value,
|
||||
emailOnFormSubmitted.value
|
||||
emailOnFormSubmitted.value,
|
||||
emailOnFormUpdated.value,
|
||||
emailOnCommentAdded.value
|
||||
)
|
||||
|
||||
toast.add({
|
||||
|
||||
@@ -137,7 +137,10 @@
|
||||
"title": "Benachrichtigungen",
|
||||
"empty": "Keine Benachrichtigungen",
|
||||
"unreadCount": "{count} ungelesen",
|
||||
"tooltip": "Benachrichtigungen"
|
||||
"tooltip": "Benachrichtigungen",
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"deleteAll": "Alle löschen",
|
||||
"delete": "Benachrichtigung löschen"
|
||||
},
|
||||
"administration": {
|
||||
"title": "Administration",
|
||||
@@ -237,6 +240,8 @@
|
||||
"emailAddress": "E-Mail-Adresse",
|
||||
"onFormCreated": "Bei Erstellung eines Antrags",
|
||||
"onFormSubmitted": "Bei Einreichung eines Antrags",
|
||||
"onFormUpdated": "Wenn jemand meinen Antrag bearbeitet",
|
||||
"onCommentAdded": "Wenn jemand meinen Antrag kommentiert",
|
||||
"saved": "Einstellungen gespeichert"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,10 @@
|
||||
"title": "Notifications",
|
||||
"empty": "No notifications",
|
||||
"unreadCount": "{count} unread",
|
||||
"tooltip": "Notifications"
|
||||
"tooltip": "Notifications",
|
||||
"markAllRead": "Mark all as read",
|
||||
"deleteAll": "Delete all",
|
||||
"delete": "Delete notification"
|
||||
},
|
||||
"administration": {
|
||||
"title": "Administration",
|
||||
@@ -237,6 +240,8 @@
|
||||
"emailAddress": "Email Address",
|
||||
"onFormCreated": "When an application form is created",
|
||||
"onFormSubmitted": "When an application form is submitted",
|
||||
"onFormUpdated": "When someone edits my application form",
|
||||
"onCommentAdded": "When someone comments on my application form",
|
||||
"saved": "Settings saved"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { NotificationDto } from '~~/.api-client'
|
||||
import { useUserStore } from '~~/stores/useUserStore'
|
||||
import { useNotificationApi } from '~/composables/notification/useNotificationApi'
|
||||
import { useLogger } from '~/composables/useLogger'
|
||||
import { useUserStore } from './useUserStore'
|
||||
|
||||
export const useNotification = () => {
|
||||
const logger = useLogger().withTag('notification')
|
||||
export const useNotificationStore = defineStore('Notification', () => {
|
||||
const logger = useLogger().withTag('notificationStore')
|
||||
const userStore = useUserStore()
|
||||
const { user } = useUserSession()
|
||||
|
||||
const {
|
||||
getNotifications,
|
||||
getUnreadNotifications,
|
||||
getUnreadNotificationCount,
|
||||
markAllNotificationsAsRead,
|
||||
markNotificationAsRead
|
||||
markNotificationAsRead,
|
||||
clearAllNotifications,
|
||||
deleteNotification
|
||||
} = useNotificationApi()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const organizationId = computed(() => userStore.selectedOrganization?.id)
|
||||
const { user } = useUserSession()
|
||||
const userId = computed(() => user.value?.keycloakId)
|
||||
|
||||
// State
|
||||
const notifications = ref<NotificationDto[]>([])
|
||||
const unreadNotifications = ref<NotificationDto[]>([])
|
||||
const unreadCount = ref<number>(0)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const fetchNotifications = async (page: number = 0, size: number = 20) => {
|
||||
// Getters
|
||||
const hasUnread = computed(() => unreadCount.value > 0)
|
||||
const organizationId = computed(() => userStore.selectedOrganization?.id)
|
||||
const userId = computed(() => user.value?.keycloakId)
|
||||
|
||||
// Actions
|
||||
async function fetchNotifications(page: number = 0, size: number = 20) {
|
||||
if (!organizationId.value) {
|
||||
logger.warn('No organization selected')
|
||||
return
|
||||
@@ -40,7 +49,7 @@ export const useNotification = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUnreadNotifications = async () => {
|
||||
async function fetchUnreadNotifications() {
|
||||
if (!organizationId.value) {
|
||||
logger.warn('No organization selected')
|
||||
return
|
||||
@@ -55,7 +64,7 @@ export const useNotification = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
async function fetchUnreadCount() {
|
||||
if (!userId.value || !organizationId.value) {
|
||||
logger.warn('No user or organization selected')
|
||||
return
|
||||
@@ -70,7 +79,7 @@ export const useNotification = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
async function markAllAsRead() {
|
||||
if (!organizationId.value) {
|
||||
logger.warn('No organization selected')
|
||||
return
|
||||
@@ -86,7 +95,7 @@ export const useNotification = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
async function markAsRead(notificationId: string) {
|
||||
if (!organizationId.value) {
|
||||
logger.warn('No organization selected')
|
||||
return
|
||||
@@ -109,7 +118,7 @@ export const useNotification = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (notification: NotificationDto) => {
|
||||
async function handleNotificationClick(notification: NotificationDto) {
|
||||
if (!notification.isRead) {
|
||||
await markAsRead(notification.id)
|
||||
}
|
||||
@@ -118,7 +127,7 @@ export const useNotification = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const startPeriodicRefresh = (intervalMs: number = 30000) => {
|
||||
function startPeriodicRefresh(intervalMs: number = 30000) {
|
||||
const interval = setInterval(() => {
|
||||
void fetchUnreadCount()
|
||||
}, intervalMs)
|
||||
@@ -130,17 +139,55 @@ export const useNotification = () => {
|
||||
return interval
|
||||
}
|
||||
|
||||
async function deleteAllNotifications() {
|
||||
try {
|
||||
await clearAllNotifications()
|
||||
notifications.value = []
|
||||
unreadNotifications.value = []
|
||||
unreadCount.value = 0
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete all notifications:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSingleNotification(notificationId: string) {
|
||||
try {
|
||||
// Check if notification was unread before deleting
|
||||
const notification = notifications.value.find((n) => n.id === notificationId)
|
||||
const wasUnread = notification && !notification.isRead
|
||||
|
||||
await deleteNotification(notificationId)
|
||||
notifications.value = notifications.value.filter((n) => n.id !== notificationId)
|
||||
unreadNotifications.value = unreadNotifications.value.filter((n) => n.id !== notificationId)
|
||||
|
||||
// Update unread count if the deleted notification was unread
|
||||
if (wasUnread && unreadCount.value > 0) {
|
||||
unreadCount.value--
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete notification:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
notifications,
|
||||
unreadNotifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
// Getters
|
||||
hasUnread,
|
||||
// Actions
|
||||
fetchNotifications,
|
||||
fetchUnreadNotifications,
|
||||
fetchUnreadCount,
|
||||
markAllAsRead,
|
||||
markAsRead,
|
||||
handleNotificationClick,
|
||||
startPeriodicRefresh
|
||||
startPeriodicRefresh,
|
||||
deleteAllNotifications,
|
||||
deleteSingleNotification
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user