datajunction-ui 0.0.26 → 0.0.27-alpha.0

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 +3 -3
  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 -6
  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,4 +1,10 @@
1
- import { render, screen, fireEvent } from '@testing-library/react';
1
+ import {
2
+ render,
3
+ screen,
4
+ fireEvent,
5
+ waitFor,
6
+ act,
7
+ } from '@testing-library/react';
2
8
  import { MemoryRouter } from 'react-router-dom';
3
9
  import {
4
10
  QueryOverviewPanel,
@@ -18,6 +24,13 @@ jest.mock('react-syntax-highlighter/src/styles/hljs', () => ({
18
24
  atomOneLight: {},
19
25
  }));
20
26
 
27
+ // Mock clipboard API
28
+ Object.assign(navigator, {
29
+ clipboard: {
30
+ writeText: jest.fn(),
31
+ },
32
+ });
33
+
21
34
  const mockMeasuresResult = {
22
35
  grain_groups: [
23
36
  {
@@ -121,7 +134,7 @@ describe('QueryOverviewPanel', () => {
121
134
  describe('Header', () => {
122
135
  it('renders the overview header', () => {
123
136
  renderWithRouter(<QueryOverviewPanel {...defaultProps} />);
124
- expect(screen.getByText('Generated Query Overview')).toBeInTheDocument();
137
+ expect(screen.getByText('Query Plan')).toBeInTheDocument();
125
138
  });
126
139
 
127
140
  it('shows metric and dimension counts', () => {
@@ -147,10 +160,11 @@ describe('QueryOverviewPanel', () => {
147
160
  expect(screen.getByText('stock')).toBeInTheDocument();
148
161
  });
149
162
 
150
- it('shows aggregability badge', () => {
163
+ it('shows status badge for each pre-agg', () => {
164
+ // The updated UI shows status badges instead of aggregability badges
151
165
  renderWithRouter(<QueryOverviewPanel {...defaultProps} />);
152
- expect(screen.getByText('FULL')).toBeInTheDocument();
153
- expect(screen.getByText('LIMITED')).toBeInTheDocument();
166
+ // All pre-aggs without materialization config show "Not Set" status
167
+ expect(screen.getAllByText('○ Not Set').length).toBe(2);
154
168
  });
155
169
 
156
170
  it('displays grain columns', () => {
@@ -161,7 +175,8 @@ describe('QueryOverviewPanel', () => {
161
175
 
162
176
  it('shows materialization status', () => {
163
177
  renderWithRouter(<QueryOverviewPanel {...defaultProps} />);
164
- expect(screen.getAllByText('Not materialized').length).toBe(2);
178
+ // Status shows "Not Set" when no materialization is configured
179
+ expect(screen.getAllByText('○ Not Set').length).toBe(2);
165
180
  });
166
181
  });
167
182
 
@@ -223,9 +238,10 @@ describe('QueryOverviewPanel', () => {
223
238
  expect(screen.getByText('Generated SQL')).toBeInTheDocument();
224
239
  });
225
240
 
226
- it('shows copy SQL button', () => {
241
+ it('shows SQL view toggle with Optimized and Raw options', () => {
227
242
  renderWithRouter(<QueryOverviewPanel {...defaultProps} />);
228
- expect(screen.getByText('Copy SQL')).toBeInTheDocument();
243
+ expect(screen.getByText('Optimized')).toBeInTheDocument();
244
+ expect(screen.getByText('Raw')).toBeInTheDocument();
229
245
  });
230
246
 
231
247
  it('renders SQL in syntax highlighter', () => {
@@ -234,405 +250,2747 @@ describe('QueryOverviewPanel', () => {
234
250
  expect(screen.getByText(mockMetricsResult.sql)).toBeInTheDocument();
235
251
  });
236
252
 
237
- it('copies SQL to clipboard when copy button clicked', () => {
238
- const mockClipboard = { writeText: jest.fn() };
239
- Object.assign(navigator, { clipboard: mockClipboard });
240
-
253
+ it('defaults to Optimized view', () => {
241
254
  renderWithRouter(<QueryOverviewPanel {...defaultProps} />);
242
-
243
- fireEvent.click(screen.getByText('Copy SQL'));
244
- expect(mockClipboard.writeText).toHaveBeenCalledWith(
245
- mockMetricsResult.sql,
246
- );
255
+ const optimizedBtn = screen.getByText('Optimized');
256
+ expect(optimizedBtn).toHaveClass('active');
247
257
  });
248
- });
249
- });
250
-
251
- describe('PreAggDetailsPanel', () => {
252
- const mockPreAgg = {
253
- parent_name: 'default.repair_orders',
254
- aggregability: 'FULL',
255
- grain: ['date_id', 'customer_id'],
256
- components: [
257
- {
258
- name: 'sum_revenue',
259
- expression: 'SUM(revenue)',
260
- aggregation: 'SUM',
261
- merge: 'SUM',
262
- },
263
- {
264
- name: 'count_orders',
265
- expression: 'COUNT(*)',
266
- aggregation: 'COUNT',
267
- merge: 'SUM',
268
- },
269
- ],
270
- sql: 'SELECT date_id, customer_id, SUM(revenue) FROM orders GROUP BY 1, 2',
271
- };
272
-
273
- const mockMetricFormulas = [
274
- {
275
- name: 'default.total_revenue',
276
- short_name: 'total_revenue',
277
- combiner: 'SUM(sum_revenue)',
278
- is_derived: false,
279
- components: ['sum_revenue'],
280
- },
281
- ];
282
-
283
- const onClose = jest.fn();
284
-
285
- beforeEach(() => {
286
- jest.clearAllMocks();
287
- });
288
-
289
- it('returns null when no preAgg provided', () => {
290
- const { container } = render(
291
- <PreAggDetailsPanel preAgg={null} onClose={onClose} />,
292
- );
293
- expect(container.firstChild).toBeNull();
294
- });
295
258
 
296
- it('renders pre-aggregation badge', () => {
297
- render(
298
- <PreAggDetailsPanel
299
- preAgg={mockPreAgg}
300
- metricFormulas={mockMetricFormulas}
301
- onClose={onClose}
302
- />,
303
- );
304
- expect(screen.getByText('Pre-aggregation')).toBeInTheDocument();
305
- });
259
+ it('fetches and displays raw SQL when Raw tab is clicked', async () => {
260
+ const mockRawSql =
261
+ 'SELECT * FROM raw_table WHERE date_id = 123 GROUP BY 1';
262
+ const onFetchRawSql = jest.fn().mockResolvedValue(mockRawSql);
306
263
 
307
- it('displays source name', () => {
308
- render(
309
- <PreAggDetailsPanel
310
- preAgg={mockPreAgg}
311
- metricFormulas={mockMetricFormulas}
312
- onClose={onClose}
313
- />,
314
- );
315
- expect(screen.getByText('repair_orders')).toBeInTheDocument();
316
- expect(screen.getByText('default.repair_orders')).toBeInTheDocument();
317
- });
264
+ renderWithRouter(
265
+ <QueryOverviewPanel {...defaultProps} onFetchRawSql={onFetchRawSql} />,
266
+ );
318
267
 
319
- it('displays close button', () => {
320
- render(
321
- <PreAggDetailsPanel
322
- preAgg={mockPreAgg}
323
- metricFormulas={mockMetricFormulas}
324
- onClose={onClose}
325
- />,
326
- );
327
- expect(screen.getByTitle('Close panel')).toBeInTheDocument();
328
- });
268
+ // Click Raw tab
269
+ const rawBtn = screen.getByText('Raw');
270
+ await act(async () => {
271
+ fireEvent.click(rawBtn);
272
+ });
329
273
 
330
- it('calls onClose when close button clicked', () => {
331
- render(
332
- <PreAggDetailsPanel
333
- preAgg={mockPreAgg}
334
- metricFormulas={mockMetricFormulas}
335
- onClose={onClose}
336
- />,
337
- );
338
- fireEvent.click(screen.getByTitle('Close panel'));
339
- expect(onClose).toHaveBeenCalled();
274
+ await waitFor(() => {
275
+ expect(onFetchRawSql).toHaveBeenCalled();
276
+ });
277
+ });
340
278
  });
341
279
 
342
- describe('Grain Section', () => {
343
- it('displays grain section', () => {
344
- render(
345
- <PreAggDetailsPanel
346
- preAgg={mockPreAgg}
347
- metricFormulas={mockMetricFormulas}
348
- onClose={onClose}
280
+ describe('Materialization CTA', () => {
281
+ it('shows Configure button when not materialized', () => {
282
+ renderWithRouter(
283
+ <QueryOverviewPanel
284
+ {...defaultProps}
285
+ onPlanMaterialization={jest.fn()}
349
286
  />,
350
287
  );
351
- expect(screen.getByText('Grain (GROUP BY)')).toBeInTheDocument();
288
+ expect(screen.getByText('Configure')).toBeInTheDocument();
352
289
  });
353
290
 
354
- it('shows grain columns as pills', () => {
355
- render(
356
- <PreAggDetailsPanel
357
- preAgg={mockPreAgg}
358
- metricFormulas={mockMetricFormulas}
359
- onClose={onClose}
291
+ it('shows CTA content with Ready to materialize text', () => {
292
+ renderWithRouter(
293
+ <QueryOverviewPanel
294
+ {...defaultProps}
295
+ onPlanMaterialization={jest.fn()}
360
296
  />,
361
297
  );
362
- expect(screen.getByText('date_id')).toBeInTheDocument();
363
- expect(screen.getByText('customer_id')).toBeInTheDocument();
298
+ expect(screen.getByText('Ready to materialize?')).toBeInTheDocument();
364
299
  });
365
300
 
366
- it('shows empty message when no grain', () => {
367
- const noGrainPreAgg = { ...mockPreAgg, grain: [] };
368
- render(
369
- <PreAggDetailsPanel
370
- preAgg={noGrainPreAgg}
371
- metricFormulas={mockMetricFormulas}
372
- onClose={onClose}
301
+ it('opens configuration form when Configure button is clicked', async () => {
302
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
303
+ columns: [{ name: 'date_id', type: 'int' }],
304
+ temporalPartitions: [],
305
+ });
306
+
307
+ renderWithRouter(
308
+ <QueryOverviewPanel
309
+ {...defaultProps}
310
+ onPlanMaterialization={jest.fn()}
311
+ onFetchNodePartitions={onFetchNodePartitions}
373
312
  />,
374
313
  );
375
- expect(screen.getByText('No grain columns')).toBeInTheDocument();
314
+
315
+ const configureBtn = screen.getByText('Configure');
316
+ await act(async () => {
317
+ fireEvent.click(configureBtn);
318
+ });
319
+
320
+ // Should show configuration form
321
+ await waitFor(() => {
322
+ expect(
323
+ screen.getByText('Configure Materialization'),
324
+ ).toBeInTheDocument();
325
+ });
376
326
  });
377
327
  });
378
328
 
379
- describe('Related Metrics Section', () => {
380
- it('displays metrics using this section', () => {
381
- render(
382
- <PreAggDetailsPanel
383
- preAgg={mockPreAgg}
384
- metricFormulas={mockMetricFormulas}
385
- onClose={onClose}
329
+ describe('Materialization Configuration Form', () => {
330
+ const setupConfigForm = async () => {
331
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
332
+ columns: [
333
+ { name: 'date_id', type: 'int' },
334
+ { name: 'customer_id', type: 'int' },
335
+ ],
336
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
337
+ });
338
+
339
+ const onPlanMaterialization = jest.fn().mockResolvedValue({});
340
+
341
+ renderWithRouter(
342
+ <QueryOverviewPanel
343
+ {...defaultProps}
344
+ onPlanMaterialization={onPlanMaterialization}
345
+ onFetchNodePartitions={onFetchNodePartitions}
386
346
  />,
387
347
  );
388
- expect(screen.getByText('Metrics Using This')).toBeInTheDocument();
348
+
349
+ const configureBtn = screen.getByText('Configure');
350
+ await act(async () => {
351
+ fireEvent.click(configureBtn);
352
+ });
353
+
354
+ await waitFor(() => {
355
+ expect(
356
+ screen.getByText('Configure Materialization'),
357
+ ).toBeInTheDocument();
358
+ });
359
+
360
+ return { onPlanMaterialization, onFetchNodePartitions };
361
+ };
362
+
363
+ it('shows strategy options (Full and Incremental)', async () => {
364
+ await setupConfigForm();
365
+
366
+ expect(screen.getByText('Strategy')).toBeInTheDocument();
367
+ expect(screen.getByText('Full')).toBeInTheDocument();
368
+ expect(screen.getByText('Incremental')).toBeInTheDocument();
389
369
  });
390
370
 
391
- it('shows related metrics', () => {
392
- render(
393
- <PreAggDetailsPanel
394
- preAgg={mockPreAgg}
395
- metricFormulas={mockMetricFormulas}
396
- onClose={onClose}
397
- />,
398
- );
399
- expect(screen.getByText('total_revenue')).toBeInTheDocument();
371
+ it('allows switching strategy to Full', async () => {
372
+ await setupConfigForm();
373
+
374
+ const fullRadio = screen.getByLabelText('Full');
375
+ fireEvent.click(fullRadio);
376
+
377
+ expect(fullRadio).toBeChecked();
378
+ });
379
+
380
+ it('shows close button on configuration form', async () => {
381
+ await setupConfigForm();
382
+
383
+ // Close button is the × in the header
384
+ const closeButtons = screen.getAllByText('×');
385
+ expect(closeButtons.length).toBeGreaterThan(0);
386
+ });
387
+
388
+ it('closes configuration form when close button is clicked', async () => {
389
+ await setupConfigForm();
390
+
391
+ // Find and click the close button
392
+ const closeButton = screen.getByRole('button', {
393
+ name: /×/,
394
+ });
395
+ fireEvent.click(closeButton);
396
+
397
+ // Configuration form should be closed
398
+ await waitFor(() => {
399
+ expect(
400
+ screen.queryByText('Configure Materialization'),
401
+ ).not.toBeInTheDocument();
402
+ });
400
403
  });
401
404
  });
402
405
 
403
- describe('Components Table', () => {
404
- it('displays components section', () => {
405
- render(
406
- <PreAggDetailsPanel
407
- preAgg={mockPreAgg}
408
- metricFormulas={mockMetricFormulas}
409
- onClose={onClose}
406
+ describe('Error Display', () => {
407
+ it('shows materialization error when present', () => {
408
+ renderWithRouter(
409
+ <QueryOverviewPanel
410
+ {...defaultProps}
411
+ onPlanMaterialization={jest.fn()}
412
+ materializationError="Failed to plan materialization"
413
+ onClearError={jest.fn()}
410
414
  />,
411
415
  );
412
- expect(screen.getByText('Components (2)')).toBeInTheDocument();
416
+
417
+ expect(
418
+ screen.getByText('Failed to plan materialization'),
419
+ ).toBeInTheDocument();
413
420
  });
414
421
 
415
- it('shows component names', () => {
416
- render(
417
- <PreAggDetailsPanel
418
- preAgg={mockPreAgg}
419
- metricFormulas={mockMetricFormulas}
420
- onClose={onClose}
422
+ it('calls onClearError when dismiss button is clicked', () => {
423
+ const onClearError = jest.fn();
424
+
425
+ renderWithRouter(
426
+ <QueryOverviewPanel
427
+ {...defaultProps}
428
+ onPlanMaterialization={jest.fn()}
429
+ materializationError="Failed to plan materialization"
430
+ onClearError={onClearError}
421
431
  />,
422
432
  );
423
- expect(screen.getByText('sum_revenue')).toBeInTheDocument();
424
- expect(screen.getByText('count_orders')).toBeInTheDocument();
433
+
434
+ // Find dismiss button (aria-label="Dismiss error")
435
+ const dismissBtn = screen.getByLabelText('Dismiss error');
436
+ fireEvent.click(dismissBtn);
437
+
438
+ expect(onClearError).toHaveBeenCalled();
425
439
  });
440
+ });
426
441
 
427
- it('shows component expressions', () => {
428
- render(
429
- <PreAggDetailsPanel
430
- preAgg={mockPreAgg}
431
- metricFormulas={mockMetricFormulas}
432
- onClose={onClose}
442
+ describe('Workflow URLs Display', () => {
443
+ it('displays workflow URLs when provided', () => {
444
+ renderWithRouter(
445
+ <QueryOverviewPanel
446
+ {...defaultProps}
447
+ onPlanMaterialization={jest.fn()}
448
+ workflowUrls={['http://workflow.example.com/job/123']}
449
+ onClearWorkflowUrls={jest.fn()}
433
450
  />,
434
451
  );
435
- expect(screen.getByText('SUM(revenue)')).toBeInTheDocument();
436
- expect(screen.getByText('COUNT(*)')).toBeInTheDocument();
452
+
453
+ // The workflow URL or a related indicator should be shown
454
+ expect(screen.getByText(/workflow/i)).toBeInTheDocument();
437
455
  });
456
+ });
438
457
 
439
- it('shows aggregation functions', () => {
440
- render(
441
- <PreAggDetailsPanel
442
- preAgg={mockPreAgg}
443
- metricFormulas={mockMetricFormulas}
444
- onClose={onClose}
458
+ describe('Planned Pre-aggregations', () => {
459
+ it('shows status badges for planned pre-aggs', () => {
460
+ const plannedPreaggs = {
461
+ 'default.repair_orders|customer_id,date_id': {
462
+ id: 1,
463
+ node_name: 'default.repair_orders',
464
+ grain_columns: ['date_id', 'customer_id'],
465
+ workflow_status: 'active',
466
+ workflow_urls: ['http://example.com'],
467
+ },
468
+ };
469
+
470
+ renderWithRouter(
471
+ <QueryOverviewPanel
472
+ {...defaultProps}
473
+ plannedPreaggs={plannedPreaggs}
474
+ onPlanMaterialization={jest.fn()}
445
475
  />,
446
476
  );
447
- expect(screen.getAllByText('SUM').length).toBeGreaterThan(0);
448
- expect(screen.getByText('COUNT')).toBeInTheDocument();
477
+
478
+ // Should show some indicator of planned state
479
+ // The exact text depends on the status logic
480
+ expect(
481
+ screen.queryByText('Not Set') || screen.queryByText('Workflow Active'),
482
+ ).toBeTruthy();
449
483
  });
450
484
  });
451
485
 
452
- describe('SQL Section', () => {
453
- it('displays SQL section when sql is present', () => {
454
- render(
455
- <PreAggDetailsPanel
456
- preAgg={mockPreAgg}
457
- metricFormulas={mockMetricFormulas}
458
- onClose={onClose}
486
+ describe('Loaded Cube Display', () => {
487
+ it('shows cube name banner when loadedCubeName is provided', () => {
488
+ renderWithRouter(
489
+ <QueryOverviewPanel
490
+ {...defaultProps}
491
+ loadedCubeName="default.test_cube"
492
+ onPlanMaterialization={jest.fn()}
459
493
  />,
460
494
  );
461
- expect(screen.getByText('Pre-Aggregation SQL')).toBeInTheDocument();
495
+
496
+ // Should show some indication of loaded cube
497
+ // The exact text depends on the implementation
498
+ expect(screen.getByText(/Query Plan/)).toBeInTheDocument();
462
499
  });
500
+ });
463
501
 
464
- it('shows copy button', () => {
465
- render(
466
- <PreAggDetailsPanel
467
- preAgg={mockPreAgg}
468
- metricFormulas={mockMetricFormulas}
469
- onClose={onClose}
502
+ describe('Partition Setup Form', () => {
503
+ const setupPartitionTest = async (partitionResults = {}) => {
504
+ const mockNodePartitions = {
505
+ 'default.repair_orders': {
506
+ columns: [
507
+ { name: 'date_id', type: 'int' },
508
+ { name: 'customer_id', type: 'int' },
509
+ { name: 'dateint', type: 'int' },
510
+ ],
511
+ temporalPartitions: [],
512
+ ...partitionResults,
513
+ },
514
+ };
515
+
516
+ const onFetchNodePartitions = jest.fn().mockImplementation(nodeName =>
517
+ Promise.resolve(
518
+ mockNodePartitions[nodeName] || {
519
+ columns: [],
520
+ temporalPartitions: [],
521
+ },
522
+ ),
523
+ );
524
+
525
+ const onSetPartition = jest.fn().mockResolvedValue({ status: 200 });
526
+ const onPlanMaterialization = jest.fn().mockResolvedValue({});
527
+
528
+ renderWithRouter(
529
+ <QueryOverviewPanel
530
+ {...defaultProps}
531
+ onPlanMaterialization={onPlanMaterialization}
532
+ onFetchNodePartitions={onFetchNodePartitions}
533
+ onSetPartition={onSetPartition}
470
534
  />,
471
535
  );
472
- expect(screen.getByText('Copy SQL')).toBeInTheDocument();
536
+
537
+ // Open config form
538
+ const configureBtn = screen.getByText('Configure');
539
+ await act(async () => {
540
+ fireEvent.click(configureBtn);
541
+ });
542
+
543
+ await waitFor(() => {
544
+ expect(
545
+ screen.getByText('Configure Materialization'),
546
+ ).toBeInTheDocument();
547
+ });
548
+
549
+ return { onFetchNodePartitions, onSetPartition, onPlanMaterialization };
550
+ };
551
+
552
+ it('shows partition setup form when incremental is selected and no temporal partitions', async () => {
553
+ await setupPartitionTest();
554
+
555
+ // Select incremental strategy
556
+ const incrementalRadio = screen.getByLabelText('Incremental');
557
+ await act(async () => {
558
+ fireEvent.click(incrementalRadio);
559
+ });
560
+
561
+ // Should show partition setup header
562
+ await waitFor(() => {
563
+ expect(
564
+ screen.getByText('Set up temporal partitions for incremental builds'),
565
+ ).toBeInTheDocument();
566
+ });
473
567
  });
474
- });
475
- });
476
568
 
477
- describe('MetricDetailsPanel', () => {
478
- const mockMetric = {
479
- name: 'default.avg_repair_price',
480
- short_name: 'avg_repair_price',
481
- combiner: 'SUM(sum_revenue) / SUM(count_orders)',
482
- is_derived: true,
483
- components: ['sum_revenue', 'count_orders'],
484
- };
569
+ it('shows column dropdown with date-like columns prioritized', async () => {
570
+ await setupPartitionTest();
485
571
 
486
- const mockGrainGroups = [
487
- {
488
- parent_name: 'default.repair_orders',
489
- components: [{ name: 'sum_revenue' }, { name: 'count_orders' }],
490
- },
491
- ];
572
+ // Select incremental strategy
573
+ const incrementalRadio = screen.getByLabelText('Incremental');
574
+ await act(async () => {
575
+ fireEvent.click(incrementalRadio);
576
+ });
492
577
 
493
- const onClose = jest.fn();
578
+ await waitFor(() => {
579
+ // Multiple Column labels may exist (one per node)
580
+ const columnLabels = screen.getAllByText('Column');
581
+ expect(columnLabels.length).toBeGreaterThan(0);
582
+ });
494
583
 
495
- beforeEach(() => {
496
- jest.clearAllMocks();
497
- });
584
+ // Should have date-like columns with star markers
585
+ expect(screen.getByText(/dateint.*★/)).toBeInTheDocument();
586
+ expect(screen.getByText(/date_id.*★/)).toBeInTheDocument();
587
+ });
498
588
 
499
- it('returns null when no metric provided', () => {
500
- const { container } = render(
501
- <MetricDetailsPanel metric={null} onClose={onClose} />,
502
- );
503
- expect(container.firstChild).toBeNull();
504
- });
589
+ it('shows granularity dropdown with Day, Hour, Month options', async () => {
590
+ await setupPartitionTest();
505
591
 
506
- it('renders metric badge', () => {
507
- render(
508
- <MetricDetailsPanel
509
- metric={mockMetric}
510
- grainGroups={mockGrainGroups}
511
- onClose={onClose}
512
- />,
513
- );
514
- expect(screen.getByText('Derived Metric')).toBeInTheDocument();
515
- });
592
+ const incrementalRadio = screen.getByLabelText('Incremental');
593
+ await act(async () => {
594
+ fireEvent.click(incrementalRadio);
595
+ });
516
596
 
517
- it('renders regular metric badge for non-derived', () => {
518
- const nonDerivedMetric = { ...mockMetric, is_derived: false };
519
- render(
520
- <MetricDetailsPanel
521
- metric={nonDerivedMetric}
522
- grainGroups={mockGrainGroups}
523
- onClose={onClose}
524
- />,
525
- );
526
- expect(screen.getByText('Metric')).toBeInTheDocument();
597
+ await waitFor(() => {
598
+ const granularityLabels = screen.getAllByText('Granularity');
599
+ expect(granularityLabels.length).toBeGreaterThan(0);
600
+ });
601
+
602
+ // Check granularity options exist - use getAllByRole since there may be multiple
603
+ const granularitySelects = screen.getAllByRole('combobox');
604
+ // Find the one with Day selected
605
+ const daySelect = granularitySelects.find(
606
+ select => select.value === 'day',
607
+ );
608
+ expect(daySelect).toBeInTheDocument();
609
+ });
610
+
611
+ it('shows format input field with placeholder', async () => {
612
+ await setupPartitionTest();
613
+
614
+ const incrementalRadio = screen.getByLabelText('Incremental');
615
+ await act(async () => {
616
+ fireEvent.click(incrementalRadio);
617
+ });
618
+
619
+ await waitFor(() => {
620
+ const formatLabels = screen.getAllByText('Format');
621
+ expect(formatLabels.length).toBeGreaterThan(0);
622
+ });
623
+
624
+ // Format input should have placeholder - there may be multiple
625
+ const formatInputs = screen.getAllByPlaceholderText('yyyyMMdd');
626
+ expect(formatInputs.length).toBeGreaterThan(0);
627
+ });
628
+
629
+ it('disables Set button when no column is selected', async () => {
630
+ await setupPartitionTest();
631
+
632
+ const incrementalRadio = screen.getByLabelText('Incremental');
633
+ await act(async () => {
634
+ fireEvent.click(incrementalRadio);
635
+ });
636
+
637
+ await waitFor(() => {
638
+ // Wait for partition setup form to show
639
+ expect(
640
+ screen.getByText('Set up temporal partitions for incremental builds'),
641
+ ).toBeInTheDocument();
642
+ });
643
+
644
+ // Set button should be disabled when no column selected (initially empty)
645
+ const setBtns = screen.getAllByText('Set');
646
+ // At least one Set button should be disabled
647
+ const hasDisabledBtn = setBtns.some(btn => btn.disabled);
648
+ expect(hasDisabledBtn).toBe(true);
649
+ });
650
+
651
+ it('enables Set button when column is selected', async () => {
652
+ await setupPartitionTest();
653
+
654
+ const incrementalRadio = screen.getByLabelText('Incremental');
655
+ await act(async () => {
656
+ fireEvent.click(incrementalRadio);
657
+ });
658
+
659
+ await waitFor(() => {
660
+ const columnLabels = screen.getAllByText('Column');
661
+ expect(columnLabels.length).toBeGreaterThan(0);
662
+ });
663
+
664
+ // Select a column in the first dropdown
665
+ const columnSelects = screen.getAllByRole('combobox');
666
+ await act(async () => {
667
+ fireEvent.change(columnSelects[0], { target: { value: 'date_id' } });
668
+ });
669
+
670
+ // Set button should now be enabled
671
+ const setBtns = screen.getAllByText('Set');
672
+ expect(setBtns[0]).not.toBeDisabled();
673
+ });
674
+
675
+ it('calls onSetPartition when Set button is clicked', async () => {
676
+ const { onSetPartition } = await setupPartitionTest();
677
+
678
+ const incrementalRadio = screen.getByLabelText('Incremental');
679
+ await act(async () => {
680
+ fireEvent.click(incrementalRadio);
681
+ });
682
+
683
+ await waitFor(() => {
684
+ const columnLabels = screen.getAllByText('Column');
685
+ expect(columnLabels.length).toBeGreaterThan(0);
686
+ });
687
+
688
+ // Select a column
689
+ const columnSelects = screen.getAllByRole('combobox');
690
+ await act(async () => {
691
+ fireEvent.change(columnSelects[0], { target: { value: 'date_id' } });
692
+ });
693
+
694
+ // Click Set
695
+ const setBtns = screen.getAllByText('Set');
696
+ await act(async () => {
697
+ fireEvent.click(setBtns[0]);
698
+ });
699
+
700
+ await waitFor(() => {
701
+ expect(onSetPartition).toHaveBeenCalledWith(
702
+ 'default.repair_orders',
703
+ 'date_id',
704
+ 'temporal',
705
+ 'yyyyMMdd',
706
+ 'day',
707
+ );
708
+ });
709
+ });
710
+
711
+ it('shows success state when partition is already configured', async () => {
712
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
713
+ columns: [{ name: 'date_id', type: 'int' }],
714
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
715
+ });
716
+
717
+ renderWithRouter(
718
+ <QueryOverviewPanel
719
+ {...defaultProps}
720
+ onPlanMaterialization={jest.fn()}
721
+ onFetchNodePartitions={onFetchNodePartitions}
722
+ />,
723
+ );
724
+
725
+ // Open config form
726
+ const configureBtn = screen.getByText('Configure');
727
+ await act(async () => {
728
+ fireEvent.click(configureBtn);
729
+ });
730
+
731
+ await waitFor(() => {
732
+ expect(
733
+ screen.getByText('Configure Materialization'),
734
+ ).toBeInTheDocument();
735
+ });
736
+
737
+ // When partition already exists, the incremental strategy should default to enabled
738
+ // and partition setup form should NOT be shown
739
+ await waitFor(() => {
740
+ // The incremental option should show the partition name badge
741
+ // If partition setup prompt is NOT shown, it means partition is configured
742
+ expect(
743
+ screen.queryByText(
744
+ 'Set up temporal partitions for incremental builds',
745
+ ),
746
+ ).not.toBeInTheDocument();
747
+ });
748
+ });
527
749
  });
528
750
 
529
- it('displays metric name', () => {
530
- render(
531
- <MetricDetailsPanel
532
- metric={mockMetric}
533
- grainGroups={mockGrainGroups}
534
- onClose={onClose}
535
- />,
536
- );
537
- expect(screen.getByText('avg_repair_price')).toBeInTheDocument();
538
- expect(screen.getByText('default.avg_repair_price')).toBeInTheDocument();
751
+ describe('Backfill Date Range in Config Form', () => {
752
+ const setupBackfillTest = async () => {
753
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
754
+ columns: [{ name: 'date_id', type: 'int' }],
755
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
756
+ });
757
+
758
+ const onPlanMaterialization = jest.fn().mockResolvedValue({});
759
+
760
+ renderWithRouter(
761
+ <QueryOverviewPanel
762
+ {...defaultProps}
763
+ onPlanMaterialization={onPlanMaterialization}
764
+ onFetchNodePartitions={onFetchNodePartitions}
765
+ />,
766
+ );
767
+
768
+ // Open config form
769
+ const configureBtn = screen.getByText('Configure');
770
+ await act(async () => {
771
+ fireEvent.click(configureBtn);
772
+ });
773
+
774
+ await waitFor(() => {
775
+ expect(
776
+ screen.getByText('Configure Materialization'),
777
+ ).toBeInTheDocument();
778
+ });
779
+
780
+ return { onPlanMaterialization };
781
+ };
782
+
783
+ it('shows "Run initial backfill" checkbox for incremental strategy', async () => {
784
+ await setupBackfillTest();
785
+
786
+ // Incremental should be the default when partition exists
787
+ await waitFor(() => {
788
+ expect(screen.getByText('Run initial backfill')).toBeInTheDocument();
789
+ });
790
+ });
791
+
792
+ it('shows backfill date range when checkbox is checked', async () => {
793
+ await setupBackfillTest();
794
+
795
+ await waitFor(() => {
796
+ expect(screen.getByText('Run initial backfill')).toBeInTheDocument();
797
+ });
798
+
799
+ // Checkbox should be checked by default
800
+ const checkbox = screen.getByRole('checkbox', {
801
+ name: /Run initial backfill/i,
802
+ });
803
+ expect(checkbox).toBeChecked();
804
+
805
+ // Date range should be visible
806
+ expect(screen.getByText('Backfill Date Range')).toBeInTheDocument();
807
+ expect(screen.getByText('From')).toBeInTheDocument();
808
+ expect(screen.getByText('To')).toBeInTheDocument();
809
+ });
810
+
811
+ it('hides backfill date range when checkbox is unchecked', async () => {
812
+ await setupBackfillTest();
813
+
814
+ await waitFor(() => {
815
+ expect(screen.getByText('Run initial backfill')).toBeInTheDocument();
816
+ });
817
+
818
+ // Uncheck the checkbox
819
+ const checkbox = screen.getByRole('checkbox', {
820
+ name: /Run initial backfill/i,
821
+ });
822
+ await act(async () => {
823
+ fireEvent.click(checkbox);
824
+ });
825
+
826
+ // Date range should be hidden
827
+ expect(screen.queryByText('Backfill Date Range')).not.toBeInTheDocument();
828
+ });
829
+
830
+ it('shows "Today" and "Specific date" options for end date', async () => {
831
+ await setupBackfillTest();
832
+
833
+ await waitFor(() => {
834
+ expect(screen.getByText('Backfill Date Range')).toBeInTheDocument();
835
+ });
836
+
837
+ // Find the select for backfill "To" field
838
+ // There are multiple selects, we need the one with 'today' value
839
+ const selects = screen.getAllByRole('combobox');
840
+ const toSelect = selects.find(s => s.value === 'today');
841
+ expect(toSelect).toBeInTheDocument();
842
+
843
+ // Change to specific date
844
+ await act(async () => {
845
+ fireEvent.change(toSelect, { target: { value: 'specific' } });
846
+ });
847
+
848
+ // Should show an additional date input
849
+ await waitFor(() => {
850
+ // There should be date inputs visible
851
+ const dateInputs = document.querySelectorAll('input[type="date"]');
852
+ expect(dateInputs.length).toBeGreaterThanOrEqual(2);
853
+ });
854
+ });
855
+
856
+ it('hides backfill options for full strategy', async () => {
857
+ await setupBackfillTest();
858
+
859
+ // Switch to full strategy
860
+ const fullRadio = screen.getByLabelText('Full');
861
+ await act(async () => {
862
+ fireEvent.click(fullRadio);
863
+ });
864
+
865
+ // Backfill checkbox should not be visible
866
+ expect(
867
+ screen.queryByText('Run initial backfill'),
868
+ ).not.toBeInTheDocument();
869
+ });
539
870
  });
540
871
 
541
- it('calls onClose when close button clicked', () => {
542
- render(
543
- <MetricDetailsPanel
544
- metric={mockMetric}
545
- grainGroups={mockGrainGroups}
546
- onClose={onClose}
547
- />,
548
- );
549
- fireEvent.click(screen.getByTitle('Close panel'));
550
- expect(onClose).toHaveBeenCalled();
872
+ describe('Schedule Configuration', () => {
873
+ const setupScheduleTest = async () => {
874
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
875
+ columns: [{ name: 'date_id', type: 'int' }],
876
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
877
+ });
878
+
879
+ renderWithRouter(
880
+ <QueryOverviewPanel
881
+ {...defaultProps}
882
+ onPlanMaterialization={jest.fn()}
883
+ onFetchNodePartitions={onFetchNodePartitions}
884
+ />,
885
+ );
886
+
887
+ // Open config form
888
+ const configureBtn = screen.getByText('Configure');
889
+ await act(async () => {
890
+ fireEvent.click(configureBtn);
891
+ });
892
+
893
+ await waitFor(() => {
894
+ expect(
895
+ screen.getByText('Configure Materialization'),
896
+ ).toBeInTheDocument();
897
+ });
898
+ };
899
+
900
+ it('shows schedule dropdown with recommended, hourly, and custom options', async () => {
901
+ await setupScheduleTest();
902
+
903
+ expect(screen.getByText('Schedule')).toBeInTheDocument();
904
+
905
+ // The schedule select should have options - find one with 'auto' value
906
+ const selects = screen.getAllByRole('combobox');
907
+ const scheduleSelect = selects.find(s => s.value === 'auto');
908
+ expect(scheduleSelect).toBeInTheDocument();
909
+ });
910
+
911
+ it('shows custom cron input when custom is selected', async () => {
912
+ await setupScheduleTest();
913
+
914
+ // Find the schedule select (has 'auto' value initially)
915
+ const selects = screen.getAllByRole('combobox');
916
+ const scheduleSelect = selects.find(s => s.value === 'auto');
917
+
918
+ await act(async () => {
919
+ fireEvent.change(scheduleSelect, { target: { value: 'custom' } });
920
+ });
921
+
922
+ // Custom input should appear
923
+ await waitFor(() => {
924
+ expect(screen.getByPlaceholderText('0 6 * * *')).toBeInTheDocument();
925
+ });
926
+ });
551
927
  });
552
928
 
553
- describe('Formula Section', () => {
554
- it('displays combiner formula section', () => {
555
- render(
556
- <MetricDetailsPanel
557
- metric={mockMetric}
558
- grainGroups={mockGrainGroups}
559
- onClose={onClose}
929
+ describe('Lookback Window', () => {
930
+ it('shows lookback window input for incremental strategy', async () => {
931
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
932
+ columns: [{ name: 'date_id', type: 'int' }],
933
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
934
+ });
935
+
936
+ renderWithRouter(
937
+ <QueryOverviewPanel
938
+ {...defaultProps}
939
+ onPlanMaterialization={jest.fn()}
940
+ onFetchNodePartitions={onFetchNodePartitions}
560
941
  />,
561
942
  );
562
- expect(screen.getByText('Combiner Formula')).toBeInTheDocument();
943
+
944
+ // Open config form
945
+ const configureBtn = screen.getByText('Configure');
946
+ await act(async () => {
947
+ fireEvent.click(configureBtn);
948
+ });
949
+
950
+ await waitFor(() => {
951
+ expect(screen.getByText('Lookback Window')).toBeInTheDocument();
952
+ });
953
+
954
+ // Should have placeholder
955
+ expect(screen.getByPlaceholderText('1 day')).toBeInTheDocument();
956
+
957
+ // Should have hint text
958
+ expect(screen.getByText(/For late-arriving data/)).toBeInTheDocument();
563
959
  });
564
960
 
565
- it('shows the formula', () => {
566
- render(
567
- <MetricDetailsPanel
568
- metric={mockMetric}
569
- grainGroups={mockGrainGroups}
570
- onClose={onClose}
961
+ it('hides lookback window for full strategy', async () => {
962
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
963
+ columns: [{ name: 'date_id', type: 'int' }],
964
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
965
+ });
966
+
967
+ renderWithRouter(
968
+ <QueryOverviewPanel
969
+ {...defaultProps}
970
+ onPlanMaterialization={jest.fn()}
971
+ onFetchNodePartitions={onFetchNodePartitions}
571
972
  />,
572
973
  );
573
- expect(
574
- screen.getByText('SUM(sum_revenue) / SUM(count_orders)'),
575
- ).toBeInTheDocument();
974
+
975
+ // Open config form
976
+ const configureBtn = screen.getByText('Configure');
977
+ await act(async () => {
978
+ fireEvent.click(configureBtn);
979
+ });
980
+
981
+ await waitFor(() => {
982
+ expect(
983
+ screen.getByText('Configure Materialization'),
984
+ ).toBeInTheDocument();
985
+ });
986
+
987
+ // Switch to full strategy
988
+ const fullRadio = screen.getByLabelText('Full');
989
+ await act(async () => {
990
+ fireEvent.click(fullRadio);
991
+ });
992
+
993
+ // Lookback window should be hidden
994
+ expect(screen.queryByText('Lookback Window')).not.toBeInTheDocument();
576
995
  });
577
996
  });
578
997
 
579
- describe('Components Section', () => {
580
- it('displays components used section', () => {
581
- render(
582
- <MetricDetailsPanel
583
- metric={mockMetric}
584
- grainGroups={mockGrainGroups}
585
- onClose={onClose}
998
+ describe('Druid Cube Configuration', () => {
999
+ it('shows Druid cube checkbox', async () => {
1000
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
1001
+ columns: [{ name: 'date_id', type: 'int' }],
1002
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
1003
+ });
1004
+
1005
+ renderWithRouter(
1006
+ <QueryOverviewPanel
1007
+ {...defaultProps}
1008
+ onPlanMaterialization={jest.fn()}
1009
+ onFetchNodePartitions={onFetchNodePartitions}
586
1010
  />,
587
1011
  );
588
- expect(screen.getByText('Components Used')).toBeInTheDocument();
1012
+
1013
+ // Open config form
1014
+ const configureBtn = screen.getByText('Configure');
1015
+ await act(async () => {
1016
+ fireEvent.click(configureBtn);
1017
+ });
1018
+
1019
+ await waitFor(() => {
1020
+ expect(
1021
+ screen.getByText('Enable Druid cube materialization'),
1022
+ ).toBeInTheDocument();
1023
+ });
589
1024
  });
590
1025
 
591
- it('shows component tags', () => {
592
- render(
593
- <MetricDetailsPanel
594
- metric={mockMetric}
595
- grainGroups={mockGrainGroups}
596
- onClose={onClose}
1026
+ it('shows cube name inputs when Druid is enabled and no cube loaded', async () => {
1027
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
1028
+ columns: [{ name: 'date_id', type: 'int' }],
1029
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
1030
+ });
1031
+
1032
+ renderWithRouter(
1033
+ <QueryOverviewPanel
1034
+ {...defaultProps}
1035
+ onPlanMaterialization={jest.fn()}
1036
+ onFetchNodePartitions={onFetchNodePartitions}
597
1037
  />,
598
1038
  );
599
- expect(screen.getByText('sum_revenue')).toBeInTheDocument();
600
- expect(screen.getByText('count_orders')).toBeInTheDocument();
1039
+
1040
+ // Open config form
1041
+ const configureBtn = screen.getByText('Configure');
1042
+ await act(async () => {
1043
+ fireEvent.click(configureBtn);
1044
+ });
1045
+
1046
+ await waitFor(() => {
1047
+ expect(screen.getByText('Cube Name')).toBeInTheDocument();
1048
+ });
1049
+
1050
+ // Should show namespace and name inputs
1051
+ expect(screen.getByPlaceholderText('users.myname')).toBeInTheDocument();
1052
+ expect(screen.getByPlaceholderText('my_cube')).toBeInTheDocument();
1053
+ });
1054
+
1055
+ it('shows preview of pre-aggregations to combine', async () => {
1056
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
1057
+ columns: [{ name: 'date_id', type: 'int' }],
1058
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
1059
+ });
1060
+
1061
+ renderWithRouter(
1062
+ <QueryOverviewPanel
1063
+ {...defaultProps}
1064
+ onPlanMaterialization={jest.fn()}
1065
+ onFetchNodePartitions={onFetchNodePartitions}
1066
+ />,
1067
+ );
1068
+
1069
+ // Open config form
1070
+ const configureBtn = screen.getByText('Configure');
1071
+ await act(async () => {
1072
+ fireEvent.click(configureBtn);
1073
+ });
1074
+
1075
+ await waitFor(() => {
1076
+ expect(
1077
+ screen.getByText('Pre-aggregations to combine:'),
1078
+ ).toBeInTheDocument();
1079
+ });
1080
+
1081
+ // Should show the pre-agg source (may appear multiple times)
1082
+ const repairOrdersElements = screen.getAllByText('repair_orders');
1083
+ expect(repairOrdersElements.length).toBeGreaterThan(0);
601
1084
  });
602
1085
  });
603
1086
 
604
- describe('Source Pre-aggregations Section', () => {
605
- it('displays source pre-aggregations section', () => {
606
- render(
607
- <MetricDetailsPanel
608
- metric={mockMetric}
609
- grainGroups={mockGrainGroups}
610
- onClose={onClose}
1087
+ describe('Form Submission', () => {
1088
+ it('shows correct button text for Druid cube materialization', async () => {
1089
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
1090
+ columns: [{ name: 'date_id', type: 'int' }],
1091
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
1092
+ });
1093
+
1094
+ renderWithRouter(
1095
+ <QueryOverviewPanel
1096
+ {...defaultProps}
1097
+ onPlanMaterialization={jest.fn()}
1098
+ onFetchNodePartitions={onFetchNodePartitions}
611
1099
  />,
612
1100
  );
613
- expect(screen.getByText('Source Pre-aggregations')).toBeInTheDocument();
1101
+
1102
+ // Open config form
1103
+ const configureBtn = screen.getByText('Configure');
1104
+ await act(async () => {
1105
+ fireEvent.click(configureBtn);
1106
+ });
1107
+
1108
+ await waitFor(() => {
1109
+ expect(
1110
+ screen.getByText('Create Pre-Agg Workflows & Schedule Cube'),
1111
+ ).toBeInTheDocument();
1112
+ });
614
1113
  });
615
1114
 
616
- it('shows related pre-agg sources', () => {
617
- render(
618
- <MetricDetailsPanel
619
- metric={mockMetric}
620
- grainGroups={mockGrainGroups}
621
- onClose={onClose}
1115
+ it('shows Cancel and submit buttons', async () => {
1116
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
1117
+ columns: [{ name: 'date_id', type: 'int' }],
1118
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
1119
+ });
1120
+
1121
+ renderWithRouter(
1122
+ <QueryOverviewPanel
1123
+ {...defaultProps}
1124
+ onPlanMaterialization={jest.fn()}
1125
+ onFetchNodePartitions={onFetchNodePartitions}
622
1126
  />,
623
1127
  );
624
- expect(screen.getByText('repair_orders')).toBeInTheDocument();
1128
+
1129
+ // Open config form
1130
+ const configureBtn = screen.getByText('Configure');
1131
+ await act(async () => {
1132
+ fireEvent.click(configureBtn);
1133
+ });
1134
+
1135
+ await waitFor(() => {
1136
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
1137
+ });
625
1138
  });
626
1139
 
627
- it('shows empty message when no sources found', () => {
628
- render(
629
- <MetricDetailsPanel
630
- metric={mockMetric}
631
- grainGroups={[]}
632
- onClose={onClose}
1140
+ it('closes form when Cancel is clicked', async () => {
1141
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
1142
+ columns: [{ name: 'date_id', type: 'int' }],
1143
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
1144
+ });
1145
+
1146
+ renderWithRouter(
1147
+ <QueryOverviewPanel
1148
+ {...defaultProps}
1149
+ onPlanMaterialization={jest.fn()}
1150
+ onFetchNodePartitions={onFetchNodePartitions}
633
1151
  />,
634
1152
  );
635
- expect(screen.getByText('No source found')).toBeInTheDocument();
1153
+
1154
+ // Open config form
1155
+ const configureBtn = screen.getByText('Configure');
1156
+ await act(async () => {
1157
+ fireEvent.click(configureBtn);
1158
+ });
1159
+
1160
+ await waitFor(() => {
1161
+ expect(
1162
+ screen.getByText('Configure Materialization'),
1163
+ ).toBeInTheDocument();
1164
+ });
1165
+
1166
+ // Click Cancel
1167
+ fireEvent.click(screen.getByText('Cancel'));
1168
+
1169
+ // Form should be closed
1170
+ await waitFor(() => {
1171
+ expect(
1172
+ screen.queryByText('Configure Materialization'),
1173
+ ).not.toBeInTheDocument();
1174
+ });
1175
+ });
1176
+
1177
+ it('calls onPlanMaterialization with config when submitted', async () => {
1178
+ const onFetchNodePartitions = jest.fn().mockResolvedValue({
1179
+ columns: [{ name: 'date_id', type: 'int' }],
1180
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
1181
+ });
1182
+
1183
+ const onPlanMaterialization = jest.fn().mockResolvedValue({});
1184
+
1185
+ renderWithRouter(
1186
+ <QueryOverviewPanel
1187
+ {...defaultProps}
1188
+ onPlanMaterialization={onPlanMaterialization}
1189
+ onFetchNodePartitions={onFetchNodePartitions}
1190
+ />,
1191
+ );
1192
+
1193
+ // Open config form
1194
+ const configureBtn = screen.getByText('Configure');
1195
+ await act(async () => {
1196
+ fireEvent.click(configureBtn);
1197
+ });
1198
+
1199
+ await waitFor(() => {
1200
+ expect(
1201
+ screen.getByText('Create Pre-Agg Workflows & Schedule Cube'),
1202
+ ).toBeInTheDocument();
1203
+ });
1204
+
1205
+ // Submit the form
1206
+ const submitBtn = screen.getByText(
1207
+ 'Create Pre-Agg Workflows & Schedule Cube',
1208
+ );
1209
+ await act(async () => {
1210
+ fireEvent.click(submitBtn);
1211
+ });
1212
+
1213
+ await waitFor(() => {
1214
+ expect(onPlanMaterialization).toHaveBeenCalled();
1215
+ });
1216
+
1217
+ // Check the config structure
1218
+ const callArgs = onPlanMaterialization.mock.calls[0][1];
1219
+ expect(callArgs).toHaveProperty('strategy');
1220
+ expect(callArgs).toHaveProperty('schedule');
1221
+ expect(callArgs).toHaveProperty('enableDruidCube', true);
1222
+ });
1223
+ });
1224
+ });
1225
+
1226
+ describe('PreAggDetailsPanel', () => {
1227
+ const mockPreAgg = {
1228
+ parent_name: 'default.repair_orders',
1229
+ aggregability: 'FULL',
1230
+ grain: ['date_id', 'customer_id'],
1231
+ components: [
1232
+ {
1233
+ name: 'sum_revenue',
1234
+ expression: 'SUM(revenue)',
1235
+ aggregation: 'SUM',
1236
+ merge: 'SUM',
1237
+ },
1238
+ {
1239
+ name: 'count_orders',
1240
+ expression: 'COUNT(*)',
1241
+ aggregation: 'COUNT',
1242
+ merge: 'SUM',
1243
+ },
1244
+ ],
1245
+ sql: 'SELECT date_id, customer_id, SUM(revenue) FROM orders GROUP BY 1, 2',
1246
+ };
1247
+
1248
+ const mockMetricFormulas = [
1249
+ {
1250
+ name: 'default.total_revenue',
1251
+ short_name: 'total_revenue',
1252
+ combiner: 'SUM(sum_revenue)',
1253
+ is_derived: false,
1254
+ components: ['sum_revenue'],
1255
+ },
1256
+ ];
1257
+
1258
+ const onClose = jest.fn();
1259
+
1260
+ beforeEach(() => {
1261
+ jest.clearAllMocks();
1262
+ });
1263
+
1264
+ it('returns null when no preAgg provided', () => {
1265
+ const { container } = render(
1266
+ <PreAggDetailsPanel preAgg={null} onClose={onClose} />,
1267
+ );
1268
+ expect(container.firstChild).toBeNull();
1269
+ });
1270
+
1271
+ it('renders pre-aggregation badge', () => {
1272
+ render(
1273
+ <PreAggDetailsPanel
1274
+ preAgg={mockPreAgg}
1275
+ metricFormulas={mockMetricFormulas}
1276
+ onClose={onClose}
1277
+ />,
1278
+ );
1279
+ expect(screen.getByText('Pre-aggregation')).toBeInTheDocument();
1280
+ });
1281
+
1282
+ it('displays source name', () => {
1283
+ render(
1284
+ <PreAggDetailsPanel
1285
+ preAgg={mockPreAgg}
1286
+ metricFormulas={mockMetricFormulas}
1287
+ onClose={onClose}
1288
+ />,
1289
+ );
1290
+ expect(screen.getByText('repair_orders')).toBeInTheDocument();
1291
+ expect(screen.getByText('default.repair_orders')).toBeInTheDocument();
1292
+ });
1293
+
1294
+ it('displays close button', () => {
1295
+ render(
1296
+ <PreAggDetailsPanel
1297
+ preAgg={mockPreAgg}
1298
+ metricFormulas={mockMetricFormulas}
1299
+ onClose={onClose}
1300
+ />,
1301
+ );
1302
+ expect(screen.getByTitle('Close panel')).toBeInTheDocument();
1303
+ });
1304
+
1305
+ it('calls onClose when close button clicked', () => {
1306
+ render(
1307
+ <PreAggDetailsPanel
1308
+ preAgg={mockPreAgg}
1309
+ metricFormulas={mockMetricFormulas}
1310
+ onClose={onClose}
1311
+ />,
1312
+ );
1313
+ fireEvent.click(screen.getByTitle('Close panel'));
1314
+ expect(onClose).toHaveBeenCalled();
1315
+ });
1316
+
1317
+ describe('Grain Section', () => {
1318
+ it('displays grain section', () => {
1319
+ render(
1320
+ <PreAggDetailsPanel
1321
+ preAgg={mockPreAgg}
1322
+ metricFormulas={mockMetricFormulas}
1323
+ onClose={onClose}
1324
+ />,
1325
+ );
1326
+ expect(screen.getByText('Grain (GROUP BY)')).toBeInTheDocument();
1327
+ });
1328
+
1329
+ it('shows grain columns as pills', () => {
1330
+ render(
1331
+ <PreAggDetailsPanel
1332
+ preAgg={mockPreAgg}
1333
+ metricFormulas={mockMetricFormulas}
1334
+ onClose={onClose}
1335
+ />,
1336
+ );
1337
+ expect(screen.getByText('date_id')).toBeInTheDocument();
1338
+ expect(screen.getByText('customer_id')).toBeInTheDocument();
1339
+ });
1340
+
1341
+ it('shows empty message when no grain', () => {
1342
+ const noGrainPreAgg = { ...mockPreAgg, grain: [] };
1343
+ render(
1344
+ <PreAggDetailsPanel
1345
+ preAgg={noGrainPreAgg}
1346
+ metricFormulas={mockMetricFormulas}
1347
+ onClose={onClose}
1348
+ />,
1349
+ );
1350
+ expect(screen.getByText('No grain columns')).toBeInTheDocument();
1351
+ });
1352
+ });
1353
+
1354
+ describe('Related Metrics Section', () => {
1355
+ it('displays metrics using this section', () => {
1356
+ render(
1357
+ <PreAggDetailsPanel
1358
+ preAgg={mockPreAgg}
1359
+ metricFormulas={mockMetricFormulas}
1360
+ onClose={onClose}
1361
+ />,
1362
+ );
1363
+ expect(screen.getByText('Metrics Using This')).toBeInTheDocument();
1364
+ });
1365
+
1366
+ it('shows related metrics', () => {
1367
+ render(
1368
+ <PreAggDetailsPanel
1369
+ preAgg={mockPreAgg}
1370
+ metricFormulas={mockMetricFormulas}
1371
+ onClose={onClose}
1372
+ />,
1373
+ );
1374
+ expect(screen.getByText('total_revenue')).toBeInTheDocument();
1375
+ });
1376
+ });
1377
+
1378
+ describe('Components Table', () => {
1379
+ it('displays components section', () => {
1380
+ render(
1381
+ <PreAggDetailsPanel
1382
+ preAgg={mockPreAgg}
1383
+ metricFormulas={mockMetricFormulas}
1384
+ onClose={onClose}
1385
+ />,
1386
+ );
1387
+ expect(screen.getByText('Components (2)')).toBeInTheDocument();
1388
+ });
1389
+
1390
+ it('shows component names', () => {
1391
+ render(
1392
+ <PreAggDetailsPanel
1393
+ preAgg={mockPreAgg}
1394
+ metricFormulas={mockMetricFormulas}
1395
+ onClose={onClose}
1396
+ />,
1397
+ );
1398
+ expect(screen.getByText('sum_revenue')).toBeInTheDocument();
1399
+ expect(screen.getByText('count_orders')).toBeInTheDocument();
1400
+ });
1401
+
1402
+ it('shows component expressions', () => {
1403
+ render(
1404
+ <PreAggDetailsPanel
1405
+ preAgg={mockPreAgg}
1406
+ metricFormulas={mockMetricFormulas}
1407
+ onClose={onClose}
1408
+ />,
1409
+ );
1410
+ expect(screen.getByText('SUM(revenue)')).toBeInTheDocument();
1411
+ expect(screen.getByText('COUNT(*)')).toBeInTheDocument();
1412
+ });
1413
+
1414
+ it('shows aggregation functions', () => {
1415
+ render(
1416
+ <PreAggDetailsPanel
1417
+ preAgg={mockPreAgg}
1418
+ metricFormulas={mockMetricFormulas}
1419
+ onClose={onClose}
1420
+ />,
1421
+ );
1422
+ expect(screen.getAllByText('SUM').length).toBeGreaterThan(0);
1423
+ expect(screen.getByText('COUNT')).toBeInTheDocument();
1424
+ });
1425
+ });
1426
+
1427
+ describe('SQL Section', () => {
1428
+ it('displays SQL section when sql is present', () => {
1429
+ render(
1430
+ <PreAggDetailsPanel
1431
+ preAgg={mockPreAgg}
1432
+ metricFormulas={mockMetricFormulas}
1433
+ onClose={onClose}
1434
+ />,
1435
+ );
1436
+ expect(screen.getByText('Pre-Aggregation SQL')).toBeInTheDocument();
1437
+ });
1438
+
1439
+ it('shows copy button', () => {
1440
+ render(
1441
+ <PreAggDetailsPanel
1442
+ preAgg={mockPreAgg}
1443
+ metricFormulas={mockMetricFormulas}
1444
+ onClose={onClose}
1445
+ />,
1446
+ );
1447
+ expect(screen.getByText('Copy SQL')).toBeInTheDocument();
1448
+ });
1449
+
1450
+ it('copies SQL when copy button is clicked', () => {
1451
+ render(
1452
+ <PreAggDetailsPanel
1453
+ preAgg={mockPreAgg}
1454
+ metricFormulas={mockMetricFormulas}
1455
+ onClose={onClose}
1456
+ />,
1457
+ );
1458
+
1459
+ const copyBtn = screen.getByText('Copy SQL');
1460
+ fireEvent.click(copyBtn);
1461
+
1462
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
1463
+ mockPreAgg.sql,
1464
+ );
1465
+ });
1466
+
1467
+ it('hides SQL section when sql is not present', () => {
1468
+ const preAggWithoutSql = { ...mockPreAgg, sql: null };
1469
+ render(
1470
+ <PreAggDetailsPanel
1471
+ preAgg={preAggWithoutSql}
1472
+ metricFormulas={mockMetricFormulas}
1473
+ onClose={onClose}
1474
+ />,
1475
+ );
1476
+
1477
+ expect(screen.queryByText('Pre-Aggregation SQL')).not.toBeInTheDocument();
1478
+ });
1479
+ });
1480
+
1481
+ describe('Pre-aggregation Type Badge', () => {
1482
+ it('shows Pre-aggregation badge', () => {
1483
+ render(
1484
+ <PreAggDetailsPanel
1485
+ preAgg={mockPreAgg}
1486
+ metricFormulas={mockMetricFormulas}
1487
+ onClose={onClose}
1488
+ />,
1489
+ );
1490
+ expect(screen.getByText('Pre-aggregation')).toBeInTheDocument();
1491
+ });
1492
+
1493
+ it('shows parent name in title', () => {
1494
+ render(
1495
+ <PreAggDetailsPanel
1496
+ preAgg={mockPreAgg}
1497
+ metricFormulas={mockMetricFormulas}
1498
+ onClose={onClose}
1499
+ />,
1500
+ );
1501
+ expect(screen.getByText('repair_orders')).toBeInTheDocument();
1502
+ });
1503
+ });
1504
+
1505
+ describe('Merge Function Display', () => {
1506
+ it('shows merge functions for components', () => {
1507
+ render(
1508
+ <PreAggDetailsPanel
1509
+ preAgg={mockPreAgg}
1510
+ metricFormulas={mockMetricFormulas}
1511
+ onClose={onClose}
1512
+ />,
1513
+ );
1514
+
1515
+ // Components have merge functions
1516
+ expect(screen.getAllByText('SUM').length).toBeGreaterThan(0);
1517
+ });
1518
+ });
1519
+ });
1520
+
1521
+ describe('MetricDetailsPanel', () => {
1522
+ const mockMetric = {
1523
+ name: 'default.avg_repair_price',
1524
+ short_name: 'avg_repair_price',
1525
+ combiner: 'SUM(sum_revenue) / SUM(count_orders)',
1526
+ is_derived: true,
1527
+ components: ['sum_revenue', 'count_orders'],
1528
+ };
1529
+
1530
+ const mockGrainGroups = [
1531
+ {
1532
+ parent_name: 'default.repair_orders',
1533
+ components: [{ name: 'sum_revenue' }, { name: 'count_orders' }],
1534
+ },
1535
+ ];
1536
+
1537
+ const onClose = jest.fn();
1538
+
1539
+ beforeEach(() => {
1540
+ jest.clearAllMocks();
1541
+ });
1542
+
1543
+ it('returns null when no metric provided', () => {
1544
+ const { container } = render(
1545
+ <MetricDetailsPanel metric={null} onClose={onClose} />,
1546
+ );
1547
+ expect(container.firstChild).toBeNull();
1548
+ });
1549
+
1550
+ it('renders metric badge', () => {
1551
+ render(
1552
+ <MetricDetailsPanel
1553
+ metric={mockMetric}
1554
+ grainGroups={mockGrainGroups}
1555
+ onClose={onClose}
1556
+ />,
1557
+ );
1558
+ expect(screen.getByText('Derived Metric')).toBeInTheDocument();
1559
+ });
1560
+
1561
+ it('renders regular metric badge for non-derived', () => {
1562
+ const nonDerivedMetric = { ...mockMetric, is_derived: false };
1563
+ render(
1564
+ <MetricDetailsPanel
1565
+ metric={nonDerivedMetric}
1566
+ grainGroups={mockGrainGroups}
1567
+ onClose={onClose}
1568
+ />,
1569
+ );
1570
+ expect(screen.getByText('Metric')).toBeInTheDocument();
1571
+ });
1572
+
1573
+ it('displays metric name', () => {
1574
+ render(
1575
+ <MetricDetailsPanel
1576
+ metric={mockMetric}
1577
+ grainGroups={mockGrainGroups}
1578
+ onClose={onClose}
1579
+ />,
1580
+ );
1581
+ expect(screen.getByText('avg_repair_price')).toBeInTheDocument();
1582
+ expect(screen.getByText('default.avg_repair_price')).toBeInTheDocument();
1583
+ });
1584
+
1585
+ it('calls onClose when close button clicked', () => {
1586
+ render(
1587
+ <MetricDetailsPanel
1588
+ metric={mockMetric}
1589
+ grainGroups={mockGrainGroups}
1590
+ onClose={onClose}
1591
+ />,
1592
+ );
1593
+ fireEvent.click(screen.getByTitle('Close panel'));
1594
+ expect(onClose).toHaveBeenCalled();
1595
+ });
1596
+
1597
+ describe('Formula Section', () => {
1598
+ it('displays combiner formula section', () => {
1599
+ render(
1600
+ <MetricDetailsPanel
1601
+ metric={mockMetric}
1602
+ grainGroups={mockGrainGroups}
1603
+ onClose={onClose}
1604
+ />,
1605
+ );
1606
+ expect(screen.getByText('Combiner Formula')).toBeInTheDocument();
1607
+ });
1608
+
1609
+ it('shows the formula', () => {
1610
+ render(
1611
+ <MetricDetailsPanel
1612
+ metric={mockMetric}
1613
+ grainGroups={mockGrainGroups}
1614
+ onClose={onClose}
1615
+ />,
1616
+ );
1617
+ expect(
1618
+ screen.getByText('SUM(sum_revenue) / SUM(count_orders)'),
1619
+ ).toBeInTheDocument();
1620
+ });
1621
+ });
1622
+
1623
+ describe('Components Section', () => {
1624
+ it('displays components used section', () => {
1625
+ render(
1626
+ <MetricDetailsPanel
1627
+ metric={mockMetric}
1628
+ grainGroups={mockGrainGroups}
1629
+ onClose={onClose}
1630
+ />,
1631
+ );
1632
+ expect(screen.getByText('Components Used')).toBeInTheDocument();
1633
+ });
1634
+
1635
+ it('shows component tags', () => {
1636
+ render(
1637
+ <MetricDetailsPanel
1638
+ metric={mockMetric}
1639
+ grainGroups={mockGrainGroups}
1640
+ onClose={onClose}
1641
+ />,
1642
+ );
1643
+ expect(screen.getByText('sum_revenue')).toBeInTheDocument();
1644
+ expect(screen.getByText('count_orders')).toBeInTheDocument();
1645
+ });
1646
+ });
1647
+
1648
+ describe('Source Pre-aggregations Section', () => {
1649
+ it('displays source pre-aggregations section', () => {
1650
+ render(
1651
+ <MetricDetailsPanel
1652
+ metric={mockMetric}
1653
+ grainGroups={mockGrainGroups}
1654
+ onClose={onClose}
1655
+ />,
1656
+ );
1657
+ expect(screen.getByText('Source Pre-aggregations')).toBeInTheDocument();
1658
+ });
1659
+
1660
+ it('shows related pre-agg sources', () => {
1661
+ render(
1662
+ <MetricDetailsPanel
1663
+ metric={mockMetric}
1664
+ grainGroups={mockGrainGroups}
1665
+ onClose={onClose}
1666
+ />,
1667
+ );
1668
+ expect(screen.getByText('repair_orders')).toBeInTheDocument();
1669
+ });
1670
+
1671
+ it('shows empty message when no sources found', () => {
1672
+ render(
1673
+ <MetricDetailsPanel
1674
+ metric={mockMetric}
1675
+ grainGroups={[]}
1676
+ onClose={onClose}
1677
+ />,
1678
+ );
1679
+ expect(screen.getByText('No source found')).toBeInTheDocument();
1680
+ });
1681
+ });
1682
+
1683
+ describe('Multiple Source Pre-aggregations', () => {
1684
+ it('shows multiple sources when metric uses components from different pre-aggs', () => {
1685
+ const multiSourceGrainGroups = [
1686
+ {
1687
+ parent_name: 'default.repair_orders',
1688
+ components: [{ name: 'sum_revenue' }],
1689
+ },
1690
+ {
1691
+ parent_name: 'default.inventory',
1692
+ components: [{ name: 'count_orders' }],
1693
+ },
1694
+ ];
1695
+
1696
+ render(
1697
+ <MetricDetailsPanel
1698
+ metric={mockMetric}
1699
+ grainGroups={multiSourceGrainGroups}
1700
+ onClose={onClose}
1701
+ />,
1702
+ );
1703
+
1704
+ expect(screen.getByText('repair_orders')).toBeInTheDocument();
1705
+ expect(screen.getByText('inventory')).toBeInTheDocument();
1706
+ });
1707
+ });
1708
+
1709
+ describe('Full Metric Name Display', () => {
1710
+ it('displays full metric name as subtitle', () => {
1711
+ render(
1712
+ <MetricDetailsPanel
1713
+ metric={mockMetric}
1714
+ grainGroups={mockGrainGroups}
1715
+ onClose={onClose}
1716
+ />,
1717
+ );
1718
+
1719
+ expect(screen.getByText('default.avg_repair_price')).toBeInTheDocument();
1720
+ });
1721
+ });
1722
+
1723
+ describe('Full Name Display', () => {
1724
+ it('renders the full metric name', () => {
1725
+ render(
1726
+ <MemoryRouter>
1727
+ <MetricDetailsPanel
1728
+ metric={mockMetric}
1729
+ grainGroups={mockGrainGroups}
1730
+ onClose={onClose}
1731
+ />
1732
+ </MemoryRouter>,
1733
+ );
1734
+
1735
+ expect(screen.getByText('default.avg_repair_price')).toBeInTheDocument();
1736
+ });
1737
+ });
1738
+ });
1739
+
1740
+ describe('QueryOverviewPanel - Pre-Agg Cards', () => {
1741
+ const mockMeasuresWithPreaggs = {
1742
+ grain_groups: [
1743
+ {
1744
+ parent_name: 'default.repair_orders',
1745
+ aggregability: 'FULL',
1746
+ grain: ['customer_id', 'date_id'], // alphabetically sorted for consistency
1747
+ components: [
1748
+ { name: 'sum_revenue', expression: 'SUM(revenue)' },
1749
+ { name: 'count_orders', expression: 'COUNT(*)' },
1750
+ ],
1751
+ sql: 'SELECT date_id, customer_id, SUM(revenue) FROM orders GROUP BY 1, 2',
1752
+ },
1753
+ ],
1754
+ metric_formulas: [
1755
+ {
1756
+ name: 'default.num_repair_orders',
1757
+ short_name: 'num_repair_orders',
1758
+ combiner: 'SUM(count_orders)',
1759
+ components: ['count_orders'],
1760
+ },
1761
+ ],
1762
+ };
1763
+
1764
+ // Key format is: parent_name|sorted_grain_cols
1765
+ const mockPlannedPreaggs = {
1766
+ 'default.repair_orders|customer_id,date_id': {
1767
+ id: 'preagg-123',
1768
+ parent_name: 'default.repair_orders',
1769
+ grain_columns: ['customer_id', 'date_id'],
1770
+ strategy: 'incremental_time',
1771
+ schedule: '0 6 * * *',
1772
+ lookback_window: '1 day',
1773
+ workflow_urls: ['https://workflow.example.com/scheduled-123'],
1774
+ availability: {
1775
+ updated_at: '2024-01-15T10:30:00Z',
1776
+ },
1777
+ },
1778
+ };
1779
+
1780
+ const baseProps = {
1781
+ measuresResult: mockMeasuresWithPreaggs,
1782
+ metricsResult: { sql: 'SELECT ...' },
1783
+ selectedMetrics: ['default.num_repair_orders'],
1784
+ selectedDimensions: ['default.date_dim.dateint'],
1785
+ loadedCubeName: null,
1786
+ plannedPreaggs: mockPlannedPreaggs,
1787
+ onPlanMaterialization: jest.fn(),
1788
+ onUpdateConfig: jest.fn(),
1789
+ onCreateWorkflow: jest.fn(),
1790
+ onRunBackfill: jest.fn(),
1791
+ onDeactivatePreaggWorkflow: jest.fn(),
1792
+ onFetchNodePartitions: jest.fn().mockResolvedValue({
1793
+ columns: [{ name: 'date_id', type: 'int' }],
1794
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
1795
+ }),
1796
+ };
1797
+
1798
+ beforeEach(() => {
1799
+ jest.clearAllMocks();
1800
+ });
1801
+
1802
+ describe('Pre-Agg Card Display', () => {
1803
+ it('shows pre-agg cards with names', () => {
1804
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1805
+ expect(screen.getByText('repair_orders')).toBeInTheDocument();
1806
+ });
1807
+
1808
+ it('shows Active status pill for configured pre-aggs', () => {
1809
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1810
+ expect(screen.getByText('● Active')).toBeInTheDocument();
1811
+ });
1812
+
1813
+ it('shows Not Set status for unconfigured pre-aggs', () => {
1814
+ renderWithRouter(
1815
+ <QueryOverviewPanel {...baseProps} plannedPreaggs={{}} />,
1816
+ );
1817
+ expect(screen.getByText('○ Not Set')).toBeInTheDocument();
1818
+ });
1819
+
1820
+ it('displays grain information', () => {
1821
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1822
+ // Grain is displayed as joined string
1823
+ expect(screen.getByText(/customer_id.*date_id/i)).toBeInTheDocument();
1824
+ });
1825
+
1826
+ it('shows schedule summary for active pre-aggs', () => {
1827
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1828
+ // The schedule '0 6 * * *' should show "Daily at 6:00am" or the raw schedule
1829
+ // Schedule is shown for active preaggs (may appear multiple times)
1830
+ const scheduleTexts = screen.getAllByText(/Daily|6:00|0 6/i);
1831
+ expect(scheduleTexts.length).toBeGreaterThan(0);
1832
+ });
1833
+ });
1834
+
1835
+ describe('Pre-Agg Card Expansion', () => {
1836
+ it('expands card when clicked to show details', async () => {
1837
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1838
+
1839
+ // Find and click the expand button
1840
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1841
+ await act(async () => {
1842
+ fireEvent.click(expandBtn);
1843
+ });
1844
+
1845
+ // Should show strategy details
1846
+ await waitFor(() => {
1847
+ expect(
1848
+ screen.getByText('Incremental (Time-based)'),
1849
+ ).toBeInTheDocument();
1850
+ });
1851
+ });
1852
+
1853
+ it('shows schedule in expanded view', async () => {
1854
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1855
+
1856
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1857
+ await act(async () => {
1858
+ fireEvent.click(expandBtn);
1859
+ });
1860
+
1861
+ await waitFor(() => {
1862
+ expect(screen.getByText('0 6 * * *')).toBeInTheDocument();
1863
+ });
1864
+ });
1865
+
1866
+ it('shows lookback window in expanded view', async () => {
1867
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1868
+
1869
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1870
+ await act(async () => {
1871
+ fireEvent.click(expandBtn);
1872
+ });
1873
+
1874
+ await waitFor(() => {
1875
+ expect(screen.getByText('1 day')).toBeInTheDocument();
1876
+ });
1877
+ });
1878
+
1879
+ it('shows last run time when available', async () => {
1880
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1881
+
1882
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1883
+ await act(async () => {
1884
+ fireEvent.click(expandBtn);
1885
+ });
1886
+
1887
+ await waitFor(() => {
1888
+ expect(screen.getByText('Last Run:')).toBeInTheDocument();
1889
+ });
1890
+ });
1891
+
1892
+ it('shows workflow links when available', async () => {
1893
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1894
+
1895
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1896
+ await act(async () => {
1897
+ fireEvent.click(expandBtn);
1898
+ });
1899
+
1900
+ await waitFor(() => {
1901
+ expect(screen.getByText('Scheduled')).toBeInTheDocument();
1902
+ });
1903
+ });
1904
+
1905
+ it('collapses when clicking expand button again', async () => {
1906
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1907
+
1908
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1909
+ await act(async () => {
1910
+ fireEvent.click(expandBtn);
1911
+ });
1912
+
1913
+ await waitFor(() => {
1914
+ expect(
1915
+ screen.getByText('Incremental (Time-based)'),
1916
+ ).toBeInTheDocument();
1917
+ });
1918
+
1919
+ // Click collapse button
1920
+ const collapseBtn = screen.getByRole('button', { name: 'Collapse' });
1921
+ await act(async () => {
1922
+ fireEvent.click(collapseBtn);
1923
+ });
1924
+
1925
+ await waitFor(() => {
1926
+ expect(
1927
+ screen.queryByText('Incremental (Time-based)'),
1928
+ ).not.toBeInTheDocument();
1929
+ });
1930
+ });
1931
+ });
1932
+
1933
+ describe('Pre-Agg Edit Config Form', () => {
1934
+ it('opens edit form when Edit Config button is clicked', async () => {
1935
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1936
+
1937
+ // Expand the card first
1938
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1939
+ await act(async () => {
1940
+ fireEvent.click(expandBtn);
1941
+ });
1942
+
1943
+ await waitFor(() => {
1944
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
1945
+ });
1946
+
1947
+ // Click Edit Config
1948
+ await act(async () => {
1949
+ fireEvent.click(screen.getByText('Edit Config'));
1950
+ });
1951
+
1952
+ await waitFor(() => {
1953
+ expect(
1954
+ screen.getByText('Edit Materialization Config'),
1955
+ ).toBeInTheDocument();
1956
+ });
1957
+ });
1958
+
1959
+ it('shows strategy options in edit form', async () => {
1960
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1961
+
1962
+ // Expand and open edit
1963
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1964
+ await act(async () => {
1965
+ fireEvent.click(expandBtn);
1966
+ });
1967
+
1968
+ await waitFor(() => {
1969
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
1970
+ });
1971
+
1972
+ await act(async () => {
1973
+ fireEvent.click(screen.getByText('Edit Config'));
1974
+ });
1975
+
1976
+ await waitFor(() => {
1977
+ expect(screen.getByText('Full')).toBeInTheDocument();
1978
+ expect(screen.getByText('Incremental (Time)')).toBeInTheDocument();
1979
+ });
1980
+ });
1981
+
1982
+ it('closes edit form when close button is clicked', async () => {
1983
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
1984
+
1985
+ // Expand and open edit
1986
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
1987
+ await act(async () => {
1988
+ fireEvent.click(expandBtn);
1989
+ });
1990
+
1991
+ await waitFor(() => {
1992
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
1993
+ });
1994
+
1995
+ await act(async () => {
1996
+ fireEvent.click(screen.getByText('Edit Config'));
1997
+ });
1998
+
1999
+ await waitFor(() => {
2000
+ expect(
2001
+ screen.getByText('Edit Materialization Config'),
2002
+ ).toBeInTheDocument();
2003
+ });
2004
+
2005
+ // Find close button (×) and click
2006
+ const closeBtn = screen.getByRole('button', { name: '×' });
2007
+ await act(async () => {
2008
+ fireEvent.click(closeBtn);
2009
+ });
2010
+
2011
+ await waitFor(() => {
2012
+ expect(
2013
+ screen.queryByText('Edit Materialization Config'),
2014
+ ).not.toBeInTheDocument();
2015
+ });
2016
+ });
2017
+
2018
+ it('closes edit form when Cancel is clicked', async () => {
2019
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2020
+
2021
+ // Expand and open edit
2022
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2023
+ await act(async () => {
2024
+ fireEvent.click(expandBtn);
2025
+ });
2026
+
2027
+ await waitFor(() => {
2028
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
2029
+ });
2030
+
2031
+ await act(async () => {
2032
+ fireEvent.click(screen.getByText('Edit Config'));
2033
+ });
2034
+
2035
+ await waitFor(() => {
2036
+ expect(
2037
+ screen.getByText('Edit Materialization Config'),
2038
+ ).toBeInTheDocument();
2039
+ });
2040
+
2041
+ // Click Cancel
2042
+ const cancelBtns = screen.getAllByText('Cancel');
2043
+ await act(async () => {
2044
+ fireEvent.click(cancelBtns[cancelBtns.length - 1]);
2045
+ });
2046
+
2047
+ await waitFor(() => {
2048
+ expect(
2049
+ screen.queryByText('Edit Materialization Config'),
2050
+ ).not.toBeInTheDocument();
2051
+ });
2052
+ });
2053
+
2054
+ it('calls onUpdateConfig when Save is clicked', async () => {
2055
+ const onUpdateConfig = jest.fn().mockResolvedValue({});
2056
+ renderWithRouter(
2057
+ <QueryOverviewPanel {...baseProps} onUpdateConfig={onUpdateConfig} />,
2058
+ );
2059
+
2060
+ // Expand and open edit
2061
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2062
+ await act(async () => {
2063
+ fireEvent.click(expandBtn);
2064
+ });
2065
+
2066
+ await waitFor(() => {
2067
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
2068
+ });
2069
+
2070
+ await act(async () => {
2071
+ fireEvent.click(screen.getByText('Edit Config'));
2072
+ });
2073
+
2074
+ await waitFor(() => {
2075
+ expect(screen.getByText('Save')).toBeInTheDocument();
2076
+ });
2077
+
2078
+ // Click Save
2079
+ await act(async () => {
2080
+ fireEvent.click(screen.getByText('Save'));
2081
+ });
2082
+
2083
+ await waitFor(() => {
2084
+ expect(onUpdateConfig).toHaveBeenCalledWith(
2085
+ 'preagg-123',
2086
+ expect.any(Object),
2087
+ );
2088
+ });
2089
+ });
2090
+ });
2091
+
2092
+ describe('Pre-Agg Workflow Actions', () => {
2093
+ it('shows Refresh button when workflow exists', async () => {
2094
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2095
+
2096
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2097
+ await act(async () => {
2098
+ fireEvent.click(expandBtn);
2099
+ });
2100
+
2101
+ await waitFor(() => {
2102
+ expect(screen.getByText('↻ Refresh')).toBeInTheDocument();
2103
+ });
2104
+ });
2105
+
2106
+ it('calls onCreateWorkflow when Refresh is clicked', async () => {
2107
+ const onCreateWorkflow = jest.fn().mockResolvedValue({});
2108
+ renderWithRouter(
2109
+ <QueryOverviewPanel
2110
+ {...baseProps}
2111
+ onCreateWorkflow={onCreateWorkflow}
2112
+ />,
2113
+ );
2114
+
2115
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2116
+ await act(async () => {
2117
+ fireEvent.click(expandBtn);
2118
+ });
2119
+
2120
+ await waitFor(() => {
2121
+ expect(screen.getByText('↻ Refresh')).toBeInTheDocument();
2122
+ });
2123
+
2124
+ await act(async () => {
2125
+ fireEvent.click(screen.getByText('↻ Refresh'));
2126
+ });
2127
+
2128
+ await waitFor(() => {
2129
+ expect(onCreateWorkflow).toHaveBeenCalledWith('preagg-123', true);
2130
+ });
2131
+ });
2132
+
2133
+ it('shows Run Backfill button', async () => {
2134
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2135
+
2136
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2137
+ await act(async () => {
2138
+ fireEvent.click(expandBtn);
2139
+ });
2140
+
2141
+ await waitFor(() => {
2142
+ expect(screen.getByText('Run Backfill')).toBeInTheDocument();
2143
+ });
2144
+ });
2145
+
2146
+ it('shows Deactivate button when workflow exists', async () => {
2147
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2148
+
2149
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2150
+ await act(async () => {
2151
+ fireEvent.click(expandBtn);
2152
+ });
2153
+
2154
+ await waitFor(() => {
2155
+ expect(screen.getByText('⏹ Deactivate')).toBeInTheDocument();
2156
+ });
2157
+ });
2158
+
2159
+ it('calls onDeactivatePreaggWorkflow when Deactivate is clicked and confirmed', async () => {
2160
+ const onDeactivatePreaggWorkflow = jest.fn().mockResolvedValue({});
2161
+ window.confirm = jest.fn().mockReturnValue(true);
2162
+
2163
+ renderWithRouter(
2164
+ <QueryOverviewPanel
2165
+ {...baseProps}
2166
+ onDeactivatePreaggWorkflow={onDeactivatePreaggWorkflow}
2167
+ />,
2168
+ );
2169
+
2170
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2171
+ await act(async () => {
2172
+ fireEvent.click(expandBtn);
2173
+ });
2174
+
2175
+ await waitFor(() => {
2176
+ expect(screen.getByText('⏹ Deactivate')).toBeInTheDocument();
2177
+ });
2178
+
2179
+ await act(async () => {
2180
+ fireEvent.click(screen.getByText('⏹ Deactivate'));
2181
+ });
2182
+
2183
+ await waitFor(() => {
2184
+ expect(window.confirm).toHaveBeenCalled();
2185
+ expect(onDeactivatePreaggWorkflow).toHaveBeenCalledWith('preagg-123');
2186
+ });
2187
+ });
2188
+
2189
+ it('does not deactivate when confirmation is cancelled', async () => {
2190
+ const onDeactivatePreaggWorkflow = jest.fn().mockResolvedValue({});
2191
+ window.confirm = jest.fn().mockReturnValue(false);
2192
+
2193
+ renderWithRouter(
2194
+ <QueryOverviewPanel
2195
+ {...baseProps}
2196
+ onDeactivatePreaggWorkflow={onDeactivatePreaggWorkflow}
2197
+ />,
2198
+ );
2199
+
2200
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2201
+ await act(async () => {
2202
+ fireEvent.click(expandBtn);
2203
+ });
2204
+
2205
+ await waitFor(() => {
2206
+ expect(screen.getByText('⏹ Deactivate')).toBeInTheDocument();
2207
+ });
2208
+
2209
+ await act(async () => {
2210
+ fireEvent.click(screen.getByText('⏹ Deactivate'));
2211
+ });
2212
+
2213
+ expect(window.confirm).toHaveBeenCalled();
2214
+ expect(onDeactivatePreaggWorkflow).not.toHaveBeenCalled();
2215
+ });
2216
+ });
2217
+
2218
+ describe('Create Workflow Button', () => {
2219
+ it('shows Create Workflow button when no workflow exists', async () => {
2220
+ const preaggWithoutWorkflow = {
2221
+ 'default.repair_orders|customer_id,date_id': {
2222
+ id: 'preagg-123',
2223
+ parent_name: 'default.repair_orders',
2224
+ grain_columns: ['date_id', 'customer_id'],
2225
+ strategy: 'incremental_time',
2226
+ schedule: '0 6 * * *',
2227
+ workflow_urls: [], // No workflows
2228
+ },
2229
+ };
2230
+
2231
+ renderWithRouter(
2232
+ <QueryOverviewPanel
2233
+ {...baseProps}
2234
+ plannedPreaggs={preaggWithoutWorkflow}
2235
+ />,
2236
+ );
2237
+
2238
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2239
+ await act(async () => {
2240
+ fireEvent.click(expandBtn);
2241
+ });
2242
+
2243
+ await waitFor(() => {
2244
+ expect(screen.getByText('Create Workflow')).toBeInTheDocument();
2245
+ });
2246
+ });
2247
+
2248
+ it('calls onCreateWorkflow when Create Workflow is clicked', async () => {
2249
+ const onCreateWorkflow = jest.fn().mockResolvedValue({
2250
+ workflow_urls: ['https://workflow.example.com/new-123'],
2251
+ });
2252
+
2253
+ const preaggWithoutWorkflow = {
2254
+ 'default.repair_orders|customer_id,date_id': {
2255
+ id: 'preagg-123',
2256
+ parent_name: 'default.repair_orders',
2257
+ grain_columns: ['date_id', 'customer_id'],
2258
+ strategy: 'incremental_time',
2259
+ schedule: '0 6 * * *',
2260
+ workflow_urls: [],
2261
+ },
2262
+ };
2263
+
2264
+ renderWithRouter(
2265
+ <QueryOverviewPanel
2266
+ {...baseProps}
2267
+ plannedPreaggs={preaggWithoutWorkflow}
2268
+ onCreateWorkflow={onCreateWorkflow}
2269
+ />,
2270
+ );
2271
+
2272
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2273
+ await act(async () => {
2274
+ fireEvent.click(expandBtn);
2275
+ });
2276
+
2277
+ await waitFor(() => {
2278
+ expect(screen.getByText('Create Workflow')).toBeInTheDocument();
2279
+ });
2280
+
2281
+ await act(async () => {
2282
+ fireEvent.click(screen.getByText('Create Workflow'));
2283
+ });
2284
+
2285
+ await waitFor(() => {
2286
+ expect(onCreateWorkflow).toHaveBeenCalledWith('preagg-123');
2287
+ });
2288
+ });
2289
+ });
2290
+ });
2291
+
2292
+ describe('QueryOverviewPanel - Backfill Modal', () => {
2293
+ const mockMeasuresWithPreaggs = {
2294
+ grain_groups: [
2295
+ {
2296
+ parent_name: 'default.repair_orders',
2297
+ aggregability: 'FULL',
2298
+ grain: ['date_id'],
2299
+ components: [{ name: 'count_orders' }],
2300
+ },
2301
+ ],
2302
+ metric_formulas: [
2303
+ {
2304
+ name: 'default.num_repair_orders',
2305
+ short_name: 'num_repair_orders',
2306
+ combiner: 'SUM(count_orders)',
2307
+ components: ['count_orders'],
2308
+ },
2309
+ ],
2310
+ };
2311
+
2312
+ // Key must match normalized grain
2313
+ const mockPlannedPreaggs = {
2314
+ 'default.repair_orders|date_id': {
2315
+ id: 'preagg-456',
2316
+ parent_name: 'default.repair_orders',
2317
+ grain_columns: ['date_id'],
2318
+ strategy: 'incremental_time',
2319
+ schedule: '0 6 * * *',
2320
+ workflow_urls: ['https://workflow.example.com/test'],
2321
+ },
2322
+ };
2323
+
2324
+ const baseProps = {
2325
+ measuresResult: mockMeasuresWithPreaggs,
2326
+ metricsResult: { sql: 'SELECT ...' },
2327
+ selectedMetrics: ['default.num_repair_orders'],
2328
+ selectedDimensions: ['default.date_dim.date_id'], // Must have at least one dimension
2329
+ plannedPreaggs: mockPlannedPreaggs,
2330
+ onRunBackfill: jest.fn(),
2331
+ onFetchNodePartitions: jest.fn().mockResolvedValue({
2332
+ columns: [],
2333
+ temporalPartitions: [],
2334
+ }),
2335
+ };
2336
+
2337
+ beforeEach(() => {
2338
+ jest.clearAllMocks();
2339
+ });
2340
+
2341
+ it('opens backfill modal when Run Backfill is clicked on preagg card', async () => {
2342
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2343
+
2344
+ // Find and expand the preagg card
2345
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2346
+ await act(async () => {
2347
+ fireEvent.click(expandBtn);
2348
+ });
2349
+
2350
+ // Wait for Run Backfill button to appear in expanded view
2351
+ await waitFor(() => {
2352
+ const backfillBtns = screen.getAllByText('Run Backfill');
2353
+ expect(backfillBtns.length).toBeGreaterThan(0);
2354
+ });
2355
+
2356
+ // Click the Run Backfill button on preagg
2357
+ const backfillBtns = screen.getAllByText('Run Backfill');
2358
+ await act(async () => {
2359
+ fireEvent.click(backfillBtns[backfillBtns.length - 1]);
2360
+ });
2361
+
2362
+ // Modal should open with heading
2363
+ await waitFor(() => {
2364
+ const headings = screen.getAllByRole('heading', { level: 3 });
2365
+ const backfillHeading = headings.find(
2366
+ h => h.textContent === 'Run Backfill',
2367
+ );
2368
+ expect(backfillHeading).toBeInTheDocument();
2369
+ });
2370
+ });
2371
+
2372
+ it('shows start and end date inputs in modal', async () => {
2373
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2374
+
2375
+ // Expand card
2376
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2377
+ await act(async () => {
2378
+ fireEvent.click(expandBtn);
2379
+ });
2380
+
2381
+ // Open modal
2382
+ await waitFor(() => {
2383
+ const backfillBtns = screen.getAllByText('Run Backfill');
2384
+ expect(backfillBtns.length).toBeGreaterThan(0);
2385
+ });
2386
+
2387
+ const backfillBtns = screen.getAllByText('Run Backfill');
2388
+ await act(async () => {
2389
+ fireEvent.click(backfillBtns[backfillBtns.length - 1]);
2390
+ });
2391
+
2392
+ await waitFor(() => {
2393
+ expect(screen.getByText('Start Date')).toBeInTheDocument();
2394
+ expect(screen.getByText('End Date')).toBeInTheDocument();
2395
+ });
2396
+ });
2397
+
2398
+ it('calls onRunBackfill when Start Backfill is clicked in modal', async () => {
2399
+ const onRunBackfill = jest
2400
+ .fn()
2401
+ .mockResolvedValue({ job_url: 'https://job.example.com' });
2402
+ renderWithRouter(
2403
+ <QueryOverviewPanel {...baseProps} onRunBackfill={onRunBackfill} />,
2404
+ );
2405
+
2406
+ // Expand card
2407
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2408
+ await act(async () => {
2409
+ fireEvent.click(expandBtn);
2410
+ });
2411
+
2412
+ // Open modal
2413
+ await waitFor(() => {
2414
+ const backfillBtns = screen.getAllByText('Run Backfill');
2415
+ expect(backfillBtns.length).toBeGreaterThan(0);
2416
+ });
2417
+
2418
+ const backfillBtns = screen.getAllByText('Run Backfill');
2419
+ await act(async () => {
2420
+ fireEvent.click(backfillBtns[backfillBtns.length - 1]);
2421
+ });
2422
+
2423
+ // Wait for modal
2424
+ await waitFor(() => {
2425
+ expect(screen.getByText('Start Date')).toBeInTheDocument();
2426
+ });
2427
+
2428
+ // Click Start Backfill
2429
+ await act(async () => {
2430
+ fireEvent.click(screen.getByText('Start Backfill'));
2431
+ });
2432
+
2433
+ await waitFor(() => {
2434
+ expect(onRunBackfill).toHaveBeenCalledWith(
2435
+ 'preagg-456',
2436
+ expect.any(String),
2437
+ expect.any(String),
2438
+ );
2439
+ });
2440
+ });
2441
+
2442
+ it('closes modal when Cancel is clicked', async () => {
2443
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2444
+
2445
+ // Expand card
2446
+ const expandBtn = screen.getByRole('button', { name: 'Expand' });
2447
+ await act(async () => {
2448
+ fireEvent.click(expandBtn);
2449
+ });
2450
+
2451
+ // Open modal
2452
+ await waitFor(() => {
2453
+ const backfillBtns = screen.getAllByText('Run Backfill');
2454
+ expect(backfillBtns.length).toBeGreaterThan(0);
2455
+ });
2456
+
2457
+ const backfillBtns = screen.getAllByText('Run Backfill');
2458
+ await act(async () => {
2459
+ fireEvent.click(backfillBtns[backfillBtns.length - 1]);
2460
+ });
2461
+
2462
+ // Wait for modal
2463
+ await waitFor(() => {
2464
+ expect(screen.getByText('Start Date')).toBeInTheDocument();
2465
+ });
2466
+
2467
+ // Click Cancel
2468
+ const cancelBtns = screen.getAllByText('Cancel');
2469
+ await act(async () => {
2470
+ fireEvent.click(cancelBtns[cancelBtns.length - 1]);
2471
+ });
2472
+
2473
+ // Modal should close
2474
+ await waitFor(() => {
2475
+ expect(screen.queryByText('Start Date')).not.toBeInTheDocument();
2476
+ });
2477
+ });
2478
+ });
2479
+
2480
+ describe('QueryOverviewPanel - Cube Materialization Section', () => {
2481
+ // Cube section only appears when workflowUrls.length > 0
2482
+ // Also, selectedMetrics AND selectedDimensions must be non-empty to render main content
2483
+ const mockMeasuresResult = {
2484
+ grain_groups: [
2485
+ {
2486
+ parent_name: 'default.repair_orders',
2487
+ grain: ['date_id'],
2488
+ components: [{ name: 'count_orders' }],
2489
+ },
2490
+ ],
2491
+ metric_formulas: [
2492
+ {
2493
+ name: 'default.num_repair_orders',
2494
+ short_name: 'num_repair_orders',
2495
+ combiner: 'SUM(count_orders)',
2496
+ components: ['count_orders'],
2497
+ },
2498
+ ],
2499
+ };
2500
+
2501
+ const mockCubeMaterialization = {
2502
+ strategy: 'incremental_time',
2503
+ schedule: '0 8 * * *',
2504
+ lookbackWindow: '2 days',
2505
+ druidDatasource: 'dj__test_cube',
2506
+ preaggTables: ['default.repair_orders'],
2507
+ };
2508
+
2509
+ // These workflow URLs trigger the cube section to show
2510
+ const mockWorkflowUrls = [
2511
+ 'https://workflow.example.com/scheduled',
2512
+ 'https://workflow.example.com/adhoc_backfill',
2513
+ ];
2514
+
2515
+ const baseProps = {
2516
+ measuresResult: mockMeasuresResult,
2517
+ metricsResult: { sql: 'SELECT ...' },
2518
+ selectedMetrics: ['default.num_repair_orders'],
2519
+ selectedDimensions: ['default.date_dim.date_id'], // Must have at least one dimension
2520
+ plannedPreaggs: {},
2521
+ loadedCubeName: 'default.test_cube',
2522
+ cubeMaterialization: mockCubeMaterialization,
2523
+ workflowUrls: mockWorkflowUrls, // This triggers cube section
2524
+ onUpdateCubeConfig: jest.fn(),
2525
+ onRefreshCubeWorkflow: jest.fn(),
2526
+ onRunCubeBackfill: jest.fn(),
2527
+ onDeactivateCubeWorkflow: jest.fn(),
2528
+ onFetchNodePartitions: jest.fn().mockResolvedValue({
2529
+ columns: [],
2530
+ temporalPartitions: [],
2531
+ }),
2532
+ };
2533
+
2534
+ beforeEach(() => {
2535
+ jest.clearAllMocks();
2536
+ });
2537
+
2538
+ describe('Cube Summary Display', () => {
2539
+ it('shows cube section when workflowUrls exist', () => {
2540
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2541
+ expect(screen.getByText('Druid Cube')).toBeInTheDocument();
2542
+ });
2543
+
2544
+ it('displays cube datasource name', () => {
2545
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2546
+ expect(screen.getByText('dj__test_cube')).toBeInTheDocument();
2547
+ });
2548
+
2549
+ it('shows Active status for configured cube', () => {
2550
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2551
+ const activePills = screen.getAllByText('● Active');
2552
+ expect(activePills.length).toBeGreaterThan(0);
2553
+ });
2554
+
2555
+ it('shows Workflow active status', () => {
2556
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2557
+ expect(screen.getByText('Workflow active')).toBeInTheDocument();
2558
+ });
2559
+ });
2560
+
2561
+ describe('Cube Card Expansion', () => {
2562
+ it('expands cube card when clicked', async () => {
2563
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2564
+
2565
+ // Find cube expand button (first one)
2566
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2567
+ await act(async () => {
2568
+ fireEvent.click(expandBtns[0]);
2569
+ });
2570
+
2571
+ // Should show strategy details
2572
+ await waitFor(() => {
2573
+ expect(screen.getByText('Strategy:')).toBeInTheDocument();
2574
+ });
2575
+ });
2576
+
2577
+ it('shows workflow links when expanded', async () => {
2578
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2579
+
2580
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2581
+ await act(async () => {
2582
+ fireEvent.click(expandBtns[0]);
2583
+ });
2584
+
2585
+ await waitFor(() => {
2586
+ // Check for workflow links
2587
+ expect(screen.getByText('Workflows:')).toBeInTheDocument();
2588
+ });
2589
+ });
2590
+
2591
+ it('shows action buttons when expanded', async () => {
2592
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2593
+
2594
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2595
+ await act(async () => {
2596
+ fireEvent.click(expandBtns[0]);
2597
+ });
2598
+
2599
+ await waitFor(() => {
2600
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
2601
+ expect(screen.getByText('↻ Refresh')).toBeInTheDocument();
2602
+ });
2603
+ });
2604
+ });
2605
+
2606
+ describe('Cube Edit Config Form', () => {
2607
+ it('opens edit form when Edit Config is clicked', async () => {
2608
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2609
+
2610
+ // Expand cube card first
2611
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2612
+ await act(async () => {
2613
+ fireEvent.click(expandBtns[0]);
2614
+ });
2615
+
2616
+ await waitFor(() => {
2617
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
2618
+ });
2619
+
2620
+ // Click Edit Config
2621
+ await act(async () => {
2622
+ fireEvent.click(screen.getByText('Edit Config'));
2623
+ });
2624
+
2625
+ await waitFor(() => {
2626
+ expect(
2627
+ screen.getByText('Edit Materialization Config'),
2628
+ ).toBeInTheDocument();
2629
+ });
2630
+ });
2631
+
2632
+ it('shows strategy options in edit form', async () => {
2633
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2634
+
2635
+ // Expand and open edit
2636
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2637
+ await act(async () => {
2638
+ fireEvent.click(expandBtns[0]);
2639
+ });
2640
+
2641
+ await waitFor(() => {
2642
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
2643
+ });
2644
+
2645
+ await act(async () => {
2646
+ fireEvent.click(screen.getByText('Edit Config'));
2647
+ });
2648
+
2649
+ await waitFor(() => {
2650
+ expect(screen.getByText('Full')).toBeInTheDocument();
2651
+ expect(screen.getByText('Incremental (Time)')).toBeInTheDocument();
2652
+ });
2653
+ });
2654
+
2655
+ it('calls onUpdateCubeConfig when Save is clicked', async () => {
2656
+ const onUpdateCubeConfig = jest.fn().mockResolvedValue({});
2657
+ renderWithRouter(
2658
+ <QueryOverviewPanel
2659
+ {...baseProps}
2660
+ onUpdateCubeConfig={onUpdateCubeConfig}
2661
+ />,
2662
+ );
2663
+
2664
+ // Expand and open edit
2665
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2666
+ await act(async () => {
2667
+ fireEvent.click(expandBtns[0]);
2668
+ });
2669
+
2670
+ await waitFor(() => {
2671
+ expect(screen.getByText('Edit Config')).toBeInTheDocument();
2672
+ });
2673
+
2674
+ await act(async () => {
2675
+ fireEvent.click(screen.getByText('Edit Config'));
2676
+ });
2677
+
2678
+ await waitFor(() => {
2679
+ expect(screen.getByText('Save')).toBeInTheDocument();
2680
+ });
2681
+
2682
+ await act(async () => {
2683
+ fireEvent.click(screen.getByText('Save'));
2684
+ });
2685
+
2686
+ await waitFor(() => {
2687
+ expect(onUpdateCubeConfig).toHaveBeenCalled();
2688
+ });
2689
+ });
2690
+ });
2691
+
2692
+ describe('Cube Workflow Actions', () => {
2693
+ it('calls onRefreshCubeWorkflow when Refresh is clicked', async () => {
2694
+ const onRefreshCubeWorkflow = jest.fn().mockResolvedValue({});
2695
+ renderWithRouter(
2696
+ <QueryOverviewPanel
2697
+ {...baseProps}
2698
+ onRefreshCubeWorkflow={onRefreshCubeWorkflow}
2699
+ />,
2700
+ );
2701
+
2702
+ // Expand cube card
2703
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2704
+ await act(async () => {
2705
+ fireEvent.click(expandBtns[0]);
2706
+ });
2707
+
2708
+ await waitFor(() => {
2709
+ expect(screen.getByText('↻ Refresh')).toBeInTheDocument();
2710
+ });
2711
+
2712
+ await act(async () => {
2713
+ fireEvent.click(screen.getByText('↻ Refresh'));
2714
+ });
2715
+
2716
+ await waitFor(() => {
2717
+ expect(onRefreshCubeWorkflow).toHaveBeenCalled();
2718
+ });
2719
+ });
2720
+
2721
+ it('calls onDeactivateCubeWorkflow when Deactivate is clicked and confirmed', async () => {
2722
+ const onDeactivateCubeWorkflow = jest.fn().mockResolvedValue({});
2723
+ window.confirm = jest.fn().mockReturnValue(true);
2724
+
2725
+ renderWithRouter(
2726
+ <QueryOverviewPanel
2727
+ {...baseProps}
2728
+ onDeactivateCubeWorkflow={onDeactivateCubeWorkflow}
2729
+ />,
2730
+ );
2731
+
2732
+ // Expand cube card
2733
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2734
+ await act(async () => {
2735
+ fireEvent.click(expandBtns[0]);
2736
+ });
2737
+
2738
+ await waitFor(() => {
2739
+ expect(screen.getByText('⏹ Deactivate')).toBeInTheDocument();
2740
+ });
2741
+
2742
+ await act(async () => {
2743
+ fireEvent.click(screen.getByText('⏹ Deactivate'));
2744
+ });
2745
+
2746
+ await waitFor(() => {
2747
+ expect(window.confirm).toHaveBeenCalled();
2748
+ expect(onDeactivateCubeWorkflow).toHaveBeenCalled();
2749
+ });
2750
+ });
2751
+ });
2752
+
2753
+ describe('Cube Backfill Modal', () => {
2754
+ it('opens cube backfill modal when Run Backfill is clicked', async () => {
2755
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2756
+
2757
+ // Expand cube card
2758
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2759
+ await act(async () => {
2760
+ fireEvent.click(expandBtns[0]);
2761
+ });
2762
+
2763
+ await waitFor(() => {
2764
+ const backfillBtns = screen.getAllByText('Run Backfill');
2765
+ expect(backfillBtns.length).toBeGreaterThan(0);
2766
+ });
2767
+
2768
+ const backfillBtns = screen.getAllByText('Run Backfill');
2769
+ await act(async () => {
2770
+ fireEvent.click(backfillBtns[0]);
2771
+ });
2772
+
2773
+ // Modal should show
2774
+ await waitFor(() => {
2775
+ expect(screen.getByText('Run Cube Backfill')).toBeInTheDocument();
2776
+ });
2777
+ });
2778
+
2779
+ it('shows backfill description in modal', async () => {
2780
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2781
+
2782
+ // Expand and open modal
2783
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2784
+ await act(async () => {
2785
+ fireEvent.click(expandBtns[0]);
2786
+ });
2787
+
2788
+ await waitFor(() => {
2789
+ const backfillBtns = screen.getAllByText('Run Backfill');
2790
+ expect(backfillBtns.length).toBeGreaterThan(0);
2791
+ });
2792
+
2793
+ const backfillBtns = screen.getAllByText('Run Backfill');
2794
+ await act(async () => {
2795
+ fireEvent.click(backfillBtns[0]);
2796
+ });
2797
+
2798
+ await waitFor(() => {
2799
+ expect(
2800
+ screen.getByText(/Run a backfill for the specified date range/i),
2801
+ ).toBeInTheDocument();
2802
+ });
2803
+ });
2804
+
2805
+ it('calls onRunCubeBackfill when Start Backfill is clicked', async () => {
2806
+ const onRunCubeBackfill = jest.fn().mockResolvedValue({
2807
+ workflow_urls: ['https://workflow.example.com/backfill'],
2808
+ });
2809
+ renderWithRouter(
2810
+ <QueryOverviewPanel
2811
+ {...baseProps}
2812
+ onRunCubeBackfill={onRunCubeBackfill}
2813
+ />,
2814
+ );
2815
+
2816
+ // Expand and open modal
2817
+ const expandBtns = screen.getAllByRole('button', { name: 'Expand' });
2818
+ await act(async () => {
2819
+ fireEvent.click(expandBtns[0]);
2820
+ });
2821
+
2822
+ await waitFor(() => {
2823
+ const backfillBtns = screen.getAllByText('Run Backfill');
2824
+ expect(backfillBtns.length).toBeGreaterThan(0);
2825
+ });
2826
+
2827
+ const backfillBtns = screen.getAllByText('Run Backfill');
2828
+ await act(async () => {
2829
+ fireEvent.click(backfillBtns[0]);
2830
+ });
2831
+
2832
+ await waitFor(() => {
2833
+ expect(screen.getByText('Run Cube Backfill')).toBeInTheDocument();
2834
+ });
2835
+
2836
+ // Click Start Backfill
2837
+ await act(async () => {
2838
+ fireEvent.click(screen.getByText('Start Backfill'));
2839
+ });
2840
+
2841
+ await waitFor(() => {
2842
+ expect(onRunCubeBackfill).toHaveBeenCalled();
2843
+ });
2844
+ });
2845
+ });
2846
+ });
2847
+
2848
+ describe('QueryOverviewPanel - Custom Schedule and Druid Config', () => {
2849
+ // This tests the materialization config form which shows when there are unconfigured preaggs
2850
+ const mockMeasuresResult = {
2851
+ grain_groups: [
2852
+ {
2853
+ parent_name: 'default.repair_orders',
2854
+ grain: ['date_id'],
2855
+ components: [{ name: 'count_orders' }],
2856
+ },
2857
+ ],
2858
+ metric_formulas: [
2859
+ {
2860
+ name: 'default.num_repair_orders',
2861
+ short_name: 'num_repair_orders',
2862
+ combiner: 'SUM(count_orders)',
2863
+ components: ['count_orders'],
2864
+ },
2865
+ ],
2866
+ };
2867
+
2868
+ const baseProps = {
2869
+ measuresResult: mockMeasuresResult,
2870
+ metricsResult: { sql: 'SELECT ...' },
2871
+ selectedMetrics: ['default.num_repair_orders'],
2872
+ selectedDimensions: ['default.date_dim.date_id'], // Must have at least one dimension
2873
+ plannedPreaggs: {}, // No planned preaggs = shows Configure button
2874
+ onPlanMaterialization: jest.fn(),
2875
+ onFetchNodePartitions: jest.fn().mockResolvedValue({
2876
+ columns: [{ name: 'date_id', type: 'int' }],
2877
+ temporalPartitions: [{ name: 'date_id', granularity: 'DAY' }],
2878
+ }),
2879
+ };
2880
+
2881
+ beforeEach(() => {
2882
+ jest.clearAllMocks();
2883
+ });
2884
+
2885
+ describe('Custom Schedule Input', () => {
2886
+ it('shows schedule options in config form', async () => {
2887
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2888
+
2889
+ // Open config form
2890
+ const configureBtn = screen.getByText('Configure');
2891
+ await act(async () => {
2892
+ fireEvent.click(configureBtn);
2893
+ });
2894
+
2895
+ // Wait for form to load
2896
+ await waitFor(() => {
2897
+ expect(
2898
+ screen.getByText('Configure Materialization'),
2899
+ ).toBeInTheDocument();
2900
+ });
2901
+
2902
+ // Should have schedule label
2903
+ expect(screen.getByText('Schedule')).toBeInTheDocument();
2904
+ });
2905
+
2906
+ it('allows selecting custom schedule type', async () => {
2907
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2908
+
2909
+ // Open config form
2910
+ const configureBtn = screen.getByText('Configure');
2911
+ await act(async () => {
2912
+ fireEvent.click(configureBtn);
2913
+ });
2914
+
2915
+ await waitFor(() => {
2916
+ expect(
2917
+ screen.getByText('Configure Materialization'),
2918
+ ).toBeInTheDocument();
2919
+ });
2920
+
2921
+ // Find and change schedule select
2922
+ const selects = screen.getAllByRole('combobox');
2923
+ expect(selects.length).toBeGreaterThan(0);
2924
+ });
2925
+ });
2926
+
2927
+ describe('Druid Cube Namespace and Name Inputs', () => {
2928
+ it('shows Druid cube config when checkbox is enabled', async () => {
2929
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2930
+
2931
+ // Open config form
2932
+ const configureBtn = screen.getByText('Configure');
2933
+ await act(async () => {
2934
+ fireEvent.click(configureBtn);
2935
+ });
2936
+
2937
+ await waitFor(() => {
2938
+ expect(
2939
+ screen.getByText('Enable Druid cube materialization'),
2940
+ ).toBeInTheDocument();
2941
+ });
2942
+
2943
+ // The checkbox should be checked by default, showing cube name inputs
2944
+ await waitFor(() => {
2945
+ expect(screen.getByText('Cube Name')).toBeInTheDocument();
2946
+ });
2947
+ });
2948
+
2949
+ it('shows cube namespace and name placeholders', async () => {
2950
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2951
+
2952
+ // Open config form
2953
+ const configureBtn = screen.getByText('Configure');
2954
+ await act(async () => {
2955
+ fireEvent.click(configureBtn);
2956
+ });
2957
+
2958
+ await waitFor(() => {
2959
+ expect(screen.getByPlaceholderText('users.myname')).toBeInTheDocument();
2960
+ expect(screen.getByPlaceholderText('my_cube')).toBeInTheDocument();
2961
+ });
2962
+ });
2963
+
2964
+ it('shows full cube name preview', async () => {
2965
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2966
+
2967
+ // Open config form
2968
+ const configureBtn = screen.getByText('Configure');
2969
+ await act(async () => {
2970
+ fireEvent.click(configureBtn);
2971
+ });
2972
+
2973
+ await waitFor(() => {
2974
+ expect(screen.getByText('Full name:')).toBeInTheDocument();
2975
+ });
2976
+ });
2977
+ });
2978
+
2979
+ describe('Form submission button text', () => {
2980
+ it('shows Create Pre-Agg Workflows button when Druid is enabled', async () => {
2981
+ renderWithRouter(<QueryOverviewPanel {...baseProps} />);
2982
+
2983
+ // Open config form
2984
+ const configureBtn = screen.getByText('Configure');
2985
+ await act(async () => {
2986
+ fireEvent.click(configureBtn);
2987
+ });
2988
+
2989
+ await waitFor(() => {
2990
+ expect(
2991
+ screen.getByText('Create Pre-Agg Workflows & Schedule Cube'),
2992
+ ).toBeInTheDocument();
2993
+ });
636
2994
  });
637
2995
  });
638
2996
  });