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,13 +1,397 @@
|
|
|
1
|
-
import { useState, useCallback, useMemo } from 'react';
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect, 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';
|
|
5
|
+
import {
|
|
6
|
+
LineChart,
|
|
7
|
+
Line,
|
|
8
|
+
BarChart,
|
|
9
|
+
Bar,
|
|
10
|
+
XAxis,
|
|
11
|
+
YAxis,
|
|
12
|
+
CartesianGrid,
|
|
13
|
+
Tooltip,
|
|
14
|
+
ResponsiveContainer,
|
|
15
|
+
} from 'recharts';
|
|
5
16
|
|
|
6
17
|
SyntaxHighlighter.registerLanguage('sql', sql);
|
|
7
18
|
|
|
19
|
+
const SERIES_COLORS = [
|
|
20
|
+
'#60a5fa',
|
|
21
|
+
'#34d399',
|
|
22
|
+
'#fbbf24',
|
|
23
|
+
'#f87171',
|
|
24
|
+
'#a78bfa',
|
|
25
|
+
'#22d3ee',
|
|
26
|
+
'#fb923c',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Threshold for switching from multi-series to small multiples
|
|
30
|
+
const SMALL_MULTIPLES_THRESHOLD = 2;
|
|
31
|
+
|
|
32
|
+
function isTimeColumn(col) {
|
|
33
|
+
const name = col.name.toLowerCase();
|
|
34
|
+
const type = (col.type || '').toLowerCase();
|
|
35
|
+
return (
|
|
36
|
+
type.includes('date') ||
|
|
37
|
+
type.includes('timestamp') ||
|
|
38
|
+
type.includes('time') ||
|
|
39
|
+
name === 'date' ||
|
|
40
|
+
name === 'day' ||
|
|
41
|
+
name === 'week' ||
|
|
42
|
+
name === 'month' ||
|
|
43
|
+
name === 'year' ||
|
|
44
|
+
name === 'quarter' ||
|
|
45
|
+
name.endsWith('_date') ||
|
|
46
|
+
name.endsWith('_day') ||
|
|
47
|
+
name.endsWith('_week') ||
|
|
48
|
+
name.endsWith('_month') ||
|
|
49
|
+
name.endsWith('_year') ||
|
|
50
|
+
name.endsWith('_at') ||
|
|
51
|
+
name.startsWith('date') ||
|
|
52
|
+
name.startsWith('ds')
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isNumericColumn(col) {
|
|
57
|
+
const type = (col.type || '').toLowerCase();
|
|
58
|
+
return (
|
|
59
|
+
type.includes('int') ||
|
|
60
|
+
type.includes('float') ||
|
|
61
|
+
type.includes('double') ||
|
|
62
|
+
type.includes('decimal') ||
|
|
63
|
+
type.includes('numeric') ||
|
|
64
|
+
type.includes('real') ||
|
|
65
|
+
type.includes('number')
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function detectChartConfig(columns, rows) {
|
|
70
|
+
if (!columns.length || !rows.length) return null;
|
|
71
|
+
|
|
72
|
+
const tagged = columns.map((c, i) => ({ ...c, idx: i }));
|
|
73
|
+
const timeCols = tagged.filter(c => isTimeColumn(c));
|
|
74
|
+
const numericCols = tagged.filter(c => isNumericColumn(c));
|
|
75
|
+
const nonNumericCols = tagged.filter(c => !isNumericColumn(c));
|
|
76
|
+
const nonTimeCatCols = nonNumericCols.filter(c => !isTimeColumn(c));
|
|
77
|
+
|
|
78
|
+
// Time dimension present → line chart
|
|
79
|
+
if (timeCols.length > 0) {
|
|
80
|
+
const xCol = timeCols[0];
|
|
81
|
+
const metricCols = numericCols.filter(c => c.idx !== xCol.idx);
|
|
82
|
+
if (metricCols.length > 0) {
|
|
83
|
+
// Exactly one categorical dim + one metric → pivot as series
|
|
84
|
+
if (nonTimeCatCols.length === 1) {
|
|
85
|
+
return {
|
|
86
|
+
type: 'line',
|
|
87
|
+
xCol,
|
|
88
|
+
groupByCol: nonTimeCatCols[0],
|
|
89
|
+
metricCols,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { type: 'line', xCol, metricCols };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Categorical dimension(s) → bar chart
|
|
97
|
+
if (nonTimeCatCols.length > 0 && numericCols.length > 0) {
|
|
98
|
+
if (nonTimeCatCols.length === 1) {
|
|
99
|
+
return { type: 'bar', xCol: nonTimeCatCols[0], metricCols: numericCols };
|
|
100
|
+
}
|
|
101
|
+
if (nonTimeCatCols.length === 2) {
|
|
102
|
+
return {
|
|
103
|
+
type: 'bar',
|
|
104
|
+
xCol: nonTimeCatCols[0],
|
|
105
|
+
groupByCol: nonTimeCatCols[1],
|
|
106
|
+
metricCols: numericCols,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// 3+ cats → fall back to first cat as x-axis
|
|
110
|
+
return { type: 'bar', xCol: nonTimeCatCols[0], metricCols: numericCols };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Multiple numeric columns, no string/time dim → treat first as x-axis (line)
|
|
114
|
+
if (numericCols.length > 1) {
|
|
115
|
+
const xCol = numericCols[0];
|
|
116
|
+
const metricCols = numericCols.slice(1);
|
|
117
|
+
return { type: 'line', xCol, metricCols };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Scalar result → KPI cards
|
|
121
|
+
if (numericCols.length > 0) {
|
|
122
|
+
return { type: 'kpi', metricCols: numericCols };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const MAX_GROUP_VALUES = 7;
|
|
129
|
+
|
|
130
|
+
function buildPivotedData(rows, columns, xCol, groupByCol, metricCols) {
|
|
131
|
+
const xIdx = xCol.idx;
|
|
132
|
+
const gIdx = groupByCol.idx;
|
|
133
|
+
const metricIdxs = metricCols.map(c => c.idx);
|
|
134
|
+
|
|
135
|
+
// Pass 1: group totals only (cheap — just numbers)
|
|
136
|
+
const groupTotals = {};
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
const gVal = String(row[gIdx] ?? '(null)');
|
|
139
|
+
groupTotals[gVal] =
|
|
140
|
+
(groupTotals[gVal] || 0) + (Number(row[metricIdxs[0]]) || 0);
|
|
141
|
+
}
|
|
142
|
+
const groupValues = Object.entries(groupTotals)
|
|
143
|
+
.sort((a, b) => b[1] - a[1])
|
|
144
|
+
.slice(0, MAX_GROUP_VALUES)
|
|
145
|
+
.map(([k]) => k);
|
|
146
|
+
const groupSet = new Set(groupValues);
|
|
147
|
+
|
|
148
|
+
// Pass 2: build ALL metric pivot maps in one sweep
|
|
149
|
+
const pivotMaps = metricCols.map(() => ({}));
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
const gVal = String(row[gIdx] ?? '(null)');
|
|
152
|
+
if (!groupSet.has(gVal)) continue;
|
|
153
|
+
const xVal = row[xIdx];
|
|
154
|
+
const mapKey = String(xVal ?? '(null)');
|
|
155
|
+
for (let m = 0; m < metricCols.length; m++) {
|
|
156
|
+
const pm = pivotMaps[m];
|
|
157
|
+
if (!pm[mapKey]) pm[mapKey] = { [xCol.name]: xVal };
|
|
158
|
+
pm[mapKey][gVal] = row[metricIdxs[m]];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const sortFn = (a, b) => {
|
|
163
|
+
const av = a[xCol.name];
|
|
164
|
+
const bv = b[xCol.name];
|
|
165
|
+
if (av === null && bv === null) return 0;
|
|
166
|
+
if (av === null) return 1;
|
|
167
|
+
if (bv === null) return -1;
|
|
168
|
+
if (typeof av === 'number' && typeof bv === 'number') return av - bv;
|
|
169
|
+
return String(av).localeCompare(String(bv));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const pivotedByMetric = metricCols.map((metricCol, m) => {
|
|
173
|
+
const pivoted = Object.values(pivotMaps[m]);
|
|
174
|
+
pivoted.sort(sortFn);
|
|
175
|
+
return { col: metricCol, data: pivoted };
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return { pivotedByMetric, groupValues };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildChartData(columns, rows, xCol) {
|
|
182
|
+
const data = rows.map(row => {
|
|
183
|
+
const obj = {};
|
|
184
|
+
columns.forEach((col, i) => {
|
|
185
|
+
obj[col.name] = row[i];
|
|
186
|
+
});
|
|
187
|
+
return obj;
|
|
188
|
+
});
|
|
189
|
+
const key = xCol.name;
|
|
190
|
+
data.sort((a, b) => {
|
|
191
|
+
const av = a[key];
|
|
192
|
+
const bv = b[key];
|
|
193
|
+
if (av === null && bv === null) return 0;
|
|
194
|
+
if (av === null) return 1;
|
|
195
|
+
if (bv === null) return -1;
|
|
196
|
+
if (typeof av === 'number' && typeof bv === 'number') return av - bv;
|
|
197
|
+
return String(av).localeCompare(String(bv));
|
|
198
|
+
});
|
|
199
|
+
return data;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function formatYAxis(value) {
|
|
203
|
+
if (Math.abs(value) >= 1_000_000_000)
|
|
204
|
+
return (value / 1_000_000_000).toFixed(1) + 'B';
|
|
205
|
+
if (Math.abs(value) >= 1_000_000) return (value / 1_000_000).toFixed(1) + 'M';
|
|
206
|
+
if (Math.abs(value) >= 1_000) return (value / 1_000).toFixed(1) + 'K';
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function KpiCards({ rows, metricCols }) {
|
|
211
|
+
const row = rows[0] || [];
|
|
212
|
+
return (
|
|
213
|
+
<div className="kpi-cards">
|
|
214
|
+
{metricCols.map(col => {
|
|
215
|
+
const val = row[col.idx];
|
|
216
|
+
const formatted =
|
|
217
|
+
val == null
|
|
218
|
+
? '—'
|
|
219
|
+
: typeof val === 'number'
|
|
220
|
+
? val.toLocaleString(undefined, { maximumFractionDigits: 4 })
|
|
221
|
+
: String(val);
|
|
222
|
+
return (
|
|
223
|
+
<div key={col.idx} className="kpi-card">
|
|
224
|
+
<div className="kpi-label">{col.name}</div>
|
|
225
|
+
<div className="kpi-value">{formatted}</div>
|
|
226
|
+
{col.type && <div className="kpi-type">{col.type}</div>}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
})}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const CHART_MARGIN = { top: 8, right: 24, left: 8, bottom: 40 };
|
|
235
|
+
const AXIS_TICK = { fontSize: 11, fill: '#64748b' };
|
|
236
|
+
const TOOLTIP_STYLE = { fontSize: 12, border: '1px solid #e2e8f0' };
|
|
237
|
+
|
|
238
|
+
const Chart = memo(function Chart({
|
|
239
|
+
type,
|
|
240
|
+
xCol,
|
|
241
|
+
metricCols,
|
|
242
|
+
seriesKeys,
|
|
243
|
+
chartData,
|
|
244
|
+
seriesColors = SERIES_COLORS,
|
|
245
|
+
}) {
|
|
246
|
+
const showDots = chartData.length <= 60;
|
|
247
|
+
const keys = seriesKeys || metricCols.map(c => c.name);
|
|
248
|
+
const xInterval =
|
|
249
|
+
type === 'line'
|
|
250
|
+
? 'preserveStartEnd'
|
|
251
|
+
: Math.max(0, Math.ceil(chartData.length / 20) - 1);
|
|
252
|
+
if (type === 'line') {
|
|
253
|
+
return (
|
|
254
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
255
|
+
<LineChart data={chartData} margin={CHART_MARGIN}>
|
|
256
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
257
|
+
<XAxis
|
|
258
|
+
dataKey={xCol.name}
|
|
259
|
+
tick={AXIS_TICK}
|
|
260
|
+
angle={-35}
|
|
261
|
+
textAnchor="end"
|
|
262
|
+
interval={xInterval}
|
|
263
|
+
/>
|
|
264
|
+
<YAxis tickFormatter={formatYAxis} tick={AXIS_TICK} width={60} />
|
|
265
|
+
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
|
266
|
+
{keys.map((key, i) => (
|
|
267
|
+
<Line
|
|
268
|
+
key={key}
|
|
269
|
+
type="monotone"
|
|
270
|
+
dataKey={key}
|
|
271
|
+
stroke={seriesColors[i % seriesColors.length]}
|
|
272
|
+
dot={showDots}
|
|
273
|
+
strokeWidth={2}
|
|
274
|
+
isAnimationActive={false}
|
|
275
|
+
/>
|
|
276
|
+
))}
|
|
277
|
+
</LineChart>
|
|
278
|
+
</ResponsiveContainer>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return (
|
|
282
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
283
|
+
<BarChart data={chartData} margin={CHART_MARGIN}>
|
|
284
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
285
|
+
<XAxis
|
|
286
|
+
dataKey={xCol.name}
|
|
287
|
+
tick={AXIS_TICK}
|
|
288
|
+
angle={-35}
|
|
289
|
+
textAnchor="end"
|
|
290
|
+
interval={xInterval}
|
|
291
|
+
/>
|
|
292
|
+
<YAxis tickFormatter={formatYAxis} tick={AXIS_TICK} width={60} />
|
|
293
|
+
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
|
294
|
+
{keys.map((key, i) => (
|
|
295
|
+
<Bar
|
|
296
|
+
key={key}
|
|
297
|
+
dataKey={key}
|
|
298
|
+
fill={seriesColors[i % seriesColors.length]}
|
|
299
|
+
isAnimationActive={false}
|
|
300
|
+
/>
|
|
301
|
+
))}
|
|
302
|
+
</BarChart>
|
|
303
|
+
</ResponsiveContainer>
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const ChartView = memo(function ChartView({
|
|
308
|
+
chartConfig,
|
|
309
|
+
chartData,
|
|
310
|
+
pivotedByMetric,
|
|
311
|
+
groupValues,
|
|
312
|
+
rows,
|
|
313
|
+
columns,
|
|
314
|
+
}) {
|
|
315
|
+
if (!chartConfig) {
|
|
316
|
+
return <div className="chart-no-data">No chartable data detected</div>;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (chartConfig.type === 'kpi') {
|
|
320
|
+
return <KpiCards rows={rows} metricCols={chartConfig.metricCols} />;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const { type, xCol, metricCols } = chartConfig;
|
|
324
|
+
|
|
325
|
+
// Pivoted multi-metric: small multiples, one per metric, each with groupBy series
|
|
326
|
+
if (pivotedByMetric && pivotedByMetric.length > 1) {
|
|
327
|
+
return (
|
|
328
|
+
<div className="small-multiples">
|
|
329
|
+
{pivotedByMetric.map(({ col, data }) => (
|
|
330
|
+
<div key={col.idx} className="small-multiple">
|
|
331
|
+
<div className="small-multiple-label">{col.name}</div>
|
|
332
|
+
<div className="small-multiple-chart">
|
|
333
|
+
<Chart
|
|
334
|
+
type={type}
|
|
335
|
+
xCol={xCol}
|
|
336
|
+
metricCols={[col]}
|
|
337
|
+
seriesKeys={groupValues}
|
|
338
|
+
chartData={data}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
))}
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Pivoted single-metric: one chart with groupBy as series
|
|
348
|
+
if (groupValues) {
|
|
349
|
+
return (
|
|
350
|
+
<Chart
|
|
351
|
+
type={type}
|
|
352
|
+
xCol={xCol}
|
|
353
|
+
metricCols={metricCols}
|
|
354
|
+
seriesKeys={groupValues}
|
|
355
|
+
chartData={chartData}
|
|
356
|
+
/>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// No groupBy: standard small multiples or single chart
|
|
361
|
+
if (metricCols.length > SMALL_MULTIPLES_THRESHOLD) {
|
|
362
|
+
return (
|
|
363
|
+
<div className="small-multiples">
|
|
364
|
+
{metricCols.map((col, i) => (
|
|
365
|
+
<div key={col.idx} className="small-multiple">
|
|
366
|
+
<div className="small-multiple-label">{col.name}</div>
|
|
367
|
+
<div className="small-multiple-chart">
|
|
368
|
+
<Chart
|
|
369
|
+
type={type}
|
|
370
|
+
xCol={xCol}
|
|
371
|
+
metricCols={[col]}
|
|
372
|
+
chartData={chartData}
|
|
373
|
+
seriesColors={[SERIES_COLORS[i % SERIES_COLORS.length]]}
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<Chart
|
|
384
|
+
type={type}
|
|
385
|
+
xCol={xCol}
|
|
386
|
+
metricCols={metricCols}
|
|
387
|
+
chartData={chartData}
|
|
388
|
+
/>
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
8
392
|
/**
|
|
9
393
|
* ResultsView - Displays query results with SQL and data table
|
|
10
|
-
* Layout: SQL in top
|
|
394
|
+
* Layout: SQL in top ~25%, results in bottom ~75% with Table/Chart tabs
|
|
11
395
|
*/
|
|
12
396
|
export function ResultsView({
|
|
13
397
|
sql: sqlQuery,
|
|
@@ -22,10 +406,12 @@ export function ResultsView({
|
|
|
22
406
|
dialect,
|
|
23
407
|
cubeName,
|
|
24
408
|
availability,
|
|
409
|
+
links,
|
|
25
410
|
}) {
|
|
26
411
|
const [copied, setCopied] = useState(false);
|
|
27
412
|
const [sortColumn, setSortColumn] = useState(null);
|
|
28
413
|
const [sortDirection, setSortDirection] = useState('asc');
|
|
414
|
+
const [activeTab, setActiveTab] = useState('table');
|
|
29
415
|
|
|
30
416
|
const handleCopySql = useCallback(() => {
|
|
31
417
|
if (sqlQuery) {
|
|
@@ -35,12 +421,13 @@ export function ResultsView({
|
|
|
35
421
|
}
|
|
36
422
|
}, [sqlQuery]);
|
|
37
423
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
424
|
+
const columns = useMemo(
|
|
425
|
+
() => results?.results?.[0]?.columns || [],
|
|
426
|
+
[results],
|
|
427
|
+
);
|
|
428
|
+
const rows = useMemo(() => results?.results?.[0]?.rows || [], [results]);
|
|
41
429
|
const rowCount = rows.length;
|
|
42
430
|
|
|
43
|
-
// Handle column header click for sorting
|
|
44
431
|
const handleSort = useCallback(
|
|
45
432
|
columnIndex => {
|
|
46
433
|
if (sortColumn === columnIndex) {
|
|
@@ -53,17 +440,14 @@ export function ResultsView({
|
|
|
53
440
|
[sortColumn],
|
|
54
441
|
);
|
|
55
442
|
|
|
56
|
-
// Sort rows based on current sort state
|
|
57
443
|
const sortedRows = useMemo(() => {
|
|
58
444
|
if (sortColumn === null) return rows;
|
|
59
445
|
return [...rows].sort((a, b) => {
|
|
60
446
|
const aVal = a[sortColumn];
|
|
61
447
|
const bVal = b[sortColumn];
|
|
62
|
-
// Handle nulls - nulls go last
|
|
63
448
|
if (aVal === null && bVal === null) return 0;
|
|
64
449
|
if (aVal === null) return 1;
|
|
65
450
|
if (bVal === null) return -1;
|
|
66
|
-
// Compare values
|
|
67
451
|
let cmp;
|
|
68
452
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
69
453
|
cmp = aVal - bVal;
|
|
@@ -74,6 +458,41 @@ export function ResultsView({
|
|
|
74
458
|
});
|
|
75
459
|
}, [rows, sortColumn, sortDirection]);
|
|
76
460
|
|
|
461
|
+
const chartConfig = useMemo(
|
|
462
|
+
() => detectChartConfig(columns, rows),
|
|
463
|
+
[columns, rows],
|
|
464
|
+
);
|
|
465
|
+
const { chartData, pivotedByMetric, groupValues } = useMemo(() => {
|
|
466
|
+
if (!chartConfig || !chartConfig.xCol)
|
|
467
|
+
return { chartData: [], pivotedByMetric: null, groupValues: null };
|
|
468
|
+
if (chartConfig.groupByCol) {
|
|
469
|
+
const { pivotedByMetric, groupValues } = buildPivotedData(
|
|
470
|
+
rows,
|
|
471
|
+
columns,
|
|
472
|
+
chartConfig.xCol,
|
|
473
|
+
chartConfig.groupByCol,
|
|
474
|
+
chartConfig.metricCols,
|
|
475
|
+
);
|
|
476
|
+
return {
|
|
477
|
+
chartData: pivotedByMetric[0].data,
|
|
478
|
+
pivotedByMetric,
|
|
479
|
+
groupValues,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
chartData: buildChartData(columns, rows, chartConfig.xCol),
|
|
484
|
+
pivotedByMetric: null,
|
|
485
|
+
groupValues: null,
|
|
486
|
+
};
|
|
487
|
+
}, [columns, rows, chartConfig]);
|
|
488
|
+
|
|
489
|
+
const canChart = rowCount > 0;
|
|
490
|
+
|
|
491
|
+
// Reset to table view if new results can't be charted
|
|
492
|
+
useEffect(() => {
|
|
493
|
+
if (!canChart && activeTab === 'chart') setActiveTab('table');
|
|
494
|
+
}, [canChart, activeTab]);
|
|
495
|
+
|
|
77
496
|
return (
|
|
78
497
|
<div className="results-view">
|
|
79
498
|
{/* Header */}
|
|
@@ -100,9 +519,9 @@ export function ResultsView({
|
|
|
100
519
|
</div>
|
|
101
520
|
</div>
|
|
102
521
|
|
|
103
|
-
{/* Two-pane layout: SQL (top
|
|
522
|
+
{/* Two-pane layout: SQL (top) + Results (bottom) */}
|
|
104
523
|
<div className="results-panes">
|
|
105
|
-
{/* SQL Pane
|
|
524
|
+
{/* SQL Pane */}
|
|
106
525
|
<div className="sql-pane">
|
|
107
526
|
<div className="sql-pane-header">
|
|
108
527
|
<span className="sql-pane-title">SQL Query</span>
|
|
@@ -166,7 +585,7 @@ export function ResultsView({
|
|
|
166
585
|
</div>
|
|
167
586
|
</div>
|
|
168
587
|
|
|
169
|
-
{/* Results Pane
|
|
588
|
+
{/* Results Pane */}
|
|
170
589
|
<div className="results-pane">
|
|
171
590
|
{loading ? (
|
|
172
591
|
<div className="results-loading">
|
|
@@ -176,6 +595,21 @@ export function ResultsView({
|
|
|
176
595
|
Querying {selectedMetrics.length} metric(s) with{' '}
|
|
177
596
|
{selectedDimensions.length} dimension(s)
|
|
178
597
|
</span>
|
|
598
|
+
{links && links.length > 0 && (
|
|
599
|
+
<span className="results-links">
|
|
600
|
+
{links.map((link, idx) => (
|
|
601
|
+
<a
|
|
602
|
+
key={idx}
|
|
603
|
+
href={link}
|
|
604
|
+
target="_blank"
|
|
605
|
+
rel="noopener noreferrer"
|
|
606
|
+
className="results-link"
|
|
607
|
+
>
|
|
608
|
+
View query ↗
|
|
609
|
+
</a>
|
|
610
|
+
))}
|
|
611
|
+
</span>
|
|
612
|
+
)}
|
|
179
613
|
</div>
|
|
180
614
|
) : error ? (
|
|
181
615
|
<div className="results-error">
|
|
@@ -191,85 +625,131 @@ export function ResultsView({
|
|
|
191
625
|
</div>
|
|
192
626
|
) : (
|
|
193
627
|
<div className="results-table-section">
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
{
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
628
|
+
{/* Tab bar */}
|
|
629
|
+
<div className="results-tabs-bar">
|
|
630
|
+
<div className="results-tabs">
|
|
631
|
+
<button
|
|
632
|
+
className={`results-tab ${
|
|
633
|
+
activeTab === 'table' ? 'active' : ''
|
|
634
|
+
}`}
|
|
635
|
+
onClick={() => setActiveTab('table')}
|
|
636
|
+
>
|
|
637
|
+
Table
|
|
638
|
+
</button>
|
|
639
|
+
<button
|
|
640
|
+
className={`results-tab ${
|
|
641
|
+
activeTab === 'chart' ? 'active' : ''
|
|
642
|
+
} ${!canChart ? 'disabled' : ''}`}
|
|
643
|
+
onClick={() => canChart && setActiveTab('chart')}
|
|
644
|
+
title={
|
|
645
|
+
!canChart
|
|
646
|
+
? 'No chartable data (need at least one numeric column)'
|
|
647
|
+
: undefined
|
|
648
|
+
}
|
|
649
|
+
>
|
|
650
|
+
Chart
|
|
651
|
+
</button>
|
|
652
|
+
</div>
|
|
653
|
+
<div className="results-tabs-meta">
|
|
654
|
+
<span className="table-count">
|
|
655
|
+
{rowCount.toLocaleString()} rows
|
|
656
|
+
</span>
|
|
657
|
+
{filters && filters.length > 0 && (
|
|
658
|
+
<div className="table-filters">
|
|
659
|
+
{filters.map((filter, idx) => (
|
|
660
|
+
<span key={idx} className="filter-chip small">
|
|
661
|
+
{filter}
|
|
662
|
+
</span>
|
|
663
|
+
))}
|
|
664
|
+
</div>
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
208
667
|
</div>
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
{
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
668
|
+
|
|
669
|
+
{/* Content */}
|
|
670
|
+
{activeTab === 'table' ? (
|
|
671
|
+
<div className="results-table-wrapper">
|
|
672
|
+
{rowCount === 0 ? (
|
|
673
|
+
<div className="table-empty">
|
|
674
|
+
<p>No results returned</p>
|
|
675
|
+
</div>
|
|
676
|
+
) : (
|
|
677
|
+
<table className="results-table">
|
|
678
|
+
<thead>
|
|
679
|
+
<tr>
|
|
680
|
+
{columns.map((col, idx) => (
|
|
681
|
+
<th
|
|
682
|
+
key={idx}
|
|
683
|
+
title={col.semantic_name || col.name}
|
|
684
|
+
onClick={() => handleSort(idx)}
|
|
685
|
+
className={sortColumn === idx ? 'sorted' : ''}
|
|
686
|
+
>
|
|
687
|
+
<span className="col-header-content">
|
|
688
|
+
{col.name}
|
|
689
|
+
<span className="sort-arrows">
|
|
690
|
+
<span
|
|
691
|
+
className={`sort-arrow up ${
|
|
692
|
+
sortColumn === idx &&
|
|
693
|
+
sortDirection === 'asc'
|
|
694
|
+
? 'active'
|
|
695
|
+
: ''
|
|
696
|
+
}`}
|
|
697
|
+
>
|
|
698
|
+
▲
|
|
699
|
+
</span>
|
|
700
|
+
<span
|
|
701
|
+
className={`sort-arrow down ${
|
|
702
|
+
sortColumn === idx &&
|
|
703
|
+
sortDirection === 'desc'
|
|
704
|
+
? 'active'
|
|
705
|
+
: ''
|
|
706
|
+
}`}
|
|
707
|
+
>
|
|
708
|
+
▼
|
|
709
|
+
</span>
|
|
247
710
|
</span>
|
|
248
711
|
</span>
|
|
249
|
-
|
|
250
|
-
|
|
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>
|
|
712
|
+
<span className="col-type">{col.type}</span>
|
|
713
|
+
</th>
|
|
266
714
|
))}
|
|
267
715
|
</tr>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
716
|
+
</thead>
|
|
717
|
+
<tbody>
|
|
718
|
+
{sortedRows.map((row, rowIdx) => (
|
|
719
|
+
<tr key={rowIdx}>
|
|
720
|
+
{row.map((cell, cellIdx) => (
|
|
721
|
+
<td key={cellIdx}>
|
|
722
|
+
{cell === null ? (
|
|
723
|
+
<span className="null-value">NULL</span>
|
|
724
|
+
) : (
|
|
725
|
+
String(cell)
|
|
726
|
+
)}
|
|
727
|
+
</td>
|
|
728
|
+
))}
|
|
729
|
+
</tr>
|
|
730
|
+
))}
|
|
731
|
+
</tbody>
|
|
732
|
+
</table>
|
|
733
|
+
)}
|
|
734
|
+
</div>
|
|
735
|
+
) : (
|
|
736
|
+
<div className="results-chart-wrapper">
|
|
737
|
+
{canChart ? (
|
|
738
|
+
<ChartView
|
|
739
|
+
chartConfig={chartConfig}
|
|
740
|
+
chartData={chartData}
|
|
741
|
+
pivotedByMetric={pivotedByMetric}
|
|
742
|
+
groupValues={groupValues}
|
|
743
|
+
rows={rows}
|
|
744
|
+
columns={columns}
|
|
745
|
+
/>
|
|
746
|
+
) : (
|
|
747
|
+
<div className="chart-no-data">
|
|
748
|
+
No chartable data detected
|
|
749
|
+
</div>
|
|
750
|
+
)}
|
|
751
|
+
</div>
|
|
752
|
+
)}
|
|
273
753
|
</div>
|
|
274
754
|
)}
|
|
275
755
|
</div>
|