datajunction-ui 0.0.17 → 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 (34) 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/NamespacePage/NodeModeSelect.jsx +27 -0
  10. package/src/app/pages/NamespacePage/NodeTypeSelect.jsx +1 -1
  11. package/src/app/pages/NamespacePage/__tests__/index.test.jsx +1 -0
  12. package/src/app/pages/NamespacePage/index.jsx +33 -2
  13. package/src/app/pages/NotificationsPage/Loadable.jsx +6 -0
  14. package/src/app/pages/NotificationsPage/__tests__/index.test.jsx +287 -0
  15. package/src/app/pages/NotificationsPage/index.jsx +136 -0
  16. package/src/app/pages/Root/__tests__/index.test.jsx +18 -53
  17. package/src/app/pages/Root/index.tsx +23 -19
  18. package/src/app/pages/SettingsPage/CreateServiceAccountModal.jsx +152 -0
  19. package/src/app/pages/SettingsPage/Loadable.jsx +16 -0
  20. package/src/app/pages/SettingsPage/NotificationSubscriptionsSection.jsx +189 -0
  21. package/src/app/pages/SettingsPage/ProfileSection.jsx +41 -0
  22. package/src/app/pages/SettingsPage/ServiceAccountsSection.jsx +95 -0
  23. package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +318 -0
  24. package/src/app/pages/SettingsPage/__tests__/NotificationSubscriptionsSection.test.jsx +233 -0
  25. package/src/app/pages/SettingsPage/__tests__/ProfileSection.test.jsx +65 -0
  26. package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +150 -0
  27. package/src/app/pages/SettingsPage/__tests__/index.test.jsx +184 -0
  28. package/src/app/pages/SettingsPage/index.jsx +148 -0
  29. package/src/app/services/DJService.js +86 -1
  30. package/src/app/utils/__tests__/date.test.js +198 -0
  31. package/src/app/utils/date.js +65 -0
  32. package/src/styles/index.css +1 -1
  33. package/src/styles/nav-bar.css +274 -0
  34. package/src/styles/settings.css +787 -0
@@ -0,0 +1,287 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { NotificationsPage } from '../index';
4
+ import DJClientContext from '../../../providers/djclient';
5
+
6
+ describe('<NotificationsPage />', () => {
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().toISOString(),
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
+ getSubscribedHistory: jest.fn().mockResolvedValue(mockNotifications),
45
+ getNodesByNames: jest.fn().mockResolvedValue(mockNodes),
46
+ ...overrides,
47
+ });
48
+
49
+ const renderWithContext = mockDjClient => {
50
+ return render(
51
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
52
+ <NotificationsPage />
53
+ </DJClientContext.Provider>,
54
+ );
55
+ };
56
+
57
+ beforeEach(() => {
58
+ jest.clearAllMocks();
59
+ });
60
+
61
+ it('renders the page title', async () => {
62
+ const mockDjClient = createMockDjClient();
63
+ renderWithContext(mockDjClient);
64
+
65
+ expect(screen.getByText('Notifications')).toBeInTheDocument();
66
+ });
67
+
68
+ it('shows loading state initially', () => {
69
+ const mockDjClient = createMockDjClient({
70
+ getSubscribedHistory: jest.fn().mockImplementation(
71
+ () => new Promise(() => {}), // Never resolves
72
+ ),
73
+ });
74
+ renderWithContext(mockDjClient);
75
+
76
+ // LoadingIcon should be present (check for the container)
77
+ const loadingContainer = document.querySelector(
78
+ '[style*="text-align: center"]',
79
+ );
80
+ expect(loadingContainer).toBeInTheDocument();
81
+ });
82
+
83
+ it('shows empty state when no notifications', async () => {
84
+ const mockDjClient = createMockDjClient({
85
+ getSubscribedHistory: jest.fn().mockResolvedValue([]),
86
+ });
87
+ renderWithContext(mockDjClient);
88
+
89
+ await waitFor(() => {
90
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
91
+ });
92
+
93
+ expect(screen.getByText(/No notifications yet/i)).toBeInTheDocument();
94
+ expect(
95
+ screen.getByText(/Watch nodes to receive updates/i),
96
+ ).toBeInTheDocument();
97
+ });
98
+
99
+ it('displays notifications with display names', async () => {
100
+ const mockDjClient = createMockDjClient();
101
+ renderWithContext(mockDjClient);
102
+
103
+ await waitFor(() => {
104
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
105
+ });
106
+
107
+ // Display names should be shown
108
+ expect(await screen.findByText('Revenue Metric')).toBeInTheDocument();
109
+ expect(await screen.findByText('Country')).toBeInTheDocument();
110
+ });
111
+
112
+ it('displays entity names below display names', async () => {
113
+ const mockDjClient = createMockDjClient();
114
+ renderWithContext(mockDjClient);
115
+
116
+ await waitFor(() => {
117
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
118
+ });
119
+
120
+ // Entity names should be shown
121
+ expect(
122
+ await screen.findByText('default.metrics.revenue'),
123
+ ).toBeInTheDocument();
124
+ expect(
125
+ await screen.findByText('default.dimensions.country'),
126
+ ).toBeInTheDocument();
127
+ });
128
+
129
+ it('falls back to entity_name when no display_name', async () => {
130
+ const mockDjClient = createMockDjClient({
131
+ getNodesByNames: jest.fn().mockResolvedValue([]), // No node info
132
+ });
133
+ renderWithContext(mockDjClient);
134
+
135
+ await waitFor(() => {
136
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
137
+ });
138
+
139
+ // Entity names should be shown as the title (no display names)
140
+ const revenueElements = await screen.findAllByText(
141
+ 'default.metrics.revenue',
142
+ );
143
+ expect(revenueElements.length).toBeGreaterThan(0);
144
+ });
145
+
146
+ it('shows version badge when version is available', async () => {
147
+ const mockDjClient = createMockDjClient();
148
+ renderWithContext(mockDjClient);
149
+
150
+ await waitFor(() => {
151
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
152
+ });
153
+
154
+ expect(await screen.findByText('v2')).toBeInTheDocument();
155
+ expect(await screen.findByText('v1')).toBeInTheDocument();
156
+ });
157
+
158
+ it('links to revision page when version is available', async () => {
159
+ const mockDjClient = createMockDjClient();
160
+ renderWithContext(mockDjClient);
161
+
162
+ await waitFor(() => {
163
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
164
+ });
165
+
166
+ await waitFor(() => {
167
+ const links = document.querySelectorAll('a.notification-item');
168
+ expect(links.length).toBe(2);
169
+
170
+ const revenueLink = Array.from(links).find(l =>
171
+ l.textContent.includes('Revenue Metric'),
172
+ );
173
+ expect(revenueLink).toHaveAttribute(
174
+ 'href',
175
+ '/nodes/default.metrics.revenue/revisions/v2',
176
+ );
177
+ });
178
+ });
179
+
180
+ it('links to history page when no version', async () => {
181
+ const mockDjClient = createMockDjClient({
182
+ getSubscribedHistory: jest.fn().mockResolvedValue([
183
+ {
184
+ id: 1,
185
+ entity_type: 'node',
186
+ entity_name: 'default.source.orders',
187
+ node: 'default.source.orders',
188
+ activity_type: 'update',
189
+ user: 'alice',
190
+ created_at: new Date().toISOString(),
191
+ details: {}, // No version
192
+ },
193
+ ]),
194
+ getNodesByNames: jest.fn().mockResolvedValue([]),
195
+ });
196
+ renderWithContext(mockDjClient);
197
+
198
+ await waitFor(() => {
199
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
200
+ });
201
+
202
+ await waitFor(() => {
203
+ const link = document.querySelector('a.notification-item');
204
+ expect(link).toHaveAttribute(
205
+ 'href',
206
+ '/nodes/default.source.orders/history',
207
+ );
208
+ });
209
+ });
210
+
211
+ it('shows node type badge', async () => {
212
+ const mockDjClient = createMockDjClient();
213
+ renderWithContext(mockDjClient);
214
+
215
+ await waitFor(() => {
216
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
217
+ });
218
+
219
+ expect(await screen.findByText('METRIC')).toBeInTheDocument();
220
+ expect(await screen.findByText('DIMENSION')).toBeInTheDocument();
221
+ });
222
+
223
+ it('shows activity type and user', async () => {
224
+ const mockDjClient = createMockDjClient();
225
+ renderWithContext(mockDjClient);
226
+
227
+ await waitFor(() => {
228
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
229
+ });
230
+
231
+ expect(await screen.findByText('alice')).toBeInTheDocument();
232
+ expect(await screen.findByText('bob')).toBeInTheDocument();
233
+ });
234
+
235
+ it('groups notifications by date', async () => {
236
+ const mockDjClient = createMockDjClient();
237
+ renderWithContext(mockDjClient);
238
+
239
+ await waitFor(() => {
240
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
241
+ });
242
+
243
+ // Both notifications are from today
244
+ expect(await screen.findByText('Today')).toBeInTheDocument();
245
+ });
246
+
247
+ it('fetches node info via GraphQL', async () => {
248
+ const mockDjClient = createMockDjClient();
249
+ renderWithContext(mockDjClient);
250
+
251
+ await waitFor(() => {
252
+ expect(mockDjClient.getSubscribedHistory).toHaveBeenCalledWith(50);
253
+ });
254
+
255
+ await waitFor(() => {
256
+ expect(mockDjClient.getNodesByNames).toHaveBeenCalledWith([
257
+ 'default.metrics.revenue',
258
+ 'default.dimensions.country',
259
+ ]);
260
+ });
261
+ });
262
+
263
+ it('handles errors gracefully', async () => {
264
+ const consoleSpy = jest
265
+ .spyOn(console, 'error')
266
+ .mockImplementation(() => {});
267
+
268
+ const mockDjClient = createMockDjClient({
269
+ getSubscribedHistory: jest
270
+ .fn()
271
+ .mockRejectedValue(new Error('Network error')),
272
+ });
273
+ renderWithContext(mockDjClient);
274
+
275
+ await waitFor(() => {
276
+ expect(consoleSpy).toHaveBeenCalledWith(
277
+ 'Error fetching notifications:',
278
+ expect.any(Error),
279
+ );
280
+ });
281
+
282
+ // Should show empty state after error
283
+ expect(screen.getByText(/No notifications yet/i)).toBeInTheDocument();
284
+
285
+ consoleSpy.mockRestore();
286
+ });
287
+ });
@@ -0,0 +1,136 @@
1
+ import { useContext, useEffect, useState } from 'react';
2
+ import DJClientContext from '../../providers/djclient';
3
+ import LoadingIcon from '../../icons/LoadingIcon';
4
+ import { formatRelativeTime, groupByDate } from '../../utils/date';
5
+
6
+ // Enrich history entries with node info from GraphQL
7
+ const enrichWithNodeInfo = (entries, nodes) => {
8
+ const nodeMap = new Map(nodes.map(n => [n.name, n]));
9
+ return entries.map(entry => {
10
+ const node = nodeMap.get(entry.entity_name);
11
+ return {
12
+ ...entry,
13
+ node_type: node?.type,
14
+ display_name: node?.current?.displayName,
15
+ };
16
+ });
17
+ };
18
+
19
+ export function NotificationsPage() {
20
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
21
+ const [notifications, setNotifications] = useState([]);
22
+ const [loading, setLoading] = useState(true);
23
+
24
+ useEffect(() => {
25
+ async function fetchNotifications() {
26
+ try {
27
+ const history = (await djClient.getSubscribedHistory(50)) || [];
28
+
29
+ // Get unique entity names and fetch their info via GraphQL
30
+ const nodeNames = Array.from(new Set(history.map(h => h.entity_name)));
31
+ const nodes = nodeNames.length
32
+ ? await djClient.getNodesByNames(nodeNames)
33
+ : [];
34
+
35
+ const enriched = enrichWithNodeInfo(history, nodes);
36
+ setNotifications(enriched);
37
+ } catch (error) {
38
+ console.error('Error fetching notifications:', error);
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ }
43
+ fetchNotifications();
44
+ }, [djClient]);
45
+
46
+ const groupedNotifications = groupByDate(notifications);
47
+
48
+ return (
49
+ <div className="mid">
50
+ <div className="card">
51
+ <div className="card-header">
52
+ <h2>Notifications</h2>
53
+ </div>
54
+ <div className="card-body">
55
+ <div className="notifications-list">
56
+ {loading ? (
57
+ <div style={{ padding: '2rem', textAlign: 'center' }}>
58
+ <LoadingIcon />
59
+ </div>
60
+ ) : notifications.length === 0 ? (
61
+ <div
62
+ style={{
63
+ padding: '2rem 1rem',
64
+ color: '#666',
65
+ textAlign: 'center',
66
+ }}
67
+ >
68
+ No notifications yet. Watch nodes to receive updates when they
69
+ change.
70
+ </div>
71
+ ) : (
72
+ groupedNotifications.map(group => (
73
+ <div key={group.label} className="notification-group">
74
+ <div
75
+ style={{
76
+ padding: '0.5rem 0.75rem',
77
+ backgroundColor: '#f8f9fa',
78
+ borderBottom: '1px solid #eee',
79
+ fontSize: '12px',
80
+ fontWeight: 600,
81
+ color: '#666',
82
+ textTransform: 'uppercase',
83
+ letterSpacing: '0.5px',
84
+ }}
85
+ >
86
+ {group.label}
87
+ </div>
88
+ {group.items.map(entry => {
89
+ const version = entry.details?.version;
90
+ const href = version
91
+ ? `/nodes/${entry.entity_name}/revisions/${version}`
92
+ : `/nodes/${entry.entity_name}/history`;
93
+
94
+ return (
95
+ <a
96
+ key={entry.id}
97
+ className="notification-item"
98
+ href={href}
99
+ >
100
+ <span className="notification-node">
101
+ <span className="notification-title">
102
+ {entry.display_name || entry.entity_name}
103
+ {version && (
104
+ <span className="badge version">{version}</span>
105
+ )}
106
+ </span>
107
+ {entry.display_name && (
108
+ <span className="notification-entity">
109
+ {entry.entity_name}
110
+ </span>
111
+ )}
112
+ </span>
113
+ <span className="notification-meta">
114
+ {entry.node_type && (
115
+ <span
116
+ className={`node_type__${entry.node_type} badge node_type`}
117
+ >
118
+ {entry.node_type.toUpperCase()}
119
+ </span>
120
+ )}
121
+ {entry.activity_type}d by{' '}
122
+ <span style={{ color: '#333' }}>{entry.user}</span> ·{' '}
123
+ {formatRelativeTime(entry.created_at)}
124
+ </span>
125
+ </a>
126
+ );
127
+ })}
128
+ </div>
129
+ ))
130
+ )}
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ );
136
+ }
@@ -8,9 +8,22 @@ describe('<Root />', () => {
8
8
  const mockDjClient = {
9
9
  logout: jest.fn(),
10
10
  nodeDetails: jest.fn(),
11
- listTags: jest.fn(),
11
+ listTags: jest.fn().mockResolvedValue([]),
12
+ nodes: jest.fn().mockResolvedValue([]),
13
+ whoami: jest.fn().mockResolvedValue({
14
+ id: 1,
15
+ username: 'testuser',
16
+ email: 'test@example.com',
17
+ }),
18
+ getSubscribedHistory: jest.fn().mockResolvedValue([]),
19
+ markNotificationsRead: jest.fn().mockResolvedValue({}),
20
+ getNodesByNames: jest.fn().mockResolvedValue([]),
12
21
  };
13
22
 
23
+ beforeEach(() => {
24
+ jest.clearAllMocks();
25
+ });
26
+
14
27
  it('renders with the correct title and navigation', async () => {
15
28
  render(
16
29
  <HelmetProvider>
@@ -20,60 +33,12 @@ describe('<Root />', () => {
20
33
  </HelmetProvider>,
21
34
  );
22
35
 
23
- waitFor(() => {
36
+ await waitFor(() => {
24
37
  expect(document.title).toEqual('DataJunction');
25
- const metaDescription = document.querySelector(
26
- "meta[name='description']",
27
- );
28
- expect(metaDescription).toBeInTheDocument();
29
- expect(metaDescription.content).toBe(
30
- 'DataJunction Metrics Platform Webapp',
31
- );
32
-
33
- expect(screen.getByText(/^DataJunction$/)).toBeInTheDocument();
34
- expect(screen.getByText('Explore').closest('a')).toHaveAttribute(
35
- 'href',
36
- '/',
37
- );
38
- expect(screen.getByText('SQL').closest('a')).toHaveAttribute(
39
- 'href',
40
- '/sql',
41
- );
42
- expect(screen.getByText('Open-Source').closest('a')).toHaveAttribute(
43
- 'href',
44
- 'https://www.datajunction.io',
45
- );
46
38
  });
47
- });
48
-
49
- it('renders Logout button unless REACT_DISABLE_AUTH is true', () => {
50
- process.env.REACT_DISABLE_AUTH = 'false';
51
- render(
52
- <HelmetProvider>
53
- <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
54
- <Root />
55
- </DJClientContext.Provider>
56
- </HelmetProvider>,
57
- );
58
- expect(screen.getByText('Logout')).toBeInTheDocument();
59
- });
60
-
61
- it('calls logout and reloads window on logout button click', () => {
62
- process.env.REACT_DISABLE_AUTH = 'false';
63
- const originalLocation = window.location;
64
- delete window.location;
65
- window.location = { ...originalLocation, reload: jest.fn() };
66
-
67
- render(
68
- <HelmetProvider>
69
- <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
70
- <Root />
71
- </DJClientContext.Provider>
72
- </HelmetProvider>,
73
- );
74
39
 
75
- screen.getByText('Logout').click();
76
- expect(mockDjClient.logout).toHaveBeenCalled();
77
- window.location = originalLocation;
40
+ // Check navigation links exist
41
+ expect(screen.getByText('Explore')).toBeInTheDocument();
42
+ expect(screen.getByText('SQL')).toBeInTheDocument();
78
43
  });
79
44
  });
@@ -1,9 +1,11 @@
1
- import { useContext } from 'react';
1
+ import { useState } from 'react';
2
2
  import { Outlet } from 'react-router-dom';
3
3
  import DJLogo from '../../icons/DJLogo';
4
4
  import { Helmet } from 'react-helmet-async';
5
- import DJClientContext from '../../providers/djclient';
6
5
  import Search from '../../components/Search';
6
+ import NotificationBell from '../../components/NotificationBell';
7
+ import UserMenu from '../../components/UserMenu';
8
+ import '../../../styles/nav-bar.css';
7
9
 
8
10
  // Define the type for the docs sites
9
11
  type DocsSites = {
@@ -21,21 +23,16 @@ const docsSites: DocsSites = process.env.REACT_APP_DOCS_SITES
21
23
  : defaultDocsSites;
22
24
 
23
25
  export function Root() {
24
- const djClient = useContext(DJClientContext).DataJunctionAPI;
25
-
26
- const handleLogout = async () => {
27
- await djClient.logout();
28
- window.location.reload();
29
- };
26
+ // Track which dropdown is open to close others
27
+ const [openDropdown, setOpenDropdown] = useState<
28
+ 'notifications' | 'user' | null
29
+ >(null);
30
30
 
31
31
  return (
32
32
  <>
33
33
  <Helmet>
34
34
  <title>DataJunction</title>
35
- <meta
36
- name="description"
37
- content="DataJunction Metrics Platform Webapp"
38
- />
35
+ <meta name="description" content="DataJunction UI" />
39
36
  </Helmet>
40
37
  <div className="container d-flex align-items-center justify-content-between">
41
38
  <div className="header">
@@ -105,13 +102,20 @@ export function Root() {
105
102
  {process.env.REACT_DISABLE_AUTH === 'true' ? (
106
103
  ''
107
104
  ) : (
108
- <span className="menu-link">
109
- <span className="menu-title">
110
- <a href={'/'} onClick={handleLogout}>
111
- Logout
112
- </a>
113
- </span>
114
- </span>
105
+ <div className="nav-right">
106
+ <NotificationBell
107
+ onDropdownToggle={isOpen => {
108
+ setOpenDropdown(isOpen ? 'notifications' : null);
109
+ }}
110
+ forceClose={openDropdown === 'user'}
111
+ />
112
+ <UserMenu
113
+ onDropdownToggle={isOpen => {
114
+ setOpenDropdown(isOpen ? 'user' : null);
115
+ }}
116
+ forceClose={openDropdown === 'notifications'}
117
+ />
118
+ </div>
115
119
  )}
116
120
  </div>
117
121
  <Outlet />