Skip to content
Closed
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
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"ignore": [
"src/api/**",
"src/components/ui/**",
"src/config/announcements.ts",
"src/config/banners.ts",
"src/config/preSubmitHooks.ts",
"src/components/shared/BetaFeatureWrapper/BetaFeatureWrapper.tsx"
],
Expand Down
3 changes: 2 additions & 1 deletion react-compiler.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export const REACT_COMPILER_ENABLED_DIRS = [
"src/components/shared/Dialogs/PipelineNameDialog.tsx",
"src/components/shared/SecretsManagement/components/SecretsBackendUnavailable.tsx",
"src/components/shared/HighlightText.tsx",
"src/components/shared/AnnouncementBanners.tsx",
"src/components/shared/Banners",
"src/components/shared/Markdown",
"src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection",
"src/components/ui/typography.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 @@ -9,6 +9,7 @@ import logo from "/Tangle_white.png";
import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill";
import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication";
import { BannerInbox } from "@/components/shared/Banners/BannerInbox";
import { CopyText } from "@/components/shared/CopyText/CopyText";
import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle";
import ImportPipeline from "@/components/shared/ImportPipeline";
Expand Down Expand Up @@ -128,6 +129,7 @@ const DefaultAppMenu = () => {

<EditorVersionToggle />
<RunVersionToggle />
<BannerInbox />

{/* Settings & status */}
{isOnSettingsRoute ? (
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { ToastContainer } from "react-toastify";

import { BannerRegion } from "@/components/shared/Banners/BannerRegion";
import { ConfirmDialogBridge } from "@/components/shared/ConfirmDialogBridge/ConfirmDialogBridge";
import { useClickTracking } from "@/hooks/useClickTracking";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
Expand Down Expand Up @@ -37,6 +38,7 @@ function RootLayoutContent() {

<div className="App flex flex-col min-h-screen w-full">
<AppMenu />
<BannerRegion />

<main className="flex-1 grid">
<Outlet />
Expand Down
64 changes: 0 additions & 64 deletions src/components/shared/AnnouncementBanners.tsx

This file was deleted.

51 changes: 51 additions & 0 deletions src/components/shared/Banners/BannerCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { InfoBox } from "@/components/shared/InfoBox";
import { UntrustedMarkdown } from "@/components/shared/Markdown/Markdown";
import { BlockStack } from "@/components/ui/layout";
import { Link } from "@/components/ui/link";
import type { TangleBanner } from "@/config/banners";

interface BannerCardProps {
banner: TangleBanner;
bodyClassName?: string;
onDismiss?: () => void;
}

export const BannerCard = ({
banner,
bodyClassName,
onDismiss,
}: BannerCardProps) => {
const hasBody = banner.body.trim().length > 0;

return (
<InfoBox
title={banner.title}
variant={banner.variant}
width="full"
onDismiss={onDismiss}
>
<BlockStack gap="1">
{hasBody && (
<div className={bodyClassName}>
<UntrustedMarkdown body={banner.body} />
</div>
)}
{banner.action && (
<Link
href={banner.action.url}
size="sm"
variant="primary"
external
aria-label={
banner.title
? `${banner.action.text}: ${banner.title}`
: banner.action.text
}
>
{banner.action.text}
</Link>
)}
</BlockStack>
</InfoBox>
);
};
165 changes: 165 additions & 0 deletions src/components/shared/Banners/BannerInbox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { installSource } from "@/config/bannerTestSource";

import { BannerInbox } from "./BannerInbox";

class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}

vi.stubGlobal("ResizeObserver", ResizeObserverMock);

describe("<BannerInbox />", () => {
beforeEach(() => {
localStorage.clear();
});

afterEach(() => {
cleanup();
delete window.__TANGLE_BANNER_SOURCE__;
});

it("renders nothing when there are no banners", () => {
const { container } = render(<BannerInbox />);

expect(container).toBeEmptyDOMElement();
});

it("counts the banners the reader has not opened yet", () => {
installSource([
{ id: "a", title: "One", body: "" },
{ id: "b", title: "Two", body: "" },
]);

render(<BannerInbox />);

expect(screen.getByTestId("banner-inbox-unread")).toHaveTextContent("2");
expect(screen.getByTestId("banner-inbox-trigger")).toHaveAttribute(
"aria-label",
"Notices, 2 unread",
);
});

it("lists every banner in full once opened", () => {
installSource([
{ id: "a", title: "One", body: "Body of **one**" },
{ id: "b", title: "Two", body: "Body of two" },
]);

render(<BannerInbox />);
fireEvent.click(screen.getByTestId("banner-inbox-trigger"));

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

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

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

expect(screen.queryByTestId("banner-inbox-unread")).not.toBeInTheDocument();
expect(screen.getByTestId("banner-inbox-trigger")).toHaveAttribute(
"aria-label",
"Notices, 1 active",
);
});

it("lets a dismissible banner be removed from the centre for good", () => {
installSource([
{ id: "a", title: "Optional", body: "", dismissible: true },
{ id: "b", title: "Mandatory", body: "" },
]);

render(<BannerInbox />);
fireEvent.click(screen.getByTestId("banner-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(<BannerInbox />);

expect(screen.getByTestId("banner-inbox-trigger")).toHaveAttribute(
"aria-label",
"Notices, 1 active",
);
});

// A dismiss that cannot be persisted is held in memory for the lifetime of the
// module, so this case needs an id no other test reuses.
it("removes a dismissed banner even when it cannot be persisted", () => {
installSource([
{ id: "unpersistable", title: "Optional", body: "", dismissible: true },
]);

render(<BannerInbox />);
fireEvent.click(screen.getByTestId("banner-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("puts a hidden strip back on the page", () => {
localStorage.setItem("hidden-banners", JSON.stringify(["a"]));
installSource([{ id: "a", title: "One", body: "", dismissible: true }]);

render(<BannerInbox />);
fireEvent.click(screen.getByTestId("banner-inbox-trigger"));
fireEvent.click(screen.getByTestId("banner-inbox-show"));

expect(screen.queryByTestId("banner-inbox-show")).not.toBeInTheDocument();
expect(screen.getByTestId("banner-inbox-hide")).toBeInTheDocument();
expect(localStorage.getItem("hidden-banners")).toBe("[]");
});

it("hides a showing strip from the centre, and offers to bring it back", () => {
installSource([{ id: "a", title: "One", body: "", dismissible: true }]);

render(<BannerInbox />);
fireEvent.click(screen.getByTestId("banner-inbox-trigger"));

expect(screen.queryByTestId("banner-inbox-show")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("banner-inbox-hide"));

expect(screen.getByTestId("banner-inbox-show")).toBeInTheDocument();
expect(localStorage.getItem("hidden-banners")).toContain("a");
});

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

expect(
screen.getAllByTestId("info-box-title").map((el) => el.textContent),
).toEqual(["Error", "Warning", "Info"]);
});
});
Loading
Loading