datajunction-ui 0.0.99 → 0.0.101

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.99",
3
+ "version": "0.0.101",
4
4
  "description": "DataJunction UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useMemo, useEffect, memo } from 'react';
1
+ import { useState, useCallback, useMemo, useEffect, useRef, memo } from 'react';
2
2
  import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
3
3
  import { foundation } from 'react-syntax-highlighter/src/styles/hljs';
4
4
  import sql from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
@@ -417,6 +417,38 @@ export function ResultsView({
417
417
  const [sortDirection, setSortDirection] = useState('asc');
418
418
  const [activeTab, setActiveTab] = useState('table');
419
419
 
420
+ // Resizable SQL pane height (px); null = use CSS default
421
+ const [sqlPaneHeight, setSqlPaneHeight] = useState(null);
422
+ const resultsPanesRef = useRef(null);
423
+
424
+ const handleSqlResizerMouseDown = useCallback(
425
+ e => {
426
+ e.preventDefault();
427
+ const container = resultsPanesRef.current;
428
+ if (!container) return;
429
+ const startY = e.clientY;
430
+ const startHeight = sqlPaneHeight ?? container.offsetHeight * 0.333;
431
+
432
+ const onMouseMove = moveEvent => {
433
+ const delta = moveEvent.clientY - startY;
434
+ const newHeight = Math.max(
435
+ 80,
436
+ Math.min(container.offsetHeight * 0.8, startHeight + delta),
437
+ );
438
+ setSqlPaneHeight(newHeight);
439
+ };
440
+
441
+ const onMouseUp = () => {
442
+ document.removeEventListener('mousemove', onMouseMove);
443
+ document.removeEventListener('mouseup', onMouseUp);
444
+ };
445
+
446
+ document.addEventListener('mousemove', onMouseMove);
447
+ document.addEventListener('mouseup', onMouseUp);
448
+ },
449
+ [sqlPaneHeight],
450
+ );
451
+
420
452
  const handleCopySql = useCallback(() => {
421
453
  if (sqlQuery) {
422
454
  navigator.clipboard.writeText(sqlQuery);
@@ -524,9 +556,16 @@ export function ResultsView({
524
556
  </div>
525
557
 
526
558
  {/* Two-pane layout: SQL (top) + Results (bottom) */}
527
- <div className="results-panes">
559
+ <div className="results-panes" ref={resultsPanesRef}>
528
560
  {/* SQL Pane */}
529
- <div className="sql-pane">
561
+ <div
562
+ className="sql-pane"
563
+ style={
564
+ sqlPaneHeight != null
565
+ ? { flex: `0 0 ${sqlPaneHeight}px`, maxHeight: sqlPaneHeight }
566
+ : undefined
567
+ }
568
+ >
530
569
  <div className="sql-pane-header">
531
570
  <span className="sql-pane-title">SQL Query</span>
532
571
  {cubeName && (
@@ -589,6 +628,13 @@ export function ResultsView({
589
628
  </div>
590
629
  </div>
591
630
 
631
+ {/* Horizontal resizer between SQL and results panes */}
632
+ <div
633
+ className="horizontal-resizer"
634
+ onMouseDown={handleSqlResizerMouseDown}
635
+ title="Drag to resize"
636
+ />
637
+
592
638
  {/* Results Pane */}
593
639
  <div className="results-pane">
594
640
  {loading ? (
@@ -710,5 +710,156 @@ describe('ResultsView', () => {
710
710
  expect(link).toBeInTheDocument();
711
711
  expect(link).toHaveAttribute('href', 'https://example.com/query/123');
712
712
  });
713
+
714
+ // ── Pivoted chart paths (buildPivotedData + ChartView branches) ──────────
715
+
716
+ it('renders pivoted line chart for time + 1 categorical + 1 metric (groupByCol path)', () => {
717
+ // detectChartConfig line 84-90: timeCols + nonTimeCatCols.length === 1
718
+ // buildPivotedData lines 131-178, useMemo lines 505-512, ChartView line 353
719
+ const pivotedLineResults = {
720
+ results: [
721
+ {
722
+ columns: [
723
+ { name: 'date', type: 'DATE' },
724
+ { name: 'country', type: 'STRING' },
725
+ { name: 'revenue', type: 'FLOAT' },
726
+ ],
727
+ rows: [
728
+ ['2024-01-01', 'US', 1000],
729
+ ['2024-01-01', 'UK', 500],
730
+ ['2024-01-02', 'US', 1200],
731
+ ['2024-01-02', null, 300], // null group value → '(null)'
732
+ ],
733
+ },
734
+ ],
735
+ };
736
+
737
+ render(<ResultsView {...defaultProps} results={pivotedLineResults} />);
738
+ fireEvent.click(screen.getByText('Chart'));
739
+
740
+ expect(screen.getByTestId('LineChart')).toBeInTheDocument();
741
+ });
742
+
743
+ it('renders pivoted multi-metric small multiples (time + 1 cat + 2 metrics)', () => {
744
+ // ChartView lines 331-334: pivotedByMetric.length > 1
745
+ const pivotedMultiMetricResults = {
746
+ results: [
747
+ {
748
+ columns: [
749
+ { name: 'date', type: 'DATE' },
750
+ { name: 'country', type: 'STRING' },
751
+ { name: 'revenue', type: 'FLOAT' },
752
+ { name: 'orders', type: 'FLOAT' },
753
+ ],
754
+ rows: [
755
+ ['2024-01-01', 'US', 1000, 10],
756
+ ['2024-01-01', 'UK', 500, 5],
757
+ ['2024-01-02', 'US', 1200, 12],
758
+ ],
759
+ },
760
+ ],
761
+ };
762
+
763
+ render(
764
+ <ResultsView {...defaultProps} results={pivotedMultiMetricResults} />,
765
+ );
766
+ fireEvent.click(screen.getByText('Chart'));
767
+
768
+ // pivotedByMetric.length === 2 → small-multiples wrapper
769
+ expect(document.querySelector('.small-multiples')).toBeInTheDocument();
770
+ const labels = document.querySelectorAll('.small-multiple-label');
771
+ expect(labels.length).toBe(2);
772
+ expect(labels[0]).toHaveTextContent('revenue');
773
+ expect(labels[1]).toHaveTextContent('orders');
774
+ });
775
+
776
+ it('renders grouped bar chart for 2 categorical columns (lines 101-107)', () => {
777
+ // detectChartConfig: nonTimeCatCols.length === 2 → bar with groupByCol
778
+ const groupedBarResults = {
779
+ results: [
780
+ {
781
+ columns: [
782
+ { name: 'region', type: 'STRING' },
783
+ { name: 'category', type: 'STRING' },
784
+ { name: 'revenue', type: 'FLOAT' },
785
+ ],
786
+ rows: [
787
+ ['North', 'A', 1000],
788
+ ['South', 'A', 800],
789
+ ['North', 'B', 600],
790
+ ['South', null, 400], // null group value
791
+ ],
792
+ },
793
+ ],
794
+ };
795
+
796
+ render(<ResultsView {...defaultProps} results={groupedBarResults} />);
797
+ fireEvent.click(screen.getByText('Chart'));
798
+
799
+ expect(screen.getByTestId('BarChart')).toBeInTheDocument();
800
+ });
801
+
802
+ it('renders bar chart for 3+ categorical columns falling back to first as x-axis (lines 108-110)', () => {
803
+ const threeCatResults = {
804
+ results: [
805
+ {
806
+ columns: [
807
+ { name: 'a', type: 'STRING' },
808
+ { name: 'b', type: 'STRING' },
809
+ { name: 'c', type: 'STRING' },
810
+ { name: 'metric', type: 'FLOAT' },
811
+ ],
812
+ rows: [
813
+ ['x1', 'y1', 'z1', 100],
814
+ ['x2', 'y2', 'z2', 200],
815
+ ],
816
+ },
817
+ ],
818
+ };
819
+
820
+ render(<ResultsView {...defaultProps} results={threeCatResults} />);
821
+ fireEvent.click(screen.getByText('Chart'));
822
+
823
+ expect(screen.getByTestId('BarChart')).toBeInTheDocument();
824
+ });
825
+ });
826
+
827
+ describe('Pane Resize', () => {
828
+ it('handles SQL pane drag resize via horizontal-resizer', () => {
829
+ render(<ResultsView {...defaultProps} />);
830
+
831
+ const resizer = document.querySelector('.horizontal-resizer');
832
+ expect(resizer).toBeInTheDocument();
833
+
834
+ // Mousedown starts the drag
835
+ fireEvent.mouseDown(resizer, { clientY: 300 });
836
+
837
+ // Mousemove updates height (in jsdom offsetHeight is 0, so newHeight clamps to min 80)
838
+ fireEvent.mouseMove(document, { clientY: 350 });
839
+
840
+ // Inline style should be applied to sql-pane
841
+ const sqlPane = document.querySelector('.sql-pane');
842
+ expect(sqlPane.style.maxHeight).toBeTruthy();
843
+
844
+ // Mouseup removes event listeners
845
+ fireEvent.mouseUp(document);
846
+
847
+ // Further moves should not update (listeners removed)
848
+ const heightAfterUp = sqlPane.style.maxHeight;
849
+ fireEvent.mouseMove(document, { clientY: 500 });
850
+ expect(sqlPane.style.maxHeight).toBe(heightAfterUp);
851
+ });
852
+
853
+ it('does not update height when no resultsPanesRef container', () => {
854
+ // Renders and immediately fires mousedown on resizer
855
+ render(<ResultsView {...defaultProps} />);
856
+ const resizer = document.querySelector('.horizontal-resizer');
857
+ // Should not throw even if container measures are 0
858
+ expect(() => {
859
+ fireEvent.mouseDown(resizer, { clientY: 100 });
860
+ fireEvent.mouseMove(document, { clientY: 200 });
861
+ fireEvent.mouseUp(document);
862
+ }).not.toThrow();
863
+ });
713
864
  });
714
865
  });
@@ -1427,4 +1427,100 @@ describe('QueryPlannerPage', () => {
1427
1427
  ).toBeInTheDocument();
1428
1428
  });
1429
1429
  });
1430
+
1431
+ describe('Pane Resize Handlers', () => {
1432
+ it('resizes selection panel when dragging left vertical-resizer', async () => {
1433
+ renderPage();
1434
+ await waitFor(() => expect(mockDjClient.metrics).toHaveBeenCalled());
1435
+
1436
+ const resizers = document.querySelectorAll('.vertical-resizer');
1437
+ // First resizer is between selection panel and main content
1438
+ const selectionResizer = resizers[0];
1439
+ expect(selectionResizer).toBeInTheDocument();
1440
+
1441
+ // Mousedown begins drag
1442
+ fireEvent.mouseDown(selectionResizer, { clientX: 200 });
1443
+
1444
+ // Move right → wider selection panel
1445
+ fireEvent.mouseMove(document, { clientX: 280 });
1446
+
1447
+ const selectionPane = document.querySelector('.planner-selection');
1448
+ // Inline width should be applied (clamped to min 200)
1449
+ expect(selectionPane.style.width).toBeTruthy();
1450
+
1451
+ // Mouseup ends drag
1452
+ fireEvent.mouseUp(document);
1453
+
1454
+ // Further moves should not update
1455
+ const widthAfterUp = selectionPane.style.width;
1456
+ fireEvent.mouseMove(document, { clientX: 600 });
1457
+ expect(selectionPane.style.width).toBe(widthAfterUp);
1458
+ });
1459
+
1460
+ it('resizes details panel when dragging right vertical-resizer', async () => {
1461
+ renderPage();
1462
+ await waitFor(() => expect(mockDjClient.metrics).toHaveBeenCalled());
1463
+
1464
+ const resizers = document.querySelectorAll('.vertical-resizer');
1465
+ // Second resizer is between graph and details panel (visible in non-results state)
1466
+ expect(resizers.length).toBeGreaterThanOrEqual(2);
1467
+ const detailsResizer = resizers[1];
1468
+
1469
+ // Mousedown begins drag
1470
+ fireEvent.mouseDown(detailsResizer, { clientX: 800 });
1471
+
1472
+ // Move left → wider details panel
1473
+ fireEvent.mouseMove(document, { clientX: 700 });
1474
+
1475
+ const detailsPane = document.querySelector('.planner-details');
1476
+ expect(detailsPane.style.width).toBeTruthy();
1477
+
1478
+ // Mouseup ends drag
1479
+ fireEvent.mouseUp(document);
1480
+
1481
+ const widthAfterUp = detailsPane.style.width;
1482
+ fireEvent.mouseMove(document, { clientX: 400 });
1483
+ expect(detailsPane.style.width).toBe(widthAfterUp);
1484
+ });
1485
+ });
1486
+
1487
+ describe('URL Init Edge Cases', () => {
1488
+ it('handles invalid cube data returned from cubeForPlanner (missing cube_node_metrics)', async () => {
1489
+ // cubeForPlanner returns data without cube_node_metrics array
1490
+ mockDjClient.cubeForPlanner.mockResolvedValue({ name: 'bad.cube' });
1491
+
1492
+ renderPage(['/query-planner?cube=bad.cube']);
1493
+
1494
+ await waitFor(() => {
1495
+ expect(mockDjClient.cubeForPlanner).toHaveBeenCalledWith('bad.cube');
1496
+ });
1497
+
1498
+ // Page should still render without crashing (invalid cube data logged, not applied)
1499
+ expect(screen.getByText('Load from Cube')).toBeInTheDocument();
1500
+ });
1501
+
1502
+ it('handles cubeForPlanner throwing during URL init', async () => {
1503
+ mockDjClient.cubeForPlanner.mockRejectedValue(new Error('Network error'));
1504
+
1505
+ renderPage(['/query-planner?cube=bad.cube']);
1506
+
1507
+ await waitFor(() => {
1508
+ expect(mockDjClient.cubeForPlanner).toHaveBeenCalled();
1509
+ });
1510
+
1511
+ // Page should still render gracefully
1512
+ expect(screen.getByText('Load from Cube')).toBeInTheDocument();
1513
+ });
1514
+
1515
+ it('handles metrics API failure on mount gracefully', async () => {
1516
+ mockDjClient.metrics.mockRejectedValue(new Error('API down'));
1517
+
1518
+ // Should not throw
1519
+ renderPage();
1520
+
1521
+ await waitFor(() => {
1522
+ expect(mockDjClient.metrics).toHaveBeenCalled();
1523
+ });
1524
+ });
1525
+ });
1430
1526
  });
@@ -84,6 +84,67 @@ export function QueryPlannerPage() {
84
84
  // Node selection for details panel
85
85
  const [selectedNode, setSelectedNode] = useState(null);
86
86
 
87
+ // Resizable pane widths (px); null = use CSS default
88
+ const [selectionWidth, setSelectionWidth] = useState(null);
89
+ const [detailsWidth, setDetailsWidth] = useState(null);
90
+ const plannerLayoutRef = useRef(null);
91
+
92
+ const handleSelectionResizerMouseDown = useCallback(
93
+ e => {
94
+ e.preventDefault();
95
+ const container = plannerLayoutRef.current;
96
+ if (!container) return;
97
+ const startX = e.clientX;
98
+ const startWidth = selectionWidth ?? container.offsetWidth * 0.2; // match CSS default 20%
99
+
100
+ const onMouseMove = moveEvent => {
101
+ const delta = moveEvent.clientX - startX;
102
+ const newWidth = Math.max(
103
+ 200,
104
+ Math.min(container.offsetWidth * 0.5, startWidth + delta),
105
+ );
106
+ setSelectionWidth(newWidth);
107
+ };
108
+
109
+ const onMouseUp = () => {
110
+ document.removeEventListener('mousemove', onMouseMove);
111
+ document.removeEventListener('mouseup', onMouseUp);
112
+ };
113
+
114
+ document.addEventListener('mousemove', onMouseMove);
115
+ document.addEventListener('mouseup', onMouseUp);
116
+ },
117
+ [selectionWidth],
118
+ );
119
+
120
+ const handleDetailsResizerMouseDown = useCallback(
121
+ e => {
122
+ e.preventDefault();
123
+ const container = plannerLayoutRef.current;
124
+ if (!container) return;
125
+ const startX = e.clientX;
126
+ const startWidth = detailsWidth ?? container.offsetWidth * 0.4; // match CSS default 40%
127
+
128
+ const onMouseMove = moveEvent => {
129
+ const delta = startX - moveEvent.clientX;
130
+ const newWidth = Math.max(
131
+ 280,
132
+ Math.min(container.offsetWidth * 0.7, startWidth + delta),
133
+ );
134
+ setDetailsWidth(newWidth);
135
+ };
136
+
137
+ const onMouseUp = () => {
138
+ document.removeEventListener('mousemove', onMouseMove);
139
+ document.removeEventListener('mouseup', onMouseUp);
140
+ };
141
+
142
+ document.addEventListener('mousemove', onMouseMove);
143
+ document.addEventListener('mouseup', onMouseUp);
144
+ },
145
+ [detailsWidth],
146
+ );
147
+
87
148
  // Materialization state - map of grain_key -> pre-agg info
88
149
  const [plannedPreaggs, setPlannedPreaggs] = useState({});
89
150
 
@@ -1229,9 +1290,12 @@ export function QueryPlannerPage() {
1229
1290
  )}
1230
1291
 
1231
1292
  {/* Three-column layout */}
1232
- <div className="planner-layout">
1293
+ <div className="planner-layout" ref={plannerLayoutRef}>
1233
1294
  {/* Left: Selection Panel */}
1234
- <aside className="planner-selection">
1295
+ <aside
1296
+ className="planner-selection"
1297
+ style={selectionWidth != null ? { width: selectionWidth } : undefined}
1298
+ >
1235
1299
  <SelectionPanel
1236
1300
  metrics={metrics}
1237
1301
  selectedMetrics={selectedMetrics}
@@ -1257,6 +1321,13 @@ export function QueryPlannerPage() {
1257
1321
  />
1258
1322
  </aside>
1259
1323
 
1324
+ {/* Vertical resizer between selection panel and main content */}
1325
+ <div
1326
+ className="vertical-resizer"
1327
+ onMouseDown={handleSelectionResizerMouseDown}
1328
+ title="Drag to resize"
1329
+ />
1330
+
1260
1331
  {/* Main Content Area - Either Results or Graph+Details */}
1261
1332
  {showResults ? (
1262
1333
  <ResultsView
@@ -1321,8 +1392,18 @@ export function QueryPlannerPage() {
1321
1392
  )}
1322
1393
  </main>
1323
1394
 
1395
+ {/* Vertical resizer between graph and details */}
1396
+ <div
1397
+ className="vertical-resizer"
1398
+ onMouseDown={handleDetailsResizerMouseDown}
1399
+ title="Drag to resize"
1400
+ />
1401
+
1324
1402
  {/* Right: Details Panel */}
1325
- <aside className="planner-details">
1403
+ <aside
1404
+ className="planner-details"
1405
+ style={detailsWidth != null ? { width: detailsWidth } : undefined}
1406
+ >
1326
1407
  {selectedNode?.type === 'preagg' ||
1327
1408
  selectedNode?.type === 'component' ? (
1328
1409
  <PreAggDetailsPanel
@@ -390,10 +390,9 @@
390
390
  /* Left: Selection Panel */
391
391
  .planner-selection {
392
392
  width: 20%;
393
- /* min-width: 280px; */
393
+ min-width: 200px;
394
394
  min-height: 0;
395
395
  background: var(--planner-surface);
396
- border-right: 1px solid var(--planner-border);
397
396
  overflow: hidden;
398
397
  display: flex;
399
398
  flex-direction: column;
@@ -475,12 +474,37 @@
475
474
  max-width: 300px;
476
475
  }
477
476
 
477
+ /* Vertical resizer between graph and details panels */
478
+ .vertical-resizer {
479
+ width: 1px;
480
+ background: var(--planner-border);
481
+ cursor: col-resize;
482
+ flex-shrink: 0;
483
+ position: relative;
484
+ z-index: 1;
485
+ transition: background 0.15s;
486
+ }
487
+
488
+ /* Wider invisible hit area */
489
+ .vertical-resizer::after {
490
+ content: '';
491
+ position: absolute;
492
+ top: 0;
493
+ bottom: 0;
494
+ left: -4px;
495
+ right: -4px;
496
+ }
497
+
498
+ .vertical-resizer:hover,
499
+ .vertical-resizer:active {
500
+ background: var(--accent-primary);
501
+ }
502
+
478
503
  /* Right: Details Panel */
479
504
  .planner-details {
480
505
  width: 40%;
481
- min-width: 380px;
506
+ min-width: 280px;
482
507
  background: var(--planner-surface);
483
- border-left: 1px solid var(--planner-border);
484
508
  overflow-y: auto;
485
509
  }
486
510
 
@@ -3941,12 +3965,37 @@ a.action-btn {
3941
3965
  flex: 0 0 33.333%;
3942
3966
  display: flex;
3943
3967
  flex-direction: column;
3944
- border-bottom: 2px solid var(--planner-border);
3945
3968
  background: var(--planner-surface);
3946
- min-height: 150px;
3969
+ min-height: 80px;
3947
3970
  max-height: 33.333%;
3948
3971
  }
3949
3972
 
3973
+ /* Horizontal resizer between SQL pane and results pane */
3974
+ .horizontal-resizer {
3975
+ height: 1px;
3976
+ background: var(--planner-border);
3977
+ cursor: row-resize;
3978
+ flex-shrink: 0;
3979
+ position: relative;
3980
+ z-index: 1;
3981
+ transition: background 0.15s;
3982
+ }
3983
+
3984
+ /* Wider invisible hit area */
3985
+ .horizontal-resizer::after {
3986
+ content: '';
3987
+ position: absolute;
3988
+ left: 0;
3989
+ right: 0;
3990
+ top: -4px;
3991
+ bottom: -4px;
3992
+ }
3993
+
3994
+ .horizontal-resizer:hover,
3995
+ .horizontal-resizer:active {
3996
+ background: var(--accent-primary);
3997
+ }
3998
+
3950
3999
  .sql-pane-header {
3951
4000
  display: flex;
3952
4001
  align-items: center;