datajunction-ui 0.0.26 → 0.0.27

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.
Files changed (28) hide show
  1. package/package.json +2 -2
  2. package/src/app/components/Search.jsx +41 -33
  3. package/src/app/components/__tests__/Search.test.jsx +46 -11
  4. package/src/app/index.tsx +3 -3
  5. package/src/app/pages/AddEditNodePage/MetricQueryField.jsx +57 -8
  6. package/src/app/pages/AddEditNodePage/UpstreamNodeField.jsx +17 -5
  7. package/src/app/pages/AddEditNodePage/__tests__/index.test.jsx +97 -1
  8. package/src/app/pages/AddEditNodePage/index.jsx +61 -17
  9. package/src/app/pages/NodePage/WatchNodeButton.jsx +12 -5
  10. package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +93 -15
  11. package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +2320 -65
  12. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +234 -25
  13. package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +315 -122
  14. package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +2672 -314
  15. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +567 -0
  16. package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +480 -55
  17. package/src/app/pages/QueryPlannerPage/index.jsx +1021 -14
  18. package/src/app/pages/QueryPlannerPage/styles.css +1990 -62
  19. package/src/app/pages/Root/__tests__/index.test.jsx +79 -8
  20. package/src/app/pages/Root/index.tsx +1 -6
  21. package/src/app/pages/SQLBuilderPage/__tests__/index.test.jsx +82 -0
  22. package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +37 -0
  23. package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +48 -0
  24. package/src/app/pages/SettingsPage/__tests__/index.test.jsx +169 -1
  25. package/src/app/services/DJService.js +492 -3
  26. package/src/app/services/__tests__/DJService.test.jsx +582 -0
  27. package/src/mocks/mockNodes.jsx +36 -0
  28. package/webpack.config.js +27 -0
@@ -2,6 +2,8 @@ import { useState, useMemo, useEffect, useRef } from 'react';
2
2
 
3
3
  /**
4
4
  * SelectionPanel - Browse and select metrics and dimensions
5
+ * Features selected items as chips at the top for visibility
6
+ * Includes cube preset loading for quick configuration
5
7
  */
6
8
  export function SelectionPanel({
7
9
  metrics,
@@ -11,11 +13,59 @@ export function SelectionPanel({
11
13
  selectedDimensions,
12
14
  onDimensionsChange,
13
15
  loading,
16
+ cubes = [],
17
+ onLoadCubePreset,
18
+ loadedCubeName = null, // Managed by parent for URL persistence
19
+ onClearSelection,
14
20
  }) {
15
21
  const [metricsSearch, setMetricsSearch] = useState('');
16
22
  const [dimensionsSearch, setDimensionsSearch] = useState('');
17
23
  const [expandedNamespaces, setExpandedNamespaces] = useState(new Set());
24
+ const [showCubeDropdown, setShowCubeDropdown] = useState(false);
25
+ const [cubeSearch, setCubeSearch] = useState('');
26
+ const [metricsChipsExpanded, setMetricsChipsExpanded] = useState(false);
27
+ const [dimensionsChipsExpanded, setDimensionsChipsExpanded] = useState(false);
18
28
  const prevSearchRef = useRef('');
29
+ const cubeDropdownRef = useRef(null);
30
+
31
+ // Threshold for showing expand/collapse button
32
+ const CHIPS_COLLAPSE_THRESHOLD = 8;
33
+
34
+ // Find the loaded cube object from the name
35
+ const loadedCube = useMemo(() => {
36
+ if (!loadedCubeName) return null;
37
+ return (
38
+ cubes.find(c => c.name === loadedCubeName) || { name: loadedCubeName }
39
+ );
40
+ }, [loadedCubeName, cubes]);
41
+
42
+ // Close cube dropdown when clicking outside
43
+ useEffect(() => {
44
+ const handleClickOutside = event => {
45
+ if (
46
+ cubeDropdownRef.current &&
47
+ !cubeDropdownRef.current.contains(event.target)
48
+ ) {
49
+ setShowCubeDropdown(false);
50
+ }
51
+ };
52
+ document.addEventListener('mousedown', handleClickOutside);
53
+ return () => document.removeEventListener('mousedown', handleClickOutside);
54
+ }, []);
55
+
56
+ // Filter cubes by search (GraphQL returns display_name)
57
+ const filteredCubes = useMemo(() => {
58
+ const search = cubeSearch.toLowerCase().trim();
59
+ if (!search) return cubes;
60
+ return cubes.filter(cube => {
61
+ const name = cube.name || '';
62
+ const displayName = cube.display_name || '';
63
+ return (
64
+ name.toLowerCase().includes(search) ||
65
+ displayName.toLowerCase().includes(search)
66
+ );
67
+ });
68
+ }, [cubes, cubeSearch]);
19
69
 
20
70
  // Get short name from full metric name
21
71
  const getShortName = fullName => {
@@ -43,60 +93,45 @@ export function SelectionPanel({
43
93
  }, [metrics]);
44
94
 
45
95
  // Filter and sort namespaces/metrics by search relevance
46
- // Namespaces matching the search term appear first, then sorted by metric matches
47
96
  const { filteredGroups, sortedNamespaces } = useMemo(() => {
48
97
  const search = metricsSearch.trim().toLowerCase();
49
98
 
50
99
  if (!search) {
51
- // No search - return original groups, sorted alphabetically
52
100
  const namespaces = Object.keys(groupedMetrics).sort();
53
101
  return { filteredGroups: groupedMetrics, sortedNamespaces: namespaces };
54
102
  }
55
103
 
56
- // Filter to groups that have matching metrics
57
104
  const filtered = {};
58
105
  Object.entries(groupedMetrics).forEach(([namespace, items]) => {
59
106
  const matchingItems = items.filter(m => m.toLowerCase().includes(search));
60
107
  if (matchingItems.length > 0) {
61
- // Sort metrics within namespace: prefix matches first
62
108
  matchingItems.sort((a, b) => {
63
109
  const aShort = getShortName(a).toLowerCase();
64
110
  const bShort = getShortName(b).toLowerCase();
65
-
66
111
  const aPrefix = aShort.startsWith(search);
67
112
  const bPrefix = bShort.startsWith(search);
68
113
  if (aPrefix && !bPrefix) return -1;
69
114
  if (!aPrefix && bPrefix) return 1;
70
-
71
115
  return aShort.localeCompare(bShort);
72
116
  });
73
117
  filtered[namespace] = matchingItems;
74
118
  }
75
119
  });
76
120
 
77
- // Sort namespaces by relevance
78
121
  const namespaces = Object.keys(filtered).sort((a, b) => {
79
122
  const aLower = a.toLowerCase();
80
123
  const bLower = b.toLowerCase();
81
-
82
- // Priority 1: Namespace starts with search term
83
124
  const aPrefix = aLower.startsWith(search);
84
125
  const bPrefix = bLower.startsWith(search);
85
126
  if (aPrefix && !bPrefix) return -1;
86
127
  if (!aPrefix && bPrefix) return 1;
87
-
88
- // Priority 2: Namespace contains search term
89
128
  const aContains = aLower.includes(search);
90
129
  const bContains = bLower.includes(search);
91
130
  if (aContains && !bContains) return -1;
92
131
  if (!aContains && bContains) return 1;
93
-
94
- // Priority 3: Has more matching metrics
95
132
  const aCount = filtered[a].length;
96
133
  const bCount = filtered[b].length;
97
134
  if (aCount !== bCount) return bCount - aCount;
98
-
99
- // Priority 4: Alphabetical
100
135
  return aLower.localeCompare(bLower);
101
136
  });
102
137
 
@@ -107,16 +142,13 @@ export function SelectionPanel({
107
142
  useEffect(() => {
108
143
  const currentSearch = metricsSearch.trim();
109
144
  const prevSearch = prevSearchRef.current;
110
-
111
- // Only auto-expand when starting a new search or search term changes
112
145
  if (currentSearch && currentSearch !== prevSearch) {
113
146
  setExpandedNamespaces(new Set(sortedNamespaces));
114
147
  }
115
-
116
148
  prevSearchRef.current = currentSearch;
117
149
  }, [metricsSearch, sortedNamespaces]);
118
150
 
119
- // Dedupe dimensions by name, keeping shortest path for each
151
+ // Dedupe dimensions by name
120
152
  const dedupedDimensions = useMemo(() => {
121
153
  const byName = new Map();
122
154
  dimensions.forEach(d => {
@@ -132,12 +164,11 @@ export function SelectionPanel({
132
164
  return Array.from(byName.values());
133
165
  }, [dimensions]);
134
166
 
135
- // Filter and sort dimensions by search (prefix matches first)
167
+ // Filter and sort dimensions by search
136
168
  const filteredDimensions = useMemo(() => {
137
169
  const search = dimensionsSearch.trim().toLowerCase();
138
170
  if (!search) return dedupedDimensions;
139
171
 
140
- // Search in both full name and short display name
141
172
  const matches = dedupedDimensions.filter(d => {
142
173
  if (!d.name) return false;
143
174
  const fullName = d.name.toLowerCase();
@@ -146,25 +177,22 @@ export function SelectionPanel({
146
177
  return fullName.includes(search) || shortDisplay.includes(search);
147
178
  });
148
179
 
149
- // Sort: prefix matches on short name first
150
180
  matches.sort((a, b) => {
151
181
  const aParts = (a.name || '').split('.');
152
182
  const bParts = (b.name || '').split('.');
153
183
  const aShort = aParts.slice(-2).join('.').toLowerCase();
154
184
  const bShort = bParts.slice(-2).join('.').toLowerCase();
155
-
156
185
  const aPrefix = aShort.startsWith(search);
157
186
  const bPrefix = bShort.startsWith(search);
158
187
  if (aPrefix && !bPrefix) return -1;
159
188
  if (!aPrefix && bPrefix) return 1;
160
-
161
189
  return aShort.localeCompare(bShort);
162
190
  });
163
191
 
164
192
  return matches;
165
193
  }, [dedupedDimensions, dimensionsSearch]);
166
194
 
167
- // Get display name for dimension (last 2 segments: dim_node.column)
195
+ // Get display name for dimension (last 2 segments)
168
196
  const getDimDisplayName = fullName => {
169
197
  const parts = (fullName || '').split('.');
170
198
  return parts.slice(-2).join('.');
@@ -190,6 +218,10 @@ export function SelectionPanel({
190
218
  }
191
219
  };
192
220
 
221
+ const removeMetric = metric => {
222
+ onMetricsChange(selectedMetrics.filter(m => m !== metric));
223
+ };
224
+
193
225
  const toggleDimension = dimName => {
194
226
  if (selectedDimensions.includes(dimName)) {
195
227
  onDimensionsChange(selectedDimensions.filter(d => d !== dimName));
@@ -198,6 +230,10 @@ export function SelectionPanel({
198
230
  }
199
231
  };
200
232
 
233
+ const removeDimension = dimName => {
234
+ onDimensionsChange(selectedDimensions.filter(d => d !== dimName));
235
+ };
236
+
201
237
  const selectAllInNamespace = (namespace, items) => {
202
238
  const newSelection = [...new Set([...selectedMetrics, ...items])];
203
239
  onMetricsChange(newSelection);
@@ -207,8 +243,92 @@ export function SelectionPanel({
207
243
  onMetricsChange(selectedMetrics.filter(m => !items.includes(m)));
208
244
  };
209
245
 
246
+ const handleCubeSelect = cube => {
247
+ if (onLoadCubePreset) {
248
+ onLoadCubePreset(cube.name);
249
+ }
250
+ // loadedCubeName is now managed by parent via onLoadCubePreset
251
+ setShowCubeDropdown(false);
252
+ setCubeSearch('');
253
+ };
254
+
255
+ const clearSelection = () => {
256
+ if (onClearSelection) {
257
+ onClearSelection(); // Parent handles clearing metrics, dimensions, and cube
258
+ } else {
259
+ onMetricsChange([]);
260
+ onDimensionsChange([]);
261
+ }
262
+ };
263
+
210
264
  return (
211
265
  <div className="selection-panel">
266
+ {/* Cube Preset Dropdown */}
267
+ {cubes.length > 0 && (
268
+ <div className="cube-preset-section" ref={cubeDropdownRef}>
269
+ <div className="preset-row">
270
+ <button
271
+ className={`preset-button ${loadedCube ? 'has-preset' : ''}`}
272
+ onClick={() => setShowCubeDropdown(!showCubeDropdown)}
273
+ >
274
+ <span className="preset-icon">{loadedCube ? '📦' : '📂'}</span>
275
+ <span className="preset-label">
276
+ {loadedCube
277
+ ? loadedCube.display_name ||
278
+ (loadedCube.name
279
+ ? loadedCube.name.split('.').pop()
280
+ : 'Cube')
281
+ : 'Load from Cube'}
282
+ </span>
283
+ <span className="dropdown-arrow">
284
+ {showCubeDropdown ? '▲' : '▼'}
285
+ </span>
286
+ </button>
287
+ {(selectedMetrics.length > 0 || selectedDimensions.length > 0) && (
288
+ <button className="clear-all-btn" onClick={clearSelection}>
289
+ Clear all
290
+ </button>
291
+ )}
292
+ </div>
293
+
294
+ {showCubeDropdown && (
295
+ <div className="cube-dropdown">
296
+ <input
297
+ type="text"
298
+ className="cube-search"
299
+ placeholder="Search cubes..."
300
+ value={cubeSearch}
301
+ onChange={e => setCubeSearch(e.target.value)}
302
+ autoFocus
303
+ />
304
+ <div className="cube-list">
305
+ {filteredCubes.length === 0 ? (
306
+ <div className="cube-empty">
307
+ {cubeSearch
308
+ ? 'No cubes match your search'
309
+ : 'No cubes available'}
310
+ </div>
311
+ ) : (
312
+ filteredCubes.map(cube => (
313
+ <button
314
+ key={cube.name}
315
+ className="cube-option"
316
+ onClick={() => handleCubeSelect(cube)}
317
+ >
318
+ <span className="cube-name">
319
+ {cube.display_name ||
320
+ (cube.name ? cube.name.split('.').pop() : 'Unknown')}
321
+ </span>
322
+ <span className="cube-info">{cube.name}</span>
323
+ </button>
324
+ ))
325
+ )}
326
+ </div>
327
+ </div>
328
+ )}
329
+ </div>
330
+ )}
331
+
212
332
  {/* Metrics Section */}
213
333
  <div className="selection-section">
214
334
  <div className="section-header">
@@ -218,6 +338,47 @@ export function SelectionPanel({
218
338
  </span>
219
339
  </div>
220
340
 
341
+ {/* Selected Metrics Chips */}
342
+ {selectedMetrics.length > 0 && (
343
+ <div className="selected-chips-container">
344
+ <div
345
+ className={`selected-chips-wrapper ${
346
+ metricsChipsExpanded ? 'expanded' : ''
347
+ }`}
348
+ >
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>
363
+ </div>
364
+ {selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD && (
365
+ <button
366
+ className="chips-toggle"
367
+ onClick={() => setMetricsChipsExpanded(!metricsChipsExpanded)}
368
+ >
369
+ <span>
370
+ {metricsChipsExpanded
371
+ ? 'Show less'
372
+ : `Show all ${selectedMetrics.length}`}
373
+ </span>
374
+ <span className="chips-toggle-icon">
375
+ {metricsChipsExpanded ? '▲' : '▼'}
376
+ </span>
377
+ </button>
378
+ )}
379
+ </div>
380
+ )}
381
+
221
382
  <div className="search-box">
222
383
  <input
223
384
  type="text"
@@ -328,6 +489,54 @@ export function SelectionPanel({
328
489
  <div className="empty-list">Loading dimensions...</div>
329
490
  ) : (
330
491
  <>
492
+ {/* Selected Dimensions Chips */}
493
+ {selectedDimensions.length > 0 && (
494
+ <div className="selected-chips-container">
495
+ <div
496
+ className={`selected-chips-wrapper ${
497
+ dimensionsChipsExpanded ? 'expanded' : ''
498
+ }`}
499
+ >
500
+ <div className="selected-chips">
501
+ {selectedDimensions.map(dimName => (
502
+ <span
503
+ key={dimName}
504
+ className="selected-chip dimension-chip"
505
+ >
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>
519
+ </div>
520
+ {selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD && (
521
+ <button
522
+ className="chips-toggle"
523
+ onClick={() =>
524
+ setDimensionsChipsExpanded(!dimensionsChipsExpanded)
525
+ }
526
+ >
527
+ <span>
528
+ {dimensionsChipsExpanded
529
+ ? 'Show less'
530
+ : `Show all ${selectedDimensions.length}`}
531
+ </span>
532
+ <span className="chips-toggle-icon">
533
+ {dimensionsChipsExpanded ? '▲' : '▼'}
534
+ </span>
535
+ </button>
536
+ )}
537
+ </div>
538
+ )}
539
+
331
540
  <div className="search-box">
332
541
  <input
333
542
  type="text"