datajunction-ui 0.0.44 → 0.0.46

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.
@@ -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;
@@ -17,6 +17,11 @@ export function SelectionPanel({
17
17
  onLoadCubePreset,
18
18
  loadedCubeName = null, // Managed by parent for URL persistence
19
19
  onClearSelection,
20
+ filters = [],
21
+ onFiltersChange,
22
+ onRunQuery,
23
+ canRunQuery = false,
24
+ queryLoading = false,
20
25
  }) {
21
26
  const [metricsSearch, setMetricsSearch] = useState('');
22
27
  const [dimensionsSearch, setDimensionsSearch] = useState('');
@@ -25,8 +30,11 @@ export function SelectionPanel({
25
30
  const [cubeSearch, setCubeSearch] = useState('');
26
31
  const [metricsChipsExpanded, setMetricsChipsExpanded] = useState(false);
27
32
  const [dimensionsChipsExpanded, setDimensionsChipsExpanded] = useState(false);
33
+ const [filterInput, setFilterInput] = useState('');
28
34
  const prevSearchRef = useRef('');
29
35
  const cubeDropdownRef = useRef(null);
36
+ const metricsSearchRef = useRef(null);
37
+ const dimensionsSearchRef = useRef(null);
30
38
 
31
39
  // Threshold for showing expand/collapse button
32
40
  const CHIPS_COLLAPSE_THRESHOLD = 8;
@@ -261,6 +269,27 @@ export function SelectionPanel({
261
269
  }
262
270
  };
263
271
 
272
+ const handleAddFilter = () => {
273
+ const trimmed = filterInput.trim();
274
+ if (trimmed && !filters.includes(trimmed) && onFiltersChange) {
275
+ onFiltersChange([...filters, trimmed]);
276
+ setFilterInput('');
277
+ }
278
+ };
279
+
280
+ const handleFilterKeyDown = e => {
281
+ if (e.key === 'Enter') {
282
+ e.preventDefault();
283
+ handleAddFilter();
284
+ }
285
+ };
286
+
287
+ const handleRemoveFilter = filterToRemove => {
288
+ if (onFiltersChange) {
289
+ onFiltersChange(filters.filter(f => f !== filterToRemove));
290
+ }
291
+ };
292
+
264
293
  return (
265
294
  <div className="selection-panel">
266
295
  {/* Cube Preset Dropdown */}
@@ -286,7 +315,7 @@ export function SelectionPanel({
286
315
  </button>
287
316
  {(selectedMetrics.length > 0 || selectedDimensions.length > 0) && (
288
317
  <button className="clear-all-btn" onClick={clearSelection}>
289
- Clear all
318
+ Clear
290
319
  </button>
291
320
  )}
292
321
  </div>
@@ -312,7 +341,9 @@ export function SelectionPanel({
312
341
  filteredCubes.map(cube => (
313
342
  <button
314
343
  key={cube.name}
315
- className="cube-option"
344
+ className={`cube-option ${
345
+ loadedCubeName === cube.name ? 'selected' : ''
346
+ }`}
316
347
  onClick={() => handleCubeSelect(cube)}
317
348
  >
318
349
  <span className="cube-name">
@@ -320,6 +351,9 @@ export function SelectionPanel({
320
351
  (cube.name ? cube.name.split('.').pop() : 'Unknown')}
321
352
  </span>
322
353
  <span className="cube-info">{cube.name}</span>
354
+ {loadedCubeName === cube.name && (
355
+ <span className="cube-selected-icon">✓</span>
356
+ )}
323
357
  </button>
324
358
  ))
325
359
  )}
@@ -338,62 +372,71 @@ export function SelectionPanel({
338
372
  </span>
339
373
  </div>
340
374
 
341
- {/* Selected Metrics Chips */}
342
- {selectedMetrics.length > 0 && (
343
- <div className="selected-chips-container">
375
+ {/* Combined Chips + Search Input */}
376
+ <div
377
+ className="combobox-input"
378
+ onClick={() => metricsSearchRef.current?.focus()}
379
+ >
380
+ {selectedMetrics.length > 0 && (
344
381
  <div
345
- className={`selected-chips-wrapper ${
346
- metricsChipsExpanded ? 'expanded' : ''
382
+ className={`combobox-chips ${
383
+ selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD
384
+ ? metricsChipsExpanded
385
+ ? 'expanded'
386
+ : 'collapsed'
387
+ : ''
347
388
  }`}
348
389
  >
349
- <div className="selected-chips">
350
- {selectedMetrics.map(metric => (
351
- <span key={metric} className="selected-chip metric-chip">
352
- <span className="chip-label">{getShortName(metric)}</span>
353
- <button
354
- className="chip-remove"
355
- onClick={() => removeMetric(metric)}
356
- title={`Remove ${getShortName(metric)}`}
357
- >
358
- ×
359
- </button>
360
- </span>
361
- ))}
362
- </div>
390
+ {selectedMetrics.map(metric => (
391
+ <span key={metric} className="selected-chip metric-chip">
392
+ {getShortName(metric)}
393
+ <button
394
+ className="chip-remove"
395
+ onClick={e => {
396
+ e.stopPropagation();
397
+ removeMetric(metric);
398
+ }}
399
+ title={`Remove ${getShortName(metric)}`}
400
+ >
401
+ ×
402
+ </button>
403
+ </span>
404
+ ))}
363
405
  </div>
406
+ )}
407
+ <div className="combobox-input-row">
408
+ <input
409
+ ref={metricsSearchRef}
410
+ type="text"
411
+ className="combobox-search"
412
+ placeholder="Search metrics..."
413
+ value={metricsSearch}
414
+ onChange={e => setMetricsSearch(e.target.value)}
415
+ onClick={e => e.stopPropagation()}
416
+ />
364
417
  {selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD && (
365
418
  <button
366
- className="chips-toggle"
367
- onClick={() => setMetricsChipsExpanded(!metricsChipsExpanded)}
419
+ className="combobox-action"
420
+ onClick={e => {
421
+ e.stopPropagation();
422
+ setMetricsChipsExpanded(!metricsChipsExpanded);
423
+ }}
368
424
  >
369
- <span>
370
- {metricsChipsExpanded
371
- ? 'Show less'
372
- : `Show all ${selectedMetrics.length}`}
373
- </span>
374
- <span className="chips-toggle-icon">
375
- {metricsChipsExpanded ? '▲' : '▼'}
376
- </span>
425
+ {metricsChipsExpanded ? 'Show less' : 'Show all'}
426
+ </button>
427
+ )}
428
+ {selectedMetrics.length > 0 && (
429
+ <button
430
+ className="combobox-action"
431
+ onClick={e => {
432
+ e.stopPropagation();
433
+ onMetricsChange([]);
434
+ }}
435
+ >
436
+ Clear
377
437
  </button>
378
438
  )}
379
439
  </div>
380
- )}
381
-
382
- <div className="search-box">
383
- <input
384
- type="text"
385
- placeholder="Search metrics..."
386
- value={metricsSearch}
387
- onChange={e => setMetricsSearch(e.target.value)}
388
- />
389
- {metricsSearch && (
390
- <button
391
- className="clear-search"
392
- onClick={() => setMetricsSearch('')}
393
- >
394
- ×
395
- </button>
396
- )}
397
440
  </div>
398
441
 
399
442
  <div className="selection-list">
@@ -489,69 +532,74 @@ export function SelectionPanel({
489
532
  <div className="empty-list">Loading dimensions...</div>
490
533
  ) : (
491
534
  <>
492
- {/* Selected Dimensions Chips */}
493
- {selectedDimensions.length > 0 && (
494
- <div className="selected-chips-container">
535
+ {/* Combined Chips + Search Input */}
536
+ <div
537
+ className="combobox-input"
538
+ onClick={() => dimensionsSearchRef.current?.focus()}
539
+ >
540
+ {selectedDimensions.length > 0 && (
495
541
  <div
496
- className={`selected-chips-wrapper ${
497
- dimensionsChipsExpanded ? 'expanded' : ''
542
+ className={`combobox-chips ${
543
+ selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD
544
+ ? dimensionsChipsExpanded
545
+ ? 'expanded'
546
+ : 'collapsed'
547
+ : ''
498
548
  }`}
499
549
  >
500
- <div className="selected-chips">
501
- {selectedDimensions.map(dimName => (
502
- <span
503
- key={dimName}
504
- className="selected-chip dimension-chip"
550
+ {selectedDimensions.map(dimName => (
551
+ <span
552
+ key={dimName}
553
+ className="selected-chip dimension-chip"
554
+ >
555
+ {getDimDisplayName(dimName)}
556
+ <button
557
+ className="chip-remove"
558
+ onClick={e => {
559
+ e.stopPropagation();
560
+ removeDimension(dimName);
561
+ }}
562
+ title={`Remove ${getDimDisplayName(dimName)}`}
505
563
  >
506
- <span className="chip-label">
507
- {getDimDisplayName(dimName)}
508
- </span>
509
- <button
510
- className="chip-remove"
511
- onClick={() => removeDimension(dimName)}
512
- title={`Remove ${getDimDisplayName(dimName)}`}
513
- >
514
- ×
515
- </button>
516
- </span>
517
- ))}
518
- </div>
564
+ ×
565
+ </button>
566
+ </span>
567
+ ))}
519
568
  </div>
569
+ )}
570
+ <div className="combobox-input-row">
571
+ <input
572
+ ref={dimensionsSearchRef}
573
+ type="text"
574
+ className="combobox-search"
575
+ placeholder="Search dimensions..."
576
+ value={dimensionsSearch}
577
+ onChange={e => setDimensionsSearch(e.target.value)}
578
+ onClick={e => e.stopPropagation()}
579
+ />
520
580
  {selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD && (
521
581
  <button
522
- className="chips-toggle"
523
- onClick={() =>
524
- setDimensionsChipsExpanded(!dimensionsChipsExpanded)
525
- }
582
+ className="combobox-action"
583
+ onClick={e => {
584
+ e.stopPropagation();
585
+ setDimensionsChipsExpanded(!dimensionsChipsExpanded);
586
+ }}
526
587
  >
527
- <span>
528
- {dimensionsChipsExpanded
529
- ? 'Show less'
530
- : `Show all ${selectedDimensions.length}`}
531
- </span>
532
- <span className="chips-toggle-icon">
533
- {dimensionsChipsExpanded ? '▲' : '▼'}
534
- </span>
588
+ {dimensionsChipsExpanded ? 'Show less' : 'Show all'}
589
+ </button>
590
+ )}
591
+ {selectedDimensions.length > 0 && (
592
+ <button
593
+ className="combobox-action"
594
+ onClick={e => {
595
+ e.stopPropagation();
596
+ onDimensionsChange([]);
597
+ }}
598
+ >
599
+ Clear
535
600
  </button>
536
601
  )}
537
602
  </div>
538
- )}
539
-
540
- <div className="search-box">
541
- <input
542
- type="text"
543
- placeholder="Search dimensions..."
544
- value={dimensionsSearch}
545
- onChange={e => setDimensionsSearch(e.target.value)}
546
- />
547
- {dimensionsSearch && (
548
- <button
549
- className="clear-search"
550
- onClick={() => setDimensionsSearch('')}
551
- >
552
- ×
553
- </button>
554
- )}
555
603
  </div>
556
604
 
557
605
  <div className="selection-list dimensions-list">
@@ -586,6 +634,83 @@ export function SelectionPanel({
586
634
  </>
587
635
  )}
588
636
  </div>
637
+
638
+ {/* Divider */}
639
+ <div className="section-divider" />
640
+
641
+ {/* Filters Section */}
642
+ <div className="selection-section filters-section">
643
+ <div className="section-header">
644
+ <h3>Filters</h3>
645
+ <span className="selection-count">{filters.length} applied</span>
646
+ </div>
647
+
648
+ {/* Filter chips */}
649
+ {filters.length > 0 && (
650
+ <div className="filter-chips-container">
651
+ {filters.map((filter, idx) => (
652
+ <span key={idx} className="filter-chip">
653
+ <span className="filter-chip-text">{filter}</span>
654
+ <button
655
+ className="filter-chip-remove"
656
+ onClick={() => handleRemoveFilter(filter)}
657
+ title="Remove filter"
658
+ >
659
+ ×
660
+ </button>
661
+ </span>
662
+ ))}
663
+ </div>
664
+ )}
665
+
666
+ {/* Filter input */}
667
+ <div className="filter-input-container">
668
+ <input
669
+ type="text"
670
+ className="filter-input"
671
+ placeholder="e.g. v3.date.date_id >= '2024-01-01'"
672
+ value={filterInput}
673
+ onChange={e => setFilterInput(e.target.value)}
674
+ onKeyDown={handleFilterKeyDown}
675
+ />
676
+ <button
677
+ className="filter-add-btn"
678
+ onClick={handleAddFilter}
679
+ disabled={!filterInput.trim()}
680
+ >
681
+ Add
682
+ </button>
683
+ </div>
684
+ </div>
685
+
686
+ {/* Run Query Section */}
687
+ <div className="run-query-section">
688
+ <button
689
+ className="run-query-btn"
690
+ onClick={onRunQuery}
691
+ disabled={!canRunQuery || queryLoading}
692
+ >
693
+ {queryLoading ? (
694
+ <>
695
+ <span className="spinner small" />
696
+ Running...
697
+ </>
698
+ ) : (
699
+ <>
700
+ <span className="run-icon">▶</span>
701
+ Run Query
702
+ </>
703
+ )}
704
+ </button>
705
+ {!canRunQuery && selectedMetrics.length > 0 && (
706
+ <span className="run-hint">Select at least one dimension</span>
707
+ )}
708
+ {!canRunQuery && selectedMetrics.length === 0 && (
709
+ <span className="run-hint">
710
+ Select metrics and dimensions to run a query
711
+ </span>
712
+ )}
713
+ </div>
589
714
  </div>
590
715
  );
591
716
  }