feat: add notifications widget in the navbar (#16983)

**Preview:**
<img width="479" alt="Screenshot 2025-03-18 at 10 38 25"
src="https://github.com/user-attachments/assets/2e4cb48e-3606-478c-a68d-13465789330b"
/>

[Figma
file](https://www.figma.com/design/5kRpzK8Qr1k38nNz7H0HSh/Inbox-notifications?node-id=1-2726&t=PUsQwLrwyzXUxhf1-0)

**This PR adds:**
- Notification widget in the navbar
- Show notifications
- Option to mark each notification as read
- Update notifications in realtime 

**What is next?**
- Option to mark all the notifications as read at once
- Option to load previous notifications - Right now, it only shows the
latest 25 notifications
- Having custom icons for each type of notification

**And about tests?**
The notification widget components are well covered by the current
stories, but we definitely want to have e2e tests for it. However, in my
recent projects, I found more useful to ship the UI features first, get
feedback, change whatever needs to be changed, and then, add the e2e
tests to avoid major rework.

Related to https://github.com/coder/internal/issues/336
This commit is contained in:
Bruno Quaresma
2025-03-18 15:21:22 -03:00
committed by GitHub
parent cb19fd47b0
commit ab8ba96707
10 changed files with 187 additions and 89 deletions
+87 -26
View File
@@ -124,6 +124,39 @@ export const watchWorkspace = (workspaceId: string): EventSource => {
);
};
type WatchInboxNotificationsParams = {
read_status?: "read" | "unread" | "all";
};
export const watchInboxNotifications = (
onNewNotification: (res: TypesGen.GetInboxNotificationResponse) => void,
params?: WatchInboxNotificationsParams,
) => {
const searchParams = new URLSearchParams(params);
const socket = createWebSocket(
"/api/v2/notifications/inbox/watch",
searchParams,
);
socket.addEventListener("message", (event) => {
try {
const res = JSON.parse(
event.data,
) as TypesGen.GetInboxNotificationResponse;
onNewNotification(res);
} catch (error) {
console.warn("Error parsing inbox notification: ", error);
}
});
socket.addEventListener("error", (event) => {
console.warn("Watch inbox notifications error: ", event);
socket.close();
});
return socket;
};
export const getURLWithSearchParams = (
basePath: string,
options?: SearchParamOptions,
@@ -184,15 +217,11 @@ export const watchBuildLogsByTemplateVersionId = (
searchParams.append("after", after.toString());
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${proto}//${
location.host
}/api/v2/templateversions/${versionId}/logs?${searchParams.toString()}`,
const socket = createWebSocket(
`/api/v2/templateversions/${versionId}/logs`,
searchParams,
);
socket.binaryType = "blob";
socket.addEventListener("message", (event) =>
onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog),
);
@@ -214,21 +243,21 @@ export const watchWorkspaceAgentLogs = (
agentId: string,
{ after, onMessage, onDone, onError }: WatchWorkspaceAgentLogsOptions,
) => {
// WebSocket compression in Safari (confirmed in 16.5) is broken when
// the server sends large messages. The following error is seen:
//
// WebSocket connection to 'wss://.../logs?follow&after=0' failed: The operation couldn’t be completed. Protocol error
//
const noCompression =
userAgentParser(navigator.userAgent).browser.name === "Safari"
? "&no_compression"
: "";
const searchParams = new URLSearchParams({ after: after.toString() });
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${proto}//${location.host}/api/v2/workspaceagents/${agentId}/logs?follow&after=${after}${noCompression}`,
/**
* WebSocket compression in Safari (confirmed in 16.5) is broken when
* the server sends large messages. The following error is seen:
* WebSocket connection to 'wss://...' failed: The operation couldn’t be completed.
*/
if (userAgentParser(navigator.userAgent).browser.name === "Safari") {
searchParams.set("no_compression", "");
}
const socket = createWebSocket(
`/api/v2/workspaceagents/${agentId}/logs`,
searchParams,
);
socket.binaryType = "blob";
socket.addEventListener("message", (event) => {
const logs = JSON.parse(event.data) as TypesGen.WorkspaceAgentLog[];
@@ -267,13 +296,11 @@ export const watchBuildLogsByBuildId = (
if (after !== undefined) {
searchParams.append("after", after.toString());
}
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${proto}//${
location.host
}/api/v2/workspacebuilds/${buildId}/logs?${searchParams.toString()}`,
const socket = createWebSocket(
`/api/v2/workspacebuilds/${buildId}/logs`,
searchParams,
);
socket.binaryType = "blob";
socket.addEventListener("message", (event) =>
onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog),
@@ -2406,6 +2433,25 @@ class ApiMethods {
);
return res.data;
};
getInboxNotifications = async () => {
const res = await this.axios.get<TypesGen.ListInboxNotificationsResponse>(
"/api/v2/notifications/inbox",
);
return res.data;
};
updateInboxNotificationReadStatus = async (
notificationId: string,
req: TypesGen.UpdateInboxNotificationReadStatusRequest,
) => {
const res =
await this.axios.put<TypesGen.UpdateInboxNotificationReadStatusResponse>(
`/api/v2/notifications/inbox/${notificationId}/read-status`,
req,
);
return res.data;
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,
@@ -2457,6 +2503,21 @@ function getConfiguredAxiosInstance(): AxiosInstance {
return instance;
}
/**
* Utility function to help create a WebSocket connection with Coder's API.
*/
function createWebSocket(
path: string,
params: URLSearchParams = new URLSearchParams(),
) {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${protocol}//${location.host}${path}?${params.toString()}`,
);
socket.binaryType = "blob";
return socket;
}
// Other non-API methods defined here to make it a little easier to find them.
interface ClientApi extends ApiMethods {
getCsrfToken: () => string;
@@ -1,7 +1,9 @@
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { CoderIcon } from "components/Icons/CoderIcon";
import type { ProxyContextValue } from "contexts/ProxyContext";
import { NotificationsInbox } from "modules/notifications/NotificationsInbox/NotificationsInbox";
import type { FC } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { cn } from "utils/cn";
@@ -65,6 +67,18 @@ export const NavbarView: FC<NavbarViewProps> = ({
canViewHealth={canViewHealth}
/>
<NotificationsInbox
fetchNotifications={API.getInboxNotifications}
markAllAsRead={() => {
throw new Error("Function not implemented.");
}}
markNotificationAsRead={(notificationId) =>
API.updateInboxNotificationReadStatus(notificationId, {
is_read: true,
})
}
/>
{user && (
<UserDropdown
user={user}
@@ -1,6 +1,6 @@
import { Button, type ButtonProps } from "components/Button/Button";
import { BellIcon } from "lucide-react";
import { type FC, forwardRef } from "react";
import { forwardRef } from "react";
import { UnreadBadge } from "./UnreadBadge";
type InboxButtonProps = {
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";
import { MockNotification } from "testHelpers/entities";
import { daysAgo } from "utils/time";
import { InboxItem } from "./InboxItem";
const meta: Meta<typeof InboxItem> = {
@@ -22,7 +23,7 @@ export const Read: Story = {
args: {
notification: {
...MockNotification,
read_status: "read",
read_at: daysAgo(1),
},
},
};
@@ -31,7 +32,7 @@ export const Unread: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
read_at: null,
},
},
};
@@ -40,7 +41,7 @@ export const UnreadFocus: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
read_at: null,
},
},
play: async ({ canvasElement }) => {
@@ -54,7 +55,7 @@ export const OnMarkNotificationAsRead: Story = {
args: {
notification: {
...MockNotification,
read_status: "unread",
read_at: null,
},
onMarkNotificationAsRead: fn(),
},
@@ -1,13 +1,13 @@
import type { InboxNotification } from "api/typesGenerated";
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;
notification: InboxNotification;
onMarkNotificationAsRead: (notificationId: string) => void;
};
@@ -25,7 +25,7 @@ export const InboxItem: FC<InboxItemProps> = ({
<Avatar fallback="AR" />
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 flex-1">
<span className="text-content-secondary text-sm font-medium">
{notification.content}
</span>
@@ -41,7 +41,7 @@ export const InboxItem: FC<InboxItemProps> = ({
</div>
<div className="w-12 flex flex-col items-end flex-shrink-0">
{notification.read_status === "unread" && (
{notification.read_at === null && (
<>
<div className="group-focus:hidden group-hover:hidden size-2.5 rounded-full bg-highlight-sky">
<span className="sr-only">Unread</span>
@@ -1,3 +1,4 @@
import type { InboxNotification } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
Popover,
@@ -13,10 +14,9 @@ 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;
notifications: readonly InboxNotification[] | undefined;
unreadCount: number;
error: unknown;
onRetry: () => void;
@@ -134,7 +134,13 @@ export const MarkNotificationAsRead: Story = {
notifications: MockNotifications,
unread_count: 2,
})),
markNotificationAsRead: fn(),
markNotificationAsRead: fn(async () => ({
unread_count: 1,
notification: {
...MockNotifications[1],
read_at: new Date().toISOString(),
},
})),
},
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
@@ -1,22 +1,24 @@
import { API, watchInboxNotifications } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import type {
ListInboxNotificationsResponse,
UpdateInboxNotificationReadStatusResponse,
} from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import type { FC } from "react";
import { useEffectEvent } from "hooks/hookPolyfills";
import { type FC, useEffect, useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { InboxPopover } from "./InboxPopover";
import type { Notification } from "./types";
const NOTIFICATIONS_QUERY_KEY = ["notifications"];
type NotificationsResponse = {
notifications: Notification[];
unread_count: number;
};
type NotificationsInboxProps = {
defaultOpen?: boolean;
fetchNotifications: () => Promise<NotificationsResponse>;
fetchNotifications: () => Promise<ListInboxNotificationsResponse>;
markAllAsRead: () => Promise<void>;
markNotificationAsRead: (notificationId: string) => Promise<void>;
markNotificationAsRead: (
notificationId: string,
) => Promise<UpdateInboxNotificationReadStatusResponse>;
};
export const NotificationsInbox: FC<NotificationsInboxProps> = ({
@@ -36,15 +38,52 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({
queryFn: fetchNotifications,
});
const updateNotificationsCache = useEffectEvent(
async (
callback: (
res: ListInboxNotificationsResponse,
) => ListInboxNotificationsResponse,
) => {
await queryClient.cancelQueries(NOTIFICATIONS_QUERY_KEY);
queryClient.setQueryData<ListInboxNotificationsResponse>(
NOTIFICATIONS_QUERY_KEY,
(prev) => {
if (!prev) {
return { notifications: [], unread_count: 0 };
}
return callback(prev);
},
);
},
);
useEffect(() => {
const socket = watchInboxNotifications(
(res) => {
updateNotificationsCache((prev) => {
return {
unread_count: res.unread_count,
notifications: [res.notification, ...prev.notifications],
};
});
},
{ read_status: "unread" },
);
return () => {
socket.close();
};
}, [updateNotificationsCache]);
const markAllAsReadMutation = useMutation({
mutationFn: markAllAsRead,
onSuccess: () => {
safeUpdateNotificationsCache((prev) => {
updateNotificationsCache((prev) => {
return {
unread_count: 0,
notifications: prev.notifications.map((n) => ({
...n,
read_status: "read",
read_at: new Date().toISOString(),
})),
};
});
@@ -59,15 +98,15 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({
const markNotificationAsReadMutation = useMutation({
mutationFn: markNotificationAsRead,
onSuccess: (_, notificationId) => {
safeUpdateNotificationsCache((prev) => {
onSuccess: (res) => {
updateNotificationsCache((prev) => {
return {
unread_count: prev.unread_count - 1,
unread_count: res.unread_count,
notifications: prev.notifications.map((n) => {
if (n.id !== notificationId) {
if (n.id !== res.notification.id) {
return n;
}
return { ...n, read_status: "read" };
return res.notification;
}),
};
});
@@ -80,21 +119,6 @@ export const NotificationsInbox: FC<NotificationsInboxProps> = ({
},
});
async function safeUpdateNotificationsCache(
callback: (res: NotificationsResponse) => NotificationsResponse,
) {
await queryClient.cancelQueries(NOTIFICATIONS_QUERY_KEY);
queryClient.setQueryData<NotificationsResponse>(
NOTIFICATIONS_QUERY_KEY,
(prev) => {
if (!prev) {
return { notifications: [], unread_count: 0 };
}
return callback(prev);
},
);
}
return (
<InboxPopover
defaultOpen={defaultOpen}
@@ -1,12 +0,0 @@
// 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;
}[];
};
+12 -8
View File
@@ -7,7 +7,6 @@ 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";
@@ -4245,9 +4244,9 @@ export const MockNotificationTemplates: TypesGen.NotificationTemplate[] = [
export const MockNotificationMethodsResponse: TypesGen.NotificationMethodsResponse =
{ available: ["smtp", "webhook"], default: "smtp" };
export const MockNotification: Notification = {
export const MockNotification: TypesGen.InboxNotification = {
id: "1",
read_status: "unread",
read_at: null,
content:
"New user account testuser has been created. This new user account was created for Test User by Kira Pilot.",
created_at: mockTwoDaysAgo(),
@@ -4257,14 +4256,19 @@ export const MockNotification: Notification = {
url: "https://dev.coder.com/templates/coder/coder",
},
],
user_id: MockUser.id,
template_id: MockTemplate.id,
targets: [],
title: "User account created",
icon: "user",
};
export const MockNotifications: Notification[] = [
export const MockNotifications: TypesGen.InboxNotification[] = [
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" },
{ ...MockNotification, id: "2", read_at: null },
{ ...MockNotification, id: "3", read_at: mockTwoDaysAgo() },
{ ...MockNotification, id: "4", read_at: mockTwoDaysAgo() },
{ ...MockNotification, id: "5", read_at: mockTwoDaysAgo() },
];
function mockTwoDaysAgo() {