datajunction-ui 0.0.94 → 0.0.96
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.
- package/package.json +1 -1
- package/src/app/components/Search.jsx +26 -25
- package/src/app/components/Tab.jsx +6 -1
- package/src/app/components/__tests__/Search.test.jsx +15 -23
- package/src/app/components/search.css +43 -5
- package/src/app/icons/ChartIcon.jsx +28 -0
- package/src/app/pages/NodePage/__tests__/NodeDimensionsTab.test.jsx +50 -0
- package/src/app/pages/NodePage/index.jsx +6 -2
- package/src/app/pages/QueryPlannerPage/ResultsView.jsx +566 -86
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +32 -1
- package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +326 -0
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +431 -2
- package/src/app/pages/QueryPlannerPage/index.jsx +31 -5
- package/src/app/pages/QueryPlannerPage/styles.css +220 -2
- package/src/app/pages/Root/__tests__/index.test.jsx +2 -2
- package/src/app/pages/Root/index.tsx +2 -2
- package/src/app/services/DJService.js +60 -17
- package/src/app/services/__tests__/DJService.test.jsx +13 -15
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
2
2
|
|
|
3
|
+
const ENGINE_OPTIONS = [
|
|
4
|
+
{ value: null, label: 'Auto' },
|
|
5
|
+
{ value: 'druid', label: 'Druid' },
|
|
6
|
+
{ value: 'trino', label: 'Trino' },
|
|
7
|
+
];
|
|
8
|
+
|
|
3
9
|
/**
|
|
4
10
|
* SelectionPanel - Browse and select metrics and dimensions
|
|
5
11
|
* Features selected items as chips at the top for visibility
|
|
@@ -19,6 +25,8 @@ export function SelectionPanel({
|
|
|
19
25
|
onClearSelection,
|
|
20
26
|
filters = [],
|
|
21
27
|
onFiltersChange,
|
|
28
|
+
selectedEngine = null,
|
|
29
|
+
onEngineChange,
|
|
22
30
|
onRunQuery,
|
|
23
31
|
canRunQuery = false,
|
|
24
32
|
queryLoading = false,
|
|
@@ -604,7 +612,11 @@ export function SelectionPanel({
|
|
|
604
612
|
|
|
605
613
|
<div className="selection-list dimensions-list">
|
|
606
614
|
{filteredDimensions.map(dim => (
|
|
607
|
-
<label
|
|
615
|
+
<label
|
|
616
|
+
key={dim.name}
|
|
617
|
+
className="selection-item dimension-item"
|
|
618
|
+
title={dim.name}
|
|
619
|
+
>
|
|
608
620
|
<input
|
|
609
621
|
type="checkbox"
|
|
610
622
|
checked={selectedDimensions.includes(dim.name)}
|
|
@@ -614,6 +626,7 @@ export function SelectionPanel({
|
|
|
614
626
|
<span className="item-name">
|
|
615
627
|
{getDimDisplayName(dim.name)}
|
|
616
628
|
</span>
|
|
629
|
+
<span className="dimension-full-name">{dim.name}</span>
|
|
617
630
|
{dim.path && dim.path.length > 1 && (
|
|
618
631
|
<span className="dimension-path">
|
|
619
632
|
{dim.path.slice(1).join(' ▶ ')}
|
|
@@ -683,6 +696,24 @@ export function SelectionPanel({
|
|
|
683
696
|
</div>
|
|
684
697
|
</div>
|
|
685
698
|
|
|
699
|
+
{/* Engine Selection */}
|
|
700
|
+
<div className="engine-section">
|
|
701
|
+
<span className="engine-label">Engine</span>
|
|
702
|
+
<div className="engine-pills">
|
|
703
|
+
{ENGINE_OPTIONS.map(({ value, label }) => (
|
|
704
|
+
<button
|
|
705
|
+
key={label}
|
|
706
|
+
className={`engine-pill${
|
|
707
|
+
selectedEngine === value ? ' active' : ''
|
|
708
|
+
}`}
|
|
709
|
+
onClick={() => onEngineChange && onEngineChange(value)}
|
|
710
|
+
>
|
|
711
|
+
{label}
|
|
712
|
+
</button>
|
|
713
|
+
))}
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
|
|
686
717
|
{/* Run Query Section */}
|
|
687
718
|
<div className="run-query-section">
|
|
688
719
|
<button
|
|
@@ -14,6 +14,49 @@ jest.mock('react-syntax-highlighter/src/styles/hljs', () => ({
|
|
|
14
14
|
}));
|
|
15
15
|
jest.mock('react-syntax-highlighter/dist/esm/languages/hljs/sql', () => ({}));
|
|
16
16
|
|
|
17
|
+
// Mock recharts to avoid canvas rendering issues in jsdom.
|
|
18
|
+
// YAxis calls tickFormatter with sample values (covering the formatYAxis helper).
|
|
19
|
+
jest.mock('recharts', () => {
|
|
20
|
+
const React = require('react');
|
|
21
|
+
const mockComponent =
|
|
22
|
+
name =>
|
|
23
|
+
({ children, ...props }) =>
|
|
24
|
+
React.createElement('div', { 'data-testid': name, ...props }, children);
|
|
25
|
+
|
|
26
|
+
// YAxis: call tickFormatter with a spread of magnitudes so formatYAxis branches are hit
|
|
27
|
+
const MockYAxis = ({ children, tickFormatter, ...props }) => {
|
|
28
|
+
if (tickFormatter) {
|
|
29
|
+
// Exercise all four branches of formatYAxis
|
|
30
|
+
tickFormatter(1_500_000_000); // >=1B
|
|
31
|
+
tickFormatter(2_500_000); // >=1M
|
|
32
|
+
tickFormatter(5_000); // >=1K
|
|
33
|
+
tickFormatter(42); // plain
|
|
34
|
+
}
|
|
35
|
+
return React.createElement(
|
|
36
|
+
'div',
|
|
37
|
+
{ 'data-testid': 'YAxis', ...props },
|
|
38
|
+
children,
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
LineChart: mockComponent('LineChart'),
|
|
44
|
+
BarChart: mockComponent('BarChart'),
|
|
45
|
+
Line: mockComponent('Line'),
|
|
46
|
+
Bar: mockComponent('Bar'),
|
|
47
|
+
XAxis: mockComponent('XAxis'),
|
|
48
|
+
YAxis: MockYAxis,
|
|
49
|
+
CartesianGrid: mockComponent('CartesianGrid'),
|
|
50
|
+
Tooltip: mockComponent('Tooltip'),
|
|
51
|
+
ResponsiveContainer: ({ children }) =>
|
|
52
|
+
React.createElement(
|
|
53
|
+
'div',
|
|
54
|
+
{ 'data-testid': 'ResponsiveContainer' },
|
|
55
|
+
children,
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
17
60
|
// Mock clipboard API
|
|
18
61
|
const mockWriteText = jest.fn();
|
|
19
62
|
Object.assign(navigator, {
|
|
@@ -385,4 +428,287 @@ describe('ResultsView', () => {
|
|
|
385
428
|
);
|
|
386
429
|
});
|
|
387
430
|
});
|
|
431
|
+
|
|
432
|
+
describe('Chart Tab', () => {
|
|
433
|
+
// Results with a string dimension + numeric metric → bar chart config
|
|
434
|
+
const barChartResults = {
|
|
435
|
+
results: [
|
|
436
|
+
{
|
|
437
|
+
columns: [
|
|
438
|
+
{ name: 'country', type: 'STRING' },
|
|
439
|
+
{ name: 'revenue', type: 'FLOAT' },
|
|
440
|
+
],
|
|
441
|
+
rows: [
|
|
442
|
+
['US', 1000],
|
|
443
|
+
['UK', 500],
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// Results with a time dimension + numeric metric → line chart config
|
|
450
|
+
const lineChartResults = {
|
|
451
|
+
results: [
|
|
452
|
+
{
|
|
453
|
+
columns: [
|
|
454
|
+
{ name: 'date', type: 'DATE' },
|
|
455
|
+
{ name: 'revenue', type: 'FLOAT' },
|
|
456
|
+
],
|
|
457
|
+
rows: [
|
|
458
|
+
['2024-01-01', 1000],
|
|
459
|
+
['2024-01-02', 1500],
|
|
460
|
+
],
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// Results with two numeric columns (no string/time dim) → line with first as x
|
|
466
|
+
const allNumericResults = {
|
|
467
|
+
results: [
|
|
468
|
+
{
|
|
469
|
+
columns: [
|
|
470
|
+
{ name: 'x_val', type: 'FLOAT' },
|
|
471
|
+
{ name: 'y_val', type: 'FLOAT' },
|
|
472
|
+
],
|
|
473
|
+
rows: [
|
|
474
|
+
[1.0, 10.5],
|
|
475
|
+
[2.0, 20.5],
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// Scalar result: single numeric column, one row → KPI cards
|
|
482
|
+
const scalarResults = {
|
|
483
|
+
results: [
|
|
484
|
+
{
|
|
485
|
+
columns: [{ name: 'total_revenue', type: 'FLOAT' }],
|
|
486
|
+
rows: [[1234567.89]],
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
it('renders Chart tab button', () => {
|
|
492
|
+
render(<ResultsView {...defaultProps} results={barChartResults} />);
|
|
493
|
+
expect(screen.getByText('Chart')).toBeInTheDocument();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('Chart tab is enabled when data is chartable (bar chart data)', () => {
|
|
497
|
+
render(<ResultsView {...defaultProps} results={barChartResults} />);
|
|
498
|
+
const chartTab = screen.getByText('Chart').closest('button');
|
|
499
|
+
expect(chartTab).not.toHaveClass('disabled');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('Chart tab is enabled for any data with rows, showing no-data message when unchartable', () => {
|
|
503
|
+
render(
|
|
504
|
+
<ResultsView
|
|
505
|
+
{...defaultProps}
|
|
506
|
+
results={{
|
|
507
|
+
results: [
|
|
508
|
+
{
|
|
509
|
+
columns: [{ name: 'name', type: 'STRING' }],
|
|
510
|
+
rows: [['Alice']],
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
}}
|
|
514
|
+
/>,
|
|
515
|
+
);
|
|
516
|
+
const chartTab = screen.getByText('Chart').closest('button');
|
|
517
|
+
expect(chartTab).not.toHaveClass('disabled');
|
|
518
|
+
|
|
519
|
+
fireEvent.click(chartTab);
|
|
520
|
+
expect(
|
|
521
|
+
screen.getByText('No chartable data detected'),
|
|
522
|
+
).toBeInTheDocument();
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('switches to chart view when Chart tab is clicked (bar chart)', () => {
|
|
526
|
+
render(<ResultsView {...defaultProps} results={barChartResults} />);
|
|
527
|
+
|
|
528
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
529
|
+
|
|
530
|
+
// The chart wrapper should appear
|
|
531
|
+
expect(
|
|
532
|
+
document.querySelector('.results-chart-wrapper'),
|
|
533
|
+
).toBeInTheDocument();
|
|
534
|
+
expect(screen.getByTestId('BarChart')).toBeInTheDocument();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('switches to chart view when Chart tab is clicked (line chart)', () => {
|
|
538
|
+
render(<ResultsView {...defaultProps} results={lineChartResults} />);
|
|
539
|
+
|
|
540
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
541
|
+
|
|
542
|
+
expect(
|
|
543
|
+
document.querySelector('.results-chart-wrapper'),
|
|
544
|
+
).toBeInTheDocument();
|
|
545
|
+
expect(screen.getByTestId('LineChart')).toBeInTheDocument();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('switches back to table view when Table tab is clicked', () => {
|
|
549
|
+
render(<ResultsView {...defaultProps} results={barChartResults} />);
|
|
550
|
+
|
|
551
|
+
// Switch to chart
|
|
552
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
553
|
+
expect(
|
|
554
|
+
document.querySelector('.results-chart-wrapper'),
|
|
555
|
+
).toBeInTheDocument();
|
|
556
|
+
|
|
557
|
+
// Switch back to table
|
|
558
|
+
fireEvent.click(screen.getByText('Table'));
|
|
559
|
+
expect(
|
|
560
|
+
document.querySelector('.results-table-wrapper'),
|
|
561
|
+
).toBeInTheDocument();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('renders line chart for all-numeric columns (lines 93-95: no time/string dim)', () => {
|
|
565
|
+
render(<ResultsView {...defaultProps} results={allNumericResults} />);
|
|
566
|
+
|
|
567
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
568
|
+
|
|
569
|
+
expect(screen.getByTestId('LineChart')).toBeInTheDocument();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('renders KPI cards for scalar numeric result', () => {
|
|
573
|
+
render(<ResultsView {...defaultProps} results={scalarResults} />);
|
|
574
|
+
|
|
575
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
576
|
+
|
|
577
|
+
expect(document.querySelector('.kpi-cards')).toBeInTheDocument();
|
|
578
|
+
expect(document.querySelector('.kpi-label')).toHaveTextContent(
|
|
579
|
+
'total_revenue',
|
|
580
|
+
);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('KPI card formats null value as em-dash', () => {
|
|
584
|
+
render(
|
|
585
|
+
<ResultsView
|
|
586
|
+
{...defaultProps}
|
|
587
|
+
results={{
|
|
588
|
+
results: [
|
|
589
|
+
{
|
|
590
|
+
columns: [{ name: 'revenue', type: 'FLOAT' }],
|
|
591
|
+
rows: [[null]],
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
}}
|
|
595
|
+
/>,
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
599
|
+
|
|
600
|
+
expect(document.querySelector('.kpi-value')).toHaveTextContent('—');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('KPI card formats numeric value with toLocaleString', () => {
|
|
604
|
+
render(<ResultsView {...defaultProps} results={scalarResults} />);
|
|
605
|
+
|
|
606
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
607
|
+
|
|
608
|
+
// 1234567.89 formatted
|
|
609
|
+
const kpiValue = document.querySelector('.kpi-value');
|
|
610
|
+
expect(kpiValue).toBeInTheDocument();
|
|
611
|
+
// Value should be a localized number string (not null)
|
|
612
|
+
expect(kpiValue.textContent).not.toBe('—');
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('KPI card shows column type when present', () => {
|
|
616
|
+
render(<ResultsView {...defaultProps} results={scalarResults} />);
|
|
617
|
+
|
|
618
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
619
|
+
|
|
620
|
+
expect(document.querySelector('.kpi-type')).toHaveTextContent('FLOAT');
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('renders small multiples when 3+ metric columns present', () => {
|
|
624
|
+
const threeMetricResults = {
|
|
625
|
+
results: [
|
|
626
|
+
{
|
|
627
|
+
columns: [
|
|
628
|
+
{ name: 'date', type: 'DATE' },
|
|
629
|
+
{ name: 'metric_a', type: 'FLOAT' },
|
|
630
|
+
{ name: 'metric_b', type: 'FLOAT' },
|
|
631
|
+
{ name: 'metric_c', type: 'FLOAT' },
|
|
632
|
+
],
|
|
633
|
+
rows: [
|
|
634
|
+
['2024-01-01', 10, 20, 30],
|
|
635
|
+
['2024-01-02', 15, 25, 35],
|
|
636
|
+
],
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
render(<ResultsView {...defaultProps} results={threeMetricResults} />);
|
|
642
|
+
|
|
643
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
644
|
+
|
|
645
|
+
// SMALL_MULTIPLES_THRESHOLD is 2, so 3 metric cols triggers small multiples
|
|
646
|
+
expect(document.querySelector('.small-multiples')).toBeInTheDocument();
|
|
647
|
+
const labels = document.querySelectorAll('.small-multiple-label');
|
|
648
|
+
expect(labels.length).toBe(3);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('shows no-data message when Chart tab clicked with unchartable data', () => {
|
|
652
|
+
// Single string column → not chartable, but tab is still clickable
|
|
653
|
+
render(
|
|
654
|
+
<ResultsView
|
|
655
|
+
{...defaultProps}
|
|
656
|
+
results={{
|
|
657
|
+
results: [
|
|
658
|
+
{
|
|
659
|
+
columns: [{ name: 'label', type: 'STRING' }],
|
|
660
|
+
rows: [['x']],
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
}}
|
|
664
|
+
/>,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
const chartTab = screen.getByText('Chart').closest('button');
|
|
668
|
+
fireEvent.click(chartTab);
|
|
669
|
+
|
|
670
|
+
expect(
|
|
671
|
+
screen.getByText('No chartable data detected'),
|
|
672
|
+
).toBeInTheDocument();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('resets to table view if new results are not chartable while on chart tab', () => {
|
|
676
|
+
const { rerender } = render(
|
|
677
|
+
<ResultsView {...defaultProps} results={barChartResults} />,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
// Switch to chart view
|
|
681
|
+
fireEvent.click(screen.getByText('Chart'));
|
|
682
|
+
expect(
|
|
683
|
+
document.querySelector('.results-chart-wrapper'),
|
|
684
|
+
).toBeInTheDocument();
|
|
685
|
+
|
|
686
|
+
// Re-render with non-chartable results (empty rows)
|
|
687
|
+
rerender(
|
|
688
|
+
<ResultsView
|
|
689
|
+
{...defaultProps}
|
|
690
|
+
results={{ results: [{ columns: [], rows: [] }] }}
|
|
691
|
+
/>,
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// Should auto-reset to table view
|
|
695
|
+
expect(
|
|
696
|
+
document.querySelector('.results-table-wrapper'),
|
|
697
|
+
).toBeInTheDocument();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('shows links during loading when links prop is provided', () => {
|
|
701
|
+
render(
|
|
702
|
+
<ResultsView
|
|
703
|
+
{...defaultProps}
|
|
704
|
+
loading={true}
|
|
705
|
+
links={['https://example.com/query/123']}
|
|
706
|
+
/>,
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const link = screen.getByText('View query ↗');
|
|
710
|
+
expect(link).toBeInTheDocument();
|
|
711
|
+
expect(link).toHaveAttribute('href', 'https://example.com/query/123');
|
|
712
|
+
});
|
|
713
|
+
});
|
|
388
714
|
});
|