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.
@@ -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 1/3, results in bottom 2/3
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
- // Parse results data - handle new v3 format
39
- const columns = results?.results?.[0]?.columns || [];
40
- const rows = results?.results?.[0]?.rows || [];
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 1/3) + Results (bottom 2/3) */}
522
+ {/* Two-pane layout: SQL (top) + Results (bottom) */}
104
523
  <div className="results-panes">
105
- {/* SQL Pane - always visible, top 1/3 */}
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 - bottom 2/3 */}
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
- <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
- )}
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
- <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
-
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
- </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>
712
+ <span className="col-type">{col.type}</span>
713
+ </th>
266
714
  ))}
267
715
  </tr>
268
- ))}
269
- </tbody>
270
- </table>
271
- )}
272
- </div>
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>