@checkstack/notification-frontend 0.2.36 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +295 -0
- package/package.json +9 -8
- package/src/components/CollapsedGroupTimeline.tsx +63 -0
- package/src/components/NotificationBell.tsx +186 -108
- package/src/components/NotificationSubjects.tsx +154 -0
- package/src/components/NotificationSubscriptionsManager.tsx +239 -0
- package/src/components/SubjectKindRegistry.ts +57 -0
- package/src/components/SubscriptionRow.tsx +191 -0
- package/src/components/SubscriptionSubControlsRegistry.ts +45 -0
- package/src/components/collapse.ts +59 -0
- package/src/index.tsx +23 -0
- package/src/pages/NotificationsPage.tsx +158 -89
- package/tsconfig.json +21 -1
|
@@ -1,28 +1,47 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
2
|
import { Link } from "react-router-dom";
|
|
3
|
-
import { Bell, Check, Trash2, ChevronDown } from "lucide-react";
|
|
3
|
+
import { Bell, Check, Trash2, ChevronDown, ChevronUp } from "lucide-react";
|
|
4
4
|
import {
|
|
5
5
|
PageLayout,
|
|
6
6
|
Badge,
|
|
7
7
|
Button,
|
|
8
8
|
Card,
|
|
9
9
|
useToast,
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
Popover,
|
|
11
|
+
PopoverContent,
|
|
12
|
+
PopoverTrigger,
|
|
12
13
|
DropdownMenuItem,
|
|
13
|
-
|
|
14
|
+
MenuCloseContext,
|
|
14
15
|
Markdown,
|
|
15
16
|
} from "@checkstack/ui";
|
|
16
17
|
import { usePluginClient } from "@checkstack/frontend-api";
|
|
17
18
|
import type { Notification } from "@checkstack/notification-common";
|
|
18
19
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
19
20
|
import { extractErrorMessage } from "@checkstack/common";
|
|
21
|
+
import { NotificationSubjects } from "../components/NotificationSubjects";
|
|
22
|
+
import { groupByCollapseKey } from "../components/collapse";
|
|
23
|
+
import { CollapsedGroupTimeline } from "../components/CollapsedGroupTimeline";
|
|
20
24
|
|
|
21
25
|
export const NotificationsPage = () => {
|
|
22
26
|
const notificationClient = usePluginClient(NotificationApi);
|
|
23
27
|
const toast = useToast();
|
|
24
28
|
|
|
25
29
|
const [filter, setFilter] = useState<"all" | "unread">("all");
|
|
30
|
+
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
|
31
|
+
() => new Set(),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const toggleExpanded = useCallback((key: string) => {
|
|
35
|
+
setExpandedGroups((prev) => {
|
|
36
|
+
const next = new Set(prev);
|
|
37
|
+
if (next.has(key)) {
|
|
38
|
+
next.delete(key);
|
|
39
|
+
} else {
|
|
40
|
+
next.add(key);
|
|
41
|
+
}
|
|
42
|
+
return next;
|
|
43
|
+
});
|
|
44
|
+
}, []);
|
|
26
45
|
const [page, setPage] = useState(0);
|
|
27
46
|
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
|
28
47
|
const pageSize = 20;
|
|
@@ -135,41 +154,41 @@ export const NotificationsPage = () => {
|
|
|
135
154
|
{/* Header with filters */}
|
|
136
155
|
<div className="flex items-center justify-between">
|
|
137
156
|
<div className="flex items-center gap-2">
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
>
|
|
157
|
+
<Popover
|
|
158
|
+
open={filterDropdownOpen}
|
|
159
|
+
onOpenChange={setFilterDropdownOpen}
|
|
160
|
+
>
|
|
161
|
+
<PopoverTrigger asChild>
|
|
144
162
|
<Button variant="outline" size="sm">
|
|
145
163
|
{filter === "all" ? "All" : "Unread"}{" "}
|
|
146
164
|
<ChevronDown className="h-4 w-4 ml-1" />
|
|
147
165
|
</Button>
|
|
148
|
-
</
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<DropdownMenuItem
|
|
156
|
-
onClick={() => {
|
|
157
|
-
setFilter("all");
|
|
158
|
-
setFilterDropdownOpen(false);
|
|
159
|
-
}}
|
|
160
|
-
>
|
|
161
|
-
All notifications
|
|
162
|
-
</DropdownMenuItem>
|
|
163
|
-
<DropdownMenuItem
|
|
164
|
-
onClick={() => {
|
|
165
|
-
setFilter("unread");
|
|
166
|
-
setFilterDropdownOpen(false);
|
|
166
|
+
</PopoverTrigger>
|
|
167
|
+
<PopoverContent align="start" className="w-56 p-1">
|
|
168
|
+
<MenuCloseContext.Provider
|
|
169
|
+
value={{
|
|
170
|
+
onClose: () => {
|
|
171
|
+
setFilterDropdownOpen(false);
|
|
172
|
+
},
|
|
167
173
|
}}
|
|
168
174
|
>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
175
|
+
<DropdownMenuItem
|
|
176
|
+
onClick={() => {
|
|
177
|
+
setFilter("all");
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
All notifications
|
|
181
|
+
</DropdownMenuItem>
|
|
182
|
+
<DropdownMenuItem
|
|
183
|
+
onClick={() => {
|
|
184
|
+
setFilter("unread");
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
Unread only
|
|
188
|
+
</DropdownMenuItem>
|
|
189
|
+
</MenuCloseContext.Provider>
|
|
190
|
+
</PopoverContent>
|
|
191
|
+
</Popover>
|
|
173
192
|
<span className="text-sm text-muted-foreground">
|
|
174
193
|
{total} notification{total === 1 ? "" : "s"}
|
|
175
194
|
</span>
|
|
@@ -192,69 +211,119 @@ export const NotificationsPage = () => {
|
|
|
192
211
|
</Card>
|
|
193
212
|
) : (
|
|
194
213
|
<div className="space-y-2">
|
|
195
|
-
{notifications.map((
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
214
|
+
{groupByCollapseKey(notifications).map((group) => {
|
|
215
|
+
const notification = group.representative;
|
|
216
|
+
const isExpanded = expandedGroups.has(group.key);
|
|
217
|
+
return (
|
|
218
|
+
<Card
|
|
219
|
+
key={group.key}
|
|
220
|
+
className={`p-4 ${
|
|
221
|
+
notification.isRead ? "" : "border-l-4 border-l-primary"
|
|
222
|
+
}`}
|
|
223
|
+
>
|
|
224
|
+
<div className="flex items-start justify-between gap-4">
|
|
225
|
+
<div className="flex-1 min-w-0">
|
|
226
|
+
<div className="flex items-center gap-2 mb-1">
|
|
227
|
+
{getImportanceBadge(notification.importance)}
|
|
228
|
+
<span className="text-xs text-muted-foreground">
|
|
229
|
+
{formatDate(notification.createdAt)}
|
|
230
|
+
</span>
|
|
231
|
+
{group.collapsed && (
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={() => toggleExpanded(group.key)}
|
|
235
|
+
aria-label={
|
|
236
|
+
isExpanded
|
|
237
|
+
? "Collapse update history"
|
|
238
|
+
: "Show update history"
|
|
239
|
+
}
|
|
240
|
+
>
|
|
241
|
+
<Badge
|
|
242
|
+
variant="secondary"
|
|
243
|
+
className="text-[10px] cursor-pointer hover:bg-accent"
|
|
244
|
+
>
|
|
245
|
+
+{group.count - 1} updates
|
|
246
|
+
{isExpanded ? (
|
|
247
|
+
<ChevronUp className="ml-0.5 h-3 w-3" />
|
|
248
|
+
) : (
|
|
249
|
+
<ChevronDown className="ml-0.5 h-3 w-3" />
|
|
250
|
+
)}
|
|
251
|
+
</Badge>
|
|
252
|
+
</button>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
<h3
|
|
256
|
+
className={`font-medium ${
|
|
257
|
+
notification.isRead
|
|
258
|
+
? "text-muted-foreground"
|
|
259
|
+
: "text-foreground"
|
|
260
|
+
}`}
|
|
261
|
+
>
|
|
262
|
+
{notification.title}
|
|
263
|
+
</h3>
|
|
264
|
+
<Markdown size="sm" className="text-muted-foreground mt-1">
|
|
265
|
+
{notification.body}
|
|
266
|
+
</Markdown>
|
|
267
|
+
{notification.subjects &&
|
|
268
|
+
notification.subjects.length > 0 && (
|
|
269
|
+
<NotificationSubjects
|
|
270
|
+
subjects={notification.subjects}
|
|
271
|
+
maxVisible={5}
|
|
272
|
+
/>
|
|
273
|
+
)}
|
|
274
|
+
{notification.action && (
|
|
275
|
+
<div className="flex gap-2 mt-2">
|
|
276
|
+
<Link
|
|
277
|
+
to={notification.action.url}
|
|
278
|
+
className="text-sm text-primary hover:text-primary/80"
|
|
279
|
+
>
|
|
280
|
+
{notification.action.label}
|
|
281
|
+
</Link>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
{group.collapsed && isExpanded && (
|
|
285
|
+
<CollapsedGroupTimeline
|
|
286
|
+
notifications={group.notifications}
|
|
287
|
+
variant="page"
|
|
288
|
+
/>
|
|
289
|
+
)}
|
|
209
290
|
</div>
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
<Link
|
|
225
|
-
to={notification.action.url}
|
|
226
|
-
className="text-sm text-primary hover:text-primary/80"
|
|
291
|
+
<div className="flex items-center gap-1">
|
|
292
|
+
{!notification.isRead && (
|
|
293
|
+
<Button
|
|
294
|
+
variant="ghost"
|
|
295
|
+
size="icon"
|
|
296
|
+
onClick={() => {
|
|
297
|
+
// Mark every notification in the group as read
|
|
298
|
+
// so the badge clears in one shot.
|
|
299
|
+
for (const n of group.notifications) {
|
|
300
|
+
handleMarkAsRead(n.id);
|
|
301
|
+
}
|
|
302
|
+
}}
|
|
303
|
+
disabled={markAsReadMutation.isPending}
|
|
304
|
+
title="Mark as read"
|
|
227
305
|
>
|
|
228
|
-
|
|
229
|
-
</
|
|
230
|
-
|
|
231
|
-
)}
|
|
232
|
-
</div>
|
|
233
|
-
<div className="flex items-center gap-1">
|
|
234
|
-
{!notification.isRead && (
|
|
306
|
+
<Check className="h-4 w-4" />
|
|
307
|
+
</Button>
|
|
308
|
+
)}
|
|
235
309
|
<Button
|
|
236
310
|
variant="ghost"
|
|
237
311
|
size="icon"
|
|
238
|
-
onClick={() =>
|
|
239
|
-
|
|
240
|
-
|
|
312
|
+
onClick={() => {
|
|
313
|
+
for (const n of group.notifications) {
|
|
314
|
+
handleDelete(n.id);
|
|
315
|
+
}
|
|
316
|
+
}}
|
|
317
|
+
disabled={deleteMutation.isPending}
|
|
318
|
+
title="Delete"
|
|
241
319
|
>
|
|
242
|
-
<
|
|
320
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
243
321
|
</Button>
|
|
244
|
-
|
|
245
|
-
<Button
|
|
246
|
-
variant="ghost"
|
|
247
|
-
size="icon"
|
|
248
|
-
onClick={() => handleDelete(notification.id)}
|
|
249
|
-
disabled={deleteMutation.isPending}
|
|
250
|
-
title="Delete"
|
|
251
|
-
>
|
|
252
|
-
<Trash2 className="h-4 w-4 text-destructive" />
|
|
253
|
-
</Button>
|
|
322
|
+
</div>
|
|
254
323
|
</div>
|
|
255
|
-
</
|
|
256
|
-
|
|
257
|
-
)
|
|
324
|
+
</Card>
|
|
325
|
+
);
|
|
326
|
+
})}
|
|
258
327
|
</div>
|
|
259
328
|
)}
|
|
260
329
|
|
package/tsconfig.json
CHANGED
|
@@ -2,5 +2,25 @@
|
|
|
2
2
|
"extends": "@checkstack/tsconfig/frontend.json",
|
|
3
3
|
"include": [
|
|
4
4
|
"src"
|
|
5
|
+
],
|
|
6
|
+
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../auth-frontend"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../common"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "../frontend-api"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "../notification-common"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "../signal-frontend"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "../ui"
|
|
24
|
+
}
|
|
5
25
|
]
|
|
6
|
-
}
|
|
26
|
+
}
|