@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.
@@ -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
- DropdownMenu,
11
- DropdownMenuContent,
10
+ Popover,
11
+ PopoverContent,
12
+ PopoverTrigger,
12
13
  DropdownMenuItem,
13
- DropdownMenuTrigger,
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
- <DropdownMenu>
139
- <DropdownMenuTrigger
140
- onClick={() => {
141
- setFilterDropdownOpen(!filterDropdownOpen);
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
- </DropdownMenuTrigger>
149
- <DropdownMenuContent
150
- isOpen={filterDropdownOpen}
151
- onClose={() => {
152
- setFilterDropdownOpen(false);
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
- Unread only
170
- </DropdownMenuItem>
171
- </DropdownMenuContent>
172
- </DropdownMenu>
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((notification) => (
196
- <Card
197
- key={notification.id}
198
- className={`p-4 ${
199
- notification.isRead ? "" : "border-l-4 border-l-primary"
200
- }`}
201
- >
202
- <div className="flex items-start justify-between gap-4">
203
- <div className="flex-1 min-w-0">
204
- <div className="flex items-center gap-2 mb-1">
205
- {getImportanceBadge(notification.importance)}
206
- <span className="text-xs text-muted-foreground">
207
- {formatDate(notification.createdAt)}
208
- </span>
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
- <h3
211
- className={`font-medium ${
212
- notification.isRead
213
- ? "text-muted-foreground"
214
- : "text-foreground"
215
- }`}
216
- >
217
- {notification.title}
218
- </h3>
219
- <Markdown size="sm" className="text-muted-foreground mt-1">
220
- {notification.body}
221
- </Markdown>
222
- {notification.action && (
223
- <div className="flex gap-2 mt-2">
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
- {notification.action.label}
229
- </Link>
230
- </div>
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={() => handleMarkAsRead(notification.id)}
239
- disabled={markAsReadMutation.isPending}
240
- title="Mark as read"
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
- <Check className="h-4 w-4" />
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
- </div>
256
- </Card>
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
+ }