@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.
- package/CHANGELOG.md +295 -0
- package/package.json +9 -8
- 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
- package/tsconfig.json +21 -1
|
@@ -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: [
|