@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.
@@ -0,0 +1,239 @@
1
+ import React from "react";
2
+ import { Bell, BellOff, BellRing } from "lucide-react";
3
+ import {
4
+ Button,
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ useToast,
12
+ cn,
13
+ } from "@checkstack/ui";
14
+ import { usePluginClient } from "@checkstack/frontend-api";
15
+ import {
16
+ NotificationApi,
17
+ subscriptionGroupId,
18
+ type NotificationTarget,
19
+ } from "@checkstack/notification-common";
20
+ import { extractErrorMessage } from "@checkstack/common";
21
+ import { SubscriptionRow } from "./SubscriptionRow";
22
+
23
+ export interface NotificationSubscriptionsManagerProps<TResource> {
24
+ /**
25
+ * The notification target — typed handle on a kind of resource.
26
+ * Drives which subscription specs the dialog enumerates.
27
+ */
28
+ target: NotificationTarget<TResource>;
29
+ /**
30
+ * The specific resource (e.g. a system or group object). Forwarded
31
+ * to per-spec sub-control panels via the SubControls registry.
32
+ */
33
+ resource: TResource;
34
+ /** Trigger size; defaults to icon-only Bell button. */
35
+ triggerSize?: "default" | "sm" | "lg" | "icon";
36
+ triggerClassName?: string;
37
+ }
38
+
39
+ /**
40
+ * Single component used on every "manage notifications for this
41
+ * resource" surface. The dialog enumerates registered subscription
42
+ * specs *from the backend's spec registry* (single source of truth) —
43
+ * so a row appears for every spec the platform knows about, not just
44
+ * the ones a frontend plugin remembered to wire up. Sub-controls
45
+ * (e.g. anomaly's per-field mute) attach via the
46
+ * `registerSubscriptionSubControls` registry, keyed by specId.
47
+ */
48
+ export function NotificationSubscriptionsManager<TResource>({
49
+ target,
50
+ resource,
51
+ triggerSize = "icon",
52
+ triggerClassName,
53
+ }: NotificationSubscriptionsManagerProps<TResource>) {
54
+ const [open, setOpen] = React.useState(false);
55
+ const notificationClient = usePluginClient(NotificationApi);
56
+ const toast = useToast();
57
+
58
+ const resourceKey = target.keyOf(resource);
59
+ const resourceLabel = target.labelOf(resource);
60
+
61
+ const { data: allSpecs = [] } =
62
+ notificationClient.listSubscriptionSpecs.useQuery(
63
+ {},
64
+ { staleTime: 60_000 },
65
+ );
66
+
67
+ const specs = React.useMemo(
68
+ () => allSpecs.filter((s) => s.targetTypeId === target.targetTypeId),
69
+ [allSpecs, target.targetTypeId],
70
+ );
71
+
72
+ const groupIds = React.useMemo(
73
+ () => specs.map((s) => subscriptionGroupId(s, resourceKey)),
74
+ [specs, resourceKey],
75
+ );
76
+
77
+ const { data: statusMap = {}, refetch: refetchStatus } =
78
+ notificationClient.getMySubscriptionStatus.useQuery(
79
+ { groupIds },
80
+ { enabled: groupIds.length > 0, staleTime: 30_000 },
81
+ );
82
+
83
+ const subscribedCount = groupIds.filter((id) => statusMap[id]).length;
84
+ const totalCount = groupIds.length;
85
+ const allSubscribed = totalCount > 0 && subscribedCount === totalCount;
86
+ const anySubscribed = subscribedCount > 0;
87
+
88
+ const subscribeMutation = notificationClient.subscribe.useMutation();
89
+ const unsubscribeMutation = notificationClient.unsubscribe.useMutation();
90
+ const isPending =
91
+ subscribeMutation.isPending || unsubscribeMutation.isPending;
92
+
93
+ const handleSubscribeAll = async () => {
94
+ try {
95
+ await Promise.all(
96
+ groupIds
97
+ .filter((id) => !statusMap[id])
98
+ .map((groupId) => subscribeMutation.mutateAsync({ groupId })),
99
+ );
100
+ toast.success(`Subscribed to all notifications for ${resourceLabel}`);
101
+ void refetchStatus();
102
+ } catch (error) {
103
+ toast.error(extractErrorMessage(error, "Failed to subscribe to all"));
104
+ }
105
+ };
106
+
107
+ const handleUnsubscribeAll = async () => {
108
+ try {
109
+ await Promise.all(
110
+ groupIds
111
+ .filter((id) => statusMap[id])
112
+ .map((groupId) => unsubscribeMutation.mutateAsync({ groupId })),
113
+ );
114
+ toast.success(
115
+ `Unsubscribed from all notifications for ${resourceLabel}`,
116
+ );
117
+ void refetchStatus();
118
+ } catch (error) {
119
+ toast.error(
120
+ extractErrorMessage(error, "Failed to unsubscribe from all"),
121
+ );
122
+ }
123
+ };
124
+
125
+ const TriggerIcon = anySubscribed ? BellRing : Bell;
126
+
127
+ return (
128
+ <>
129
+ <Button
130
+ type="button"
131
+ variant={anySubscribed ? "primary" : "ghost"}
132
+ size={triggerSize}
133
+ onClick={() => setOpen(true)}
134
+ className={cn(
135
+ "transition-all duration-200",
136
+ !anySubscribed && "text-muted-foreground hover:text-foreground",
137
+ triggerClassName,
138
+ )}
139
+ title={`Manage notifications for ${resourceLabel}`}
140
+ aria-label={`Manage notifications for ${resourceLabel}`}
141
+ >
142
+ <TriggerIcon
143
+ className={cn("h-4 w-4", anySubscribed && "fill-current")}
144
+ aria-hidden="true"
145
+ />
146
+ </Button>
147
+ <Dialog open={open} onOpenChange={setOpen}>
148
+ <DialogContent size="lg">
149
+ <DialogHeader>
150
+ <DialogTitle className="flex items-center gap-2">
151
+ <Bell className="h-4 w-4" />
152
+ Notifications for {resourceLabel}
153
+ </DialogTitle>
154
+ <DialogDescription>
155
+ Choose which notification types you want to receive for
156
+ this {target.resourceKind}.
157
+ </DialogDescription>
158
+ </DialogHeader>
159
+
160
+ <div className="space-y-3">
161
+ {totalCount === 0 ? (
162
+ <div className="rounded-md border border-border bg-muted/20 p-4 text-sm text-muted-foreground">
163
+ No notification types are available for this{" "}
164
+ {target.resourceKind} yet.
165
+ </div>
166
+ ) : (
167
+ <>
168
+ <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/20 p-3">
169
+ <div className="text-sm min-w-0">
170
+ <div className="font-medium">
171
+ {subscribedCount} of {totalCount} subscribed
172
+ </div>
173
+ <div className="text-xs text-muted-foreground">
174
+ Toggle every notification type at once.
175
+ </div>
176
+ </div>
177
+ <div className="flex items-center gap-2 shrink-0">
178
+ {allSubscribed ? (
179
+ <Button
180
+ type="button"
181
+ variant="outline"
182
+ size="sm"
183
+ disabled={isPending}
184
+ onClick={handleUnsubscribeAll}
185
+ >
186
+ <BellOff className="mr-1 h-3 w-3" />
187
+ Unsubscribe from all
188
+ </Button>
189
+ ) : (
190
+ <Button
191
+ type="button"
192
+ variant="primary"
193
+ size="sm"
194
+ disabled={isPending}
195
+ onClick={handleSubscribeAll}
196
+ >
197
+ <BellRing className="mr-1 h-3 w-3" />
198
+ Subscribe to all
199
+ </Button>
200
+ )}
201
+ </div>
202
+ </div>
203
+ <div className="overflow-hidden rounded-md border border-border">
204
+ {specs.map((spec) => {
205
+ const groupId = subscriptionGroupId(spec, resourceKey);
206
+ return (
207
+ <SubscriptionRow
208
+ key={spec.specId}
209
+ specId={spec.specId}
210
+ title={spec.display.title}
211
+ description={spec.display.description}
212
+ iconName={spec.display.iconName}
213
+ groupId={groupId}
214
+ resource={resource}
215
+ isDirectlySubscribed={statusMap[groupId] ?? false}
216
+ onToggled={() => void refetchStatus()}
217
+ />
218
+ );
219
+ })}
220
+ </div>
221
+ </>
222
+ )}
223
+ </div>
224
+
225
+ <DialogFooter>
226
+ <Button
227
+ type="button"
228
+ variant="outline"
229
+ size="sm"
230
+ onClick={() => setOpen(false)}
231
+ >
232
+ Done
233
+ </Button>
234
+ </DialogFooter>
235
+ </DialogContent>
236
+ </Dialog>
237
+ </>
238
+ );
239
+ }
@@ -0,0 +1,57 @@
1
+ import type { ComponentType } from "react";
2
+ import { Box } from "lucide-react";
3
+ import type { LucideIcon } from "lucide-react";
4
+
5
+ /**
6
+ * Frontend rendering metadata for a notification subject `kind`. Domain
7
+ * plugins register their kinds at module load (typically from each
8
+ * `*-frontend` package's plugin entry point) so the bell and notifications
9
+ * page can show kind-appropriate icons and labels.
10
+ *
11
+ * Kinds are namespaced as `<pluginId>.<localKind>` (matching the backend
12
+ * convention enforced by `NotificationSubjectSchema`). Unknown kinds
13
+ * (e.g., from a plugin the current user hasn't installed) fall back to a
14
+ * generic chip.
15
+ */
16
+ export interface SubjectKindRenderer {
17
+ /** Human-readable singular label, e.g., "System". */
18
+ label: string;
19
+ /** Lucide icon component shown in the chip. */
20
+ icon: LucideIcon | ComponentType<{ className?: string }>;
21
+ }
22
+
23
+ const registry = new Map<string, SubjectKindRenderer>();
24
+
25
+ /**
26
+ * Register frontend rendering metadata for a notification subject kind.
27
+ * Idempotent — re-registering the same kind overwrites the prior entry.
28
+ *
29
+ * Example:
30
+ * ```ts
31
+ * import { Server } from "lucide-react";
32
+ * import { registerSubjectKind } from "@checkstack/notification-frontend";
33
+ *
34
+ * registerSubjectKind("catalog.system", { label: "System", icon: Server });
35
+ * ```
36
+ */
37
+ export function registerSubjectKind(
38
+ kind: string,
39
+ renderer: SubjectKindRenderer,
40
+ ): void {
41
+ registry.set(kind, renderer);
42
+ }
43
+
44
+ /** Default renderer used for unknown / unregistered kinds. */
45
+ const FALLBACK_RENDERER: SubjectKindRenderer = {
46
+ label: "Subject",
47
+ icon: Box,
48
+ };
49
+
50
+ /**
51
+ * Look up the renderer for a kind, falling back to a generic chip when the
52
+ * kind hasn't been registered (e.g., emitted by a plugin not loaded in
53
+ * this frontend bundle).
54
+ */
55
+ export function getSubjectKindRenderer(kind: string): SubjectKindRenderer {
56
+ return registry.get(kind) ?? FALLBACK_RENDERER;
57
+ }
@@ -0,0 +1,191 @@
1
+ import React from "react";
2
+ import { Bell, BellOff, ChevronDown, ChevronRight } from "lucide-react";
3
+ import { Button, DynamicIcon, type LucideIconName, useToast } from "@checkstack/ui";
4
+ import { usePluginClient } from "@checkstack/frontend-api";
5
+ import { NotificationApi } from "@checkstack/notification-common";
6
+ import { extractErrorMessage } from "@checkstack/common";
7
+ import {
8
+ getSubscriptionSubControls,
9
+ type SubscriptionSubControlsComponent,
10
+ } from "./SubscriptionSubControlsRegistry";
11
+
12
+ /**
13
+ * Inheritance source enriched with display data. The row reveals these
14
+ * as a small "Inherited from: …" hint when the user is reachable via a
15
+ * parent group rather than a direct subscription.
16
+ */
17
+ export interface ResolvedInheritance {
18
+ groupId: string;
19
+ label: string;
20
+ /** True if the user is subscribed to this parent group. */
21
+ subscribed: boolean;
22
+ }
23
+
24
+ export interface SubscriptionRowProps {
25
+ /** specId — also the lookup key into the SubControls registry. */
26
+ specId: string;
27
+ /** Display fields lifted off the registered spec record. */
28
+ title: string;
29
+ description: string;
30
+ iconName?: string;
31
+ /**
32
+ * The fully-resolved groupId for this (spec × resource). Computed by
33
+ * the manager so the row doesn't need the spec object.
34
+ */
35
+ groupId: string;
36
+ /**
37
+ * The resource handed to the optional SubControls component (anomaly
38
+ * mute list, etc.). Untyped here because the registry stores
39
+ * components keyed by specId — the registering plugin owns the type
40
+ * contract.
41
+ */
42
+ resource: unknown;
43
+ /** Whether the current user is directly subscribed to `groupId`. */
44
+ isDirectlySubscribed: boolean;
45
+ /**
46
+ * Pre-resolved inheritance sources. Optional, cosmetic — the backend
47
+ * dispatcher already walks parent edges at notification time.
48
+ */
49
+ inheritance?: ResolvedInheritance[];
50
+ /** Map of inherited groupId → subscribed?. Optional. */
51
+ inheritanceStatus?: Record<string, boolean>;
52
+ /** Re-fetch the surrounding status query after a successful toggle. */
53
+ onToggled?: () => void;
54
+ }
55
+
56
+ /**
57
+ * Generic row that powers every notification-subscription dialog row.
58
+ * Owns the subscribe toggle, the inheritance hint, and the optional
59
+ * sub-controls panel. Driven entirely by display fields from the
60
+ * backend's spec registry — no ties to a slot extension.
61
+ */
62
+ export function SubscriptionRow({
63
+ specId,
64
+ title,
65
+ description,
66
+ iconName,
67
+ groupId,
68
+ resource,
69
+ isDirectlySubscribed,
70
+ inheritance,
71
+ inheritanceStatus,
72
+ onToggled,
73
+ }: SubscriptionRowProps) {
74
+ const notificationClient = usePluginClient(NotificationApi);
75
+ const toast = useToast();
76
+ const [expanded, setExpanded] = React.useState(false);
77
+
78
+ const SubControls = getSubscriptionSubControls(specId) as
79
+ | SubscriptionSubControlsComponent<unknown>
80
+ | undefined;
81
+
82
+ const inheritedActive = (inheritance ?? []).filter(
83
+ (i) => inheritanceStatus?.[i.groupId],
84
+ );
85
+ const isReachable = isDirectlySubscribed || inheritedActive.length > 0;
86
+
87
+ const subscribeMutation = notificationClient.subscribe.useMutation({
88
+ onSuccess: () => onToggled?.(),
89
+ onError: (error) =>
90
+ toast.error(
91
+ extractErrorMessage(error, "Failed to subscribe to notifications"),
92
+ ),
93
+ });
94
+ const unsubscribeMutation = notificationClient.unsubscribe.useMutation({
95
+ onSuccess: () => onToggled?.(),
96
+ onError: (error) =>
97
+ toast.error(
98
+ extractErrorMessage(error, "Failed to unsubscribe from notifications"),
99
+ ),
100
+ });
101
+
102
+ const isPending =
103
+ subscribeMutation.isPending || unsubscribeMutation.isPending;
104
+
105
+ const handleToggle = () => {
106
+ if (isDirectlySubscribed) {
107
+ unsubscribeMutation.mutate({ groupId });
108
+ } else {
109
+ subscribeMutation.mutate({ groupId });
110
+ }
111
+ };
112
+
113
+ const ToggleIcon = isDirectlySubscribed ? BellOff : Bell;
114
+
115
+ return (
116
+ <div className="flex flex-col border-b last:border-b-0">
117
+ <div className="flex items-center gap-3 px-4 py-3">
118
+ <div className="shrink-0 rounded-md bg-muted/40 p-2 text-muted-foreground">
119
+ {iconName ? (
120
+ <DynamicIcon
121
+ name={iconName as LucideIconName}
122
+ className="h-4 w-4"
123
+ fallback={Bell}
124
+ />
125
+ ) : (
126
+ <Bell className="h-4 w-4" />
127
+ )}
128
+ </div>
129
+ <div className="flex-1 min-w-0">
130
+ <div className="text-sm font-medium">{title}</div>
131
+ <div className="text-xs text-muted-foreground">{description}</div>
132
+ {!isDirectlySubscribed && inheritedActive.length > 0 && (
133
+ <div className="text-[11px] text-muted-foreground mt-1">
134
+ Inherited from:{" "}
135
+ {inheritedActive.map((i, idx) => (
136
+ <React.Fragment key={i.groupId}>
137
+ {idx > 0 ? ", " : undefined}
138
+ <span className="font-medium">{i.label}</span>
139
+ </React.Fragment>
140
+ ))}
141
+ <Button
142
+ type="button"
143
+ variant="link"
144
+ size="sm"
145
+ className="ml-1 h-auto p-0 text-[11px]"
146
+ disabled={isPending}
147
+ onClick={handleToggle}
148
+ >
149
+ Override here
150
+ </Button>
151
+ </div>
152
+ )}
153
+ </div>
154
+ <div className="flex items-center gap-2 shrink-0">
155
+ {SubControls && isReachable && (
156
+ <Button
157
+ type="button"
158
+ variant="ghost"
159
+ size="sm"
160
+ className="h-7 px-2 text-[11px] gap-1"
161
+ onClick={() => setExpanded((e) => !e)}
162
+ >
163
+ {expanded ? (
164
+ <ChevronDown className="h-3 w-3" />
165
+ ) : (
166
+ <ChevronRight className="h-3 w-3" />
167
+ )}
168
+ Details
169
+ </Button>
170
+ )}
171
+ <Button
172
+ type="button"
173
+ variant={isDirectlySubscribed ? "outline" : "primary"}
174
+ size="sm"
175
+ className="h-7 px-2 text-[11px] gap-1"
176
+ disabled={isPending}
177
+ onClick={handleToggle}
178
+ >
179
+ <ToggleIcon className="h-3 w-3" />
180
+ {isDirectlySubscribed ? "Unsubscribe" : "Subscribe"}
181
+ </Button>
182
+ </div>
183
+ </div>
184
+ {SubControls && isReachable && expanded && (
185
+ <div className="px-4 pb-4 pt-1 border-t bg-muted/20">
186
+ <SubControls resource={resource} groupId={groupId} />
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,45 @@
1
+ import type { ComponentType } from "react";
2
+ import type { NotificationSubscriptionSpec } from "@checkstack/notification-common";
3
+
4
+ /**
5
+ * Optional per-spec sub-control panel — anomaly's per-field mute list,
6
+ * future severity / channel filters, etc. The dialog renders rows from
7
+ * the backend's spec registry (single source of truth), but plugins
8
+ * that want sub-granularity register their React component here keyed
9
+ * by `specId`.
10
+ *
11
+ * Registration is idempotent — re-registering the same spec overwrites
12
+ * the prior entry.
13
+ */
14
+ export type SubscriptionSubControlsComponent<TResource = unknown> =
15
+ ComponentType<{
16
+ resource: TResource;
17
+ groupId: string;
18
+ }>;
19
+
20
+ const registry = new Map<string, SubscriptionSubControlsComponent<unknown>>();
21
+
22
+ /**
23
+ * Register a sub-control panel for a subscription spec. Most plugins
24
+ * never call this — only the few that want sub-granularity (anomaly's
25
+ * mute list).
26
+ */
27
+ export function registerSubscriptionSubControls<TResource>(
28
+ spec: NotificationSubscriptionSpec<TResource>,
29
+ Component: SubscriptionSubControlsComponent<TResource>,
30
+ ): void {
31
+ registry.set(
32
+ spec.specId,
33
+ Component as SubscriptionSubControlsComponent<unknown>,
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Look up the registered sub-control component for a spec id; returns
39
+ * undefined when none was registered.
40
+ */
41
+ export function getSubscriptionSubControls(
42
+ specId: string,
43
+ ): SubscriptionSubControlsComponent<unknown> | undefined {
44
+ return registry.get(specId);
45
+ }
@@ -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: [