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
@@ -0,0 +1,150 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import { ServiceAccountsSection } from '../ServiceAccountsSection';
4
+
5
+ describe('ServiceAccountsSection', () => {
6
+ const mockOnCreate = jest.fn();
7
+ const mockOnDelete = jest.fn();
8
+
9
+ const mockAccounts = [
10
+ {
11
+ id: 1,
12
+ name: 'my-pipeline',
13
+ client_id: 'abc-123-xyz',
14
+ created_at: '2024-12-01T00:00:00Z',
15
+ },
16
+ {
17
+ id: 2,
18
+ name: 'etl-job',
19
+ client_id: 'def-456-uvw',
20
+ created_at: '2024-12-02T00:00:00Z',
21
+ },
22
+ ];
23
+
24
+ beforeEach(() => {
25
+ jest.clearAllMocks();
26
+ });
27
+
28
+ it('renders empty state when no accounts', () => {
29
+ render(
30
+ <ServiceAccountsSection
31
+ accounts={[]}
32
+ onCreate={mockOnCreate}
33
+ onDelete={mockOnDelete}
34
+ />,
35
+ );
36
+
37
+ expect(screen.getByText(/No service accounts yet/i)).toBeInTheDocument();
38
+ });
39
+
40
+ it('renders accounts list', () => {
41
+ render(
42
+ <ServiceAccountsSection
43
+ accounts={mockAccounts}
44
+ onCreate={mockOnCreate}
45
+ onDelete={mockOnDelete}
46
+ />,
47
+ );
48
+
49
+ expect(screen.getByText('my-pipeline')).toBeInTheDocument();
50
+ expect(screen.getByText('etl-job')).toBeInTheDocument();
51
+ expect(screen.getByText('abc-123-xyz')).toBeInTheDocument();
52
+ expect(screen.getByText('def-456-uvw')).toBeInTheDocument();
53
+ });
54
+
55
+ it('renders section title and create button', () => {
56
+ render(
57
+ <ServiceAccountsSection
58
+ accounts={[]}
59
+ onCreate={mockOnCreate}
60
+ onDelete={mockOnDelete}
61
+ />,
62
+ );
63
+
64
+ expect(screen.getByText('Service Accounts')).toBeInTheDocument();
65
+ expect(screen.getByText('+ Create')).toBeInTheDocument();
66
+ });
67
+
68
+ it('opens create modal when create button is clicked', () => {
69
+ render(
70
+ <ServiceAccountsSection
71
+ accounts={[]}
72
+ onCreate={mockOnCreate}
73
+ onDelete={mockOnDelete}
74
+ />,
75
+ );
76
+
77
+ fireEvent.click(screen.getByText('+ Create'));
78
+
79
+ expect(screen.getByText('Create Service Account')).toBeInTheDocument();
80
+ });
81
+
82
+ it('calls onDelete when delete is confirmed', async () => {
83
+ window.confirm = jest.fn().mockReturnValue(true);
84
+ mockOnDelete.mockResolvedValue();
85
+
86
+ render(
87
+ <ServiceAccountsSection
88
+ accounts={mockAccounts}
89
+ onCreate={mockOnCreate}
90
+ onDelete={mockOnDelete}
91
+ />,
92
+ );
93
+
94
+ const deleteButtons = screen.getAllByTitle('Delete service account');
95
+ fireEvent.click(deleteButtons[0]);
96
+
97
+ expect(window.confirm).toHaveBeenCalledWith(
98
+ 'Delete service account "my-pipeline"?\n\nThis will revoke all access for this account and cannot be undone.',
99
+ );
100
+
101
+ await waitFor(() => {
102
+ expect(mockOnDelete).toHaveBeenCalledWith('abc-123-xyz');
103
+ });
104
+ });
105
+
106
+ it('does not call onDelete when delete is cancelled', () => {
107
+ window.confirm = jest.fn().mockReturnValue(false);
108
+
109
+ render(
110
+ <ServiceAccountsSection
111
+ accounts={mockAccounts}
112
+ onCreate={mockOnCreate}
113
+ onDelete={mockOnDelete}
114
+ />,
115
+ );
116
+
117
+ const deleteButtons = screen.getAllByTitle('Delete service account');
118
+ fireEvent.click(deleteButtons[0]);
119
+
120
+ expect(mockOnDelete).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('renders description text', () => {
124
+ render(
125
+ <ServiceAccountsSection
126
+ accounts={[]}
127
+ onCreate={mockOnCreate}
128
+ onDelete={mockOnDelete}
129
+ />,
130
+ );
131
+
132
+ expect(
133
+ screen.getByText(/Service accounts allow programmatic access/i),
134
+ ).toBeInTheDocument();
135
+ });
136
+
137
+ it('shows table headers when accounts exist', () => {
138
+ render(
139
+ <ServiceAccountsSection
140
+ accounts={mockAccounts}
141
+ onCreate={mockOnCreate}
142
+ onDelete={mockOnDelete}
143
+ />,
144
+ );
145
+
146
+ expect(screen.getByText('Name')).toBeInTheDocument();
147
+ expect(screen.getByText('Client ID')).toBeInTheDocument();
148
+ expect(screen.getByText('Created')).toBeInTheDocument();
149
+ });
150
+ });
@@ -0,0 +1,184 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { SettingsPage } from '../index';
4
+ import DJClientContext from '../../../providers/djclient';
5
+
6
+ describe('SettingsPage', () => {
7
+ const mockDjClient = {
8
+ whoami: jest.fn(),
9
+ getNotificationPreferences: jest.fn(),
10
+ getNodesByNames: jest.fn(),
11
+ listServiceAccounts: jest.fn(),
12
+ subscribeToNotifications: jest.fn(),
13
+ unsubscribeFromNotifications: jest.fn(),
14
+ createServiceAccount: jest.fn(),
15
+ deleteServiceAccount: jest.fn(),
16
+ };
17
+
18
+ const renderWithContext = () => {
19
+ return render(
20
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
21
+ <SettingsPage />
22
+ </DJClientContext.Provider>,
23
+ );
24
+ };
25
+
26
+ beforeEach(() => {
27
+ jest.clearAllMocks();
28
+
29
+ // Default mock implementations
30
+ mockDjClient.whoami.mockResolvedValue({
31
+ username: 'testuser',
32
+ email: 'test@example.com',
33
+ name: 'Test User',
34
+ });
35
+ mockDjClient.getNotificationPreferences.mockResolvedValue([]);
36
+ mockDjClient.getNodesByNames.mockResolvedValue([]);
37
+ mockDjClient.listServiceAccounts.mockResolvedValue([]);
38
+ });
39
+
40
+ it('shows loading state initially', () => {
41
+ // Make whoami hang
42
+ mockDjClient.whoami.mockImplementation(() => new Promise(() => {}));
43
+
44
+ renderWithContext();
45
+
46
+ // Should show loading icon (or some loading indicator)
47
+ expect(document.querySelector('.settings-page')).toBeInTheDocument();
48
+ });
49
+
50
+ it('renders all sections after loading', async () => {
51
+ renderWithContext();
52
+
53
+ await waitFor(() => {
54
+ expect(screen.getByText('Settings')).toBeInTheDocument();
55
+ });
56
+
57
+ expect(screen.getByText('Profile')).toBeInTheDocument();
58
+ expect(screen.getByText('Notification Subscriptions')).toBeInTheDocument();
59
+ expect(screen.getByText('Service Accounts')).toBeInTheDocument();
60
+ });
61
+
62
+ it('fetches and displays user profile', async () => {
63
+ mockDjClient.whoami.mockResolvedValue({
64
+ username: 'alice',
65
+ email: 'alice@example.com',
66
+ name: 'Alice Smith',
67
+ });
68
+
69
+ renderWithContext();
70
+
71
+ await waitFor(() => {
72
+ expect(screen.getByText('alice')).toBeInTheDocument();
73
+ });
74
+
75
+ expect(screen.getByText('alice@example.com')).toBeInTheDocument();
76
+ expect(screen.getByText('AS')).toBeInTheDocument(); // initials
77
+ });
78
+
79
+ it('fetches and displays subscriptions', async () => {
80
+ mockDjClient.getNotificationPreferences.mockResolvedValue([
81
+ {
82
+ entity_name: 'default.my_metric',
83
+ entity_type: 'node',
84
+ activity_types: ['update'],
85
+ },
86
+ ]);
87
+
88
+ mockDjClient.getNodesByNames.mockResolvedValue([
89
+ {
90
+ name: 'default.my_metric',
91
+ type: 'METRIC',
92
+ current: {
93
+ displayName: 'My Metric',
94
+ status: 'VALID',
95
+ mode: 'PUBLISHED',
96
+ },
97
+ },
98
+ ]);
99
+
100
+ renderWithContext();
101
+
102
+ await waitFor(() => {
103
+ expect(screen.getByText('default.my_metric')).toBeInTheDocument();
104
+ });
105
+ });
106
+
107
+ it('fetches and displays service accounts', async () => {
108
+ mockDjClient.listServiceAccounts.mockResolvedValue([
109
+ {
110
+ id: 1,
111
+ name: 'my-pipeline',
112
+ client_id: 'abc-123',
113
+ created_at: '2024-12-01T00:00:00Z',
114
+ },
115
+ ]);
116
+
117
+ renderWithContext();
118
+
119
+ await waitFor(() => {
120
+ expect(screen.getByText('my-pipeline')).toBeInTheDocument();
121
+ });
122
+
123
+ expect(screen.getByText('abc-123')).toBeInTheDocument();
124
+ });
125
+
126
+ it('handles service accounts API error gracefully', async () => {
127
+ const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
128
+ mockDjClient.listServiceAccounts.mockRejectedValue(
129
+ new Error('Not available'),
130
+ );
131
+
132
+ renderWithContext();
133
+
134
+ await waitFor(() => {
135
+ expect(screen.getByText('Settings')).toBeInTheDocument();
136
+ });
137
+
138
+ // Page should still render without service accounts
139
+ expect(screen.getByText(/No service accounts yet/i)).toBeInTheDocument();
140
+
141
+ consoleSpy.mockRestore();
142
+ });
143
+
144
+ it('handles fetch error gracefully', async () => {
145
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
146
+ mockDjClient.whoami.mockRejectedValue(new Error('Network error'));
147
+
148
+ renderWithContext();
149
+
150
+ await waitFor(() => {
151
+ // Page should render even after error
152
+ expect(document.querySelector('.settings-page')).toBeInTheDocument();
153
+ });
154
+
155
+ consoleSpy.mockRestore();
156
+ });
157
+
158
+ it('enriches subscriptions with node info from GraphQL', async () => {
159
+ mockDjClient.getNotificationPreferences.mockResolvedValue([
160
+ {
161
+ entity_name: 'default.orders',
162
+ entity_type: 'node',
163
+ activity_types: ['update'],
164
+ },
165
+ ]);
166
+
167
+ mockDjClient.getNodesByNames.mockResolvedValue([
168
+ {
169
+ name: 'default.orders',
170
+ type: 'SOURCE',
171
+ current: {
172
+ displayName: 'Orders Table',
173
+ status: 'VALID',
174
+ },
175
+ },
176
+ ]);
177
+
178
+ renderWithContext();
179
+
180
+ await waitFor(() => {
181
+ expect(screen.getByText('SOURCE')).toBeInTheDocument();
182
+ });
183
+ });
184
+ });
@@ -0,0 +1,148 @@
1
+ import { useContext, useEffect, useState } from 'react';
2
+ import DJClientContext from '../../providers/djclient';
3
+ import LoadingIcon from '../../icons/LoadingIcon';
4
+ import ProfileSection from './ProfileSection';
5
+ import NotificationSubscriptionsSection from './NotificationSubscriptionsSection';
6
+ import ServiceAccountsSection from './ServiceAccountsSection';
7
+ import '../../../styles/settings.css';
8
+
9
+ /**
10
+ * Main Settings page that orchestrates all settings sections.
11
+ */
12
+ export function SettingsPage() {
13
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
14
+ const [currentUser, setCurrentUser] = useState(null);
15
+ const [subscriptions, setSubscriptions] = useState([]);
16
+ const [serviceAccounts, setServiceAccounts] = useState([]);
17
+ const [loading, setLoading] = useState(true);
18
+
19
+ useEffect(() => {
20
+ async function fetchData() {
21
+ try {
22
+ // Fetch user profile
23
+ const user = await djClient.whoami();
24
+ setCurrentUser(user);
25
+
26
+ // Fetch notification subscriptions
27
+ const prefs = await djClient.getNotificationPreferences();
28
+
29
+ // Fetch node details for subscriptions via GraphQL
30
+ const nodeNames = (prefs || [])
31
+ .filter(p => p.entity_type === 'node')
32
+ .map(p => p.entity_name);
33
+
34
+ let nodeInfoMap = {};
35
+ if (nodeNames.length > 0) {
36
+ const nodes = await djClient.getNodesByNames(nodeNames);
37
+ nodeInfoMap = Object.fromEntries(
38
+ nodes.map(n => [
39
+ n.name,
40
+ {
41
+ node_type: n.type?.toLowerCase(),
42
+ display_name: n.current?.displayName,
43
+ status: n.current?.status?.toLowerCase(),
44
+ mode: n.current?.mode?.toLowerCase(),
45
+ },
46
+ ]),
47
+ );
48
+ }
49
+
50
+ // Merge node info into subscriptions
51
+ const enrichedPrefs = (prefs || []).map(pref => ({
52
+ ...pref,
53
+ ...(nodeInfoMap[pref.entity_name] || {}),
54
+ }));
55
+ setSubscriptions(enrichedPrefs);
56
+
57
+ // Fetch service accounts
58
+ try {
59
+ const accounts = await djClient.listServiceAccounts();
60
+ setServiceAccounts(accounts || []);
61
+ } catch (err) {
62
+ // Service accounts may not be available, ignore error
63
+ console.log('Service accounts not available:', err);
64
+ }
65
+ } catch (error) {
66
+ console.error('Error fetching settings data:', error);
67
+ } finally {
68
+ setLoading(false);
69
+ }
70
+ }
71
+ fetchData();
72
+ }, [djClient]);
73
+
74
+ // Subscription handlers
75
+ const handleUpdateSubscription = async (sub, activityTypes) => {
76
+ await djClient.subscribeToNotifications({
77
+ entity_type: sub.entity_type,
78
+ entity_name: sub.entity_name,
79
+ activity_types: activityTypes,
80
+ alert_types: sub.alert_types || ['web'],
81
+ });
82
+
83
+ // Update local state
84
+ setSubscriptions(
85
+ subscriptions.map(s =>
86
+ s.entity_name === sub.entity_name
87
+ ? { ...s, activity_types: activityTypes }
88
+ : s,
89
+ ),
90
+ );
91
+ };
92
+
93
+ const handleUnsubscribe = async sub => {
94
+ await djClient.unsubscribeFromNotifications({
95
+ entity_type: sub.entity_type,
96
+ entity_name: sub.entity_name,
97
+ });
98
+ setSubscriptions(
99
+ subscriptions.filter(s => s.entity_name !== sub.entity_name),
100
+ );
101
+ };
102
+
103
+ // Service account handlers
104
+ const handleCreateServiceAccount = async name => {
105
+ const result = await djClient.createServiceAccount(name);
106
+ if (result.client_id) {
107
+ setServiceAccounts([...serviceAccounts, result]);
108
+ }
109
+ return result;
110
+ };
111
+
112
+ const handleDeleteServiceAccount = async clientId => {
113
+ await djClient.deleteServiceAccount(clientId);
114
+ setServiceAccounts(serviceAccounts.filter(a => a.client_id !== clientId));
115
+ };
116
+
117
+ if (loading) {
118
+ return (
119
+ <div className="settings-page">
120
+ <div className="settings-container">
121
+ <LoadingIcon />
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ return (
128
+ <div className="settings-page">
129
+ <div className="settings-container">
130
+ <h1 className="settings-title">Settings</h1>
131
+
132
+ <ProfileSection user={currentUser} />
133
+
134
+ <NotificationSubscriptionsSection
135
+ subscriptions={subscriptions}
136
+ onUpdate={handleUpdateSubscription}
137
+ onUnsubscribe={handleUnsubscribe}
138
+ />
139
+
140
+ <ServiceAccountsSection
141
+ accounts={serviceAccounts}
142
+ onCreate={handleCreateServiceAccount}
143
+ onDelete={handleDeleteServiceAccount}
144
+ />
145
+ </div>
146
+ </div>
147
+ );
148
+ }
@@ -323,6 +323,41 @@ export const DataJunctionAPI = {
323
323
  return results.data.findNodes[0];
324
324
  },
325
325
 
326
+ // Fetch basic node info for multiple nodes by name (for Settings page)
327
+ getNodesByNames: async function (names) {
328
+ if (!names || names.length === 0) {
329
+ return [];
330
+ }
331
+ const query = `
332
+ query GetNodesByNames($names: [String!]) {
333
+ findNodes(names: $names) {
334
+ name
335
+ type
336
+ current {
337
+ displayName
338
+ status
339
+ mode
340
+ }
341
+ }
342
+ }
343
+ `;
344
+
345
+ const results = await (
346
+ await fetch(DJ_GQL, {
347
+ method: 'POST',
348
+ headers: {
349
+ 'Content-Type': 'application/json',
350
+ },
351
+ credentials: 'include',
352
+ body: JSON.stringify({
353
+ query,
354
+ variables: { names },
355
+ }),
356
+ })
357
+ ).json();
358
+ return results.data?.findNodes || [];
359
+ },
360
+
326
361
  getMetric: async function (name) {
327
362
  const query = `
328
363
  query GetMetric($name: String!) {
@@ -1463,4 +1498,50 @@ export const DataJunctionAPI = {
1463
1498
  json: await response.json(),
1464
1499
  };
1465
1500
  },
1501
+
1502
+ // GET /history/ with only_subscribed filter
1503
+ getSubscribedHistory: async function (limit = 10) {
1504
+ return await (
1505
+ await fetch(`${DJ_URL}/history/?only_subscribed=true&limit=${limit}`, {
1506
+ credentials: 'include',
1507
+ })
1508
+ ).json();
1509
+ },
1510
+
1511
+ // POST /notifications/mark-read
1512
+ markNotificationsRead: async function () {
1513
+ const response = await fetch(`${DJ_URL}/notifications/mark-read`, {
1514
+ method: 'POST',
1515
+ credentials: 'include',
1516
+ });
1517
+ return await response.json();
1518
+ },
1519
+
1520
+ // Service Account APIs
1521
+ listServiceAccounts: async function () {
1522
+ const response = await fetch(`${DJ_URL}/service-accounts`, {
1523
+ credentials: 'include',
1524
+ });
1525
+ return await response.json();
1526
+ },
1527
+
1528
+ createServiceAccount: async function (name) {
1529
+ const response = await fetch(`${DJ_URL}/service-accounts`, {
1530
+ method: 'POST',
1531
+ headers: {
1532
+ 'Content-Type': 'application/json',
1533
+ },
1534
+ credentials: 'include',
1535
+ body: JSON.stringify({ name }),
1536
+ });
1537
+ return await response.json();
1538
+ },
1539
+
1540
+ deleteServiceAccount: async function (clientId) {
1541
+ const response = await fetch(`${DJ_URL}/service-accounts/${clientId}`, {
1542
+ method: 'DELETE',
1543
+ credentials: 'include',
1544
+ });
1545
+ return await response.json();
1546
+ },
1466
1547
  };