datajunction-ui 0.0.18 → 0.0.19
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/package.json +1 -1
- package/src/app/components/NotificationBell.tsx +223 -0
- package/src/app/components/UserMenu.tsx +100 -0
- package/src/app/components/__tests__/NotificationBell.test.tsx +302 -0
- package/src/app/components/__tests__/UserMenu.test.tsx +241 -0
- package/src/app/icons/NotificationIcon.jsx +27 -0
- package/src/app/icons/SettingsIcon.jsx +28 -0
- package/src/app/index.tsx +12 -0
- package/src/app/pages/NotificationsPage/Loadable.jsx +6 -0
- package/src/app/pages/NotificationsPage/__tests__/index.test.jsx +287 -0
- package/src/app/pages/NotificationsPage/index.jsx +136 -0
- package/src/app/pages/Root/__tests__/index.test.jsx +18 -53
- package/src/app/pages/Root/index.tsx +23 -19
- package/src/app/pages/SettingsPage/CreateServiceAccountModal.jsx +152 -0
- package/src/app/pages/SettingsPage/Loadable.jsx +16 -0
- package/src/app/pages/SettingsPage/NotificationSubscriptionsSection.jsx +189 -0
- package/src/app/pages/SettingsPage/ProfileSection.jsx +41 -0
- package/src/app/pages/SettingsPage/ServiceAccountsSection.jsx +95 -0
- package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +318 -0
- package/src/app/pages/SettingsPage/__tests__/NotificationSubscriptionsSection.test.jsx +233 -0
- package/src/app/pages/SettingsPage/__tests__/ProfileSection.test.jsx +65 -0
- package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +150 -0
- package/src/app/pages/SettingsPage/__tests__/index.test.jsx +184 -0
- package/src/app/pages/SettingsPage/index.jsx +148 -0
- package/src/app/services/DJService.js +81 -0
- package/src/app/utils/__tests__/date.test.js +198 -0
- package/src/app/utils/date.js +65 -0
- package/src/styles/index.css +1 -1
- package/src/styles/nav-bar.css +274 -0
- package/src/styles/settings.css +787 -0
package/package.json
CHANGED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useContext, useEffect, useState } from 'react';
|
|
2
|
+
import DJClientContext from '../providers/djclient';
|
|
3
|
+
import NotificationIcon from '../icons/NotificationIcon';
|
|
4
|
+
import SettingsIcon from '../icons/SettingsIcon';
|
|
5
|
+
import LoadingIcon from '../icons/LoadingIcon';
|
|
6
|
+
import { formatRelativeTime } from '../utils/date';
|
|
7
|
+
|
|
8
|
+
interface HistoryEntry {
|
|
9
|
+
id: number;
|
|
10
|
+
entity_type: string;
|
|
11
|
+
entity_name: string;
|
|
12
|
+
node: string;
|
|
13
|
+
activity_type: string;
|
|
14
|
+
user: string;
|
|
15
|
+
created_at: string;
|
|
16
|
+
details?: {
|
|
17
|
+
version?: string;
|
|
18
|
+
[key: string]: any;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface EnrichedHistoryEntry extends HistoryEntry {
|
|
23
|
+
node_type?: string;
|
|
24
|
+
display_name?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface NodeInfo {
|
|
28
|
+
name: string;
|
|
29
|
+
type: string;
|
|
30
|
+
current?: {
|
|
31
|
+
displayName?: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Calculate unread count based on last_viewed_notifications_at
|
|
36
|
+
const calculateUnreadCount = (
|
|
37
|
+
notifs: HistoryEntry[],
|
|
38
|
+
lastViewed: string | null | undefined,
|
|
39
|
+
): number => {
|
|
40
|
+
if (!lastViewed) return notifs.length;
|
|
41
|
+
const lastViewedDate = new Date(lastViewed);
|
|
42
|
+
return notifs.filter(n => new Date(n.created_at) > lastViewedDate).length;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Enrich history entries with node info from GraphQL
|
|
46
|
+
const enrichWithNodeInfo = (
|
|
47
|
+
entries: HistoryEntry[],
|
|
48
|
+
nodes: NodeInfo[],
|
|
49
|
+
): EnrichedHistoryEntry[] => {
|
|
50
|
+
const nodeMap = new Map(nodes.map(n => [n.name, n]));
|
|
51
|
+
return entries.map(entry => {
|
|
52
|
+
const node = nodeMap.get(entry.entity_name);
|
|
53
|
+
return {
|
|
54
|
+
...entry,
|
|
55
|
+
node_type: node?.type,
|
|
56
|
+
display_name: node?.current?.displayName,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
interface NotificationBellProps {
|
|
62
|
+
onDropdownToggle?: (isOpen: boolean) => void;
|
|
63
|
+
forceClose?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export default function NotificationBell({
|
|
67
|
+
onDropdownToggle,
|
|
68
|
+
forceClose,
|
|
69
|
+
}: NotificationBellProps) {
|
|
70
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
71
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
72
|
+
|
|
73
|
+
// Close when forceClose becomes true
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (forceClose && showDropdown) {
|
|
76
|
+
setShowDropdown(false);
|
|
77
|
+
}
|
|
78
|
+
}, [forceClose, showDropdown]);
|
|
79
|
+
const [notifications, setNotifications] = useState<EnrichedHistoryEntry[]>(
|
|
80
|
+
[],
|
|
81
|
+
);
|
|
82
|
+
const [loading, setLoading] = useState(false);
|
|
83
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
84
|
+
|
|
85
|
+
// Fetch notifications on mount
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
async function fetchNotifications() {
|
|
88
|
+
setLoading(true);
|
|
89
|
+
try {
|
|
90
|
+
const current = await djClient.whoami();
|
|
91
|
+
const history: HistoryEntry[] =
|
|
92
|
+
(await djClient.getSubscribedHistory(10)) || [];
|
|
93
|
+
|
|
94
|
+
// Get unique entity names and fetch their info via GraphQL
|
|
95
|
+
// (some may not be nodes, but GraphQL will just not return them)
|
|
96
|
+
const nodeNames = Array.from(new Set(history.map(h => h.entity_name)));
|
|
97
|
+
const nodes: NodeInfo[] = nodeNames.length
|
|
98
|
+
? await djClient.getNodesByNames(nodeNames)
|
|
99
|
+
: [];
|
|
100
|
+
|
|
101
|
+
const enriched = enrichWithNodeInfo(history, nodes);
|
|
102
|
+
setNotifications(enriched);
|
|
103
|
+
setUnreadCount(
|
|
104
|
+
calculateUnreadCount(history, current?.last_viewed_notifications_at),
|
|
105
|
+
);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Error fetching notifications:', error);
|
|
108
|
+
} finally {
|
|
109
|
+
setLoading(false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
fetchNotifications();
|
|
113
|
+
}, [djClient]);
|
|
114
|
+
|
|
115
|
+
// Close dropdown when clicking outside
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
118
|
+
const target = event.target as HTMLElement;
|
|
119
|
+
if (!target.closest('.notification-bell-dropdown')) {
|
|
120
|
+
setShowDropdown(false);
|
|
121
|
+
onDropdownToggle?.(false);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
document.addEventListener('click', handleClickOutside);
|
|
125
|
+
return () => document.removeEventListener('click', handleClickOutside);
|
|
126
|
+
}, [onDropdownToggle]);
|
|
127
|
+
|
|
128
|
+
const handleToggle = (e: React.MouseEvent) => {
|
|
129
|
+
e.stopPropagation();
|
|
130
|
+
const willOpen = !showDropdown;
|
|
131
|
+
|
|
132
|
+
// Mark as read when opening
|
|
133
|
+
if (willOpen && unreadCount > 0) {
|
|
134
|
+
djClient.markNotificationsRead();
|
|
135
|
+
setUnreadCount(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setShowDropdown(willOpen);
|
|
139
|
+
onDropdownToggle?.(willOpen);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="nav-dropdown notification-bell-dropdown">
|
|
144
|
+
<button className="nav-icon-button" onClick={handleToggle}>
|
|
145
|
+
<NotificationIcon />
|
|
146
|
+
{unreadCount > 0 && (
|
|
147
|
+
<span className="notification-badge">{unreadCount}</span>
|
|
148
|
+
)}
|
|
149
|
+
</button>
|
|
150
|
+
{showDropdown && (
|
|
151
|
+
<div className="nav-dropdown-menu notifications-menu">
|
|
152
|
+
<div className="dropdown-header">
|
|
153
|
+
<span className="header-left">
|
|
154
|
+
<NotificationIcon /> Updates
|
|
155
|
+
</span>
|
|
156
|
+
<a
|
|
157
|
+
href="/settings#notifications"
|
|
158
|
+
className="header-settings"
|
|
159
|
+
title="Manage subscriptions"
|
|
160
|
+
>
|
|
161
|
+
<SettingsIcon />
|
|
162
|
+
</a>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="notifications-list">
|
|
165
|
+
{loading ? (
|
|
166
|
+
<div className="dropdown-item">
|
|
167
|
+
<LoadingIcon centered={false} />
|
|
168
|
+
</div>
|
|
169
|
+
) : notifications.length === 0 ? (
|
|
170
|
+
<div className="dropdown-item text-muted">
|
|
171
|
+
No updates on watched nodes
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
notifications.slice(0, 5).map(entry => {
|
|
175
|
+
const version = entry.details?.version;
|
|
176
|
+
const href = version
|
|
177
|
+
? `/nodes/${entry.entity_name}/revisions/${version}`
|
|
178
|
+
: `/nodes/${entry.entity_name}/history`;
|
|
179
|
+
return (
|
|
180
|
+
<a key={entry.id} className="notification-item" href={href}>
|
|
181
|
+
<span className="notification-node">
|
|
182
|
+
<span className="notification-title">
|
|
183
|
+
{entry.display_name || entry.entity_name}
|
|
184
|
+
{version && (
|
|
185
|
+
<span className="badge version">{version}</span>
|
|
186
|
+
)}
|
|
187
|
+
</span>
|
|
188
|
+
{entry.display_name && (
|
|
189
|
+
<span className="notification-entity">
|
|
190
|
+
{entry.entity_name}
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
</span>
|
|
194
|
+
<span className="notification-meta">
|
|
195
|
+
{entry.node_type && (
|
|
196
|
+
<span
|
|
197
|
+
className={`node_type__${entry.node_type} badge node_type`}
|
|
198
|
+
>
|
|
199
|
+
{entry.node_type.toUpperCase()}
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
{entry.activity_type}d by{' '}
|
|
203
|
+
<span style={{ color: '#333' }}>{entry.user}</span> ·{' '}
|
|
204
|
+
{formatRelativeTime(entry.created_at)}
|
|
205
|
+
</span>
|
|
206
|
+
</a>
|
|
207
|
+
);
|
|
208
|
+
})
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
{notifications.length > 0 && (
|
|
212
|
+
<>
|
|
213
|
+
<hr className="dropdown-divider" />
|
|
214
|
+
<a className="dropdown-item view-all" href="/notifications">
|
|
215
|
+
View all
|
|
216
|
+
</a>
|
|
217
|
+
</>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useContext, useEffect, useState } from 'react';
|
|
2
|
+
import DJClientContext from '../providers/djclient';
|
|
3
|
+
|
|
4
|
+
interface User {
|
|
5
|
+
id: number;
|
|
6
|
+
username: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Extract initials from user's name or username
|
|
12
|
+
const getInitials = (user: User | null): string => {
|
|
13
|
+
if (!user) return '?';
|
|
14
|
+
if (user.name) {
|
|
15
|
+
return user.name
|
|
16
|
+
.split(' ')
|
|
17
|
+
.map(n => n[0])
|
|
18
|
+
.join('')
|
|
19
|
+
.toUpperCase()
|
|
20
|
+
.slice(0, 2);
|
|
21
|
+
}
|
|
22
|
+
return user.username.slice(0, 2).toUpperCase();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface UserMenuProps {
|
|
26
|
+
onDropdownToggle?: (isOpen: boolean) => void;
|
|
27
|
+
forceClose?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function UserMenu({
|
|
31
|
+
onDropdownToggle,
|
|
32
|
+
forceClose,
|
|
33
|
+
}: UserMenuProps) {
|
|
34
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
35
|
+
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
36
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
37
|
+
|
|
38
|
+
// Close when forceClose becomes true
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (forceClose && showDropdown) {
|
|
41
|
+
setShowDropdown(false);
|
|
42
|
+
}
|
|
43
|
+
}, [forceClose, showDropdown]);
|
|
44
|
+
|
|
45
|
+
// Fetch current user on mount
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
async function fetchUser() {
|
|
48
|
+
const user = await djClient.whoami();
|
|
49
|
+
setCurrentUser(user);
|
|
50
|
+
}
|
|
51
|
+
fetchUser();
|
|
52
|
+
}, [djClient]);
|
|
53
|
+
|
|
54
|
+
// Close dropdown when clicking outside
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
57
|
+
const target = event.target as HTMLElement;
|
|
58
|
+
if (!target.closest('.user-menu-dropdown')) {
|
|
59
|
+
setShowDropdown(false);
|
|
60
|
+
onDropdownToggle?.(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
document.addEventListener('click', handleClickOutside);
|
|
64
|
+
return () => document.removeEventListener('click', handleClickOutside);
|
|
65
|
+
}, [onDropdownToggle]);
|
|
66
|
+
|
|
67
|
+
const handleToggle = (e: React.MouseEvent) => {
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
const willOpen = !showDropdown;
|
|
70
|
+
setShowDropdown(willOpen);
|
|
71
|
+
onDropdownToggle?.(willOpen);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleLogout = async () => {
|
|
75
|
+
await djClient.logout();
|
|
76
|
+
window.location.reload();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="nav-dropdown user-menu-dropdown">
|
|
81
|
+
<button className="avatar-button" onClick={handleToggle}>
|
|
82
|
+
{getInitials(currentUser)}
|
|
83
|
+
</button>
|
|
84
|
+
{showDropdown && (
|
|
85
|
+
<div className="nav-dropdown-menu">
|
|
86
|
+
<div className="dropdown-header">
|
|
87
|
+
{currentUser?.username || 'User'}
|
|
88
|
+
</div>
|
|
89
|
+
<hr className="dropdown-divider" />
|
|
90
|
+
<a className="dropdown-item" href="/settings">
|
|
91
|
+
Settings
|
|
92
|
+
</a>
|
|
93
|
+
<a className="dropdown-item" href="/" onClick={handleLogout}>
|
|
94
|
+
Logout
|
|
95
|
+
</a>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
3
|
+
import NotificationBell from '../NotificationBell';
|
|
4
|
+
import DJClientContext from '../../providers/djclient';
|
|
5
|
+
|
|
6
|
+
describe('<NotificationBell />', () => {
|
|
7
|
+
const mockNotifications = [
|
|
8
|
+
{
|
|
9
|
+
id: 1,
|
|
10
|
+
entity_type: 'node',
|
|
11
|
+
entity_name: 'default.metrics.revenue',
|
|
12
|
+
node: 'default.metrics.revenue',
|
|
13
|
+
activity_type: 'update',
|
|
14
|
+
user: 'alice',
|
|
15
|
+
created_at: new Date().toISOString(),
|
|
16
|
+
details: { version: 'v2' },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 2,
|
|
20
|
+
entity_type: 'node',
|
|
21
|
+
entity_name: 'default.dimensions.country',
|
|
22
|
+
node: 'default.dimensions.country',
|
|
23
|
+
activity_type: 'create',
|
|
24
|
+
user: 'bob',
|
|
25
|
+
created_at: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
|
|
26
|
+
details: { version: 'v1' },
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const mockNodes = [
|
|
31
|
+
{
|
|
32
|
+
name: 'default.metrics.revenue',
|
|
33
|
+
type: 'metric',
|
|
34
|
+
current: { displayName: 'Revenue Metric' },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'default.dimensions.country',
|
|
38
|
+
type: 'dimension',
|
|
39
|
+
current: { displayName: 'Country' },
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const createMockDjClient = (overrides = {}) => ({
|
|
44
|
+
whoami: jest.fn().mockResolvedValue({
|
|
45
|
+
id: 1,
|
|
46
|
+
username: 'testuser',
|
|
47
|
+
last_viewed_notifications_at: null,
|
|
48
|
+
}),
|
|
49
|
+
getSubscribedHistory: jest.fn().mockResolvedValue(mockNotifications),
|
|
50
|
+
getNodesByNames: jest.fn().mockResolvedValue(mockNodes),
|
|
51
|
+
markNotificationsRead: jest.fn().mockResolvedValue({}),
|
|
52
|
+
...overrides,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const renderWithContext = (mockDjClient: any) => {
|
|
56
|
+
return render(
|
|
57
|
+
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
58
|
+
<NotificationBell />
|
|
59
|
+
</DJClientContext.Provider>,
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renders the notification bell button', async () => {
|
|
68
|
+
const mockDjClient = createMockDjClient();
|
|
69
|
+
renderWithContext(mockDjClient);
|
|
70
|
+
|
|
71
|
+
const button = screen.getByRole('button');
|
|
72
|
+
expect(button).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('shows unread badge when there are unread notifications', async () => {
|
|
76
|
+
const mockDjClient = createMockDjClient();
|
|
77
|
+
renderWithContext(mockDjClient);
|
|
78
|
+
|
|
79
|
+
// Wait for notifications to load
|
|
80
|
+
await waitFor(() => {
|
|
81
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Badge should show count of 2 (all notifications are unread since last_viewed is null)
|
|
85
|
+
const badge = await screen.findByText('2');
|
|
86
|
+
expect(badge).toHaveClass('notification-badge');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not show badge when all notifications have been viewed', async () => {
|
|
90
|
+
const mockDjClient = createMockDjClient({
|
|
91
|
+
whoami: jest.fn().mockResolvedValue({
|
|
92
|
+
id: 1,
|
|
93
|
+
username: 'testuser',
|
|
94
|
+
// Set last_viewed to future date so all notifications are "read"
|
|
95
|
+
last_viewed_notifications_at: new Date(
|
|
96
|
+
Date.now() + 10000,
|
|
97
|
+
).toISOString(),
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
renderWithContext(mockDjClient);
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Badge should not be present (no unread count shown)
|
|
107
|
+
const badge = document.querySelector('.notification-badge');
|
|
108
|
+
expect(badge).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('opens dropdown when bell is clicked', async () => {
|
|
112
|
+
const mockDjClient = createMockDjClient();
|
|
113
|
+
renderWithContext(mockDjClient);
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const button = screen.getByRole('button');
|
|
120
|
+
fireEvent.click(button);
|
|
121
|
+
|
|
122
|
+
expect(screen.getByText('Updates')).toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('displays notifications in the dropdown', async () => {
|
|
126
|
+
const mockDjClient = createMockDjClient();
|
|
127
|
+
renderWithContext(mockDjClient);
|
|
128
|
+
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const button = screen.getByRole('button');
|
|
134
|
+
fireEvent.click(button);
|
|
135
|
+
|
|
136
|
+
// Check that display names are shown
|
|
137
|
+
expect(screen.getByText('Revenue Metric')).toBeInTheDocument();
|
|
138
|
+
expect(screen.getByText('Country')).toBeInTheDocument();
|
|
139
|
+
|
|
140
|
+
// Check that entity names are shown below
|
|
141
|
+
expect(screen.getByText('default.metrics.revenue')).toBeInTheDocument();
|
|
142
|
+
expect(screen.getByText('default.dimensions.country')).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('shows empty state when no notifications', async () => {
|
|
146
|
+
const mockDjClient = createMockDjClient({
|
|
147
|
+
getSubscribedHistory: jest.fn().mockResolvedValue([]),
|
|
148
|
+
});
|
|
149
|
+
renderWithContext(mockDjClient);
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const button = screen.getByRole('button');
|
|
156
|
+
fireEvent.click(button);
|
|
157
|
+
|
|
158
|
+
expect(screen.getByText('No updates on watched nodes')).toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('marks notifications as read when dropdown is opened', async () => {
|
|
162
|
+
const mockDjClient = createMockDjClient();
|
|
163
|
+
renderWithContext(mockDjClient);
|
|
164
|
+
|
|
165
|
+
await waitFor(() => {
|
|
166
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const button = screen.getByRole('button');
|
|
170
|
+
fireEvent.click(button);
|
|
171
|
+
|
|
172
|
+
expect(mockDjClient.markNotificationsRead).toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('does not mark as read if already all read', async () => {
|
|
176
|
+
const mockDjClient = createMockDjClient({
|
|
177
|
+
whoami: jest.fn().mockResolvedValue({
|
|
178
|
+
id: 1,
|
|
179
|
+
username: 'testuser',
|
|
180
|
+
last_viewed_notifications_at: new Date(
|
|
181
|
+
Date.now() + 10000,
|
|
182
|
+
).toISOString(),
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
renderWithContext(mockDjClient);
|
|
186
|
+
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const button = screen.getByRole('button');
|
|
192
|
+
fireEvent.click(button);
|
|
193
|
+
|
|
194
|
+
// Should not call markNotificationsRead since unreadCount is 0
|
|
195
|
+
expect(mockDjClient.markNotificationsRead).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('shows View all link when there are notifications', async () => {
|
|
199
|
+
const mockDjClient = createMockDjClient();
|
|
200
|
+
renderWithContext(mockDjClient);
|
|
201
|
+
|
|
202
|
+
await waitFor(() => {
|
|
203
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const button = screen.getByRole('button');
|
|
207
|
+
fireEvent.click(button);
|
|
208
|
+
|
|
209
|
+
const viewAllLink = screen.getByText('View all');
|
|
210
|
+
expect(viewAllLink).toHaveAttribute('href', '/notifications');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('calls onDropdownToggle when dropdown state changes', async () => {
|
|
214
|
+
const mockDjClient = createMockDjClient();
|
|
215
|
+
const onDropdownToggle = jest.fn();
|
|
216
|
+
|
|
217
|
+
render(
|
|
218
|
+
<DJClientContext.Provider
|
|
219
|
+
value={{ DataJunctionAPI: mockDjClient as any }}
|
|
220
|
+
>
|
|
221
|
+
<NotificationBell onDropdownToggle={onDropdownToggle} />
|
|
222
|
+
</DJClientContext.Provider>,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
await waitFor(() => {
|
|
226
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const button = screen.getByRole('button');
|
|
230
|
+
fireEvent.click(button);
|
|
231
|
+
|
|
232
|
+
expect(onDropdownToggle).toHaveBeenCalledWith(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('closes dropdown when forceClose becomes true', async () => {
|
|
236
|
+
const mockDjClient = createMockDjClient();
|
|
237
|
+
|
|
238
|
+
const { rerender } = render(
|
|
239
|
+
<DJClientContext.Provider
|
|
240
|
+
value={{ DataJunctionAPI: mockDjClient as any }}
|
|
241
|
+
>
|
|
242
|
+
<NotificationBell forceClose={false} />
|
|
243
|
+
</DJClientContext.Provider>,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Open the dropdown
|
|
251
|
+
const button = screen.getByRole('button');
|
|
252
|
+
fireEvent.click(button);
|
|
253
|
+
|
|
254
|
+
// Verify dropdown is open
|
|
255
|
+
expect(screen.getByText('Updates')).toBeInTheDocument();
|
|
256
|
+
|
|
257
|
+
// Rerender with forceClose=true
|
|
258
|
+
rerender(
|
|
259
|
+
<DJClientContext.Provider
|
|
260
|
+
value={{ DataJunctionAPI: mockDjClient as any }}
|
|
261
|
+
>
|
|
262
|
+
<NotificationBell forceClose={true} />
|
|
263
|
+
</DJClientContext.Provider>,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Dropdown should be closed
|
|
267
|
+
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('closes dropdown when clicking outside', async () => {
|
|
271
|
+
const mockDjClient = createMockDjClient();
|
|
272
|
+
const onDropdownToggle = jest.fn();
|
|
273
|
+
|
|
274
|
+
render(
|
|
275
|
+
<DJClientContext.Provider
|
|
276
|
+
value={{ DataJunctionAPI: mockDjClient as any }}
|
|
277
|
+
>
|
|
278
|
+
<NotificationBell onDropdownToggle={onDropdownToggle} />
|
|
279
|
+
</DJClientContext.Provider>,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
await waitFor(() => {
|
|
283
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Open the dropdown
|
|
287
|
+
const button = screen.getByRole('button');
|
|
288
|
+
fireEvent.click(button);
|
|
289
|
+
|
|
290
|
+
// Verify dropdown is open
|
|
291
|
+
expect(screen.getByText('Updates')).toBeInTheDocument();
|
|
292
|
+
|
|
293
|
+
// Click outside the dropdown
|
|
294
|
+
fireEvent.click(document.body);
|
|
295
|
+
|
|
296
|
+
// Dropdown should be closed
|
|
297
|
+
expect(screen.queryByText('Updates')).not.toBeInTheDocument();
|
|
298
|
+
|
|
299
|
+
// onDropdownToggle should have been called with false
|
|
300
|
+
expect(onDropdownToggle).toHaveBeenCalledWith(false);
|
|
301
|
+
});
|
|
302
|
+
});
|