Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitab8ba96

Browse files
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"/>[Figmafile](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 thelatest 25 notifications- Having custom icons for each type of notification**And about tests?**The notification widget components are well covered by the currentstories, but we definitely want to have e2e tests for it. However, in myrecent projects, I found more useful to ship the UI features first, getfeedback, change whatever needs to be changed, and then, add the e2etests to avoid major rework.Related tocoder/internal#336
1 parentcb19fd4 commitab8ba96

File tree

10 files changed

+187
-89
lines changed

10 files changed

+187
-89
lines changed

‎site/src/api/api.ts

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,39 @@ export const watchWorkspace = (workspaceId: string): EventSource => {
124124
);
125125
};
126126

127+
typeWatchInboxNotificationsParams={
128+
read_status?:"read"|"unread"|"all";
129+
};
130+
131+
exportconstwatchInboxNotifications=(
132+
onNewNotification:(res:TypesGen.GetInboxNotificationResponse)=>void,
133+
params?:WatchInboxNotificationsParams,
134+
)=>{
135+
constsearchParams=newURLSearchParams(params);
136+
constsocket=createWebSocket(
137+
"/api/v2/notifications/inbox/watch",
138+
searchParams,
139+
);
140+
141+
socket.addEventListener("message",(event)=>{
142+
try{
143+
constres=JSON.parse(
144+
event.data,
145+
)asTypesGen.GetInboxNotificationResponse;
146+
onNewNotification(res);
147+
}catch(error){
148+
console.warn("Error parsing inbox notification: ",error);
149+
}
150+
});
151+
152+
socket.addEventListener("error",(event)=>{
153+
console.warn("Watch inbox notifications error: ",event);
154+
socket.close();
155+
});
156+
157+
returnsocket;
158+
};
159+
127160
exportconstgetURLWithSearchParams=(
128161
basePath:string,
129162
options?:SearchParamOptions,
@@ -184,15 +217,11 @@ export const watchBuildLogsByTemplateVersionId = (
184217
searchParams.append("after",after.toString());
185218
}
186219

187-
constproto=location.protocol==="https:" ?"wss:" :"ws:";
188-
constsocket=newWebSocket(
189-
`${proto}//${
190-
location.host
191-
}/api/v2/templateversions/${versionId}/logs?${searchParams.toString()}`,
220+
constsocket=createWebSocket(
221+
`/api/v2/templateversions/${versionId}/logs`,
222+
searchParams,
192223
);
193224

194-
socket.binaryType="blob";
195-
196225
socket.addEventListener("message",(event)=>
197226
onMessage(JSON.parse(event.data)asTypesGen.ProvisionerJobLog),
198227
);
@@ -214,21 +243,21 @@ export const watchWorkspaceAgentLogs = (
214243
agentId:string,
215244
{ after, onMessage, onDone, onError}:WatchWorkspaceAgentLogsOptions,
216245
)=>{
217-
// WebSocket compression in Safari (confirmed in 16.5) is broken when
218-
// the server sends large messages. The following error is seen:
219-
//
220-
// WebSocket connection to 'wss://.../logs?follow&after=0' failed: The operation couldn’t be completed. Protocol error
221-
//
222-
constnoCompression=
223-
userAgentParser(navigator.userAgent).browser.name==="Safari"
224-
?"&no_compression"
225-
:"";
246+
constsearchParams=newURLSearchParams({after:after.toString()});
226247

227-
constproto=location.protocol==="https:" ?"wss:" :"ws:";
228-
constsocket=newWebSocket(
229-
`${proto}//${location.host}/api/v2/workspaceagents/${agentId}/logs?follow&after=${after}${noCompression}`,
248+
/**
249+
* WebSocket compression in Safari (confirmed in 16.5) is broken when
250+
* the server sends large messages. The following error is seen:
251+
* WebSocket connection to 'wss://...' failed: The operation couldn’t be completed.
252+
*/
253+
if(userAgentParser(navigator.userAgent).browser.name==="Safari"){
254+
searchParams.set("no_compression","");
255+
}
256+
257+
constsocket=createWebSocket(
258+
`/api/v2/workspaceagents/${agentId}/logs`,
259+
searchParams,
230260
);
231-
socket.binaryType="blob";
232261

233262
socket.addEventListener("message",(event)=>{
234263
constlogs=JSON.parse(event.data)asTypesGen.WorkspaceAgentLog[];
@@ -267,13 +296,11 @@ export const watchBuildLogsByBuildId = (
267296
if(after!==undefined){
268297
searchParams.append("after",after.toString());
269298
}
270-
constproto=location.protocol==="https:" ?"wss:" :"ws:";
271-
constsocket=newWebSocket(
272-
`${proto}//${
273-
location.host
274-
}/api/v2/workspacebuilds/${buildId}/logs?${searchParams.toString()}`,
299+
300+
constsocket=createWebSocket(
301+
`/api/v2/workspacebuilds/${buildId}/logs`,
302+
searchParams,
275303
);
276-
socket.binaryType="blob";
277304

278305
socket.addEventListener("message",(event)=>
279306
onMessage(JSON.parse(event.data)asTypesGen.ProvisionerJobLog),
@@ -2406,6 +2433,25 @@ class ApiMethods {
24062433
);
24072434
returnres.data;
24082435
};
2436+
2437+
getInboxNotifications=async()=>{
2438+
constres=awaitthis.axios.get<TypesGen.ListInboxNotificationsResponse>(
2439+
"/api/v2/notifications/inbox",
2440+
);
2441+
returnres.data;
2442+
};
2443+
2444+
updateInboxNotificationReadStatus=async(
2445+
notificationId:string,
2446+
req:TypesGen.UpdateInboxNotificationReadStatusRequest,
2447+
)=>{
2448+
constres=
2449+
awaitthis.axios.put<TypesGen.UpdateInboxNotificationReadStatusResponse>(
2450+
`/api/v2/notifications/inbox/${notificationId}/read-status`,
2451+
req,
2452+
);
2453+
returnres.data;
2454+
};
24092455
}
24102456

24112457
// This is a hard coded CSRF token/cookie pair for local development. In prod,
@@ -2457,6 +2503,21 @@ function getConfiguredAxiosInstance(): AxiosInstance {
24572503
returninstance;
24582504
}
24592505

2506+
/**
2507+
* Utility function to help create a WebSocket connection with Coder's API.
2508+
*/
2509+
functioncreateWebSocket(
2510+
path:string,
2511+
params:URLSearchParams=newURLSearchParams(),
2512+
){
2513+
constprotocol=location.protocol==="https:" ?"wss:" :"ws:";
2514+
constsocket=newWebSocket(
2515+
`${protocol}//${location.host}${path}?${params.toString()}`,
2516+
);
2517+
socket.binaryType="blob";
2518+
returnsocket;
2519+
}
2520+
24602521
// Other non-API methods defined here to make it a little easier to find them.
24612522
interfaceClientApiextendsApiMethods{
24622523
getCsrfToken:()=>string;

‎site/src/modules/dashboard/Navbar/NavbarView.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import{API}from"api/api";
12
importtype*asTypesGenfrom"api/typesGenerated";
23
import{ExternalImage}from"components/ExternalImage/ExternalImage";
34
import{CoderIcon}from"components/Icons/CoderIcon";
45
importtype{ProxyContextValue}from"contexts/ProxyContext";
6+
import{NotificationsInbox}from"modules/notifications/NotificationsInbox/NotificationsInbox";
57
importtype{FC}from"react";
68
import{NavLink,useLocation}from"react-router-dom";
79
import{cn}from"utils/cn";
@@ -65,6 +67,18 @@ export const NavbarView: FC<NavbarViewProps> = ({
6567
canViewHealth={canViewHealth}
6668
/>
6769

70+
<NotificationsInbox
71+
fetchNotifications={API.getInboxNotifications}
72+
markAllAsRead={()=>{
73+
thrownewError("Function not implemented.");
74+
}}
75+
markNotificationAsRead={(notificationId)=>
76+
API.updateInboxNotificationReadStatus(notificationId,{
77+
is_read:true,
78+
})
79+
}
80+
/>
81+
6882
{user&&(
6983
<UserDropdown
7084
user={user}

‎site/src/modules/notifications/NotificationsInbox/InboxButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import{Button,typeButtonProps}from"components/Button/Button";
22
import{BellIcon}from"lucide-react";
3-
import{typeFC,forwardRef}from"react";
3+
import{forwardRef}from"react";
44
import{UnreadBadge}from"./UnreadBadge";
55

66
typeInboxButtonProps={

‎site/src/modules/notifications/NotificationsInbox/InboxItem.stories.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
importtype{Meta,StoryObj}from"@storybook/react";
22
import{expect,fn,userEvent,within}from"@storybook/test";
33
import{MockNotification}from"testHelpers/entities";
4+
import{daysAgo}from"utils/time";
45
import{InboxItem}from"./InboxItem";
56

67
constmeta:Meta<typeofInboxItem>={
@@ -22,7 +23,7 @@ export const Read: Story = {
2223
args:{
2324
notification:{
2425
...MockNotification,
25-
read_status:"read",
26+
read_at:daysAgo(1),
2627
},
2728
},
2829
};
@@ -31,7 +32,7 @@ export const Unread: Story = {
3132
args:{
3233
notification:{
3334
...MockNotification,
34-
read_status:"unread",
35+
read_at:null,
3536
},
3637
},
3738
};
@@ -40,7 +41,7 @@ export const UnreadFocus: Story = {
4041
args:{
4142
notification:{
4243
...MockNotification,
43-
read_status:"unread",
44+
read_at:null,
4445
},
4546
},
4647
play:async({ canvasElement})=>{
@@ -54,7 +55,7 @@ export const OnMarkNotificationAsRead: Story = {
5455
args:{
5556
notification:{
5657
...MockNotification,
57-
read_status:"unread",
58+
read_at:null,
5859
},
5960
onMarkNotificationAsRead:fn(),
6061
},

‎site/src/modules/notifications/NotificationsInbox/InboxItem.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
importtype{InboxNotification}from"api/typesGenerated";
12
import{Avatar}from"components/Avatar/Avatar";
23
import{Button}from"components/Button/Button";
34
import{SquareCheckBig}from"lucide-react";
45
importtype{FC}from"react";
56
import{LinkasRouterLink}from"react-router-dom";
67
import{relativeTime}from"utils/time";
7-
importtype{Notification}from"./types";
88

99
typeInboxItemProps={
10-
notification:Notification;
10+
notification:InboxNotification;
1111
onMarkNotificationAsRead:(notificationId:string)=>void;
1212
};
1313

@@ -25,7 +25,7 @@ export const InboxItem: FC<InboxItemProps> = ({
2525
<Avatarfallback="AR"/>
2626
</div>
2727

28-
<divclassName="flex flex-col gap-3">
28+
<divclassName="flex flex-col gap-3 flex-1">
2929
<spanclassName="text-content-secondary text-sm font-medium">
3030
{notification.content}
3131
</span>
@@ -41,7 +41,7 @@ export const InboxItem: FC<InboxItemProps> = ({
4141
</div>
4242

4343
<divclassName="w-12 flex flex-col items-end flex-shrink-0">
44-
{notification.read_status==="unread"&&(
44+
{notification.read_at===null&&(
4545
<>
4646
<divclassName="group-focus:hidden group-hover:hidden size-2.5 rounded-full bg-highlight-sky">
4747
<spanclassName="sr-only">Unread</span>

‎site/src/modules/notifications/NotificationsInbox/InboxPopover.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
importtype{InboxNotification}from"api/typesGenerated";
12
import{Button}from"components/Button/Button";
23
import{
34
Popover,
@@ -13,10 +14,9 @@ import { cn } from "utils/cn";
1314
import{InboxButton}from"./InboxButton";
1415
import{InboxItem}from"./InboxItem";
1516
import{UnreadBadge}from"./UnreadBadge";
16-
importtype{Notification}from"./types";
1717

1818
typeInboxPopoverProps={
19-
notifications:Notification[]|undefined;
19+
notifications:readonlyInboxNotification[]|undefined;
2020
unreadCount:number;
2121
error:unknown;
2222
onRetry:()=>void;

‎site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,13 @@ export const MarkNotificationAsRead: Story = {
134134
notifications:MockNotifications,
135135
unread_count:2,
136136
})),
137-
markNotificationAsRead:fn(),
137+
markNotificationAsRead:fn(async()=>({
138+
unread_count:1,
139+
notification:{
140+
...MockNotifications[1],
141+
read_at:newDate().toISOString(),
142+
},
143+
})),
138144
},
139145
play:async({ canvasElement})=>{
140146
constbody=within(canvasElement.ownerDocument.body);

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp