datajunction-ui 0.0.27 → 0.0.29

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.
@@ -185,7 +185,7 @@ describe('SettingsPage', () => {
185
185
  });
186
186
  });
187
187
 
188
- it('handles subscription update', async () => {
188
+ it('handles subscription update via edit mode and checkbox toggle', async () => {
189
189
  mockDjClient.getNotificationPreferences.mockResolvedValue([
190
190
  {
191
191
  entity_name: 'default.my_metric',
@@ -218,18 +218,93 @@ describe('SettingsPage', () => {
218
218
  expect(screen.getByText('default.my_metric')).toBeInTheDocument();
219
219
  });
220
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();
221
+ // First click Edit button to enter edit mode and show checkboxes
222
+ const editBtn = screen.getByTitle('Edit subscription');
223
+ fireEvent.click(editBtn);
224
+
225
+ // Wait for checkboxes to appear
226
+ await waitFor(() => {
227
+ expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
228
+ });
229
+
230
+ // Add another activity type (e.g., 'create')
231
+ const createCheckbox = screen.getByLabelText('Create');
232
+ fireEvent.click(createCheckbox);
233
+
234
+ // Save changes
235
+ const saveBtn = screen.getByText('Save');
236
+ fireEvent.click(saveBtn);
237
+
238
+ await waitFor(() => {
239
+ expect(mockDjClient.subscribeToNotifications).toHaveBeenCalledWith({
240
+ entity_type: 'node',
241
+ entity_name: 'default.my_metric',
242
+ activity_types: expect.any(Array),
243
+ alert_types: ['web'],
228
244
  });
229
- }
245
+ });
246
+ });
247
+
248
+ it('updates local subscription state after subscription update', async () => {
249
+ mockDjClient.getNotificationPreferences.mockResolvedValue([
250
+ {
251
+ entity_name: 'default.my_metric',
252
+ entity_type: 'node',
253
+ activity_types: ['update', 'status_change'],
254
+ alert_types: ['web'],
255
+ },
256
+ ]);
257
+
258
+ mockDjClient.getNodesByNames.mockResolvedValue([
259
+ {
260
+ name: 'default.my_metric',
261
+ type: 'METRIC',
262
+ current: {
263
+ displayName: 'My Metric',
264
+ status: 'VALID',
265
+ mode: 'PUBLISHED',
266
+ },
267
+ },
268
+ ]);
269
+
270
+ mockDjClient.subscribeToNotifications.mockResolvedValue({
271
+ status: 200,
272
+ json: { message: 'Updated' },
273
+ });
274
+
275
+ renderWithContext();
276
+
277
+ await waitFor(() => {
278
+ expect(screen.getByText('default.my_metric')).toBeInTheDocument();
279
+ });
280
+
281
+ // First click the Edit button to enter edit mode
282
+ const editBtn = screen.getByTitle('Edit subscription');
283
+ fireEvent.click(editBtn);
284
+
285
+ // Now checkboxes should be visible
286
+ await waitFor(() => {
287
+ expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
288
+ });
289
+
290
+ // Toggle a checkbox and save
291
+ const checkboxes = screen.getAllByRole('checkbox');
292
+ fireEvent.click(checkboxes[0]);
293
+
294
+ // Click Save button
295
+ const saveBtn = screen.getByText('Save');
296
+ fireEvent.click(saveBtn);
297
+
298
+ await waitFor(() => {
299
+ expect(mockDjClient.subscribeToNotifications).toHaveBeenCalled();
300
+ });
230
301
  });
231
302
 
232
- it('handles subscription unsubscribe', async () => {
303
+ it('handles subscription unsubscribe and removes from list', async () => {
304
+ // Mock window.confirm to return true
305
+ const originalConfirm = window.confirm;
306
+ window.confirm = jest.fn().mockReturnValue(true);
307
+
233
308
  mockDjClient.getNotificationPreferences.mockResolvedValue([
234
309
  {
235
310
  entity_name: 'default.my_metric',
@@ -237,6 +312,12 @@ describe('SettingsPage', () => {
237
312
  activity_types: ['update'],
238
313
  alert_types: ['web'],
239
314
  },
315
+ {
316
+ entity_name: 'default.another_metric',
317
+ entity_type: 'node',
318
+ activity_types: ['status_change'],
319
+ alert_types: ['web'],
320
+ },
240
321
  ]);
241
322
 
242
323
  mockDjClient.getNodesByNames.mockResolvedValue([
@@ -249,6 +330,15 @@ describe('SettingsPage', () => {
249
330
  mode: 'PUBLISHED',
250
331
  },
251
332
  },
333
+ {
334
+ name: 'default.another_metric',
335
+ type: 'METRIC',
336
+ current: {
337
+ displayName: 'Another Metric',
338
+ status: 'VALID',
339
+ mode: 'PUBLISHED',
340
+ },
341
+ },
252
342
  ]);
253
343
 
254
344
  mockDjClient.unsubscribeFromNotifications.mockResolvedValue({
@@ -260,18 +350,20 @@ describe('SettingsPage', () => {
260
350
 
261
351
  await waitFor(() => {
262
352
  expect(screen.getByText('default.my_metric')).toBeInTheDocument();
353
+ expect(screen.getByText('default.another_metric')).toBeInTheDocument();
263
354
  });
264
355
 
265
- // Find unsubscribe button
266
- const unsubscribeBtn = screen.queryByRole('button', {
267
- name: /unsubscribe/i,
356
+ // Find unsubscribe buttons (there are multiple, one per subscription)
357
+ const unsubscribeBtns = screen.getAllByTitle('Unsubscribe');
358
+ fireEvent.click(unsubscribeBtns[0]);
359
+
360
+ await waitFor(() => {
361
+ expect(window.confirm).toHaveBeenCalled();
362
+ expect(mockDjClient.unsubscribeFromNotifications).toHaveBeenCalled();
268
363
  });
269
- if (unsubscribeBtn) {
270
- fireEvent.click(unsubscribeBtn);
271
- await waitFor(() => {
272
- expect(mockDjClient.unsubscribeFromNotifications).toHaveBeenCalled();
273
- });
274
- }
364
+
365
+ // Restore original confirm
366
+ window.confirm = originalConfirm;
275
367
  });
276
368
 
277
369
  it('opens create service account modal', async () => {
@@ -291,7 +383,69 @@ describe('SettingsPage', () => {
291
383
  });
292
384
  });
293
385
 
294
- it('handles service account deletion', async () => {
386
+ it('creates service account and adds to list when successful', async () => {
387
+ const newAccount = {
388
+ id: 99,
389
+ name: 'new-account',
390
+ client_id: 'new-client-id-123',
391
+ client_secret: 'secret-xyz',
392
+ created_at: '2025-01-11T00:00:00Z',
393
+ };
394
+
395
+ mockDjClient.listServiceAccounts.mockResolvedValue([]);
396
+ mockDjClient.createServiceAccount.mockResolvedValue(newAccount);
397
+
398
+ renderWithContext();
399
+
400
+ await waitFor(() => {
401
+ expect(screen.getByText('Settings')).toBeInTheDocument();
402
+ });
403
+
404
+ // Open create modal
405
+ const createBtn = screen.getByRole('button', { name: /create/i });
406
+ fireEvent.click(createBtn);
407
+
408
+ await waitFor(() => {
409
+ expect(screen.getByText('Create Service Account')).toBeInTheDocument();
410
+ });
411
+
412
+ // Fill in the name input using ID
413
+ const nameInput = document.getElementById('service-account-name');
414
+ fireEvent.change(nameInput, { target: { value: 'new-account' } });
415
+
416
+ // Submit the form by clicking the submit button in the modal
417
+ const submitBtns = screen.getAllByRole('button', { name: /create/i });
418
+ // The second Create button is the submit button in the modal
419
+ fireEvent.click(submitBtns[submitBtns.length - 1]);
420
+
421
+ await waitFor(() => {
422
+ expect(mockDjClient.createServiceAccount).toHaveBeenCalledWith(
423
+ 'new-account',
424
+ );
425
+ });
426
+ });
427
+
428
+ it('does not add service account to list if creation returns no client_id', async () => {
429
+ mockDjClient.listServiceAccounts.mockResolvedValue([]);
430
+ mockDjClient.createServiceAccount.mockResolvedValue({
431
+ error: 'Name already exists',
432
+ });
433
+
434
+ renderWithContext();
435
+
436
+ await waitFor(() => {
437
+ expect(screen.getByText('Settings')).toBeInTheDocument();
438
+ });
439
+
440
+ // The service accounts section should still show empty state
441
+ expect(screen.getByText(/No service accounts yet/i)).toBeInTheDocument();
442
+ });
443
+
444
+ it('handles service account deletion by clicking delete button with confirmation', async () => {
445
+ // Mock window.confirm to return true
446
+ const originalConfirm = window.confirm;
447
+ window.confirm = jest.fn().mockReturnValue(true);
448
+
295
449
  mockDjClient.listServiceAccounts.mockResolvedValue([
296
450
  {
297
451
  id: 1,
@@ -299,6 +453,12 @@ describe('SettingsPage', () => {
299
453
  client_id: 'abc-123',
300
454
  created_at: '2024-12-01T00:00:00Z',
301
455
  },
456
+ {
457
+ id: 2,
458
+ name: 'other-pipeline',
459
+ client_id: 'def-456',
460
+ created_at: '2024-12-02T00:00:00Z',
461
+ },
302
462
  ]);
303
463
 
304
464
  mockDjClient.deleteServiceAccount.mockResolvedValue({
@@ -309,16 +469,98 @@ describe('SettingsPage', () => {
309
469
 
310
470
  await waitFor(() => {
311
471
  expect(screen.getByText('my-pipeline')).toBeInTheDocument();
472
+ expect(screen.getByText('other-pipeline')).toBeInTheDocument();
312
473
  });
313
474
 
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
- }
475
+ // Find delete button by title attribute (exact match) - first one for my-pipeline
476
+ const deleteBtn = screen.getAllByTitle('Delete service account')[0];
477
+ fireEvent.click(deleteBtn);
478
+
479
+ await waitFor(() => {
480
+ expect(window.confirm).toHaveBeenCalled();
481
+ expect(mockDjClient.deleteServiceAccount).toHaveBeenCalledWith('abc-123');
482
+ });
483
+
484
+ // Restore original confirm
485
+ window.confirm = originalConfirm;
486
+ });
487
+
488
+ it('removes service account from list after deletion', async () => {
489
+ // Mock window.confirm to return true
490
+ const originalConfirm = window.confirm;
491
+ window.confirm = jest.fn().mockReturnValue(true);
492
+
493
+ mockDjClient.listServiceAccounts.mockResolvedValue([
494
+ {
495
+ id: 1,
496
+ name: 'my-pipeline',
497
+ client_id: 'abc-123',
498
+ created_at: '2024-12-01T00:00:00Z',
499
+ },
500
+ ]);
501
+
502
+ mockDjClient.deleteServiceAccount.mockResolvedValue({
503
+ message: 'Deleted',
504
+ });
505
+
506
+ renderWithContext();
507
+
508
+ await waitFor(() => {
509
+ expect(screen.getByText('my-pipeline')).toBeInTheDocument();
510
+ });
511
+
512
+ // Find and click the delete button
513
+ const deleteBtn = screen.getByTitle('Delete service account');
514
+ fireEvent.click(deleteBtn);
515
+
516
+ await waitFor(() => {
517
+ expect(mockDjClient.deleteServiceAccount).toHaveBeenCalledWith('abc-123');
518
+ });
519
+
520
+ // After deletion, the account should be removed
521
+ await waitFor(() => {
522
+ expect(screen.queryByText('my-pipeline')).not.toBeInTheDocument();
523
+ });
524
+
525
+ // Restore original confirm
526
+ window.confirm = originalConfirm;
527
+ });
528
+
529
+ it('does not delete service account when confirmation is cancelled', async () => {
530
+ // Mock window.confirm to return false
531
+ const originalConfirm = window.confirm;
532
+ window.confirm = jest.fn().mockReturnValue(false);
533
+
534
+ mockDjClient.listServiceAccounts.mockResolvedValue([
535
+ {
536
+ id: 1,
537
+ name: 'my-pipeline',
538
+ client_id: 'abc-123',
539
+ created_at: '2024-12-01T00:00:00Z',
540
+ },
541
+ ]);
542
+
543
+ renderWithContext();
544
+
545
+ await waitFor(() => {
546
+ expect(screen.getByText('my-pipeline')).toBeInTheDocument();
547
+ });
548
+
549
+ // Find and click the delete button
550
+ const deleteBtn = screen.getByTitle('Delete service account');
551
+ fireEvent.click(deleteBtn);
552
+
553
+ // Should show confirmation
554
+ expect(window.confirm).toHaveBeenCalled();
555
+
556
+ // deleteServiceAccount should NOT be called since user cancelled
557
+ expect(mockDjClient.deleteServiceAccount).not.toHaveBeenCalled();
558
+
559
+ // Account should still be in the list
560
+ expect(screen.getByText('my-pipeline')).toBeInTheDocument();
561
+
562
+ // Restore original confirm
563
+ window.confirm = originalConfirm;
322
564
  });
323
565
 
324
566
  it('handles non-node subscription types gracefully', async () => {
@@ -352,4 +594,49 @@ describe('SettingsPage', () => {
352
594
  // getNotificationPreferences should not be called while user is loading
353
595
  expect(mockDjClient.getNotificationPreferences).not.toHaveBeenCalled();
354
596
  });
597
+
598
+ it('handles notification preferences fetch error gracefully', async () => {
599
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
600
+ mockDjClient.getNotificationPreferences.mockRejectedValue(
601
+ new Error('Failed to fetch preferences'),
602
+ );
603
+
604
+ renderWithContext();
605
+
606
+ await waitFor(() => {
607
+ // Page should still render after error
608
+ expect(screen.getByText('Settings')).toBeInTheDocument();
609
+ });
610
+
611
+ // Error should be logged
612
+ expect(consoleSpy).toHaveBeenCalled();
613
+
614
+ consoleSpy.mockRestore();
615
+ });
616
+
617
+ it('handles null notification preferences response', async () => {
618
+ mockDjClient.getNotificationPreferences.mockResolvedValue(null);
619
+
620
+ renderWithContext();
621
+
622
+ await waitFor(() => {
623
+ expect(screen.getByText('Settings')).toBeInTheDocument();
624
+ });
625
+
626
+ // Should render subscriptions section with empty list
627
+ expect(screen.getByText(/not watching any nodes yet/i)).toBeInTheDocument();
628
+ });
629
+
630
+ it('handles null service accounts response', async () => {
631
+ mockDjClient.listServiceAccounts.mockResolvedValue(null);
632
+
633
+ renderWithContext();
634
+
635
+ await waitFor(() => {
636
+ expect(screen.getByText('Settings')).toBeInTheDocument();
637
+ });
638
+
639
+ // Should render service accounts section with empty list
640
+ expect(screen.getByText(/No service accounts yet/i)).toBeInTheDocument();
641
+ });
355
642
  });
@@ -10,6 +10,9 @@ const DJ_GQL = process.env.REACT_APP_DJ_GQL
10
10
  ? process.env.REACT_APP_DJ_GQL
11
11
  : process.env.REACT_APP_DJ_URL + '/graphql';
12
12
 
13
+ // Export the base URL for components that need direct access
14
+ export const getDJUrl = () => DJ_URL;
15
+
13
16
  export const DataJunctionAPI = {
14
17
  listNodesForLanding: async function (
15
18
  namespace,
@@ -202,9 +205,9 @@ export const DataJunctionAPI = {
202
205
  const cubeMetrics = (current.cubeMetrics || []).map(m => m.name);
203
206
  const cubeDimensions = (current.cubeDimensions || []).map(d => d.name);
204
207
 
205
- // Extract druid_cube materialization if present
208
+ // Extract druid_cube materialization if present (v3 or legacy)
206
209
  const druidMat = (current.materializations || []).find(
207
- m => m.name === 'druid_cube',
210
+ m => m.name === 'druid_cube' || m.name === 'druid_cube_v3',
208
211
  );
209
212
  const cubeMaterialization = druidMat
210
213
  ? {
@@ -1014,6 +1017,27 @@ export const DataJunctionAPI = {
1014
1017
  ).json();
1015
1018
  },
1016
1019
 
1020
+ namespaceSources: async function (namespace) {
1021
+ return await (
1022
+ await fetch(`${DJ_URL}/namespaces/${namespace}/sources`, {
1023
+ credentials: 'include',
1024
+ })
1025
+ ).json();
1026
+ },
1027
+
1028
+ namespaceSourcesBulk: async function (namespaces) {
1029
+ return await (
1030
+ await fetch(`${DJ_URL}/namespaces/sources/bulk`, {
1031
+ method: 'POST',
1032
+ headers: {
1033
+ 'Content-Type': 'application/json',
1034
+ },
1035
+ body: JSON.stringify({ namespaces }),
1036
+ credentials: 'include',
1037
+ })
1038
+ ).json();
1039
+ },
1040
+
1017
1041
  sql: async function (metric_name, selection) {
1018
1042
  const params = new URLSearchParams(selection);
1019
1043
  for (const [key, value] of Object.entries(selection)) {
@@ -1107,6 +1131,7 @@ export const DataJunctionAPI = {
1107
1131
  metricSelection,
1108
1132
  dimensionSelection,
1109
1133
  filters = '',
1134
+ useMaterialized = true,
1110
1135
  ) {
1111
1136
  const params = new URLSearchParams();
1112
1137
  metricSelection.forEach(metric => params.append('metrics', metric));
@@ -1116,9 +1141,17 @@ export const DataJunctionAPI = {
1116
1141
  if (filters) {
1117
1142
  params.append('filters', filters);
1118
1143
  }
1144
+ if (useMaterialized) {
1145
+ params.append('use_materialized', 'true');
1146
+ params.append('dialect', 'druid');
1147
+ } else {
1148
+ params.append('use_materialized', 'false');
1149
+ params.append('dialect', 'spark');
1150
+ }
1119
1151
  return await (
1120
1152
  await fetch(`${DJ_URL}/sql/metrics/v3/?${params}`, {
1121
1153
  credentials: 'include',
1154
+ params: params,
1122
1155
  })
1123
1156
  ).json();
1124
1157
  },