diff --git a/ui-react/apps/console/src/components/announcements/AnnouncementModal.css b/ui-react/apps/console/src/components/announcements/AnnouncementModal.css new file mode 100644 index 00000000000..96e762fba4d --- /dev/null +++ b/ui-react/apps/console/src/components/announcements/AnnouncementModal.css @@ -0,0 +1,121 @@ +/* Colors are manually synced with tailwind.preset.js dark theme tokens. + Update these if the theme palette changes. */ + +.announcement-modal-content .ProseMirror { + outline: none; + color: #e1e4ea; + font-size: 0.875rem; + line-height: 1.625; +} + +.announcement-modal-content .ProseMirror h1 { + font-size: 1.5rem; + font-weight: 700; + margin-top: 1.5rem; + margin-bottom: 0.5rem; + line-height: 1.3; +} + +.announcement-modal-content .ProseMirror h1:first-child { + margin-top: 0; +} + +.announcement-modal-content .ProseMirror h2 { + font-size: 1.25rem; + font-weight: 600; + margin-top: 1.25rem; + margin-bottom: 0.5rem; + line-height: 1.3; +} + +.announcement-modal-content .ProseMirror h3 { + font-size: 1.1rem; + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.5rem; + line-height: 1.3; +} + +.announcement-modal-content .ProseMirror p { + margin-bottom: 0.75rem; +} + +.announcement-modal-content .ProseMirror p:last-child { + margin-bottom: 0; +} + +.announcement-modal-content .ProseMirror ul, +.announcement-modal-content .ProseMirror ol { + padding-left: 1.5rem; + margin-bottom: 0.75rem; +} + +.announcement-modal-content .ProseMirror ul { + list-style: disc; +} + +.announcement-modal-content .ProseMirror ol { + list-style: decimal; +} + +.announcement-modal-content .ProseMirror li { + margin-bottom: 0.25rem; +} + +.announcement-modal-content .ProseMirror blockquote { + border-left: 3px solid #383d47; + padding-left: 1rem; + margin-left: 0; + margin-bottom: 0.75rem; + color: #8b8f99; + font-style: italic; +} + +.announcement-modal-content .ProseMirror code { + background: #1e2127; + border: 1px solid #2c2f36; + border-radius: 0.25rem; + padding: 0.125rem 0.375rem; + font-family: "IBM Plex Mono", monospace; + font-size: 0.8125rem; +} + +.announcement-modal-content .ProseMirror pre { + background: #1e2127; + border: 1px solid #2c2f36; + border-radius: 0.5rem; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + overflow-x: auto; +} + +.announcement-modal-content .ProseMirror pre code { + background: none; + border: none; + padding: 0; + font-size: 0.8125rem; + line-height: 1.5; +} + +.announcement-modal-content .ProseMirror a { + color: #667acc; + text-decoration: underline; + text-underline-offset: 2px; +} + +.announcement-modal-content .ProseMirror a:hover { + opacity: 0.8; +} + +.announcement-modal-content .ProseMirror img { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + margin: 0.75rem 0; +} + +.announcement-modal-content .ProseMirror hr { + border: none; + border-top: 1px solid #2c2f36; + margin: 1.25rem 0; +} diff --git a/ui-react/apps/console/src/components/announcements/AnnouncementModal.tsx b/ui-react/apps/console/src/components/announcements/AnnouncementModal.tsx new file mode 100644 index 00000000000..b72a94e2dcb --- /dev/null +++ b/ui-react/apps/console/src/components/announcements/AnnouncementModal.tsx @@ -0,0 +1,104 @@ +import { useId } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Link from "@tiptap/extension-link"; +import Image from "@tiptap/extension-image"; +import { Markdown } from "@tiptap/markdown"; +import { MegaphoneIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import BaseDialog from "@/components/common/BaseDialog"; +import { formatDateShort } from "@/utils/date"; +import { isAllowedUrl } from "@/utils/url"; +import type { Announcement } from "@/client"; +import "./AnnouncementModal.css"; + +interface AnnouncementContentProps { + content: string; +} + +function AnnouncementContent({ content }: AnnouncementContentProps) { + const editor = useEditor({ + extensions: [ + StarterKit, + Link.configure({ + openOnClick: true, + validate: (url) => isAllowedUrl(url), + HTMLAttributes: { + rel: "noopener noreferrer", + target: "_blank", + }, + }), + Image.configure({ allowBase64: false }), + Markdown, + ], + content, + contentType: "markdown", + editable: false, + }); + + if (!editor) return
; + + return ( +
+ +
+ ); +} + +interface AnnouncementModalProps { + open: boolean; + onClose: () => void; + announcement: Announcement; +} + +export default function AnnouncementModal({ + open, + onClose, + announcement, +}: AnnouncementModalProps) { + const titleId = useId(); + + return ( + +
+
+
+ +
+
+

+ {announcement.title} +

+

+ {formatDateShort(announcement.date)} +

+
+
+ +
+ +
+ +
+ +
+ +
+
+ ); +} diff --git a/ui-react/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx b/ui-react/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx new file mode 100644 index 00000000000..c94536c4ccb --- /dev/null +++ b/ui-react/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { getConfig } from "@/env"; +import { useLatestAnnouncement } from "@/hooks/useLatestAnnouncement"; +import AnnouncementModal from "./AnnouncementModal"; +import type { Announcement } from "@/client"; + +const STORAGE_KEY = "announcement"; + +function computeHash(announcement: Announcement): string { + const json = JSON.stringify(announcement); + return btoa( + Array.from(new TextEncoder().encode(json), (b) => + String.fromCharCode(b), + ).join(""), + ); +} + +function getStoredHash(): string { + return localStorage.getItem(STORAGE_KEY) ?? ""; +} + +function markSeen(announcement: Announcement): void { + localStorage.setItem(STORAGE_KEY, computeHash(announcement)); +} + +export default function AnnouncementModalTrigger() { + if (!getConfig().announcements) return null; + + return ; +} + +function AnnouncementModalInner() { + const { announcement } = useLatestAnnouncement(); + const [dismissed, setDismissed] = useState(false); + + const show = + !!announcement && + !dismissed && + computeHash(announcement) !== getStoredHash(); + + const handleClose = () => { + if (announcement) markSeen(announcement); + setDismissed(true); + }; + + if (!show || !announcement) return null; + + return ( + + ); +} diff --git a/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx b/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx new file mode 100644 index 00000000000..1287a73dcce --- /dev/null +++ b/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModal.test.tsx @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Announcement } from "@/client"; + +// ── Dependency mocks ────────────────────────────────────────────────────────── + +// Silence the CSS import — jsdom cannot process it +vi.mock("../AnnouncementModal.css", () => ({})); + +// Mock the focus trap so jsdom focus state doesn't interfere +vi.mock("@/hooks/useFocusTrap", () => ({ useFocusTrap: vi.fn() })); + +// jsdom doesn't implement showModal/close — stub them +HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, +) { + this.setAttribute("open", ""); +}); +HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute("open"); +}); + +// Tiptap uses real DOM APIs that aren't fully available in jsdom. +// Mock the whole editor so AnnouncementContent renders a stable placeholder. +vi.mock("@tiptap/react", () => ({ + useEditor: vi.fn(() => null), + EditorContent: () => null, +})); + +vi.mock("@tiptap/starter-kit", () => ({ default: {} })); +vi.mock("@tiptap/extension-link", () => ({ + default: { configure: vi.fn(() => ({})) }, +})); +vi.mock("@tiptap/extension-image", () => ({ + default: { configure: vi.fn(() => ({})) }, +})); +vi.mock("@tiptap/markdown", () => ({ Markdown: {} })); +vi.mock("@/utils/url", () => ({ isAllowedUrl: vi.fn(() => true) })); + +import AnnouncementModal from "../AnnouncementModal"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeAnnouncement(overrides: Partial = {}): Announcement { + return { + uuid: "ann-uuid-1", + title: "New Feature Released", + content: "## Hello\nThis is the content.", + date: "2024-06-15T00:00:00Z", + ...overrides, + }; +} + +function renderModal({ + open = true, + onClose = vi.fn(), + announcement = makeAnnouncement(), +}: { + open?: boolean; + onClose?: () => void; + announcement?: Announcement; +} = {}) { + return { + onClose, + ...render( + , + ), + }; +} + +// ── Setup / teardown ────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(cleanup); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("AnnouncementModal", () => { + describe("when open=false", () => { + it("renders nothing", () => { + renderModal({ open: false }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("when open=true", () => { + it("renders the dialog element", () => { + renderModal(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("shows the announcement title", () => { + renderModal({ + announcement: makeAnnouncement({ title: "Big Announcement" }), + }); + expect(screen.getByText("Big Announcement")).toBeInTheDocument(); + }); + + it("shows the formatted announcement date", () => { + renderModal({ + announcement: makeAnnouncement({ date: "2024-06-15T12:00:00Z" }), + }); + // formatDateShort renders "Jun 15, 2024" + expect(screen.getByText("Jun 15, 2024")).toBeInTheDocument(); + }); + + it("renders the close button with correct aria-label", () => { + renderModal(); + expect( + screen.getByRole("button", { name: /close announcement/i }), + ).toBeInTheDocument(); + }); + + it("renders the 'Got it' button", () => { + renderModal(); + expect( + screen.getByRole("button", { name: /got it/i }), + ).toBeInTheDocument(); + }); + }); + + describe("accessibility", () => { + it("dialog is labelled by the title element", () => { + renderModal({ + announcement: makeAnnouncement({ title: "My Announcement" }), + }); + const dialog = screen.getByRole("dialog"); + const labelledById = dialog.getAttribute("aria-labelledby"); + expect(labelledById).toBeTruthy(); + + const titleEl = document.getElementById(labelledById!); + expect(titleEl).not.toBeNull(); + expect(titleEl!.textContent).toBe("My Announcement"); + }); + }); + + describe("close interactions", () => { + it("calls onClose when the close button is clicked", async () => { + const user = userEvent.setup(); + const { onClose } = renderModal(); + + await user.click( + screen.getByRole("button", { name: /close announcement/i }), + ); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("calls onClose when 'Got it' is clicked", async () => { + const user = userEvent.setup(); + const { onClose } = renderModal(); + + await user.click(screen.getByRole("button", { name: /got it/i })); + + expect(onClose).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx b/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx new file mode 100644 index 00000000000..77529fb3582 --- /dev/null +++ b/ui-react/apps/console/src/components/announcements/__tests__/AnnouncementModalTrigger.test.tsx @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { Announcement } from "@/client"; + +// ── Dependency mocks ────────────────────────────────────────────────────────── + +vi.mock("@/env", () => ({ getConfig: vi.fn() })); + +vi.mock("@/hooks/useLatestAnnouncement", () => ({ + useLatestAnnouncement: vi.fn(), +})); + +// Replace AnnouncementModal with a simple stub that exposes the open state +// and wires up onClose — we don't need to render the full modal here. +vi.mock("../AnnouncementModal", () => ({ + default: ({ + open, + onClose, + announcement, + }: { + open: boolean; + onClose: () => void; + announcement: Announcement; + }) => + open ? ( +
+ {announcement.title} + +
+ ) : null, +})); + +import { getConfig } from "@/env"; +import { useLatestAnnouncement } from "@/hooks/useLatestAnnouncement"; +import AnnouncementModalTrigger from "../AnnouncementModalTrigger"; + +const mockGetConfig = vi.mocked(getConfig); +const mockUseLatestAnnouncement = vi.mocked(useLatestAnnouncement); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeAnnouncement(overrides: Partial = {}): Announcement { + return { + uuid: "ann-uuid-1", + title: "Test Announcement", + content: "## Content", + date: "2024-06-01T00:00:00Z", + ...overrides, + }; +} + +function computeHash(announcement: Announcement): string { + return btoa(JSON.stringify(announcement)); +} + +// ── Setup / teardown ────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + + // Default: feature enabled, no announcement available + mockGetConfig.mockReturnValue({ announcements: true } as ReturnType< + typeof getConfig + >); + mockUseLatestAnnouncement.mockReturnValue({ + announcement: null, + isLoading: false, + }); +}); + +afterEach(cleanup); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("AnnouncementModalTrigger", () => { + describe("when announcements feature flag is disabled", () => { + it("renders nothing without calling any hooks", () => { + mockGetConfig.mockReturnValue({ announcements: false } as ReturnType< + typeof getConfig + >); + + render(); + + expect( + screen.queryByTestId("announcement-modal"), + ).not.toBeInTheDocument(); + // The inner component (which calls the hook) must not have mounted + expect(mockUseLatestAnnouncement).not.toHaveBeenCalled(); + }); + }); + + describe("when no announcement is available", () => { + it("renders nothing", () => { + mockUseLatestAnnouncement.mockReturnValue({ + announcement: null, + isLoading: false, + }); + + render(); + + expect( + screen.queryByTestId("announcement-modal"), + ).not.toBeInTheDocument(); + }); + }); + + describe("when an announcement is available and already seen", () => { + it("renders nothing when the stored hash matches", () => { + const ann = makeAnnouncement(); + localStorage.setItem("announcement", computeHash(ann)); + mockUseLatestAnnouncement.mockReturnValue({ + announcement: ann, + isLoading: false, + }); + + render(); + + expect( + screen.queryByTestId("announcement-modal"), + ).not.toBeInTheDocument(); + }); + }); + + describe("when a new (unseen) announcement is available", () => { + it("shows the modal", () => { + const ann = makeAnnouncement(); + mockUseLatestAnnouncement.mockReturnValue({ + announcement: ann, + isLoading: false, + }); + + render(); + + expect(screen.getByTestId("announcement-modal")).toBeInTheDocument(); + }); + + it("passes the announcement title to the modal", () => { + const ann = makeAnnouncement({ title: "Important Update" }); + mockUseLatestAnnouncement.mockReturnValue({ + announcement: ann, + isLoading: false, + }); + + render(); + + expect(screen.getByTestId("modal-title")).toHaveTextContent( + "Important Update", + ); + }); + + it("shows the modal when localStorage has a hash for a different announcement", () => { + const old = makeAnnouncement({ uuid: "old-uuid", title: "Old" }); + localStorage.setItem("announcement", computeHash(old)); + + const fresh = makeAnnouncement({ uuid: "new-uuid", title: "New" }); + mockUseLatestAnnouncement.mockReturnValue({ + announcement: fresh, + isLoading: false, + }); + + render(); + + expect(screen.getByTestId("announcement-modal")).toBeInTheDocument(); + }); + }); + + describe("on modal close", () => { + it("hides the modal after it is closed", async () => { + const user = userEvent.setup(); + const ann = makeAnnouncement(); + mockUseLatestAnnouncement.mockReturnValue({ + announcement: ann, + isLoading: false, + }); + + render(); + expect(screen.getByTestId("announcement-modal")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + await waitFor(() => { + expect( + screen.queryByTestId("announcement-modal"), + ).not.toBeInTheDocument(); + }); + }); + + it("stores the announcement hash in localStorage when closed", async () => { + const user = userEvent.setup(); + const ann = makeAnnouncement(); + mockUseLatestAnnouncement.mockReturnValue({ + announcement: ann, + isLoading: false, + }); + + render(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + await waitFor(() => { + expect(localStorage.getItem("announcement")).toBe(computeHash(ann)); + }); + }); + + it("does not re-show the modal after dismiss within the same render", async () => { + const user = userEvent.setup(); + const ann = makeAnnouncement(); + mockUseLatestAnnouncement.mockReturnValue({ + announcement: ann, + isLoading: false, + }); + + render(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + await waitFor(() => { + expect( + screen.queryByTestId("announcement-modal"), + ).not.toBeInTheDocument(); + }); + + // Hash is now stored — a fresh render should not show the modal + cleanup(); + render(); + expect( + screen.queryByTestId("announcement-modal"), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/ui-react/apps/console/src/components/layout/AppLayout.tsx b/ui-react/apps/console/src/components/layout/AppLayout.tsx index fbc4e7f7486..00d9d331bdb 100644 --- a/ui-react/apps/console/src/components/layout/AppLayout.tsx +++ b/ui-react/apps/console/src/components/layout/AppLayout.tsx @@ -4,6 +4,7 @@ import AppBar from "./AppBar"; import TerminalManager from "../terminal/TerminalManager"; import ConnectivityBanner from "../common/ConnectivityBanner"; import WelcomeWizardTrigger from "../wizard/WelcomeWizardTrigger"; +import AnnouncementModalTrigger from "../announcements/AnnouncementModalTrigger"; import { SidebarMobileDrawer } from "./SidebarShell"; import ChatwootProvider from "./ChatwootProvider"; import { useNamespaces } from "@/hooks/useNamespaces"; @@ -47,10 +48,10 @@ export default function AppLayout() { > )} @@ -69,6 +70,7 @@ export default function AppLayout() {
+ ); diff --git a/ui-react/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts b/ui-react/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts new file mode 100644 index 00000000000..c44ea839cd6 --- /dev/null +++ b/ui-react/apps/console/src/hooks/__tests__/useLatestAnnouncement.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useLatestAnnouncement } from "../useLatestAnnouncement"; +import type { Announcement } from "@/client"; + +// ── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("@/env", () => ({ getConfig: vi.fn() })); + +// Mock the generated query-option factories so we control what queryFn runs +vi.mock("@/client/@tanstack/react-query.gen", () => ({ + listAnnouncementsOptions: vi.fn(), + getAnnouncementOptions: vi.fn(), +})); + +import { getConfig } from "@/env"; +import { + listAnnouncementsOptions, + getAnnouncementOptions, +} from "@/client/@tanstack/react-query.gen"; + +const mockGetConfig = vi.mocked(getConfig); +const mockListAnnouncementsOptions = vi.mocked(listAnnouncementsOptions); +const mockGetAnnouncementOptions = vi.mocked(getAnnouncementOptions); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeAnnouncement(overrides: Partial = {}): Announcement { + return { + uuid: "ann-uuid-1", + title: "Test Announcement", + content: "## Hello\nSome content", + date: "2024-06-01T00:00:00Z", + ...overrides, + }; +} + +/** Returns queryOptions-shaped objects that delegate to the provided fn. */ +function makeListOptions(fn: () => unknown) { + return { + queryKey: ["listAnnouncements"], + queryFn: fn, + }; +} + +function makeDetailOptions(uuid: string, fn: () => unknown) { + return { + queryKey: ["getAnnouncement", uuid], + queryFn: fn, + }; +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, retryDelay: 0 }, + }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + + // Default: feature enabled + mockGetConfig.mockReturnValue({ announcements: true } as ReturnType< + typeof getConfig + >); + + // Default: both option factories return never-resolving promises (loading state) + mockListAnnouncementsOptions.mockReturnValue( + makeListOptions(() => new Promise(() => {})) as never, + ); + mockGetAnnouncementOptions.mockReturnValue( + makeDetailOptions("", () => new Promise(() => {})) as never, + ); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("useLatestAnnouncement", () => { + describe("when announcements feature flag is disabled", () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ announcements: false } as ReturnType< + typeof getConfig + >); + }); + + it("returns null announcement immediately", () => { + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + expect(result.current.announcement).toBeNull(); + }); + + it("returns isLoading false", () => { + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(false); + }); + + it("does not call listAnnouncementsOptions queryFn", () => { + const listFn = vi.fn().mockResolvedValue([]); + mockListAnnouncementsOptions.mockReturnValue( + makeListOptions(listFn) as never, + ); + + renderHook(() => useLatestAnnouncement(), { wrapper: createWrapper() }); + + expect(listFn).not.toHaveBeenCalled(); + }); + }); + + describe("loading state", () => { + it("returns isLoading true while list query is pending", () => { + mockListAnnouncementsOptions.mockReturnValue( + makeListOptions(() => new Promise(() => {})) as never, + ); + + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + }); + + it("returns null announcement while queries are loading", () => { + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + expect(result.current.announcement).toBeNull(); + }); + }); + + describe("when list resolves but is empty", () => { + it("returns null announcement", async () => { + mockListAnnouncementsOptions.mockReturnValue( + makeListOptions(() => Promise.resolve([])) as never, + ); + + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.announcement).toBeNull(); + }); + + it("does not call getAnnouncementOptions queryFn", async () => { + const detailFn = vi.fn().mockResolvedValue(makeAnnouncement()); + mockListAnnouncementsOptions.mockReturnValue( + makeListOptions(() => Promise.resolve([])) as never, + ); + mockGetAnnouncementOptions.mockReturnValue( + makeDetailOptions("", detailFn) as never, + ); + + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(detailFn).not.toHaveBeenCalled(); + }); + }); + + describe("when both queries resolve successfully", () => { + it("returns the full announcement object", async () => { + const ann = makeAnnouncement({ uuid: "ann-abc", title: "Big Update" }); + + mockListAnnouncementsOptions.mockReturnValue( + makeListOptions(() => Promise.resolve([{ uuid: "ann-abc" }])) as never, + ); + mockGetAnnouncementOptions.mockReturnValue( + makeDetailOptions("ann-abc", () => Promise.resolve(ann)) as never, + ); + + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.announcement).not.toBeNull()); + expect(result.current.announcement).toEqual(ann); + }); + + it("returns isLoading false after both queries settle", async () => { + const ann = makeAnnouncement(); + + mockListAnnouncementsOptions.mockReturnValue( + makeListOptions(() => + Promise.resolve([{ uuid: "ann-uuid-1" }]), + ) as never, + ); + mockGetAnnouncementOptions.mockReturnValue( + makeDetailOptions("ann-uuid-1", () => Promise.resolve(ann)) as never, + ); + + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.announcement).toEqual(ann); + }); + }); + + describe("when the list query resolves but the detail query is still loading", () => { + it("returns isLoading true", async () => { + mockListAnnouncementsOptions.mockReturnValue( + makeListOptions(() => + Promise.resolve([{ uuid: "ann-uuid-1" }]), + ) as never, + ); + // detail never resolves + mockGetAnnouncementOptions.mockReturnValue( + makeDetailOptions("ann-uuid-1", () => new Promise(() => {})) as never, + ); + + const { result } = renderHook(() => useLatestAnnouncement(), { + wrapper: createWrapper(), + }); + + // Let the list query settle so latestUuid is set and the detail query is enabled + await waitFor(() => + expect(mockGetAnnouncementOptions).toHaveBeenCalledWith( + expect.objectContaining({ path: { uuid: "ann-uuid-1" } }), + ), + ); + + expect(result.current.isLoading).toBe(true); + }); + }); +}); diff --git a/ui-react/apps/console/src/hooks/useLatestAnnouncement.ts b/ui-react/apps/console/src/hooks/useLatestAnnouncement.ts new file mode 100644 index 00000000000..941a10c0972 --- /dev/null +++ b/ui-react/apps/console/src/hooks/useLatestAnnouncement.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import { getConfig } from "@/env"; +import { + listAnnouncementsOptions, + getAnnouncementOptions, +} from "@/client/@tanstack/react-query.gen"; + +export function useLatestAnnouncement() { + const enabled = getConfig().announcements; + + const listResult = useQuery({ + ...listAnnouncementsOptions({ + query: { page: 1, per_page: 1, order_by: "desc" }, + }), + enabled, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: false, + }); + + const latestUuid = listResult.data?.[0]?.uuid; + + const detailResult = useQuery({ + ...getAnnouncementOptions({ path: { uuid: latestUuid ?? "" } }), + enabled: enabled && !!latestUuid, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: false, + }); + + return { + announcement: detailResult.data ?? null, + isLoading: + listResult.isLoading || (!!latestUuid && detailResult.isLoading), + }; +}