datajunction-ui
Version:
DataJunction UI
1 lines • 85.5 kB
Source Map (JSON)
{"version":3,"file":"index-ZNcAQhUL.cjs","sources":["../src/app/components/forms/Action.jsx","../src/app/components/forms/NodeNameField.jsx","../node_modules/react-select/dist/useAsync-9eb532f6.esm.js","../node_modules/react-select/async/dist/react-select-async.esm.js","../src/app/pages/CubeBuilderPage/MetricsSelect.jsx","../src/app/pages/CubeBuilderPage/DimensionsSelect.jsx","../src/app/pages/CubeBuilderPage/CubePreviewPanel.jsx","../src/app/pages/CubeBuilderPage/index.jsx"],"sourcesContent":["export class Action {\n static Add = new Action('add');\n static Edit = new Action('edit');\n\n constructor(name) {\n this.name = name;\n }\n}\n","import { ErrorMessage, Field } from 'formik';\nimport { FormikSelect } from '../../pages/AddEditNodePage/FormikSelect';\nimport { FullNameField } from '../../pages/AddEditNodePage/FullNameField';\nimport React, { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { useParams } from 'react-router-dom';\n\n/*\n * This component creates the namespace selector, display name input, and\n * derived name fields in a form. It can be reused any time we need to create\n * a new node.\n */\n\nexport default function NodeNameField() {\n const [namespaces, setNamespaces] = useState([]);\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n let { initialNamespace } = useParams();\n\n useEffect(() => {\n const fetchData = async () => {\n const namespaces = await djClient.namespaces();\n setNamespaces(\n namespaces.map(m => ({\n value: m['namespace'],\n label: m['namespace'],\n })),\n );\n };\n fetchData().catch(console.error);\n }, [djClient, djClient.metrics]);\n\n return (\n <>\n <div className=\"NamespaceInput\">\n <ErrorMessage name=\"namespace\" component=\"span\" />\n <label htmlFor=\"namespace\">Namespace *</label>\n <FormikSelect\n selectOptions={namespaces}\n formikFieldName=\"namespace\"\n placeholder=\"Choose Namespace\"\n defaultValue={{\n value: initialNamespace,\n label: initialNamespace,\n }}\n />\n </div>\n <div className=\"DisplayNameInput NodeCreationInput\">\n <ErrorMessage name=\"display_name\" component=\"span\" />\n <label htmlFor=\"displayName\">Display Name *</label>\n <Field\n type=\"text\"\n name=\"display_name\"\n id=\"displayName\"\n placeholder=\"Human readable display name\"\n />\n </div>\n <div className=\"FullNameInput NodeCreationInput\">\n <ErrorMessage name=\"name\" component=\"span\" />\n <label htmlFor=\"FullName\">Full Name</label>\n <FullNameField type=\"text\" name=\"name\" />\n </div>\n </>\n );\n}\n","import _defineProperty from '@babel/runtime/helpers/esm/defineProperty';\nimport _objectSpread from '@babel/runtime/helpers/esm/objectSpread2';\nimport _slicedToArray from '@babel/runtime/helpers/esm/slicedToArray';\nimport _objectWithoutProperties from '@babel/runtime/helpers/esm/objectWithoutProperties';\nimport { useRef, useState, useEffect, useCallback } from 'react';\nimport { L as handleInputChange } from './index-6ea50319.esm.js';\n\nvar _excluded = [\"defaultOptions\", \"cacheOptions\", \"loadOptions\", \"options\", \"isLoading\", \"onInputChange\", \"filterOption\"];\nfunction useAsync(_ref) {\n var _ref$defaultOptions = _ref.defaultOptions,\n propsDefaultOptions = _ref$defaultOptions === void 0 ? false : _ref$defaultOptions,\n _ref$cacheOptions = _ref.cacheOptions,\n cacheOptions = _ref$cacheOptions === void 0 ? false : _ref$cacheOptions,\n propsLoadOptions = _ref.loadOptions;\n _ref.options;\n var _ref$isLoading = _ref.isLoading,\n propsIsLoading = _ref$isLoading === void 0 ? false : _ref$isLoading,\n propsOnInputChange = _ref.onInputChange,\n _ref$filterOption = _ref.filterOption,\n filterOption = _ref$filterOption === void 0 ? null : _ref$filterOption,\n restSelectProps = _objectWithoutProperties(_ref, _excluded);\n var propsInputValue = restSelectProps.inputValue;\n var lastRequest = useRef(undefined);\n var mounted = useRef(false);\n var _useState = useState(Array.isArray(propsDefaultOptions) ? propsDefaultOptions : undefined),\n _useState2 = _slicedToArray(_useState, 2),\n defaultOptions = _useState2[0],\n setDefaultOptions = _useState2[1];\n var _useState3 = useState(typeof propsInputValue !== 'undefined' ? propsInputValue : ''),\n _useState4 = _slicedToArray(_useState3, 2),\n stateInputValue = _useState4[0],\n setStateInputValue = _useState4[1];\n var _useState5 = useState(propsDefaultOptions === true),\n _useState6 = _slicedToArray(_useState5, 2),\n isLoading = _useState6[0],\n setIsLoading = _useState6[1];\n var _useState7 = useState(undefined),\n _useState8 = _slicedToArray(_useState7, 2),\n loadedInputValue = _useState8[0],\n setLoadedInputValue = _useState8[1];\n var _useState9 = useState([]),\n _useState10 = _slicedToArray(_useState9, 2),\n loadedOptions = _useState10[0],\n setLoadedOptions = _useState10[1];\n var _useState11 = useState(false),\n _useState12 = _slicedToArray(_useState11, 2),\n passEmptyOptions = _useState12[0],\n setPassEmptyOptions = _useState12[1];\n var _useState13 = useState({}),\n _useState14 = _slicedToArray(_useState13, 2),\n optionsCache = _useState14[0],\n setOptionsCache = _useState14[1];\n var _useState15 = useState(undefined),\n _useState16 = _slicedToArray(_useState15, 2),\n prevDefaultOptions = _useState16[0],\n setPrevDefaultOptions = _useState16[1];\n var _useState17 = useState(undefined),\n _useState18 = _slicedToArray(_useState17, 2),\n prevCacheOptions = _useState18[0],\n setPrevCacheOptions = _useState18[1];\n if (cacheOptions !== prevCacheOptions) {\n setOptionsCache({});\n setPrevCacheOptions(cacheOptions);\n }\n if (propsDefaultOptions !== prevDefaultOptions) {\n setDefaultOptions(Array.isArray(propsDefaultOptions) ? propsDefaultOptions : undefined);\n setPrevDefaultOptions(propsDefaultOptions);\n }\n useEffect(function () {\n mounted.current = true;\n return function () {\n mounted.current = false;\n };\n }, []);\n var loadOptions = useCallback(function (inputValue, callback) {\n if (!propsLoadOptions) return callback();\n var loader = propsLoadOptions(inputValue, callback);\n if (loader && typeof loader.then === 'function') {\n loader.then(callback, function () {\n return callback();\n });\n }\n }, [propsLoadOptions]);\n useEffect(function () {\n if (propsDefaultOptions === true) {\n loadOptions(stateInputValue, function (options) {\n if (!mounted.current) return;\n setDefaultOptions(options || []);\n setIsLoading(!!lastRequest.current);\n });\n }\n // NOTE: this effect is designed to only run when the component mounts,\n // so we don't want to include any hook dependencies\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n var onInputChange = useCallback(function (newValue, actionMeta) {\n var inputValue = handleInputChange(newValue, actionMeta, propsOnInputChange);\n if (!inputValue) {\n lastRequest.current = undefined;\n setStateInputValue('');\n setLoadedInputValue('');\n setLoadedOptions([]);\n setIsLoading(false);\n setPassEmptyOptions(false);\n return;\n }\n if (cacheOptions && optionsCache[inputValue]) {\n setStateInputValue(inputValue);\n setLoadedInputValue(inputValue);\n setLoadedOptions(optionsCache[inputValue]);\n setIsLoading(false);\n setPassEmptyOptions(false);\n } else {\n var request = lastRequest.current = {};\n setStateInputValue(inputValue);\n setIsLoading(true);\n setPassEmptyOptions(!loadedInputValue);\n loadOptions(inputValue, function (options) {\n if (!mounted) return;\n if (request !== lastRequest.current) return;\n lastRequest.current = undefined;\n setIsLoading(false);\n setLoadedInputValue(inputValue);\n setLoadedOptions(options || []);\n setPassEmptyOptions(false);\n setOptionsCache(options ? _objectSpread(_objectSpread({}, optionsCache), {}, _defineProperty({}, inputValue, options)) : optionsCache);\n });\n }\n }, [cacheOptions, loadOptions, loadedInputValue, optionsCache, propsOnInputChange]);\n var options = passEmptyOptions ? [] : stateInputValue && loadedInputValue ? loadedOptions : defaultOptions || [];\n return _objectSpread(_objectSpread({}, restSelectProps), {}, {\n options: options,\n isLoading: isLoading || propsIsLoading,\n onInputChange: onInputChange,\n filterOption: filterOption\n });\n}\n\nexport { useAsync as u };\n","import _extends from '@babel/runtime/helpers/esm/extends';\nimport * as React from 'react';\nimport { forwardRef } from 'react';\nimport { S as Select } from '../../dist/Select-ce3716e1.esm.js';\nimport { u as useStateManager } from '../../dist/useStateManager-7e1e8489.esm.js';\nimport { u as useAsync } from '../../dist/useAsync-9eb532f6.esm.js';\nexport { u as useAsync } from '../../dist/useAsync-9eb532f6.esm.js';\nimport '@babel/runtime/helpers/objectSpread2';\nimport '@babel/runtime/helpers/classCallCheck';\nimport '@babel/runtime/helpers/createClass';\nimport '@babel/runtime/helpers/inherits';\nimport '@babel/runtime/helpers/createSuper';\nimport '@babel/runtime/helpers/toConsumableArray';\nimport '../../dist/index-6ea50319.esm.js';\nimport '@emotion/react';\nimport '@babel/runtime/helpers/slicedToArray';\nimport '@babel/runtime/helpers/objectWithoutProperties';\nimport '@babel/runtime/helpers/typeof';\nimport '@babel/runtime/helpers/taggedTemplateLiteral';\nimport '@babel/runtime/helpers/defineProperty';\nimport 'react-dom';\nimport '@floating-ui/dom';\nimport 'use-isomorphic-layout-effect';\nimport 'memoize-one';\n\nvar AsyncSelect = /*#__PURE__*/forwardRef(function (props, ref) {\n var stateManagedProps = useAsync(props);\n var selectProps = useStateManager(stateManagedProps);\n return /*#__PURE__*/React.createElement(Select, _extends({\n ref: ref\n }, selectProps));\n});\nvar AsyncSelect$1 = AsyncSelect;\n\nexport { AsyncSelect$1 as default };\n","/**\n * A select component for picking metrics.\n * Uses async search to efficiently handle large numbers of metrics.\n * Results are grouped by namespace for easier navigation.\n */\nimport AsyncSelect from 'react-select/async';\n// `components` is exported from the main `react-select` entry, not from\n// `react-select/async`. CRA's webpack was lenient about this; Vite's\n// stricter ESM resolution rejects the named import on the subpath.\nimport { components } from 'react-select';\nimport React, { useContext, useEffect, useState, useCallback } from 'react';\nimport DJClientContext from '../../providers/djclient';\n\n// Debounce helper\nconst debounce = (fn, ms) => {\n let timer;\n return (...args) => {\n clearTimeout(timer);\n return new Promise(resolve => {\n timer = setTimeout(() => resolve(fn(...args)), ms);\n });\n };\n};\n\n/**\n * Extract namespace from a fully qualified metric name.\n * e.g., \"finance.total_revenue\" -> \"finance\"\n */\nconst getNamespace = metricName => {\n if (!metricName) return 'default';\n const parts = metricName.split('.');\n return parts.slice(0, -1).join('.') || 'default';\n};\n\n/**\n * Git branch icon SVG component\n */\nconst GitBranchIcon = () => (\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"10\"\n height=\"10\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <line x1=\"6\" y1=\"3\" x2=\"6\" y2=\"15\" />\n <circle cx=\"18\" cy=\"6\" r=\"3\" />\n <circle cx=\"6\" cy=\"18\" r=\"3\" />\n <path d=\"M18 9a9 9 0 0 1-9 9\" />\n </svg>\n);\n\n/**\n * Custom Group heading component matching Explorer styling.\n */\nconst GroupHeading = props => {\n const { data } = props;\n const { namespace, gitInfo, count } = data;\n\n return (\n <div\n style={{\n display: 'flex',\n alignItems: 'center',\n padding: '8px 12px',\n backgroundColor: '#fafafa',\n borderBottom: '1px solid #eee',\n }}\n >\n <span style={{ fontWeight: 500, color: '#333' }}>{namespace}</span>\n {gitInfo?.branch && (\n <span\n title={`Branch: ${gitInfo.branch}`}\n style={{\n marginLeft: '8px',\n fontSize: '11px',\n padding: '2px 6px',\n borderRadius: '3px',\n backgroundColor: gitInfo.isDefaultBranch ? '#d4edda' : '#fff3cd',\n color: gitInfo.isDefaultBranch ? '#155724' : '#856404',\n display: 'inline-flex',\n alignItems: 'center',\n gap: '4px',\n }}\n >\n <GitBranchIcon />\n {gitInfo.branch}\n </span>\n )}\n <span style={{ marginLeft: '8px', color: '#999', fontSize: '12px' }}>\n ({count})\n </span>\n </div>\n );\n};\n\n/**\n * Group flat metrics array into react-select grouped format.\n * Includes branch info for styled group headers.\n */\nconst groupMetricsByNamespace = metrics => {\n const grouped = {};\n const gitInfoByNamespace = {};\n\n metrics.forEach(metric => {\n const namespace = getNamespace(metric.value);\n if (!grouped[namespace]) {\n grouped[namespace] = [];\n gitInfoByNamespace[namespace] = metric.gitInfo;\n }\n grouped[namespace].push(metric);\n });\n\n // Sort namespaces alphabetically and build grouped options\n return Object.keys(grouped)\n .sort()\n .map(namespace => {\n const gitInfo = gitInfoByNamespace[namespace];\n const count = grouped[namespace].length;\n\n return {\n label: namespace,\n namespace,\n gitInfo,\n count,\n options: grouped[namespace],\n };\n });\n};\n\n/**\n * Custom option component that shows display name and full node name.\n */\nconst formatOptionLabel = (option, { context }) => {\n if (context === 'menu') {\n const displayName = option.label;\n const nodeName = option.value;\n const isDifferent = displayName !== nodeName;\n\n return (\n <div>\n <div>{displayName}</div>\n {isDifferent && (\n <div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>\n {nodeName}\n </div>\n )}\n </div>\n );\n }\n // For selected chips: show display name with tooltip and optional branch badge\n const displayName = option.label || option.value;\n const gitInfo = option.gitInfo;\n const showBranch = gitInfo?.branch && !gitInfo?.isDefaultBranch;\n\n return (\n <span\n title={option.value}\n style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}\n >\n <span>{displayName}</span>\n {showBranch && (\n <span\n style={{\n fontSize: '9px',\n padding: '1px 4px',\n borderRadius: '2px',\n backgroundColor: 'rgba(255, 255, 255, 0.6)',\n color: '#a2283e',\n border: '1px solid rgba(162, 40, 62, 0.2)',\n fontWeight: 500,\n letterSpacing: '0.2px',\n }}\n >\n ⎇ {gitInfo.branch}\n </span>\n )}\n </span>\n );\n};\n\nexport const MetricsSelect = React.memo(function MetricsSelect({\n cube,\n onChange,\n}) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n // Currently selected metrics (for controlled component)\n const [selectedMetrics, setSelectedMetrics] = useState([]);\n\n // Load existing cube metrics when editing\n useEffect(() => {\n if (cube?.current?.cubeMetrics) {\n const cubeMetrics = cube.current.cubeMetrics.map(metric => ({\n value: metric.name,\n label: metric.displayName || metric.name,\n }));\n setSelectedMetrics(cubeMetrics);\n onChange(cubeMetrics.map(m => m.value));\n\n // Fetch gitInfo for existing metrics so we can display branch badges\n const names = cubeMetrics.map(m => m.value);\n djClient.getMetricsInfo(names).then(enriched => {\n if (enriched.length > 0) {\n const infoByName = Object.fromEntries(\n enriched.map(e => [e.value, e]),\n );\n setSelectedMetrics(prev =>\n prev.map(m => (infoByName[m.value] ? infoByName[m.value] : m)),\n );\n }\n });\n }\n }, [cube, onChange, djClient]);\n\n // Async load options - searches metrics via GraphQL, grouped by namespace\n const loadOptions = useCallback(\n debounce(async inputValue => {\n if (!inputValue || inputValue.length < 2) {\n return [];\n }\n try {\n const results = await djClient.searchMetrics(inputValue, 50);\n return groupMetricsByNamespace(results);\n } catch (error) {\n console.error('Error searching metrics:', error);\n return [];\n }\n }, 300),\n [djClient],\n );\n\n const handleChange = selected => {\n setSelectedMetrics(selected || []);\n onChange((selected || []).map(option => option.value));\n };\n\n // Custom styles to color-code metric tags (matching Query Planner exactly)\n const metricStyles = {\n multiValue: base => ({\n ...base,\n backgroundColor: '#fad7dd',\n border: '1px solid rgba(162, 40, 62, 0.3)',\n borderRadius: '3px',\n margin: '2px',\n }),\n multiValueLabel: base => ({\n ...base,\n color: '#a2283e',\n fontSize: '10px',\n fontWeight: 500,\n padding: '2px 4px 2px 6px',\n }),\n multiValueRemove: base => ({\n ...base,\n color: '#a2283e',\n padding: '0 4px',\n ':hover': {\n backgroundColor: '#f5c4cd',\n color: '#a2283e',\n },\n }),\n };\n\n return (\n <AsyncSelect\n value={selectedMetrics}\n loadOptions={loadOptions}\n onChange={handleChange}\n name=\"metrics\"\n placeholder=\"Type to search metrics...\"\n noOptionsMessage={({ inputValue }) =>\n inputValue.length < 2\n ? 'Type at least 2 characters to search'\n : 'No metrics found'\n }\n loadingMessage={() => 'Searching...'}\n formatOptionLabel={formatOptionLabel}\n components={{ GroupHeading }}\n styles={metricStyles}\n isMulti\n isClearable\n closeMenuOnSelect={false}\n cacheOptions\n defaultOptions={false}\n />\n );\n});\n","/**\n * A select component for picking dimensions.\n * Dimensions are grouped by hop distance (how many joins away from the metrics).\n */\nimport Select from 'react-select';\nimport React, { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { labelize } from '../../../utils/form';\n\n/**\n * Calculate hop distance from path length.\n * path.length represents how many joins away the dimension is.\n */\nconst getHopDistance = path => {\n if (!path || path.length === 0) return 0;\n return path.length;\n};\n\n/**\n * Render role information as a label suffix when a dimension is reached\n * via a named role (e.g. \"[birth_country]\"). Stored role values can be raw\n * (e.g. \"birth_country\") or already bracketed; normalize either form to\n * \" [role]\". Returns \"\" when there is no role.\n */\nconst formatRoleSuffix = role => {\n if (!role) return '';\n const stripped = role.replace(/^\\[|\\]$/g, '');\n return stripped ? ` [${stripped}]` : '';\n};\n\n/**\n * Parse a role suffix off the end of a dimension's full name.\n * \"default.user_dim.country_code[birth_country]\" → [\"default.user_dim.country_code\", \"birth_country\"]\n * \"default.user_dim.country_code\" → [\"default.user_dim.country_code\", \"\"]\n */\nconst splitRole = fullName => {\n if (!fullName) return [fullName, ''];\n const match = fullName.match(/^(.+?)\\[([^\\]]+)\\]$/);\n return match ? [match[1], match[2]] : [fullName, ''];\n};\n\n/**\n * Get human-readable label for hop distance.\n */\nconst getHopLabel = hopDistance => {\n if (hopDistance === 0) return 'Direct Dimensions';\n if (hopDistance === 1) return '1 Hop Away';\n return `${hopDistance} Hops Away`;\n};\n\nexport const DimensionsSelect = React.memo(function DimensionsSelect({\n cube,\n metrics,\n onChange,\n}) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n // Dimensions grouped by hop distance, then by node+path\n const [dimensionsByHop, setDimensionsByHop] = useState({});\n\n // The selected dimensions, grouped by dimension node and path\n const [selectedDimensionsByGroup, setSelectedDimensionsByGroup] = useState(\n {},\n );\n\n // The existing cube node's dimensions, if editing a cube\n const [defaultDimensions, setDefaultDimensions] = useState([]);\n\n useEffect(() => {\n const fetchData = async () => {\n let cubeDimensions = undefined;\n // `cube` may be the bare cube object or a ref-like { current: ... }.\n // Either shape can also arrive partially populated while data is in\n // flight, so guard before touching `.cubeDimensions` to avoid the\n // Suspense boundary surfacing a TypeError to the user.\n const cubeData = cube?.current ?? cube;\n if (cubeData?.cubeDimensions) {\n cubeDimensions = cubeData.cubeDimensions.map(cubeDim => {\n return {\n value: cubeDim.name,\n label:\n labelize(cubeDim.attribute) +\n formatRoleSuffix(cubeDim.role) +\n (cubeDim.properties?.includes('primary_key') ? ' (PK)' : ''),\n };\n });\n setDefaultDimensions(cubeDimensions);\n onChange(cubeDimensions.map(m => m.value));\n }\n\n if (metrics && metrics.length > 0) {\n // Populate the common dimensions list based on the selected metrics\n const commonDimensions = await djClient.commonDimensions(metrics);\n\n // First group by node_name + path (original grouping)\n const groupedByNodePath = commonDimensions.reduce(\n (group, dimension) => {\n const key = dimension.node_name + JSON.stringify(dimension.path);\n group[key] = group[key] ?? [];\n group[key].push(dimension);\n return group;\n },\n {},\n );\n\n // Then organize by hop distance\n const byHop = {};\n Object.values(groupedByNodePath).forEach(dimensionsInGroup => {\n const hopDistance = getHopDistance(dimensionsInGroup[0].path);\n byHop[hopDistance] = byHop[hopDistance] ?? [];\n byHop[hopDistance].push(dimensionsInGroup);\n });\n\n setDimensionsByHop(byHop);\n\n // Set the selected cube dimensions if an existing cube is being edited.\n // Only run when we actually have the cube's dimensions in hand — the\n // outer guard leaves `cubeDimensions` undefined when the cube data\n // hasn't loaded yet.\n if (cube && cubeDimensions) {\n const currentSelectedDimensionsByGroup = {};\n Object.values(groupedByNodePath).forEach(dimensionsInGroup => {\n const groupKey =\n dimensionsInGroup[0].node_name +\n JSON.stringify(dimensionsInGroup[0].path);\n currentSelectedDimensionsByGroup[groupKey] = getValue(\n cubeDimensions.filter(\n dim =>\n dimensionsInGroup.filter(x => dim.value === x.name).length >\n 0,\n ),\n );\n });\n setSelectedDimensionsByGroup(currentSelectedDimensionsByGroup);\n onChange(Object.values(currentSelectedDimensionsByGroup).flat(2));\n }\n } else {\n setDimensionsByHop({});\n }\n };\n fetchData().catch(console.error);\n }, [djClient, onChange, metrics, cube]);\n\n // Retrieves the selected values as a list (since it is a multi-select)\n const getValue = options => {\n if (options) {\n return options.map(option => option.value);\n } else {\n return [];\n }\n };\n\n // Sort hop distances numerically\n const sortedHops = Object.keys(dimensionsByHop)\n .map(Number)\n .sort((a, b) => a - b);\n\n // Custom styles to color-code dimension tags (matching Query Planner exactly)\n const dimensionStyles = {\n multiValue: base => ({\n ...base,\n backgroundColor: '#ffefd0',\n border: '1px solid rgba(169, 102, 33, 0.3)',\n borderRadius: '3px',\n margin: '2px',\n }),\n multiValueLabel: base => ({\n ...base,\n color: '#a96621',\n fontSize: '10px',\n fontWeight: 500,\n padding: '2px 4px 2px 6px',\n }),\n multiValueRemove: base => ({\n ...base,\n color: '#a96621',\n padding: '0 4px',\n ':hover': {\n backgroundColor: '#ffe4b3',\n color: '#a96621',\n },\n }),\n };\n\n if (sortedHops.length === 0) {\n return null;\n }\n\n return (\n <div>\n {sortedHops.map(hopDistance => {\n const groupsAtHop = dimensionsByHop[hopDistance];\n const dimensionCount = groupsAtHop.reduce(\n (sum, g) => sum + g.length,\n 0,\n );\n\n return (\n <div key={hopDistance} style={{ marginBottom: '20px' }}>\n {/* Hop distance header */}\n <div\n style={{\n display: 'flex',\n alignItems: 'center',\n padding: '6px 0',\n borderBottom: '1px solid #dee2e6',\n marginBottom: '8px',\n marginTop: hopDistance > 0 ? '24px' : '0',\n }}\n >\n <span\n style={{\n fontWeight: 600,\n color: '#212529',\n fontSize: '14px',\n }}\n >\n {getHopLabel(hopDistance)}\n </span>\n <span\n style={{\n marginLeft: '10px',\n color: '#6c757d',\n fontSize: '13px',\n }}\n >\n ({dimensionCount} dimension{dimensionCount !== 1 ? 's' : ''})\n </span>\n </div>\n\n {/* Dimension groups within this hop - indented */}\n <div style={{ paddingLeft: '6px' }}>\n {groupsAtHop.map(dimensionsInGroup => {\n const groupKey =\n dimensionsInGroup[0].node_name +\n JSON.stringify(dimensionsInGroup[0].path);\n\n // Convert path node names to display names\n const pathDisplayNames = dimensionsInGroup[0].path.map(\n nodeName => {\n const lastSegment = nodeName.split('.').pop();\n return labelize(lastSegment);\n },\n );\n\n const groupHeader = (\n <h5\n style={{\n fontWeight: 'normal',\n marginBottom: '5px',\n marginTop: '10px',\n fontSize: '13px',\n }}\n title={dimensionsInGroup[0].path.join(' → ')}\n >\n <a href={`/nodes/${dimensionsInGroup[0].node_name}`}>\n <b>{dimensionsInGroup[0].node_display_name}</b>\n </a>\n {pathDisplayNames.length > 0 && (\n <>\n {' '}\n <span style={{ color: '#6c757d' }}>via</span>{' '}\n {pathDisplayNames.map((displayName, idx) => (\n <span key={idx}>\n {idx > 0 && ' → '}\n <a\n href={`/nodes/${dimensionsInGroup[0].path[idx]}`}\n >\n {displayName}\n </a>\n </span>\n ))}\n </>\n )}\n </h5>\n );\n\n const dimensionGroupOptions = dimensionsInGroup.map(dim => {\n const [bareName, role] = splitRole(dim.name);\n return {\n value: dim.name,\n label:\n labelize(bareName.split('.').slice(-1)[0]) +\n formatRoleSuffix(role) +\n (dim.properties?.includes('primary_key') ? ' (PK)' : ''),\n };\n });\n\n const cubeDimensions = defaultDimensions.filter(\n dim =>\n dimensionGroupOptions.filter(x => dim.value === x.value)\n .length > 0,\n );\n\n return (\n <div key={groupKey}>\n {groupHeader}\n <span\n data-testid={\n 'dimensions-' + dimensionsInGroup[0].node_name\n }\n >\n <Select\n className=\"\"\n name={'dimensions-' + groupKey}\n defaultValue={cubeDimensions}\n options={dimensionGroupOptions}\n styles={dimensionStyles}\n isMulti\n isClearable\n closeMenuOnSelect={false}\n onChange={selected => {\n const newSelected = {\n ...selectedDimensionsByGroup,\n [groupKey]: getValue(selected),\n };\n setSelectedDimensionsByGroup(newSelected);\n onChange(Object.values(newSelected).flat(2));\n }}\n />\n </span>\n </div>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n );\n});\n","/**\n * Preview panel for cube builder showing selection summary and generated SQL.\n * Matches Query Planner styling exactly.\n */\nimport React, {\n useContext,\n useEffect,\n useMemo,\n useState,\n useCallback,\n} from 'react';\nimport { Light as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport sql from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';\nimport atomOneLight from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-light';\nimport DJClientContext from '../../providers/djclient';\nimport {\n formatBytes,\n formatScanEstimate,\n} from '../QueryPlannerPage/PreAggDetailsPanel';\n\nSyntaxHighlighter.registerLanguage('sql', sql);\n\nconst debounce = (fn, ms) => {\n let timer;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), ms);\n };\n};\n\nexport const CubePreviewPanel = React.memo(function CubePreviewPanel({\n metrics = [],\n dimensions = [],\n}) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [result, setResult] = useState(null);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState(null);\n\n // Fetch SQL when metrics/dimensions change\n const fetchSql = useCallback(\n debounce(async (m, d) => {\n if (m.length === 0 || d.length === 0) {\n setResult(null);\n return;\n }\n setLoading(true);\n setError(null);\n try {\n const res = await djClient.metricsV3(m, d, '');\n if (res.errors && res.errors.length > 0) {\n setError(\n res.errors\n .map(e =>\n typeof e === 'string' ? e : e.message || JSON.stringify(e),\n )\n .join(', '),\n );\n setResult(null);\n } else if (res.message) {\n setError(res.message);\n setResult(null);\n } else {\n setResult(res);\n }\n } catch (err) {\n setError(err.message || 'Failed to generate SQL');\n setResult(null);\n } finally {\n setLoading(false);\n }\n }, 500),\n [djClient],\n );\n\n useEffect(() => {\n fetchSql(metrics, dimensions);\n }, [metrics, dimensions, fetchSql]);\n\n // Get short name from full metric/dimension name\n const getShortName = fullName => {\n if (!fullName) return '';\n const parts = fullName.split('.');\n return parts[parts.length - 1];\n };\n\n const scanInfo = formatScanEstimate(result?.scan_estimate);\n\n // SyntaxHighlighter is the heaviest piece of DOM in the form — re-rendering\n // it on every keystroke makes typing in unrelated fields feel laggy. Memo\n // by `result?.sql` so the highlighted output is reused as long as the SQL\n // hasn't changed.\n const highlightedSql = useMemo(() => {\n if (!result?.sql) return null;\n return (\n <SyntaxHighlighter\n language=\"sql\"\n style={atomOneLight}\n customStyle={{\n margin: 0,\n padding: 0,\n fontSize: '11px',\n background: 'transparent',\n border: 'none',\n }}\n >\n {result.sql}\n </SyntaxHighlighter>\n );\n }, [result?.sql]);\n\n return (\n <div className=\"cube-preview-panel\">\n <div className=\"preview-section-header\">\n <span className=\"preview-section-icon\">⌘</span>\n <span className=\"preview-section-title\">Generated SQL</span>\n </div>\n\n {/* Scan Cost Banner */}\n {scanInfo && (\n <div className={`scan-estimate-banner scan-estimate-${scanInfo.level}`}>\n <span className=\"scan-estimate-icon\">{scanInfo.icon}</span>\n <div className=\"scan-estimate-content\">\n <div className=\"scan-estimate-header\">\n <strong>Scan Cost:</strong>{' '}\n {scanInfo.totalBytes !== null && scanInfo.totalBytes !== undefined\n ? (scanInfo.hasMissingData ? '≥ ' : '') +\n formatBytes(scanInfo.totalBytes)\n : 'Unknown'}\n </div>\n <div className=\"scan-estimate-sources\">\n {scanInfo.sources.map((source, idx) => {\n let displayName = source.source_name;\n if (source.schema_ && source.table) {\n displayName = `${source.schema_}.${source.table}`;\n } else if (source.table) {\n displayName = source.table;\n }\n return (\n <div key={idx} className=\"scan-source-item\">\n <span\n className=\"scan-source-name\"\n title={source.source_name}\n >\n {displayName}\n </span>\n <span className=\"scan-source-size\">\n {source.total_bytes !== null &&\n source.total_bytes !== undefined\n ? formatBytes(source.total_bytes)\n : 'no size data'}\n </span>\n </div>\n );\n })}\n </div>\n </div>\n </div>\n )}\n\n <div className=\"preview-sql-container\">\n {loading && <div className=\"preview-loading\">Generating SQL...</div>}\n {error && <div className=\"preview-error\">{error}</div>}\n {!loading && !error && !result?.sql && (\n <div className=\"preview-empty\">\n Select metrics and dimensions to preview SQL\n </div>\n )}\n {!loading && !error && highlightedSql}\n </div>\n </div>\n );\n});\n","import React, { useCallback, useContext, useEffect, useState } from 'react';\nimport Select from 'react-select';\nimport NamespaceHeader from '../../components/NamespaceHeader';\nimport { DataJunctionAPI } from '../../services/DJService';\nimport DJClientContext from '../../providers/djclient';\nimport { useCurrentUser } from '../../providers/UserProvider';\nimport 'react-querybuilder/dist/query-builder.scss';\nimport 'styles/styles.scss';\nimport './styles.css';\nimport '../QueryPlannerPage/styles.css';\nimport { ErrorMessage, FastField, Field, Form, Formik, useField } from 'formik';\nimport { displayMessageAfterSubmit } from '../../../utils/form';\nimport { useParams, useNavigate } from 'react-router-dom';\nimport { Action } from '../../components/forms/Action';\nimport NodeNameField from '../../components/forms/NodeNameField';\nimport { MetricsSelect } from './MetricsSelect';\nimport { DimensionsSelect } from './DimensionsSelect';\nimport { CubePreviewPanel } from './CubePreviewPanel';\n\n// Description textarea using FastField so typing here doesn't trigger\n// re-renders of unrelated form sections (heavy ones like the SQL preview).\nconst DescriptionField = () => (\n <FastField\n as=\"textarea\"\n id=\"Description\"\n name=\"description\"\n placeholder=\"Describe your cube\"\n />\n);\n\n// Simple Tags select matching MetricsSelect styling\nconst CubeTagsSelect = ({ defaultValue }) => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [field, , helpers] = useField('tags');\n const [options, setOptions] = useState([]);\n const [selected, setSelected] = useState(defaultValue || []);\n\n useEffect(() => {\n djClient.listTags().then(tags => {\n setOptions(tags.map(t => ({ value: t.name, label: t.display_name })));\n });\n }, [djClient]);\n\n useEffect(() => {\n if (defaultValue) setSelected(defaultValue);\n }, [defaultValue]);\n\n const handleChange = sel => {\n setSelected(sel || []);\n helpers.setValue((sel || []).map(s => s.value));\n };\n\n return (\n <Select\n value={selected}\n options={options}\n onChange={handleChange}\n isMulti\n placeholder=\"Select tags...\"\n />\n );\n};\n\n// Simple Owners select matching MetricsSelect styling\nconst CubeOwnersSelect = ({ defaultValue, isEdit = false }) => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const { currentUser } = useCurrentUser();\n const [field, , helpers] = useField('owners');\n const [options, setOptions] = useState([]);\n const [selected, setSelected] = useState(defaultValue || []);\n\n useEffect(() => {\n djClient.users().then(users => {\n setOptions(users.map(u => ({ value: u.username, label: u.username })));\n });\n }, [djClient]);\n\n // Only auto-default to current user in Add mode; in Edit mode wait for defaultValue\n useEffect(() => {\n if (defaultValue) {\n setSelected(defaultValue);\n helpers.setValue(defaultValue.map(d => d.value));\n } else if (!isEdit && currentUser) {\n const def = [\n { value: currentUser.username, label: currentUser.username },\n ];\n setSelected(def);\n helpers.setValue([currentUser.username]);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [defaultValue, currentUser, isEdit]);\n\n const handleChange = sel => {\n setSelected(sel || []);\n helpers.setValue((sel || []).map(s => s.value));\n };\n\n return (\n <Select\n value={selected}\n options={options}\n onChange={handleChange}\n isMulti\n placeholder=\"Select owners...\"\n />\n );\n};\n\nexport function CubeBuilderPage() {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const navigate = useNavigate();\n\n let { nodeType, initialNamespace, name } = useParams();\n const action = name !== undefined ? Action.Edit : Action.Add;\n const validator = ruleType => !!ruleType.value;\n\n const initialValues = {\n name: action === Action.Edit ? name : '',\n namespace: action === Action.Add ? initialNamespace : '',\n display_name: '',\n description: '',\n mode: 'published',\n metrics: [],\n dimensions: [],\n filters: [],\n tags: [],\n owners: [],\n };\n\n const handleSubmit = (values, { setSubmitting, setStatus }) => {\n if (action === Action.Add) {\n setTimeout(() => {\n createNode(values, setStatus);\n setSubmitting(false);\n }, 400);\n } else {\n setTimeout(() => {\n patchNode(values, setStatus);\n setSubmitting(false);\n }, 400);\n }\n window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });\n };\n\n const createNode = async (values, setStatus) => {\n const { status, json } = await djClient.createCube(\n values.name,\n values.display_name,\n values.description,\n values.mode,\n values.metrics,\n values.dimensions,\n values.filters || [],\n );\n if (status === 200 || status === 201) {\n if (values.tags) {\n await djClient.tagsNode(values.name, values.tags);\n }\n setStatus({\n success: true,\n savedName: json.name,\n });\n } else {\n setStatus({\n failure: `${json.message}`,\n });\n }\n };\n\n const patchNode = async (values, setStatus) => {\n const { status, json } = await djClient.patchCube(\n values.name,\n values.display_name,\n values.description,\n values.mode,\n values.metrics,\n values.dimensions,\n values.filters || [],\n values.owners,\n );\n const tagsResponse = await djClient.tagsNode(\n values.name,\n (values.tags || []).map(tag => tag),\n );\n if ((status === 200 || status === 201) && tagsResponse.status === 200) {\n setStatus({\n success: true,\n savedName: json.name,\n });\n } else {\n setStatus({\n failure: `${json.message}`,\n });\n }\n };\n\n const updateFieldsWithNodeData = (\n data,\n setFieldValue,\n setSelectTags,\n setSelectOwners,\n ) => {\n setFieldValue('display_name', data.current.displayName || '', false);\n setFieldValue('description', data.current.description || '', false);\n setFieldValue('mode', data.current.mode.toLowerCase() || 'draft', false);\n setFieldValue(\n 'tags',\n data.tags.map(tag => tag.name),\n );\n // Store default values as arrays for the Select components\n setSelectTags(\n data.tags.map(t => ({ value: t.name, label: t.displayName })),\n );\n if (data.owners) {\n setSelectOwners(\n data.owners.map(owner => ({\n value: owner.username,\n label: owner.username,\n })),\n );\n }\n };\n\n // @ts-ignore\n return (\n <>\n <div className=\"mid\">\n <NamespaceHeader\n namespace={\n initialNamespace\n ? initialNamespace\n : name\n ? name.substring(0, name.lastIndexOf('.'))\n : ''\n }\n />\n <Formik\n initialValues={initialValues}\n validate={validator}\n onSubmit={handleSubmit}\n >\n {function Render({\n isSubmitting,\n status,\n setFieldValue,\n values,\n props,\n }) {\n const [node, setNode] = useState([]);\n const [selectTags, setSelectTags] = useState(null);\n const [selectOwners, setSelectOwners] = useState(null);\n const [justSaved, setJustSaved] = useState(false);\n const [saveError, setSaveError] = useState(null);\n\n // Get cube\n useEffect(() => {\n const fetchData = async () => {\n if (name) {\n const cube = await djClient.getCubeForEditing(name);\n setNode(cube);\n updateFieldsWithNodeData(\n cube,\n setFieldValue,\n setSelectTags,\n setSelectOwners,\n );\n }\n };\n fetchData().catch(console.error);\n }, [setFieldValue]);\n\n // Stable callbacks so the memoized child components don't re-render\n // on every Formik state change (e.g. typing in display_name).\n const setMetrics = useCallback(\n v => setFieldValue('metrics', v),\n [setFieldValue],\n );\n const setDimensions = useCallback(\n v => setFieldValue('dimensions', v),\n [setFieldValue],\n );\n\n // Briefly show \"Saved\" state on the button, then redirect to the cube page\n useEffect(() => {\n if (status?.success) {\n setJustSaved(true);\n setSaveError(null);\n const savedName = status.savedName;\n const timer = setTimeout(() => {\n if (savedName) {\n navigate(`/nodes/${savedName}`);\n }\n }, 1200);\n return () => clearTimeout(timer);\n }\n if (status?.failure) {\n setSaveError(status.failure);\n setJustSaved(false);\n }\n }, [status, navigate]);\n\n return (\n <Form className=\"cube-builder\">\n {/* Header */}\n <div className=\"cube-builder-header\">\n <h2>\n {action === Action.Edit ? 'Edit' : 'Create'}{' '}\n <span className=\"node_type__cube node_type_creation_heading\">\n Cube\n </span>\n </h2>\n </div>\n\n {/* Two-column layout */}\n <div className=\"cube-builder-layout\">\n {/* Left: Main form */}\n <div className=\"cube-builder-main\">\n {/* Details Section */}\n <div className=\"cube-form-section\">\n <div className=\"cube-form-section-body\">\n {action === Action.Add ? (\n <>\n {/* Add mode uses NodeNameField for namespace/name */}\n <NodeNameField />\n\n {/* Row: Description | Mode */}\n <div className=\"cube-field-row\">\n <div className=\"cube-field cube-field-grow\">\n <ErrorMessage\n name=\"description\"\n component=\"span\"\n />\n <label\n className=\"cube-field-label\"\n htmlFor=\"Description\"\n >\n Description\n </label>\n <DescriptionField />\n </div>\n <div className=\"cube-field cube-field-small\">\n <label\n className=\"cube-field-label\"\n htmlFor=\"Mode\"\n >\n Mode\n </label>\n <Field as=\"select\" name=\"mode\" id=\"Mode\">\n <option value=\"draft\">Draft</option>\n <option value=\"published\">Published</option>\n </Field>\n </div>\n </div>\n\n {/* Row: Tags | Owners */}\n <div className=\"cube-field-row\">\n <div className=\"cube-field cube-field-half\">\n <label className=\"cube-field-label\">Tags</label>\n <CubeTagsSelect />\n </div>\n <div className=\"cube-field cube-field-half\">\n <label className=\"cube-field-label\">\n Owners\n </label>\n <CubeOwnersSelect />\n </div>\n </div>\n </>\n ) : (\n <>\n {/* Row 1: Name (full width) */}\n <div className=\"cube-field\">\n <label className=\"cube-field-label\">Name</label>\n <div className=\"cube-field-static\">{name}</div>\n </div>\n\n {/* Row 2: Display Name | Mode */}\n <div className=\"cube-field-row\">\n <div className=\"cube-field cube-field-grow\">\n <ErrorMessage\n name=\"display_name\"\n component=\"span\"\n />\n <label\n className=\"cube-field-label\"\n htmlFor=\"displayName\"\n >\n Display Name\n </label