datajunction-ui 0.0.98 → 0.0.100
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/pages/QueryPlannerPage/ResultsView.jsx +55 -5
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +676 -356
- package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +151 -0
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +9 -9
- package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +101 -3
- package/src/app/pages/QueryPlannerPage/index.jsx +118 -11
- package/src/app/pages/QueryPlannerPage/styles.css +232 -14
- package/src/app/pages/Root/__tests__/index.test.jsx +1 -1
- package/src/app/pages/Root/index.tsx +1 -1
- package/src/app/services/DJService.js +27 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
1
|
+
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
|
2
2
|
|
|
3
3
|
const ENGINE_OPTIONS = [
|
|
4
4
|
{ value: null, label: 'Auto' },
|
|
@@ -30,19 +30,32 @@ export function SelectionPanel({
|
|
|
30
30
|
onRunQuery,
|
|
31
31
|
canRunQuery = false,
|
|
32
32
|
queryLoading = false,
|
|
33
|
+
compatibleMetrics = null, // Set<string> of compatible metric names, or null if no filter
|
|
33
34
|
}) {
|
|
34
35
|
const [metricsSearch, setMetricsSearch] = useState('');
|
|
35
36
|
const [dimensionsSearch, setDimensionsSearch] = useState('');
|
|
36
37
|
const [expandedNamespaces, setExpandedNamespaces] = useState(new Set());
|
|
38
|
+
const [expandedDimGroups, setExpandedDimGroups] = useState(new Set());
|
|
39
|
+
const [expandedRolePaths, setExpandedRolePaths] = useState(new Set());
|
|
37
40
|
const [showCubeDropdown, setShowCubeDropdown] = useState(false);
|
|
38
41
|
const [cubeSearch, setCubeSearch] = useState('');
|
|
39
42
|
const [metricsChipsExpanded, setMetricsChipsExpanded] = useState(false);
|
|
40
43
|
const [dimensionsChipsExpanded, setDimensionsChipsExpanded] = useState(false);
|
|
41
44
|
const [filterInput, setFilterInput] = useState('');
|
|
45
|
+
const [split1, setSplit1] = useState(35); // metrics / dims boundary (%)
|
|
46
|
+
const [split2, setSplit2] = useState(65); // dims / filters boundary (%)
|
|
47
|
+
const [split3, setSplit3] = useState(85); // filters / engine+run boundary (%)
|
|
42
48
|
const prevSearchRef = useRef('');
|
|
43
49
|
const cubeDropdownRef = useRef(null);
|
|
44
50
|
const metricsSearchRef = useRef(null);
|
|
45
51
|
const dimensionsSearchRef = useRef(null);
|
|
52
|
+
const filterInputRef = useRef(null);
|
|
53
|
+
const sectionsRef = useRef(null);
|
|
54
|
+
const dragRef = useRef(null);
|
|
55
|
+
const splitRef = useRef({ split1: 35, split2: 65, split3: 85 });
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
splitRef.current = { split1, split2, split3 };
|
|
58
|
+
}, [split1, split2, split3]);
|
|
46
59
|
|
|
47
60
|
// Threshold for showing expand/collapse button
|
|
48
61
|
const CHIPS_COLLAPSE_THRESHOLD = 8;
|
|
@@ -164,7 +177,7 @@ export function SelectionPanel({
|
|
|
164
177
|
prevSearchRef.current = currentSearch;
|
|
165
178
|
}, [metricsSearch, sortedNamespaces]);
|
|
166
179
|
|
|
167
|
-
// Dedupe dimensions by name
|
|
180
|
+
// Dedupe dimensions by name, keeping shortest path per name
|
|
168
181
|
const dedupedDimensions = useMemo(() => {
|
|
169
182
|
const byName = new Map();
|
|
170
183
|
dimensions.forEach(d => {
|
|
@@ -180,34 +193,147 @@ export function SelectionPanel({
|
|
|
180
193
|
return Array.from(byName.values());
|
|
181
194
|
}, [dimensions]);
|
|
182
195
|
|
|
183
|
-
//
|
|
184
|
-
const
|
|
196
|
+
// Extract role path from a dimension name, e.g. "foo.bar[a->b->c]" → "a->b->c" (or null)
|
|
197
|
+
const getRolePath = name => {
|
|
198
|
+
const match = name.match(/\[([^\]]+)\]$/);
|
|
199
|
+
return match ? match[1] : null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Format a role path for display: "a->b->c" → "via a → b → c"
|
|
203
|
+
const formatRolePath = roleKey =>
|
|
204
|
+
roleKey ? 'via ' + roleKey.replace(/->/g, ' → ') : 'direct';
|
|
205
|
+
|
|
206
|
+
// Count hops in a role path string
|
|
207
|
+
const rolePathHops = roleKey => (roleKey ? roleKey.split('->').length : 0);
|
|
208
|
+
|
|
209
|
+
// Group dimensions by node, then by role path within each node
|
|
210
|
+
const groupedDimensions = useMemo(() => {
|
|
185
211
|
const search = dimensionsSearch.trim().toLowerCase();
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (!d.name) return
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
212
|
+
const nodeMap = new Map();
|
|
213
|
+
|
|
214
|
+
dedupedDimensions.forEach(d => {
|
|
215
|
+
if (!d.name) return;
|
|
216
|
+
const nodeKey =
|
|
217
|
+
d.path?.length > 0
|
|
218
|
+
? d.path[d.path.length - 1]
|
|
219
|
+
: d.name.split('.').slice(0, -1).join('.');
|
|
220
|
+
const distance = Math.max(0, d.path ? d.path.length - 1 : 0);
|
|
221
|
+
const roleKey = getRolePath(d.name); // e.g. "title_deal_window->title" or null
|
|
222
|
+
|
|
223
|
+
if (!nodeMap.has(nodeKey)) {
|
|
224
|
+
nodeMap.set(nodeKey, {
|
|
225
|
+
nodeKey,
|
|
226
|
+
minDistance: distance,
|
|
227
|
+
rolePathMap: new Map(),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const node = nodeMap.get(nodeKey);
|
|
231
|
+
node.minDistance = Math.min(node.minDistance, distance);
|
|
232
|
+
|
|
233
|
+
if (!node.rolePathMap.has(roleKey)) {
|
|
234
|
+
node.rolePathMap.set(roleKey, { roleKey, dimensions: [] });
|
|
235
|
+
}
|
|
236
|
+
node.rolePathMap.get(roleKey).dimensions.push(d);
|
|
194
237
|
});
|
|
195
238
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
239
|
+
let groupsArray = Array.from(nodeMap.values()).map(node => {
|
|
240
|
+
// Sort role paths: direct (null) first, then by hop count, then alphabetically
|
|
241
|
+
const rolePaths = Array.from(node.rolePathMap.values()).sort(
|
|
242
|
+
(a, b) =>
|
|
243
|
+
rolePathHops(a.roleKey) - rolePathHops(b.roleKey) ||
|
|
244
|
+
(a.roleKey || '').localeCompare(b.roleKey || ''),
|
|
245
|
+
);
|
|
246
|
+
// Sort dims within each role path alphabetically
|
|
247
|
+
rolePaths.forEach(rp => {
|
|
248
|
+
rp.dimensions.sort((a, b) => a.name.localeCompare(b.name));
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
nodeKey: node.nodeKey,
|
|
252
|
+
minDistance: node.minDistance,
|
|
253
|
+
rolePaths,
|
|
254
|
+
totalCount: rolePaths.reduce((n, rp) => n + rp.dimensions.length, 0),
|
|
255
|
+
};
|
|
206
256
|
});
|
|
207
257
|
|
|
208
|
-
|
|
258
|
+
// Apply search: filter within role paths, prune empty role paths and nodes
|
|
259
|
+
if (search) {
|
|
260
|
+
groupsArray = groupsArray
|
|
261
|
+
.map(group => ({
|
|
262
|
+
...group,
|
|
263
|
+
rolePaths: group.rolePaths
|
|
264
|
+
.map(rp => ({
|
|
265
|
+
...rp,
|
|
266
|
+
dimensions: rp.dimensions.filter(d => {
|
|
267
|
+
const lower = d.name.toLowerCase();
|
|
268
|
+
return (
|
|
269
|
+
lower.includes(search) ||
|
|
270
|
+
d.name.split('.').pop().toLowerCase().includes(search)
|
|
271
|
+
);
|
|
272
|
+
}),
|
|
273
|
+
}))
|
|
274
|
+
.filter(
|
|
275
|
+
rp =>
|
|
276
|
+
rp.dimensions.length > 0 ||
|
|
277
|
+
(rp.roleKey || '').toLowerCase().includes(search),
|
|
278
|
+
),
|
|
279
|
+
}))
|
|
280
|
+
.filter(
|
|
281
|
+
group =>
|
|
282
|
+
group.rolePaths.length > 0 ||
|
|
283
|
+
group.nodeKey.toLowerCase().includes(search),
|
|
284
|
+
)
|
|
285
|
+
.map(group => ({
|
|
286
|
+
...group,
|
|
287
|
+
totalCount: group.rolePaths.reduce(
|
|
288
|
+
(n, rp) => n + rp.dimensions.length,
|
|
289
|
+
0,
|
|
290
|
+
),
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Sort node groups by distance, then alphabetically
|
|
295
|
+
groupsArray.sort(
|
|
296
|
+
(a, b) =>
|
|
297
|
+
a.minDistance - b.minDistance || a.nodeKey.localeCompare(b.nodeKey),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
return groupsArray;
|
|
209
301
|
}, [dedupedDimensions, dimensionsSearch]);
|
|
210
302
|
|
|
303
|
+
// Auto-expand on first load and when searching
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (groupedDimensions.length === 0) return;
|
|
306
|
+
if (dimensionsSearch.trim()) {
|
|
307
|
+
// Expand everything with matches
|
|
308
|
+
setExpandedDimGroups(new Set(groupedDimensions.map(g => g.nodeKey)));
|
|
309
|
+
setExpandedRolePaths(
|
|
310
|
+
new Set(
|
|
311
|
+
groupedDimensions.flatMap(g =>
|
|
312
|
+
g.rolePaths.map(rp => `${g.nodeKey}::${rp.roleKey}`),
|
|
313
|
+
),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
} else {
|
|
317
|
+
// On first load: expand distance-0 node groups and their direct (null) role paths
|
|
318
|
+
setExpandedDimGroups(prev => {
|
|
319
|
+
if (prev.size > 0) return prev;
|
|
320
|
+
return new Set(
|
|
321
|
+
groupedDimensions
|
|
322
|
+
.filter(g => g.minDistance === 0)
|
|
323
|
+
.map(g => g.nodeKey),
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
setExpandedRolePaths(prev => {
|
|
327
|
+
if (prev.size > 0) return prev;
|
|
328
|
+
return new Set(
|
|
329
|
+
groupedDimensions
|
|
330
|
+
.filter(g => g.minDistance === 0)
|
|
331
|
+
.map(g => `${g.nodeKey}::null`),
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}, [groupedDimensions, dimensionsSearch]);
|
|
336
|
+
|
|
211
337
|
// Get display name for dimension (last 2 segments)
|
|
212
338
|
const getDimDisplayName = fullName => {
|
|
213
339
|
const parts = (fullName || '').split('.');
|
|
@@ -226,6 +352,72 @@ export function SelectionPanel({
|
|
|
226
352
|
});
|
|
227
353
|
};
|
|
228
354
|
|
|
355
|
+
const toggleDimGroup = nodeKey => {
|
|
356
|
+
setExpandedDimGroups(prev => {
|
|
357
|
+
const next = new Set(prev);
|
|
358
|
+
if (next.has(nodeKey)) {
|
|
359
|
+
next.delete(nodeKey);
|
|
360
|
+
} else {
|
|
361
|
+
next.add(nodeKey);
|
|
362
|
+
}
|
|
363
|
+
return next;
|
|
364
|
+
});
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const toggleRolePath = (nodeKey, roleKey) => {
|
|
368
|
+
const key = `${nodeKey}::${roleKey}`;
|
|
369
|
+
setExpandedRolePaths(prev => {
|
|
370
|
+
const next = new Set(prev);
|
|
371
|
+
if (next.has(key)) {
|
|
372
|
+
next.delete(key);
|
|
373
|
+
} else {
|
|
374
|
+
next.add(key);
|
|
375
|
+
}
|
|
376
|
+
return next;
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const getDimGroupShortName = nodeKey => nodeKey.split('.').pop();
|
|
381
|
+
|
|
382
|
+
const handleDividerMouseDown = useCallback((e, divider) => {
|
|
383
|
+
e.preventDefault();
|
|
384
|
+
const startSplit =
|
|
385
|
+
divider === 1
|
|
386
|
+
? splitRef.current.split1
|
|
387
|
+
: divider === 2
|
|
388
|
+
? splitRef.current.split2
|
|
389
|
+
: splitRef.current.split3;
|
|
390
|
+
dragRef.current = { divider, startY: e.clientY, startSplit };
|
|
391
|
+
}, []);
|
|
392
|
+
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
const onMouseMove = e => {
|
|
395
|
+
if (!dragRef.current || !sectionsRef.current) return;
|
|
396
|
+
const height = sectionsRef.current.getBoundingClientRect().height;
|
|
397
|
+
const deltaPct = ((e.clientY - dragRef.current.startY) / height) * 100;
|
|
398
|
+
const { divider, startSplit } = dragRef.current;
|
|
399
|
+
const { split1, split2, split3 } = splitRef.current;
|
|
400
|
+
if (divider === 1) {
|
|
401
|
+
setSplit1(Math.max(10, Math.min(split2 - 15, startSplit + deltaPct)));
|
|
402
|
+
} else if (divider === 2) {
|
|
403
|
+
setSplit2(
|
|
404
|
+
Math.max(split1 + 15, Math.min(split3 - 10, startSplit + deltaPct)),
|
|
405
|
+
);
|
|
406
|
+
} else {
|
|
407
|
+
setSplit3(Math.max(split2 + 10, Math.min(95, startSplit + deltaPct)));
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const onMouseUp = () => {
|
|
411
|
+
dragRef.current = null;
|
|
412
|
+
};
|
|
413
|
+
window.addEventListener('mousemove', onMouseMove);
|
|
414
|
+
window.addEventListener('mouseup', onMouseUp);
|
|
415
|
+
return () => {
|
|
416
|
+
window.removeEventListener('mousemove', onMouseMove);
|
|
417
|
+
window.removeEventListener('mouseup', onMouseUp);
|
|
418
|
+
};
|
|
419
|
+
}, []);
|
|
420
|
+
|
|
229
421
|
const toggleMetric = metric => {
|
|
230
422
|
if (selectedMetrics.includes(metric)) {
|
|
231
423
|
onMetricsChange(selectedMetrics.filter(m => m !== metric));
|
|
@@ -298,6 +490,14 @@ export function SelectionPanel({
|
|
|
298
490
|
}
|
|
299
491
|
};
|
|
300
492
|
|
|
493
|
+
const addDimAsFilter = (e, dimName) => {
|
|
494
|
+
e.preventDefault();
|
|
495
|
+
e.stopPropagation();
|
|
496
|
+
const prefix = filterInput.trim() ? filterInput.trimEnd() + ' AND ' : '';
|
|
497
|
+
setFilterInput(prefix + dimName + ' ');
|
|
498
|
+
filterInputRef.current?.focus();
|
|
499
|
+
};
|
|
500
|
+
|
|
301
501
|
return (
|
|
302
502
|
<div className="selection-panel">
|
|
303
503
|
{/* Cube Preset Dropdown */}
|
|
@@ -371,377 +571,497 @@ export function SelectionPanel({
|
|
|
371
571
|
</div>
|
|
372
572
|
)}
|
|
373
573
|
|
|
374
|
-
{/*
|
|
375
|
-
<div className="
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
<
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
574
|
+
{/* Resizable sections */}
|
|
575
|
+
<div className="resizable-sections" ref={sectionsRef}>
|
|
576
|
+
{/* Metrics Section */}
|
|
577
|
+
<div className="selection-section" style={{ flex: split1 }}>
|
|
578
|
+
<div className="section-header">
|
|
579
|
+
<h3>Metrics</h3>
|
|
580
|
+
<span className="selection-count">
|
|
581
|
+
{selectedMetrics.length} selected
|
|
582
|
+
</span>
|
|
583
|
+
</div>
|
|
382
584
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
{selectedMetrics.length > 0 && (
|
|
389
|
-
<div
|
|
390
|
-
className={`combobox-chips ${
|
|
391
|
-
selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD
|
|
392
|
-
? metricsChipsExpanded
|
|
393
|
-
? 'expanded'
|
|
394
|
-
: 'collapsed'
|
|
395
|
-
: ''
|
|
396
|
-
}`}
|
|
397
|
-
>
|
|
398
|
-
{selectedMetrics.map(metric => (
|
|
399
|
-
<span key={metric} className="selected-chip metric-chip">
|
|
400
|
-
{getShortName(metric)}
|
|
401
|
-
<button
|
|
402
|
-
className="chip-remove"
|
|
403
|
-
onClick={e => {
|
|
404
|
-
e.stopPropagation();
|
|
405
|
-
removeMetric(metric);
|
|
406
|
-
}}
|
|
407
|
-
title={`Remove ${getShortName(metric)}`}
|
|
408
|
-
>
|
|
409
|
-
×
|
|
410
|
-
</button>
|
|
411
|
-
</span>
|
|
412
|
-
))}
|
|
413
|
-
</div>
|
|
414
|
-
)}
|
|
415
|
-
<div className="combobox-input-row">
|
|
416
|
-
<input
|
|
417
|
-
ref={metricsSearchRef}
|
|
418
|
-
type="text"
|
|
419
|
-
className="combobox-search"
|
|
420
|
-
placeholder="Search metrics..."
|
|
421
|
-
value={metricsSearch}
|
|
422
|
-
onChange={e => setMetricsSearch(e.target.value)}
|
|
423
|
-
onClick={e => e.stopPropagation()}
|
|
424
|
-
/>
|
|
425
|
-
{selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD && (
|
|
426
|
-
<button
|
|
427
|
-
className="combobox-action"
|
|
428
|
-
onClick={e => {
|
|
429
|
-
e.stopPropagation();
|
|
430
|
-
setMetricsChipsExpanded(!metricsChipsExpanded);
|
|
431
|
-
}}
|
|
432
|
-
>
|
|
433
|
-
{metricsChipsExpanded ? 'Show less' : 'Show all'}
|
|
434
|
-
</button>
|
|
435
|
-
)}
|
|
585
|
+
{/* Combined Chips + Search Input */}
|
|
586
|
+
<div
|
|
587
|
+
className="combobox-input"
|
|
588
|
+
onClick={() => metricsSearchRef.current?.focus()}
|
|
589
|
+
>
|
|
436
590
|
{selectedMetrics.length > 0 && (
|
|
437
|
-
<
|
|
438
|
-
className=
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
591
|
+
<div
|
|
592
|
+
className={`combobox-chips ${
|
|
593
|
+
selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD
|
|
594
|
+
? metricsChipsExpanded
|
|
595
|
+
? 'expanded'
|
|
596
|
+
: 'collapsed'
|
|
597
|
+
: ''
|
|
598
|
+
}`}
|
|
443
599
|
>
|
|
444
|
-
|
|
445
|
-
|
|
600
|
+
{selectedMetrics.map(metric => (
|
|
601
|
+
<span key={metric} className="selected-chip metric-chip">
|
|
602
|
+
{getShortName(metric)}
|
|
603
|
+
<button
|
|
604
|
+
className="chip-remove"
|
|
605
|
+
onClick={e => {
|
|
606
|
+
e.stopPropagation();
|
|
607
|
+
removeMetric(metric);
|
|
608
|
+
}}
|
|
609
|
+
title={`Remove ${getShortName(metric)}`}
|
|
610
|
+
>
|
|
611
|
+
×
|
|
612
|
+
</button>
|
|
613
|
+
</span>
|
|
614
|
+
))}
|
|
615
|
+
</div>
|
|
616
|
+
)}
|
|
617
|
+
<div className="combobox-input-row">
|
|
618
|
+
<input
|
|
619
|
+
ref={metricsSearchRef}
|
|
620
|
+
type="text"
|
|
621
|
+
className="combobox-search"
|
|
622
|
+
placeholder="Search metrics..."
|
|
623
|
+
value={metricsSearch}
|
|
624
|
+
onChange={e => setMetricsSearch(e.target.value)}
|
|
625
|
+
onClick={e => e.stopPropagation()}
|
|
626
|
+
/>
|
|
627
|
+
{selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD && (
|
|
628
|
+
<button
|
|
629
|
+
className="combobox-action"
|
|
630
|
+
onClick={e => {
|
|
631
|
+
e.stopPropagation();
|
|
632
|
+
setMetricsChipsExpanded(!metricsChipsExpanded);
|
|
633
|
+
}}
|
|
634
|
+
>
|
|
635
|
+
{metricsChipsExpanded ? 'Show less' : 'Show all'}
|
|
636
|
+
</button>
|
|
637
|
+
)}
|
|
638
|
+
{selectedMetrics.length > 0 && (
|
|
639
|
+
<button
|
|
640
|
+
className="combobox-action"
|
|
641
|
+
onClick={e => {
|
|
642
|
+
e.stopPropagation();
|
|
643
|
+
onMetricsChange([]);
|
|
644
|
+
}}
|
|
645
|
+
>
|
|
646
|
+
Clear
|
|
647
|
+
</button>
|
|
648
|
+
)}
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
|
|
652
|
+
<div className="selection-list">
|
|
653
|
+
{sortedNamespaces.map(namespace => {
|
|
654
|
+
const items = filteredGroups[namespace];
|
|
655
|
+
const isExpanded = expandedNamespaces.has(namespace);
|
|
656
|
+
const selectedInNamespace = items.filter(m =>
|
|
657
|
+
selectedMetrics.includes(m),
|
|
658
|
+
).length;
|
|
659
|
+
|
|
660
|
+
return (
|
|
661
|
+
<div key={namespace} className="namespace-group">
|
|
662
|
+
<div
|
|
663
|
+
className="namespace-header"
|
|
664
|
+
onClick={() => toggleNamespace(namespace)}
|
|
665
|
+
>
|
|
666
|
+
<span className="expand-icon">
|
|
667
|
+
{isExpanded ? '▼' : '▶'}
|
|
668
|
+
</span>
|
|
669
|
+
<span className="namespace-name">{namespace}</span>
|
|
670
|
+
<span className="namespace-count">
|
|
671
|
+
{selectedInNamespace > 0 && (
|
|
672
|
+
<span className="selected-badge">
|
|
673
|
+
{selectedInNamespace}
|
|
674
|
+
</span>
|
|
675
|
+
)}
|
|
676
|
+
{items.length}
|
|
677
|
+
</span>
|
|
678
|
+
</div>
|
|
679
|
+
|
|
680
|
+
{isExpanded && (
|
|
681
|
+
<div className="namespace-items">
|
|
682
|
+
<div className="namespace-actions">
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
className="select-all-btn"
|
|
686
|
+
onClick={() => selectAllInNamespace(namespace, items)}
|
|
687
|
+
>
|
|
688
|
+
Select all
|
|
689
|
+
</button>
|
|
690
|
+
<button
|
|
691
|
+
type="button"
|
|
692
|
+
className="select-all-btn"
|
|
693
|
+
onClick={() =>
|
|
694
|
+
deselectAllInNamespace(namespace, items)
|
|
695
|
+
}
|
|
696
|
+
>
|
|
697
|
+
Clear
|
|
698
|
+
</button>
|
|
699
|
+
</div>
|
|
700
|
+
{items.map(metric => {
|
|
701
|
+
const isIncompatible =
|
|
702
|
+
compatibleMetrics !== null &&
|
|
703
|
+
!compatibleMetrics.has(metric) &&
|
|
704
|
+
!selectedMetrics.includes(metric);
|
|
705
|
+
return (
|
|
706
|
+
<label
|
|
707
|
+
key={metric}
|
|
708
|
+
className={`selection-item${
|
|
709
|
+
isIncompatible ? ' metric-incompatible' : ''
|
|
710
|
+
}`}
|
|
711
|
+
title={
|
|
712
|
+
isIncompatible
|
|
713
|
+
? 'Not compatible with selected dimensions'
|
|
714
|
+
: metric
|
|
715
|
+
}
|
|
716
|
+
>
|
|
717
|
+
<input
|
|
718
|
+
type="checkbox"
|
|
719
|
+
checked={selectedMetrics.includes(metric)}
|
|
720
|
+
onChange={() => toggleMetric(metric)}
|
|
721
|
+
/>
|
|
722
|
+
<span className="item-name">
|
|
723
|
+
{getShortName(metric)}
|
|
724
|
+
</span>
|
|
725
|
+
{compatibleMetrics !== null &&
|
|
726
|
+
compatibleMetrics.has(metric) &&
|
|
727
|
+
!selectedMetrics.includes(metric) && (
|
|
728
|
+
<span
|
|
729
|
+
className="metric-compatible-badge"
|
|
730
|
+
title="Compatible with selected dimensions"
|
|
731
|
+
>
|
|
732
|
+
✓
|
|
733
|
+
</span>
|
|
734
|
+
)}
|
|
735
|
+
</label>
|
|
736
|
+
);
|
|
737
|
+
})}
|
|
738
|
+
</div>
|
|
739
|
+
)}
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
})}
|
|
743
|
+
|
|
744
|
+
{sortedNamespaces.length === 0 && (
|
|
745
|
+
<div className="empty-list">
|
|
746
|
+
{metricsSearch
|
|
747
|
+
? 'No metrics match your search'
|
|
748
|
+
: 'No metrics available'}
|
|
749
|
+
</div>
|
|
446
750
|
)}
|
|
447
751
|
</div>
|
|
448
752
|
</div>
|
|
449
753
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
754
|
+
{/* Draggable Divider 1: metrics / dims */}
|
|
755
|
+
<div
|
|
756
|
+
className="section-divider draggable-divider"
|
|
757
|
+
onMouseDown={e => handleDividerMouseDown(e, 1)}
|
|
758
|
+
/>
|
|
759
|
+
|
|
760
|
+
{/* Dimensions Section */}
|
|
761
|
+
<div className="selection-section" style={{ flex: split2 - split1 }}>
|
|
762
|
+
<div className="section-header">
|
|
763
|
+
<h3>Dimensions</h3>
|
|
764
|
+
<span className="selection-count">
|
|
765
|
+
{selectedDimensions.length} selected
|
|
766
|
+
{dimensions.length > 0 && ` / ${dimensions.length} available`}
|
|
767
|
+
</span>
|
|
768
|
+
</div>
|
|
769
|
+
|
|
770
|
+
{selectedMetrics.length === 0 ? (
|
|
771
|
+
<div className="empty-list hint">
|
|
772
|
+
Select metrics to see available dimensions
|
|
773
|
+
</div>
|
|
774
|
+
) : loading ? (
|
|
775
|
+
<div className="empty-list">Loading dimensions...</div>
|
|
776
|
+
) : (
|
|
777
|
+
<>
|
|
778
|
+
{/* Combined Chips + Search Input */}
|
|
779
|
+
<div
|
|
780
|
+
className="combobox-input"
|
|
781
|
+
onClick={() => dimensionsSearchRef.current?.focus()}
|
|
782
|
+
>
|
|
783
|
+
{selectedDimensions.length > 0 && (
|
|
784
|
+
<div
|
|
785
|
+
className={`combobox-chips ${
|
|
786
|
+
selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD
|
|
787
|
+
? dimensionsChipsExpanded
|
|
788
|
+
? 'expanded'
|
|
789
|
+
: 'collapsed'
|
|
790
|
+
: ''
|
|
791
|
+
}`}
|
|
792
|
+
>
|
|
793
|
+
{selectedDimensions.map(dimName => (
|
|
794
|
+
<span
|
|
795
|
+
key={dimName}
|
|
796
|
+
className="selected-chip dimension-chip"
|
|
797
|
+
title={dimName}
|
|
798
|
+
>
|
|
799
|
+
<span className="chip-label">
|
|
800
|
+
{getDimDisplayName(dimName)}
|
|
801
|
+
</span>
|
|
802
|
+
<button
|
|
803
|
+
className="chip-remove"
|
|
804
|
+
onClick={e => {
|
|
805
|
+
e.stopPropagation();
|
|
806
|
+
removeDimension(dimName);
|
|
807
|
+
}}
|
|
808
|
+
title={`Remove ${getDimDisplayName(dimName)}`}
|
|
809
|
+
>
|
|
810
|
+
×
|
|
811
|
+
</button>
|
|
470
812
|
</span>
|
|
471
|
-
)}
|
|
472
|
-
|
|
473
|
-
|
|
813
|
+
))}
|
|
814
|
+
</div>
|
|
815
|
+
)}
|
|
816
|
+
<div className="combobox-input-row">
|
|
817
|
+
<input
|
|
818
|
+
ref={dimensionsSearchRef}
|
|
819
|
+
type="text"
|
|
820
|
+
className="combobox-search"
|
|
821
|
+
placeholder="Search dimensions..."
|
|
822
|
+
value={dimensionsSearch}
|
|
823
|
+
onChange={e => setDimensionsSearch(e.target.value)}
|
|
824
|
+
onClick={e => e.stopPropagation()}
|
|
825
|
+
/>
|
|
826
|
+
{selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD && (
|
|
827
|
+
<button
|
|
828
|
+
className="combobox-action"
|
|
829
|
+
onClick={e => {
|
|
830
|
+
e.stopPropagation();
|
|
831
|
+
setDimensionsChipsExpanded(!dimensionsChipsExpanded);
|
|
832
|
+
}}
|
|
833
|
+
>
|
|
834
|
+
{dimensionsChipsExpanded ? 'Show less' : 'Show all'}
|
|
835
|
+
</button>
|
|
836
|
+
)}
|
|
837
|
+
{selectedDimensions.length > 0 && (
|
|
838
|
+
<button
|
|
839
|
+
className="combobox-action"
|
|
840
|
+
onClick={e => {
|
|
841
|
+
e.stopPropagation();
|
|
842
|
+
onDimensionsChange([]);
|
|
843
|
+
}}
|
|
844
|
+
>
|
|
845
|
+
Clear
|
|
846
|
+
</button>
|
|
847
|
+
)}
|
|
474
848
|
</div>
|
|
849
|
+
</div>
|
|
475
850
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
851
|
+
<div className="selection-list dimensions-list">
|
|
852
|
+
{groupedDimensions.map(group => {
|
|
853
|
+
const nodeExpanded = expandedDimGroups.has(group.nodeKey);
|
|
854
|
+
return (
|
|
855
|
+
<div key={group.nodeKey} className="dim-group">
|
|
856
|
+
<div
|
|
857
|
+
className="dim-group-header"
|
|
858
|
+
onClick={() => toggleDimGroup(group.nodeKey)}
|
|
859
|
+
title={group.nodeKey}
|
|
483
860
|
>
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
<button
|
|
487
|
-
type="button"
|
|
488
|
-
className="select-all-btn"
|
|
489
|
-
onClick={() => deselectAllInNamespace(namespace, items)}
|
|
490
|
-
>
|
|
491
|
-
Clear
|
|
492
|
-
</button>
|
|
493
|
-
</div>
|
|
494
|
-
{items.map(metric => (
|
|
495
|
-
<label key={metric} className="selection-item">
|
|
496
|
-
<input
|
|
497
|
-
type="checkbox"
|
|
498
|
-
checked={selectedMetrics.includes(metric)}
|
|
499
|
-
onChange={() => toggleMetric(metric)}
|
|
500
|
-
/>
|
|
501
|
-
<span className="item-name">
|
|
502
|
-
{getShortName(metric)}
|
|
861
|
+
<span className="expand-icon">
|
|
862
|
+
{nodeExpanded ? '▼' : '▶'}
|
|
503
863
|
</span>
|
|
504
|
-
|
|
505
|
-
|
|
864
|
+
<span className="dim-group-name">
|
|
865
|
+
{getDimGroupShortName(group.nodeKey)}
|
|
866
|
+
</span>
|
|
867
|
+
<span className="dim-group-count">
|
|
868
|
+
{group.totalCount}
|
|
869
|
+
</span>
|
|
870
|
+
</div>
|
|
871
|
+
{nodeExpanded &&
|
|
872
|
+
group.rolePaths.map(rp => {
|
|
873
|
+
const rpKey = `${group.nodeKey}::${rp.roleKey}`;
|
|
874
|
+
const rpExpanded = expandedRolePaths.has(rpKey);
|
|
875
|
+
const hops = rolePathHops(rp.roleKey);
|
|
876
|
+
const hopsLabel =
|
|
877
|
+
hops === 0
|
|
878
|
+
? 'direct'
|
|
879
|
+
: `${hops} hop${hops > 1 ? 's' : ''}`;
|
|
880
|
+
return (
|
|
881
|
+
<div key={rpKey} className="dim-role-group">
|
|
882
|
+
<div
|
|
883
|
+
className="dim-role-header"
|
|
884
|
+
onClick={() =>
|
|
885
|
+
toggleRolePath(group.nodeKey, rp.roleKey)
|
|
886
|
+
}
|
|
887
|
+
>
|
|
888
|
+
<span className="expand-icon">
|
|
889
|
+
{rpExpanded ? '▼' : '▶'}
|
|
890
|
+
</span>
|
|
891
|
+
<span className="dim-role-label">
|
|
892
|
+
{formatRolePath(rp.roleKey)}
|
|
893
|
+
</span>
|
|
894
|
+
<span className="dim-group-meta">
|
|
895
|
+
{hopsLabel}
|
|
896
|
+
</span>
|
|
897
|
+
<span className="dim-group-count">
|
|
898
|
+
{rp.dimensions.length}
|
|
899
|
+
</span>
|
|
900
|
+
</div>
|
|
901
|
+
{rpExpanded &&
|
|
902
|
+
rp.dimensions.map(dim => (
|
|
903
|
+
<label
|
|
904
|
+
key={dim.name}
|
|
905
|
+
className="selection-item dimension-item dim-role-item"
|
|
906
|
+
title={dim.name}
|
|
907
|
+
>
|
|
908
|
+
<input
|
|
909
|
+
type="checkbox"
|
|
910
|
+
checked={selectedDimensions.includes(
|
|
911
|
+
dim.name,
|
|
912
|
+
)}
|
|
913
|
+
onChange={() => toggleDimension(dim.name)}
|
|
914
|
+
/>
|
|
915
|
+
<div className="dimension-info">
|
|
916
|
+
<span className="item-name">
|
|
917
|
+
{getDimDisplayName(
|
|
918
|
+
dim.name.replace(/\[[^\]]*\]$/, ''),
|
|
919
|
+
)}
|
|
920
|
+
</span>
|
|
921
|
+
</div>
|
|
922
|
+
<button
|
|
923
|
+
className="dim-filter-btn"
|
|
924
|
+
title={`Add "${dim.name}" to filters`}
|
|
925
|
+
onClick={e => addDimAsFilter(e, dim.name)}
|
|
926
|
+
>
|
|
927
|
+
+ filter
|
|
928
|
+
</button>
|
|
929
|
+
</label>
|
|
930
|
+
))}
|
|
931
|
+
</div>
|
|
932
|
+
);
|
|
933
|
+
})}
|
|
934
|
+
</div>
|
|
935
|
+
);
|
|
936
|
+
})}
|
|
937
|
+
|
|
938
|
+
{groupedDimensions.length === 0 && (
|
|
939
|
+
<div className="empty-list">
|
|
940
|
+
{dimensionsSearch
|
|
941
|
+
? 'No dimensions match your search'
|
|
942
|
+
: 'No shared dimensions'}
|
|
506
943
|
</div>
|
|
507
944
|
)}
|
|
508
945
|
</div>
|
|
509
|
-
|
|
510
|
-
})}
|
|
511
|
-
|
|
512
|
-
{sortedNamespaces.length === 0 && (
|
|
513
|
-
<div className="empty-list">
|
|
514
|
-
{metricsSearch
|
|
515
|
-
? 'No metrics match your search'
|
|
516
|
-
: 'No metrics available'}
|
|
517
|
-
</div>
|
|
946
|
+
</>
|
|
518
947
|
)}
|
|
519
948
|
</div>
|
|
520
|
-
</div>
|
|
521
949
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
<div className="section-header">
|
|
528
|
-
<h3>Dimensions</h3>
|
|
529
|
-
<span className="selection-count">
|
|
530
|
-
{selectedDimensions.length} selected
|
|
531
|
-
{dimensions.length > 0 && ` / ${dimensions.length} available`}
|
|
532
|
-
</span>
|
|
533
|
-
</div>
|
|
950
|
+
{/* Draggable Divider 2: dims / filters */}
|
|
951
|
+
<div
|
|
952
|
+
className="section-divider draggable-divider"
|
|
953
|
+
onMouseDown={e => handleDividerMouseDown(e, 2)}
|
|
954
|
+
/>
|
|
534
955
|
|
|
535
|
-
{
|
|
536
|
-
|
|
537
|
-
|
|
956
|
+
{/* Filters Section */}
|
|
957
|
+
<div
|
|
958
|
+
className="selection-section filters-section"
|
|
959
|
+
style={{ flex: split3 - split2 }}
|
|
960
|
+
>
|
|
961
|
+
<div className="section-header">
|
|
962
|
+
<h3>Filters</h3>
|
|
963
|
+
<span className="selection-count">{filters.length} applied</span>
|
|
538
964
|
</div>
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
onClick={() => dimensionsSearchRef.current?.focus()}
|
|
547
|
-
>
|
|
548
|
-
{selectedDimensions.length > 0 && (
|
|
549
|
-
<div
|
|
550
|
-
className={`combobox-chips ${
|
|
551
|
-
selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD
|
|
552
|
-
? dimensionsChipsExpanded
|
|
553
|
-
? 'expanded'
|
|
554
|
-
: 'collapsed'
|
|
555
|
-
: ''
|
|
556
|
-
}`}
|
|
557
|
-
>
|
|
558
|
-
{selectedDimensions.map(dimName => (
|
|
559
|
-
<span
|
|
560
|
-
key={dimName}
|
|
561
|
-
className="selected-chip dimension-chip"
|
|
562
|
-
>
|
|
563
|
-
{getDimDisplayName(dimName)}
|
|
564
|
-
<button
|
|
565
|
-
className="chip-remove"
|
|
566
|
-
onClick={e => {
|
|
567
|
-
e.stopPropagation();
|
|
568
|
-
removeDimension(dimName);
|
|
569
|
-
}}
|
|
570
|
-
title={`Remove ${getDimDisplayName(dimName)}`}
|
|
571
|
-
>
|
|
572
|
-
×
|
|
573
|
-
</button>
|
|
574
|
-
</span>
|
|
575
|
-
))}
|
|
576
|
-
</div>
|
|
577
|
-
)}
|
|
578
|
-
<div className="combobox-input-row">
|
|
579
|
-
<input
|
|
580
|
-
ref={dimensionsSearchRef}
|
|
581
|
-
type="text"
|
|
582
|
-
className="combobox-search"
|
|
583
|
-
placeholder="Search dimensions..."
|
|
584
|
-
value={dimensionsSearch}
|
|
585
|
-
onChange={e => setDimensionsSearch(e.target.value)}
|
|
586
|
-
onClick={e => e.stopPropagation()}
|
|
587
|
-
/>
|
|
588
|
-
{selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD && (
|
|
589
|
-
<button
|
|
590
|
-
className="combobox-action"
|
|
591
|
-
onClick={e => {
|
|
592
|
-
e.stopPropagation();
|
|
593
|
-
setDimensionsChipsExpanded(!dimensionsChipsExpanded);
|
|
594
|
-
}}
|
|
595
|
-
>
|
|
596
|
-
{dimensionsChipsExpanded ? 'Show less' : 'Show all'}
|
|
597
|
-
</button>
|
|
598
|
-
)}
|
|
599
|
-
{selectedDimensions.length > 0 && (
|
|
965
|
+
|
|
966
|
+
{/* Filter chips */}
|
|
967
|
+
{filters.length > 0 && (
|
|
968
|
+
<div className="filter-chips-container">
|
|
969
|
+
{filters.map((filter, idx) => (
|
|
970
|
+
<span key={idx} className="filter-chip">
|
|
971
|
+
<span className="filter-chip-text">{filter}</span>
|
|
600
972
|
<button
|
|
601
|
-
className="
|
|
602
|
-
onClick={
|
|
603
|
-
|
|
604
|
-
onDimensionsChange([]);
|
|
605
|
-
}}
|
|
973
|
+
className="filter-chip-remove"
|
|
974
|
+
onClick={() => handleRemoveFilter(filter)}
|
|
975
|
+
title="Remove filter"
|
|
606
976
|
>
|
|
607
|
-
|
|
977
|
+
×
|
|
608
978
|
</button>
|
|
609
|
-
|
|
610
|
-
</div>
|
|
611
|
-
</div>
|
|
612
|
-
|
|
613
|
-
<div className="selection-list dimensions-list">
|
|
614
|
-
{filteredDimensions.map(dim => (
|
|
615
|
-
<label
|
|
616
|
-
key={dim.name}
|
|
617
|
-
className="selection-item dimension-item"
|
|
618
|
-
title={dim.name}
|
|
619
|
-
>
|
|
620
|
-
<input
|
|
621
|
-
type="checkbox"
|
|
622
|
-
checked={selectedDimensions.includes(dim.name)}
|
|
623
|
-
onChange={() => toggleDimension(dim.name)}
|
|
624
|
-
/>
|
|
625
|
-
<div className="dimension-info">
|
|
626
|
-
<span className="item-name">
|
|
627
|
-
{getDimDisplayName(dim.name)}
|
|
628
|
-
</span>
|
|
629
|
-
<span className="dimension-full-name">{dim.name}</span>
|
|
630
|
-
{dim.path && dim.path.length > 1 && (
|
|
631
|
-
<span className="dimension-path">
|
|
632
|
-
{dim.path.slice(1).join(' ▶ ')}
|
|
633
|
-
</span>
|
|
634
|
-
)}
|
|
635
|
-
</div>
|
|
636
|
-
</label>
|
|
979
|
+
</span>
|
|
637
980
|
))}
|
|
638
|
-
|
|
639
|
-
{filteredDimensions.length === 0 && (
|
|
640
|
-
<div className="empty-list">
|
|
641
|
-
{dimensionsSearch
|
|
642
|
-
? 'No dimensions match your search'
|
|
643
|
-
: 'No shared dimensions'}
|
|
644
|
-
</div>
|
|
645
|
-
)}
|
|
646
981
|
</div>
|
|
647
|
-
|
|
648
|
-
)}
|
|
649
|
-
</div>
|
|
650
|
-
|
|
651
|
-
{/* Divider */}
|
|
652
|
-
<div className="section-divider" />
|
|
982
|
+
)}
|
|
653
983
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
984
|
+
{/* Filter input */}
|
|
985
|
+
<div className="filter-input-container">
|
|
986
|
+
<input
|
|
987
|
+
ref={filterInputRef}
|
|
988
|
+
type="text"
|
|
989
|
+
className="filter-input"
|
|
990
|
+
placeholder="e.g. v3.date.date_id >= '2024-01-01'"
|
|
991
|
+
value={filterInput}
|
|
992
|
+
onChange={e => setFilterInput(e.target.value)}
|
|
993
|
+
onKeyDown={handleFilterKeyDown}
|
|
994
|
+
/>
|
|
995
|
+
<button
|
|
996
|
+
className="filter-add-btn"
|
|
997
|
+
onClick={handleAddFilter}
|
|
998
|
+
disabled={!filterInput.trim()}
|
|
999
|
+
>
|
|
1000
|
+
Add
|
|
1001
|
+
</button>
|
|
1002
|
+
</div>
|
|
659
1003
|
</div>
|
|
660
1004
|
|
|
661
|
-
{/*
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
1005
|
+
{/* Draggable Divider 3: filters / engine+run */}
|
|
1006
|
+
<div
|
|
1007
|
+
className="section-divider draggable-divider"
|
|
1008
|
+
onMouseDown={e => handleDividerMouseDown(e, 3)}
|
|
1009
|
+
/>
|
|
1010
|
+
|
|
1011
|
+
{/* Engine + Run Query Section */}
|
|
1012
|
+
<div
|
|
1013
|
+
className="selection-section engine-run-section"
|
|
1014
|
+
style={{ flex: 100 - split3 }}
|
|
1015
|
+
>
|
|
1016
|
+
{/* Engine Selection */}
|
|
1017
|
+
<div className="engine-section">
|
|
1018
|
+
<span className="engine-label">Engine</span>
|
|
1019
|
+
<div className="engine-pills">
|
|
1020
|
+
{ENGINE_OPTIONS.map(({ value, label }) => (
|
|
667
1021
|
<button
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1022
|
+
key={label}
|
|
1023
|
+
className={`engine-pill${
|
|
1024
|
+
selectedEngine === value ? ' active' : ''
|
|
1025
|
+
}`}
|
|
1026
|
+
onClick={() => onEngineChange && onEngineChange(value)}
|
|
671
1027
|
>
|
|
672
|
-
|
|
1028
|
+
{label}
|
|
673
1029
|
</button>
|
|
674
|
-
|
|
675
|
-
|
|
1030
|
+
))}
|
|
1031
|
+
</div>
|
|
676
1032
|
</div>
|
|
677
|
-
)}
|
|
678
|
-
|
|
679
|
-
{/* Filter input */}
|
|
680
|
-
<div className="filter-input-container">
|
|
681
|
-
<input
|
|
682
|
-
type="text"
|
|
683
|
-
className="filter-input"
|
|
684
|
-
placeholder="e.g. v3.date.date_id >= '2024-01-01'"
|
|
685
|
-
value={filterInput}
|
|
686
|
-
onChange={e => setFilterInput(e.target.value)}
|
|
687
|
-
onKeyDown={handleFilterKeyDown}
|
|
688
|
-
/>
|
|
689
|
-
<button
|
|
690
|
-
className="filter-add-btn"
|
|
691
|
-
onClick={handleAddFilter}
|
|
692
|
-
disabled={!filterInput.trim()}
|
|
693
|
-
>
|
|
694
|
-
Add
|
|
695
|
-
</button>
|
|
696
|
-
</div>
|
|
697
|
-
</div>
|
|
698
1033
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
<span className="engine-label">Engine</span>
|
|
702
|
-
<div className="engine-pills">
|
|
703
|
-
{ENGINE_OPTIONS.map(({ value, label }) => (
|
|
1034
|
+
{/* Run Query Section */}
|
|
1035
|
+
<div className="run-query-section">
|
|
704
1036
|
<button
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
}`}
|
|
709
|
-
onClick={() => onEngineChange && onEngineChange(value)}
|
|
1037
|
+
className="run-query-btn"
|
|
1038
|
+
onClick={onRunQuery}
|
|
1039
|
+
disabled={!canRunQuery || queryLoading}
|
|
710
1040
|
>
|
|
711
|
-
{
|
|
1041
|
+
{queryLoading ? (
|
|
1042
|
+
<>
|
|
1043
|
+
<span className="spinner small" />
|
|
1044
|
+
Running...
|
|
1045
|
+
</>
|
|
1046
|
+
) : (
|
|
1047
|
+
<>
|
|
1048
|
+
<span className="run-icon">▶</span>
|
|
1049
|
+
Run Query
|
|
1050
|
+
</>
|
|
1051
|
+
)}
|
|
712
1052
|
</button>
|
|
713
|
-
|
|
1053
|
+
{!canRunQuery && selectedMetrics.length > 0 && (
|
|
1054
|
+
<span className="run-hint">Select at least one dimension</span>
|
|
1055
|
+
)}
|
|
1056
|
+
{!canRunQuery && selectedMetrics.length === 0 && (
|
|
1057
|
+
<span className="run-hint">
|
|
1058
|
+
Select metrics and dimensions to run a query
|
|
1059
|
+
</span>
|
|
1060
|
+
)}
|
|
1061
|
+
</div>
|
|
714
1062
|
</div>
|
|
715
1063
|
</div>
|
|
716
|
-
|
|
717
|
-
{/* Run Query Section */}
|
|
718
|
-
<div className="run-query-section">
|
|
719
|
-
<button
|
|
720
|
-
className="run-query-btn"
|
|
721
|
-
onClick={onRunQuery}
|
|
722
|
-
disabled={!canRunQuery || queryLoading}
|
|
723
|
-
>
|
|
724
|
-
{queryLoading ? (
|
|
725
|
-
<>
|
|
726
|
-
<span className="spinner small" />
|
|
727
|
-
Running...
|
|
728
|
-
</>
|
|
729
|
-
) : (
|
|
730
|
-
<>
|
|
731
|
-
<span className="run-icon">▶</span>
|
|
732
|
-
Run Query
|
|
733
|
-
</>
|
|
734
|
-
)}
|
|
735
|
-
</button>
|
|
736
|
-
{!canRunQuery && selectedMetrics.length > 0 && (
|
|
737
|
-
<span className="run-hint">Select at least one dimension</span>
|
|
738
|
-
)}
|
|
739
|
-
{!canRunQuery && selectedMetrics.length === 0 && (
|
|
740
|
-
<span className="run-hint">
|
|
741
|
-
Select metrics and dimensions to run a query
|
|
742
|
-
</span>
|
|
743
|
-
)}
|
|
744
|
-
</div>
|
|
1064
|
+
{/* end resizable-sections */}
|
|
745
1065
|
</div>
|
|
746
1066
|
);
|
|
747
1067
|
}
|