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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/app/components/NotificationBell.tsx +223 -0
  3. package/src/app/components/UserMenu.tsx +100 -0
  4. package/src/app/components/__tests__/NotificationBell.test.tsx +302 -0
  5. package/src/app/components/__tests__/UserMenu.test.tsx +241 -0
  6. package/src/app/icons/NotificationIcon.jsx +27 -0
  7. package/src/app/icons/SettingsIcon.jsx +28 -0
  8. package/src/app/index.tsx +12 -0
  9. package/src/app/pages/NotificationsPage/Loadable.jsx +6 -0
  10. package/src/app/pages/NotificationsPage/__tests__/index.test.jsx +287 -0
  11. package/src/app/pages/NotificationsPage/index.jsx +136 -0
  12. package/src/app/pages/Root/__tests__/index.test.jsx +18 -53
  13. package/src/app/pages/Root/index.tsx +23 -19
  14. package/src/app/pages/SettingsPage/CreateServiceAccountModal.jsx +152 -0
  15. package/src/app/pages/SettingsPage/Loadable.jsx +16 -0
  16. package/src/app/pages/SettingsPage/NotificationSubscriptionsSection.jsx +189 -0
  17. package/src/app/pages/SettingsPage/ProfileSection.jsx +41 -0
  18. package/src/app/pages/SettingsPage/ServiceAccountsSection.jsx +95 -0
  19. package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +318 -0
  20. package/src/app/pages/SettingsPage/__tests__/NotificationSubscriptionsSection.test.jsx +233 -0
  21. package/src/app/pages/SettingsPage/__tests__/ProfileSection.test.jsx +65 -0
  22. package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +150 -0
  23. package/src/app/pages/SettingsPage/__tests__/index.test.jsx +184 -0
  24. package/src/app/pages/SettingsPage/index.jsx +148 -0
  25. package/src/app/services/DJService.js +81 -0
  26. package/src/app/utils/__tests__/date.test.js +198 -0
  27. package/src/app/utils/date.js +65 -0
  28. package/src/styles/index.css +1 -1
  29. package/src/styles/nav-bar.css +274 -0
  30. package/src/styles/settings.css +787 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -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
+ });