From 86a09a1e5d919d72df47b3b288aa6d25b55a41bf Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 5 Mar 2025 20:10:46 +0000 Subject: [PATCH 1/5] chore: add notification UI components --- site/package.json | 7 +- site/pnpm-lock.yaml | 71 +++++++ site/src/components/Button/Button.tsx | 3 +- site/src/components/ScrollArea/ScrollArea.tsx | 46 +++++ .../InboxButton.stories.tsx | 18 ++ .../NotificationsInbox/InboxButton.tsx | 30 +++ .../NotificationsInbox/InboxItem.stories.tsx | 74 ++++++++ .../NotificationsInbox/InboxItem.tsx | 68 +++++++ .../InboxPopover.stories.tsx | 129 +++++++++++++ .../NotificationsInbox/InboxPopover.tsx | 125 ++++++++++++ .../NotificationsInbox.stories.tsx | 178 ++++++++++++++++++ .../NotificationsInbox/NotificationsInbox.tsx | 111 +++++++++++ .../UnreadBadge.stories.tsx | 22 +++ .../NotificationsInbox/UnreadBadge.tsx | 25 +++ .../notifications/NotificationsInbox/types.ts | 12 ++ site/src/testHelpers/entities.ts | 29 +++ 16 files changed, 946 insertions(+), 2 deletions(-) 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 892e1d50a005f..5198b24d2b826 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", @@ -189,7 +190,11 @@ "vite-plugin-checker": "0.8.0", "vite-plugin-turbosnap": "1.0.3" }, - "browserslist": ["chrome 110", "firefox 111", "safari 16.0"], + "browserslist": [ + "chrome 110", + "firefox 111", + "safari 16.0" + ], "resolutions": { "optionator": "0.9.3", "semver": "7.6.2" diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 62ae51082e96a..5e5f468a05cf5 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: @@ -7897,6 +7935,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 @@ -7914,6 +7961,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 @@ -7976,6 +8040,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..a199bec0c5799 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -20,7 +20,7 @@ export const buttonVariants = cva( default: "bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold", outline: - "border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary", + "border border-border-default text-content-primary bg-transparent bg-surface-primary hover:bg-surface-secondary", subtle: "border-none bg-transparent text-content-secondary hover:text-content-primary", destructive: @@ -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..711ad3d878c7c --- /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 React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; +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..7bc49bfa0e0d2 --- /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 { forwardRef, type FC } 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..ce93601d467dc --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InboxItem } from "./InboxItem"; +import { fn, userEvent, within, expect } from "@storybook/test"; +import { MockNotification } from "testHelpers/entities"; + +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, + }, +}; + +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..ea3d76fa6c3a0 --- /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 type { Notification } from "./types"; +import { relativeTime } from "utils/time"; +import { Link as RouterLink } from "react-router-dom"; + +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..d667f8d750356 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx @@ -0,0 +1,129 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { InboxPopover } from "./InboxPopover"; +import { MockNotifications } from "testHelpers/entities"; +import { fn, userEvent, within, expect } from "@storybook/test"; + +const meta: Meta = { + title: "modules/notifications/NotificationsInbox/InboxPopover", + component: InboxPopover, + render: (args) => { + return ( +
+
+ +
+
+ ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultOpen: true, + unreadCount: 2, + notifications: MockNotifications.slice(0, 3), + }, +}; + +export const Scrollable: Story = { + args: { + defaultOpen: true, + unreadCount: 2, + notifications: MockNotifications, + }, +}; + +export const Loading: Story = { + args: { + defaultOpen: true, + unreadCount: 0, + notifications: undefined, + }, +}; + +export const LoadingFailure: Story = { + args: { + defaultOpen: true, + unreadCount: 0, + notifications: undefined, + error: new Error("Failed to load notifications"), + }, +}; + +export const Empty: Story = { + args: { + defaultOpen: true, + unreadCount: 0, + notifications: [], + }, +}; + +export const OnRetry: Story = { + args: { + defaultOpen: true, + 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: { + defaultOpen: true, + 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..fba38503f91eb --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx @@ -0,0 +1,125 @@ +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; +import type { FC } from "react"; +import { InboxButton } from "./InboxButton"; +import { UnreadBadge } from "./UnreadBadge"; +import { Button } from "components/Button/Button"; +import { RefreshCwIcon, SettingsIcon } from "lucide-react"; +import { InboxItem } from "./InboxItem"; +import type { Notification } from "./types"; +import { cn } from "utils/cn"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { Spinner } from "components/Spinner/Spinner"; +import { Link as RouterLink } from "react-router-dom"; + +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) => { + return ( + + ); + })} +
+ ) : ( +
+
+ 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..7725a0261b0bc --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx @@ -0,0 +1,178 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NotificationsInbox } from "./NotificationsInbox"; +import { mockApiError, MockNotifications } from "testHelpers/entities"; +import { fn, userEvent, within, expect } from "@storybook/test"; +import { withGlobalSnackbar } from "testHelpers/storybook"; + +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(() => + Promise.resolve({ notifications: MockNotifications, unread_count: 2 }), + ), + }, +}; + +export const Failure: Story = { + args: { + defaultOpen: true, + fetchNotifications: fn(() => + Promise.reject( + mockApiError({ + message: "Failed to load notifications", + }), + ), + ), + }, +}; + +export const FailAndRetry: Story = { + args: { + defaultOpen: true, + fetchNotifications: (() => { + let count = 0; + + return fn(() => { + count += 1; + + // Fail on the first 3 attempts + // 3 is the maximum number of retries from react-query + if (count < 3) { + return Promise.reject( + mockApiError({ + message: "Failed to load notifications", + }), + ); + } + + return Promise.resolve({ + 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 expect( + body.queryByText("Error loading notifications"), + ).not.toBeInTheDocument(); + }, +}; + +export const MarkAllAsRead: Story = { + args: { + defaultOpen: true, + fetchNotifications: fn(() => + Promise.resolve({ notifications: MockNotifications, unread_count: 2 }), + ), + markAllAsRead: fn(() => Promise.resolve()), + }, + 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(() => + Promise.resolve({ notifications: MockNotifications, unread_count: 2 }), + ), + markAllAsRead: fn(() => + Promise.reject( + 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(() => + Promise.resolve({ notifications: MockNotifications, unread_count: 2 }), + ), + markNotificationAsRead: fn(() => + // true as true is necessary to solve a really strange TypeScript error + // https://stackoverflow.com/questions/75864591/type-boolean-is-not-assignable-to-type-true + Promise.resolve({ is_read: true as true }), + ), + }, + 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(() => + Promise.resolve({ notifications: MockNotifications, unread_count: 2 }), + ), + markNotificationAsRead: fn(() => + Promise.reject( + 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..4382bdd0d5947 --- /dev/null +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx @@ -0,0 +1,111 @@ +import type { FC } from "react"; +import { InboxPopover } from "./InboxPopover"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import type { Notification } from "./types"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { getErrorDetail, getErrorMessage } from "api/errors"; + +const NOTIFICATIONS_QUERY_KEY = ["notifications"]; + +type NotificationsResponse = { + notifications: Notification[]; + unread_count: number; +}; + +type NotificationsInboxProps = { + defaultOpen?: boolean; + fetchNotifications: () => Promise; + markAllAsRead: () => Promise; + markNotificationAsRead: ( + notificationId: string, + ) => Promise<{ is_read: true }>; +}; + +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 aa87ac7fbf6fc..e5aa38f8561f1 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -9,6 +9,7 @@ import type { Permissions } from "contexts/auth/permissions"; import type { ProxyLatencyReport } from "contexts/useProxyLatency"; import range from "lodash/range"; import type { OrganizationPermissions } from "modules/management/organizationPermissions"; +import type { Notification } from "modules/notifications/NotificationsInbox/types"; import type { FileTree } from "utils/filetree"; import type { TemplateVersionFiles } from "utils/templateVersion"; @@ -4242,3 +4243,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(); +} From 0079e8e0b9523e4b8e7b0fa077efacfe2032415c Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 5 Mar 2025 20:20:49 +0000 Subject: [PATCH 2/5] FMT --- site/package.json | 6 +----- site/src/components/ScrollArea/ScrollArea.tsx | 2 +- .../NotificationsInbox/InboxButton.tsx | 2 +- .../NotificationsInbox/InboxItem.stories.tsx | 4 ++-- .../notifications/NotificationsInbox/InboxItem.tsx | 4 ++-- .../NotificationsInbox/InboxPopover.stories.tsx | 4 ++-- .../NotificationsInbox/InboxPopover.tsx | 14 +++++++------- .../NotificationsInbox.stories.tsx | 6 +++--- .../NotificationsInbox/NotificationsInbox.tsx | 6 +++--- 9 files changed, 22 insertions(+), 26 deletions(-) diff --git a/site/package.json b/site/package.json index 5198b24d2b826..d1d592460d176 100644 --- a/site/package.json +++ b/site/package.json @@ -190,11 +190,7 @@ "vite-plugin-checker": "0.8.0", "vite-plugin-turbosnap": "1.0.3" }, - "browserslist": [ - "chrome 110", - "firefox 111", - "safari 16.0" - ], + "browserslist": ["chrome 110", "firefox 111", "safari 16.0"], "resolutions": { "optionator": "0.9.3", "semver": "7.6.2" diff --git a/site/src/components/ScrollArea/ScrollArea.tsx b/site/src/components/ScrollArea/ScrollArea.tsx index 711ad3d878c7c..93512f093e8ed 100644 --- a/site/src/components/ScrollArea/ScrollArea.tsx +++ b/site/src/components/ScrollArea/ScrollArea.tsx @@ -1,9 +1,9 @@ +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; /** * Copied from shadc/ui on 03/05/2025 * @see {@link https://ui.shadcn.com/docs/components/scroll-area} */ import * as React from "react"; -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { cn } from "utils/cn"; export const ScrollArea = React.forwardRef< diff --git a/site/src/modules/notifications/NotificationsInbox/InboxButton.tsx b/site/src/modules/notifications/NotificationsInbox/InboxButton.tsx index 7bc49bfa0e0d2..8bc59303f8aff 100644 --- a/site/src/modules/notifications/NotificationsInbox/InboxButton.tsx +++ b/site/src/modules/notifications/NotificationsInbox/InboxButton.tsx @@ -1,6 +1,6 @@ import { Button, type ButtonProps } from "components/Button/Button"; import { BellIcon } from "lucide-react"; -import { forwardRef, type FC } from "react"; +import { type FC, forwardRef } from "react"; import { UnreadBadge } from "./UnreadBadge"; type InboxButtonProps = { diff --git a/site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx b/site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx index ce93601d467dc..c138510e8b7ef 100644 --- a/site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx +++ b/site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { InboxItem } from "./InboxItem"; -import { fn, userEvent, within, expect } from "@storybook/test"; +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", diff --git a/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx b/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx index ea3d76fa6c3a0..7079f05896b78 100644 --- a/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx +++ b/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx @@ -2,9 +2,9 @@ import { Avatar } from "components/Avatar/Avatar"; import { Button } from "components/Button/Button"; import { SquareCheckBig } from "lucide-react"; import type { FC } from "react"; -import type { Notification } from "./types"; -import { relativeTime } from "utils/time"; import { Link as RouterLink } from "react-router-dom"; +import { relativeTime } from "utils/time"; +import type { Notification } from "./types"; type InboxItemProps = { notification: Notification; diff --git a/site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx b/site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx index d667f8d750356..441bc48601a4e 100644 --- a/site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx +++ b/site/src/modules/notifications/NotificationsInbox/InboxPopover.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { InboxPopover } from "./InboxPopover"; +import { expect, fn, userEvent, within } from "@storybook/test"; import { MockNotifications } from "testHelpers/entities"; -import { fn, userEvent, within, expect } from "@storybook/test"; +import { InboxPopover } from "./InboxPopover"; const meta: Meta = { title: "modules/notifications/NotificationsInbox/InboxPopover", diff --git a/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx b/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx index fba38503f91eb..8c717b99efb52 100644 --- a/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx +++ b/site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx @@ -1,19 +1,19 @@ +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 { UnreadBadge } from "./UnreadBadge"; -import { Button } from "components/Button/Button"; -import { RefreshCwIcon, SettingsIcon } from "lucide-react"; import { InboxItem } from "./InboxItem"; +import { UnreadBadge } from "./UnreadBadge"; import type { Notification } from "./types"; -import { cn } from "utils/cn"; -import { ScrollArea } from "components/ScrollArea/ScrollArea"; -import { Spinner } from "components/Spinner/Spinner"; -import { Link as RouterLink } from "react-router-dom"; type InboxPopoverProps = { notifications: Notification[] | undefined; diff --git a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx index 7725a0261b0bc..7d4837d1e4c5b 100644 --- a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { NotificationsInbox } from "./NotificationsInbox"; -import { mockApiError, MockNotifications } from "testHelpers/entities"; -import { fn, userEvent, within, expect } from "@storybook/test"; +import { expect, fn, userEvent, 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", diff --git a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx index 4382bdd0d5947..2103dcbd05e6d 100644 --- a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx @@ -1,9 +1,9 @@ +import { getErrorDetail, getErrorMessage } from "api/errors"; +import { displayError } from "components/GlobalSnackbar/utils"; import type { FC } from "react"; -import { InboxPopover } from "./InboxPopover"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { InboxPopover } from "./InboxPopover"; import type { Notification } from "./types"; -import { displayError } from "components/GlobalSnackbar/utils"; -import { getErrorDetail, getErrorMessage } from "api/errors"; const NOTIFICATIONS_QUERY_KEY = ["notifications"]; From 0473e7b53891b507ff3a9108e2f279c20b43ca1f Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 11 Mar 2025 17:36:03 +0000 Subject: [PATCH 3/5] Apply Kayla suggestions --- site/src/components/Button/Button.tsx | 2 +- site/src/components/ScrollArea/ScrollArea.tsx | 2 +- .../NotificationsInbox/InboxItem.tsx | 2 +- .../InboxPopover.stories.tsx | 11 +-- .../NotificationsInbox/InboxPopover.tsx | 16 ++-- .../NotificationsInbox.stories.tsx | 87 +++++++++---------- .../NotificationsInbox/NotificationsInbox.tsx | 4 +- 7 files changed, 56 insertions(+), 68 deletions(-) diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx index a199bec0c5799..d9daae9c59252 100644 --- a/site/src/components/Button/Button.tsx +++ b/site/src/components/Button/Button.tsx @@ -20,7 +20,7 @@ export const buttonVariants = cva( default: "bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold", outline: - "border border-border-default text-content-primary bg-transparent bg-surface-primary hover:bg-surface-secondary", + "border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary", subtle: "border-none bg-transparent text-content-secondary hover:text-content-primary", destructive: diff --git a/site/src/components/ScrollArea/ScrollArea.tsx b/site/src/components/ScrollArea/ScrollArea.tsx index 93512f093e8ed..d4544a0ca2d33 100644 --- a/site/src/components/ScrollArea/ScrollArea.tsx +++ b/site/src/components/ScrollArea/ScrollArea.tsx @@ -1,8 +1,8 @@ -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; /** * 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"; diff --git a/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx b/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx index 7079f05896b78..2086a5f0a7fed 100644 --- a/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx +++ b/site/src/modules/notifications/NotificationsInbox/InboxItem.tsx @@ -49,7 +49,7 @@ export const InboxItem: FC = ({