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.
@@ -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
- // Filter and sort dimensions by search
184
- const filteredDimensions = useMemo(() => {
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
- if (!search) return dedupedDimensions;
187
-
188
- const matches = dedupedDimensions.filter(d => {
189
- if (!d.name) return false;
190
- const fullName = d.name.toLowerCase();
191
- const parts = d.name.split('.');
192
- const shortDisplay = parts.slice(-2).join('.').toLowerCase();
193
- return fullName.includes(search) || shortDisplay.includes(search);
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
- matches.sort((a, b) => {
197
- const aParts = (a.name || '').split('.');
198
- const bParts = (b.name || '').split('.');
199
- const aShort = aParts.slice(-2).join('.').toLowerCase();
200
- const bShort = bParts.slice(-2).join('.').toLowerCase();
201
- const aPrefix = aShort.startsWith(search);
202
- const bPrefix = bShort.startsWith(search);
203
- if (aPrefix && !bPrefix) return -1;
204
- if (!aPrefix && bPrefix) return 1;
205
- return aShort.localeCompare(bShort);
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
- return matches;
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
- {/* Metrics Section */}
375
- <div className="selection-section">
376
- <div className="section-header">
377
- <h3>Metrics</h3>
378
- <span className="selection-count">
379
- {selectedMetrics.length} selected
380
- </span>
381
- </div>
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
- {/* Combined Chips + Search Input */}
384
- <div
385
- className="combobox-input"
386
- onClick={() => metricsSearchRef.current?.focus()}
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
- <button
438
- className="combobox-action"
439
- onClick={e => {
440
- e.stopPropagation();
441
- onMetricsChange([]);
442
- }}
591
+ <div
592
+ className={`combobox-chips ${
593
+ selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD
594
+ ? metricsChipsExpanded
595
+ ? 'expanded'
596
+ : 'collapsed'
597
+ : ''
598
+ }`}
443
599
  >
444
- Clear
445
- </button>
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
- <div className="selection-list">
451
- {sortedNamespaces.map(namespace => {
452
- const items = filteredGroups[namespace];
453
- const isExpanded = expandedNamespaces.has(namespace);
454
- const selectedInNamespace = items.filter(m =>
455
- selectedMetrics.includes(m),
456
- ).length;
457
-
458
- return (
459
- <div key={namespace} className="namespace-group">
460
- <div
461
- className="namespace-header"
462
- onClick={() => toggleNamespace(namespace)}
463
- >
464
- <span className="expand-icon">{isExpanded ? '▼' : '▶'}</span>
465
- <span className="namespace-name">{namespace}</span>
466
- <span className="namespace-count">
467
- {selectedInNamespace > 0 && (
468
- <span className="selected-badge">
469
- {selectedInNamespace}
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
- {items.length}
473
- </span>
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
- {isExpanded && (
477
- <div className="namespace-items">
478
- <div className="namespace-actions">
479
- <button
480
- type="button"
481
- className="select-all-btn"
482
- onClick={() => selectAllInNamespace(namespace, items)}
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
- Select all
485
- </button>
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
- </label>
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
- {/* Divider */}
523
- <div className="section-divider" />
524
-
525
- {/* Dimensions Section */}
526
- <div className="selection-section">
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
- {selectedMetrics.length === 0 ? (
536
- <div className="empty-list hint">
537
- Select metrics to see available dimensions
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
- ) : loading ? (
540
- <div className="empty-list">Loading dimensions...</div>
541
- ) : (
542
- <>
543
- {/* Combined Chips + Search Input */}
544
- <div
545
- className="combobox-input"
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="combobox-action"
602
- onClick={e => {
603
- e.stopPropagation();
604
- onDimensionsChange([]);
605
- }}
973
+ className="filter-chip-remove"
974
+ onClick={() => handleRemoveFilter(filter)}
975
+ title="Remove filter"
606
976
  >
607
- Clear
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
- {/* Filters Section */}
655
- <div className="selection-section filters-section">
656
- <div className="section-header">
657
- <h3>Filters</h3>
658
- <span className="selection-count">{filters.length} applied</span>
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
- {/* Filter chips */}
662
- {filters.length > 0 && (
663
- <div className="filter-chips-container">
664
- {filters.map((filter, idx) => (
665
- <span key={idx} className="filter-chip">
666
- <span className="filter-chip-text">{filter}</span>
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
- className="filter-chip-remove"
669
- onClick={() => handleRemoveFilter(filter)}
670
- title="Remove filter"
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
- </span>
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
- {/* Engine Selection */}
700
- <div className="engine-section">
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
- key={label}
706
- className={`engine-pill${
707
- selectedEngine === value ? ' active' : ''
708
- }`}
709
- onClick={() => onEngineChange && onEngineChange(value)}
1037
+ className="run-query-btn"
1038
+ onClick={onRunQuery}
1039
+ disabled={!canRunQuery || queryLoading}
710
1040
  >
711
- {label}
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
  }