diff --git a/react-compiler.config.js b/react-compiler.config.js index 1651641962..f71879296a 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -42,6 +42,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/hooks/useContainerLog.ts", "src/hooks/usePipelineRunList.ts", "src/hooks/useNotices.ts", + "src/hooks/useNoticeInbox.ts", "src/components/shared/FavoriteToggle.tsx", "src/components/shared/FloatingSelectionBar.tsx", "src/components/shared/ComponentLifecycleBadges.tsx", diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx index 195aa373bd..7560158c36 100644 --- a/src/components/layout/AppMenu.tsx +++ b/src/components/layout/AppMenu.tsx @@ -12,6 +12,7 @@ import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarA import { CopyText } from "@/components/shared/CopyText/CopyText"; import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle"; import ImportPipeline from "@/components/shared/ImportPipeline"; +import { NoticeInbox } from "@/components/shared/Notices/NoticeInbox"; import { RunVersionToggle } from "@/components/shared/RunVersionToggle"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; @@ -128,6 +129,7 @@ const DefaultAppMenu = () => { + {/* Settings & status */} {isOnSettingsRoute ? ( diff --git a/src/components/shared/Notices/NoticeInbox.test.tsx b/src/components/shared/Notices/NoticeInbox.test.tsx new file mode 100644 index 0000000000..6849d1a535 --- /dev/null +++ b/src/components/shared/Notices/NoticeInbox.test.tsx @@ -0,0 +1,212 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { installSource } from "@/config/noticeTestSource"; +import { resetNoticeInboxForTests } from "@/hooks/useNoticeInbox"; +import { resetNoticeStateForTests } from "@/hooks/useNotices"; + +import { NoticeInbox } from "./NoticeInbox"; + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal("ResizeObserver", ResizeObserverMock); + +describe("", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + delete window.__TANGLE_NOTICE_SOURCE__; + resetNoticeInboxForTests(); + resetNoticeStateForTests(); + }); + + it("stays in the header with nothing to show", () => { + render(); + + const trigger = screen.getByTestId("notice-inbox-trigger"); + expect(trigger).toHaveAttribute("aria-label", "Notices, none"); + expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument(); + + fireEvent.click(trigger); + + expect(screen.getByTestId("notice-inbox-empty")).toHaveTextContent( + "No notices", + ); + expect(screen.queryByTestId("notice-inbox-hide")).not.toBeInTheDocument(); + }); + + it("counts the notices the reader has not opened yet", () => { + installSource([ + { id: "a", title: "One", body: "" }, + { id: "b", title: "Two", body: "" }, + ]); + + render(); + + expect(screen.getByTestId("notice-inbox-unread")).toHaveTextContent("2"); + expect(screen.getByTestId("notice-inbox-trigger")).toHaveAttribute( + "aria-label", + "Notices, 2 unread", + ); + }); + + it("lists every notice in full once opened", () => { + installSource([ + { id: "a", title: "One", body: "Body of **one**" }, + { id: "b", title: "Two", body: "Body of two" }, + ]); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + + expect(screen.getByTestId("notice-inbox")).toBeInTheDocument(); + expect(screen.getAllByTestId("info-box-title")).toHaveLength(2); + expect(screen.getByText("one").tagName).toBe("STRONG"); + expect(screen.getByText(/Body of two/)).toBeInTheDocument(); + }); + + it("clears the unread count once opened, and keeps it clear across a remount", () => { + installSource([{ id: "a", title: "One", body: "" }]); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + + expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument(); + + cleanup(); + render(); + + expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument(); + expect(screen.getByTestId("notice-inbox-trigger")).toHaveAttribute( + "aria-label", + "Notices, 1 active", + ); + }); + + it("lets a dismissible notice be removed from the centre for good", () => { + installSource([ + { id: "a", title: "Optional", body: "", dismissible: true }, + { id: "b", title: "Mandatory", body: "" }, + ]); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + + expect(screen.getAllByLabelText("Dismiss")).toHaveLength(1); + fireEvent.click(screen.getByLabelText("Dismiss")); + + expect(screen.queryByText("Optional")).not.toBeInTheDocument(); + expect(screen.getByText("Mandatory")).toBeInTheDocument(); + + cleanup(); + render(); + + expect(screen.getByTestId("notice-inbox-trigger")).toHaveAttribute( + "aria-label", + "Notices, 1 active", + ); + }); + + it("removes a dismissed notice even when it cannot be persisted", () => { + installSource([ + { id: "a", title: "Optional", body: "", dismissible: true }, + ]); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("storage is unavailable"); + }); + fireEvent.click(screen.getByLabelText("Dismiss")); + setItem.mockRestore(); + + expect(screen.queryByText("Optional")).not.toBeInTheDocument(); + }); + + it("clears the unread count for the session even when it cannot be persisted", () => { + installSource([{ id: "a", title: "One", body: "" }]); + + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("storage is unavailable"); + }); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + + expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument(); + + cleanup(); + render(); + setItem.mockRestore(); + + expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument(); + }); + + it("keeps the centre open and reachable after the last notice goes", () => { + installSource([ + { id: "a", title: "Optional", body: "", dismissible: true }, + ]); + + render(); + const trigger = screen.getByTestId("notice-inbox-trigger"); + fireEvent.click(trigger); + fireEvent.click(screen.getByLabelText("Dismiss")); + + expect(screen.getByTestId("notice-inbox-empty")).toHaveTextContent( + "No notices", + ); + expect(screen.queryByTestId("notice-inbox-hide")).not.toBeInTheDocument(); + + fireEvent.keyDown(screen.getByTestId("notice-inbox"), { key: "Escape" }); + expect(trigger).toBeInTheDocument(); + + fireEvent.click(trigger); + + expect(screen.getByTestId("notice-inbox-empty")).toBeInTheDocument(); + }); + + it("caps the unread badge, keeping the exact count on the trigger", () => { + installSource( + Array.from({ length: 12 }, (_, index) => ({ + id: `notice-${index}`, + title: `Notice ${index}`, + body: "", + })), + ); + + render(); + + expect(screen.getByTestId("notice-inbox-unread")).toHaveTextContent("9+"); + expect(screen.getByTestId("notice-inbox-trigger")).toHaveAttribute( + "aria-label", + "Notices, 12 unread", + ); + }); + + it("orders the list by severity", () => { + installSource([ + { id: "a", title: "Info", body: "", variant: "info" }, + { id: "b", title: "Error", body: "", variant: "error" }, + { id: "c", title: "Warning", body: "", variant: "warning" }, + ]); + + render(); + fireEvent.click(screen.getByTestId("notice-inbox-trigger")); + + expect( + screen.getAllByTestId("info-box-title").map((el) => el.textContent), + ).toEqual(["Error", "Warning", "Info"]); + }); +}); diff --git a/src/components/shared/Notices/NoticeInbox.tsx b/src/components/shared/Notices/NoticeInbox.tsx new file mode 100644 index 0000000000..17de58aad8 --- /dev/null +++ b/src/components/shared/Notices/NoticeInbox.tsx @@ -0,0 +1,91 @@ +import { useEffect } from "react"; + +import TooltipButton from "@/components/shared/Buttons/TooltipButton"; +import { NoticeCard } from "@/components/shared/Notices/NoticeCard"; +import { Badge } from "@/components/ui/badge"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack } from "@/components/ui/layout"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Text } from "@/components/ui/typography"; +import { closeNoticeInbox, useNoticeInbox } from "@/hooks/useNoticeInbox"; +import { tracking } from "@/utils/tracking"; + +const VIEWPORT_GUTTER = 16; + +function triggerLabel(unreadCount: number, total: number): string { + if (unreadCount > 0) return `Notices, ${unreadCount} unread`; + if (total > 0) return `Notices, ${total} active`; + return "Notices, none"; +} + +export const NoticeInbox = () => { + const { notices, unreadCount, isOpen, setOpen, dismiss } = useNoticeInbox(); + + useEffect(() => closeNoticeInbox, []); + + const label = triggerLabel(unreadCount, notices.length); + + return ( + + + + + {unreadCount > 0 && ( + + {unreadCount > 9 ? "9+" : unreadCount} + + )} + + + + + + Notices + + {notices.length === 0 ? ( + + No notices + + ) : ( + notices.map((notice) => ( + dismiss(notice) : undefined + } + /> + )) + )} + + + + ); +}; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 611b1d333b..130c189e5b 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -39,7 +39,7 @@ function PopoverContent({ align={align} sideOffset={sideOffset} className={cn( - "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 max-w-(--radix-popover-content-available-width) max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border p-4 shadow-md outline-hidden", className, )} {...props} diff --git a/src/hooks/useNoticeInbox.ts b/src/hooks/useNoticeInbox.ts new file mode 100644 index 0000000000..178fc462eb --- /dev/null +++ b/src/hooks/useNoticeInbox.ts @@ -0,0 +1,110 @@ +import { useSyncExternalStore } from "react"; + +import type { TangleNotice } from "@/config/notices"; +import { useNotices } from "@/hooks/useNotices"; +import { getStorage } from "@/utils/typedStorage"; + +const READ_KEY = "read-notices"; + +const storage = getStorage< + typeof READ_KEY, + Record +>(); + +const readForThisSession = new Set(); + +let isOpen = false; +let revision = 0; +const listeners = new Set<() => void>(); + +function publish() { + revision += 1; + listeners.forEach((listener) => listener()); +} + +function handleStorage(event: StorageEvent) { + // typedStorage re-dispatches a synthetic same-tab event on every write, which + // arrives without a storageArea. Those writes already publish themselves. + if (event.storageArea === null) return; + if (event.key === READ_KEY) publish(); +} + +function subscribe(listener: () => void): () => void { + if (listeners.size === 0) window.addEventListener("storage", handleStorage); + listeners.add(listener); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) + window.removeEventListener("storage", handleStorage); + }; +} + +function getRevision(): number { + return revision; +} + +function readIds(): string[] { + const stored = storage.getItem(READ_KEY); + return Array.isArray(stored) ? stored : []; +} + +let cachedReadIds: Set | null = null; +let cachedRevision = -1; + +function getReadIds(revision: number): Set { + if (cachedReadIds && cachedRevision === revision) return cachedReadIds; + + cachedRevision = revision; + cachedReadIds = new Set([...readIds(), ...readForThisSession]); + + return cachedReadIds; +} + +export function closeNoticeInbox() { + isOpen = false; + publish(); +} + +function openNoticeInbox(notices: readonly TangleNotice[]) { + const stored = readIds(); + const merged = [...new Set([...stored, ...notices.map(({ id }) => id)])]; + if (merged.length !== stored.length) storage.setItem(READ_KEY, merged); + + const persisted = new Set(readIds()); + notices.forEach(({ id }) => { + if (!persisted.has(id)) readForThisSession.add(id); + }); + + isOpen = true; + publish(); +} + +export function resetNoticeInboxForTests(): void { + isOpen = false; + readForThisSession.clear(); + cachedReadIds = null; + cachedRevision = -1; +} + +export interface NoticeInbox { + notices: readonly TangleNotice[]; + unreadCount: number; + isOpen: boolean; + setOpen: (open: boolean) => void; + dismiss: (notice: TangleNotice) => void; +} + +export function useNoticeInbox(): NoticeInbox { + const { notices, dismiss } = useNotices(); + const revision = useSyncExternalStore(subscribe, getRevision); + const readIdSet = getReadIds(revision); + + return { + notices, + unreadCount: notices.filter((notice) => !readIdSet.has(notice.id)).length, + isOpen, + setOpen: (open) => (open ? openNoticeInbox(notices) : closeNoticeInbox()), + dismiss, + }; +} diff --git a/src/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx index 20de56208f..6706c58a7d 100644 --- a/src/routes/v2/shared/components/AppMenuActions.tsx +++ b/src/routes/v2/shared/components/AppMenuActions.tsx @@ -6,6 +6,7 @@ import { isAuthorizationRequired } from "@/components/shared/Authentication/help import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication"; import TooltipButton from "@/components/shared/Buttons/TooltipButton"; import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle"; +import { NoticeInbox } from "@/components/shared/Notices/NoticeInbox"; import { RunVersionToggle } from "@/components/shared/RunVersionToggle"; import { Icon } from "@/components/ui/icon"; import { InlineStack } from "@/components/ui/layout"; @@ -30,6 +31,7 @@ export function AppMenuActions() { + {tourMode ? (