datajunction-ui 0.0.26-alpha.0 → 0.0.27

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 (28) hide show
  1. package/package.json +2 -2
  2. package/src/app/components/Search.jsx +41 -33
  3. package/src/app/components/__tests__/Search.test.jsx +46 -11
  4. package/src/app/index.tsx +1 -1
  5. package/src/app/pages/AddEditNodePage/MetricQueryField.jsx +57 -8
  6. package/src/app/pages/AddEditNodePage/UpstreamNodeField.jsx +17 -5
  7. package/src/app/pages/AddEditNodePage/__tests__/index.test.jsx +97 -1
  8. package/src/app/pages/AddEditNodePage/index.jsx +61 -17
  9. package/src/app/pages/NodePage/WatchNodeButton.jsx +12 -5
  10. package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +93 -15
  11. package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +2320 -65
  12. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +234 -25
  13. package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +315 -122
  14. package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +2672 -314
  15. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +567 -0
  16. package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +480 -55
  17. package/src/app/pages/QueryPlannerPage/index.jsx +1021 -14
  18. package/src/app/pages/QueryPlannerPage/styles.css +1990 -62
  19. package/src/app/pages/Root/__tests__/index.test.jsx +79 -8
  20. package/src/app/pages/Root/index.tsx +1 -1
  21. package/src/app/pages/SQLBuilderPage/__tests__/index.test.jsx +82 -0
  22. package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +37 -0
  23. package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +48 -0
  24. package/src/app/pages/SettingsPage/__tests__/index.test.jsx +169 -1
  25. package/src/app/services/DJService.js +492 -3
  26. package/src/app/services/__tests__/DJService.test.jsx +582 -0
  27. package/src/mocks/mockNodes.jsx +36 -0
  28. package/webpack.config.js +27 -0
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render, screen, waitFor } from '@testing-library/react';
2
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
3
  import { Root } from '../index';
4
4
  import DJClientContext from '../../../providers/djclient';
5
5
  import { HelmetProvider } from 'react-helmet-async';
@@ -20,18 +20,22 @@ describe('<Root />', () => {
20
20
  getNodesByNames: jest.fn().mockResolvedValue([]),
21
21
  };
22
22
 
23
- beforeEach(() => {
24
- jest.clearAllMocks();
25
- });
26
-
27
- it('renders with the correct title and navigation', async () => {
28
- render(
23
+ const renderRoot = () => {
24
+ return render(
29
25
  <HelmetProvider>
30
26
  <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
31
27
  <Root />
32
28
  </DJClientContext.Provider>
33
29
  </HelmetProvider>,
34
30
  );
31
+ };
32
+
33
+ beforeEach(() => {
34
+ jest.clearAllMocks();
35
+ });
36
+
37
+ it('renders with the correct title and navigation', async () => {
38
+ renderRoot();
35
39
 
36
40
  await waitFor(() => {
37
41
  expect(document.title).toEqual('DataJunction');
@@ -39,6 +43,73 @@ describe('<Root />', () => {
39
43
 
40
44
  // Check navigation links exist
41
45
  expect(screen.getByText('Explore')).toBeInTheDocument();
42
- expect(screen.getByText('SQL')).toBeInTheDocument();
46
+ expect(screen.getByText('Query Planner')).toBeInTheDocument();
47
+ });
48
+
49
+ it('renders Docs dropdown', async () => {
50
+ renderRoot();
51
+
52
+ await waitFor(() => {
53
+ expect(screen.getByText('Docs')).toBeInTheDocument();
54
+ });
55
+
56
+ // Default docs site should be visible in dropdown
57
+ expect(screen.getByText('Open-Source')).toBeInTheDocument();
58
+ });
59
+
60
+ it('renders notification bell and user menu when auth is enabled', async () => {
61
+ // Default REACT_DISABLE_AUTH is not 'true', so auth components should show
62
+ renderRoot();
63
+
64
+ await waitFor(() => {
65
+ expect(document.title).toEqual('DataJunction');
66
+ });
67
+
68
+ // Look for nav-right container which contains the notification and user menu
69
+ const navRight = document.querySelector('.nav-right');
70
+ expect(navRight).toBeInTheDocument();
71
+ });
72
+
73
+ it('handles notification dropdown toggle', async () => {
74
+ renderRoot();
75
+
76
+ await waitFor(() => {
77
+ expect(document.title).toEqual('DataJunction');
78
+ });
79
+
80
+ // Find the notification bell button and click it
81
+ const bellButton = document.querySelector('[aria-label="Notifications"]');
82
+ if (bellButton) {
83
+ fireEvent.click(bellButton);
84
+ // The dropdown should open
85
+ await waitFor(() => {
86
+ // After clicking, the dropdown state changes
87
+ expect(bellButton).toBeInTheDocument();
88
+ });
89
+ }
90
+ });
91
+
92
+ it('handles user menu dropdown toggle', async () => {
93
+ renderRoot();
94
+
95
+ await waitFor(() => {
96
+ expect(document.title).toEqual('DataJunction');
97
+ });
98
+
99
+ // The nav-right container should be present for auth-enabled mode
100
+ const navRight = document.querySelector('.nav-right');
101
+ expect(navRight).toBeInTheDocument();
102
+ });
103
+
104
+ it('renders logo link correctly', async () => {
105
+ renderRoot();
106
+
107
+ await waitFor(() => {
108
+ expect(document.title).toEqual('DataJunction');
109
+ });
110
+
111
+ // Check logo link - name is "Data Junction" with space
112
+ const logoLink = screen.getByRole('link', { name: /data.*junction/i });
113
+ expect(logoLink).toHaveAttribute('href', '/');
43
114
  });
44
115
  });
@@ -61,7 +61,7 @@ export function Root() {
61
61
  </span>
62
62
  <span className="menu-link">
63
63
  <span className="menu-title">
64
- <a href="/sql">SQL</a>
64
+ <a href="/planner">Query Planner</a>
65
65
  </span>
66
66
  </span>
67
67
  <span className="menu-link">
@@ -14,6 +14,7 @@ const mockDjClient = {
14
14
  commonDimensions: jest.fn(),
15
15
  sqls: jest.fn(),
16
16
  data: jest.fn(),
17
+ stream: jest.fn(),
17
18
  };
18
19
 
19
20
  const mockMetrics = [
@@ -97,6 +98,16 @@ const mockCommonDimensions = [
97
98
  },
98
99
  ];
99
100
 
101
+ // Additional dimensions for testing type handling
102
+ const mockDimensionsWithBool = [
103
+ {
104
+ name: 'default.is_active',
105
+ type: 'bool',
106
+ path: ['default.repair_order'],
107
+ },
108
+ ...mockCommonDimensions,
109
+ ];
110
+
100
111
  describe('SQLBuilderPage', () => {
101
112
  beforeEach(() => {
102
113
  mockDjClient.metrics.mockResolvedValue(mockMetrics);
@@ -171,3 +182,74 @@ describe('SQLBuilderPage', () => {
171
182
  expect(mockDjClient.sqls).toHaveBeenCalled();
172
183
  });
173
184
  });
185
+
186
+ describe('SQLBuilderPage - Data fetching', () => {
187
+ beforeEach(() => {
188
+ jest.clearAllMocks();
189
+ mockDjClient.metrics.mockResolvedValue(mockMetrics);
190
+ mockDjClient.commonDimensions.mockResolvedValue(mockCommonDimensions);
191
+ mockDjClient.sqls.mockResolvedValue({ sql: 'SELECT * FROM table' });
192
+ mockDjClient.data.mockResolvedValue({});
193
+ });
194
+
195
+ it('fetches metrics on initial render', async () => {
196
+ render(
197
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
198
+ <SQLBuilderPage />
199
+ </DJClientContext.Provider>,
200
+ );
201
+
202
+ await waitFor(() => {
203
+ expect(mockDjClient.metrics).toHaveBeenCalled();
204
+ });
205
+
206
+ await waitFor(() => {
207
+ expect(screen.getAllByText('3 Available Metrics')[0]).toBeInTheDocument();
208
+ });
209
+ });
210
+
211
+ it('displays instruction card when no selections', async () => {
212
+ render(
213
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
214
+ <SQLBuilderPage />
215
+ </DJClientContext.Provider>,
216
+ );
217
+
218
+ await waitFor(() => {
219
+ expect(screen.getByText('Using the SQL Builder')).toBeInTheDocument();
220
+ });
221
+
222
+ expect(
223
+ screen.getByText(/Start by selecting one or more/),
224
+ ).toBeInTheDocument();
225
+ });
226
+
227
+ it('clears dimensions when no common dimensions found', async () => {
228
+ mockDjClient.commonDimensions.mockResolvedValue([]);
229
+
230
+ render(
231
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
232
+ <SQLBuilderPage />
233
+ </DJClientContext.Provider>,
234
+ );
235
+
236
+ // Wait for metrics
237
+ await waitFor(() => {
238
+ expect(screen.getAllByText('3 Available Metrics')[0]).toBeInTheDocument();
239
+ });
240
+
241
+ // Select metric
242
+ const selectMetrics = screen.getAllByTestId('select-metrics')[0];
243
+ fireEvent.keyDown(selectMetrics.firstChild, { key: 'ArrowDown' });
244
+ await waitFor(() => {
245
+ expect(screen.getByText('default.num_repair_orders')).toBeInTheDocument();
246
+ });
247
+ fireEvent.click(screen.getByText('default.num_repair_orders'));
248
+ fireEvent.click(screen.getAllByText('Group By')[0]);
249
+
250
+ // When no common dimensions
251
+ await waitFor(() => {
252
+ expect(screen.getAllByText('0 Shared Dimensions')[0]).toBeInTheDocument();
253
+ });
254
+ });
255
+ });
@@ -315,4 +315,41 @@ describe('CreateServiceAccountModal', () => {
315
315
 
316
316
  expect(navigator.clipboard.writeText).toHaveBeenCalledWith('abc-123');
317
317
  });
318
+
319
+ it('copies client secret to clipboard when copy button is clicked', async () => {
320
+ const credentials = {
321
+ name: 'my-account',
322
+ client_id: 'abc-123',
323
+ client_secret: 'secret-xyz',
324
+ };
325
+ mockOnCreate.mockResolvedValue(credentials);
326
+
327
+ Object.assign(navigator, {
328
+ clipboard: {
329
+ writeText: jest.fn().mockResolvedValue(),
330
+ },
331
+ });
332
+
333
+ render(
334
+ <CreateServiceAccountModal
335
+ isOpen={true}
336
+ onClose={mockOnClose}
337
+ onCreate={mockOnCreate}
338
+ />,
339
+ );
340
+
341
+ const input = screen.getByLabelText('Name');
342
+ fireEvent.change(input, { target: { value: 'my-account' } });
343
+ fireEvent.click(screen.getByText('Create'));
344
+
345
+ await waitFor(() => {
346
+ expect(screen.getByText('secret-xyz')).toBeInTheDocument();
347
+ });
348
+
349
+ // Click the second copy button (client secret)
350
+ const copyButtons = screen.getAllByTitle('Copy');
351
+ fireEvent.click(copyButtons[1]);
352
+
353
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('secret-xyz');
354
+ });
318
355
  });
@@ -147,4 +147,52 @@ describe('ServiceAccountsSection', () => {
147
147
  expect(screen.getByText('Client ID')).toBeInTheDocument();
148
148
  expect(screen.getByText('Created')).toBeInTheDocument();
149
149
  });
150
+
151
+ it('handles delete error gracefully', async () => {
152
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
153
+ const alertSpy = jest.spyOn(window, 'alert').mockImplementation();
154
+ window.confirm = jest.fn().mockReturnValue(true);
155
+ mockOnDelete.mockRejectedValue(new Error('Delete failed'));
156
+
157
+ render(
158
+ <ServiceAccountsSection
159
+ accounts={mockAccounts}
160
+ onCreate={mockOnCreate}
161
+ onDelete={mockOnDelete}
162
+ />,
163
+ );
164
+
165
+ const deleteButtons = screen.getAllByTitle('Delete service account');
166
+ fireEvent.click(deleteButtons[0]);
167
+
168
+ await waitFor(() => {
169
+ expect(alertSpy).toHaveBeenCalledWith('Failed to delete service account');
170
+ });
171
+
172
+ consoleSpy.mockRestore();
173
+ alertSpy.mockRestore();
174
+ });
175
+
176
+ it('closes modal when close button is clicked', () => {
177
+ render(
178
+ <ServiceAccountsSection
179
+ accounts={[]}
180
+ onCreate={mockOnCreate}
181
+ onDelete={mockOnDelete}
182
+ />,
183
+ );
184
+
185
+ // Open modal
186
+ fireEvent.click(screen.getByText('+ Create'));
187
+ expect(screen.getByText('Create Service Account')).toBeInTheDocument();
188
+
189
+ // Close modal
190
+ fireEvent.click(screen.getByTitle('Close'));
191
+
192
+ // Modal should no longer be visible (it stays in DOM but should be hidden)
193
+ // The modal content should be hidden
194
+ expect(
195
+ screen.queryByText('Create Service Account'),
196
+ ).not.toBeInTheDocument();
197
+ });
150
198
  });
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render, screen, waitFor } from '@testing-library/react';
2
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
3
  import { SettingsPage } from '../index';
4
4
  import DJClientContext from '../../../providers/djclient';
5
5
  import { UserProvider } from '../../../providers/UserProvider';
@@ -184,4 +184,172 @@ describe('SettingsPage', () => {
184
184
  expect(screen.getByText('SOURCE')).toBeInTheDocument();
185
185
  });
186
186
  });
187
+
188
+ it('handles subscription update', async () => {
189
+ mockDjClient.getNotificationPreferences.mockResolvedValue([
190
+ {
191
+ entity_name: 'default.my_metric',
192
+ entity_type: 'node',
193
+ activity_types: ['update'],
194
+ alert_types: ['web'],
195
+ },
196
+ ]);
197
+
198
+ mockDjClient.getNodesByNames.mockResolvedValue([
199
+ {
200
+ name: 'default.my_metric',
201
+ type: 'METRIC',
202
+ current: {
203
+ displayName: 'My Metric',
204
+ status: 'VALID',
205
+ mode: 'PUBLISHED',
206
+ },
207
+ },
208
+ ]);
209
+
210
+ mockDjClient.subscribeToNotifications.mockResolvedValue({
211
+ status: 200,
212
+ json: { message: 'Subscribed' },
213
+ });
214
+
215
+ renderWithContext();
216
+
217
+ await waitFor(() => {
218
+ expect(screen.getByText('default.my_metric')).toBeInTheDocument();
219
+ });
220
+
221
+ // The subscription is rendered with checkboxes for activity types
222
+ // Find and interact with the update checkbox (if available in UI)
223
+ const updateCheckbox = screen.queryByLabelText(/update/i);
224
+ if (updateCheckbox) {
225
+ fireEvent.click(updateCheckbox);
226
+ await waitFor(() => {
227
+ expect(mockDjClient.subscribeToNotifications).toHaveBeenCalled();
228
+ });
229
+ }
230
+ });
231
+
232
+ it('handles subscription unsubscribe', async () => {
233
+ mockDjClient.getNotificationPreferences.mockResolvedValue([
234
+ {
235
+ entity_name: 'default.my_metric',
236
+ entity_type: 'node',
237
+ activity_types: ['update'],
238
+ alert_types: ['web'],
239
+ },
240
+ ]);
241
+
242
+ mockDjClient.getNodesByNames.mockResolvedValue([
243
+ {
244
+ name: 'default.my_metric',
245
+ type: 'METRIC',
246
+ current: {
247
+ displayName: 'My Metric',
248
+ status: 'VALID',
249
+ mode: 'PUBLISHED',
250
+ },
251
+ },
252
+ ]);
253
+
254
+ mockDjClient.unsubscribeFromNotifications.mockResolvedValue({
255
+ status: 200,
256
+ json: { message: 'Unsubscribed' },
257
+ });
258
+
259
+ renderWithContext();
260
+
261
+ await waitFor(() => {
262
+ expect(screen.getByText('default.my_metric')).toBeInTheDocument();
263
+ });
264
+
265
+ // Find unsubscribe button
266
+ const unsubscribeBtn = screen.queryByRole('button', {
267
+ name: /unsubscribe/i,
268
+ });
269
+ if (unsubscribeBtn) {
270
+ fireEvent.click(unsubscribeBtn);
271
+ await waitFor(() => {
272
+ expect(mockDjClient.unsubscribeFromNotifications).toHaveBeenCalled();
273
+ });
274
+ }
275
+ });
276
+
277
+ it('opens create service account modal', async () => {
278
+ renderWithContext();
279
+
280
+ await waitFor(() => {
281
+ expect(screen.getByText('Settings')).toBeInTheDocument();
282
+ });
283
+
284
+ // Find and click create button
285
+ const createBtn = screen.getByRole('button', { name: /create/i });
286
+ fireEvent.click(createBtn);
287
+
288
+ // Modal should be visible
289
+ await waitFor(() => {
290
+ expect(screen.getByText('Create Service Account')).toBeInTheDocument();
291
+ });
292
+ });
293
+
294
+ it('handles service account deletion', async () => {
295
+ mockDjClient.listServiceAccounts.mockResolvedValue([
296
+ {
297
+ id: 1,
298
+ name: 'my-pipeline',
299
+ client_id: 'abc-123',
300
+ created_at: '2024-12-01T00:00:00Z',
301
+ },
302
+ ]);
303
+
304
+ mockDjClient.deleteServiceAccount.mockResolvedValue({
305
+ message: 'Deleted',
306
+ });
307
+
308
+ renderWithContext();
309
+
310
+ await waitFor(() => {
311
+ expect(screen.getByText('my-pipeline')).toBeInTheDocument();
312
+ });
313
+
314
+ // Find delete button
315
+ const deleteBtn = screen.queryByRole('button', { name: /delete/i });
316
+ if (deleteBtn) {
317
+ fireEvent.click(deleteBtn);
318
+ await waitFor(() => {
319
+ expect(mockDjClient.deleteServiceAccount).toHaveBeenCalled();
320
+ });
321
+ }
322
+ });
323
+
324
+ it('handles non-node subscription types gracefully', async () => {
325
+ mockDjClient.getNotificationPreferences.mockResolvedValue([
326
+ {
327
+ entity_name: 'namespace.test',
328
+ entity_type: 'namespace',
329
+ activity_types: ['create'],
330
+ alert_types: ['web'],
331
+ },
332
+ ]);
333
+
334
+ renderWithContext();
335
+
336
+ await waitFor(() => {
337
+ expect(screen.getByText('Settings')).toBeInTheDocument();
338
+ });
339
+
340
+ // Namespace subscription should still appear
341
+ await waitFor(() => {
342
+ expect(screen.getByText('namespace.test')).toBeInTheDocument();
343
+ });
344
+ });
345
+
346
+ it('skips fetching if userLoading', async () => {
347
+ // When user is still loading, the component waits
348
+ mockDjClient.whoami.mockImplementation(() => new Promise(() => {}));
349
+
350
+ renderWithContext();
351
+
352
+ // getNotificationPreferences should not be called while user is loading
353
+ expect(mockDjClient.getNotificationPreferences).not.toHaveBeenCalled();
354
+ });
187
355
  });