@checkstack/notification-frontend 0.1.0 → 0.2.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 +73 -0
- package/package.json +1 -1
- package/src/components/NotificationBell.tsx +47 -64
- package/src/pages/NotificationSettingsPage.tsx +183 -261
- package/src/pages/NotificationsPage.tsx +65 -74
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# @checkstack/notification-frontend
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [4eed42d]
|
|
8
|
+
- @checkstack/frontend-api@0.3.0
|
|
9
|
+
- @checkstack/auth-frontend@0.3.1
|
|
10
|
+
- @checkstack/ui@0.2.2
|
|
11
|
+
|
|
12
|
+
## 0.2.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- 7a23261: ## TanStack Query Integration
|
|
17
|
+
|
|
18
|
+
Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
|
|
19
|
+
|
|
20
|
+
### New Features
|
|
21
|
+
|
|
22
|
+
- **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
|
|
23
|
+
- **Automatic request deduplication**: Multiple components requesting the same data share a single network request
|
|
24
|
+
- **Built-in caching**: Configurable stale time and cache duration per query
|
|
25
|
+
- **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
|
|
26
|
+
- **Background refetching**: Stale data is automatically refreshed when components mount
|
|
27
|
+
|
|
28
|
+
### Contract Changes
|
|
29
|
+
|
|
30
|
+
All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
const getItems = proc()
|
|
34
|
+
.meta({ operationType: "query", access: [access.read] })
|
|
35
|
+
.output(z.array(itemSchema))
|
|
36
|
+
.query();
|
|
37
|
+
|
|
38
|
+
const createItem = proc()
|
|
39
|
+
.meta({ operationType: "mutation", access: [access.manage] })
|
|
40
|
+
.input(createItemSchema)
|
|
41
|
+
.output(itemSchema)
|
|
42
|
+
.mutation();
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Migration
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
// Before (forPlugin pattern)
|
|
49
|
+
const api = useApi(myPluginApiRef);
|
|
50
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
api.getItems().then(setItems);
|
|
53
|
+
}, [api]);
|
|
54
|
+
|
|
55
|
+
// After (usePluginClient pattern)
|
|
56
|
+
const client = usePluginClient(MyPluginApi);
|
|
57
|
+
const { data: items, isLoading } = client.getItems.useQuery({});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Bug Fixes
|
|
61
|
+
|
|
62
|
+
- Fixed `rpc.test.ts` test setup for middleware type inference
|
|
63
|
+
- Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
|
|
64
|
+
- Fixed null→undefined warnings in notification and queue frontends
|
|
65
|
+
|
|
66
|
+
### Patch Changes
|
|
67
|
+
|
|
68
|
+
- Updated dependencies [7a23261]
|
|
69
|
+
- @checkstack/frontend-api@0.2.0
|
|
70
|
+
- @checkstack/common@0.3.0
|
|
71
|
+
- @checkstack/auth-frontend@0.3.0
|
|
72
|
+
- @checkstack/notification-common@0.2.0
|
|
73
|
+
- @checkstack/ui@0.2.1
|
|
74
|
+
- @checkstack/signal-frontend@0.0.7
|
|
75
|
+
|
|
3
76
|
## 0.1.0
|
|
4
77
|
|
|
5
78
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
2
|
import { Link } from "react-router-dom";
|
|
3
3
|
import { Bell, CheckCheck } from "lucide-react";
|
|
4
4
|
import {
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
Button,
|
|
12
12
|
stripMarkdown,
|
|
13
13
|
} from "@checkstack/ui";
|
|
14
|
-
import { useApi,
|
|
14
|
+
import { useApi, usePluginClient } from "@checkstack/frontend-api";
|
|
15
15
|
import { useSignal } from "@checkstack/signal-frontend";
|
|
16
16
|
import { resolveRoute } from "@checkstack/common";
|
|
17
17
|
import type { Notification } from "@checkstack/notification-common";
|
|
@@ -27,54 +27,42 @@ import { authApiRef } from "@checkstack/auth-frontend/api";
|
|
|
27
27
|
export const NotificationBell = () => {
|
|
28
28
|
const authApi = useApi(authApiRef);
|
|
29
29
|
const { data: session, isPending: isAuthLoading } = authApi.useSession();
|
|
30
|
-
const
|
|
31
|
-
const notificationClient = rpcApi.forPlugin(NotificationApi);
|
|
30
|
+
const notificationClient = usePluginClient(NotificationApi);
|
|
32
31
|
|
|
33
|
-
const [unreadCount, setUnreadCount] = useState(0);
|
|
34
|
-
const [recentNotifications, setRecentNotifications] = useState<
|
|
35
|
-
Notification[]
|
|
36
|
-
>([]);
|
|
37
32
|
const [isOpen, setIsOpen] = useState(false);
|
|
38
|
-
const [loading, setLoading] = useState(true);
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.error("Failed to fetch unread count:", error);
|
|
48
|
-
}
|
|
49
|
-
}, [notificationClient, session]);
|
|
34
|
+
// State for real-time updates
|
|
35
|
+
const [signalUnreadCount, setSignalUnreadCount] = useState<
|
|
36
|
+
number | undefined
|
|
37
|
+
>();
|
|
38
|
+
const [signalNotifications, setSignalNotifications] = useState<
|
|
39
|
+
Notification[] | undefined
|
|
40
|
+
>();
|
|
50
41
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
offset: 0,
|
|
58
|
-
unreadOnly: true, // Only show unread notifications in the dropdown
|
|
59
|
-
});
|
|
60
|
-
setRecentNotifications(notifications);
|
|
61
|
-
} catch (error) {
|
|
62
|
-
console.error("Failed to fetch notifications:", error);
|
|
63
|
-
}
|
|
64
|
-
}, [notificationClient, session]);
|
|
42
|
+
// Fetch unread count via useQuery
|
|
43
|
+
const { data: unreadData, isLoading: unreadLoading } =
|
|
44
|
+
notificationClient.getUnreadCount.useQuery(undefined, {
|
|
45
|
+
enabled: !!session,
|
|
46
|
+
staleTime: 30_000,
|
|
47
|
+
});
|
|
65
48
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
49
|
+
// Fetch recent notifications via useQuery
|
|
50
|
+
const { data: notificationsData, isLoading: notificationsLoading } =
|
|
51
|
+
notificationClient.getNotifications.useQuery(
|
|
52
|
+
{ limit: 5, offset: 0, unreadOnly: true },
|
|
53
|
+
{
|
|
54
|
+
enabled: !!session && isOpen,
|
|
55
|
+
staleTime: 30_000,
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Mark all as read mutation
|
|
60
|
+
const markAsReadMutation = notificationClient.markAsRead.useMutation();
|
|
61
|
+
|
|
62
|
+
// Use signal data if available, otherwise use query data
|
|
63
|
+
const unreadCount = signalUnreadCount ?? unreadData?.count ?? 0;
|
|
64
|
+
const recentNotifications =
|
|
65
|
+
signalNotifications ?? notificationsData?.notifications ?? [];
|
|
78
66
|
|
|
79
67
|
// ==========================================================================
|
|
80
68
|
// REALTIME SIGNAL SUBSCRIPTIONS (replaces polling)
|
|
@@ -85,10 +73,10 @@ export const NotificationBell = () => {
|
|
|
85
73
|
NOTIFICATION_RECEIVED,
|
|
86
74
|
useCallback((payload) => {
|
|
87
75
|
// Increment unread count
|
|
88
|
-
|
|
76
|
+
setSignalUnreadCount((prev) => (prev ?? 0) + 1);
|
|
89
77
|
|
|
90
78
|
// Add to recent notifications if dropdown is open
|
|
91
|
-
|
|
79
|
+
setSignalNotifications((prev) => [
|
|
92
80
|
{
|
|
93
81
|
id: payload.id,
|
|
94
82
|
title: payload.title,
|
|
@@ -98,7 +86,7 @@ export const NotificationBell = () => {
|
|
|
98
86
|
isRead: false,
|
|
99
87
|
createdAt: new Date(),
|
|
100
88
|
},
|
|
101
|
-
...prev.slice(0, 4), // Keep only 5 items
|
|
89
|
+
...(prev ?? []).slice(0, 4), // Keep only 5 items
|
|
102
90
|
]);
|
|
103
91
|
}, [])
|
|
104
92
|
);
|
|
@@ -107,7 +95,7 @@ export const NotificationBell = () => {
|
|
|
107
95
|
useSignal(
|
|
108
96
|
NOTIFICATION_COUNT_CHANGED,
|
|
109
97
|
useCallback((payload) => {
|
|
110
|
-
|
|
98
|
+
setSignalUnreadCount(payload.unreadCount);
|
|
111
99
|
}, [])
|
|
112
100
|
);
|
|
113
101
|
|
|
@@ -117,32 +105,25 @@ export const NotificationBell = () => {
|
|
|
117
105
|
useCallback((payload) => {
|
|
118
106
|
if (payload.notificationId) {
|
|
119
107
|
// Single notification marked as read - remove from list
|
|
120
|
-
|
|
121
|
-
prev.filter((n) => n.id !== payload.notificationId)
|
|
108
|
+
setSignalNotifications((prev) =>
|
|
109
|
+
(prev ?? []).filter((n) => n.id !== payload.notificationId)
|
|
122
110
|
);
|
|
123
|
-
|
|
111
|
+
setSignalUnreadCount((prev) => Math.max(0, (prev ?? 1) - 1));
|
|
124
112
|
} else {
|
|
125
113
|
// All marked as read - clear the list
|
|
126
|
-
|
|
127
|
-
|
|
114
|
+
setSignalNotifications([]);
|
|
115
|
+
setSignalUnreadCount(0);
|
|
128
116
|
}
|
|
129
117
|
}, [])
|
|
130
118
|
);
|
|
131
119
|
|
|
132
120
|
// ==========================================================================
|
|
133
121
|
|
|
134
|
-
// Fetch notifications when dropdown opens
|
|
135
|
-
useEffect(() => {
|
|
136
|
-
if (isOpen) {
|
|
137
|
-
void fetchRecentNotifications();
|
|
138
|
-
}
|
|
139
|
-
}, [isOpen, fetchRecentNotifications]);
|
|
140
|
-
|
|
141
122
|
const handleMarkAllAsRead = async () => {
|
|
142
123
|
try {
|
|
143
|
-
await
|
|
144
|
-
|
|
145
|
-
|
|
124
|
+
await markAsReadMutation.mutateAsync({});
|
|
125
|
+
setSignalUnreadCount(0);
|
|
126
|
+
setSignalNotifications([]);
|
|
146
127
|
} catch (error) {
|
|
147
128
|
console.error("Failed to mark all as read:", error);
|
|
148
129
|
}
|
|
@@ -157,6 +138,8 @@ export const NotificationBell = () => {
|
|
|
157
138
|
return;
|
|
158
139
|
}
|
|
159
140
|
|
|
141
|
+
const loading = unreadLoading || notificationsLoading;
|
|
142
|
+
|
|
160
143
|
if (loading) {
|
|
161
144
|
return (
|
|
162
145
|
<Button variant="ghost" size="icon" className="relative" disabled>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
2
|
import { Bell, Clock, Zap, Send } from "lucide-react";
|
|
3
3
|
import {
|
|
4
4
|
PageLayout,
|
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
SectionHeader,
|
|
9
9
|
DynamicForm,
|
|
10
10
|
} from "@checkstack/ui";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
usePluginClient,
|
|
13
|
+
useApi,
|
|
14
|
+
accessApiRef,
|
|
15
|
+
} from "@checkstack/frontend-api";
|
|
12
16
|
import type { EnrichedSubscription } from "@checkstack/notification-common";
|
|
13
17
|
import {
|
|
14
18
|
NotificationApi,
|
|
@@ -24,287 +28,218 @@ import {
|
|
|
24
28
|
} from "../components/UserChannelCard";
|
|
25
29
|
|
|
26
30
|
export const NotificationSettingsPage = () => {
|
|
27
|
-
const
|
|
31
|
+
const notificationClient = usePluginClient(NotificationApi);
|
|
28
32
|
const accessApi = useApi(accessApiRef);
|
|
29
|
-
const notificationClient = rpcApi.forPlugin(NotificationApi);
|
|
30
33
|
const toast = useToast();
|
|
31
34
|
|
|
32
35
|
// Check if user has admin access
|
|
33
|
-
const { allowed: isAdmin } = accessApi.useAccess(
|
|
34
|
-
notificationAccess.admin
|
|
35
|
-
);
|
|
36
|
+
const { allowed: isAdmin } = accessApi.useAccess(notificationAccess.admin);
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
-
const [retentionSchema, setRetentionSchema] = useState<
|
|
39
|
-
Record<string, unknown> | undefined
|
|
40
|
-
>();
|
|
38
|
+
// Local state for editing
|
|
41
39
|
const [retentionSettings, setRetentionSettings] = useState<
|
|
42
40
|
Record<string, unknown>
|
|
43
41
|
>({
|
|
44
42
|
retentionDays: 30,
|
|
45
43
|
enabled: false,
|
|
46
44
|
});
|
|
47
|
-
const [retentionLoading, setRetentionLoading] = useState(true);
|
|
48
|
-
const [retentionSaving, setRetentionSaving] = useState(false);
|
|
49
45
|
const [retentionValid, setRetentionValid] = useState(true);
|
|
50
|
-
|
|
51
|
-
// Subscription state - now uses enriched subscriptions only
|
|
52
|
-
const [subscriptions, setSubscriptions] = useState<EnrichedSubscription[]>(
|
|
53
|
-
[]
|
|
54
|
-
);
|
|
55
|
-
const [subsLoading, setSubsLoading] = useState(true);
|
|
56
|
-
|
|
57
|
-
// Delivery strategies state (admin only)
|
|
58
|
-
const [strategies, setStrategies] = useState<DeliveryStrategy[]>([]);
|
|
59
|
-
const [strategiesLoading, setStrategiesLoading] = useState(true);
|
|
60
|
-
const [strategySaving, setStrategySaving] = useState<string | undefined>();
|
|
61
|
-
|
|
62
|
-
// User channels state
|
|
63
|
-
const [userChannels, setUserChannels] = useState<UserDeliveryChannel[]>([]);
|
|
64
|
-
const [channelsLoading, setChannelsLoading] = useState(true);
|
|
65
46
|
const [channelSaving, setChannelSaving] = useState<string | undefined>();
|
|
66
47
|
const [channelConnecting, setChannelConnecting] = useState<
|
|
67
48
|
string | undefined
|
|
68
49
|
>();
|
|
69
50
|
const [channelTesting, setChannelTesting] = useState<string | undefined>();
|
|
51
|
+
const [strategySaving, setStrategySaving] = useState<string | undefined>();
|
|
70
52
|
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
setRetentionLoading(false);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
try {
|
|
78
|
-
const [schema, settings] = await Promise.all([
|
|
79
|
-
notificationClient.getRetentionSchema(),
|
|
80
|
-
notificationClient.getRetentionSettings(),
|
|
81
|
-
]);
|
|
82
|
-
setRetentionSchema(schema as Record<string, unknown>);
|
|
83
|
-
setRetentionSettings(settings);
|
|
84
|
-
} catch (error) {
|
|
85
|
-
const message =
|
|
86
|
-
error instanceof Error
|
|
87
|
-
? error.message
|
|
88
|
-
: "Failed to load retention settings";
|
|
89
|
-
toast.error(message);
|
|
90
|
-
} finally {
|
|
91
|
-
setRetentionLoading(false);
|
|
92
|
-
}
|
|
93
|
-
}, [notificationClient, isAdmin, toast]);
|
|
53
|
+
// Query: Retention schema (admin only)
|
|
54
|
+
const { data: retentionSchema } =
|
|
55
|
+
notificationClient.getRetentionSchema.useQuery({}, { enabled: isAdmin });
|
|
94
56
|
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
const subsData = await notificationClient.getSubscriptions();
|
|
99
|
-
setSubscriptions(subsData);
|
|
100
|
-
} catch (error) {
|
|
101
|
-
const message =
|
|
102
|
-
error instanceof Error
|
|
103
|
-
? error.message
|
|
104
|
-
: "Failed to fetch subscriptions";
|
|
105
|
-
toast.error(message);
|
|
106
|
-
} finally {
|
|
107
|
-
setSubsLoading(false);
|
|
108
|
-
}
|
|
109
|
-
}, [notificationClient, toast]);
|
|
57
|
+
// Query: Retention settings (admin only)
|
|
58
|
+
const { data: fetchedRetentionSettings, isLoading: retentionLoading } =
|
|
59
|
+
notificationClient.getRetentionSettings.useQuery({}, { enabled: isAdmin });
|
|
110
60
|
|
|
111
|
-
//
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const data = await notificationClient.getDeliveryStrategies();
|
|
119
|
-
setStrategies(data as DeliveryStrategy[]);
|
|
120
|
-
} catch (error) {
|
|
121
|
-
const message =
|
|
122
|
-
error instanceof Error
|
|
123
|
-
? error.message
|
|
124
|
-
: "Failed to load delivery channels";
|
|
125
|
-
toast.error(message);
|
|
126
|
-
} finally {
|
|
127
|
-
setStrategiesLoading(false);
|
|
128
|
-
}
|
|
129
|
-
}, [notificationClient, isAdmin, toast]);
|
|
61
|
+
// Query: Subscriptions
|
|
62
|
+
const {
|
|
63
|
+
data: subscriptions = [],
|
|
64
|
+
isLoading: subsLoading,
|
|
65
|
+
refetch: refetchSubscriptions,
|
|
66
|
+
} = notificationClient.getSubscriptions.useQuery({});
|
|
130
67
|
|
|
131
|
-
//
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
} finally {
|
|
141
|
-
setChannelsLoading(false);
|
|
142
|
-
}
|
|
143
|
-
}, [notificationClient, toast]);
|
|
68
|
+
// Query: Delivery strategies (admin only)
|
|
69
|
+
const {
|
|
70
|
+
data: strategies = [],
|
|
71
|
+
isLoading: strategiesLoading,
|
|
72
|
+
refetch: refetchStrategies,
|
|
73
|
+
} = notificationClient.getDeliveryStrategies.useQuery(
|
|
74
|
+
{},
|
|
75
|
+
{ enabled: isAdmin }
|
|
76
|
+
);
|
|
144
77
|
|
|
78
|
+
// Query: User delivery channels
|
|
79
|
+
const {
|
|
80
|
+
data: userChannels = [],
|
|
81
|
+
isLoading: channelsLoading,
|
|
82
|
+
refetch: refetchChannels,
|
|
83
|
+
} = notificationClient.getUserDeliveryChannels.useQuery({});
|
|
84
|
+
|
|
85
|
+
// Sync fetched retention settings to local state
|
|
145
86
|
useEffect(() => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
87
|
+
if (fetchedRetentionSettings) {
|
|
88
|
+
setRetentionSettings(fetchedRetentionSettings);
|
|
89
|
+
}
|
|
90
|
+
}, [fetchedRetentionSettings]);
|
|
91
|
+
|
|
92
|
+
// Mutations
|
|
93
|
+
const setRetentionMutation =
|
|
94
|
+
notificationClient.setRetentionSettings.useMutation({
|
|
95
|
+
onSuccess: () => {
|
|
96
|
+
toast.success("Retention settings saved");
|
|
97
|
+
},
|
|
98
|
+
onError: (error) => {
|
|
99
|
+
toast.error(
|
|
100
|
+
error instanceof Error ? error.message : "Failed to save settings"
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
156
104
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
105
|
+
const unsubscribeMutation = notificationClient.unsubscribe.useMutation({
|
|
106
|
+
onSuccess: () => {
|
|
107
|
+
toast.success("Unsubscribed successfully");
|
|
108
|
+
void refetchSubscriptions();
|
|
109
|
+
},
|
|
110
|
+
onError: (error) => {
|
|
111
|
+
toast.error(
|
|
112
|
+
error instanceof Error ? error.message : "Failed to unsubscribe"
|
|
162
113
|
);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const updateStrategyMutation =
|
|
118
|
+
notificationClient.updateDeliveryStrategy.useMutation({
|
|
119
|
+
onSuccess: () => {
|
|
120
|
+
toast.success("Updated delivery channel");
|
|
121
|
+
void refetchStrategies();
|
|
122
|
+
setStrategySaving(undefined);
|
|
123
|
+
},
|
|
124
|
+
onError: (error) => {
|
|
125
|
+
toast.error(
|
|
126
|
+
error instanceof Error ? error.message : "Failed to update channel"
|
|
127
|
+
);
|
|
128
|
+
setStrategySaving(undefined);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const setUserPreferenceMutation =
|
|
133
|
+
notificationClient.setUserDeliveryPreference.useMutation({
|
|
134
|
+
onSuccess: () => {
|
|
135
|
+
toast.success("Updated notification channel");
|
|
136
|
+
void refetchChannels();
|
|
137
|
+
setChannelSaving(undefined);
|
|
138
|
+
},
|
|
139
|
+
onError: (error) => {
|
|
140
|
+
toast.error(
|
|
141
|
+
error instanceof Error ? error.message : "Failed to update preference"
|
|
142
|
+
);
|
|
143
|
+
setChannelSaving(undefined);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const unlinkChannelMutation =
|
|
148
|
+
notificationClient.unlinkDeliveryChannel.useMutation({
|
|
149
|
+
onSuccess: () => {
|
|
150
|
+
toast.success("Disconnected notification channel");
|
|
151
|
+
void refetchChannels();
|
|
152
|
+
setChannelSaving(undefined);
|
|
153
|
+
},
|
|
154
|
+
onError: (error) => {
|
|
155
|
+
toast.error(
|
|
156
|
+
error instanceof Error ? error.message : "Failed to disconnect"
|
|
157
|
+
);
|
|
158
|
+
setChannelSaving(undefined);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const getOAuthUrlMutation =
|
|
163
|
+
notificationClient.getDeliveryOAuthUrl.useMutation({
|
|
164
|
+
onSuccess: (data) => {
|
|
165
|
+
globalThis.location.href = data.authUrl;
|
|
166
|
+
},
|
|
167
|
+
onError: (error) => {
|
|
168
|
+
toast.error(
|
|
169
|
+
error instanceof Error ? error.message : "Failed to start OAuth flow"
|
|
170
|
+
);
|
|
171
|
+
setChannelConnecting(undefined);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const sendTestMutation = notificationClient.sendTestNotification.useMutation({
|
|
176
|
+
onSettled: () => {
|
|
177
|
+
setChannelTesting(undefined);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const handleSaveRetention = () => {
|
|
182
|
+
setRetentionMutation.mutate(
|
|
183
|
+
retentionSettings as { enabled: boolean; retentionDays: number }
|
|
184
|
+
);
|
|
171
185
|
};
|
|
172
186
|
|
|
173
|
-
const handleUnsubscribe =
|
|
174
|
-
|
|
175
|
-
await notificationClient.unsubscribe({ groupId });
|
|
176
|
-
setSubscriptions((prev) => prev.filter((s) => s.groupId !== groupId));
|
|
177
|
-
toast.success("Unsubscribed successfully");
|
|
178
|
-
} catch (error) {
|
|
179
|
-
const message =
|
|
180
|
-
error instanceof Error ? error.message : "Failed to unsubscribe";
|
|
181
|
-
toast.error(message);
|
|
182
|
-
}
|
|
187
|
+
const handleUnsubscribe = (groupId: string) => {
|
|
188
|
+
unsubscribeMutation.mutate({ groupId });
|
|
183
189
|
};
|
|
184
190
|
|
|
185
|
-
// Handle strategy update (enabled state and config)
|
|
186
191
|
const handleStrategyUpdate = async (
|
|
187
192
|
strategyId: string,
|
|
188
193
|
enabled: boolean,
|
|
189
194
|
config?: Record<string, unknown>,
|
|
190
195
|
layoutConfig?: Record<string, unknown>
|
|
191
196
|
) => {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
});
|
|
200
|
-
// Update local state
|
|
201
|
-
setStrategies((prev) =>
|
|
202
|
-
prev.map((s) =>
|
|
203
|
-
s.qualifiedId === strategyId
|
|
204
|
-
? { ...s, enabled, config, layoutConfig }
|
|
205
|
-
: s
|
|
206
|
-
)
|
|
207
|
-
);
|
|
208
|
-
toast.success(`${enabled ? "Enabled" : "Disabled"} delivery channel`);
|
|
209
|
-
} catch (error) {
|
|
210
|
-
const message =
|
|
211
|
-
error instanceof Error ? error.message : "Failed to update channel";
|
|
212
|
-
toast.error(message);
|
|
213
|
-
} finally {
|
|
214
|
-
setStrategySaving(undefined);
|
|
215
|
-
}
|
|
197
|
+
setStrategySaving(strategyId);
|
|
198
|
+
await updateStrategyMutation.mutateAsync({
|
|
199
|
+
strategyId,
|
|
200
|
+
enabled,
|
|
201
|
+
config,
|
|
202
|
+
layoutConfig,
|
|
203
|
+
});
|
|
216
204
|
};
|
|
217
205
|
|
|
218
|
-
// Handle user channel toggle
|
|
219
206
|
const handleChannelToggle = async (strategyId: string, enabled: boolean) => {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
});
|
|
226
|
-
setUserChannels((prev) =>
|
|
227
|
-
prev.map((c) => (c.strategyId === strategyId ? { ...c, enabled } : c))
|
|
228
|
-
);
|
|
229
|
-
toast.success(`${enabled ? "Enabled" : "Disabled"} notification channel`);
|
|
230
|
-
} catch (error) {
|
|
231
|
-
const message =
|
|
232
|
-
error instanceof Error ? error.message : "Failed to update preference";
|
|
233
|
-
toast.error(message);
|
|
234
|
-
} finally {
|
|
235
|
-
setChannelSaving(undefined);
|
|
236
|
-
}
|
|
207
|
+
setChannelSaving(strategyId);
|
|
208
|
+
await setUserPreferenceMutation.mutateAsync({
|
|
209
|
+
strategyId,
|
|
210
|
+
enabled,
|
|
211
|
+
});
|
|
237
212
|
};
|
|
238
213
|
|
|
239
|
-
// Handle OAuth connect
|
|
240
214
|
const handleChannelConnect = async (strategyId: string) => {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
});
|
|
247
|
-
// Redirect to OAuth provider
|
|
248
|
-
globalThis.location.href = authUrl;
|
|
249
|
-
} catch (error) {
|
|
250
|
-
const message =
|
|
251
|
-
error instanceof Error ? error.message : "Failed to start OAuth flow";
|
|
252
|
-
toast.error(message);
|
|
253
|
-
setChannelConnecting(undefined);
|
|
254
|
-
}
|
|
215
|
+
setChannelConnecting(strategyId);
|
|
216
|
+
await getOAuthUrlMutation.mutateAsync({
|
|
217
|
+
strategyId,
|
|
218
|
+
returnUrl: globalThis.location.pathname,
|
|
219
|
+
});
|
|
255
220
|
};
|
|
256
221
|
|
|
257
|
-
// Handle OAuth disconnect
|
|
258
222
|
const handleChannelDisconnect = async (strategyId: string) => {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
await notificationClient.unlinkDeliveryChannel({ strategyId });
|
|
262
|
-
setUserChannels((prev) =>
|
|
263
|
-
prev.map((c) =>
|
|
264
|
-
c.strategyId === strategyId
|
|
265
|
-
? { ...c, linkedAt: undefined, enabled: false, isConfigured: false }
|
|
266
|
-
: c
|
|
267
|
-
)
|
|
268
|
-
);
|
|
269
|
-
toast.success("Disconnected notification channel");
|
|
270
|
-
} catch (error) {
|
|
271
|
-
const message =
|
|
272
|
-
error instanceof Error ? error.message : "Failed to disconnect";
|
|
273
|
-
toast.error(message);
|
|
274
|
-
} finally {
|
|
275
|
-
setChannelSaving(undefined);
|
|
276
|
-
}
|
|
223
|
+
setChannelSaving(strategyId);
|
|
224
|
+
await unlinkChannelMutation.mutateAsync({ strategyId });
|
|
277
225
|
};
|
|
278
226
|
|
|
279
|
-
// Handle user config save
|
|
280
227
|
const handleChannelConfigSave = async (
|
|
281
228
|
strategyId: string,
|
|
282
229
|
userConfig: Record<string, unknown>
|
|
283
230
|
) => {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
? { ...c, userConfig, isConfigured: true }
|
|
297
|
-
: c
|
|
298
|
-
)
|
|
299
|
-
);
|
|
300
|
-
toast.success("Saved channel settings");
|
|
301
|
-
} catch (error) {
|
|
302
|
-
const message =
|
|
303
|
-
error instanceof Error ? error.message : "Failed to save settings";
|
|
304
|
-
toast.error(message);
|
|
305
|
-
} finally {
|
|
306
|
-
setChannelSaving(undefined);
|
|
307
|
-
}
|
|
231
|
+
setChannelSaving(strategyId);
|
|
232
|
+
const channel = userChannels.find((c) => c.strategyId === strategyId);
|
|
233
|
+
await setUserPreferenceMutation.mutateAsync({
|
|
234
|
+
strategyId,
|
|
235
|
+
enabled: channel?.enabled ?? false,
|
|
236
|
+
userConfig,
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const handleTest = async (strategyId: string) => {
|
|
241
|
+
setChannelTesting(strategyId);
|
|
242
|
+
return sendTestMutation.mutateAsync({ strategyId });
|
|
308
243
|
};
|
|
309
244
|
|
|
310
245
|
return (
|
|
@@ -323,7 +258,7 @@ export const NotificationSettingsPage = () => {
|
|
|
323
258
|
Loading your channels...
|
|
324
259
|
</div>
|
|
325
260
|
</Card>
|
|
326
|
-
) : userChannels.length === 0 ? (
|
|
261
|
+
) : (userChannels as UserDeliveryChannel[]).length === 0 ? (
|
|
327
262
|
<Card className="p-4">
|
|
328
263
|
<div className="text-center py-4 text-muted-foreground">
|
|
329
264
|
No notification channels available. Contact your administrator
|
|
@@ -332,7 +267,7 @@ export const NotificationSettingsPage = () => {
|
|
|
332
267
|
</Card>
|
|
333
268
|
) : (
|
|
334
269
|
<div className="space-y-3">
|
|
335
|
-
{userChannels.map((channel) => (
|
|
270
|
+
{(userChannels as UserDeliveryChannel[]).map((channel) => (
|
|
336
271
|
<UserChannelCard
|
|
337
272
|
key={channel.strategyId}
|
|
338
273
|
channel={channel}
|
|
@@ -340,21 +275,7 @@ export const NotificationSettingsPage = () => {
|
|
|
340
275
|
onConnect={handleChannelConnect}
|
|
341
276
|
onDisconnect={handleChannelDisconnect}
|
|
342
277
|
onSaveConfig={handleChannelConfigSave}
|
|
343
|
-
onTest={
|
|
344
|
-
setChannelTesting(strategyId);
|
|
345
|
-
try {
|
|
346
|
-
const result =
|
|
347
|
-
await notificationClient.sendTestNotification({
|
|
348
|
-
strategyId,
|
|
349
|
-
});
|
|
350
|
-
if (!result.success) {
|
|
351
|
-
alert(`Test failed: ${result.error}`);
|
|
352
|
-
}
|
|
353
|
-
return result;
|
|
354
|
-
} finally {
|
|
355
|
-
setChannelTesting(undefined);
|
|
356
|
-
}
|
|
357
|
-
}}
|
|
278
|
+
onTest={handleTest}
|
|
358
279
|
saving={channelSaving === channel.strategyId}
|
|
359
280
|
connecting={channelConnecting === channel.strategyId}
|
|
360
281
|
testing={channelTesting === channel.strategyId}
|
|
@@ -372,13 +293,13 @@ export const NotificationSettingsPage = () => {
|
|
|
372
293
|
icon={<Bell className="h-5 w-5" />}
|
|
373
294
|
/>
|
|
374
295
|
<Card className="p-4">
|
|
375
|
-
{subscriptions.length === 0 ? (
|
|
296
|
+
{(subscriptions as EnrichedSubscription[]).length === 0 ? (
|
|
376
297
|
<div className="text-center py-4 text-muted-foreground">
|
|
377
298
|
No active subscriptions
|
|
378
299
|
</div>
|
|
379
300
|
) : (
|
|
380
301
|
<div className="space-y-3">
|
|
381
|
-
{subscriptions.map((sub) => (
|
|
302
|
+
{(subscriptions as EnrichedSubscription[]).map((sub) => (
|
|
382
303
|
<div
|
|
383
304
|
key={sub.groupId}
|
|
384
305
|
className="flex items-center justify-between py-2 border-b last:border-0"
|
|
@@ -395,7 +316,8 @@ export const NotificationSettingsPage = () => {
|
|
|
395
316
|
<Button
|
|
396
317
|
variant="outline"
|
|
397
318
|
size="sm"
|
|
398
|
-
onClick={() =>
|
|
319
|
+
onClick={() => handleUnsubscribe(sub.groupId)}
|
|
320
|
+
disabled={unsubscribeMutation.isPending}
|
|
399
321
|
>
|
|
400
322
|
Unsubscribe
|
|
401
323
|
</Button>
|
|
@@ -435,7 +357,7 @@ export const NotificationSettingsPage = () => {
|
|
|
435
357
|
Loading delivery channels...
|
|
436
358
|
</div>
|
|
437
359
|
</Card>
|
|
438
|
-
) : strategies.length === 0 ? (
|
|
360
|
+
) : (strategies as DeliveryStrategy[]).length === 0 ? (
|
|
439
361
|
<Card className="p-4">
|
|
440
362
|
<div className="text-center py-4 text-muted-foreground">
|
|
441
363
|
No delivery channels registered. Plugins can register delivery
|
|
@@ -444,7 +366,7 @@ export const NotificationSettingsPage = () => {
|
|
|
444
366
|
</Card>
|
|
445
367
|
) : (
|
|
446
368
|
<div className="space-y-3">
|
|
447
|
-
{strategies.map((strategy) => (
|
|
369
|
+
{(strategies as DeliveryStrategy[]).map((strategy) => (
|
|
448
370
|
<StrategyCard
|
|
449
371
|
key={strategy.qualifiedId}
|
|
450
372
|
strategy={strategy}
|
|
@@ -473,18 +395,18 @@ export const NotificationSettingsPage = () => {
|
|
|
473
395
|
) : (
|
|
474
396
|
<div className="space-y-4">
|
|
475
397
|
<DynamicForm
|
|
476
|
-
schema={retentionSchema}
|
|
398
|
+
schema={retentionSchema as Record<string, unknown>}
|
|
477
399
|
value={retentionSettings}
|
|
478
400
|
onChange={setRetentionSettings}
|
|
479
401
|
onValidChange={setRetentionValid}
|
|
480
402
|
/>
|
|
481
403
|
<Button
|
|
482
|
-
onClick={
|
|
483
|
-
|
|
484
|
-
}}
|
|
485
|
-
disabled={retentionSaving || !retentionValid}
|
|
404
|
+
onClick={handleSaveRetention}
|
|
405
|
+
disabled={setRetentionMutation.isPending || !retentionValid}
|
|
486
406
|
>
|
|
487
|
-
{
|
|
407
|
+
{setRetentionMutation.isPending
|
|
408
|
+
? "Saving..."
|
|
409
|
+
: "Save Settings"}
|
|
488
410
|
</Button>
|
|
489
411
|
</div>
|
|
490
412
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState
|
|
1
|
+
import { useState } from "react";
|
|
2
2
|
import { Link } from "react-router-dom";
|
|
3
3
|
import { Bell, Check, Trash2, ChevronDown } from "lucide-react";
|
|
4
4
|
import {
|
|
@@ -13,88 +13,82 @@ import {
|
|
|
13
13
|
DropdownMenuTrigger,
|
|
14
14
|
Markdown,
|
|
15
15
|
} from "@checkstack/ui";
|
|
16
|
-
import {
|
|
16
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
17
17
|
import type { Notification } from "@checkstack/notification-common";
|
|
18
18
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
19
19
|
|
|
20
20
|
export const NotificationsPage = () => {
|
|
21
|
-
const
|
|
22
|
-
const notificationClient = rpcApi.forPlugin(NotificationApi);
|
|
21
|
+
const notificationClient = usePluginClient(NotificationApi);
|
|
23
22
|
const toast = useToast();
|
|
24
23
|
|
|
25
|
-
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
26
|
-
const [total, setTotal] = useState(0);
|
|
27
|
-
const [loading, setLoading] = useState(true);
|
|
28
24
|
const [filter, setFilter] = useState<"all" | "unread">("all");
|
|
29
25
|
const [page, setPage] = useState(0);
|
|
30
26
|
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
|
31
27
|
const pageSize = 20;
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
setTotal(totalCount);
|
|
44
|
-
} catch (error) {
|
|
45
|
-
const message =
|
|
46
|
-
error instanceof Error
|
|
47
|
-
? error.message
|
|
48
|
-
: "Failed to fetch notifications";
|
|
49
|
-
toast.error(message);
|
|
50
|
-
} finally {
|
|
51
|
-
setLoading(false);
|
|
52
|
-
}
|
|
53
|
-
}, [notificationClient, page, filter, toast]);
|
|
29
|
+
// Query: Fetch notifications
|
|
30
|
+
const {
|
|
31
|
+
data: notificationsData,
|
|
32
|
+
isLoading: loading,
|
|
33
|
+
refetch,
|
|
34
|
+
} = notificationClient.getNotifications.useQuery({
|
|
35
|
+
limit: pageSize,
|
|
36
|
+
offset: page * pageSize,
|
|
37
|
+
unreadOnly: filter === "unread",
|
|
38
|
+
});
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}, [fetchNotifications]);
|
|
40
|
+
const notifications = notificationsData?.notifications ?? [];
|
|
41
|
+
const total = notificationsData?.total ?? 0;
|
|
58
42
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
prev.map((n) => (n.id === notificationId ? { ...n, isRead: true } : n))
|
|
64
|
-
);
|
|
43
|
+
// Mutation: Mark as read
|
|
44
|
+
const markAsReadMutation = notificationClient.markAsRead.useMutation({
|
|
45
|
+
onSuccess: () => {
|
|
46
|
+
void refetch();
|
|
65
47
|
toast.success("Notification marked as read");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
48
|
+
},
|
|
49
|
+
onError: (error) => {
|
|
50
|
+
toast.error(
|
|
51
|
+
error instanceof Error ? error.message : "Failed to mark as read"
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
72
55
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
setTotal((prev) => prev - 1);
|
|
56
|
+
// Mutation: Delete notification
|
|
57
|
+
const deleteMutation = notificationClient.deleteNotification.useMutation({
|
|
58
|
+
onSuccess: () => {
|
|
59
|
+
void refetch();
|
|
78
60
|
toast.success("Notification deleted");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
};
|
|
61
|
+
},
|
|
62
|
+
onError: (error) => {
|
|
63
|
+
toast.error(
|
|
64
|
+
error instanceof Error ? error.message : "Failed to delete notification"
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
87
68
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
69
|
+
// Mutation: Mark all as read
|
|
70
|
+
const markAllAsReadMutation = notificationClient.markAsRead.useMutation({
|
|
71
|
+
onSuccess: () => {
|
|
72
|
+
void refetch();
|
|
92
73
|
toast.success("All notifications marked as read");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
74
|
+
},
|
|
75
|
+
onError: (error) => {
|
|
76
|
+
toast.error(
|
|
77
|
+
error instanceof Error ? error.message : "Failed to mark all as read"
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const handleMarkAsRead = (notificationId: string) => {
|
|
83
|
+
markAsReadMutation.mutate({ notificationId });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleDelete = (notificationId: string) => {
|
|
87
|
+
deleteMutation.mutate({ notificationId });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleMarkAllAsRead = () => {
|
|
91
|
+
markAllAsReadMutation.mutate({});
|
|
98
92
|
};
|
|
99
93
|
|
|
100
94
|
const getImportanceBadge = (importance: Notification["importance"]) => {
|
|
@@ -182,9 +176,8 @@ export const NotificationsPage = () => {
|
|
|
182
176
|
<Button
|
|
183
177
|
variant="outline"
|
|
184
178
|
size="sm"
|
|
185
|
-
onClick={
|
|
186
|
-
|
|
187
|
-
}}
|
|
179
|
+
onClick={handleMarkAllAsRead}
|
|
180
|
+
disabled={markAllAsReadMutation.isPending}
|
|
188
181
|
>
|
|
189
182
|
<Check className="h-4 w-4 mr-1" /> Mark all read
|
|
190
183
|
</Button>
|
|
@@ -241,9 +234,8 @@ export const NotificationsPage = () => {
|
|
|
241
234
|
<Button
|
|
242
235
|
variant="ghost"
|
|
243
236
|
size="icon"
|
|
244
|
-
onClick={() =>
|
|
245
|
-
|
|
246
|
-
}}
|
|
237
|
+
onClick={() => handleMarkAsRead(notification.id)}
|
|
238
|
+
disabled={markAsReadMutation.isPending}
|
|
247
239
|
title="Mark as read"
|
|
248
240
|
>
|
|
249
241
|
<Check className="h-4 w-4" />
|
|
@@ -252,9 +244,8 @@ export const NotificationsPage = () => {
|
|
|
252
244
|
<Button
|
|
253
245
|
variant="ghost"
|
|
254
246
|
size="icon"
|
|
255
|
-
onClick={() =>
|
|
256
|
-
|
|
257
|
-
}}
|
|
247
|
+
onClick={() => handleDelete(notification.id)}
|
|
248
|
+
disabled={deleteMutation.isPending}
|
|
258
249
|
title="Delete"
|
|
259
250
|
>
|
|
260
251
|
<Trash2 className="h-4 w-4 text-destructive" />
|