datajunction-ui 0.0.26-alpha.0 → 0.0.27-alpha.0
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 +2 -2
- package/src/app/components/Search.jsx +41 -33
- package/src/app/components/__tests__/Search.test.jsx +46 -11
- package/src/app/index.tsx +1 -1
- package/src/app/pages/AddEditNodePage/MetricQueryField.jsx +57 -8
- package/src/app/pages/AddEditNodePage/UpstreamNodeField.jsx +17 -5
- package/src/app/pages/AddEditNodePage/__tests__/index.test.jsx +97 -1
- package/src/app/pages/AddEditNodePage/index.jsx +61 -17
- package/src/app/pages/NodePage/WatchNodeButton.jsx +12 -5
- package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +93 -15
- package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +2320 -65
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +234 -25
- package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +315 -122
- package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +2672 -314
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +567 -0
- package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +480 -55
- package/src/app/pages/QueryPlannerPage/index.jsx +1021 -14
- package/src/app/pages/QueryPlannerPage/styles.css +1990 -62
- package/src/app/pages/Root/__tests__/index.test.jsx +79 -8
- package/src/app/pages/Root/index.tsx +1 -1
- package/src/app/pages/SQLBuilderPage/__tests__/index.test.jsx +82 -0
- package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +37 -0
- package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +48 -0
- package/src/app/pages/SettingsPage/__tests__/index.test.jsx +169 -1
- package/src/app/services/DJService.js +492 -3
- package/src/app/services/__tests__/DJService.test.jsx +582 -0
- package/src/mocks/mockNodes.jsx +36 -0
- 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
|
|
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
|
|
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
|
|
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"
|