diff --git a/src/routes/Settings/SettingsLayout.tsx b/src/routes/Settings/SettingsLayout.tsx index db1e04704b..3697d2738e 100644 --- a/src/routes/Settings/SettingsLayout.tsx +++ b/src/routes/Settings/SettingsLayout.tsx @@ -50,14 +50,22 @@ const AGENT_ITEM: SidebarItem = { testId: "settings-nav-agent", }; +const PROTOTYPE_BANNER_ITEM: SidebarItem = { + to: "/settings/prototype-banner", + label: "Prototype Banner", + icon: "Megaphone", + testId: "settings-nav-prototype-banner", +}; + export function SettingsLayout() { const router = useRouter(); const componentSearchEnabled = useFlagValue("component-search-v2"); const aiAssistantEnabled = useFlagValue("ai-assistant"); - const sidebarItems = - componentSearchEnabled || aiAssistantEnabled - ? [...SIDEBAR_ITEMS, AGENT_ITEM] - : SIDEBAR_ITEMS; + const sidebarItems = [ + ...SIDEBAR_ITEMS, + ...(componentSearchEnabled || aiAssistantEnabled ? [AGENT_ITEM] : []), + PROTOTYPE_BANNER_ITEM, + ]; const handleGoBack = () => { router.history.back(); diff --git a/src/routes/Settings/sections/PrototypeBannerSettings.test.tsx b/src/routes/Settings/sections/PrototypeBannerSettings.test.tsx new file mode 100644 index 0000000000..e8caa1f74b --- /dev/null +++ b/src/routes/Settings/sections/PrototypeBannerSettings.test.tsx @@ -0,0 +1,198 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PrototypeBannerSettings } from "./PrototypeBannerSettings"; + +const SETTING_NAME = "system:web_ui/banners"; + +const fetchWithErrorHandling = vi.hoisted(() => + vi.fn<(url: string, options?: RequestInit) => Promise>(() => + Promise.resolve({}), + ), +); +const mockNotify = vi.hoisted(() => vi.fn()); + +vi.mock("@/utils/fetchWithErrorHandling", () => ({ + fetchWithErrorHandling: (url: string, options?: RequestInit) => + fetchWithErrorHandling(url, options), +})); + +vi.mock("@/hooks/useToastNotification", () => ({ + default: () => mockNotify, +})); + +let backend = { available: true, backendUrl: "https://backend.example" }; + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => backend, +})); + +function renderPanel() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +function typeDraft(text: string) { + fireEvent.change(screen.getByTestId("prototype-banner-input"), { + target: { value: text }, + }); +} + +function patchCalls() { + return fetchWithErrorHandling.mock.calls.filter( + ([, options]) => options?.method === "PATCH", + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + fetchWithErrorHandling.mockResolvedValue({}); + backend = { available: true, backendUrl: "https://backend.example" }; +}); + +describe("PrototypeBannerSettings", () => { + it("shows the newest banner as the latest and the rest as history", async () => { + fetchWithErrorHandling.mockResolvedValue({ + [SETTING_NAME]: [ + { "2026-01-01T00:00:00.000Z": "older banner" }, + { "2026-03-01T00:00:00.000Z": "newest banner" }, + ], + }); + + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("prototype-banner-latest")).toHaveTextContent( + "newest banner", + ), + ); + expect(screen.getByTestId("prototype-banner-latest")).not.toHaveTextContent( + "older banner", + ); + + const history = screen.getByTestId("prototype-banner-history"); + expect(history).toHaveTextContent("newest banner"); + expect(history).toHaveTextContent("older banner"); + expect(screen.getByText("History (2)")).toBeInTheDocument(); + }); + + it("saves a trimmed banner, clears the draft and confirms", async () => { + renderPanel(); + await waitFor(() => + expect(screen.getByTestId("prototype-banner-latest")).toHaveTextContent( + "No banners yet.", + ), + ); + + typeDraft(" runs are delayed "); + fireEvent.click(screen.getByTestId("prototype-banner-save")); + + await waitFor(() => expect(patchCalls()).toHaveLength(1)); + const saved = JSON.parse(patchCalls()[0][1]?.body as string).settings[ + SETTING_NAME + ]; + expect(Object.values(saved[0])).toEqual(["runs are delayed"]); + + await waitFor(() => + expect(screen.getByTestId("prototype-banner-input")).toHaveValue(""), + ); + expect(mockNotify).toHaveBeenCalledWith("Banner saved", "success"); + // The saved banner is the new latest without a reload. + expect(screen.getByTestId("prototype-banner-latest")).toHaveTextContent( + "runs are delayed", + ); + }); + + it("does not save a whitespace-only draft", async () => { + renderPanel(); + await waitFor(() => + expect(screen.getByTestId("prototype-banner-save")).toBeDisabled(), + ); + + typeDraft(" "); + + expect(screen.getByTestId("prototype-banner-save")).toBeDisabled(); + expect(patchCalls()).toHaveLength(0); + }); + + it("reports a failed save instead of clearing the draft", async () => { + fetchWithErrorHandling.mockImplementation((_url, options) => + options?.method === "PATCH" + ? Promise.reject(new Error("backend said no")) + : Promise.resolve({}), + ); + + renderPanel(); + typeDraft("will fail"); + fireEvent.click(screen.getByTestId("prototype-banner-save")); + + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith( + "Failed to save banner: backend said no", + "error", + ), + ); + expect(screen.getByTestId("prototype-banner-input")).toHaveValue( + "will fail", + ); + }); + + it("re-reads the banners when refreshed", async () => { + fetchWithErrorHandling.mockResolvedValueOnce({ + [SETTING_NAME]: [{ "2026-03-01T00:00:00.000Z": "first read" }], + }); + fetchWithErrorHandling.mockResolvedValueOnce({ + [SETTING_NAME]: [{ "2026-04-01T00:00:00.000Z": "second read" }], + }); + + renderPanel(); + await waitFor(() => + expect(screen.getByTestId("prototype-banner-latest")).toHaveTextContent( + "first read", + ), + ); + + fireEvent.click(screen.getByTestId("prototype-banner-refresh")); + + await waitFor(() => + expect(screen.getByTestId("prototype-banner-latest")).toHaveTextContent( + "second read", + ), + ); + }); + + it("renders every banner when two share a timestamp", async () => { + const sameTs = "2026-03-01T00:00:00.000Z"; + fetchWithErrorHandling.mockResolvedValue({ + [SETTING_NAME]: [{ [sameTs]: "banner A" }, { [sameTs]: "banner B" }], + }); + + renderPanel(); + + await waitFor(() => + expect(screen.getByText("History (2)")).toBeInTheDocument(), + ); + const history = screen.getByTestId("prototype-banner-history"); + expect(history).toHaveTextContent("banner A"); + expect(history).toHaveTextContent("banner B"); + }); + + it("asks for a backend instead of reading settings when none is connected", () => { + backend = { available: false, backendUrl: "" }; + + renderPanel(); + + expect( + screen.getByText("Connect a backend to read and write banners."), + ).toBeInTheDocument(); + expect(screen.queryByTestId("prototype-banner-input")).toBeNull(); + expect(fetchWithErrorHandling).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/Settings/sections/PrototypeBannerSettings.tsx b/src/routes/Settings/sections/PrototypeBannerSettings.tsx new file mode 100644 index 0000000000..2f08a1ce98 --- /dev/null +++ b/src/routes/Settings/sections/PrototypeBannerSettings.tsx @@ -0,0 +1,174 @@ +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; +import { useBackend } from "@/providers/BackendProvider"; +import { SYSTEM_UI_USER_ID } from "@/utils/constants"; + +import { + bannerText, + bannerTimestamp, + latestBanner, + type PrototypeBanner, + usePrototypeBanners, + useSavePrototypeBanner, +} from "./prototypeBanners"; + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleString(); +} + +function BannerRow({ banner }: { banner: PrototypeBanner }) { + return ( + + + {formatTimestamp(bannerTimestamp(banner))} + + {bannerText(banner)} + + ); +} + +/** + * Prototype panel for banner content served from the backend instead of a + * frontend deploy. Banners are stored as one JSON list in user settings under + * the `system:web_ui` namespace. + */ +export function PrototypeBannerSettings() { + const notify = useToastNotification(); + const { available } = useBackend(); + const [draft, setDraft] = useState(""); + + const { + data: banners = [], + isLoading, + isFetching, + error, + refetch, + } = usePrototypeBanners(); + const { mutate: saveBanner, isPending } = useSavePrototypeBanner(); + + const latest = latestBanner(banners); + const trimmedDraft = draft.trim(); + + const handleSave = () => { + if (!trimmedDraft) return; + + saveBanner(trimmedDraft, { + onSuccess: () => { + setDraft(""); + notify("Banner saved", "success"); + }, + onError: (saveError) => { + notify(`Failed to save banner: ${saveError.message}`, "error"); + }, + }); + }; + + return ( + + + Prototype Banner + + Store banner text in the backend so it can change without a frontend + deploy. Banners are kept as a history list under the{" "} + {SYSTEM_UI_USER_ID} settings namespace. + + + + + + {!available ? ( + + Connect a backend to read and write banners. + + ) : ( + + + + Latest banner + + +
+ {isLoading ? ( + + Loading… + + ) : error ? ( + + {error.message} + + ) : latest ? ( + + ) : ( + + No banners yet. + + )} +
+
+ + + + + New banner +