diff --git a/panel/src/components/notifications/__tests__/notification-bell.test.tsx b/panel/src/components/notifications/__tests__/notification-bell.test.tsx index bf404d33..d6e3c27e 100644 --- a/panel/src/components/notifications/__tests__/notification-bell.test.tsx +++ b/panel/src/components/notifications/__tests__/notification-bell.test.tsx @@ -1,21 +1,78 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { NotificationBell } from "../notification-bell"; +import { NotificationType, NotificationPriority, type Notification } from "@/types"; // tooltip-aria-label-spec.md §1a: the bell button's only visible content is -// an icon — it needs a mandatory aria-label, plus a matching visible -// Tooltip using the identical string per §2. +// an icon — it needs a mandatory aria-label, plus a matching visible Tooltip +// using the identical string per §2. + +const { useNotificationStream, useNotifications, useMarkNotificationRead, useAcknowledgeNotification, useMarkAllNotificationsRead } = + vi.hoisted(() => ({ + useNotificationStream: vi.fn(), + useNotifications: vi.fn(), + useMarkNotificationRead: vi.fn(), + useAcknowledgeNotification: vi.fn(), + useMarkAllNotificationsRead: vi.fn(), + })); vi.mock("@/hooks/use-websocket", () => ({ - useNotificationStream: () => ({ - notifications: [], - isConnected: false, - isConnecting: false, - clearMessages: () => {}, - }), + useNotificationStream, })); +vi.mock("@/hooks/use-notifications", () => ({ + useNotifications, + useMarkNotificationRead, + useAcknowledgeNotification, + useMarkAllNotificationsRead, + notificationKeys: { all: ["notifications"], list: (f: unknown) => ["notifications", "list", f], detail: (id: string) => ["notifications", "detail", id] }, +})); + +function buildNotification(overrides: Partial = {}): Notification { + return { + id: "notif-1", + type: NotificationType.TASK_ASSIGNMENT, + priority: NotificationPriority.NORMAL, + from_agent: "fe-pm-00000000", + to_agents: ["fe-dev-1"], + subject: "New task assigned", + body: "You have been assigned a new task.", + requires_ack: false, + is_acknowledged: false, + is_fully_acknowledged: false, + is_read: false, + related_task_id: null, + related_message_ids: [], + timestamp: "2026-07-11T09:00:00Z", + expires_at: null, + acked_by: [], + acked_at: {}, + ...overrides, + }; +} + +function streamMock() { + useNotificationStream.mockReturnValue({ + notifications: [], + lastMessage: undefined, + allMessages: [], + clearMessages: vi.fn(), + isConnected: true, + isConnecting: false, + state: "open", + }); +} + describe("NotificationBell — aria-label + tooltip (tooltip-aria-label-spec §1a)", () => { + beforeEach(() => { + streamMock(); + useNotifications.mockReturnValue({ data: undefined }); + useMarkNotificationRead.mockReturnValue({ mutateAsync: vi.fn() }); + useAcknowledgeNotification.mockReturnValue({ mutateAsync: vi.fn() }); + useMarkAllNotificationsRead.mockReturnValue({ mutateAsync: vi.fn() }); + }); + it("exposes 'View notifications' as the bell button's accessible name and title", () => { render(); const button = screen.getByRole("button", { name: "View notifications" }); @@ -23,16 +80,111 @@ describe("NotificationBell — aria-label + tooltip (tooltip-aria-label-spec §1 }); it("shows a matching visible tooltip once hovered", async () => { - const { default: userEvent } = await import("@testing-library/user-event"); const user = userEvent.setup(); render(); - - await user.hover( - screen.getByRole("button", { name: "View notifications" }), - ); - - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "View notifications", - ); + await user.hover(screen.getByRole("button", { name: "View notifications" })); + expect(await screen.findByRole("tooltip")).toHaveTextContent("View notifications"); }); }); + +describe("NotificationBell — read/ack integration (W9-1)", () => { + beforeEach(() => { + streamMock(); + useMarkNotificationRead.mockReturnValue({ mutateAsync: vi.fn() }); + useAcknowledgeNotification.mockReturnValue({ mutateAsync: vi.fn() }); + useMarkAllNotificationsRead.mockReturnValue({ mutateAsync: vi.fn() }); + }); + + it("shows the persisted unread_count as the badge (not the stream buffer)", () => { + useNotifications.mockReturnValue({ + data: { + items: [buildNotification()], + total: 1, + unread_count: 3, + pending_ack_count: 0, + }, + }); + render(); + // The badge is the only "3" in the closed popover. + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("caps the badge at 9+ for large counts", () => { + useNotifications.mockReturnValue({ + data: { items: [], total: 42, unread_count: 42, pending_ack_count: 0 }, + }); + render(); + expect(screen.getByText("9+")).toBeInTheDocument(); + }); + + it("renders no badge when there is nothing unread", () => { + useNotifications.mockReturnValue({ + data: { items: [], total: 0, unread_count: 0, pending_ack_count: 0 }, + }); + render(); + expect(screen.queryByText(/^[0-9]/)).not.toBeInTheDocument(); + }); + + it("marks a notification read when its 'Mark Read' button is clicked", async () => { + const markRead = vi.fn().mockResolvedValue(undefined); + useMarkNotificationRead.mockReturnValue({ mutateAsync: markRead }); + useNotifications.mockReturnValue({ + data: { + items: [buildNotification({ id: "notif-1", is_read: false })], + total: 1, + unread_count: 1, + pending_ack_count: 0, + }, + }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "View notifications" })); + const markReadBtn = await screen.findByRole("button", { name: /Mark Read/ }); + await user.click(markReadBtn); + await waitFor(() => expect(markRead).toHaveBeenCalledWith("notif-1")); + }); + + it("acknowledges a notification when its 'Acknowledge' button is clicked", async () => { + const ack = vi.fn().mockResolvedValue(buildNotification({ is_acknowledged: true })); + useAcknowledgeNotification.mockReturnValue({ mutateAsync: ack }); + useNotifications.mockReturnValue({ + data: { + items: [ + buildNotification({ + id: "notif-2", + requires_ack: true, + is_acknowledged: false, + }), + ], + total: 1, + unread_count: 1, + pending_ack_count: 1, + }, + }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "View notifications" })); + const ackBtn = await screen.findByRole("button", { name: /Acknowledge/ }); + await user.click(ackBtn); + await waitFor(() => expect(ack).toHaveBeenCalledWith("notif-2")); + }); + + it("marks all notifications read via the header action", async () => { + const markAllRead = vi.fn().mockResolvedValue(undefined); + useMarkAllNotificationsRead.mockReturnValue({ mutateAsync: markAllRead }); + useNotifications.mockReturnValue({ + data: { + items: [buildNotification({ is_read: false })], + total: 1, + unread_count: 1, + pending_ack_count: 0, + }, + }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "View notifications" })); + const allBtn = await screen.findByRole("button", { name: /Mark all read/ }); + await user.click(allBtn); + await waitFor(() => expect(markAllRead).toHaveBeenCalled()); + }); +}); \ No newline at end of file diff --git a/panel/src/components/notifications/notification-bell.tsx b/panel/src/components/notifications/notification-bell.tsx index 7bae423d..e57a0809 100644 --- a/panel/src/components/notifications/notification-bell.tsx +++ b/panel/src/components/notifications/notification-bell.tsx @@ -3,6 +3,13 @@ import { useState } from "react"; import Link from "next/link"; import { useNotificationStream } from "@/hooks/use-websocket"; +import { + useNotifications, + useMarkNotificationRead, + useAcknowledgeNotification, + useMarkAllNotificationsRead, +} from "@/hooks/use-notifications"; +import type { Notification } from "@/types"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -16,18 +23,43 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Bell, Wifi, WifiOff } from "lucide-react"; +import { Bell, Wifi, WifiOff, CheckCheck, MailOpen, Check } from "lucide-react"; const BELL_LABEL = "View notifications"; +const PREVIEW_LIMIT = 10; export function NotificationBell() { const [open, setOpen] = useState(false); - const { notifications, isConnected, clearMessages } = useNotificationStream(); + const { isConnected, clearMessages } = useNotificationStream(); + const { data } = useNotifications(); + const markRead = useMarkNotificationRead(); + const acknowledge = useAcknowledgeNotification(); + const markAllRead = useMarkAllNotificationsRead(); - const unreadCount = notifications.length; + const unreadCount = data?.unread_count ?? 0; + const pendingAckCount = data?.pending_ack_count ?? 0; + const items = (data?.items ?? []).slice(0, PREVIEW_LIMIT); + + const handleMarkRead = (id: string) => { + void markRead.mutateAsync(id); + }; + const handleAcknowledge = (id: string) => { + void acknowledge.mutateAsync(id); + }; + const handleMarkAllRead = () => { + void markAllRead.mutateAsync(); + }; return ( - + { + setOpen(o); + // The stream buffer is no longer displayed here (the alerts sibling + // owns the toast); clear it on close so it can't grow unbounded. + if (!o) clearMessages(); + }} + > @@ -41,7 +73,7 @@ export function NotificationBell() { > {unreadCount > 0 && ( - + {unreadCount > 9 ? "9+" : unreadCount} )} @@ -52,50 +84,50 @@ export function NotificationBell() { -
+
-

Notifications

+

Notifications

{isConnected ? ( - + ) : ( - - )} - {unreadCount > 0 && ( - + )}
+ {unreadCount > 0 && ( + + )}
- {notifications.length === 0 ? ( + {pendingAckCount > 0 && ( +

+ {pendingAckCount} pending acknowledgement + {pendingAckCount > 1 ? "s" : ""} +

+ )} + + {items.length === 0 ? (

No new notifications

) : ( -
- {notifications - .slice(-10) - .reverse() - .map((notification, i) => ( -
-
- - {notification.subject} - - - {notification.priority} - -
-

- {notification.notification_type} -

-
- ))} +
+ {items.map((notification) => ( + + ))}
)} @@ -115,3 +147,66 @@ export function NotificationBell() { ); } + +interface BellRowProps { + notification: Notification; + onMarkRead: (id: string) => void; + onAcknowledge: (id: string) => void; +} + +function BellRow({ notification, onMarkRead, onAcknowledge }: BellRowProps) { + const needsAck = notification.requires_ack && !notification.is_acknowledged; + return ( +
+
+ + {notification.subject} + + + {notification.priority} + +
+
+ {!notification.is_read && ( + + New + + )} + {needsAck && ( + + Needs Ack + + )} +
+ {!notification.is_read && ( + + )} + {needsAck && ( + + )} +
+
+
+ ); +} \ No newline at end of file