diff --git a/site/package.json b/site/package.json index 2a5899198e5a1..109e1aab752ee 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 0e554cb233e2e..70c29f61f19a0 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 23803b89add15..d9daae9c59252 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 0000000000000..d4544a0ca2d33 --- /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 0000000000000..0a7c3af728e9e --- /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 0000000000000..8bc59303f8aff --- /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 0000000000000..f7524e0146a45 --- /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 0000000000000..2086a5f0a7fed --- /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 0000000000000..0e40b25f0fb53 --- /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 0000000000000..2b94380ef7e7a --- /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 0000000000000..18663d521d8da --- /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 0000000000000..cbd573e155956 --- /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 0000000000000..1b1ab7c5f3d2e --- /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 0000000000000..e9d463de30151 --- /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 0000000000000..168d81485791f --- /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 d2125baab39d6..ef18611caeb8a 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(); +}