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(); +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy