chore: add notification UI components (#16818)

Related to https://github.com/coder/internal/issues/336

This PR adds the base components for the Notifications UI below (you can
click on the image to open the related Figma design) based on the
response structure defined on this [notion
doc](https://www.notion.so/coderhq/Coder-Inbox-Endpoints-1a1d579be592809eb921f13baf18f783).

[![new notifications including
hover](https://github.com/user-attachments/assets/885fb055-544e-4d9e-b5bf-be986e8b9fc0)](https://www.figma.com/design/5kRpzK8Qr1k38nNz7H0HSh/Inbox-notifications?node-id=2-1098&m=dev)

**What is not included**
- Support for infinite scrolling (pending on BE definition)

**How to test the components?**
- The only way to test the components is to use Chromatic or downloading
the branch and running Storybook locally.
This commit is contained in:
Bruno Quaresma
2025-03-12 14:36:33 -03:00
committed by GitHub
parent 78df7869d5
commit f2cd046b2b
16 changed files with 930 additions and 0 deletions
+1
View File
@@ -56,6 +56,7 @@
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-label": "2.1.0",
"@radix-ui/react-popover": "1.1.5",
"@radix-ui/react-scroll-area": "1.2.3",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.1.1",
+71
View File
@@ -78,6 +78,9 @@ importers:
'@radix-ui/react-popover':
specifier: 1.1.5
version: 1.1.5(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-scroll-area':
specifier: 1.2.3
version: 1.2.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select':
specifier: 2.1.4
version: 2.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1850,6 +1853,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.0.2':
resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==, tarball: https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.1':
resolution: {integrity: sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==, tarball: https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz}
peerDependencies:
@@ -1863,6 +1879,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-scroll-area@1.2.3':
resolution: {integrity: sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==, tarball: https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.3.tgz}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.1.4':
resolution: {integrity: sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ==, tarball: https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz}
peerDependencies:
@@ -1907,6 +1936,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-slot@1.1.2':
resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==, tarball: https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-switch@1.1.1':
resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==, tarball: https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz}
peerDependencies:
@@ -7891,6 +7929,15 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-primitive@2.0.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.1.2(@types/react@18.3.12)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-roving-focus@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.1
@@ -7908,6 +7955,23 @@ snapshots:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-scroll-area@1.2.3(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.0
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.12
'@types/react-dom': 18.3.1
'@radix-ui/react-select@2.1.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.0
@@ -7970,6 +8034,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-slot@1.1.2(@types/react@18.3.12)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.12)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.12
'@radix-ui/react-switch@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.0
+1
View File
@@ -31,6 +31,7 @@ export const buttonVariants = cva(
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg",
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
icon: "size-8 px-1.5 [&_svg]:size-icon-sm",
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg",
},
},
defaultVariants: {
@@ -0,0 +1,46 @@
/**
* Copied from shadc/ui on 03/05/2025
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
*/
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "utils/cn";
export const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
export const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"border-0 border-solid border-border flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react";
import { InboxButton } from "./InboxButton";
const meta: Meta<typeof InboxButton> = {
title: "modules/notifications/NotificationsInbox/InboxButton",
component: InboxButton,
};
export default meta;
type Story = StoryObj<typeof InboxButton>;
export const AllRead: Story = {};
export const Unread: Story = {
args: {
unreadCount: 3,
},
};
@@ -0,0 +1,30 @@
import { Button, type ButtonProps } from "components/Button/Button";
import { BellIcon } from "lucide-react";
import { type FC, forwardRef } from "react";
import { UnreadBadge } from "./UnreadBadge";
type InboxButtonProps = {
unreadCount: number;
} & ButtonProps;
export const InboxButton = forwardRef<HTMLButtonElement, InboxButtonProps>(
({ unreadCount, ...props }, ref) => {
return (
<Button
size="icon-lg"
variant="outline"
className="relative"
ref={ref}
{...props}
>
<BellIcon />
{unreadCount > 0 && (
<UnreadBadge
count={unreadCount}
className="absolute top-0 right-0 -translate-y-1/2 translate-x-1/2"
/>
)}
</Button>
);
},
);
@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import { MockNotification } from "testHelpers/entities";
import { InboxItem } from "./InboxItem";
const meta: Meta<typeof InboxItem> = {
title: "modules/notifications/NotificationsInbox/InboxItem",
component: InboxItem,
render: (args) => {
return (
<div className="max-w-[460px] border-solid border-border rounded">
<InboxItem {...args} />
</div>
);
},
};
export default meta;
type Story = StoryObj<typeof InboxItem>;
export const Read: Story = {
args: {
notification: {
...MockNotification,
read_status: "read",
},
},
};
export const Unread: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
},
},
};
export const UnreadFocus: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const notification = canvas.getByRole("menuitem");
await userEvent.click(notification);
},
};
export const OnMarkNotificationAsRead: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
},
onMarkNotificationAsRead: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const notification = canvas.getByRole("menuitem");
await userEvent.click(notification);
const markButton = canvas.getByRole("button", { name: /mark as read/i });
await userEvent.click(markButton);
await expect(args.onMarkNotificationAsRead).toHaveBeenCalledTimes(1);
await expect(args.onMarkNotificationAsRead).toHaveBeenCalledWith(
args.notification.id,
);
},
parameters: {
chromatic: {
disableSnapshot: true,
},
},
};
@@ -0,0 +1,68 @@
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import { SquareCheckBig } from "lucide-react";
import type { FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { relativeTime } from "utils/time";
import type { Notification } from "./types";
type InboxItemProps = {
notification: Notification;
onMarkNotificationAsRead: (notificationId: string) => void;
};
export const InboxItem: FC<InboxItemProps> = ({
notification,
onMarkNotificationAsRead,
}) => {
return (
<div
className="flex items-stretch gap-3 p-3 group"
role="menuitem"
tabIndex={-1}
>
<div className="flex-shrink-0">
<Avatar fallback="AR" />
</div>
<div className="flex flex-col gap-3">
<span className="text-content-secondary text-sm font-medium">
{notification.content}
</span>
<div className="flex items-center gap-1">
{notification.actions.map((action) => {
return (
<Button variant="outline" size="sm" key={action.label} asChild>
<RouterLink to={action.url}>{action.label}</RouterLink>
</Button>
);
})}
</div>
</div>
<div className="w-12 flex flex-col items-end flex-shrink-0">
{notification.read_status === "unread" && (
<>
<div className="group-focus:hidden group-hover:hidden size-2.5 rounded-full bg-highlight-sky">
<span className="sr-only">Unread</span>
</div>
<Button
onClick={() => onMarkNotificationAsRead(notification.id)}
className="hidden group-focus:flex group-hover:flex bg-surface-primary"
variant="outline"
size="sm"
>
<SquareCheckBig />
mark as read
</Button>
</>
)}
<span className="mt-auto text-content-secondary text-xs font-medium whitespace-nowrap">
{relativeTime(new Date(notification.created_at))}
</span>
</div>
</div>
);
};
@@ -0,0 +1,125 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import { MockNotifications } from "testHelpers/entities";
import { InboxPopover } from "./InboxPopover";
const meta: Meta<typeof InboxPopover> = {
title: "modules/notifications/NotificationsInbox/InboxPopover",
component: InboxPopover,
args: {
defaultOpen: true,
},
render: (args) => {
return (
<div className="w-full max-w-screen-xl p-6 h-[720px]">
<header className="flex justify-end">
<InboxPopover {...args} />
</header>
</div>
);
},
};
export default meta;
type Story = StoryObj<typeof InboxPopover>;
export const Default: Story = {
args: {
unreadCount: 2,
notifications: MockNotifications.slice(0, 3),
},
};
export const Scrollable: Story = {
args: {
unreadCount: 2,
notifications: MockNotifications,
},
};
export const Loading: Story = {
args: {
unreadCount: 0,
notifications: undefined,
},
};
export const LoadingFailure: Story = {
args: {
unreadCount: 0,
notifications: undefined,
error: new Error("Failed to load notifications"),
},
};
export const Empty: Story = {
args: {
unreadCount: 0,
notifications: [],
},
};
export const OnRetry: Story = {
args: {
unreadCount: 0,
notifications: undefined,
error: new Error("Failed to load notifications"),
onRetry: fn(),
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
const retryButton = body.getByRole("button", { name: /retry/i });
await userEvent.click(retryButton);
await expect(args.onRetry).toHaveBeenCalledTimes(1);
},
parameters: {
chromatic: {
disableSnapshot: true,
},
},
};
export const OnMarkAllAsRead: Story = {
args: {
defaultOpen: true,
unreadCount: 2,
notifications: MockNotifications.slice(0, 3),
onMarkAllAsRead: fn(),
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
const markButton = body.getByRole("button", { name: /mark all as read/i });
await userEvent.click(markButton);
await expect(args.onMarkAllAsRead).toHaveBeenCalledTimes(1);
},
parameters: {
chromatic: {
disableSnapshot: true,
},
},
};
export const OnMarkNotificationAsRead: Story = {
args: {
unreadCount: 2,
notifications: MockNotifications.slice(0, 3),
onMarkNotificationAsRead: fn(),
},
play: async ({ canvasElement, args }) => {
const body = within(canvasElement.ownerDocument.body);
const notifications = body.getAllByRole("menuitem");
const secondNotification = notifications[1];
await userEvent.click(secondNotification);
const markButton = body.getByRole("button", { name: /mark as read/i });
await userEvent.click(markButton);
await expect(args.onMarkNotificationAsRead).toHaveBeenCalledTimes(1);
await expect(args.onMarkNotificationAsRead).toHaveBeenCalledWith(
args.notifications?.[1].id,
);
},
parameters: {
chromatic: {
disableSnapshot: true,
},
},
};
@@ -0,0 +1,123 @@
import { Button } from "components/Button/Button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Spinner } from "components/Spinner/Spinner";
import { RefreshCwIcon, SettingsIcon } from "lucide-react";
import type { FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { cn } from "utils/cn";
import { InboxButton } from "./InboxButton";
import { InboxItem } from "./InboxItem";
import { UnreadBadge } from "./UnreadBadge";
import type { Notification } from "./types";
type InboxPopoverProps = {
notifications: Notification[] | undefined;
unreadCount: number;
error: unknown;
onRetry: () => void;
onMarkAllAsRead: () => void;
onMarkNotificationAsRead: (notificationId: string) => void;
defaultOpen?: boolean;
};
export const InboxPopover: FC<InboxPopoverProps> = ({
defaultOpen,
unreadCount,
notifications,
error,
onRetry,
onMarkAllAsRead,
onMarkNotificationAsRead,
}) => {
return (
<Popover defaultOpen={defaultOpen}>
<PopoverTrigger asChild>
<InboxButton unreadCount={unreadCount} />
</PopoverTrigger>
<PopoverContent className="w-[466px]" align="end">
{/*
* data-radix-scroll-area-viewport is used to set the max-height of the ScrollArea
* https://github.com/shadcn-ui/ui/issues/542#issuecomment-2339361283
*/}
<ScrollArea className="[&>[data-radix-scroll-area-viewport]]:max-h-[calc(var(--radix-popover-content-available-height)-24px)]">
<div className="flex items-center justify-between p-3 border-0 border-b border-solid border-border">
<div className="flex items-center gap-2">
<span className="text-xl font-semibold">Inbox</span>
{unreadCount > 0 && <UnreadBadge count={unreadCount} />}
</div>
<div className="flex justify-end gap-1">
<Button
variant="subtle"
size="sm"
disabled={!(notifications && notifications.length > 0)}
onClick={onMarkAllAsRead}
>
Mark all as read
</Button>
<Button variant="outline" size="icon" asChild>
<RouterLink to="/settings/notifications">
<SettingsIcon />
<span className="sr-only">Notification settings</span>
</RouterLink>
</Button>
</div>
</div>
{notifications ? (
notifications.length > 0 ? (
<div
className={cn([
"[&>[role=menuitem]]:border-0 [&>[role=menuitem]:not(:last-child)]:border-b",
"[&>[role=menuitem]]:border-solid [&>[role=menuitem]]:border-border",
])}
>
{notifications.map((notification) => (
<InboxItem
key={notification.id}
notification={notification}
onMarkNotificationAsRead={onMarkNotificationAsRead}
/>
))}
</div>
) : (
<div className="p-6 flex items-center justify-center min-h-48">
<div className="text-sm text-center flex flex-col">
<span className="font-medium">No notifications</span>
<span className="text-xs text-content-secondary">
New notifications will be displayed here.
</span>
</div>
</div>
)
) : error === undefined ? (
<div className="p-6 flex items-center justify-center min-h-48">
<Spinner loading />
<span className="sr-only">Loading notifications...</span>
</div>
) : (
<div className="p-6 flex items-center justify-center min-h-48">
<div className="text-sm text-center flex flex-col">
<span className="font-medium">Error loading notifications</span>
<span className="text-xs text-content-secondary">
Click on the button below to retry
</span>
<div className="mt-3">
<Button size="sm" variant="outline" onClick={onRetry}>
<RefreshCwIcon />
Retry
</Button>
</div>
</div>
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,173 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
import { MockNotifications, mockApiError } from "testHelpers/entities";
import { withGlobalSnackbar } from "testHelpers/storybook";
import { NotificationsInbox } from "./NotificationsInbox";
const meta: Meta<typeof NotificationsInbox> = {
title: "modules/notifications/NotificationsInbox/NotificationsInbox",
component: NotificationsInbox,
render: (args) => {
return (
<div className="w-full max-w-screen-xl p-6 h-[720px]">
<header className="flex justify-end">
<NotificationsInbox {...args} />
</header>
</div>
);
},
};
export default meta;
type Story = StoryObj<typeof NotificationsInbox>;
export const Default: Story = {
args: {
defaultOpen: true,
fetchNotifications: fn(async () => ({
notifications: MockNotifications,
unread_count: 2,
})),
},
};
export const Failure: Story = {
args: {
defaultOpen: true,
fetchNotifications: fn(() => {
throw mockApiError({
message: "Failed to load notifications",
});
}),
},
};
export const FailAndRetry: Story = {
args: {
defaultOpen: true,
fetchNotifications: (() => {
let count = 0;
return fn(async () => {
count += 1;
if (count === 1) {
throw mockApiError({
message: "Failed to load notifications",
});
}
return {
notifications: MockNotifications,
unread_count: 2,
};
});
})(),
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await expect(
body.getByText("Error loading notifications"),
).toBeInTheDocument();
const retryButton = body.getByRole("button", { name: /retry/i });
await userEvent.click(retryButton);
await waitFor(() => {
expect(
body.queryByText("Error loading notifications"),
).not.toBeInTheDocument();
});
},
};
export const MarkAllAsRead: Story = {
args: {
defaultOpen: true,
fetchNotifications: fn(async () => ({
notifications: MockNotifications,
unread_count: 2,
})),
markAllAsRead: fn(),
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
let unreads = await body.findAllByText(/unread/i);
await expect(unreads).toHaveLength(2);
const markAllAsReadButton = body.getByRole("button", {
name: /mark all as read/i,
});
await userEvent.click(markAllAsReadButton);
unreads = body.queryAllByText(/unread/i);
await expect(unreads).toHaveLength(0);
},
};
export const MarkAllAsReadFailure: Story = {
decorators: [withGlobalSnackbar],
args: {
defaultOpen: true,
fetchNotifications: fn(async () => ({
notifications: MockNotifications,
unread_count: 2,
})),
markAllAsRead: fn(async () => {
throw mockApiError({
message: "Failed to mark all notifications as read",
});
}),
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const markAllAsReadButton = body.getByRole("button", {
name: /mark all as read/i,
});
await userEvent.click(markAllAsReadButton);
await body.findByText("Failed to mark all notifications as read");
},
};
export const MarkNotificationAsRead: Story = {
args: {
defaultOpen: true,
fetchNotifications: fn(async () => ({
notifications: MockNotifications,
unread_count: 2,
})),
markNotificationAsRead: fn(),
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const notifications = await body.findAllByRole("menuitem");
const secondNotification = notifications[1];
within(secondNotification).getByText(/unread/i);
await userEvent.click(secondNotification);
const markButton = body.getByRole("button", { name: /mark as read/i });
await userEvent.click(markButton);
await expect(within(secondNotification).queryByText(/unread/i)).toBeNull();
},
};
export const MarkNotificationAsReadFailure: Story = {
decorators: [withGlobalSnackbar],
args: {
defaultOpen: true,
fetchNotifications: fn(async () => ({
notifications: MockNotifications,
unread_count: 2,
})),
markNotificationAsRead: fn(() => {
throw mockApiError({ message: "Failed to mark notification as read" });
}),
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const notifications = await body.findAllByRole("menuitem");
const secondNotification = notifications[1];
await userEvent.click(secondNotification);
const markButton = body.getByRole("button", { name: /mark as read/i });
await userEvent.click(markButton);
await body.findByText("Failed to mark notification as read");
},
};
@@ -0,0 +1,109 @@
import { getErrorDetail, getErrorMessage } from "api/errors";
import { displayError } from "components/GlobalSnackbar/utils";
import type { FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { InboxPopover } from "./InboxPopover";
import type { Notification } from "./types";
const NOTIFICATIONS_QUERY_KEY = ["notifications"];
type NotificationsResponse = {
notifications: Notification[];
unread_count: number;
};
type NotificationsInboxProps = {
defaultOpen?: boolean;
fetchNotifications: () => Promise<NotificationsResponse>;
markAllAsRead: () => Promise<void>;
markNotificationAsRead: (notificationId: string) => Promise<void>;
};
export const NotificationsInbox: FC<NotificationsInboxProps> = ({
defaultOpen,
fetchNotifications,
markAllAsRead,
markNotificationAsRead,
}) => {
const queryClient = useQueryClient();
const {
data: res,
error,
refetch,
} = useQuery({
queryKey: NOTIFICATIONS_QUERY_KEY,
queryFn: fetchNotifications,
});
const markAllAsReadMutation = useMutation({
mutationFn: markAllAsRead,
onSuccess: () => {
safeUpdateNotificationsCache((prev) => {
return {
unread_count: 0,
notifications: prev.notifications.map((n) => ({
...n,
read_status: "read",
})),
};
});
},
onError: (error) => {
displayError(
getErrorMessage(error, "Error on marking all notifications as read"),
getErrorDetail(error),
);
},
});
const markNotificationAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,
onSuccess: (_, notificationId) => {
safeUpdateNotificationsCache((prev) => {
return {
unread_count: prev.unread_count - 1,
notifications: prev.notifications.map((n) => {
if (n.id !== notificationId) {
return n;
}
return { ...n, read_status: "read" };
}),
};
});
},
onError: (error) => {
displayError(
getErrorMessage(error, "Error on marking notification as read"),
getErrorDetail(error),
);
},
});
async function safeUpdateNotificationsCache(
callback: (res: NotificationsResponse) => NotificationsResponse,
) {
await queryClient.cancelQueries(NOTIFICATIONS_QUERY_KEY);
queryClient.setQueryData<NotificationsResponse>(
NOTIFICATIONS_QUERY_KEY,
(prev) => {
if (!prev) {
return { notifications: [], unread_count: 0 };
}
return callback(prev);
},
);
}
return (
<InboxPopover
defaultOpen={defaultOpen}
notifications={res?.notifications}
unreadCount={res?.unread_count ?? 0}
error={error}
onRetry={refetch}
onMarkAllAsRead={markAllAsReadMutation.mutate}
onMarkNotificationAsRead={markNotificationAsReadMutation.mutate}
/>
);
};
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "@storybook/react";
import { UnreadBadge } from "./UnreadBadge";
const meta: Meta<typeof UnreadBadge> = {
title: "modules/notifications/NotificationsInbox/UnreadBadge",
component: UnreadBadge,
};
export default meta;
type Story = StoryObj<typeof UnreadBadge>;
export const Default: Story = {
args: {
count: 3,
},
};
export const MoreThanNine: Story = {
args: {
count: 12,
},
};
@@ -0,0 +1,25 @@
import type { FC, HTMLProps } from "react";
import { cn } from "utils/cn";
type UnreadBadgeProps = {
count: number;
} & HTMLProps<HTMLSpanElement>;
export const UnreadBadge: FC<UnreadBadgeProps> = ({
count,
className,
...props
}) => {
return (
<span
className={cn([
"flex size-[18px] rounded text-2xs items-center justify-center",
"bg-surface-sky text-highlight-sky",
className,
])}
{...props}
>
{count > 9 ? "9+" : count}
</span>
);
};
@@ -0,0 +1,12 @@
// TODO: Remove this file when the types from API are available
export type Notification = {
id: string;
read_status: "read" | "unread";
content: string;
created_at: string;
actions: {
label: string;
url: string;
}[];
};
+29
View File
@@ -7,6 +7,7 @@ import type { FieldError } from "api/errors";
import type * as TypesGen from "api/typesGenerated";
import type { ProxyLatencyReport } from "contexts/useProxyLatency";
import range from "lodash/range";
import type { Notification } from "modules/notifications/NotificationsInbox/types";
import type { Permissions } from "modules/permissions";
import type { OrganizationPermissions } from "modules/permissions/organizations";
import type { FileTree } from "utils/filetree";
@@ -4243,3 +4244,31 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
export const MockNotificationMethodsResponse: TypesGen.NotificationMethodsResponse =
{ available: ["smtp", "webhook"], default: "smtp" };
export const MockNotification: Notification = {
id: "1",
read_status: "unread",
content:
"New user account testuser has been created. This new user account was created for Test User by Kira Pilot.",
created_at: mockTwoDaysAgo(),
actions: [
{
label: "View template",
url: "https://dev.coder.com/templates/coder/coder",
},
],
};
export const MockNotifications: Notification[] = [
MockNotification,
{ ...MockNotification, id: "2", read_status: "unread" },
{ ...MockNotification, id: "3", read_status: "read" },
{ ...MockNotification, id: "4", read_status: "read" },
{ ...MockNotification, id: "5", read_status: "read" },
];
function mockTwoDaysAgo() {
const date = new Date();
date.setDate(date.getDate() - 2);
return date.toISOString();
}