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,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
+ }
@@ -18,14 +18,16 @@ export const DataJunctionAPI = {
18
18
  after,
19
19
  limit,
20
20
  sortConfig,
21
+ mode,
21
22
  ) {
22
23
  const query = `
23
- query ListNodes($namespace: String, $nodeTypes: [NodeType!], $tags: [String!], $editedBy: String, $before: String, $after: String, $limit: Int, $orderBy: NodeSortField, $ascending: Boolean) {
24
+ query ListNodes($namespace: String, $nodeTypes: [NodeType!], $tags: [String!], $editedBy: String, $mode: NodeMode, $before: String, $after: String, $limit: Int, $orderBy: NodeSortField, $ascending: Boolean) {
24
25
  findNodesPaginated(
25
26
  namespace: $namespace
26
27
  nodeTypes: $nodeTypes
27
28
  tags: $tags
28
29
  editedBy: $editedBy
30
+ mode: $mode
29
31
  limit: $limit
30
32
  before: $before
31
33
  after: $after
@@ -51,6 +53,7 @@ export const DataJunctionAPI = {
51
53
  current {
52
54
  displayName
53
55
  status
56
+ mode
54
57
  updatedAt
55
58
  }
56
59
  createdBy {
@@ -83,6 +86,7 @@ export const DataJunctionAPI = {
83
86
  nodeTypes: nodeTypes,
84
87
  tags: tags,
85
88
  editedBy: editedBy,
89
+ mode: mode || null,
86
90
  before: before,
87
91
  after: after,
88
92
  limit: limit,
@@ -319,6 +323,41 @@ export const DataJunctionAPI = {
319
323
  return results.data.findNodes[0];
320
324
  },
321
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
+
322
361
  getMetric: async function (name) {
323
362
  const query = `
324
363
  query GetMetric($name: String!) {
@@ -1459,4 +1498,50 @@ export const DataJunctionAPI = {
1459
1498
  json: await response.json(),
1460
1499
  };
1461
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
+ },
1462
1547
  };