datajunction-ui
Version:
DataJunction UI
1 lines • 189 kB
Source Map (JSON)
{"version":3,"file":"index-CfXXlICc.cjs","sources":["../src/app/pages/QueryPlannerPage/SelectionPanel.jsx","../src/app/pages/QueryPlannerPage/ResultsView.jsx","../src/app/pages/QueryPlannerPage/index.jsx"],"sourcesContent":["import { useState, useMemo, useEffect, useRef, useCallback } from 'react';\n\nconst ENGINE_OPTIONS = [\n { value: null, label: 'Auto' },\n { value: 'druid', label: 'Druid' },\n { value: 'trino', label: 'Trino' },\n];\n\n// MIME type used to carry a dimension name during drag-to-filter.\nconst DIM_DRAG_MIME = 'application/x-dj-dimension';\n\n// Append a filter expression onto an in-progress one, ANDing if non-empty.\nconst andAppend = (prev, expr) =>\n (prev.trim() ? prev.trimEnd() + ' AND ' : '') + expr;\n\n/**\n * Label that clips the START under truncation, keeping the END visible:\n * \"…erience_f__cart_initiation\". Metric/dimension names share long common\n * prefixes, so a normal right-side ellipsis hides the only part that differs;\n * clipping the start surfaces the distinguishing tail. The text stays a single\n * node (the CSS direction trick puts the ellipsis on the left), so the full\n * value remains in the title tooltip and queryable by tests.\n */\nfunction TailLabel({ text }) {\n return (\n <span className=\"chip-label chip-label-clip\" title={text}>\n <bdi className=\"chip-label-inner\">{text}</bdi>\n </span>\n );\n}\n\n/**\n * SelectionPanel - Browse and select metrics and dimensions\n * Features selected items as chips at the top for visibility\n * Includes cube preset loading for quick configuration\n */\nexport function SelectionPanel({\n metrics,\n selectedMetrics,\n onMetricsChange,\n dimensions,\n selectedDimensions,\n onDimensionsChange,\n loading,\n cubes = [],\n onLoadCubePreset,\n loadedCubeName = null, // Managed by parent for URL persistence\n onClearSelection,\n filters = [],\n onFiltersChange,\n selectedEngine = null,\n onEngineChange,\n onRunQuery,\n canRunQuery = false,\n queryLoading = false,\n compatibleMetrics = null, // Set<string> of compatible metric names, or null if no filter\n}) {\n const [metricsSearch, setMetricsSearch] = useState('');\n const [dimensionsSearch, setDimensionsSearch] = useState('');\n const [expandedNamespaces, setExpandedNamespaces] = useState(new Set());\n const [expandedDimGroups, setExpandedDimGroups] = useState(new Set());\n const [expandedRolePaths, setExpandedRolePaths] = useState(new Set());\n const [showCubeDropdown, setShowCubeDropdown] = useState(false);\n const [cubeSearch, setCubeSearch] = useState('');\n const [metricsChipsExpanded, setMetricsChipsExpanded] = useState(false);\n const [dimensionsChipsExpanded, setDimensionsChipsExpanded] = useState(false);\n const [filterInput, setFilterInput] = useState('');\n // Highlights the Filters section while a dimension is dragged over it.\n const [filterDropActive, setFilterDropActive] = useState(false);\n const [split1, setSplit1] = useState(35); // metrics / dims boundary (%)\n const [split2, setSplit2] = useState(65); // dims / filters boundary (%)\n const [split3, setSplit3] = useState(85); // filters / engine+run boundary (%)\n const prevSearchRef = useRef('');\n const cubeDropdownRef = useRef(null);\n const metricsSearchRef = useRef(null);\n const dimensionsSearchRef = useRef(null);\n const filterInputRef = useRef(null);\n const sectionsRef = useRef(null);\n const dragRef = useRef(null);\n const splitRef = useRef({ split1: 35, split2: 65, split3: 85 });\n useEffect(() => {\n splitRef.current = { split1, split2, split3 };\n }, [split1, split2, split3]);\n\n // Threshold for showing expand/collapse button\n const CHIPS_COLLAPSE_THRESHOLD = 8;\n\n // Find the loaded cube object from the name\n const loadedCube = useMemo(() => {\n if (!loadedCubeName) return null;\n return (\n cubes.find(c => c.name === loadedCubeName) || { name: loadedCubeName }\n );\n }, [loadedCubeName, cubes]);\n\n // Close cube dropdown when clicking outside\n useEffect(() => {\n const handleClickOutside = event => {\n if (\n cubeDropdownRef.current &&\n !cubeDropdownRef.current.contains(event.target)\n ) {\n setShowCubeDropdown(false);\n }\n };\n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n }, []);\n\n // Filter cubes by search (GraphQL returns display_name)\n const filteredCubes = useMemo(() => {\n const search = cubeSearch.toLowerCase().trim();\n if (!search) return cubes;\n return cubes.filter(cube => {\n const name = cube.name || '';\n const displayName = cube.display_name || '';\n return (\n name.toLowerCase().includes(search) ||\n displayName.toLowerCase().includes(search)\n );\n });\n }, [cubes, cubeSearch]);\n\n // Get short name from full metric name\n const getShortName = fullName => {\n const parts = fullName.split('.');\n return parts[parts.length - 1];\n };\n\n // Get namespace from full metric name\n const getNamespace = fullName => {\n const parts = fullName.split('.');\n return parts.length > 1 ? parts.slice(0, -1).join('.') : 'default';\n };\n\n // Group metrics by namespace (e.g., \"default.cube\" -> \"default\")\n const groupedMetrics = useMemo(() => {\n const groups = {};\n metrics.forEach(metric => {\n const namespace = getNamespace(metric);\n if (!groups[namespace]) {\n groups[namespace] = [];\n }\n groups[namespace].push(metric);\n });\n return groups;\n }, [metrics]);\n\n // Filter and sort namespaces/metrics by search relevance\n const { filteredGroups, sortedNamespaces } = useMemo(() => {\n const search = metricsSearch.trim().toLowerCase();\n\n if (!search) {\n const namespaces = Object.keys(groupedMetrics).sort();\n return { filteredGroups: groupedMetrics, sortedNamespaces: namespaces };\n }\n\n const filtered = {};\n Object.entries(groupedMetrics).forEach(([namespace, items]) => {\n const matchingItems = items.filter(m => m.toLowerCase().includes(search));\n if (matchingItems.length > 0) {\n matchingItems.sort((a, b) => {\n const aShort = getShortName(a).toLowerCase();\n const bShort = getShortName(b).toLowerCase();\n const aPrefix = aShort.startsWith(search);\n const bPrefix = bShort.startsWith(search);\n if (aPrefix && !bPrefix) return -1;\n if (!aPrefix && bPrefix) return 1;\n return aShort.localeCompare(bShort);\n });\n filtered[namespace] = matchingItems;\n }\n });\n\n const namespaces = Object.keys(filtered).sort((a, b) => {\n const aLower = a.toLowerCase();\n const bLower = b.toLowerCase();\n const aPrefix = aLower.startsWith(search);\n const bPrefix = bLower.startsWith(search);\n if (aPrefix && !bPrefix) return -1;\n if (!aPrefix && bPrefix) return 1;\n const aContains = aLower.includes(search);\n const bContains = bLower.includes(search);\n if (aContains && !bContains) return -1;\n if (!aContains && bContains) return 1;\n const aCount = filtered[a].length;\n const bCount = filtered[b].length;\n if (aCount !== bCount) return bCount - aCount;\n return aLower.localeCompare(bLower);\n });\n\n return { filteredGroups: filtered, sortedNamespaces: namespaces };\n }, [groupedMetrics, metricsSearch]);\n\n // Auto-expand all matching namespaces when search changes\n useEffect(() => {\n const currentSearch = metricsSearch.trim();\n const prevSearch = prevSearchRef.current;\n if (currentSearch && currentSearch !== prevSearch) {\n setExpandedNamespaces(new Set(sortedNamespaces));\n }\n prevSearchRef.current = currentSearch;\n }, [metricsSearch, sortedNamespaces]);\n\n // Dedupe dimensions by name, keeping shortest path per name\n const dedupedDimensions = useMemo(() => {\n const byName = new Map();\n dimensions.forEach(d => {\n if (!d.name) return;\n const existing = byName.get(d.name);\n if (\n !existing ||\n (d.path?.length || 0) < (existing.path?.length || Infinity)\n ) {\n byName.set(d.name, d);\n }\n });\n return Array.from(byName.values());\n }, [dimensions]);\n\n // Extract role path from a dimension name, e.g. \"foo.bar[a->b->c]\" → \"a->b->c\" (or null)\n const getRolePath = name => {\n const match = name.match(/\\[([^\\]]+)\\]$/);\n return match ? match[1] : null;\n };\n\n // Format a role path for display: \"a->b->c\" → \"via a → b → c\"\n const formatRolePath = roleKey =>\n roleKey ? 'via ' + roleKey.replace(/->/g, ' → ') : 'direct';\n\n // Count hops in a role path string\n const rolePathHops = roleKey => (roleKey ? roleKey.split('->').length : 0);\n\n // Group dimensions by node, then by role path within each node\n const groupedDimensions = useMemo(() => {\n const search = dimensionsSearch.trim().toLowerCase();\n const nodeMap = new Map();\n\n dedupedDimensions.forEach(d => {\n if (!d.name) return;\n const nodeKey =\n d.path?.length > 0\n ? d.path[d.path.length - 1]\n : d.name.split('.').slice(0, -1).join('.');\n const distance = Math.max(0, d.path ? d.path.length - 1 : 0);\n const roleKey = getRolePath(d.name); // e.g. \"title_deal_window->title\" or null\n\n if (!nodeMap.has(nodeKey)) {\n nodeMap.set(nodeKey, {\n nodeKey,\n minDistance: distance,\n rolePathMap: new Map(),\n });\n }\n const node = nodeMap.get(nodeKey);\n node.minDistance = Math.min(node.minDistance, distance);\n\n if (!node.rolePathMap.has(roleKey)) {\n node.rolePathMap.set(roleKey, { roleKey, dimensions: [] });\n }\n node.rolePathMap.get(roleKey).dimensions.push(d);\n });\n\n let groupsArray = Array.from(nodeMap.values()).map(node => {\n // Sort role paths: direct (null) first, then by hop count, then alphabetically\n const rolePaths = Array.from(node.rolePathMap.values()).sort(\n (a, b) =>\n rolePathHops(a.roleKey) - rolePathHops(b.roleKey) ||\n (a.roleKey || '').localeCompare(b.roleKey || ''),\n );\n // Sort dims within each role path alphabetically\n rolePaths.forEach(rp => {\n rp.dimensions.sort((a, b) => a.name.localeCompare(b.name));\n });\n return {\n nodeKey: node.nodeKey,\n minDistance: node.minDistance,\n rolePaths,\n totalCount: rolePaths.reduce((n, rp) => n + rp.dimensions.length, 0),\n };\n });\n\n // Apply search: filter within role paths, prune empty role paths and nodes\n if (search) {\n groupsArray = groupsArray\n .map(group => ({\n ...group,\n rolePaths: group.rolePaths\n .map(rp => ({\n ...rp,\n dimensions: rp.dimensions.filter(d => {\n const lower = d.name.toLowerCase();\n return (\n lower.includes(search) ||\n d.name.split('.').pop().toLowerCase().includes(search)\n );\n }),\n }))\n .filter(\n rp =>\n rp.dimensions.length > 0 ||\n (rp.roleKey || '').toLowerCase().includes(search),\n ),\n }))\n .filter(\n group =>\n group.rolePaths.length > 0 ||\n group.nodeKey.toLowerCase().includes(search),\n )\n .map(group => ({\n ...group,\n totalCount: group.rolePaths.reduce(\n (n, rp) => n + rp.dimensions.length,\n 0,\n ),\n }));\n }\n\n // Sort node groups by distance, then alphabetically\n groupsArray.sort(\n (a, b) =>\n a.minDistance - b.minDistance || a.nodeKey.localeCompare(b.nodeKey),\n );\n\n return groupsArray;\n }, [dedupedDimensions, dimensionsSearch]);\n\n // Auto-expand on first load and when searching\n useEffect(() => {\n if (groupedDimensions.length === 0) return;\n if (dimensionsSearch.trim()) {\n // Expand everything with matches\n setExpandedDimGroups(new Set(groupedDimensions.map(g => g.nodeKey)));\n setExpandedRolePaths(\n new Set(\n groupedDimensions.flatMap(g =>\n g.rolePaths.map(rp => `${g.nodeKey}::${rp.roleKey}`),\n ),\n ),\n );\n } else {\n // On first load: expand distance-0 node groups and their direct (null) role paths\n setExpandedDimGroups(prev => {\n if (prev.size > 0) return prev;\n return new Set(\n groupedDimensions\n .filter(g => g.minDistance === 0)\n .map(g => g.nodeKey),\n );\n });\n setExpandedRolePaths(prev => {\n if (prev.size > 0) return prev;\n return new Set(\n groupedDimensions\n .filter(g => g.minDistance === 0)\n .map(g => `${g.nodeKey}::null`),\n );\n });\n }\n }, [groupedDimensions, dimensionsSearch]);\n\n // Get display name for dimension (last 2 segments)\n const getDimDisplayName = fullName => {\n const parts = (fullName || '').split('.');\n return parts.slice(-2).join('.');\n };\n\n const toggleNamespace = namespace => {\n setExpandedNamespaces(prev => {\n const next = new Set(prev);\n if (next.has(namespace)) {\n next.delete(namespace);\n } else {\n next.add(namespace);\n }\n return next;\n });\n };\n\n const toggleDimGroup = nodeKey => {\n setExpandedDimGroups(prev => {\n const next = new Set(prev);\n if (next.has(nodeKey)) {\n next.delete(nodeKey);\n } else {\n next.add(nodeKey);\n }\n return next;\n });\n };\n\n const toggleRolePath = (nodeKey, roleKey) => {\n const key = `${nodeKey}::${roleKey}`;\n setExpandedRolePaths(prev => {\n const next = new Set(prev);\n if (next.has(key)) {\n next.delete(key);\n } else {\n next.add(key);\n }\n return next;\n });\n };\n\n const getDimGroupShortName = nodeKey => nodeKey.split('.').pop();\n\n const handleDividerMouseDown = useCallback((e, divider) => {\n e.preventDefault();\n const startSplit =\n divider === 1\n ? splitRef.current.split1\n : divider === 2\n ? splitRef.current.split2\n : splitRef.current.split3;\n dragRef.current = { divider, startY: e.clientY, startSplit };\n }, []);\n\n useEffect(() => {\n const onMouseMove = e => {\n if (!dragRef.current || !sectionsRef.current) return;\n const height = sectionsRef.current.getBoundingClientRect().height;\n const deltaPct = ((e.clientY - dragRef.current.startY) / height) * 100;\n const { divider, startSplit } = dragRef.current;\n const { split1, split2, split3 } = splitRef.current;\n if (divider === 1) {\n setSplit1(Math.max(10, Math.min(split2 - 15, startSplit + deltaPct)));\n } else if (divider === 2) {\n setSplit2(\n Math.max(split1 + 15, Math.min(split3 - 10, startSplit + deltaPct)),\n );\n } else {\n setSplit3(Math.max(split2 + 10, Math.min(95, startSplit + deltaPct)));\n }\n };\n const onMouseUp = () => {\n dragRef.current = null;\n };\n window.addEventListener('mousemove', onMouseMove);\n window.addEventListener('mouseup', onMouseUp);\n return () => {\n window.removeEventListener('mousemove', onMouseMove);\n window.removeEventListener('mouseup', onMouseUp);\n };\n }, []);\n\n const toggleMetric = metric => {\n if (selectedMetrics.includes(metric)) {\n onMetricsChange(selectedMetrics.filter(m => m !== metric));\n } else {\n onMetricsChange([...selectedMetrics, metric]);\n }\n };\n\n const removeMetric = metric => {\n onMetricsChange(selectedMetrics.filter(m => m !== metric));\n };\n\n const toggleDimension = dimName => {\n if (selectedDimensions.includes(dimName)) {\n onDimensionsChange(selectedDimensions.filter(d => d !== dimName));\n } else {\n onDimensionsChange([...selectedDimensions, dimName]);\n }\n };\n\n const removeDimension = dimName => {\n onDimensionsChange(selectedDimensions.filter(d => d !== dimName));\n };\n\n const selectAllInNamespace = (namespace, items) => {\n const newSelection = [...new Set([...selectedMetrics, ...items])];\n onMetricsChange(newSelection);\n };\n\n const deselectAllInNamespace = (namespace, items) => {\n onMetricsChange(selectedMetrics.filter(m => !items.includes(m)));\n };\n\n const handleCubeSelect = cube => {\n if (onLoadCubePreset) {\n onLoadCubePreset(cube.name);\n }\n // loadedCubeName is now managed by parent via onLoadCubePreset\n setShowCubeDropdown(false);\n setCubeSearch('');\n };\n\n const clearSelection = () => {\n if (onClearSelection) {\n onClearSelection(); // Parent handles clearing metrics, dimensions, and cube\n } else {\n onMetricsChange([]);\n onDimensionsChange([]);\n }\n };\n\n const handleAddFilter = () => {\n const trimmed = filterInput.trim();\n if (trimmed && !filters.includes(trimmed) && onFiltersChange) {\n onFiltersChange([...filters, trimmed]);\n setFilterInput('');\n }\n };\n\n const handleFilterKeyDown = e => {\n if (e.key === 'Enter') {\n e.preventDefault();\n handleAddFilter();\n }\n };\n\n const handleRemoveFilter = filterToRemove => {\n if (onFiltersChange) {\n onFiltersChange(filters.filter(f => f !== filterToRemove));\n }\n };\n\n // Click a filter chip to edit it: pull it back into the input (preserving any\n // in-progress text by ANDing onto it) and remove the chip.\n const handleEditFilter = filter => {\n setFilterInput(prev => andAppend(prev, filter));\n if (onFiltersChange) {\n onFiltersChange(filters.filter(f => f !== filter));\n }\n filterInputRef.current?.focus();\n };\n\n // Append a dimension to the filter input (ANDing onto any existing expression)\n // then focus so the user can type the operator/value. Functional update keeps\n // it correct whether called from a click or a drag-and-drop handler.\n const appendDimToFilterInput = dimName => {\n setFilterInput(prev => andAppend(prev, dimName + ' '));\n filterInputRef.current?.focus();\n };\n\n const addDimAsFilter = (e, dimName) => {\n e.preventDefault();\n e.stopPropagation();\n appendDimToFilterInput(dimName);\n };\n\n // Drag a dimension chip/row onto the Filters section to start filtering on it.\n const handleDimDragStart = (e, dimName) => {\n e.dataTransfer.setData(DIM_DRAG_MIME, dimName);\n // Plain-text fallback so the drag has a sensible payload everywhere.\n e.dataTransfer.setData('text/plain', dimName);\n e.dataTransfer.effectAllowed = 'copy';\n };\n\n const handleFilterDragOver = e => {\n if (e.dataTransfer.types.includes(DIM_DRAG_MIME)) {\n e.preventDefault(); // allow drop\n e.dataTransfer.dropEffect = 'copy';\n if (!filterDropActive) setFilterDropActive(true);\n }\n };\n\n const handleFilterDragLeave = e => {\n // Ignore leaves into child elements of the filters section.\n if (!e.currentTarget.contains(e.relatedTarget)) {\n setFilterDropActive(false);\n }\n };\n\n const handleFilterDrop = e => {\n const dimName = e.dataTransfer.getData(DIM_DRAG_MIME);\n if (dimName) {\n e.preventDefault();\n appendDimToFilterInput(dimName);\n }\n setFilterDropActive(false);\n };\n\n return (\n <div className=\"selection-panel\">\n {/* Cube Preset Dropdown */}\n {cubes.length > 0 && (\n <div className=\"cube-preset-section\" ref={cubeDropdownRef}>\n <div className=\"preset-row\">\n <button\n className={`preset-button ${loadedCube ? 'has-preset' : ''}`}\n onClick={() => setShowCubeDropdown(!showCubeDropdown)}\n >\n <span className=\"preset-icon\">{loadedCube ? '📦' : '📂'}</span>\n <span className=\"preset-label\">\n {loadedCube\n ? loadedCube.display_name ||\n (loadedCube.name\n ? loadedCube.name.split('.').pop()\n : 'Cube')\n : 'Load from Cube'}\n </span>\n <span className=\"dropdown-arrow\">\n {showCubeDropdown ? '▲' : '▼'}\n </span>\n </button>\n {(selectedMetrics.length > 0 || selectedDimensions.length > 0) && (\n <button className=\"clear-all-btn\" onClick={clearSelection}>\n Clear\n </button>\n )}\n </div>\n\n {showCubeDropdown && (\n <div className=\"cube-dropdown\">\n <input\n type=\"text\"\n className=\"cube-search\"\n placeholder=\"Search cubes...\"\n value={cubeSearch}\n onChange={e => setCubeSearch(e.target.value)}\n autoFocus\n />\n <div className=\"cube-list\">\n {filteredCubes.length === 0 ? (\n <div className=\"cube-empty\">\n {cubeSearch\n ? 'No cubes match your search'\n : 'No cubes available'}\n </div>\n ) : (\n filteredCubes.map(cube => (\n <button\n key={cube.name}\n className={`cube-option ${\n loadedCubeName === cube.name ? 'selected' : ''\n }`}\n onClick={() => handleCubeSelect(cube)}\n >\n <span className=\"cube-name\">\n {cube.display_name ||\n (cube.name ? cube.name.split('.').pop() : 'Unknown')}\n </span>\n <span className=\"cube-info\">{cube.name}</span>\n {loadedCubeName === cube.name && (\n <span className=\"cube-selected-icon\">✓</span>\n )}\n </button>\n ))\n )}\n </div>\n </div>\n )}\n </div>\n )}\n\n {/* Resizable sections */}\n <div className=\"resizable-sections\" ref={sectionsRef}>\n {/* Metrics Section */}\n <div className=\"selection-section\" style={{ flex: split1 }}>\n <div className=\"section-header\">\n <h3>Metrics</h3>\n <span className=\"selection-count\">\n {selectedMetrics.length} selected\n </span>\n </div>\n\n {/* Combined Chips + Search Input */}\n <div\n className=\"combobox-input\"\n onClick={() => metricsSearchRef.current?.focus()}\n >\n {selectedMetrics.length > 0 && (\n <div\n className={`combobox-chips ${\n selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD\n ? metricsChipsExpanded\n ? 'expanded'\n : 'collapsed'\n : ''\n }`}\n >\n {selectedMetrics.map(metric => (\n <span key={metric} className=\"selected-chip metric-chip\">\n <TailLabel text={getShortName(metric)} />\n <button\n className=\"chip-remove\"\n onClick={e => {\n e.stopPropagation();\n removeMetric(metric);\n }}\n title={`Remove ${getShortName(metric)}`}\n >\n ×\n </button>\n </span>\n ))}\n </div>\n )}\n <div className=\"combobox-input-row\">\n <input\n ref={metricsSearchRef}\n type=\"text\"\n className=\"combobox-search\"\n placeholder=\"Search metrics...\"\n value={metricsSearch}\n onChange={e => setMetricsSearch(e.target.value)}\n onClick={e => e.stopPropagation()}\n />\n {selectedMetrics.length > CHIPS_COLLAPSE_THRESHOLD && (\n <button\n className=\"combobox-action\"\n onClick={e => {\n e.stopPropagation();\n setMetricsChipsExpanded(!metricsChipsExpanded);\n }}\n >\n {metricsChipsExpanded ? 'Show less' : 'Show all'}\n </button>\n )}\n {selectedMetrics.length > 0 && (\n <button\n className=\"combobox-action\"\n onClick={e => {\n e.stopPropagation();\n onMetricsChange([]);\n }}\n >\n Clear\n </button>\n )}\n </div>\n </div>\n\n <div className=\"selection-list\">\n {sortedNamespaces.map(namespace => {\n const items = filteredGroups[namespace];\n const isExpanded = expandedNamespaces.has(namespace);\n const selectedInNamespace = items.filter(m =>\n selectedMetrics.includes(m),\n ).length;\n\n return (\n <div key={namespace} className=\"namespace-group\">\n <div\n className=\"namespace-header\"\n onClick={() => toggleNamespace(namespace)}\n >\n <span className=\"expand-icon\">\n {isExpanded ? '▼' : '▶'}\n </span>\n <span className=\"namespace-name\">{namespace}</span>\n <span className=\"namespace-count\">\n {selectedInNamespace > 0 && (\n <span className=\"selected-badge\">\n {selectedInNamespace}\n </span>\n )}\n {items.length}\n </span>\n </div>\n\n {isExpanded && (\n <div className=\"namespace-items\">\n <div className=\"namespace-actions\">\n <button\n type=\"button\"\n className=\"select-all-btn\"\n onClick={() => selectAllInNamespace(namespace, items)}\n >\n Select all\n </button>\n <button\n type=\"button\"\n className=\"select-all-btn\"\n onClick={() =>\n deselectAllInNamespace(namespace, items)\n }\n >\n Clear\n </button>\n </div>\n {items.map(metric => {\n const isIncompatible =\n compatibleMetrics !== null &&\n !compatibleMetrics.has(metric) &&\n !selectedMetrics.includes(metric);\n return (\n <label\n key={metric}\n className={`selection-item${\n isIncompatible ? ' metric-incompatible' : ''\n }`}\n title={\n isIncompatible\n ? 'Not compatible with selected dimensions'\n : metric\n }\n >\n <input\n type=\"checkbox\"\n checked={selectedMetrics.includes(metric)}\n onChange={() => toggleMetric(metric)}\n />\n <span className=\"item-name\">\n {getShortName(metric)}\n </span>\n {compatibleMetrics !== null &&\n compatibleMetrics.has(metric) &&\n !selectedMetrics.includes(metric) && (\n <span\n className=\"metric-compatible-badge\"\n title=\"Compatible with selected dimensions\"\n >\n ✓\n </span>\n )}\n </label>\n );\n })}\n </div>\n )}\n </div>\n );\n })}\n\n {sortedNamespaces.length === 0 && (\n <div className=\"empty-list\">\n {metricsSearch\n ? 'No metrics match your search'\n : 'No metrics available'}\n </div>\n )}\n </div>\n </div>\n\n {/* Draggable Divider 1: metrics / dims */}\n <div\n className=\"section-divider draggable-divider\"\n onMouseDown={e => handleDividerMouseDown(e, 1)}\n />\n\n {/* Dimensions Section */}\n <div className=\"selection-section\" style={{ flex: split2 - split1 }}>\n <div className=\"section-header\">\n <h3>Dimensions</h3>\n <span className=\"selection-count\">\n {selectedDimensions.length} selected\n {dimensions.length > 0 && ` / ${dimensions.length} available`}\n </span>\n </div>\n\n {selectedMetrics.length === 0 ? (\n <div className=\"empty-list hint\">\n Select metrics to see available dimensions\n </div>\n ) : loading ? (\n <div className=\"empty-list\">Loading dimensions...</div>\n ) : (\n <>\n {/* Combined Chips + Search Input */}\n <div\n className=\"combobox-input\"\n onClick={() => dimensionsSearchRef.current?.focus()}\n >\n {selectedDimensions.length > 0 && (\n <div\n className={`combobox-chips ${\n selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD\n ? dimensionsChipsExpanded\n ? 'expanded'\n : 'collapsed'\n : ''\n }`}\n >\n {selectedDimensions.map(dimName => (\n <span\n key={dimName}\n className=\"selected-chip dimension-chip draggable-dim\"\n title={`${dimName} — drag to Filters to filter on it`}\n draggable\n onDragStart={e => handleDimDragStart(e, dimName)}\n >\n <TailLabel text={getDimDisplayName(dimName)} />\n <button\n className=\"chip-remove\"\n onClick={e => {\n e.stopPropagation();\n removeDimension(dimName);\n }}\n title={`Remove ${getDimDisplayName(dimName)}`}\n >\n ×\n </button>\n </span>\n ))}\n </div>\n )}\n <div className=\"combobox-input-row\">\n <input\n ref={dimensionsSearchRef}\n type=\"text\"\n className=\"combobox-search\"\n placeholder=\"Search dimensions...\"\n value={dimensionsSearch}\n onChange={e => setDimensionsSearch(e.target.value)}\n onClick={e => e.stopPropagation()}\n />\n {selectedDimensions.length > CHIPS_COLLAPSE_THRESHOLD && (\n <button\n className=\"combobox-action\"\n onClick={e => {\n e.stopPropagation();\n setDimensionsChipsExpanded(!dimensionsChipsExpanded);\n }}\n >\n {dimensionsChipsExpanded ? 'Show less' : 'Show all'}\n </button>\n )}\n {selectedDimensions.length > 0 && (\n <button\n className=\"combobox-action\"\n onClick={e => {\n e.stopPropagation();\n onDimensionsChange([]);\n }}\n >\n Clear\n </button>\n )}\n </div>\n </div>\n\n <div className=\"selection-list dimensions-list\">\n {groupedDimensions.map(group => {\n const nodeExpanded = expandedDimGroups.has(group.nodeKey);\n return (\n <div key={group.nodeKey} className=\"dim-group\">\n <div\n className=\"dim-group-header\"\n onClick={() => toggleDimGroup(group.nodeKey)}\n title={group.nodeKey}\n >\n <span className=\"expand-icon\">\n {nodeExpanded ? '▼' : '▶'}\n </span>\n <span className=\"dim-group-name\">\n {getDimGroupShortName(group.nodeKey)}\n </span>\n <span className=\"dim-group-count\">\n {group.totalCount}\n </span>\n </div>\n {nodeExpanded &&\n group.rolePaths.map(rp => {\n const rpKey = `${group.nodeKey}::${rp.roleKey}`;\n const rpExpanded = expandedRolePaths.has(rpKey);\n const hops = rolePathHops(rp.roleKey);\n const hopsLabel =\n hops === 0\n ? 'direct'\n : `${hops} hop${hops > 1 ? 's' : ''}`;\n return (\n <div key={rpKey} className=\"dim-role-group\">\n <div\n className=\"dim-role-header\"\n onClick={() =>\n toggleRolePath(group.nodeKey, rp.roleKey)\n }\n >\n <span className=\"expand-icon\">\n {rpExpanded ? '▼' : '▶'}\n </span>\n <span className=\"dim-role-label\">\n {formatRolePath(rp.roleKey)}\n </span>\n <span className=\"dim-group-meta\">\n {hopsLabel}\n </span>\n <span className=\"dim-group-count\">\n {rp.dimensions.length}\n </span>\n </div>\n {rpExpanded &&\n rp.dimensions.map(dim => (\n <label\n key={dim.name}\n className=\"selection-item dimension-item dim-role-item draggable-dim\"\n title={`${dim.name} — drag to Filters to filter on it`}\n draggable\n onDragStart={e =>\n handleDimDragStart(e, dim.name)\n }\n >\n <input\n type=\"checkbox\"\n checked={selectedDimensions.includes(\n dim.name,\n )}\n onChange={() => toggleDimension(dim.name)}\n />\n <div className=\"dimension-info\">\n <span className=\"item-name\">\n {getDimDisplayName(\n dim.name.replace(/\\[[^\\]]*\\]$/, ''),\n )}\n </span>\n </div>\n <button\n className=\"dim-filter-btn\"\n title={`Add \"${dim.name}\" to filters`}\n onClick={e => addDimAsFilter(e, dim.name)}\n >\n + filter\n </button>\n </label>\n ))}\n </div>\n );\n })}\n </div>\n );\n })}\n\n {groupedDimensions.length === 0 && (\n <div className=\"empty-list\">\n {dimensionsSearch\n ? 'No dimensions match your search'\n : 'No shared dimensions'}\n </div>\n )}\n </div>\n </>\n )}\n </div>\n\n {/* Draggable Divider 2: dims / filters */}\n <div\n className=\"section-divider draggable-divider\"\n onMouseDown={e => handleDividerMouseDown(e, 2)}\n />\n\n {/* Filters Section (drop target for dragged dimensions) */}\n <div\n className={`selection-section filters-section${\n filterDropActive ? ' drop-active' : ''\n }`}\n style={{ flex: split3 - split2 }}\n onDragOver={handleFilterDragOver}\n onDragLeave={handleFilterDragLeave}\n onDrop={handleFilterDrop}\n >\n <div className=\"section-header\">\n <h3>Filters</h3>\n <span className=\"selection-count\">{filters.length} applied</span>\n </div>\n {filterDropActive && (\n <div className=\"filter-drop-hint\">\n Drop to filter on this dimension\n </div>\n )}\n\n {/* Filter chips */}\n {filters.length > 0 && (\n <div className=\"filter-chips-container\">\n {filters.map((filter, idx) => (\n <span key={idx} className=\"filter-chip\">\n <button\n type=\"button\"\n className=\"filter-chip-text filter-chip-edit\"\n onClick={() => handleEditFilter(filter)}\n title={`${filter} — click to edit`}\n >\n {filter}\n </button>\n <button\n className=\"filter-chip-remove\"\n onClick={() => handleRemoveFilter(filter)}\n title=\"Remove filter\"\n >\n ×\n </button>\n </span>\n ))}\n </div>\n )}\n\n {/* Filter input */}\n <div className=\"filter-input-container\">\n <input\n ref={filterInputRef}\n type=\"text\"\n className=\"filter-input\"\n placeholder=\"e.g. v3.date.date_id >= '2024-01-01'\"\n value={filterInput}\n onChange={e => setFilterInput(e.target.value)}\n onKeyDown={handleFilterKeyDown}\n />\n <button\n className=\"filter-add-btn\"\n onClick={handleAddFilter}\n disabled={!filterInput.trim()}\n >\n Add\n </button>\n </div>\n </div>\n\n {/* Draggable Divider 3: filters / engine+run */}\n <div\n className=\"section-divider draggable-divider\"\n onMouseDown={e => handleDividerMouseDown(e, 3)}\n />\n\n {/* Engine + Run Query Section */}\n <div\n className=\"selection-section engine-run-section\"\n style={{ flex: 100 - split3 }}\n >\n {/* Engine Selection */}\n <div className=\"engine-section\">\n <span className=\"engine-label\">Engine</span>\n <div className=\"engine-pills\">\n {ENGINE_OPTIONS.map(({ value, label }) => (\n <button\n key={label}\n className={`engine-pill${\n selectedEngine === value ? ' active' : ''\n }`}\n onClick={() => onEngineChange && onEngineChange(value)}\n >\n {label}\n </button>\n ))}\n </div>\n </div>\n\n {/* Run Query Section */}\n <div className=\"run-query-section\">\n <button\n className=\"run-query-btn\"\n onClick={onRunQuery}\n disabled={!canRunQuery || queryLoading}\n >\n {queryLoading ? (\n <>\n <span className=\"spinner small\" />\n Running...\n </>\n ) : (\n <>\n <span className=\"run-icon\">▶</span>\n Run Query\n </>\n )}\n </button>\n {!canRunQuery && (\n <span className=\"run-hint\">\n Select at least one metric to run a query\n </span>\n )}\n </div>\n </div>\n </div>\n {/* end resizable-sections */}\n </div>\n );\n}\n\nexport default SelectionPanel;\n","import { useState, useCallback, useMemo, useEffect, useRef, memo } from 'react';\nimport { Light as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport foundation from 'react-syntax-highlighter/dist/esm/styles/hljs/foundation';\nimport sql from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';\nimport {\n LineChart,\n Line,\n BarChart,\n Bar,\n XAxis,\n YAxis,\n CartesianGrid,\n Tooltip,\n ResponsiveContainer,\n} from 'recharts';\n\nSyntaxHighlighter.registerLanguage('sql', sql);\n\nconst SERIES_COLORS = [\n '#60a5fa',\n '#34d399',\n '#fbbf24',\n '#f87171',\n '#a78bfa',\n '#22d3ee',\n '#fb923c',\n];\n\n// Threshold for switching from multi-series to small multiples\nconst SMALL_MULTIPLES_THRESHOLD = 2;\n\nfunction isTimeColumn(col) {\n const name = col.name.toLowerCase();\n const type = (col.type || '').toLowerCase();\n return (\n type.includes('date') ||\n type.includes('timestamp') ||\n type.includes('time') ||\n name === 'date' ||\n name === 'day' ||\n name === 'week' ||\n name === 'month' ||\n name === 'year' ||\n name === 'quarter' ||\n name.endsWith('_date') ||\n name.endsWith('_day') ||\n name.endsWith('_week') ||\n name.endsWith('_month') ||\n name.endsWith('_year') ||\n name.endsWith('_at') ||\n name.startsWith('date') ||\n name.startsWith('ds')\n );\n}\n\nfunction isNumericColumn(col) {\n const type = (col.type || '').toLowerCase();\n return (\n type.includes('int') ||\n type.includes('float') ||\n type.includes('double') ||\n type.includes('decimal') ||\n type.includes('numeric') ||\n type.includes('real') ||\n type.includes('number')\n );\n}\n\nfunction detectChartConfig(columns, rows) {\n if (!columns.length || !rows.length) return null;\n\n const tagged = columns.map((c, i) => ({ ...c, idx: i }));\n const timeCols = tagged.filter(c => isTimeColumn(c));\n const numericCols = tagged.filter(c => isNumericColumn(c));\n const nonNumericCols = tagged.filter(c => !isNumericColumn(c));\n const nonTimeCatCols = nonNumericCols.filter(c => !isTimeColumn(c));\n\n // Time dimension present → line chart\n if (timeCols.length > 0) {\n const xCol = timeCols[0];\n const metricCols = numericCols.filter(c => c.idx !== xCol.idx);\n if (metricCols.length > 0) {\n // Exactly one categorical dim + one metric → pivot as series\n if (nonTimeCatCols.length === 1) {\n return {\n type: 'line',\n xCol,\n groupByCol: nonTimeCatCols[0],\n metricCols,\n };\n }\n return { type: 'line', xCol, metricCols };\n }\n }\n\n // Categorical dimension(s) → bar chart\n if (nonTimeCatCols.length > 0 && numericCols.length > 0) {\n if (nonTimeCatCols.length === 1) {\n return { type: 'bar', xCol: nonTimeCatCols[0], metricCols: numericCols };\n }\n if (nonTimeCatCols.length === 2) {\n return {\n type: 'bar',\n xCol: nonTimeCatCols[0],\n groupByCol: nonTimeCatCols[1],\n metricCols: numericCols,\n };\n }\n // 3+ cats → fall back to first cat as x-axis\n return { type: 'bar', xCol: nonTimeCatCols[0], metricCols: numericCols };\n }\n\n // Multiple numeric columns, no string/time dim → treat first as x-axis (line)\n if (numericCols.length > 1) {\n const xCol = numericCols[0];\n const metricCols = numericCols.slice(1);\n return { type: 'line', xCol, metricCols };\n }\n\n // Scalar result → KPI cards\n if (numericCols.length > 0) {\n return { type: 'kpi', metricCols: numericCols };\n }\n\n return null;\n}\n\nconst MAX_GROUP_VALUES = 50;\n\nfunction buildPivotedData(rows, columns, xCol, groupByCol, metricCols) {\n const xIdx = xCol.idx;\n const gIdx = groupByCol.idx;\n const metricIdxs = metricCols.map(c => c.idx);\n\n // Pass 1: group totals only (cheap — just numbers)\n const groupTotals = {};\n for (const row of rows) {\n const gVal = String(row[gIdx] ?? '(null)');\n groupTotals[gVal] =\n (groupTotals[gVal] || 0) + (Number(row[metricIdxs[0]]) || 0);\n }\n const groupValues = Object.entries(groupTotals)\n .sort((a, b) => b[1] - a[1])\n .slice(0, MAX_GROUP_VALUES)\n .map(([k]) => k);\n const groupSet = new Set(groupValues);\n\n // Pass 2: build ALL metric pivot maps in one sweep\n const pivotMaps = metricCols.map(() => ({}));\n for (const row of rows) {\n const gVal = String(row[gIdx] ?? '(null)');\n if (!groupSet.has(gVal)) continue;\n const xVal = row[xIdx];\n const mapKey = String(xVal ?? '(null)');\n for (let m = 0; m < metricCols.length; m++) {\n const pm = pivotMaps[m];\n if (!pm[mapKey]) pm[mapKey] = { [xCol.name]: xVal };\n pm[mapKey][gVal] = row[metricIdxs[m]];\n }\n }\n\n const sortFn = (a, b) => {\n const av = a[xCol.name];\n const bv = b[xCol.name];\n if (av === null && bv === null) return 0;\n if (av === null) return 1;\n if (bv === null) return -1;\n if (typeof av === 'number' && typeof bv === 'number') return av - bv;\n return String(av).localeCompare(String(bv));\n };\n\n const pivotedByMetric = metricCols.map((metricCol, m) => {\n const pivoted = Object.values(pivotMaps[m]);\n pivoted.sort(sortFn);\n return { col: metricCol, data: pivoted };\n });\n\n return { pivotedByMetric, groupValues };\n}\n\nfunction buildChartData(columns, rows, xCol) {\n const data = rows.map(row => {\n const obj = {};\n columns.forEach((col, i) => {\n obj[col.name] = row[i];\n });\n return obj;\n });\n const key = xCol.name;\n data.sort((a, b) => {\n const av = a[key];\n const bv = b[key];\n if (av === null && bv === null) return 0;\n if (av === null) return 1;\n if (bv === null) return -1;\n if (typeof av === 'number' && typeof bv === 'number') return av - bv;\n return String(av).localeCompare(String(bv));\n });\n return data;\n}\n\nfunction formatYAxis(value) {\n if (Math.abs(value) >= 1_000_000_000)\n return (value / 1_000_000_000).toFixed(1) + 'B';\n if (Math.abs(value) >= 1_000_000) return (value / 1_000_000).toFixed(1) + 'M';\n if (Math.abs(value) >= 1_000) return (value / 1_000).toFixed(1) + 'K';\n return value;\n}\n\nfunction KpiCards({ rows, metricCols }) {\n const row = rows[0] || [];\n return (\n <div className=\"kpi-cards\">\n {metricCols.map(col => {\n const val = row[col.idx];\n const formatted =\n val == null\n ? '—'\n : typeof val === 'number'\n ? val.toLocaleString(undefined, { maximumFractionDigits: 4 })\n : String(val);\n return (\n <div key={col.idx} classNa