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.
- package/package.json +1 -1
- package/src/app/components/__tests__/NamespaceHeader.test.jsx +349 -1
- package/src/app/pages/NodePage/NodeInfoTab.jsx +38 -40
- package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +0 -133
- package/src/app/pages/NodePage/__tests__/__snapshots__/NodePage.test.jsx.snap +9 -7
- package/src/app/pages/NodePage/index.jsx +12 -11
- package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +46 -1
- package/src/app/pages/QueryPlannerPage/ResultsView.jsx +281 -0
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +225 -100
- package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +193 -0
- package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +388 -0
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +31 -51
- package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +720 -34
- package/src/app/pages/QueryPlannerPage/index.jsx +237 -117
- package/src/app/pages/QueryPlannerPage/styles.css +765 -15
- package/src/app/services/DJService.js +29 -6
- package/src/app/services/__tests__/DJService.test.jsx +163 -0
|
@@ -9,7 +9,6 @@ import NodeGraphTab from './NodeGraphTab';
|
|
|
9
9
|
import NodeHistory from './NodeHistory';
|
|
10
10
|
import NotebookDownload from './NotebookDownload';
|
|
11
11
|
import DJClientContext from '../../providers/djclient';
|
|
12
|
-
import NodeValidateTab from './NodeValidateTab';
|
|
13
12
|
import NodeMaterializationTab from './NodeMaterializationTab';
|
|
14
13
|
import NodePreAggregationsTab from './NodePreAggregationsTab';
|
|
15
14
|
import ClientCodePopover from './ClientCodePopover';
|
|
@@ -35,6 +34,11 @@ export function NodePage() {
|
|
|
35
34
|
const [node, setNode] = useState(null);
|
|
36
35
|
|
|
37
36
|
const onClickTab = id => () => {
|
|
37
|
+
// Preview tab redirects to Query Planner instead of showing content
|
|
38
|
+
if (id === 'preview') {
|
|
39
|
+
navigate(`/planner?metrics=${encodeURIComponent(name)}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
38
42
|
navigate(`/nodes/${name}/${id}`);
|
|
39
43
|
setState({ selectedTab: id });
|
|
40
44
|
};
|
|
@@ -74,7 +78,7 @@ export function NodePage() {
|
|
|
74
78
|
{
|
|
75
79
|
id: 'columns',
|
|
76
80
|
name: 'Columns',
|
|
77
|
-
display:
|
|
81
|
+
display: node?.type !== 'metric',
|
|
78
82
|
},
|
|
79
83
|
{
|
|
80
84
|
id: 'graph',
|
|
@@ -86,15 +90,10 @@ export function NodePage() {
|
|
|
86
90
|
name: 'History',
|
|
87
91
|
display: true,
|
|
88
92
|
},
|
|
89
|
-
{
|
|
90
|
-
id: 'validate',
|
|
91
|
-
name: '► Validate',
|
|
92
|
-
display: node?.type !== 'source',
|
|
93
|
-
},
|
|
94
93
|
{
|
|
95
94
|
id: 'materializations',
|
|
96
95
|
name: 'Materializations',
|
|
97
|
-
display: node?.type !== 'source',
|
|
96
|
+
display: node?.type !== 'source' && node?.type !== 'metric',
|
|
98
97
|
},
|
|
99
98
|
{
|
|
100
99
|
id: 'linked',
|
|
@@ -111,6 +110,11 @@ export function NodePage() {
|
|
|
111
110
|
name: 'Dependencies',
|
|
112
111
|
display: node?.type !== 'cube',
|
|
113
112
|
},
|
|
113
|
+
{
|
|
114
|
+
id: 'preview',
|
|
115
|
+
name: 'Preview →',
|
|
116
|
+
display: node?.type === 'metric',
|
|
117
|
+
},
|
|
114
118
|
];
|
|
115
119
|
};
|
|
116
120
|
let tabToDisplay = null;
|
|
@@ -128,9 +132,6 @@ export function NodePage() {
|
|
|
128
132
|
case 'history':
|
|
129
133
|
tabToDisplay = <NodeHistory node={node} djClient={djClient} />;
|
|
130
134
|
break;
|
|
131
|
-
case 'validate':
|
|
132
|
-
tabToDisplay = <NodeValidateTab node={node} djClient={djClient} />;
|
|
133
|
-
break;
|
|
134
135
|
case 'materializations':
|
|
135
136
|
// Cube nodes use cube-specific materialization tab
|
|
136
137
|
// Other nodes (transform, metric, dimension) use pre-aggregations tab
|
|
@@ -292,6 +292,7 @@ export function QueryOverviewPanel({
|
|
|
292
292
|
onClearWorkflowUrls,
|
|
293
293
|
loadedCubeName = null, // Existing cube name if loaded from preset
|
|
294
294
|
cubeMaterialization = null, // Full cube materialization info {schedule, strategy, lookbackWindow, ...}
|
|
295
|
+
cubeAvailability = null, // Cube availability info for data freshness
|
|
295
296
|
onUpdateCubeConfig,
|
|
296
297
|
onRefreshCubeWorkflow,
|
|
297
298
|
onRunCubeBackfill,
|
|
@@ -578,6 +579,9 @@ export function QueryOverviewPanel({
|
|
|
578
579
|
const grainGroups = measuresResult.grain_groups || [];
|
|
579
580
|
const metricFormulas = measuresResult.metric_formulas || [];
|
|
580
581
|
const sql = metricsResult.sql || '';
|
|
582
|
+
const dialect = metricsResult.dialect || null;
|
|
583
|
+
const cubeName = metricsResult.cube_name || null;
|
|
584
|
+
const isFastQuery = !!cubeName; // Fast if using materialized cube
|
|
581
585
|
|
|
582
586
|
// Determine if materialization is already configured (has active workflows)
|
|
583
587
|
const isMaterialized =
|
|
@@ -609,11 +613,29 @@ export function QueryOverviewPanel({
|
|
|
609
613
|
{/* Header */}
|
|
610
614
|
<div className="details-header">
|
|
611
615
|
<h2 className="details-title">Query Plan</h2>
|
|
612
|
-
<p className="details-
|
|
616
|
+
<p className="details-info-row">
|
|
613
617
|
{selectedMetrics.length} metric
|
|
614
618
|
{selectedMetrics.length !== 1 ? 's' : ''} ×{' '}
|
|
615
619
|
{selectedDimensions.length} dimension
|
|
616
620
|
{selectedDimensions.length !== 1 ? 's' : ''}
|
|
621
|
+
{isFastQuery && (
|
|
622
|
+
<>
|
|
623
|
+
{' · '}
|
|
624
|
+
<span className="info-materialized">
|
|
625
|
+
<span style={{ fontFamily: 'sans-serif' }}>⚡</span>{' '}
|
|
626
|
+
Materialized cube available
|
|
627
|
+
</span>
|
|
628
|
+
{cubeAvailability?.validThroughTs && (
|
|
629
|
+
<>
|
|
630
|
+
{' '}
|
|
631
|
+
· Valid thru{' '}
|
|
632
|
+
{new Date(
|
|
633
|
+
cubeAvailability.validThroughTs,
|
|
634
|
+
).toLocaleDateString()}
|
|
635
|
+
</>
|
|
636
|
+
)}
|
|
637
|
+
</>
|
|
638
|
+
)}
|
|
617
639
|
</p>
|
|
618
640
|
</div>
|
|
619
641
|
|
|
@@ -2198,6 +2220,29 @@ export function QueryOverviewPanel({
|
|
|
2198
2220
|
<span className="section-icon">⌘</span>
|
|
2199
2221
|
Generated SQL
|
|
2200
2222
|
</h3>
|
|
2223
|
+
<span className="sql-info-inline">
|
|
2224
|
+
{sqlViewMode === 'optimized' && isFastQuery ? (
|
|
2225
|
+
<>
|
|
2226
|
+
<span className="info-materialized">
|
|
2227
|
+
<span style={{ fontFamily: 'sans-serif' }}>⚡</span> Using
|
|
2228
|
+
materialized cube
|
|
2229
|
+
</span>
|
|
2230
|
+
{cubeAvailability?.validThroughTs && (
|
|
2231
|
+
<>
|
|
2232
|
+
{' · Valid thru '}
|
|
2233
|
+
{new Date(
|
|
2234
|
+
cubeAvailability.validThroughTs,
|
|
2235
|
+
).toLocaleDateString()}
|
|
2236
|
+
</>
|
|
2237
|
+
)}
|
|
2238
|
+
</>
|
|
2239
|
+
) : sqlViewMode === 'raw' ? (
|
|
2240
|
+
<span className="info-base-tables">
|
|
2241
|
+
<span style={{ fontFamily: 'sans-serif' }}>⚠️</span> Using
|
|
2242
|
+
base tables
|
|
2243
|
+
</span>
|
|
2244
|
+
) : null}
|
|
2245
|
+
</span>
|
|
2201
2246
|
<div className="sql-view-toggle">
|
|
2202
2247
|
<button
|
|
2203
2248
|
className={`sql-toggle-btn ${
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
3
|
+
import { foundation } from 'react-syntax-highlighter/src/styles/hljs';
|
|
4
|
+
import sql from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
|
|
5
|
+
|
|
6
|
+
SyntaxHighlighter.registerLanguage('sql', sql);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ResultsView - Displays query results with SQL and data table
|
|
10
|
+
* Layout: SQL in top 1/3, results in bottom 2/3
|
|
11
|
+
*/
|
|
12
|
+
export function ResultsView({
|
|
13
|
+
sql: sqlQuery,
|
|
14
|
+
results,
|
|
15
|
+
loading,
|
|
16
|
+
error,
|
|
17
|
+
elapsedTime,
|
|
18
|
+
onBackToPlan,
|
|
19
|
+
selectedMetrics,
|
|
20
|
+
selectedDimensions,
|
|
21
|
+
filters,
|
|
22
|
+
dialect,
|
|
23
|
+
cubeName,
|
|
24
|
+
availability,
|
|
25
|
+
}) {
|
|
26
|
+
const [copied, setCopied] = useState(false);
|
|
27
|
+
const [sortColumn, setSortColumn] = useState(null);
|
|
28
|
+
const [sortDirection, setSortDirection] = useState('asc');
|
|
29
|
+
|
|
30
|
+
const handleCopySql = useCallback(() => {
|
|
31
|
+
if (sqlQuery) {
|
|
32
|
+
navigator.clipboard.writeText(sqlQuery);
|
|
33
|
+
setCopied(true);
|
|
34
|
+
setTimeout(() => setCopied(false), 2000);
|
|
35
|
+
}
|
|
36
|
+
}, [sqlQuery]);
|
|
37
|
+
|
|
38
|
+
// Parse results data - handle new v3 format
|
|
39
|
+
const columns = results?.results?.[0]?.columns || [];
|
|
40
|
+
const rows = results?.results?.[0]?.rows || [];
|
|
41
|
+
const rowCount = rows.length;
|
|
42
|
+
|
|
43
|
+
// Handle column header click for sorting
|
|
44
|
+
const handleSort = useCallback(
|
|
45
|
+
columnIndex => {
|
|
46
|
+
if (sortColumn === columnIndex) {
|
|
47
|
+
setSortDirection(d => (d === 'asc' ? 'desc' : 'asc'));
|
|
48
|
+
} else {
|
|
49
|
+
setSortColumn(columnIndex);
|
|
50
|
+
setSortDirection('asc');
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[sortColumn],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Sort rows based on current sort state
|
|
57
|
+
const sortedRows = useMemo(() => {
|
|
58
|
+
if (sortColumn === null) return rows;
|
|
59
|
+
return [...rows].sort((a, b) => {
|
|
60
|
+
const aVal = a[sortColumn];
|
|
61
|
+
const bVal = b[sortColumn];
|
|
62
|
+
// Handle nulls - nulls go last
|
|
63
|
+
if (aVal === null && bVal === null) return 0;
|
|
64
|
+
if (aVal === null) return 1;
|
|
65
|
+
if (bVal === null) return -1;
|
|
66
|
+
// Compare values
|
|
67
|
+
let cmp;
|
|
68
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
69
|
+
cmp = aVal - bVal;
|
|
70
|
+
} else {
|
|
71
|
+
cmp = String(aVal).localeCompare(String(bVal));
|
|
72
|
+
}
|
|
73
|
+
return sortDirection === 'asc' ? cmp : -cmp;
|
|
74
|
+
});
|
|
75
|
+
}, [rows, sortColumn, sortDirection]);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="results-view">
|
|
79
|
+
{/* Header */}
|
|
80
|
+
<div className="results-header">
|
|
81
|
+
<button className="back-to-plan-btn" onClick={onBackToPlan}>
|
|
82
|
+
<span className="back-arrow">←</span>
|
|
83
|
+
<span>Back to Plan</span>
|
|
84
|
+
</button>
|
|
85
|
+
<div className="results-summary">
|
|
86
|
+
{loading ? (
|
|
87
|
+
<span className="results-loading-text">Running query...</span>
|
|
88
|
+
) : error ? (
|
|
89
|
+
<span className="results-error-text">Query failed</span>
|
|
90
|
+
) : (
|
|
91
|
+
<>
|
|
92
|
+
<span className="results-count">
|
|
93
|
+
{rowCount.toLocaleString()} rows
|
|
94
|
+
</span>
|
|
95
|
+
{elapsedTime != null && (
|
|
96
|
+
<span className="results-time">{elapsedTime.toFixed(2)}s</span>
|
|
97
|
+
)}
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Two-pane layout: SQL (top 1/3) + Results (bottom 2/3) */}
|
|
104
|
+
<div className="results-panes">
|
|
105
|
+
{/* SQL Pane - always visible, top 1/3 */}
|
|
106
|
+
<div className="sql-pane">
|
|
107
|
+
<div className="sql-pane-header">
|
|
108
|
+
<span className="sql-pane-title">SQL Query</span>
|
|
109
|
+
{cubeName && (
|
|
110
|
+
<span
|
|
111
|
+
className="sql-pane-info"
|
|
112
|
+
title={
|
|
113
|
+
availability
|
|
114
|
+
? `Querying materialized dataset ${[
|
|
115
|
+
availability.catalog,
|
|
116
|
+
availability.schema_,
|
|
117
|
+
availability.table,
|
|
118
|
+
]
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
.join('.')}, last refreshed for data through ${new Date(
|
|
121
|
+
availability.validThroughTs,
|
|
122
|
+
).toLocaleDateString()}`
|
|
123
|
+
: undefined
|
|
124
|
+
}
|
|
125
|
+
>
|
|
126
|
+
<span className="info-materialized">
|
|
127
|
+
<span style={{ fontFamily: 'sans-serif' }}>⚡</span> Using
|
|
128
|
+
materialized cube
|
|
129
|
+
</span>
|
|
130
|
+
{availability?.validThroughTs && (
|
|
131
|
+
<>
|
|
132
|
+
{' · Valid thru '}
|
|
133
|
+
{new Date(availability.validThroughTs).toLocaleDateString()}
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
<button
|
|
139
|
+
className={`copy-btn ${copied ? 'copied' : ''}`}
|
|
140
|
+
onClick={handleCopySql}
|
|
141
|
+
disabled={!sqlQuery}
|
|
142
|
+
>
|
|
143
|
+
{copied ? '✓ Copied' : 'Copy'}
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="sql-pane-content">
|
|
147
|
+
{sqlQuery ? (
|
|
148
|
+
<SyntaxHighlighter
|
|
149
|
+
language="sql"
|
|
150
|
+
style={foundation}
|
|
151
|
+
wrapLongLines={true}
|
|
152
|
+
customStyle={{
|
|
153
|
+
margin: 0,
|
|
154
|
+
padding: '12px 16px',
|
|
155
|
+
background: '#f8fafc',
|
|
156
|
+
fontSize: '12px',
|
|
157
|
+
height: '100%',
|
|
158
|
+
overflow: 'auto',
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{sqlQuery}
|
|
162
|
+
</SyntaxHighlighter>
|
|
163
|
+
) : (
|
|
164
|
+
<div className="sql-pane-empty">Generating SQL...</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Results Pane - bottom 2/3 */}
|
|
170
|
+
<div className="results-pane">
|
|
171
|
+
{loading ? (
|
|
172
|
+
<div className="results-loading">
|
|
173
|
+
<div className="loading-spinner large" />
|
|
174
|
+
<span>Executing query...</span>
|
|
175
|
+
<span className="loading-hint">
|
|
176
|
+
Querying {selectedMetrics.length} metric(s) with{' '}
|
|
177
|
+
{selectedDimensions.length} dimension(s)
|
|
178
|
+
</span>
|
|
179
|
+
</div>
|
|
180
|
+
) : error ? (
|
|
181
|
+
<div className="results-error">
|
|
182
|
+
<div className="error-icon">⚠</div>
|
|
183
|
+
<h3>Query Failed</h3>
|
|
184
|
+
<p className="error-message">{error}</p>
|
|
185
|
+
<button
|
|
186
|
+
className="action-btn action-btn-primary"
|
|
187
|
+
onClick={onBackToPlan}
|
|
188
|
+
>
|
|
189
|
+
Back to Plan
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
) : (
|
|
193
|
+
<div className="results-table-section">
|
|
194
|
+
<div className="table-header">
|
|
195
|
+
<span className="table-title">Results</span>
|
|
196
|
+
<span className="table-count">
|
|
197
|
+
{rowCount.toLocaleString()} rows
|
|
198
|
+
</span>
|
|
199
|
+
{filters && filters.length > 0 && (
|
|
200
|
+
<div className="table-filters">
|
|
201
|
+
{filters.map((filter, idx) => (
|
|
202
|
+
<span key={idx} className="filter-chip small">
|
|
203
|
+
{filter}
|
|
204
|
+
</span>
|
|
205
|
+
))}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
<div className="results-table-wrapper">
|
|
210
|
+
{rowCount === 0 ? (
|
|
211
|
+
<div className="table-empty">
|
|
212
|
+
<p>No results returned</p>
|
|
213
|
+
</div>
|
|
214
|
+
) : (
|
|
215
|
+
<table className="results-table">
|
|
216
|
+
<thead>
|
|
217
|
+
<tr>
|
|
218
|
+
{columns.map((col, idx) => (
|
|
219
|
+
<th
|
|
220
|
+
key={idx}
|
|
221
|
+
title={col.semantic_name || col.name}
|
|
222
|
+
onClick={() => handleSort(idx)}
|
|
223
|
+
className={sortColumn === idx ? 'sorted' : ''}
|
|
224
|
+
>
|
|
225
|
+
<span className="col-header-content">
|
|
226
|
+
{col.name}
|
|
227
|
+
<span className="sort-arrows">
|
|
228
|
+
<span
|
|
229
|
+
className={`sort-arrow up ${
|
|
230
|
+
sortColumn === idx &&
|
|
231
|
+
sortDirection === 'asc'
|
|
232
|
+
? 'active'
|
|
233
|
+
: ''
|
|
234
|
+
}`}
|
|
235
|
+
>
|
|
236
|
+
▲
|
|
237
|
+
</span>
|
|
238
|
+
<span
|
|
239
|
+
className={`sort-arrow down ${
|
|
240
|
+
sortColumn === idx &&
|
|
241
|
+
sortDirection === 'desc'
|
|
242
|
+
? 'active'
|
|
243
|
+
: ''
|
|
244
|
+
}`}
|
|
245
|
+
>
|
|
246
|
+
▼
|
|
247
|
+
</span>
|
|
248
|
+
</span>
|
|
249
|
+
</span>
|
|
250
|
+
<span className="col-type">{col.type}</span>
|
|
251
|
+
</th>
|
|
252
|
+
))}
|
|
253
|
+
</tr>
|
|
254
|
+
</thead>
|
|
255
|
+
<tbody>
|
|
256
|
+
{sortedRows.map((row, rowIdx) => (
|
|
257
|
+
<tr key={rowIdx}>
|
|
258
|
+
{row.map((cell, cellIdx) => (
|
|
259
|
+
<td key={cellIdx}>
|
|
260
|
+
{cell === null ? (
|
|
261
|
+
<span className="null-value">NULL</span>
|
|
262
|
+
) : (
|
|
263
|
+
String(cell)
|
|
264
|
+
)}
|
|
265
|
+
</td>
|
|
266
|
+
))}
|
|
267
|
+
</tr>
|
|
268
|
+
))}
|
|
269
|
+
</tbody>
|
|
270
|
+
</table>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default ResultsView;
|