datajunction-ui
Version:
DataJunction UI
1,161 lines (1,083 loc) • 41.7 kB
JSX
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
const ENGINE_OPTIONS = [
{ value: null, label: 'Auto' },
{ value: 'druid', label: 'Druid' },
{ value: 'trino', label: 'Trino' },
];
// MIME type used to carry a dimension name during drag-to-filter.
const DIM_DRAG_MIME = 'application/x-dj-dimension';
// Append a filter expression onto an in-progress one, ANDing if non-empty.
const andAppend = (prev, expr) =>
(prev.trim() ? prev.trimEnd() + ' AND ' : '') + expr;
/**
* Label that clips the START under truncation, keeping the END visible:
* "…erience_f__cart_initiation". Metric/dimension names share long common
* prefixes, so a normal right-side ellipsis hides the only part that differs;
* clipping the start surfaces the distinguishing tail. The text stays a single
* node (the CSS direction trick puts the ellipsis on the left), so the full
* value remains in the title tooltip and queryable by tests.
*/
function TailLabel({ text }) {
return (
<span className="chip-label chip-label-clip" title={text}>
<bdi className="chip-label-inner">{text}</bdi>
</span>
);
}
/**
* SelectionPanel - Browse and select metrics and dimensions
* Features selected items as chips at the top for visibility
* Includes cube preset loading for quick configuration
*/
export function SelectionPanel({
metrics,
selectedMetrics,
onMetricsChange,
dimensions,
selectedDimensions,
onDimensionsChange,
loading,
cubes = [],
onLoadCubePreset,
loadedCubeName = null, // Managed by parent for URL persistence
onClearSelection,
filters = [],
onFiltersChange,
selectedEngine = null,
onEngineChange,
onRunQuery,
canRunQuery = false,
queryLoading = false,
compatibleMetrics = null, // Set<string> of compatible metric names, or null if no filter
}) {
const [metricsSearch, setMetricsSearch] = useState('');
const [dimensionsSearch, setDimensionsSearch] = useState('');
const [expandedNamespaces, setExpandedNamespaces] = useState(new Set());
const [expandedDimGroups, setExpandedDimGroups] = useState(new Set());
const [expandedRolePaths, setExpandedRolePaths] = useState(new Set());
const [showCubeDropdown, setShowCubeDropdown] = useState(false);
const [cubeSearch, setCubeSearch] = useState('');
const [metricsChipsExpanded, setMetricsChipsExpanded] = useState(false);
const [dimensionsChipsExpanded, setDimensionsChipsExpanded] = useState(false);
const [filterInput, setFilterInput] = useState('');
// Highlights the Filters section while a dimension is dragged over it.
const [filterDropActive, setFilterDropActive] = useState(false);
const [split1, setSplit1] = useState(35); // metrics / dims boundary (%)
const [split2, setSplit2] = useState(65); // dims / filters boundary (%)
const [split3, setSplit3] = useState(85); // filters / engine+run boundary (%)
const prevSearchRef = useRef('');
const cubeDropdownRef = useRef(null);
const metricsSearchRef = useRef(null);
const dimensionsSearchRef = useRef(null);
const filterInputRef = useRef(null);
const sectionsRef = useRef(null);
const dragRef = useRef(null);
const splitRef = useRef({ split1: 35, split2: 65, split3: 85 });
useEffect(() => {
splitRef.current = { split1, split2, split3 };
}, [split1, split2, split3]);
// Threshold for showing expand/collapse button
const CHIPS_COLLAPSE_THRESHOLD = 8;
// Find the loaded cube object from the name
const loadedCube = useMemo(() => {
if (!loadedCubeName) return null;
return (
cubes.find(c => c.name === loadedCubeName) || { name: loadedCubeName }
);
}, [loadedCubeName, cubes]);
// Close cube dropdown when clicking outside
useEffect(() => {
const handleClickOutside = event => {
if (
cubeDropdownRef.current &&
!cubeDropdownRef.current.contains(event.target)
) {
setShowCubeDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Filter cubes by search (GraphQL returns display_name)
const filteredCubes = useMemo(() => {
const search = cubeSearch.toLowerCase().trim();
if (!search) return cubes;
return cubes.filter(cube => {
const name = cube.name || '';
const displayName = cube.display_name || '';
return (
name.toLowerCase().includes(search) ||
displayName.toLowerCase().includes(search)
);
});
}, [cubes, cubeSearch]);
// Get short name from full metric name
const getShortName = fullName => {
const parts = fullName.split('.');
return parts[parts.length - 1];
};
// Get namespace from full metric name
const getNamespace = fullName => {
const parts = fullName.split('.');
return parts.length > 1 ? parts.slice(0, -1).join('.') : 'default';
};
// Group metrics by namespace (e.g., "default.cube" -> "default")
const groupedMetrics = useMemo(() => {
const groups = {};
metrics.forEach(metric => {
const namespace = getNamespace(metric);
if (!groups[namespace]) {
groups[namespace] = [];
}
groups[namespace].push(metric);
});
return groups;
}, [metrics]);
// Filter and sort namespaces/metrics by search relevance
const { filteredGroups, sortedNamespaces } = useMemo(() => {
const search = metricsSearch.trim().toLowerCase();
if (!search) {
const namespaces = Object.keys(groupedMetrics).sort();
return { filteredGroups: groupedMetrics, sortedNamespaces: namespaces };
}
const filtered = {};
Object.entries(groupedMetrics).forEach(([namespace, items]) => {
const matchingItems = items.filter(m => m.toLowerCase().includes(search));
if (matchingItems.length > 0) {
matchingItems.sort((a, b) => {
const aShort = getShortName(a).toLowerCase();
const bShort = getShortName(b).toLowerCase();
const aPrefix = aShort.startsWith(search);
const bPrefix = bShort.startsWith(search);
if (aPrefix && !bPrefix) return -1;
if (!aPrefix && bPrefix) return 1;
return aShort.localeCompare(bShort);
});
filtered[namespace] = matchingItems;
}
});
const namespaces = Object.keys(filtered).sort((a, b) => {
const aLower = a.toLowerCase();
const bLower = b.toLowerCase();
const aPrefix = aLower.startsWith(search);
const bPrefix = bLower.startsWith(search);
if (aPrefix && !bPrefix) return -1;
if (!aPrefix && bPrefix) return 1;
const aContains = aLower.includes(search);
const bContains = bLower.includes(search);
if (aContains && !bContains) return -1;
if (!aContains && bContains) return 1;
const aCount = filtered[a].length;
const bCount = filtered[b].length;
if (aCount !== bCount) return bCount - aCount;
return aLower.localeCompare(bLower);
});
return { filteredGroups: filtered, sortedNamespaces: namespaces };
}, [groupedMetrics, metricsSearch]);
// Auto-expand all matching namespaces when search changes
useEffect(() => {
const currentSearch = metricsSearch.trim();
const prevSearch = prevSearchRef.current;
if (currentSearch && currentSearch !== prevSearch) {
setExpandedNamespaces(new Set(sortedNamespaces));
}
prevSearchRef.current = currentSearch;
}, [metricsSearch, sortedNamespaces]);
// Dedupe dimensions by name, keeping shortest path per name
const dedupedDimensions = useMemo(() => {
const byName = new Map();
dimensions.forEach(d => {
if (!d.name) return;
const existing = byName.get(d.name);
if (
!existing ||
(d.path?.length || 0) < (existing.path?.length || Infinity)
) {
byName.set(d.name, d);
}
});
return Array.from(byName.values());
}, [dimensions]);
// Extract role path from a dimension name, e.g. "foo.bar[a->b->c]" → "a->b->c" (or null)
const getRolePath = name => {
const match = name.match(/\[([^\]]+)\]$/);
return match ? match[1] : null;
};
// Format a role path for display: "a->b->c" → "via a → b → c"
const formatRolePath = roleKey =>
roleKey ? 'via ' + roleKey.replace(/->/g, ' → ') : 'direct';
// Count hops in a role path string
const rolePathHops = roleKey => (roleKey ? roleKey.split('->').length : 0);
// Group dimensions by node, then by role path within each node
const groupedDimensions = useMemo(() => {
const search = dimensionsSearch.trim().toLowerCase();
const nodeMap = new Map();
dedupedDimensions.forEach(d => {
if (!d.name) return;
const nodeKey =
d.path?.length > 0
? d.path[d.path.length - 1]
: d.name.split('.').slice(0, -1).join('.');
const distance = Math.max(0, d.path ? d.path.length - 1 : 0);
const roleKey = getRolePath(d.name); // e.g. "title_deal_window->title" or null
if (!nodeMap.has(nodeKey)) {
nodeMap.set(nodeKey, {
nodeKey,
minDistance: distance,
rolePathMap: new Map(),
});
}
const node = nodeMap.get(nodeKey);
node.minDistance = Math.min(node.minDistance, distance);
if (!node.rolePathMap.has(roleKey)) {
node.rolePathMap.set(roleKey, { roleKey, dimensions: [] });
}
node.rolePathMap.get(roleKey).dimensions.push(d);
});
let groupsArray = Array.from(nodeMap.values()).map(node => {
// Sort role paths: direct (null) first, then by hop count, then alphabetically
const rolePaths = Array.from(node.rolePathMap.values()).sort(
(a, b) =>
rolePathHops(a.roleKey) - rolePathHops(b.roleKey) ||
(a.roleKey || '').localeCompare(b.roleKey || ''),
);
// Sort dims within each role path alphabetically
rolePaths.forEach(rp => {
rp.dimensions.sort((a, b) => a.name.localeCompare(b.name));
});
return {
nodeKey: node.nodeKey,
minDistance: node.minDistance,
rolePaths,
totalCount: rolePaths.reduce((n, rp) => n + rp.dimensions.length, 0),
};
});
// Apply search: filter within role paths, prune empty role paths and nodes
if (search) {
groupsArray = groupsArray
.map(group => ({
...group,
rolePaths: group.rolePaths
.map(rp => ({
...rp,
dimensions: rp.dimensions.filter(d => {
const lower = d.name.toLowerCase();
return (
lower.includes(search) ||
d.name.split('.').pop().toLowerCase().includes(search)
);
}),
}))
.filter(
rp =>
rp.dimensions.length > 0 ||
(rp.roleKey || '').toLowerCase().includes(search),
),
}))
.filter(
group =>
group.rolePaths.length > 0 ||
group.nodeKey.toLowerCase().includes(search),
)
.map(group => ({
...group,
totalCount: group.rolePaths.reduce(
(n, rp) => n + rp.dimensions.length,
0,
),
}));
}
// Sort node groups by distance, then alphabetically
groupsArray.sort(
(a, b) =>
a.minDistance - b.minDistance || a.nodeKey.localeCompare(b.nodeKey),
);
return groupsArray;
}, [dedupedDimensions, dimensionsSearch]);
// Auto-expand on first load and when searching
useEffect(() => {
if (groupedDimensions.length === 0) return;
if (dimensionsSearch.trim()) {
// Expand everything with matches
setExpandedDimGroups(new Set(groupedDimensions.map(g => g.nodeKey)));
setExpandedRolePaths(
new Set(
groupedDimensions.flatMap(g =>
g.rolePaths.map(rp => `${g.nodeKey}::${rp.roleKey}`),
),
),
);
} else {
// On first load: expand distance-0 node groups and their direct (null) role paths
setExpandedDimGroups(prev => {
if (prev.size > 0) return prev;
return new Set(
groupedDimensions
.filter(g => g.minDistance === 0)
.map(g => g.nodeKey),
);
});
setExpandedRolePaths(prev => {
if (prev.size > 0) return prev;
return new Set(
groupedDimensions
.filter(g => g.minDistance === 0)
.map(g => `${g.nodeKey}::null`),
);
});
}
}, [groupedDimensions, dimensionsSearch]);
// Get display name for dimension (last 2 segments)
const getDimDisplayName = fullName => {
const parts = (fullName || '').split('.');
return parts.slice(-2).join('.');
};
const toggleNamespace = namespace => {
setExpandedNamespaces(prev => {
const next = new Set(prev);
if (next.has(namespace)) {
next.delete(namespace);
} else {
next.add(namespace);
}
return next;
});
};
const toggleDimGroup = nodeKey => {
setExpandedDimGroups(prev => {
const next = new Set(prev);
if (next.has(nodeKey)) {
next.delete(nodeKey);
} else {
next.add(nodeKey);
}
return next;
});
};
const toggleRolePath = (nodeKey, roleKey) => {
const key = `${nodeKey}::${roleKey}`;
setExpandedRolePaths(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const getDimGroupShortName = nodeKey => nodeKey.split('.').pop();
const handleDividerMouseDown = useCallback((e, divider) => {
e.preventDefault();
const startSplit =
divider === 1
? splitRef.current.split1
: divider === 2
? splitRef.current.split2
: splitRef.current.split3;
dragRef.current = { divider, startY: e.clientY, startSplit };
}, []);
useEffect(() => {
const onMouseMove = e => {
if (!dragRef.current || !sectionsRef.current) return;
const height = sectionsRef.current.getBoundingClientRect().height;
const deltaPct = ((e.clientY - dragRef.current.startY) / height) * 100;
const { divider, startSplit } = dragRef.current;
const { split1, split2, split3 } = splitRef.current;
if (divider === 1) {
setSplit1(Math.max(10, Math.min(split2 - 15, startSplit + deltaPct)));
} else if (divider === 2) {
setSplit2(
Math.max(split1 + 15, Math.min(split3 - 10, startSplit + deltaPct)),
);
} else {
setSplit3(Math.max(split2 + 10, Math.min(95, startSplit + deltaPct)));
}
};
const onMouseUp = () => {
dragRef.current = null;
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, []);
const toggleMetric = metric => {
if (selectedMetrics.includes(metric)) {
onMetricsChange(selectedMetrics.filter(m => m !== metric));
} else {
onMetricsChange([...selectedMetrics, metric]);
}
};
const removeMetric = metric => {
onMetricsChange(selectedMetrics.filter(m => m !== metric));
};
const toggleDimension = dimName => {
if (selectedDimensions.includes(dimName)) {
onDimensionsChange(selectedDimensions.filter(d => d !== dimName));
} else {
onDimensionsChange([...selectedDimensions, dimName]);
}
};
const removeDimension = dimName => {
onDimensionsChange(selectedDimensions.filter(d => d !== dimName));
};
const selectAllInNamespace = (namespace, items) => {
const newSelection = [...new Set([...selectedMetrics, ...items])];
onMetricsChange(newSelection);
};
const deselectAllInNamespace = (namespace, items) => {
onMetricsChange(selectedMetrics.filter(m => !items.includes(m)));
};
const handleCubeSelect = cube => {
if (onLoadCubePreset) {
onLoadCubePreset(cube.name);
}
// loadedCubeName is now managed by parent via onLoadCubePreset
setShowCubeDropdown(false);
setCubeSearch('');
};
const clearSelection = () => {
if (onClearSelection) {
onClearSelection(); // Parent handles clearing metrics, dimensions, and cube
} else {
onMetricsChange([]);
onDimensionsChange([]);
}
};
const handleAddFilter = () => {
const trimmed = filterInput.trim();
if (trimmed && !filters.includes(trimmed) && onFiltersChange) {
onFiltersChange([...filters, trimmed]);
setFilterInput('');
}
};
const handleFilterKeyDown = e => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddFilter();
}
};
const handleRemoveFilter = filterToRemove => {
if (onFiltersChange) {
onFiltersChange(filters.filter(f => f !== filterToRemove));
}
};
// Click a filter chip to edit it: pull it back into the input (preserving any
// in-progress text by ANDing onto it) and remove the chip.
const handleEditFilter = filter => {
setFilterInput(prev => andAppend(prev, filter));
if (onFiltersChange) {
onFiltersChange(filters.filter(f => f !== filter));
}
filterInputRef.current?.focus();
};
// Append a dimension to the filter input (ANDing onto any existing expression)
// then focus so the user can type the operator/value. Functional update keeps
// it correct whether called from a click or a drag-and-drop handler.
const appendDimToFilterInput = dimName => {
setFilterInput(prev => andAppend(prev, dimName + ' '));
filterInputRef.current?.focus();
};
const addDimAsFilter = (e, dimName) => {
e.preventDefault();
e.stopPropagation();
appendDimToFilterInput(dimName);
};
// Drag a dimension chip/row onto the Filters section to start filtering on it.
const handleDimDragStart = (e, dimName) => {
e.dataTransfer.setData(DIM_DRAG_MIME, dimName);
// Plain-text fallback so the drag has a sensible payload everywhere.
e.dataTransfer.setData('text/plain', dimName);
e.dataTransfer.effectAllowed = 'copy';
};
const handleFilterDragOver = e => {
if (e.dataTransfer.types.includes(DIM_DRAG_MIME)) {
e.preventDefault(); // allow drop
e.dataTransfer.dropEffect = 'copy';
if (!filterDropActive) setFilterDropActive(true);
}
};
const handleFilterDragLeave = e => {
// Ignore leaves into child elements of the filters section.
if (!e.currentTarget.contains(e.relatedTarget)) {
setFilterDropActive(false);
}
};
const handleFilterDrop = e => {
const dimName = e.dataTransfer.getData(DIM_DRAG_MIME);
if (dimName) {
e.preventDefault();
appendDimToFilterInput(dimName);
}
setFilterDropActive(false);
};
return (
<div className="selection-panel">
{/* Cube Preset Dropdown */}
{cubes.length > 0 && (
<div className="cube-preset-section" ref={cubeDropdownRef}>
<div className="preset-row">
<button
className={`preset-button ${loadedCube ? 'has-preset' : ''}`}
onClick={() => setShowCubeDropdown(!showCubeDropdown)}
>
<span className="preset-icon">{loadedCube ? '📦' : '📂'}</span>
<span className="preset-label">
{loadedCube
? loadedCube.display_name ||
(loadedCube.name
? loadedCube.name.split('.').pop()
: 'Cube')
: 'Load from Cube'}
</span>
<span className="dropdown-arrow">
{showCubeDropdown ? '▲' : '▼'}
</span>
</button>
{(selectedMetrics.length > 0 || selectedDimensions.length > 0) && (
<button className="clear-all-btn" onClick={clearSelection}>
Clear
</button>
)}
</div>
{showCubeDropdown && (
<div className="cube-dropdown">
<input
type="text"
className="cube-search"
placeholder="Search cubes..."
value={cubeSearch}
onChange={e => setCubeSearch(e.target.value)}
autoFocus
/>
<div className="cube-list">
{filteredCubes.length === 0 ? (
<div className="cube-empty">
{cubeSearch
? 'No cubes match your search'
: 'No cubes available'}
</div>
) : (
filteredCubes.map(cube => (
<button
key={cube.name}
className={`cube-option ${
loadedCubeName === cube.name ? 'selected' : ''
}`}
onClick={() => handleCubeSelect(cube)}
>
<span className="cube-name">
{cube.display_name ||
(cube.name ? cube.name.split('.').pop() : 'Unknown')}
</span>
<span className="cube-info">{cube.name}</span>
{loadedCubeName === cube.name && (
<span className="cube-selected-icon">✓</span>
)}
</button>
))
)}
</div>
</div>
)}
</div>
)}
{/* Resizable sections */}
<div className="resizable-sections" ref={sectionsRef}>
{/* Metrics Section */}
<div className="selection-section" style={{ flex: split1 }}>
<div className="section-header">
<h3>Metrics</h3>
<span className="selection-count">
{selectedMetrics.length} selected
</span>
</div>
{/* Combined Chips + Search Input */}
<div
className="combobox-input"
onClick={() => metricsSearchRef.current?.focus()}
>
{selectedMetrics.length > 0 && (
<div
className={`combobox-chips ${
selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD
? metricsChipsExpanded
? 'expanded'
: 'collapsed'
: ''
}`}
>
{selectedMetrics.map(metric => (
<span key={metric} className="selected-chip metric-chip">
<TailLabel text={getShortName(metric)} />
<button
className="chip-remove"
onClick={e => {
e.stopPropagation();
removeMetric(metric);
}}
title={`Remove ${getShortName(metric)}`}
>
×
</button>
</span>
))}
</div>
)}
<div className="combobox-input-row">
<input
ref={metricsSearchRef}
type="text"
className="combobox-search"
placeholder="Search metrics..."
value={metricsSearch}
onChange={e => setMetricsSearch(e.target.value)}
onClick={e => e.stopPropagation()}
/>
{selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD && (
<button
className="combobox-action"
onClick={e => {
e.stopPropagation();
setMetricsChipsExpanded(!metricsChipsExpanded);
}}
>
{metricsChipsExpanded ? 'Show less' : 'Show all'}
</button>
)}
{selectedMetrics.length > 0 && (
<button
className="combobox-action"
onClick={e => {
e.stopPropagation();
onMetricsChange([]);
}}
>
Clear
</button>
)}
</div>
</div>
<div className="selection-list">
{sortedNamespaces.map(namespace => {
const items = filteredGroups[namespace];
const isExpanded = expandedNamespaces.has(namespace);
const selectedInNamespace = items.filter(m =>
selectedMetrics.includes(m),
).length;
return (
<div key={namespace} className="namespace-group">
<div
className="namespace-header"
onClick={() => toggleNamespace(namespace)}
>
<span className="expand-icon">
{isExpanded ? '▼' : '▶'}
</span>
<span className="namespace-name">{namespace}</span>
<span className="namespace-count">
{selectedInNamespace > 0 && (
<span className="selected-badge">
{selectedInNamespace}
</span>
)}
{items.length}
</span>
</div>
{isExpanded && (
<div className="namespace-items">
<div className="namespace-actions">
<button
type="button"
className="select-all-btn"
onClick={() => selectAllInNamespace(namespace, items)}
>
Select all
</button>
<button
type="button"
className="select-all-btn"
onClick={() =>
deselectAllInNamespace(namespace, items)
}
>
Clear
</button>
</div>
{items.map(metric => {
const isIncompatible =
compatibleMetrics !== null &&
!compatibleMetrics.has(metric) &&
!selectedMetrics.includes(metric);
return (
<label
key={metric}
className={`selection-item${
isIncompatible ? ' metric-incompatible' : ''
}`}
title={
isIncompatible
? 'Not compatible with selected dimensions'
: metric
}
>
<input
type="checkbox"
checked={selectedMetrics.includes(metric)}
onChange={() => toggleMetric(metric)}
/>
<span className="item-name">
{getShortName(metric)}
</span>
{compatibleMetrics !== null &&
compatibleMetrics.has(metric) &&
!selectedMetrics.includes(metric) && (
<span
className="metric-compatible-badge"
title="Compatible with selected dimensions"
>
✓
</span>
)}
</label>
);
})}
</div>
)}
</div>
);
})}
{sortedNamespaces.length === 0 && (
<div className="empty-list">
{metricsSearch
? 'No metrics match your search'
: 'No metrics available'}
</div>
)}
</div>
</div>
{/* Draggable Divider 1: metrics / dims */}
<div
className="section-divider draggable-divider"
onMouseDown={e => handleDividerMouseDown(e, 1)}
/>
{/* Dimensions Section */}
<div className="selection-section" style={{ flex: split2 - split1 }}>
<div className="section-header">
<h3>Dimensions</h3>
<span className="selection-count">
{selectedDimensions.length} selected
{dimensions.length > 0 && ` / ${dimensions.length} available`}
</span>
</div>
{selectedMetrics.length === 0 ? (
<div className="empty-list hint">
Select metrics to see available dimensions
</div>
) : loading ? (
<div className="empty-list">Loading dimensions...</div>
) : (
<>
{/* Combined Chips + Search Input */}
<div
className="combobox-input"
onClick={() => dimensionsSearchRef.current?.focus()}
>
{selectedDimensions.length > 0 && (
<div
className={`combobox-chips ${
selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD
? dimensionsChipsExpanded
? 'expanded'
: 'collapsed'
: ''
}`}
>
{selectedDimensions.map(dimName => (
<span
key={dimName}
className="selected-chip dimension-chip draggable-dim"
title={`${dimName} — drag to Filters to filter on it`}
draggable
onDragStart={e => handleDimDragStart(e, dimName)}
>
<TailLabel text={getDimDisplayName(dimName)} />
<button
className="chip-remove"
onClick={e => {
e.stopPropagation();
removeDimension(dimName);
}}
title={`Remove ${getDimDisplayName(dimName)}`}
>
×
</button>
</span>
))}
</div>
)}
<div className="combobox-input-row">
<input
ref={dimensionsSearchRef}
type="text"
className="combobox-search"
placeholder="Search dimensions..."
value={dimensionsSearch}
onChange={e => setDimensionsSearch(e.target.value)}
onClick={e => e.stopPropagation()}
/>
{selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD && (
<button
className="combobox-action"
onClick={e => {
e.stopPropagation();
setDimensionsChipsExpanded(!dimensionsChipsExpanded);
}}
>
{dimensionsChipsExpanded ? 'Show less' : 'Show all'}
</button>
)}
{selectedDimensions.length > 0 && (
<button
className="combobox-action"
onClick={e => {
e.stopPropagation();
onDimensionsChange([]);
}}
>
Clear
</button>
)}
</div>
</div>
<div className="selection-list dimensions-list">
{groupedDimensions.map(group => {
const nodeExpanded = expandedDimGroups.has(group.nodeKey);
return (
<div key={group.nodeKey} className="dim-group">
<div
className="dim-group-header"
onClick={() => toggleDimGroup(group.nodeKey)}
title={group.nodeKey}
>
<span className="expand-icon">
{nodeExpanded ? '▼' : '▶'}
</span>
<span className="dim-group-name">
{getDimGroupShortName(group.nodeKey)}
</span>
<span className="dim-group-count">
{group.totalCount}
</span>
</div>
{nodeExpanded &&
group.rolePaths.map(rp => {
const rpKey = `${group.nodeKey}::${rp.roleKey}`;
const rpExpanded = expandedRolePaths.has(rpKey);
const hops = rolePathHops(rp.roleKey);
const hopsLabel =
hops === 0
? 'direct'
: `${hops} hop${hops > 1 ? 's' : ''}`;
return (
<div key={rpKey} className="dim-role-group">
<div
className="dim-role-header"
onClick={() =>
toggleRolePath(group.nodeKey, rp.roleKey)
}
>
<span className="expand-icon">
{rpExpanded ? '▼' : '▶'}
</span>
<span className="dim-role-label">
{formatRolePath(rp.roleKey)}
</span>
<span className="dim-group-meta">
{hopsLabel}
</span>
<span className="dim-group-count">
{rp.dimensions.length}
</span>
</div>
{rpExpanded &&
rp.dimensions.map(dim => (
<label
key={dim.name}
className="selection-item dimension-item dim-role-item draggable-dim"
title={`${dim.name} — drag to Filters to filter on it`}
draggable
onDragStart={e =>
handleDimDragStart(e, dim.name)
}
>
<input
type="checkbox"
checked={selectedDimensions.includes(
dim.name,
)}
onChange={() => toggleDimension(dim.name)}
/>
<div className="dimension-info">
<span className="item-name">
{getDimDisplayName(
dim.name.replace(/\[[^\]]*\]$/, ''),
)}
</span>
</div>
<button
className="dim-filter-btn"
title={`Add "${dim.name}" to filters`}
onClick={e => addDimAsFilter(e, dim.name)}
>
+ filter
</button>
</label>
))}
</div>
);
})}
</div>
);
})}
{groupedDimensions.length === 0 && (
<div className="empty-list">
{dimensionsSearch
? 'No dimensions match your search'
: 'No shared dimensions'}
</div>
)}
</div>
</>
)}
</div>
{/* Draggable Divider 2: dims / filters */}
<div
className="section-divider draggable-divider"
onMouseDown={e => handleDividerMouseDown(e, 2)}
/>
{/* Filters Section (drop target for dragged dimensions) */}
<div
className={`selection-section filters-section${
filterDropActive ? ' drop-active' : ''
}`}
style={{ flex: split3 - split2 }}
onDragOver={handleFilterDragOver}
onDragLeave={handleFilterDragLeave}
onDrop={handleFilterDrop}
>
<div className="section-header">
<h3>Filters</h3>
<span className="selection-count">{filters.length} applied</span>
</div>
{filterDropActive && (
<div className="filter-drop-hint">
Drop to filter on this dimension
</div>
)}
{/* Filter chips */}
{filters.length > 0 && (
<div className="filter-chips-container">
{filters.map((filter, idx) => (
<span key={idx} className="filter-chip">
<button
type="button"
className="filter-chip-text filter-chip-edit"
onClick={() => handleEditFilter(filter)}
title={`${filter} — click to edit`}
>
{filter}
</button>
<button
className="filter-chip-remove"
onClick={() => handleRemoveFilter(filter)}
title="Remove filter"
>
×
</button>
</span>
))}
</div>
)}
{/* Filter input */}
<div className="filter-input-container">
<input
ref={filterInputRef}
type="text"
className="filter-input"
placeholder="e.g. v3.date.date_id >= '2024-01-01'"
value={filterInput}
onChange={e => setFilterInput(e.target.value)}
onKeyDown={handleFilterKeyDown}
/>
<button
className="filter-add-btn"
onClick={handleAddFilter}
disabled={!filterInput.trim()}
>
Add
</button>
</div>
</div>
{/* Draggable Divider 3: filters / engine+run */}
<div
className="section-divider draggable-divider"
onMouseDown={e => handleDividerMouseDown(e, 3)}
/>
{/* Engine + Run Query Section */}
<div
className="selection-section engine-run-section"
style={{ flex: 100 - split3 }}
>
{/* Engine Selection */}
<div className="engine-section">
<span className="engine-label">Engine</span>
<div className="engine-pills">
{ENGINE_OPTIONS.map(({ value, label }) => (
<button
key={label}
className={`engine-pill${
selectedEngine === value ? ' active' : ''
}`}
onClick={() => onEngineChange && onEngineChange(value)}
>
{label}
</button>
))}
</div>
</div>
{/* Run Query Section */}
<div className="run-query-section">
<button
className="run-query-btn"
onClick={onRunQuery}
disabled={!canRunQuery || queryLoading}
>
{queryLoading ? (
<>
<span className="spinner small" />
Running...
</>
) : (
<>
<span className="run-icon">▶</span>
Run Query
</>
)}
</button>
{!canRunQuery && (
<span className="run-hint">
Select at least one metric to run a query
</span>
)}
</div>
</div>
</div>
{/* end resizable-sections */}
</div>
);
}
export default SelectionPanel;