@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,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
+ }