datajunction-ui 0.0.99 → 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.
- package/package.json +1 -1
- package/src/app/pages/QueryPlannerPage/ResultsView.jsx +49 -3
- package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +151 -0
- package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +96 -0
- package/src/app/pages/QueryPlannerPage/index.jsx +84 -3
- package/src/app/pages/QueryPlannerPage/styles.css +55 -6
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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;
|