From f2cd046b2b39e27f4aaabcacc88f44acaac42477 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 12 Mar 2025 14:36:33 -0300 Subject: [PATCH] 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. --- site/package.json | 1 + site/pnpm-lock.yaml | 71 +++++++ site/src/components/Button/Button.tsx | 1 + site/src/components/ScrollArea/ScrollArea.tsx | 46 +++++ .../InboxButton.stories.tsx | 18 ++ .../NotificationsInbox/InboxButton.tsx | 30 +++ .../NotificationsInbox/InboxItem.stories.tsx | 77 ++++++++ .../NotificationsInbox/InboxItem.tsx | 68 +++++++ .../InboxPopover.stories.tsx | 125 +++++++++++++ .../NotificationsInbox/InboxPopover.tsx | 123 +++++++++++++ .../NotificationsInbox.stories.tsx | 173 ++++++++++++++++++ .../NotificationsInbox/NotificationsInbox.tsx | 109 +++++++++++ .../UnreadBadge.stories.tsx | 22 +++ .../NotificationsInbox/UnreadBadge.tsx | 25 +++ .../notifications/NotificationsInbox/types.ts | 12 ++ site/src/testHelpers/entities.ts | 29 +++ 16 files changed, 930 insertions(+) create mode 100644 site/src/components/ScrollArea/ScrollArea.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/InboxButton.stories.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/InboxButton.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/InboxItem.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/UnreadBadge.stories.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/UnreadBadge.tsx create mode 100644 site/src/modules/notifications/NotificationsInbox/types.ts diff --git a/site/package.json b/site/package.json index 2a5899198e..109e1aab75 100644 --- a/site/package.json +++ b/site/package.json @@ -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", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 0e554cb233..70c29f61f1 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -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 diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx index 23803b89ad..d9daae9c59 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -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: { diff --git a/site/src/components/ScrollArea/ScrollArea.tsx b/site/src/components/ScrollArea/ScrollArea.tsx new file mode 100644 index 0000000000..d4544a0ca2 --- /dev/null +++ b/site/src/components/ScrollArea/ScrollArea.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +export const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); diff --git a/site/src/modules/notifications/NotificationsInbox/InboxButton.stories.tsx b/site/src/modules/notifications/NotificationsInbox/InboxButton.stories.tsx new file mode 100644 index 0000000000..0a7c3af728 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxButton.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InboxButton } from "./InboxButton"; + +const meta: Meta = { + title: "modules/notifications/NotificationsInbox/InboxButton", + component: InboxButton, +}; + +export default meta; +type Story = StoryObj; + +export const AllRead: Story = {}; + +export const Unread: Story = { + args: { + unreadCount: 3, + }, +}; diff --git a/site/src/modules/notifications/NotificationsInbox/InboxButton.tsx b/site/src/modules/notifications/NotificationsInbox/InboxButton.tsx new file mode 100644 index 0000000000..8bc59303f8 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxButton.tsx @@ -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( + ({ unreadCount, ...props }, ref) => { + return ( + + ); + }, +); diff --git a/site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx b/site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx new file mode 100644 index 0000000000..f7524e0146 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx @@ -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 = { + title: "modules/notifications/NotificationsInbox/InboxItem", + component: InboxItem, + render: (args) => { + return ( +
+ +
+ ); + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, + }, +}; diff --git a/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx b/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx new file mode 100644 index 0000000000..2086a5f0a7 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx @@ -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 = ({ + notification, + onMarkNotificationAsRead, +}) => { + return ( +
+
+ +
+ +
+ + {notification.content} + +
+ {notification.actions.map((action) => { + return ( + + ); + })} +
+
+ +
+ {notification.read_status === "unread" && ( + <> +
+ Unread +
+ + + + )} + + + {relativeTime(new Date(notification.created_at))} + +
+
+ ); +}; diff --git a/site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx b/site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx new file mode 100644 index 0000000000..0e40b25f0f --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx @@ -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 = { + title: "modules/notifications/NotificationsInbox/InboxPopover", + component: InboxPopover, + args: { + defaultOpen: true, + }, + render: (args) => { + return ( +
+
+ +
+
+ ); + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, + }, +}; diff --git a/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx b/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx new file mode 100644 index 0000000000..2b94380ef7 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx @@ -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 = ({ + defaultOpen, + unreadCount, + notifications, + error, + onRetry, + onMarkAllAsRead, + onMarkNotificationAsRead, +}) => { + return ( + + + + + + {/* + * 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 + */} + +
+
+ Inbox + {unreadCount > 0 && } +
+ +
+ + +
+
+ + {notifications ? ( + notifications.length > 0 ? ( +
[role=menuitem]]:border-0 [&>[role=menuitem]:not(:last-child)]:border-b", + "[&>[role=menuitem]]:border-solid [&>[role=menuitem]]:border-border", + ])} + > + {notifications.map((notification) => ( + + ))} +
+ ) : ( +
+
+ No notifications + + New notifications will be displayed here. + +
+
+ ) + ) : error === undefined ? ( +
+ + Loading notifications... +
+ ) : ( +
+
+ Error loading notifications + + Click on the button below to retry + +
+ +
+
+
+ )} +
+
+
+ ); +}; diff --git a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx new file mode 100644 index 0000000000..18663d521d --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx @@ -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 = { + title: "modules/notifications/NotificationsInbox/NotificationsInbox", + component: NotificationsInbox, + render: (args) => { + return ( +
+
+ +
+
+ ); + }, +}; + +export default meta; +type Story = StoryObj; + +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"); + }, +}; diff --git a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx new file mode 100644 index 0000000000..cbd573e155 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx @@ -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; + markAllAsRead: () => Promise; + markNotificationAsRead: (notificationId: string) => Promise; +}; + +export const NotificationsInbox: FC = ({ + 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( + NOTIFICATIONS_QUERY_KEY, + (prev) => { + if (!prev) { + return { notifications: [], unread_count: 0 }; + } + return callback(prev); + }, + ); + } + + return ( + + ); +}; diff --git a/site/src/modules/notifications/NotificationsInbox/UnreadBadge.stories.tsx b/site/src/modules/notifications/NotificationsInbox/UnreadBadge.stories.tsx new file mode 100644 index 0000000000..1b1ab7c5f3 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/UnreadBadge.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { UnreadBadge } from "./UnreadBadge"; + +const meta: Meta = { + title: "modules/notifications/NotificationsInbox/UnreadBadge", + component: UnreadBadge, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + count: 3, + }, +}; + +export const MoreThanNine: Story = { + args: { + count: 12, + }, +}; diff --git a/site/src/modules/notifications/NotificationsInbox/UnreadBadge.tsx b/site/src/modules/notifications/NotificationsInbox/UnreadBadge.tsx new file mode 100644 index 0000000000..e9d463de30 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/UnreadBadge.tsx @@ -0,0 +1,25 @@ +import type { FC, HTMLProps } from "react"; +import { cn } from "utils/cn"; + +type UnreadBadgeProps = { + count: number; +} & HTMLProps; + +export const UnreadBadge: FC = ({ + count, + className, + ...props +}) => { + return ( + + {count > 9 ? "9+" : count} + + ); +}; diff --git a/site/src/modules/notifications/NotificationsInbox/types.ts b/site/src/modules/notifications/NotificationsInbox/types.ts new file mode 100644 index 0000000000..168d814857 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/types.ts @@ -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; + }[]; +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index d2125baab3..ef18611cae 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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(); +}