Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/AppMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -128,6 +129,7 @@ const DefaultAppMenu = () => {

<EditorVersionToggle />
<RunVersionToggle />
<NoticeInbox />

{/* Settings & status */}
{isOnSettingsRoute ? (
Expand Down
212 changes: 212 additions & 0 deletions src/components/shared/Notices/NoticeInbox.test.tsx
Original file line number Diff line number Diff line change
@@ -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("<NoticeInbox />", () => {
beforeEach(() => {
localStorage.clear();
});

afterEach(() => {
cleanup();
delete window.__TANGLE_NOTICE_SOURCE__;
resetNoticeInboxForTests();
resetNoticeStateForTests();
});

it("stays in the header with nothing to show", () => {
render(<NoticeInbox />);

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(<NoticeInbox />);

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(<NoticeInbox />);
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(<NoticeInbox />);
fireEvent.click(screen.getByTestId("notice-inbox-trigger"));

expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument();

cleanup();
render(<NoticeInbox />);

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(<NoticeInbox />);
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(<NoticeInbox />);

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(<NoticeInbox />);
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(<NoticeInbox />);
fireEvent.click(screen.getByTestId("notice-inbox-trigger"));

expect(screen.queryByTestId("notice-inbox-unread")).not.toBeInTheDocument();

cleanup();
render(<NoticeInbox />);
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(<NoticeInbox />);
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(<NoticeInbox />);

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(<NoticeInbox />);
fireEvent.click(screen.getByTestId("notice-inbox-trigger"));

expect(
screen.getAllByTestId("info-box-title").map((el) => el.textContent),
).toEqual(["Error", "Warning", "Info"]);
});
});
91 changes: 91 additions & 0 deletions src/components/shared/Notices/NoticeInbox.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Popover open={isOpen} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<TooltipButton
tooltip="Notices"
variant="header"
className="relative"
aria-label={label}
data-testid="notice-inbox-trigger"
{...tracking("header.notices")}
>
<Icon name="Megaphone" />
{unreadCount > 0 && (
<Badge
size="xs"
shape="rounded"
variant="destructive"
position="topright"
data-testid="notice-inbox-unread"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
Comment thread
camielvs marked this conversation as resolved.
</TooltipButton>
</PopoverTrigger>
<PopoverContent
align="end"
collisionPadding={VIEWPORT_GUTTER}
className="w-96"
data-testid="notice-inbox"
>
<BlockStack gap="3">
<Text as="span" size="sm" weight="semibold">
Notices
</Text>
{notices.length === 0 ? (
<Text
as="span"
size="sm"
tone="subdued"
data-testid="notice-inbox-empty"
>
No notices
</Text>
) : (
notices.map((notice) => (
<NoticeCard
key={notice.id}
notice={notice}
onDismiss={
notice.dismissible ? () => dismiss(notice) : undefined
}
/>
))
)}
</BlockStack>
</PopoverContent>
</Popover>
);
};
2 changes: 1 addition & 1 deletion src/components/ui/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading
Loading