datajunction-ui 0.0.98 → 0.0.100

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.
@@ -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
  });
@@ -15,22 +15,22 @@ const mockDimensions = [
15
15
  {
16
16
  name: 'default.date_dim.dateint',
17
17
  type: 'timestamp',
18
- path: ['default.orders', 'default.date_dim.dateint'],
18
+ path: ['default.date_dim'],
19
19
  },
20
20
  {
21
21
  name: 'default.date_dim.month',
22
22
  type: 'int',
23
- path: ['default.orders', 'default.date_dim.month'],
23
+ path: ['default.date_dim'],
24
24
  },
25
25
  {
26
26
  name: 'default.date_dim.year',
27
27
  type: 'int',
28
- path: ['default.orders', 'default.date_dim.year'],
28
+ path: ['default.date_dim'],
29
29
  },
30
30
  {
31
31
  name: 'default.customer.country',
32
32
  type: 'string',
33
- path: ['default.orders', 'default.customer.country'],
33
+ path: ['default.customer'],
34
34
  },
35
35
  ];
36
36
 
@@ -313,8 +313,8 @@ describe('SelectionPanel', () => {
313
313
 
314
314
  it('deduplicates dimensions with same name', () => {
315
315
  const duplicateDimensions = [
316
- { name: 'default.date_dim.month', path: ['path1', 'path2', 'path3'] },
317
- { name: 'default.date_dim.month', path: ['short', 'path'] },
316
+ { name: 'default.date_dim.month', path: ['path1'] },
317
+ { name: 'default.date_dim.month', path: ['short'] },
318
318
  ];
319
319
  render(
320
320
  <SelectionPanel
@@ -702,7 +702,7 @@ describe('SelectionPanel', () => {
702
702
  {
703
703
  name: 'default.date_dim.dateint',
704
704
  type: 'timestamp',
705
- path: ['default.orders', 'default.date_dim.dateint'],
705
+ path: ['default.date_dim.dateint'],
706
706
  },
707
707
  ];
708
708
 
@@ -714,9 +714,9 @@ describe('SelectionPanel', () => {
714
714
  />,
715
715
  );
716
716
 
717
- // Full name appears in both the dimension-full-name span and the path span
717
+ // Display name (last 2 segments) appears in the dimension item
718
718
  expect(
719
- screen.getAllByText('default.date_dim.dateint').length,
719
+ screen.getAllByText('date_dim.dateint').length,
720
720
  ).toBeGreaterThanOrEqual(1);
721
721
  });
722
722
  });
@@ -55,6 +55,7 @@ jest.mock('../MetricFlowGraph', () => ({
55
55
  const mockDjClient = {
56
56
  metrics: jest.fn(),
57
57
  commonDimensions: jest.fn(),
58
+ commonMetrics: jest.fn(),
58
59
  measuresV3: jest.fn(),
59
60
  metricsV3: jest.fn(),
60
61
  listCubesForPreset: jest.fn(),
@@ -89,7 +90,7 @@ const mockCommonDimensions = [
89
90
  node_name: 'default.date_dim',
90
91
  node_display_name: 'Date',
91
92
  properties: [],
92
- path: ['default.repair_orders', 'default.date_dim.dateint'],
93
+ path: ['default.date_dim'],
93
94
  },
94
95
  {
95
96
  name: 'default.date_dim.month',
@@ -97,7 +98,7 @@ const mockCommonDimensions = [
97
98
  node_name: 'default.date_dim',
98
99
  node_display_name: 'Date',
99
100
  properties: [],
100
- path: ['default.repair_orders', 'default.date_dim.month'],
101
+ path: ['default.date_dim'],
101
102
  },
102
103
  {
103
104
  name: 'default.hard_hat.country',
@@ -105,7 +106,7 @@ const mockCommonDimensions = [
105
106
  node_name: 'default.hard_hat',
106
107
  node_display_name: 'Hard Hat',
107
108
  properties: [],
108
- path: ['default.repair_orders', 'default.hard_hat.country'],
109
+ path: ['default.hard_hat'],
109
110
  },
110
111
  ];
111
112
 
@@ -192,6 +193,7 @@ describe('QueryPlannerPage', () => {
192
193
  beforeEach(() => {
193
194
  mockDjClient.metrics.mockResolvedValue(mockMetrics);
194
195
  mockDjClient.commonDimensions.mockResolvedValue(mockCommonDimensions);
196
+ mockDjClient.commonMetrics.mockResolvedValue([]);
195
197
  mockDjClient.measuresV3.mockResolvedValue(mockMeasuresResult);
196
198
  mockDjClient.metricsV3.mockResolvedValue(mockMetricsResult);
197
199
  mockDjClient.listCubesForPreset.mockResolvedValue(mockCubes);
@@ -1425,4 +1427,100 @@ describe('QueryPlannerPage', () => {
1425
1427
  ).toBeInTheDocument();
1426
1428
  });
1427
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
+ });
1428
1526
  });
@@ -53,6 +53,9 @@ export function QueryPlannerPage() {
53
53
  const pendingDimensionsFromUrl = useRef([]);
54
54
  const pendingCubeFromUrl = useRef(null);
55
55
 
56
+ // Compatible metrics when dimensions are selected (phase 1: dimension-first flow)
57
+ const [compatibleMetrics, setCompatibleMetrics] = useState(null); // null = no filter active
58
+
56
59
  // Results state
57
60
  const [measuresResult, setMeasuresResult] = useState(null);
58
61
  const [metricsResult, setMetricsResult] = useState(null);
@@ -81,6 +84,67 @@ export function QueryPlannerPage() {
81
84
  // Node selection for details panel
82
85
  const [selectedNode, setSelectedNode] = useState(null);
83
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
+
84
148
  // Materialization state - map of grain_key -> pre-agg info
85
149
  const [plannedPreaggs, setPlannedPreaggs] = useState({});
86
150
 
@@ -259,6 +323,33 @@ export function QueryPlannerPage() {
259
323
  }
260
324
  }, [commonDimensions, selectedDimensions]);
261
325
 
326
+ // Phase 1: When dimensions are selected, reactively find compatible metrics
327
+ useEffect(() => {
328
+ if (selectedDimensions.length === 0) {
329
+ setCompatibleMetrics(null);
330
+ return;
331
+ }
332
+ let cancelled = false;
333
+ djClient
334
+ .commonMetrics(selectedDimensions)
335
+ .then(result => {
336
+ if (cancelled) return;
337
+ // API returns array of node names (strings) or objects with a name field
338
+ if (Array.isArray(result)) {
339
+ const names = result.map(r => (typeof r === 'string' ? r : r.name));
340
+ setCompatibleMetrics(new Set(names));
341
+ } else {
342
+ setCompatibleMetrics(null);
343
+ }
344
+ })
345
+ .catch(() => {
346
+ if (!cancelled) setCompatibleMetrics(null);
347
+ });
348
+ return () => {
349
+ cancelled = true;
350
+ };
351
+ }, [selectedDimensions, djClient]);
352
+
262
353
  // Fetch V3 measures and metrics SQL when selection, filters, or engine changes
263
354
  useEffect(() => {
264
355
  const fetchData = async () => {
@@ -1194,19 +1285,17 @@ export function QueryPlannerPage() {
1194
1285
 
1195
1286
  return (
1196
1287
  <div className="planner-page">
1197
- {/* Header */}
1198
- <header className="planner-header">
1199
- <div className="planner-header-content">
1200
- <h1>Explore</h1>
1201
- {/* <p>Explore metrics and dimensions and plan materializations</p> */}
1202
- </div>
1203
- {error && <div className="header-error">{error}</div>}
1204
- </header>
1288
+ {error && (
1289
+ <div className="header-error planner-error-banner">{error}</div>
1290
+ )}
1205
1291
 
1206
1292
  {/* Three-column layout */}
1207
- <div className="planner-layout">
1293
+ <div className="planner-layout" ref={plannerLayoutRef}>
1208
1294
  {/* Left: Selection Panel */}
1209
- <aside className="planner-selection">
1295
+ <aside
1296
+ className="planner-selection"
1297
+ style={selectionWidth != null ? { width: selectionWidth } : undefined}
1298
+ >
1210
1299
  <SelectionPanel
1211
1300
  metrics={metrics}
1212
1301
  selectedMetrics={selectedMetrics}
@@ -1228,9 +1317,17 @@ export function QueryPlannerPage() {
1228
1317
  selectedMetrics.length > 0 && selectedDimensions.length > 0
1229
1318
  }
1230
1319
  queryLoading={queryLoading}
1320
+ compatibleMetrics={compatibleMetrics}
1231
1321
  />
1232
1322
  </aside>
1233
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
+
1234
1331
  {/* Main Content Area - Either Results or Graph+Details */}
1235
1332
  {showResults ? (
1236
1333
  <ResultsView
@@ -1295,8 +1392,18 @@ export function QueryPlannerPage() {
1295
1392
  )}
1296
1393
  </main>
1297
1394
 
1395
+ {/* Vertical resizer between graph and details */}
1396
+ <div
1397
+ className="vertical-resizer"
1398
+ onMouseDown={handleDetailsResizerMouseDown}
1399
+ title="Drag to resize"
1400
+ />
1401
+
1298
1402
  {/* Right: Details Panel */}
1299
- <aside className="planner-details">
1403
+ <aside
1404
+ className="planner-details"
1405
+ style={detailsWidth != null ? { width: detailsWidth } : undefined}
1406
+ >
1300
1407
  {selectedNode?.type === 'preagg' ||
1301
1408
  selectedNode?.type === 'component' ? (
1302
1409
  <PreAggDetailsPanel