datajunction-ui 0.0.43 → 0.0.45

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.
@@ -17,6 +17,11 @@ export function SelectionPanel({
17
17
  onLoadCubePreset,
18
18
  loadedCubeName = null, // Managed by parent for URL persistence
19
19
  onClearSelection,
20
+ filters = [],
21
+ onFiltersChange,
22
+ onRunQuery,
23
+ canRunQuery = false,
24
+ queryLoading = false,
20
25
  }) {
21
26
  const [metricsSearch, setMetricsSearch] = useState('');
22
27
  const [dimensionsSearch, setDimensionsSearch] = useState('');
@@ -25,8 +30,11 @@ export function SelectionPanel({
25
30
  const [cubeSearch, setCubeSearch] = useState('');
26
31
  const [metricsChipsExpanded, setMetricsChipsExpanded] = useState(false);
27
32
  const [dimensionsChipsExpanded, setDimensionsChipsExpanded] = useState(false);
33
+ const [filterInput, setFilterInput] = useState('');
28
34
  const prevSearchRef = useRef('');
29
35
  const cubeDropdownRef = useRef(null);
36
+ const metricsSearchRef = useRef(null);
37
+ const dimensionsSearchRef = useRef(null);
30
38
 
31
39
  // Threshold for showing expand/collapse button
32
40
  const CHIPS_COLLAPSE_THRESHOLD = 8;
@@ -261,6 +269,27 @@ export function SelectionPanel({
261
269
  }
262
270
  };
263
271
 
272
+ const handleAddFilter = () => {
273
+ const trimmed = filterInput.trim();
274
+ if (trimmed && !filters.includes(trimmed) && onFiltersChange) {
275
+ onFiltersChange([...filters, trimmed]);
276
+ setFilterInput('');
277
+ }
278
+ };
279
+
280
+ const handleFilterKeyDown = e => {
281
+ if (e.key === 'Enter') {
282
+ e.preventDefault();
283
+ handleAddFilter();
284
+ }
285
+ };
286
+
287
+ const handleRemoveFilter = filterToRemove => {
288
+ if (onFiltersChange) {
289
+ onFiltersChange(filters.filter(f => f !== filterToRemove));
290
+ }
291
+ };
292
+
264
293
  return (
265
294
  <div className="selection-panel">
266
295
  {/* Cube Preset Dropdown */}
@@ -286,7 +315,7 @@ export function SelectionPanel({
286
315
  </button>
287
316
  {(selectedMetrics.length > 0 || selectedDimensions.length > 0) && (
288
317
  <button className="clear-all-btn" onClick={clearSelection}>
289
- Clear all
318
+ Clear
290
319
  </button>
291
320
  )}
292
321
  </div>
@@ -312,7 +341,9 @@ export function SelectionPanel({
312
341
  filteredCubes.map(cube => (
313
342
  <button
314
343
  key={cube.name}
315
- className="cube-option"
344
+ className={`cube-option ${
345
+ loadedCubeName === cube.name ? 'selected' : ''
346
+ }`}
316
347
  onClick={() => handleCubeSelect(cube)}
317
348
  >
318
349
  <span className="cube-name">
@@ -320,6 +351,9 @@ export function SelectionPanel({
320
351
  (cube.name ? cube.name.split('.').pop() : 'Unknown')}
321
352
  </span>
322
353
  <span className="cube-info">{cube.name}</span>
354
+ {loadedCubeName === cube.name && (
355
+ <span className="cube-selected-icon">✓</span>
356
+ )}
323
357
  </button>
324
358
  ))
325
359
  )}
@@ -338,62 +372,71 @@ export function SelectionPanel({
338
372
  </span>
339
373
  </div>
340
374
 
341
- {/* Selected Metrics Chips */}
342
- {selectedMetrics.length > 0 && (
343
- <div className="selected-chips-container">
375
+ {/* Combined Chips + Search Input */}
376
+ <div
377
+ className="combobox-input"
378
+ onClick={() => metricsSearchRef.current?.focus()}
379
+ >
380
+ {selectedMetrics.length > 0 && (
344
381
  <div
345
- className={`selected-chips-wrapper ${
346
- metricsChipsExpanded ? 'expanded' : ''
382
+ className={`combobox-chips ${
383
+ selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD
384
+ ? metricsChipsExpanded
385
+ ? 'expanded'
386
+ : 'collapsed'
387
+ : ''
347
388
  }`}
348
389
  >
349
- <div className="selected-chips">
350
- {selectedMetrics.map(metric => (
351
- <span key={metric} className="selected-chip metric-chip">
352
- <span className="chip-label">{getShortName(metric)}</span>
353
- <button
354
- className="chip-remove"
355
- onClick={() => removeMetric(metric)}
356
- title={`Remove ${getShortName(metric)}`}
357
- >
358
- ×
359
- </button>
360
- </span>
361
- ))}
362
- </div>
390
+ {selectedMetrics.map(metric => (
391
+ <span key={metric} className="selected-chip metric-chip">
392
+ {getShortName(metric)}
393
+ <button
394
+ className="chip-remove"
395
+ onClick={e => {
396
+ e.stopPropagation();
397
+ removeMetric(metric);
398
+ }}
399
+ title={`Remove ${getShortName(metric)}`}
400
+ >
401
+ ×
402
+ </button>
403
+ </span>
404
+ ))}
363
405
  </div>
406
+ )}
407
+ <div className="combobox-input-row">
408
+ <input
409
+ ref={metricsSearchRef}
410
+ type="text"
411
+ className="combobox-search"
412
+ placeholder="Search metrics..."
413
+ value={metricsSearch}
414
+ onChange={e => setMetricsSearch(e.target.value)}
415
+ onClick={e => e.stopPropagation()}
416
+ />
364
417
  {selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD && (
365
418
  <button
366
- className="chips-toggle"
367
- onClick={() => setMetricsChipsExpanded(!metricsChipsExpanded)}
419
+ className="combobox-action"
420
+ onClick={e => {
421
+ e.stopPropagation();
422
+ setMetricsChipsExpanded(!metricsChipsExpanded);
423
+ }}
368
424
  >
369
- <span>
370
- {metricsChipsExpanded
371
- ? 'Show less'
372
- : `Show all ${selectedMetrics.length}`}
373
- </span>
374
- <span className="chips-toggle-icon">
375
- {metricsChipsExpanded ? '▲' : '▼'}
376
- </span>
425
+ {metricsChipsExpanded ? 'Show less' : 'Show all'}
426
+ </button>
427
+ )}
428
+ {selectedMetrics.length > 0 && (
429
+ <button
430
+ className="combobox-action"
431
+ onClick={e => {
432
+ e.stopPropagation();
433
+ onMetricsChange([]);
434
+ }}
435
+ >
436
+ Clear
377
437
  </button>
378
438
  )}
379
439
  </div>
380
- )}
381
-
382
- <div className="search-box">
383
- <input
384
- type="text"
385
- placeholder="Search metrics..."
386
- value={metricsSearch}
387
- onChange={e => setMetricsSearch(e.target.value)}
388
- />
389
- {metricsSearch && (
390
- <button
391
- className="clear-search"
392
- onClick={() => setMetricsSearch('')}
393
- >
394
- ×
395
- </button>
396
- )}
397
440
  </div>
398
441
 
399
442
  <div className="selection-list">
@@ -489,69 +532,74 @@ export function SelectionPanel({
489
532
  <div className="empty-list">Loading dimensions...</div>
490
533
  ) : (
491
534
  <>
492
- {/* Selected Dimensions Chips */}
493
- {selectedDimensions.length > 0 && (
494
- <div className="selected-chips-container">
535
+ {/* Combined Chips + Search Input */}
536
+ <div
537
+ className="combobox-input"
538
+ onClick={() => dimensionsSearchRef.current?.focus()}
539
+ >
540
+ {selectedDimensions.length > 0 && (
495
541
  <div
496
- className={`selected-chips-wrapper ${
497
- dimensionsChipsExpanded ? 'expanded' : ''
542
+ className={`combobox-chips ${
543
+ selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD
544
+ ? dimensionsChipsExpanded
545
+ ? 'expanded'
546
+ : 'collapsed'
547
+ : ''
498
548
  }`}
499
549
  >
500
- <div className="selected-chips">
501
- {selectedDimensions.map(dimName => (
502
- <span
503
- key={dimName}
504
- className="selected-chip dimension-chip"
550
+ {selectedDimensions.map(dimName => (
551
+ <span
552
+ key={dimName}
553
+ className="selected-chip dimension-chip"
554
+ >
555
+ {getDimDisplayName(dimName)}
556
+ <button
557
+ className="chip-remove"
558
+ onClick={e => {
559
+ e.stopPropagation();
560
+ removeDimension(dimName);
561
+ }}
562
+ title={`Remove ${getDimDisplayName(dimName)}`}
505
563
  >
506
- <span className="chip-label">
507
- {getDimDisplayName(dimName)}
508
- </span>
509
- <button
510
- className="chip-remove"
511
- onClick={() => removeDimension(dimName)}
512
- title={`Remove ${getDimDisplayName(dimName)}`}
513
- >
514
- ×
515
- </button>
516
- </span>
517
- ))}
518
- </div>
564
+ ×
565
+ </button>
566
+ </span>
567
+ ))}
519
568
  </div>
569
+ )}
570
+ <div className="combobox-input-row">
571
+ <input
572
+ ref={dimensionsSearchRef}
573
+ type="text"
574
+ className="combobox-search"
575
+ placeholder="Search dimensions..."
576
+ value={dimensionsSearch}
577
+ onChange={e => setDimensionsSearch(e.target.value)}
578
+ onClick={e => e.stopPropagation()}
579
+ />
520
580
  {selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD && (
521
581
  <button
522
- className="chips-toggle"
523
- onClick={() =>
524
- setDimensionsChipsExpanded(!dimensionsChipsExpanded)
525
- }
582
+ className="combobox-action"
583
+ onClick={e => {
584
+ e.stopPropagation();
585
+ setDimensionsChipsExpanded(!dimensionsChipsExpanded);
586
+ }}
526
587
  >
527
- <span>
528
- {dimensionsChipsExpanded
529
- ? 'Show less'
530
- : `Show all ${selectedDimensions.length}`}
531
- </span>
532
- <span className="chips-toggle-icon">
533
- {dimensionsChipsExpanded ? '▲' : '▼'}
534
- </span>
588
+ {dimensionsChipsExpanded ? 'Show less' : 'Show all'}
589
+ </button>
590
+ )}
591
+ {selectedDimensions.length > 0 && (
592
+ <button
593
+ className="combobox-action"
594
+ onClick={e => {
595
+ e.stopPropagation();
596
+ onDimensionsChange([]);
597
+ }}
598
+ >
599
+ Clear
535
600
  </button>
536
601
  )}
537
602
  </div>
538
- )}
539
-
540
- <div className="search-box">
541
- <input
542
- type="text"
543
- placeholder="Search dimensions..."
544
- value={dimensionsSearch}
545
- onChange={e => setDimensionsSearch(e.target.value)}
546
- />
547
- {dimensionsSearch && (
548
- <button
549
- className="clear-search"
550
- onClick={() => setDimensionsSearch('')}
551
- >
552
- ×
553
- </button>
554
- )}
555
603
  </div>
556
604
 
557
605
  <div className="selection-list dimensions-list">
@@ -586,6 +634,83 @@ export function SelectionPanel({
586
634
  </>
587
635
  )}
588
636
  </div>
637
+
638
+ {/* Divider */}
639
+ <div className="section-divider" />
640
+
641
+ {/* Filters Section */}
642
+ <div className="selection-section filters-section">
643
+ <div className="section-header">
644
+ <h3>Filters</h3>
645
+ <span className="selection-count">{filters.length} applied</span>
646
+ </div>
647
+
648
+ {/* Filter chips */}
649
+ {filters.length > 0 && (
650
+ <div className="filter-chips-container">
651
+ {filters.map((filter, idx) => (
652
+ <span key={idx} className="filter-chip">
653
+ <span className="filter-chip-text">{filter}</span>
654
+ <button
655
+ className="filter-chip-remove"
656
+ onClick={() => handleRemoveFilter(filter)}
657
+ title="Remove filter"
658
+ >
659
+ ×
660
+ </button>
661
+ </span>
662
+ ))}
663
+ </div>
664
+ )}
665
+
666
+ {/* Filter input */}
667
+ <div className="filter-input-container">
668
+ <input
669
+ type="text"
670
+ className="filter-input"
671
+ placeholder="e.g. v3.date.date_id >= '2024-01-01'"
672
+ value={filterInput}
673
+ onChange={e => setFilterInput(e.target.value)}
674
+ onKeyDown={handleFilterKeyDown}
675
+ />
676
+ <button
677
+ className="filter-add-btn"
678
+ onClick={handleAddFilter}
679
+ disabled={!filterInput.trim()}
680
+ >
681
+ Add
682
+ </button>
683
+ </div>
684
+ </div>
685
+
686
+ {/* Run Query Section */}
687
+ <div className="run-query-section">
688
+ <button
689
+ className="run-query-btn"
690
+ onClick={onRunQuery}
691
+ disabled={!canRunQuery || queryLoading}
692
+ >
693
+ {queryLoading ? (
694
+ <>
695
+ <span className="spinner small" />
696
+ Running...
697
+ </>
698
+ ) : (
699
+ <>
700
+ <span className="run-icon">▶</span>
701
+ Run Query
702
+ </>
703
+ )}
704
+ </button>
705
+ {!canRunQuery && selectedMetrics.length > 0 && (
706
+ <span className="run-hint">Select at least one dimension</span>
707
+ )}
708
+ {!canRunQuery && selectedMetrics.length === 0 && (
709
+ <span className="run-hint">
710
+ Select metrics and dimensions to run a query
711
+ </span>
712
+ )}
713
+ </div>
589
714
  </div>
590
715
  );
591
716
  }
@@ -429,4 +429,197 @@ describe('MetricFlowGraph Node Display', () => {
429
429
  expect(screen.getByText('num_repair_orders')).toBeInTheDocument();
430
430
  expect(screen.getByText('avg_repair_price')).toBeInTheDocument();
431
431
  });
432
+
433
+ describe('Branch Coverage - Edge Cases', () => {
434
+ it('handles grain group with empty grain array', () => {
435
+ const grainGroupsEmptyGrain = [
436
+ {
437
+ parent_name: 'default.orders',
438
+ grain: [], // Empty grain array
439
+ components: [{ name: 'count_orders', expression: 'COUNT(*)' }],
440
+ },
441
+ ];
442
+
443
+ render(
444
+ <MetricFlowGraph
445
+ grainGroups={grainGroupsEmptyGrain}
446
+ metricFormulas={[
447
+ {
448
+ name: 'default.metric',
449
+ short_name: 'metric',
450
+ components: ['count_orders'],
451
+ is_derived: false,
452
+ },
453
+ ]}
454
+ onNodeSelect={jest.fn()}
455
+ />,
456
+ );
457
+
458
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
459
+ });
460
+
461
+ it('handles grain group with no components', () => {
462
+ const grainGroupsNoComponents = [
463
+ {
464
+ parent_name: 'default.orders',
465
+ grain: ['date_id'],
466
+ components: [], // Empty components
467
+ },
468
+ ];
469
+
470
+ render(
471
+ <MetricFlowGraph
472
+ grainGroups={grainGroupsNoComponents}
473
+ metricFormulas={[
474
+ {
475
+ name: 'default.metric',
476
+ short_name: 'metric',
477
+ components: [],
478
+ is_derived: false,
479
+ },
480
+ ]}
481
+ onNodeSelect={jest.fn()}
482
+ />,
483
+ );
484
+
485
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
486
+ });
487
+
488
+ it('handles grain group with undefined components', () => {
489
+ const grainGroupsUndefinedComponents = [
490
+ {
491
+ parent_name: 'default.orders',
492
+ grain: ['date_id'],
493
+ // components is undefined
494
+ },
495
+ ];
496
+
497
+ render(
498
+ <MetricFlowGraph
499
+ grainGroups={grainGroupsUndefinedComponents}
500
+ metricFormulas={[
501
+ {
502
+ name: 'default.metric',
503
+ short_name: 'metric',
504
+ components: [],
505
+ is_derived: false,
506
+ },
507
+ ]}
508
+ onNodeSelect={jest.fn()}
509
+ />,
510
+ );
511
+
512
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
513
+ });
514
+
515
+ it('handles metric with is_derived false', () => {
516
+ render(
517
+ <MetricFlowGraph
518
+ grainGroups={mockGrainGroups}
519
+ metricFormulas={[
520
+ {
521
+ name: 'default.simple_metric',
522
+ short_name: 'simple_metric',
523
+ combiner: 'SUM(count)',
524
+ is_derived: false,
525
+ components: ['count_orders'],
526
+ },
527
+ ]}
528
+ onNodeSelect={jest.fn()}
529
+ />,
530
+ );
531
+
532
+ expect(screen.getByText('simple_metric')).toBeInTheDocument();
533
+ });
534
+
535
+ it('handles metric with is_derived true', () => {
536
+ render(
537
+ <MetricFlowGraph
538
+ grainGroups={mockGrainGroups}
539
+ metricFormulas={[
540
+ {
541
+ name: 'default.derived_metric',
542
+ short_name: 'derived_metric',
543
+ combiner: 'SUM(a) / SUM(b)',
544
+ is_derived: true,
545
+ components: ['sum_revenue', 'count_orders'],
546
+ },
547
+ ]}
548
+ onNodeSelect={jest.fn()}
549
+ />,
550
+ );
551
+
552
+ expect(screen.getByText('derived_metric')).toBeInTheDocument();
553
+ });
554
+
555
+ it('handles selectedNode prop for preagg', () => {
556
+ render(
557
+ <MetricFlowGraph
558
+ grainGroups={mockGrainGroups}
559
+ metricFormulas={mockMetricFormulas}
560
+ onNodeSelect={jest.fn()}
561
+ selectedNode={{ type: 'preagg', index: 0, data: mockGrainGroups[0] }}
562
+ />,
563
+ );
564
+
565
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
566
+ });
567
+
568
+ it('handles selectedNode prop for metric', () => {
569
+ render(
570
+ <MetricFlowGraph
571
+ grainGroups={mockGrainGroups}
572
+ metricFormulas={mockMetricFormulas}
573
+ onNodeSelect={jest.fn()}
574
+ selectedNode={{
575
+ type: 'metric',
576
+ index: 0,
577
+ data: mockMetricFormulas[0],
578
+ }}
579
+ />,
580
+ );
581
+
582
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
583
+ });
584
+
585
+ it('handles no selectedNode', () => {
586
+ render(
587
+ <MetricFlowGraph
588
+ grainGroups={mockGrainGroups}
589
+ metricFormulas={mockMetricFormulas}
590
+ onNodeSelect={jest.fn()}
591
+ selectedNode={null}
592
+ />,
593
+ );
594
+
595
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
596
+ });
597
+
598
+ it('handles grain group with undefined grain', () => {
599
+ const grainGroupsUndefinedGrain = [
600
+ {
601
+ parent_name: 'default.orders',
602
+ // grain is undefined
603
+ components: [{ name: 'count_orders', expression: 'COUNT(*)' }],
604
+ },
605
+ ];
606
+
607
+ render(
608
+ <MetricFlowGraph
609
+ grainGroups={grainGroupsUndefinedGrain}
610
+ metricFormulas={[
611
+ {
612
+ name: 'default.metric',
613
+ short_name: 'metric',
614
+ components: ['count_orders'],
615
+ is_derived: false,
616
+ },
617
+ ]}
618
+ onNodeSelect={jest.fn()}
619
+ />,
620
+ );
621
+
622
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
623
+ });
624
+ });
432
625
  });