@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.
- package/CHANGELOG.md +187 -0
- package/package.json +6 -6
- 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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
Popover,
|
|
5
|
+
PopoverContent,
|
|
6
|
+
PopoverTrigger,
|
|
7
|
+
Button,
|
|
8
|
+
usePerformance,
|
|
9
|
+
} from "@checkstack/ui";
|
|
10
|
+
import type { NotificationSubject } from "@checkstack/notification-common";
|
|
11
|
+
import { getSubjectKindRenderer } from "./SubjectKindRegistry";
|
|
12
|
+
|
|
13
|
+
const STATUS_DOT_COLOR: Record<
|
|
14
|
+
NonNullable<NotificationSubject["status"]>,
|
|
15
|
+
string
|
|
16
|
+
> = {
|
|
17
|
+
healthy: "bg-emerald-500",
|
|
18
|
+
degraded: "bg-amber-500",
|
|
19
|
+
unhealthy: "bg-rose-500",
|
|
20
|
+
unknown: "bg-zinc-400",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface NotificationSubjectsProps {
|
|
24
|
+
subjects: NotificationSubject[];
|
|
25
|
+
/** Inline cap before collapsing the rest into a "+N" overflow popover. */
|
|
26
|
+
maxVisible?: number;
|
|
27
|
+
/** Optional callback fired when a chip's link is clicked. */
|
|
28
|
+
onSubjectClick?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compact chip list of affected entities. Each chip shows the kind icon (via
|
|
33
|
+
* the kind registry) and the subject name; clicking links to the subject's
|
|
34
|
+
* url when present. Status maps to a small colored dot.
|
|
35
|
+
*
|
|
36
|
+
* On low-power devices, transitions are disabled.
|
|
37
|
+
*/
|
|
38
|
+
export function NotificationSubjects({
|
|
39
|
+
subjects,
|
|
40
|
+
maxVisible = 3,
|
|
41
|
+
onSubjectClick,
|
|
42
|
+
}: NotificationSubjectsProps) {
|
|
43
|
+
const [overflowOpen, setOverflowOpen] = useState(false);
|
|
44
|
+
const { isLowPower } = usePerformance();
|
|
45
|
+
|
|
46
|
+
if (subjects.length === 0) return <></>;
|
|
47
|
+
|
|
48
|
+
const visible = subjects.slice(0, maxVisible);
|
|
49
|
+
const overflow = subjects.slice(maxVisible);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
|
53
|
+
{visible.map((subject) => (
|
|
54
|
+
<SubjectChip
|
|
55
|
+
key={`${subject.kind}:${subject.id}`}
|
|
56
|
+
subject={subject}
|
|
57
|
+
isLowPower={isLowPower}
|
|
58
|
+
onClick={onSubjectClick}
|
|
59
|
+
/>
|
|
60
|
+
))}
|
|
61
|
+
{overflow.length > 0 && (
|
|
62
|
+
<Popover open={overflowOpen} onOpenChange={setOverflowOpen}>
|
|
63
|
+
<PopoverTrigger asChild>
|
|
64
|
+
<Button
|
|
65
|
+
type="button"
|
|
66
|
+
variant="outline"
|
|
67
|
+
size="sm"
|
|
68
|
+
className="h-6 px-2 text-xs"
|
|
69
|
+
>
|
|
70
|
+
+{overflow.length} more
|
|
71
|
+
</Button>
|
|
72
|
+
</PopoverTrigger>
|
|
73
|
+
<PopoverContent
|
|
74
|
+
className="w-72 p-2"
|
|
75
|
+
align="start"
|
|
76
|
+
onCloseAutoFocus={(e) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<div className="flex flex-col gap-1">
|
|
81
|
+
{overflow.map((subject) => (
|
|
82
|
+
<SubjectChip
|
|
83
|
+
key={`${subject.kind}:${subject.id}`}
|
|
84
|
+
subject={subject}
|
|
85
|
+
isLowPower={isLowPower}
|
|
86
|
+
onClick={() => {
|
|
87
|
+
setOverflowOpen(false);
|
|
88
|
+
onSubjectClick?.();
|
|
89
|
+
}}
|
|
90
|
+
fullWidth
|
|
91
|
+
/>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
</PopoverContent>
|
|
95
|
+
</Popover>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface SubjectChipProps {
|
|
102
|
+
subject: NotificationSubject;
|
|
103
|
+
isLowPower: boolean;
|
|
104
|
+
onClick?: () => void;
|
|
105
|
+
fullWidth?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function SubjectChip({
|
|
109
|
+
subject,
|
|
110
|
+
isLowPower,
|
|
111
|
+
onClick,
|
|
112
|
+
fullWidth,
|
|
113
|
+
}: SubjectChipProps) {
|
|
114
|
+
const renderer = getSubjectKindRenderer(subject.kind);
|
|
115
|
+
const Icon = renderer.icon;
|
|
116
|
+
const dotColor = subject.status
|
|
117
|
+
? STATUS_DOT_COLOR[subject.status]
|
|
118
|
+
: undefined;
|
|
119
|
+
|
|
120
|
+
const baseClass = [
|
|
121
|
+
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md",
|
|
122
|
+
"border border-border bg-card text-foreground",
|
|
123
|
+
"text-xs font-medium max-w-[14rem] truncate",
|
|
124
|
+
isLowPower ? "" : "transition-colors hover:bg-accent",
|
|
125
|
+
fullWidth ? "w-full" : "",
|
|
126
|
+
]
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join(" ");
|
|
129
|
+
|
|
130
|
+
const content = (
|
|
131
|
+
<>
|
|
132
|
+
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
133
|
+
{dotColor && (
|
|
134
|
+
<span
|
|
135
|
+
className={`inline-block h-2 w-2 rounded-full shrink-0 ${dotColor}`}
|
|
136
|
+
aria-label={subject.status ?? undefined}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
<span className="truncate" title={`${renderer.label}: ${subject.name}`}>
|
|
140
|
+
{subject.name}
|
|
141
|
+
</span>
|
|
142
|
+
</>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (subject.url) {
|
|
146
|
+
return (
|
|
147
|
+
<Link to={subject.url} className={baseClass} onClick={onClick}>
|
|
148
|
+
{content}
|
|
149
|
+
</Link>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return <span className={baseClass}>{content}</span>;
|
|
154
|
+
}
|
|
@@ -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
|
+
}
|