mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+87
-26
@@ -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;
|
||||
}[];
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user