瀏覽代碼

Add a Clear all button to the notification panel (#478)

* Add a Clear all button to the notification panel

* Guard and reconcile the Clear all action after review
Bernt Christian Egeland 3 天之前
父節點
當前提交
dd0ab83140

+ 3 - 0
messages/de/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Benachrichtigungen",
     "title": "Benachrichtigungen",
     "markAllRead": "Alle als gelesen markieren",
     "markAllRead": "Alle als gelesen markieren",
+    "clearAll": "Alle entfernen",
+    "confirmClearAll": "Alle Benachrichtigungen entfernen?",
+    "clearAllFailed": "Benachrichtigungen konnten nicht entfernt werden",
     "empty": "Noch keine Benachrichtigungen",
     "empty": "Noch keine Benachrichtigungen",
     "confirmDelete": "Sind Sie sicher?",
     "confirmDelete": "Sind Sie sicher?",
     "delete": "Löschen",
     "delete": "Löschen",

+ 3 - 0
messages/en/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Notifications",
     "title": "Notifications",
     "markAllRead": "Mark all read",
     "markAllRead": "Mark all read",
+    "clearAll": "Clear all",
+    "confirmClearAll": "Remove all notifications?",
+    "clearAllFailed": "Could not clear the notifications",
     "empty": "No notifications yet",
     "empty": "No notifications yet",
     "confirmDelete": "Are you sure?",
     "confirmDelete": "Are you sure?",
     "delete": "Delete",
     "delete": "Delete",

+ 3 - 0
messages/es/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Notificaciones",
     "title": "Notificaciones",
     "markAllRead": "Marcar todo como leído",
     "markAllRead": "Marcar todo como leído",
+    "clearAll": "Borrar todo",
+    "confirmClearAll": "¿Eliminar todas las notificaciones?",
+    "clearAllFailed": "No se pudieron borrar las notificaciones",
     "empty": "Aún no hay notificaciones",
     "empty": "Aún no hay notificaciones",
     "confirmDelete": "¿Estás seguro?",
     "confirmDelete": "¿Estás seguro?",
     "delete": "Eliminar",
     "delete": "Eliminar",

+ 3 - 0
messages/fr/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Notifications",
     "title": "Notifications",
     "markAllRead": "Tout marquer comme lu",
     "markAllRead": "Tout marquer comme lu",
+    "clearAll": "Tout effacer",
+    "confirmClearAll": "Supprimer toutes les notifications ?",
+    "clearAllFailed": "Impossible d'effacer les notifications",
     "empty": "Aucune notification pour le moment",
     "empty": "Aucune notification pour le moment",
     "confirmDelete": "Êtes-vous sûr ?",
     "confirmDelete": "Êtes-vous sûr ?",
     "delete": "Supprimer",
     "delete": "Supprimer",

+ 3 - 0
messages/it/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Notifiche",
     "title": "Notifiche",
     "markAllRead": "Segna tutto come letto",
     "markAllRead": "Segna tutto come letto",
+    "clearAll": "Cancella tutto",
+    "confirmClearAll": "Eliminare tutte le notifiche?",
+    "clearAllFailed": "Impossibile cancellare le notifiche",
     "empty": "Nessuna notifica ancora",
     "empty": "Nessuna notifica ancora",
     "confirmDelete": "Sei sicuro?",
     "confirmDelete": "Sei sicuro?",
     "delete": "Elimina",
     "delete": "Elimina",

+ 3 - 0
messages/lt/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Pranešimai",
     "title": "Pranešimai",
     "markAllRead": "Pažymėti visus kaip skaitytus",
     "markAllRead": "Pažymėti visus kaip skaitytus",
+    "clearAll": "Išvalyti visus",
+    "confirmClearAll": "Pašalinti visus pranešimus?",
+    "clearAllFailed": "Nepavyko išvalyti pranešimų",
     "empty": "Pranešimų dar nėra",
     "empty": "Pranešimų dar nėra",
     "confirmDelete": "Ar tikrai?",
     "confirmDelete": "Ar tikrai?",
     "delete": "Ištrinti",
     "delete": "Ištrinti",

+ 3 - 0
messages/nb/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Varsler",
     "title": "Varsler",
     "markAllRead": "Merk alle som lest",
     "markAllRead": "Merk alle som lest",
+    "clearAll": "Fjern alle",
+    "confirmClearAll": "Fjerne alle varsler?",
+    "clearAllFailed": "Kunne ikke fjerne varslene",
     "empty": "Ingen varsler ennå",
     "empty": "Ingen varsler ennå",
     "confirmDelete": "Er du sikker?",
     "confirmDelete": "Er du sikker?",
     "delete": "Slett",
     "delete": "Slett",

+ 3 - 0
messages/nl/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Meldingen",
     "title": "Meldingen",
     "markAllRead": "Alles als gelezen markeren",
     "markAllRead": "Alles als gelezen markeren",
+    "clearAll": "Alles wissen",
+    "confirmClearAll": "Alle meldingen verwijderen?",
+    "clearAllFailed": "Meldingen konden niet worden gewist",
     "empty": "Nog geen meldingen",
     "empty": "Nog geen meldingen",
     "confirmDelete": "Weet je het zeker?",
     "confirmDelete": "Weet je het zeker?",
     "delete": "Verwijderen",
     "delete": "Verwijderen",

+ 3 - 0
messages/pl/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Powiadomienia",
     "title": "Powiadomienia",
     "markAllRead": "Oznacz wszystkie jako przeczytane",
     "markAllRead": "Oznacz wszystkie jako przeczytane",
+    "clearAll": "Wyczyść wszystko",
+    "confirmClearAll": "Usunąć wszystkie powiadomienia?",
+    "clearAllFailed": "Nie udało się wyczyścić powiadomień",
     "empty": "Brak powiadomień",
     "empty": "Brak powiadomień",
     "confirmDelete": "Czy na pewno?",
     "confirmDelete": "Czy na pewno?",
     "delete": "Usuń",
     "delete": "Usuń",

+ 3 - 0
messages/pt-BR/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Notificações",
     "title": "Notificações",
     "markAllRead": "Marcar todas como lidas",
     "markAllRead": "Marcar todas como lidas",
+    "clearAll": "Limpar tudo",
+    "confirmClearAll": "Excluir todas as notificações?",
+    "clearAllFailed": "Não foi possível limpar as notificações",
     "empty": "Nenhuma notificação ainda",
     "empty": "Nenhuma notificação ainda",
     "confirmDelete": "Tem certeza?",
     "confirmDelete": "Tem certeza?",
     "delete": "Excluir",
     "delete": "Excluir",

+ 3 - 0
messages/pt-PT/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Notificações",
     "title": "Notificações",
     "markAllRead": "Marcar todas como lidas",
     "markAllRead": "Marcar todas como lidas",
+    "clearAll": "Limpar tudo",
+    "confirmClearAll": "Eliminar todas as notificações?",
+    "clearAllFailed": "Não foi possível limpar as notificações",
     "empty": "Ainda não há notificações",
     "empty": "Ainda não há notificações",
     "confirmDelete": "Tem a certeza?",
     "confirmDelete": "Tem a certeza?",
     "delete": "Eliminar",
     "delete": "Eliminar",

+ 3 - 0
messages/ru/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Уведомления",
     "title": "Уведомления",
     "markAllRead": "Прочитать все",
     "markAllRead": "Прочитать все",
+    "clearAll": "Очистить все",
+    "confirmClearAll": "Удалить все уведомления?",
+    "clearAllFailed": "Не удалось очистить уведомления",
     "empty": "Уведомлений пока нет",
     "empty": "Уведомлений пока нет",
     "confirmDelete": "Вы уверены?",
     "confirmDelete": "Вы уверены?",
     "delete": "Удалить",
     "delete": "Удалить",

+ 3 - 0
messages/tr/notifications.json

@@ -2,6 +2,9 @@
   "panel": {
   "panel": {
     "title": "Bildirimler",
     "title": "Bildirimler",
     "markAllRead": "Tümünü okundu olarak işaretle",
     "markAllRead": "Tümünü okundu olarak işaretle",
+    "clearAll": "Tümünü temizle",
+    "confirmClearAll": "Tüm bildirimler silinsin mi?",
+    "clearAllFailed": "Bildirimler temizlenemedi",
     "empty": "Henüz bildirim yok",
     "empty": "Henüz bildirim yok",
     "confirmDelete": "Emin misiniz?",
     "confirmDelete": "Emin misiniz?",
     "delete": "Sil",
     "delete": "Sil",

+ 131 - 0
src/__tests__/features/notifications/notification-panel-clear-all.test.tsx

@@ -0,0 +1,131 @@
+/**
+ * "Clear all" in the notification panel: it asks first; on confirm it empties
+ * the list, drops the badge, and tells the server to delete everything up to
+ * the newest notification that was on screen. A failed request puts the
+ * server's list back and says so.
+ */
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { NotificationPanel } from '@/features/notifications/Components/NotificationPanel'
+import { useNotificationStore } from '@/features/notifications/store/notificationStore'
+
+const deleteAllNotifications = vi.fn(async (_before: string) => ({ success: true }))
+const getNotifications = vi.fn(async () => ({
+  success: true,
+  data: { notifications: [] as ReturnType<typeof makeNotifications>, unreadCount: 0 },
+}))
+const toastError = vi.fn()
+
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
+}))
+
+vi.mock('sonner', () => ({
+  toast: Object.assign(vi.fn(), { error: (...args: unknown[]) => toastError(...args) }),
+}))
+
+vi.mock('@/features/notifications/Actions/notificationActions', () => ({
+  getNotifications: () => getNotifications(),
+  markNotificationRead: vi.fn(async () => ({ success: true })),
+  markAllNotificationsRead: vi.fn(async () => ({ success: true })),
+  deleteNotification: vi.fn(async () => ({ success: true })),
+  deleteAllNotifications: (before: string) => deleteAllNotifications(before),
+}))
+
+function makeNotifications(count: number) {
+  return Array.from({ length: count }, (_, i) => ({
+    id: `n${i}`,
+    type: 'quote_accepted',
+    title: `Quote ${i} accepted`,
+    message: 'A customer accepted a quote',
+    entityType: 'quote',
+    entityId: `q${i}`,
+    entityUrl: `/quotes/q${i}`,
+    read: i % 2 === 0,
+    organizationId: 'org1',
+    // Newest first, a minute apart.
+    createdAt: new Date(Date.UTC(2026, 9, 30, 12, 59 - i)).toISOString(),
+  }))
+}
+
+function seed(count: number) {
+  useNotificationStore.getState().setNotifications(makeNotifications(count), Math.floor(count / 2))
+  useNotificationStore.getState().setPanelOpen(true)
+}
+
+const flush = () =>
+  act(async () => {
+    await Promise.resolve()
+  })
+
+describe('NotificationPanel clear all', () => {
+  beforeEach(() => {
+    deleteAllNotifications.mockClear()
+    deleteAllNotifications.mockResolvedValue({ success: true })
+    getNotifications.mockClear()
+    toastError.mockClear()
+    useNotificationStore.getState().setNotifications([], 0)
+  })
+
+  it('removes every notification once confirmed, up to the newest shown', async () => {
+    seed(3)
+    render(<NotificationPanel />)
+
+    expect(screen.getAllByText(/^Quote \d accepted$/)).toHaveLength(3)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }))
+    expect(screen.getByText('Remove all notifications?')).toBeInTheDocument()
+    // Nothing is gone yet.
+    expect(deleteAllNotifications).not.toHaveBeenCalled()
+    expect(useNotificationStore.getState().notifications).toHaveLength(3)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+    await flush()
+
+    expect(deleteAllNotifications).toHaveBeenCalledTimes(1)
+    expect(deleteAllNotifications).toHaveBeenCalledWith('2026-10-30T12:59:00.000Z')
+    expect(useNotificationStore.getState().notifications).toHaveLength(0)
+    expect(useNotificationStore.getState().unreadCount).toBe(0)
+    expect(screen.getByText('No notifications yet')).toBeInTheDocument()
+    expect(toastError).not.toHaveBeenCalled()
+  })
+
+  it('puts the server list back and says so when the request fails', async () => {
+    seed(2)
+    deleteAllNotifications.mockResolvedValue({ success: false })
+    getNotifications.mockResolvedValue({
+      success: true,
+      data: { notifications: makeNotifications(2), unreadCount: 1 },
+    })
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+    await flush()
+
+    expect(toastError).toHaveBeenCalledWith('Could not clear the notifications')
+    expect(getNotifications).toHaveBeenCalledTimes(1)
+    expect(useNotificationStore.getState().notifications).toHaveLength(2)
+    expect(useNotificationStore.getState().unreadCount).toBe(1)
+    expect(screen.getAllByText(/^Quote \d accepted$/)).toHaveLength(2)
+  })
+
+  it('cancel keeps everything', () => {
+    seed(2)
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+
+    expect(deleteAllNotifications).not.toHaveBeenCalled()
+    expect(useNotificationStore.getState().notifications).toHaveLength(2)
+    expect(screen.queryByText('Remove all notifications?')).not.toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Clear all' })).toBeInTheDocument()
+  })
+
+  it('is hidden when there is nothing to clear', () => {
+    useNotificationStore.getState().setPanelOpen(true)
+    render(<NotificationPanel />)
+    expect(screen.queryByRole('button', { name: 'Clear all' })).not.toBeInTheDocument()
+  })
+})

+ 22 - 1
src/features/notifications/Actions/notificationActions.ts

@@ -44,10 +44,31 @@ export async function markAllNotificationsRead() {
 }
 }
 
 
 export async function deleteNotification(id: string) {
 export async function deleteNotification(id: string) {
-  return withAuth(async ({ organizationId }) => {
+  return withAuth(async ({ organizationId, role }) => {
+    if (!readsNotifications(role)) throw new Error(ONLY_FEED_READERS)
     await db.notification.deleteMany({
     await db.notification.deleteMany({
       where: { id, organizationId },
       where: { id, organizationId },
     })
     })
     return { success: true }
     return { success: true }
   })
   })
 }
 }
+
+const ONLY_FEED_READERS = 'Only the roles that read the notification feed can delete from it'
+
+/**
+ * Empty the workshop's feed. `before` is the newest notification the person
+ * was looking at when they confirmed: anything that arrived after that is
+ * not something they chose to clear, so it stays, and the panel and the
+ * database agree on what is left.
+ */
+export async function deleteAllNotifications(before: string) {
+  return withAuth(async ({ organizationId, role }) => {
+    if (!readsNotifications(role)) throw new Error(ONLY_FEED_READERS)
+    const cutoff = new Date(before)
+    if (Number.isNaN(cutoff.getTime())) throw new Error('Invalid cutoff')
+    await db.notification.deleteMany({
+      where: { organizationId, createdAt: { lte: cutoff } },
+    })
+    return { success: true }
+  })
+}

+ 80 - 7
src/features/notifications/Components/NotificationPanel.tsx

@@ -5,15 +5,18 @@ import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { formatDistanceToNow } from 'date-fns'
 import { formatDistanceToNow } from 'date-fns'
 import { Bell, FileText, ClipboardCheck, Receipt, X } from 'lucide-react'
 import { Bell, FileText, ClipboardCheck, Receipt, X } from 'lucide-react'
+import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Badge } from '@/components/ui/badge'
 import { Badge } from '@/components/ui/badge'
 import { ScrollArea } from '@/components/ui/scroll-area'
 import { ScrollArea } from '@/components/ui/scroll-area'
 import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
 import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
 import { useNotificationStore } from '../store/notificationStore'
 import { useNotificationStore } from '../store/notificationStore'
 import {
 import {
+  getNotifications,
   markNotificationRead,
   markNotificationRead,
   markAllNotificationsRead,
   markAllNotificationsRead,
   deleteNotification,
   deleteNotification,
+  deleteAllNotifications,
 } from '../Actions/notificationActions'
 } from '../Actions/notificationActions'
 
 
 const entityIcons: Record<string, typeof FileText> = {
 const entityIcons: Record<string, typeof FileText> = {
@@ -55,8 +58,11 @@ export function NotificationPanel() {
     markRead,
     markRead,
     markAllRead,
     markAllRead,
     removeNotification,
     removeNotification,
+    clearAll,
+    setNotifications,
   } = useNotificationStore()
   } = useNotificationStore()
   const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
   const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
+  const [pendingClearAll, setPendingClearAll] = useState(false)
 
 
   const handleClickNotification = (id: string, entityUrl: string, read: boolean) => {
   const handleClickNotification = (id: string, entityUrl: string, read: boolean) => {
     if (pendingDeleteId) {
     if (pendingDeleteId) {
@@ -101,17 +107,46 @@ export function NotificationPanel() {
     markAllNotificationsRead()
     markAllNotificationsRead()
   }
   }
 
 
+  const handleConfirmClearAll = async () => {
+    // The newest notification on screen is the cutoff: one that arrives while
+    // the request is in flight was never shown, so it is not cleared.
+    const newest = Math.max(...notifications.map((n) => new Date(n.createdAt).getTime()))
+    const before = new Date(newest).toISOString()
+
+    clearAll()
+    setPendingClearAll(false)
+    setPendingDeleteId(null)
+
+    const result = await deleteAllNotifications(before).catch(() => ({ success: false }))
+    if (result.success) {
+      // The dashboard's Recent Notifications card is server-fetched, not read
+      // from the store, so it only empties with a refresh.
+      router.refresh()
+      return
+    }
+
+    toast.error(t('panel.clearAllFailed'))
+    const fresh = await getNotifications().catch(() => null)
+    if (fresh?.success && fresh.data) {
+      setNotifications(fresh.data.notifications, fresh.data.unreadCount)
+    }
+  }
+
   return (
   return (
     <Sheet
     <Sheet
       open={isPanelOpen}
       open={isPanelOpen}
       onOpenChange={(open) => {
       onOpenChange={(open) => {
         setPanelOpen(open)
         setPanelOpen(open)
-        if (!open) setPendingDeleteId(null)
+        if (!open) {
+          setPendingDeleteId(null)
+          setPendingClearAll(false)
+        }
       }}
       }}
     >
     >
       <SheetContent className="w-[380px] p-0 sm:max-w-[380px]">
       <SheetContent className="w-[380px] p-0 sm:max-w-[380px]">
         <SheetHeader className="border-b px-4 py-3">
         <SheetHeader className="border-b px-4 py-3">
-          <div className="flex items-center justify-between">
+          {/* pr-8 keeps the actions clear of the sheet's own close control. */}
+          <div className="flex items-center justify-between pr-8">
             <div className="flex items-center gap-2">
             <div className="flex items-center gap-2">
               <SheetTitle>{t('panel.title')}</SheetTitle>
               <SheetTitle>{t('panel.title')}</SheetTitle>
               {unreadCount > 0 && (
               {unreadCount > 0 && (
@@ -120,12 +155,50 @@ export function NotificationPanel() {
                 </Badge>
                 </Badge>
               )}
               )}
             </div>
             </div>
-            {unreadCount > 0 && (
-              <Button variant="ghost" size="sm" className="text-xs" onClick={handleMarkAllRead}>
-                {t('panel.markAllRead')}
-              </Button>
-            )}
+            <div className="flex items-center gap-1">
+              {unreadCount > 0 && (
+                <Button variant="ghost" size="sm" className="text-xs" onClick={handleMarkAllRead}>
+                  {t('panel.markAllRead')}
+                </Button>
+              )}
+              {notifications.length > 0 && !pendingClearAll && (
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="text-xs"
+                  onClick={() => setPendingClearAll(true)}
+                >
+                  {t('panel.clearAll')}
+                </Button>
+              )}
+            </div>
           </div>
           </div>
+          {pendingClearAll && (
+            <div className="mt-2 flex items-center justify-between gap-2">
+              <span id="notifications-clear-all-question" className="text-xs text-muted-foreground">
+                {t('panel.confirmClearAll')}
+              </span>
+              <div className="flex items-center gap-2">
+                <Button
+                  variant="destructive"
+                  size="sm"
+                  className="h-6 px-2 text-xs"
+                  aria-describedby="notifications-clear-all-question"
+                  onClick={handleConfirmClearAll}
+                >
+                  {t('panel.delete')}
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="h-6 px-2 text-xs"
+                  onClick={() => setPendingClearAll(false)}
+                >
+                  {t('panel.cancel')}
+                </Button>
+              </div>
+            </div>
+          )}
         </SheetHeader>
         </SheetHeader>
         <ScrollArea className="h-[calc(100vh-4rem)]">
         <ScrollArea className="h-[calc(100vh-4rem)]">
           {notifications.length === 0 ? (
           {notifications.length === 0 ? (

+ 2 - 0
src/features/notifications/store/notificationStore.ts

@@ -23,6 +23,7 @@ type NotificationState = {
   markRead: (id: string) => void
   markRead: (id: string) => void
   markAllRead: () => void
   markAllRead: () => void
   removeNotification: (id: string) => void
   removeNotification: (id: string) => void
+  clearAll: () => void
   setConnected: (connected: boolean) => void
   setConnected: (connected: boolean) => void
   setPanelOpen: (open: boolean) => void
   setPanelOpen: (open: boolean) => void
 }
 }
@@ -66,6 +67,7 @@ export const useNotificationStore = create<NotificationState>((set) => ({
         unreadCount: Math.max(0, state.unreadCount - (target && !target.read ? 1 : 0)),
         unreadCount: Math.max(0, state.unreadCount - (target && !target.read ? 1 : 0)),
       }
       }
     }),
     }),
+  clearAll: () => set({ notifications: [], unreadCount: 0 }),
   setConnected: (isConnected) => set({ isConnected }),
   setConnected: (isConnected) => set({ isConnected }),
   setPanelOpen: (isPanelOpen) => set({ isPanelOpen }),
   setPanelOpen: (isPanelOpen) => set({ isPanelOpen }),
 }))
 }))