@checkstack/notification-frontend 0.2.36 → 0.3.0

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.
@@ -0,0 +1,59 @@
1
+ import type { Notification } from "@checkstack/notification-common";
2
+
3
+ /**
4
+ * A group of related notifications that share a `collapseKey`. The newest
5
+ * notification (`representative`) drives the rendered title/body/action;
6
+ * `count` is the total number of notifications in the group;
7
+ * `notifications` is the full chronological list (newest first) for
8
+ * timeline expansion.
9
+ */
10
+ export interface CollapsedNotification {
11
+ /** Unique key for React reconciliation. The collapse key, or the notification id when no collapse key is set. */
12
+ key: string;
13
+ /** Whether this group represents multiple underlying notifications. */
14
+ collapsed: boolean;
15
+ count: number;
16
+ representative: Notification;
17
+ notifications: Notification[];
18
+ }
19
+
20
+ /**
21
+ * Group notifications by `collapseKey`. Notifications without a key remain
22
+ * as singletons. Within a group, the representative is the newest by
23
+ * `createdAt`. Group order is the order of first appearance in the input,
24
+ * so a list ordered by `createdAt desc` produces groups whose order
25
+ * matches their newest member.
26
+ */
27
+ export function groupByCollapseKey(
28
+ notifications: readonly Notification[],
29
+ ): CollapsedNotification[] {
30
+ const order: string[] = [];
31
+ const groups = new Map<string, Notification[]>();
32
+
33
+ for (const n of notifications) {
34
+ const key = n.collapseKey ?? `__nokey:${n.id}`;
35
+ if (!groups.has(key)) {
36
+ groups.set(key, []);
37
+ order.push(key);
38
+ }
39
+ groups.get(key)!.push(n);
40
+ }
41
+
42
+ return order.map((key) => {
43
+ const items = groups.get(key)!;
44
+ // Sort newest first within the group; createdAt is a Date in the
45
+ // schema-validated response.
46
+ const sorted = items.toSorted(
47
+ (a, b) =>
48
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
49
+ );
50
+ const representative = sorted[0]!;
51
+ return {
52
+ key,
53
+ collapsed: sorted.length > 1,
54
+ count: sorted.length,
55
+ representative,
56
+ notifications: sorted,
57
+ };
58
+ });
59
+ }
package/src/index.tsx CHANGED
@@ -13,6 +13,29 @@ import { NotificationsPage } from "./pages/NotificationsPage";
13
13
  import { NotificationSettingsPage } from "./pages/NotificationSettingsPage";
14
14
  import { NotificationUserMenuItems } from "./components/UserMenuItems";
15
15
 
16
+ // Plugin-extensible kind registry — domain frontends call `registerSubjectKind`
17
+ // at module load to bind their kinds (e.g., "catalog.system") to icon + label.
18
+ export {
19
+ registerSubjectKind,
20
+ getSubjectKindRenderer,
21
+ } from "./components/SubjectKindRegistry";
22
+ export type { SubjectKindRenderer } from "./components/SubjectKindRegistry";
23
+ export { NotificationSubjects } from "./components/NotificationSubjects";
24
+ export {
25
+ SubscriptionRow,
26
+ type SubscriptionRowProps,
27
+ type ResolvedInheritance,
28
+ } from "./components/SubscriptionRow";
29
+ export {
30
+ NotificationSubscriptionsManager,
31
+ type NotificationSubscriptionsManagerProps,
32
+ } from "./components/NotificationSubscriptionsManager";
33
+ export {
34
+ registerSubscriptionSubControls,
35
+ getSubscriptionSubControls,
36
+ type SubscriptionSubControlsComponent,
37
+ } from "./components/SubscriptionSubControlsRegistry";
38
+
16
39
  export const notificationPlugin = createFrontendPlugin({
17
40
  metadata: pluginMetadata,
18
41
  routes: [
@@ -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